diff --git a/.eslintrc.yml b/.eslintrc.yml index 03051380c5d..d339201eff3 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -15,6 +15,7 @@ extends: - prettier/react - plugin:@typescript-eslint/recommended - prettier/@typescript-eslint + - plugin:github/react rules: ########## @@ -29,6 +30,7 @@ rules: ########### # PLUGINS # ########### + # TYPESCRIPT '@typescript-eslint/naming-convention': - error @@ -111,6 +113,7 @@ rules: react/jsx-uses-vars: error react/jsx-uses-react: error react/no-unused-state: error + # niik 2023-12-05: turning this off to not muddy up the TS5 upgrade. react/no-unused-prop-types: error react/prop-types: - error @@ -121,7 +124,10 @@ rules: jsdoc/check-tag-names: error jsdoc/check-types: error jsdoc/implements-on-classes: error - jsdoc/newline-after-description: error + jsdoc/tag-lines: + - error + - any + - startLines: 1 jsdoc/no-undefined-types: error jsdoc/valid-types: error @@ -184,6 +190,20 @@ rules: - selector: ExportDefaultDeclaration message: Use of default exports is forbidden + ########### + # jsx-a11y # + ########### + + # autofocus is fine when it is being used to set focus to something in a way + # that doesn't skip any context or inputs. For example, in a named dialog, if you had + # a close button, a heading reflecting the name, and a labelled text input, + # focusing the text input wouldn't disadvantage a user because they're not + # missing anything of importance before it. The problem is when it is used on + # larger web pages, e.g. to focus a form field in the main content, entirely + # skipping the header and often much else besides. + jsx-a11y/no-autofocus: + - off + overrides: - files: '*.d.ts' rules: @@ -196,6 +216,9 @@ overrides: - files: 'script/**/*' rules: '@typescript-eslint/no-non-null-assertion': off + - files: 'app/src/ui/octicons/octicons.generated.ts' + rules: + '@typescript-eslint/naming-convention': off parserOptions: sourceType: module diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d10ac1b2c53..12b8b11eae9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -66,7 +66,7 @@ comment to the existing issue if there is extra information you can contribute. Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/). -Simply create an issue on the [GitHub Desktop issue tracker](https://github.com/desktop/desktop/issues/new?template=bug_report.md) +Simply create an issue on the [GitHub Desktop issue tracker](https://github.com/desktop/desktop/issues/new?template=bug_report.yaml) and fill out the provided issue template. The information we are interested in includes: @@ -87,7 +87,7 @@ community understand your suggestion :pencil: and find related suggestions Before creating enhancement suggestions, please check [this list](#before-submitting-an-enhancement-suggestion) as you might find out that you don't need to create one. When you are creating an enhancement suggestion, please [include as many details as possible](#how-do-i-submit-a-good-enhancement-suggestion). -Fill in [the template](ISSUE_TEMPLATE/problem-to-raise.md), including the steps +Fill in [the template](ISSUE_TEMPLATE/feature_request.yaml), including the steps that you imagine you would take if the feature you're requesting existed. #### Before Submitting An Enhancement Suggestion @@ -101,7 +101,7 @@ information you would like to add. Enhancement suggestions are tracked as [GitHub issues](https://guides.github.com/features/issues/). -Simply create an issue on the [GitHub Desktop issue tracker](https://github.com/desktop/desktop/issues/new?template=feature_request.md) +Simply create an issue on the [GitHub Desktop issue tracker](https://github.com/desktop/desktop/issues/new?template=feature_request.yaml) and fill out the provided issue template. Some additional advice: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..e22a9bd70ab --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + # Disable version updates and keep only security updates + open-pull-requests-limit: 0 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f99b109d580..c5fcc02d167 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,6 @@ Closes #[issue number] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40e7a8537d0..55addcedfd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,68 +4,102 @@ on: push: branches: - development - - __release-* pull_request: + workflow_call: + inputs: + repository: + default: desktop/desktop + required: false + type: string + ref: + required: true + type: string + upload-artifacts: + default: false + required: false + type: boolean + environment: + type: string + required: true + sign: + type: boolean + default: true + required: false + secrets: + AZURE_CODE_SIGNING_TENANT_ID: + AZURE_CODE_SIGNING_CLIENT_ID: + AZURE_CODE_SIGNING_CLIENT_SECRET: + DESKTOP_OAUTH_CLIENT_ID: + DESKTOP_OAUTH_CLIENT_SECRET: + APPLE_ID: + APPLE_ID_PASSWORD: + APPLE_TEAM_ID: + APPLE_APPLICATION_CERT: + APPLE_APPLICATION_CERT_PASSWORD: + +env: + NODE_VERSION: 20.17.0 jobs: + lint: + name: Lint + runs-on: ubuntu-latest + env: + RELEASE_CHANNEL: ${{ inputs.environment }} + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ inputs.repository || github.repository }} + ref: ${{ inputs.ref }} + submodules: recursive + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: yarn + - run: yarn + - run: yarn validate-electron-version + - run: yarn lint + - run: yarn validate-changelog + - name: Ensure a clean working directory + run: git diff --name-status --exit-code build: name: ${{ matrix.friendlyName }} ${{ matrix.arch }} runs-on: ${{ matrix.os }} - permissions: read-all + permissions: + contents: read strategy: fail-fast: false matrix: - node: [16.13.0] - os: [macos-10.15, windows-2019] + os: [macos-13-xl-arm64, windows-2019] arch: [x64, arm64] include: - - os: macos-10.15 + - os: macos-13-xl-arm64 friendlyName: macOS - os: windows-2019 friendlyName: Windows timeout-minutes: 60 + environment: ${{ inputs.environment }} env: - # Needed for macOS arm64 until hosted macos-11.0 runners become available - SDKROOT: /Library/Developer/CommandLineTools/SDKs/MacOSX11.1.sdk + RELEASE_CHANNEL: ${{ inputs.environment }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: + repository: ${{ inputs.repository || github.repository }} + ref: ${{ inputs.ref }} submodules: recursive - - name: Use Node.js ${{ matrix.node }} - uses: actions/setup-node@v1 + - uses: actions/setup-python@v5 with: - node-version: ${{ matrix.node }} - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - uses: actions/cache@v2 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + python-version: '3.11' + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - ${{ runner.os }}-yarn- - - # This step can be removed as soon as official Windows arm64 builds are published: - # https://github.com/nodejs/build/issues/2450#issuecomment-705853342 - - name: Get NodeJS node-gyp lib for Windows arm64 - if: ${{ matrix.os == 'windows-2019' && matrix.arch == 'arm64' }} - run: .\script\download-nodejs-win-arm64.ps1 ${{ matrix.node }} - + node-version: ${{ env.NODE_VERSION }} + cache: yarn - name: Install and build dependencies run: yarn env: npm_config_arch: ${{ matrix.arch }} TARGET_ARCH: ${{ matrix.arch }} - - name: Lint - run: yarn lint - - name: Validate changelog - run: yarn validate-changelog - - name: Ensure a clean working directory - run: git diff --name-status --exit-code - name: Build production app run: yarn build:prod env: @@ -74,28 +108,47 @@ jobs: ${{ secrets.DESKTOP_OAUTH_CLIENT_SECRET }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - DESKTOPBOT_TOKEN: ${{ secrets.DESKTOPBOT_TOKEN }} - KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APPLICATION_CERT: ${{ secrets.APPLE_APPLICATION_CERT }} + KEY_PASSWORD: ${{ secrets.APPLE_APPLICATION_CERT_PASSWORD }} npm_config_arch: ${{ matrix.arch }} TARGET_ARCH: ${{ matrix.arch }} - name: Prepare testing environment if: matrix.arch == 'x64' run: yarn test:setup + env: + npm_config_arch: ${{ matrix.arch }} - name: Run unit tests if: matrix.arch == 'x64' run: yarn test:unit - name: Run script tests if: matrix.arch == 'x64' run: yarn test:script - - name: Publish production app - run: yarn run publish + - name: Install Azure Code Signing Client + if: ${{ runner.os == 'Windows' && inputs.sign }} + run: | + $acsZip = Join-Path $env:RUNNER_TEMP "acs.zip" + $acsDir = Join-Path $env:RUNNER_TEMP "acs" + Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/1.0.52 -OutFile $acsZip -Verbose + Expand-Archive $acsZip -Destination $acsDir -Force -Verbose + # Replace ancient signtool in electron-winstall with one that supports ACS + Copy-Item -Path "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\*" -Include signtool.exe,signtool.exe.manifest,Microsoft.Windows.Build.Signing.mssign32.dll.manifest,mssign32.dll,Microsoft.Windows.Build.Signing.wintrust.dll.manifest,wintrust.dll,Microsoft.Windows.Build.Appx.AppxSip.dll.manifest,AppxSip.dll,Microsoft.Windows.Build.Appx.AppxPackaging.dll.manifest,AppxPackaging.dll,Microsoft.Windows.Build.Appx.OpcServices.dll.manifest,OpcServices.dll -Destination "node_modules\electron-winstaller\vendor" -Verbose + - name: Package production app + run: yarn package env: npm_config_arch: ${{ matrix.arch }} - DESKTOPBOT_TOKEN: ${{ secrets.DESKTOPBOT_TOKEN }} - WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} - KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - DEPLOYMENT_SECRET: ${{ secrets.DEPLOYMENT_SECRET }} - AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }} - AZURE_STORAGE_ACCESS_KEY: ${{ secrets.AZURE_STORAGE_ACCESS_KEY }} - AZURE_BLOB_CONTAINER: ${{ secrets.AZURE_BLOB_CONTAINER }} - AZURE_STORAGE_URL: ${{ secrets.AZURE_STORAGE_URL }} + AZURE_TENANT_ID: ${{ secrets.AZURE_CODE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CODE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CODE_SIGNING_CLIENT_SECRET }} + - name: Upload artifacts + uses: actions/upload-artifact@v4 + if: ${{ inputs.upload-artifacts }} + with: + name: ${{matrix.friendlyName}}-${{matrix.arch}} + path: | + dist/GitHub Desktop-${{matrix.arch}}.zip + dist/GitHubDesktop-*.nupkg + dist/GitHubDesktopSetup-${{matrix.arch}}.exe + dist/GitHubDesktopSetup-${{matrix.arch}}.msi + dist/bundle-size.json + if-no-files-found: error diff --git a/.github/workflows/close-invalid.yml b/.github/workflows/close-invalid.yml new file mode 100644 index 00000000000..c1fc8564a59 --- /dev/null +++ b/.github/workflows/close-invalid.yml @@ -0,0 +1,36 @@ +name: Close issue/PR on adding invalid label + +# **What it does**: This action closes issues and PRs that are labeled as invalid in the Desktop repo. + +on: + issues: + types: [labeled] + # Needed in lieu of `pull_request` so that PRs from a fork can be + # closed when marked as invalid. + pull_request_target: + types: [labeled] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + close-on-adding-invalid-label: + if: + github.repository == 'desktop/desktop' && github.event.label.name == + 'invalid' + runs-on: ubuntu-latest + + steps: + - name: Close issue + if: ${{ github.event_name == 'issues' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh issue close ${{ github.event.issue.html_url }} + + - name: Close PR + if: ${{ github.event_name == 'pull_request_target' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr close ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/close-single-word-issues.yml b/.github/workflows/close-single-word-issues.yml new file mode 100644 index 00000000000..f2ef0dae8e3 --- /dev/null +++ b/.github/workflows/close-single-word-issues.yml @@ -0,0 +1,44 @@ +name: Close Single-Word Issues + +on: + issues: + types: + - opened + +permissions: + issues: write + +jobs: + close-issue: + runs-on: ubuntu-latest + + steps: + - name: Close Single-Word Issue + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const issueTitle = context.payload.issue.title.trim(); + const isSingleWord = /^\S+$/.test(issueTitle); + + if (isSingleWord) { + const issueNumber = context.payload.issue.number; + const repo = context.repo.repo; + + // Close the issue and add the invalid label + github.rest.issues.update({ + owner: context.repo.owner, + repo: repo, + issue_number: issueNumber, + labels: ['invalid'], + state: 'closed' + }); + + // Comment on the issue + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: repo, + issue_number: issueNumber, + body: `This issue may have been opened accidentally. I'm going to close it now, but feel free to open a new issue with a more descriptive title.` + }); + } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4d8d7d8f23a..6b78b39f3f4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,11 +18,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: config-file: ./.github/codeql/codeql-config.yml @@ -32,7 +32,7 @@ jobs: # 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@v1 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -46,4 +46,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/feature-request-comment.yml b/.github/workflows/feature-request-comment.yml new file mode 100644 index 00000000000..9c65d4cc9c8 --- /dev/null +++ b/.github/workflows/feature-request-comment.yml @@ -0,0 +1,36 @@ +name: Add feature-request comment +on: + issues: + types: + - labeled + +permissions: + issues: write + +jobs: + add-comment-to-feature-request-issues: + if: github.event.label.name == 'feature-request' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + BODY: > + Thank you for your issue! We have categorized it as a feature request, + and it has been added to our backlog. In doing so, **we are not + committing to implementing this feature at this time**, but, we will + consider it for future releases based on community feedback and our own + product roadmap. + + + Unless you see the + https://github.com/desktop/desktop/labels/help%20wanted label, we are + not currently looking for external contributions for this feature. + + + **If you come across this issue and would like to see it implemented, + please add a thumbs up!** This will help us prioritize the feature. + Please only comment if you have additional information or viewpoints to + contribute. + steps: + - run: gh issue comment "$NUMBER" --body "$BODY" diff --git a/.github/workflows/on-issue-close.yml b/.github/workflows/on-issue-close.yml new file mode 100644 index 00000000000..e768226d088 --- /dev/null +++ b/.github/workflows/on-issue-close.yml @@ -0,0 +1,17 @@ +name: Remove triage tab from closed issues +on: + issues: + types: + - closed +jobs: + label_issues: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - run: gh issue edit "$NUMBER" --remove-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: triage diff --git a/.github/workflows/pr-is-external.yml b/.github/workflows/pr-is-external.yml new file mode 100644 index 00000000000..37a20941951 --- /dev/null +++ b/.github/workflows/pr-is-external.yml @@ -0,0 +1,22 @@ +name: PR external +on: + pull_request_target: + types: + - reopened + - opened + +jobs: + label_issues: + # pull_request.head.label = {owner}:{branch} + if: startsWith(github.event.pull_request.head.label, 'desktop:') == false + runs-on: ubuntu-latest + permissions: + pull-requests: write + repository-projects: read + steps: + - run: gh pr edit "$NUMBER" --add-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number }} + LABELS: external diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 00000000000..f03f1f074f3 --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,49 @@ +name: 'Create Release Pull Request' + +on: create + +jobs: + build: + name: Create Release Pull Request + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v4 + if: | + startsWith(github.ref, 'refs/heads/releases/') && !contains(github.ref, 'test') + + - name: Create Pull Request content + if: | + startsWith(github.ref, 'refs/heads/releases/') && !contains(github.ref, 'test') + run: | + PR_TITLE=`./script/draft-release/release-pr-content.sh title ${GITHUB_REF#refs/heads/}` + PR_BODY=`./script/draft-release/release-pr-content.sh body ${GITHUB_REF#refs/heads/}` + + echo "PR_BODY<> $GITHUB_ENV + echo "$PR_BODY" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + echo "PR_TITLE<> $GITHUB_ENV + echo "$PR_TITLE" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - uses: tibdex/github-app-token@v2 + id: generate-token + if: | + startsWith(github.ref, 'refs/heads/releases/') && !contains(github.ref, 'test') + with: + app_id: ${{ secrets.DESKTOP_RELEASES_APP_ID }} + private_key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }} + + - name: Create Release Pull Request + uses: peter-evans/create-pull-request@v6.0.5 + if: | + startsWith(github.ref, 'refs/heads/releases/') && !contains(github.ref, 'test') + with: + token: ${{ steps.generate-token.outputs.token }} + title: ${{ env.PR_TITLE }} + body: ${{ env.PR_BODY }} + branch: ${{ github.ref }} + base: development + draft: true diff --git a/.github/workflows/remove-triage-label.yml b/.github/workflows/remove-triage-label.yml new file mode 100644 index 00000000000..7eac3f2f8ed --- /dev/null +++ b/.github/workflows/remove-triage-label.yml @@ -0,0 +1,20 @@ +name: Remove triage label +on: + issues: + types: + - labeled + +permissions: + issues: write + +jobs: + remove-triage-label-from-issues: + if: github.event.label.name != 'triage' + runs-on: ubuntu-latest + steps: + - run: gh issue edit "$NUMBER" --remove-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: triage diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml new file mode 100644 index 00000000000..9c8ebbd91a5 --- /dev/null +++ b/.github/workflows/stale-issues.yml @@ -0,0 +1,20 @@ +name: 'Marks stale issues and PRs' +on: + schedule: + - cron: '30 1 * * *' # 1:30 AM UTC + +permissions: + issues: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + stale-issue-label: 'stale, triage' # The label that will be added to the issues when automatically marked as stale + start-date: '2024-11-25T00:00:00Z' # Skip stale action for issues/PRs created before it + days-before-stale: 365 + days-before-close: -1 # If -1, the issues nor pull requests will never be closed automatically. + days-before-pr-stale: -1 # If -1, no pull requests will be marked as stale automatically. + exempt-issue-labels: 'never-stale, help wanted, ' # issues labeled as such will be excluded them from being marked as stale diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml new file mode 100644 index 00000000000..f73bb297a68 --- /dev/null +++ b/.github/workflows/triage-issues.yml @@ -0,0 +1,34 @@ +name: Label incoming issues +on: + issues: + types: + - reopened + - opened + - unlabeled + +permissions: + issues: write + +jobs: + label_incoming_issues: + runs-on: ubuntu-latest + if: github.event.action == 'opened' || github.event.action == 'reopened' + steps: + - run: gh issue edit "$NUMBER" --add-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: triage + label_more_info_issues: + if: + github.event.action == 'unlabeled' && github.event.label.name == + 'more-info-needed' + runs-on: ubuntu-latest + steps: + - run: gh issue edit "$NUMBER" --add-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: triage diff --git a/.github/workflows/unable-to-reproduce-comment.yml b/.github/workflows/unable-to-reproduce-comment.yml new file mode 100644 index 00000000000..9c13e43ee4c --- /dev/null +++ b/.github/workflows/unable-to-reproduce-comment.yml @@ -0,0 +1,41 @@ +name: Add unable-to-reproduce comment +on: + issues: + types: + - labeled + +permissions: + issues: write + +jobs: + add-comment-to-unable-to-reproduce-issues: + if: github.event.label.name == 'unable-to-reproduce' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: more-info-needed + BODY: > + Thank you for your issue! Unfortunately, we are unable to reproduce the + issue you are experiencing. Please provide more information so we can + help you. + + + Here are some tips for writing reproduction steps: + - Step by step instructions accompanied by screenshots or screencasts + are the best. + - Be as specific as possible; include as much detail as you can. + - If not already provided, include: + - the version of GitHub Desktop you are using. + - the operating system you are using + - any environment factors you can think of. + - any custom configuration you are using. + - a log file from the day you experienced the issue (access log + files via the file menu and select `Help` > `Show Logs in + Finder/Explorer`. + - If relevant and can be shared, provide the repository or code you + are using. + steps: + - run: gh issue edit "$NUMBER" --add-label "$LABELS" + - run: gh issue comment "$NUMBER" --body "$BODY" diff --git a/.gitignore b/.gitignore index 30a0037ef0b..91bdd56682f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ coverage/ npm-debug.log yarn-error.log app/node_modules/ +vendor/windows-argv-parser/build/ .DS_Store .awcache .idea/ diff --git a/.markdownlint.js b/.markdownlint.js new file mode 100644 index 00000000000..eb043f42c40 --- /dev/null +++ b/.markdownlint.js @@ -0,0 +1,2 @@ +const markdownlintGitHub = require('@github/markdownlint-github') +module.exports = markdownlintGitHub.init() diff --git a/.node-version b/.node-version index 58a4133d910..3516580bbbc 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -16.13.0 +20.17.0 diff --git a/.nvmrc b/.nvmrc index ff650592a1e..016e34baf16 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.13.0 +v20.17.0 diff --git a/.tool-versions b/.tool-versions index 866f9e82545..5f93b9a8287 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ python 3.9.5 -nodejs 16.13.0 +nodejs 20.17.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index d527f4e15c6..9c1caea584f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,7 +27,7 @@ "editor.formatOnSave": true, "prettier.ignorePath": ".prettierignore", "eslint.options": { - "configFile": ".eslintrc.yml", + "overrideConfigFile": ".eslintrc.yml", "rulePaths": ["eslint-rules"] }, "eslint.validate": [ @@ -35,5 +35,6 @@ "javascriptreact", "typescript", "typescriptreact" - ] + ], + "editor.defaultFormatter": "esbenp.prettier-vscode" } diff --git a/README.md b/README.md index a5038a7fdbf..27a7e9d105f 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,62 @@ # [GitHub Desktop](https://desktop.github.com) -[GitHub Desktop](https://desktop.github.com/) is an open source [Electron](https://www.electronjs.org/)-based -GitHub app. It is written in [TypeScript](http://www.typescriptlang.org) and +[GitHub Desktop](https://desktop.github.com/) is an open-source [Electron](https://www.electronjs.org/)-based +GitHub app. It is written in [TypeScript](https://www.typescriptlang.org) and uses [React](https://reactjs.org/). -![GitHub Desktop screenshot - Windows](https://cloud.githubusercontent.com/assets/359239/26094502/a1f56d02-3a5d-11e7-8799-23c7ba5e5106.png) + + + A screenshot of the GitHub Desktop application showing changes being viewed and committed with two attributed co-authors + ## Where can I get it? Download the official installer for your operating system: - [macOS](https://central.github.com/deployments/desktop/desktop/latest/darwin) - - [macOS (Apple Silicon)](https://central.github.com/deployments/desktop/desktop/latest/darwin-arm64) + - [macOS (Apple silicon)](https://central.github.com/deployments/desktop/desktop/latest/darwin-arm64) - [Windows](https://central.github.com/deployments/desktop/desktop/latest/win32) - [Windows machine-wide install](https://central.github.com/deployments/desktop/desktop/latest/win32?format=msi) -You can install this alongside your existing GitHub Desktop for Mac or GitHub -Desktop for Windows application. - Linux is not officially supported; however, you can find installers created for Linux from a fork of GitHub Desktop in the [Community Releases](https://github.com/desktop/desktop#community-releases) section. -**NOTE**: There is no current migration path to import your existing -repositories into the new application - you can drag-and-drop your repositories -from disk onto the application to get started. - - ### Beta Channel Want to test out new features and get fixes before everyone else? Install the beta channel to get access to early builds of Desktop: - [macOS](https://central.github.com/deployments/desktop/desktop/latest/darwin?env=beta) - - [macOS (Apple Silicon)](https://central.github.com/deployments/desktop/desktop/latest/darwin-arm64?env=beta) + - [macOS (Apple silicon)](https://central.github.com/deployments/desktop/desktop/latest/darwin-arm64?env=beta) - [Windows](https://central.github.com/deployments/desktop/desktop/latest/win32?env=beta) - [Windows (ARM64)](https://central.github.com/deployments/desktop/desktop/latest/win32-arm64?env=beta) - + The release notes for the latest beta versions are available [here](https://desktop.github.com/release-notes/?env=beta). +### Past Releases +You can find past releases at https://desktop.githubusercontent.com. After installation of a past version, the auto update functionality will attempt to download the latest version. + ### Community Releases There are several community-supported package managers that can be used to install GitHub Desktop: - - Windows users can install using [Chocolatey](https://chocolatey.org/) package manager: - `c:\> choco install github-desktop` + - Windows users can install using [winget](https://docs.microsoft.com/en-us/windows/package-manager/winget/) `c:\> winget install github-desktop` or [Chocolatey](https://chocolatey.org/) `c:\> choco install github-desktop` - macOS users can install using [Homebrew](https://brew.sh/) package manager: `$ brew install --cask github` Installers for various Linux distributions can be found on the [`shiftkey/desktop`](https://github.com/shiftkey/desktop) fork. -Arch Linux users can install the latest version from the -[AUR](https://aur.archlinux.org/packages/github-desktop-bin/). - ## Is GitHub Desktop right for me? What are the primary areas of focus? [This document](https://github.com/desktop/desktop/blob/development/docs/process/what-is-desktop.md) describes the focus of GitHub Desktop and who the product is most useful for. -And to see what the team is working on currently and in the near future, check out the [GitHub Desktop roadmap](https://github.com/desktop/desktop/blob/development/docs/process/roadmap.md). - ## I have a problem with GitHub Desktop Note: The [GitHub Desktop Code of Conduct](https://github.com/desktop/desktop/blob/development/CODE_OF_CONDUCT.md) applies in all interactions relating to the GitHub Desktop project. @@ -85,13 +84,16 @@ resources relevant to the project. If you're looking for something to work on, check out the [help wanted](https://github.com/desktop/desktop/issues?q=is%3Aissue+is%3Aopen+label%3A%22help%20wanted%22) label. +## Building Desktop + +To setup your development environment for building Desktop, check out: [`setup.md`](./docs/contributing/setup.md). + ## More Resources See [desktop.github.com](https://desktop.github.com) for more product-oriented information about GitHub Desktop. - -See our [getting started documentation](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/overview/getting-started-with-github-desktop) for more information on how to set up, authenticate, and configure GitHub Desktop. +See our [getting started documentation](https://docs.github.com/en/desktop/overview/getting-started-with-github-desktop) for more information on how to set up, authenticate, and configure GitHub Desktop. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..d9ca9342c30 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +GitHub takes the security of our software products and services seriously, including the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). + +If you believe you have found a security vulnerability in this GitHub-owned open source repository, you can report it to us in one of two ways. + +If the vulnerability you have found is *not* [in scope for the GitHub Bug Bounty Program](https://bounty.github.com/#scope) or if you do not wish to be considered for a bounty reward, please report the issue to us directly using [private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability). + +If the vulnerability you have found is [in scope for the GitHub Bug Bounty Program](https://bounty.github.com/#scope) and you would like for your finding to be considered for a bounty reward, please submit the vulnerability to us through [HackerOne](https://hackerone.com/github) in order to be eligible to receive a bounty award. + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Thanks for helping make GitHub safe for everyone. diff --git a/app/.npmrc b/app/.npmrc index 504b4d19bae..94caaa92c37 100644 --- a/app/.npmrc +++ b/app/.npmrc @@ -1,3 +1,3 @@ runtime = electron disturl = https://electronjs.org/headers -target = 17.0.1 +target = 32.1.2 diff --git a/app/app-info.ts b/app/app-info.ts index 8c8fd1acbb3..b1317a989da 100644 --- a/app/app-info.ts +++ b/app/app-info.ts @@ -1,27 +1,12 @@ -import * as fs from 'fs' -import * as Path from 'path' - import { getSHA } from './git-info' import { getUpdatesURL, getChannel } from '../script/dist-info' import { version, productName } from './package.json' -const projectRoot = Path.dirname(__dirname) - const devClientId = '3a723b10ac5575cc5bb9' const devClientSecret = '22c34d87789a365981ed921352a7b9a8c3f69d54' const channel = getChannel() -export function getCLICommands() { - return ( - // eslint-disable-next-line no-sync - fs - .readdirSync(Path.resolve(projectRoot, 'app', 'src', 'cli', 'commands')) - .filter(name => name.endsWith('.ts')) - .map(name => name.replace(/\.ts$/, '')) - ) -} - const s = JSON.stringify export function getReplacements() { @@ -41,7 +26,6 @@ export function getReplacements() { __RELEASE_CHANNEL__: s(channel), __UPDATES_URL__: s(getUpdatesURL()), __SHA__: s(getSHA()), - __CLI_COMMANDS__: s(getCLICommands()), 'process.platform': s(process.platform), 'process.env.NODE_ENV': s(process.env.NODE_ENV || 'development'), 'process.env.TEST_ENV': s(process.env.TEST_ENV), diff --git a/app/jest.unit.config.js b/app/jest.unit.config.js index 13d8d2482d0..f7d69ec2c99 100644 --- a/app/jest.unit.config.js +++ b/app/jest.unit.config.js @@ -2,8 +2,9 @@ module.exports = { roots: ['/src/', '/test/'], transform: { '^.+\\.tsx?$': 'ts-jest', - '\\.m?jsx?$': 'jest-esm-transformer', + '\\.m?jsx?$': '/test/esm-transformer.js', }, + resolver: `/test/resolver.js`, testMatch: ['**/unit/**/*-test.ts{,x}'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], setupFiles: ['/test/globals.ts', '/test/unit-test-env.ts'], @@ -11,4 +12,5 @@ module.exports = { reporters: ['default', '../script/jest-actions-reporter.js'], // For now, @github Node modules required to be transformed by jest-esm-transformer transformIgnorePatterns: ['node_modules/(?!(@github))'], + testEnvironment: 'jsdom', } diff --git a/app/package.json b/app/package.json index 49f48512832..633c58374fb 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "productName": "GitHub Desktop", "bundleID": "com.github.GitHubClient", "companyName": "GitHub, Inc.", - "version": "3.0.2", + "version": "3.4.13-beta1", "main": "./main.js", "repository": { "type": "git", @@ -17,55 +17,61 @@ }, "license": "MIT", "dependencies": { + "@floating-ui/react-dom": "^2.0.0", "@github/alive-client": "^0.0.2", "app-path": "^3.3.0", "byline": "^5.0.0", "chalk": "^2.3.0", "classnames": "^2.2.5", - "codemirror": "^5.60.0", + "codemirror": "^5.65.17", "codemirror-mode-elixir": "^1.1.2", + "codemirror-mode-luau": "^1.0.2", + "codemirror-mode-zig": "^1.0.7", "compare-versions": "^3.6.0", "deep-equal": "^1.0.1", - "desktop-notifications": "^0.2.2", - "desktop-trampoline": "desktop/desktop-trampoline#v0.9.8", + "desktop-notifications": "^0.2.4", + "desktop-trampoline": "desktop/desktop-trampoline#v0.9.10", "dexie": "^3.2.2", - "dompurify": "^2.3.3", - "dugite": "^1.109.0", + "dompurify": "^2.5.4", + "dugite": "3.0.0-rc5", "electron-window-state": "^5.0.3", "event-kit": "^2.0.0", "focus-trap-react": "^8.1.0", "fs-admin": "^0.19.0", "fuzzaldrin-plus": "^0.6.0", "keytar": "^7.8.0", + "lodash": "^4.17.21", "marked": "^4.0.10", "mem": "^4.3.0", "memoize-one": "^4.0.3", + "minimist": "^1.2.8", "mri": "^1.1.0", "p-limit": "^2.2.0", + "p-memoize": "^7.1.1", "primer-support": "^4.0.0", "prop-types": "^15.7.2", "quick-lru": "^3.0.0", + "re2js": "^0.3.0", "react": "^16.8.4", - "react-color": "^2.19.3", + "react-confetti": "^6.1.0", "react-css-transition-replace": "^3.0.3", "react-dom": "^16.8.4", "react-transition-group": "^4.4.1", "react-virtualized": "^9.20.0", - "registry-js": "^1.15.0", + "registry-js": "^1.16.0", "source-map-support": "^0.4.15", + "split2": "^4.2.0", + "string-argv": "^0.3.2", "strip-ansi": "^4.0.0", "textarea-caret": "^3.0.2", "triple-beam": "^1.3.0", "tslib": "^2.0.0", "untildify": "^3.0.2", - "username": "^5.1.0", "uuid": "^3.0.1", - "wicg-focus-ring": "^1.0.1", + "windows-argv-parser": "file:../vendor/windows-argv-parser", "winston": "^3.6.0" }, "devDependencies": { - "devtron": "^1.4.0", - "electron-debug": "^3.1.0", "electron-devtools-installer": "^3.2.0", "temp": "^0.8.3", "webpack-hot-middleware": "^2.10.0" diff --git a/app/src/cli/commands/clone.ts b/app/src/cli/commands/clone.ts deleted file mode 100644 index 012c0531b77..00000000000 --- a/app/src/cli/commands/clone.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as QueryString from 'querystring' -import { URL } from 'url' - -import { CommandError } from '../util' -import { openDesktop } from '../open-desktop' -import { ICommandModule, mriArgv } from '../load-commands' - -interface ICloneArgs extends mriArgv { - readonly branch?: string -} - -export const command: ICommandModule = { - command: 'clone ', - description: 'Clone a repository', - args: [ - { - name: 'url|slug', - required: true, - description: 'The URL or the GitHub owner/name alias to clone', - type: 'string', - }, - ], - options: { - branch: { - type: 'string', - aliases: ['b'], - description: 'The branch to checkout after cloning', - }, - }, - handler({ _: [cloneUrl], branch }: ICloneArgs) { - if (!cloneUrl) { - throw new CommandError('Clone URL must be specified') - } - try { - const _ = new URL(cloneUrl) - _.toString() // don’t mark as unused - } catch (e) { - // invalid URL, assume a GitHub repo - cloneUrl = `https://github.com/${cloneUrl}` - } - const url = `openRepo/${cloneUrl}?${QueryString.stringify({ - branch, - })}` - openDesktop(url) - }, -} diff --git a/app/src/cli/commands/help.ts b/app/src/cli/commands/help.ts deleted file mode 100644 index 5396000901b..00000000000 --- a/app/src/cli/commands/help.ts +++ /dev/null @@ -1,79 +0,0 @@ -import chalk from 'chalk' - -import { commands, ICommandModule, IOption } from '../load-commands' - -import { dasherizeOption, printTable } from '../util' - -export const command: ICommandModule = { - command: 'help [command]', - description: 'Show the help page for a command', - handler({ _: [command] }) { - if (command) { - printCommandHelp(command, commands[command]) - } else { - printHelp() - } - }, -} - -function printHelp() { - console.log(chalk.underline('Commands:')) - const table: string[][] = [] - for (const commandName of Object.keys(commands)) { - const command = commands[commandName] - table.push([chalk.bold(command.command), command.description]) - } - printTable(table) - console.log( - `\nRun ${chalk.bold( - `github help ${chalk.gray('')}` - )} for details about each command` - ) -} - -function printCommandHelp(name: string, command: ICommandModule) { - if (!command) { - console.log(`Unrecognized command: ${chalk.bold.red.underline(name)}`) - printHelp() - return - } - console.log(`${chalk.gray('github')} ${command.command}`) - if (command.aliases) { - for (const alias of command.aliases) { - console.log(chalk.gray(`github ${alias}`)) - } - } - console.log() - const [title, body] = command.description.split('\n', 1) - console.log(chalk.bold(title)) - if (body) { - console.log(body) - } - const { options, args } = command - if (options) { - console.log(chalk.underline('\nOptions:')) - printTable( - Object.keys(options) - .map(k => [k, options[k]] as [string, IOption]) - .map(([optionName, option]) => [ - [optionName, ...(option.aliases || [])] - .map(dasherizeOption) - .map(x => chalk.bold.blue(x)) - .join(chalk.gray(', ')), - option.description, - chalk.gray(`[${chalk.underline(option.type)}]`), - ]) - ) - } - if (args && args.length) { - console.log(chalk.underline('\nArguments:')) - printTable( - args.map(arg => [ - (arg.required ? chalk.bold : chalk).blue(arg.name), - arg.required ? chalk.gray('(required)') : '', - arg.description, - chalk.gray(`[${chalk.underline(arg.type)}]`), - ]) - ) - } -} diff --git a/app/src/cli/commands/open.ts b/app/src/cli/commands/open.ts deleted file mode 100644 index b5c58d19f68..00000000000 --- a/app/src/cli/commands/open.ts +++ /dev/null @@ -1,39 +0,0 @@ -import chalk from 'chalk' -import * as Path from 'path' - -import { ICommandModule, mriArgv } from '../load-commands' -import { openDesktop } from '../open-desktop' -import { parseRemote } from '../../lib/remote-parsing' - -export const command: ICommandModule = { - command: 'open ', - aliases: [''], - description: 'Open a git repository in GitHub Desktop', - args: [ - { - name: 'path', - description: 'The path to the repository to open', - type: 'string', - required: false, - }, - ], - handler({ _: [pathArg] }: mriArgv) { - if (!pathArg) { - // just open Desktop - openDesktop() - return - } - //Check if the pathArg is a remote url - if (parseRemote(pathArg) != null) { - console.log( - `\nYou cannot open a remote URL in GitHub Desktop\n` + - `Use \`${chalk.bold(`git clone ` + pathArg)}\`` + - ` instead to initiate the clone` - ) - } else { - const repositoryPath = Path.resolve(process.cwd(), pathArg) - const url = `openLocalRepo/${encodeURIComponent(repositoryPath)}` - openDesktop(url) - } - }, -} diff --git a/app/src/cli/dev-commands-global.ts b/app/src/cli/dev-commands-global.ts deleted file mode 100644 index 2199e5e0c45..00000000000 --- a/app/src/cli/dev-commands-global.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { getCLICommands } from '../../../app/app-info' - -const g: any = global -g.__CLI_COMMANDS__ = getCLICommands() diff --git a/app/src/cli/load-commands.ts b/app/src/cli/load-commands.ts deleted file mode 100644 index f988ef4d17e..00000000000 --- a/app/src/cli/load-commands.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Argv as mriArgv } from 'mri' - -import { TypeName } from './util' - -type StringArray = ReadonlyArray - -export type CommandHandler = (args: mriArgv, argv: StringArray) => void -export { mriArgv } - -export interface IOption { - readonly type: TypeName - readonly aliases?: StringArray - readonly description: string - readonly default?: any -} - -interface IArgument { - readonly name: string - readonly required: boolean - readonly description: string - readonly type: TypeName -} - -export interface ICommandModule { - name?: string - readonly command: string - readonly description: string - readonly handler: CommandHandler - readonly aliases?: StringArray - readonly options?: { [flag: string]: IOption } - readonly args?: ReadonlyArray - readonly unknownOptionHandler?: (flag: string) => void -} - -function loadModule(name: string): ICommandModule { - return require(`./commands/${name}.ts`).command -} - -interface ICommands { - [command: string]: ICommandModule -} -export const commands: ICommands = {} - -for (const fileName of __CLI_COMMANDS__) { - const mod = loadModule(fileName) - if (!mod.name) { - mod.name = fileName - } - commands[mod.name] = mod -} diff --git a/app/src/cli/main.ts b/app/src/cli/main.ts index c9525da9b0c..48ecd76622d 100644 --- a/app/src/cli/main.ts +++ b/app/src/cli/main.ts @@ -1,107 +1,65 @@ -import mri, { - DictionaryObject, - Options as MriOptions, - ArrayOrString, -} from 'mri' -import chalk from 'chalk' +import { join, resolve } from 'path' +import parse from 'minimist' +import { execFile, ExecFileException } from 'child_process' -import { dasherizeOption, CommandError } from './util' -import { commands } from './load-commands' -const defaultCommand = 'open' - -let args = process.argv.slice(2) -if (!args[0]) { - args[0] = '.' -} -const commandArg = args[0] -args = args.slice(1) - -const supportsCommand = (name: string) => Object.hasOwn(commands, name) - -;(function attemptRun(name: string) { - try { - if (supportsCommand(name)) { - runCommand(name) - } else if (name.startsWith('--')) { - attemptRun(name.slice(2)) - } else { - try { - args.unshift(commandArg) - runCommand(defaultCommand) - } catch (err) { - logError(err) - args = [] - runCommand('help') - } +const run = (...args: Array) => { + function cb(e: ExecFileException | null, stderr: string) { + if (e) { + console.error(`Error running command ${args}`) + console.error(stderr) + process.exit(e.code) } - } catch (err) { - logError(err) - args = [name] - runCommand('help') } -})(commandArg) -function logError(err: CommandError) { - console.log(chalk.bgBlack.red('ERR!'), err.message) - if (err.stack && !err.pretty) { - console.log(chalk.gray(err.stack)) + if (process.platform === 'darwin') { + execFile('open', ['-n', join(__dirname, '../../..'), '--args', ...args], cb) + } else if (process.platform === 'win32') { + const exeName = `GitHubDesktop${__DEV__ ? '-dev' : ''}.exe` + execFile(join(__dirname, `../../${exeName}`), args, cb) + } else { + throw new Error('Unsupported platform') } } -console.log() // nice blank line before the command prompt +const args = parse(process.argv.slice(2), { + alias: { help: 'h', branch: 'b' }, + boolean: ['help'], +}) -interface IMRIOpts extends MriOptions { - alias: DictionaryObject - boolean: Array - default: DictionaryObject - string: Array +const usage = (exitCode = 1): never => { + process.stderr.write( + 'GitHub Desktop CLI usage: \n' + + ' github Open the current directory\n' + + ' github open [path] Open the provided path\n' + + ' github clone [-b branch] Clone the repository by url or name/owner\n' + + ' (ex torvalds/linux), optionally checking out\n' + + ' the branch\n' + ) + process.exit(exitCode) } -function runCommand(name: string) { - const command = commands[name] - const opts: IMRIOpts = { - alias: {}, - boolean: [], - default: {}, - string: [], - } - if (command.options) { - for (const flag of Object.keys(command.options)) { - const flagOptions = command.options[flag] - if (flagOptions.aliases) { - opts.alias[flag] = flagOptions.aliases - } - if (Object.hasOwn(flagOptions, 'default')) { - opts.default[flag] = flagOptions.default - } - switch (flagOptions.type) { - case 'string': - opts.string.push(flag) - break - case 'boolean': - opts.boolean.push(flag) - break - } - } - opts.unknown = command.unknownOptionHandler - } - const parsedArgs = mri(args, opts) - if (command.options) { - for (const flag of Object.keys(parsedArgs)) { - if (!(flag in command.options)) { - continue - } +delete process.env.ELECTRON_RUN_AS_NODE - const value = parsedArgs[flag] - const expectedType = command.options[flag].type - if (typeof value !== expectedType) { - throw new CommandError( - `Value passed to flag ${dasherizeOption( - flag - )} was of type ${typeof value}, but was expected to be of type ${expectedType}` - ) - } - } +if (args.help || args._.at(0) === 'help') { + usage(0) +} else if (args._.at(0) === 'clone') { + const urlArg = args._.at(1) + // Assume name with owner slug if it looks like it + const url = + urlArg && /^[^\/]+\/[^\/]+$/.test(urlArg) + ? `https://github.com/${urlArg}` + : urlArg + + if (!url) { + usage(1) + } else if (typeof args.branch === 'string') { + run(`--cli-clone=${url}`, `--cli-branch=${args.branch}`) + } else { + run(`--cli-clone=${url}`) } - command.handler(parsedArgs, args) +} else { + const [firstArg, secondArg] = args._ + const pathArg = firstArg === 'open' ? secondArg : firstArg + const path = resolve(pathArg ?? '.') + run(`--cli-open=${path}`) } diff --git a/app/src/cli/open-desktop.ts b/app/src/cli/open-desktop.ts deleted file mode 100644 index e81b97a94b7..00000000000 --- a/app/src/cli/open-desktop.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as ChildProcess from 'child_process' - -export function openDesktop(url: string = '') { - const env = { ...process.env } - // NB: We're gonna launch Desktop and we definitely don't want to carry over - // `ELECTRON_RUN_AS_NODE`. This seems to only happen on Windows. - delete env['ELECTRON_RUN_AS_NODE'] - - url = 'x-github-client://' + url - - if (__DARWIN__) { - return ChildProcess.spawn('open', [url], { env }) - } else if (__WIN32__) { - // https://github.com/nodejs/node/blob/b39dabefe6d/lib/child_process.js#L565-L577 - const shell = process.env.comspec || 'cmd.exe' - return ChildProcess.spawn(shell, ['/d', '/c', 'start', url], { env }) - } else { - throw new Error( - `Desktop command line interface not currently supported on platform ${process.platform}` - ) - } -} diff --git a/app/src/cli/util.ts b/app/src/cli/util.ts deleted file mode 100644 index 5bfabe4289d..00000000000 --- a/app/src/cli/util.ts +++ /dev/null @@ -1,48 +0,0 @@ -import stripAnsi from 'strip-ansi' - -export type TypeName = - | 'string' - | 'number' - | 'boolean' - | 'symbol' - | 'undefined' - | 'object' - | 'function' - -export class CommandError extends Error { - public pretty = true -} - -export const dasherizeOption = (option: string) => { - if (option.length === 1) { - return '-' + option - } else { - return '--' + option - } -} - -export function printTable(table: string[][]) { - const columnWidths = calculateColumnWidths(table) - for (const row of table) { - let rowStr = ' ' - row.forEach((item, i) => { - rowStr += item - const neededSpaces = columnWidths[i] - stripAnsi(item).length - rowStr += ' '.repeat(neededSpaces + 2) - }) - console.log(rowStr) - } -} - -function calculateColumnWidths(table: string[][]) { - const columnWidths: number[] = Array(table[0].length).fill(0) - for (const row of table) { - row.forEach((item, i) => { - const width = stripAnsi(item).length - if (columnWidths[i] < width) { - columnWidths[i] = width - } - }) - } - return columnWidths -} diff --git a/app/src/crash/crash-app.tsx b/app/src/crash/crash-app.tsx index a52636f228e..4f1e4a30333 100644 --- a/app/src/crash/crash-app.tsx +++ b/app/src/crash/crash-app.tsx @@ -4,7 +4,7 @@ import { TitleBar } from '../ui/window/title-bar' import { encodePathAsUrl } from '../lib/path' import { WindowState } from '../lib/window-state' import { Octicon } from '../ui/octicons' -import * as OcticonSymbol from '../ui/octicons/octicons.generated' +import * as octicons from '../ui/octicons/octicons.generated' import { Button } from '../ui/lib/button' import { LinkButton } from '../ui/lib/link-button' import { getVersion } from '../ui/lib/app-proxy' @@ -141,7 +141,7 @@ export class CrashApp extends React.Component { return (
- +

{message}

) @@ -201,7 +201,9 @@ export class CrashApp extends React.Component { } private renderBackgroundGraphics() { - return + return ( + + ) } public render() { diff --git a/app/src/highlighter/index.ts b/app/src/highlighter/index.ts index 63885be640a..72b75de21ef 100644 --- a/app/src/highlighter/index.ts +++ b/app/src/highlighter/index.ts @@ -104,6 +104,7 @@ const extensionModes: ReadonlyArray = [ mappings: { '.markdown': 'text/x-markdown', '.md': 'text/x-markdown', + '.mdx': 'text/x-markdown', }, }, { @@ -118,6 +119,7 @@ const extensionModes: ReadonlyArray = [ mappings: { '.xml': 'text/xml', '.xaml': 'text/xml', + '.xsd': 'text/xml', '.csproj': 'text/xml', '.fsproj': 'text/xml', '.vcxproj': 'text/xml', @@ -148,6 +150,11 @@ const extensionModes: ReadonlyArray = [ '.h': 'text/x-c', '.cpp': 'text/x-c++src', '.hpp': 'text/x-c++src', + '.cc': 'text/x-c++src', + '.hh': 'text/x-c++src', + '.hxx': 'text/x-c++src', + '.cxx': 'text/x-c++src', + '.ino': 'text/x-c++src', '.kt': 'text/x-kotlin', }, }, @@ -206,6 +213,8 @@ const extensionModes: ReadonlyArray = [ install: () => import('codemirror/mode/python/python'), mappings: { '.py': 'text/x-python', + '.pyi': 'text/x-python', + '.vpy': 'text/x-python', }, }, { @@ -268,9 +277,10 @@ const extensionModes: ReadonlyArray = [ }, }, { - install: () => import('codemirror/mode/lua/lua'), + install: () => import('codemirror-mode-luau'), mappings: { '.lua': 'text/x-lua', + '.luau': 'text/x-luau', }, }, { @@ -423,6 +433,18 @@ const extensionModes: ReadonlyArray = [ '.dart': 'application/dart', }, }, + { + install: () => import('codemirror-mode-zig'), + mappings: { + '.zig': 'text/x-zig', + }, + }, + { + install: () => import('codemirror/mode/cmake/cmake'), + mappings: { + '.cmake': 'text/x-cmake', + }, + }, ] /** @@ -443,6 +465,12 @@ const basenameModes: ReadonlyArray = [ dockerfile: 'text/x-dockerfile', }, }, + { + install: () => import('codemirror/mode/toml/toml'), + mappings: { + 'cargo.lock': 'text/x-toml', + }, + }, ] /** @@ -586,8 +614,8 @@ function readToken( throw new Error(`Mode ${getModeName(mode)} failed to advance stream.`) } -onmessage = async (ev: MessageEvent) => { - const request = ev.data as IHighlightRequest +onmessage = async (ev: MessageEvent) => { + const request = ev.data const tabSize = request.tabSize || 4 const addModeClass = request.addModeClass === true diff --git a/app/src/lib/2fa.ts b/app/src/lib/2fa.ts deleted file mode 100644 index 4e289590e66..00000000000 --- a/app/src/lib/2fa.ts +++ /dev/null @@ -1,26 +0,0 @@ -const authenticatorAppWelcomeText = - 'Open the two-factor authentication app on your device to view your authentication code and verify your identity.' -const smsMessageWelcomeText = - 'We just sent you a message via SMS with your authentication code. Enter the code in the form below to verify your identity.' - -/** - * When authentication is requested via 2FA, the endpoint provides - * a hint in the response header as to where the user should look - * to retrieve the token. - */ -export enum AuthenticationMode { - /* - * User should authenticate via a received text message. - */ - Sms, - /* - * User should open TOTP mobile application and obtain code. - */ - App, -} - -export function getWelcomeMessage(type: AuthenticationMode): string { - return type === AuthenticationMode.Sms - ? smsMessageWelcomeText - : authenticatorAppWelcomeText -} diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index a965ae1a936..d6f38944169 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -1,4 +1,3 @@ -import * as OS from 'os' import * as URL from 'url' import { Account } from '../models/account' @@ -8,14 +7,20 @@ import { HTTPMethod, APIError, urlWithQueryString, + getUserAgent, } from './http' -import { AuthenticationMode } from './2fa' import { uuid } from './uuid' -import username from 'username' import { GitProtocol } from './remote-parsing' -import { Emitter } from 'event-kit' -import JSZip from 'jszip' -import { updateEndpointVersion } from './endpoint-capabilities' +import { + getEndpointVersion, + isDotCom, + isGHE, + updateEndpointVersion, +} from './endpoint-capabilities' +import { + clearCertificateErrorSuppressionFor, + suppressCertificateErrorFor, +} from './suppress-certificate-error' const envEndpoint = process.env['DESKTOP_GITHUB_DOTCOM_API_ENDPOINT'] const envHTMLURL = process.env['DESKTOP_GITHUB_DOTCOM_HTML_URL'] @@ -26,6 +31,15 @@ if (envAdditionalCookies !== undefined) { document.cookie += '; ' + envAdditionalCookies } +type AffiliationFilter = + | 'owner' + | 'collaborator' + | 'organization_member' + | 'owner,collabor' + | 'owner,organization_member' + | 'collaborator,organization_member' + | 'owner,collaborator,organization_member' + /** * Optional set of configurable settings for the fetchAll method */ @@ -48,7 +62,15 @@ interface IFetchAllOptions { * * @param results All results retrieved thus far */ - continue?: (results: ReadonlyArray) => boolean + continue?: (results: ReadonlyArray) => boolean | Promise + + /** + * An optional callback which is invoked after each page of results is loaded + * from the API. This can be used to enable streaming of results. + * + * @param page The last fetched page of results + */ + onPage?: (page: ReadonlyArray) => void /** * Calculate the next page path given the response. @@ -89,9 +111,6 @@ enum HttpStatusCode { NotFound = 404, } -/** The note URL used for authorizations the app creates. */ -const NoteURL = 'https://desktop.github.com/' - /** * Information about a repository as returned by the GitHub API. */ @@ -224,6 +243,9 @@ interface IAPIFullIdentity { */ readonly email: string | null readonly type: GitHubAccountType + readonly plan?: { + readonly name: string + } } /** The users we get from the mentionables endpoint. */ @@ -468,6 +490,100 @@ export interface IAPIBranch { readonly protected: boolean } +/** Repository rule information returned by the GitHub API */ +export interface IAPIRepoRule { + /** + * The ID of the ruleset this rule is configured in. + */ + readonly ruleset_id: number + + /** + * The type of the rule. + */ + readonly type: APIRepoRuleType + + /** + * The parameters that apply to the rule if it is a metadata rule. + * Other rule types may have parameters, but they are not used in + * this app so they are ignored. Do not attempt to use this field + * unless you know {@link type} matches a metadata rule type. + */ + readonly parameters?: IAPIRepoRuleMetadataParameters +} + +/** + * A non-exhaustive list of rules that can be configured. Only the rule + * types used by this app are included. + */ +export enum APIRepoRuleType { + Creation = 'creation', + Update = 'update', + RequiredDeployments = 'required_deployments', + RequiredSignatures = 'required_signatures', + RequiredStatusChecks = 'required_status_checks', + PullRequest = 'pull_request', + CommitMessagePattern = 'commit_message_pattern', + CommitAuthorEmailPattern = 'commit_author_email_pattern', + CommitterEmailPattern = 'committer_email_pattern', + BranchNamePattern = 'branch_name_pattern', +} + +/** + * A ruleset returned from the GitHub API's "get all rulesets for a repo" endpoint. + * This endpoint returns a slimmed-down version of the full ruleset object, though + * only the ID is used. + */ +export interface IAPISlimRepoRuleset { + readonly id: number +} + +/** + * A ruleset returned from the GitHub API's "get a ruleset for a repo" endpoint. + */ +export interface IAPIRepoRuleset extends IAPISlimRepoRuleset { + /** + * Whether the user making the API request can bypass the ruleset. + */ + readonly current_user_can_bypass: 'always' | 'pull_requests_only' | 'never' +} + +/** + * Metadata parameters for a repo rule metadata rule. + */ +export interface IAPIRepoRuleMetadataParameters { + /** + * User-supplied name/description of the rule + */ + name: string + + /** + * Whether the operator is negated. For example, if `true` + * and {@link operator} is `starts_with`, then the rule + * will be negated to 'does not start with'. + */ + negate: boolean + + /** + * The pattern to match against. If the operator is 'regex', then + * this is a regex string match. Otherwise, it is a raw string match + * of the type specified by {@link operator} with no additional parsing. + */ + pattern: string + + /** + * The type of match to use for the pattern. For example, `starts_with` + * means {@link pattern} must be at the start of the string. + */ + operator: APIRepoRuleMetadataOperator +} + +export enum APIRepoRuleMetadataOperator { + StartsWith = 'starts_with', + EndsWith = 'ends_with', + Contains = 'contains', + RegexMatch = 'regex', +} + interface IAPIPullRequestRef { readonly ref: string readonly sha: string @@ -508,13 +624,13 @@ export interface IAPIPullRequestReview { | 'CHANGES_REQUESTED' } -/** The metadata about a GitHub server. */ -export interface IServerMetadata { - /** - * Does the server support password-based authentication? If not, the user - * must go through the OAuth flow to authenticate. - */ - readonly verifiable_password_authentication: boolean +/** Represents both issue comments and PR review comments */ +export interface IAPIComment { + readonly id: number + readonly body: string + readonly html_url: string + readonly user: IAPIIdentity + readonly created_at: string } /** The server response when handling the OAuth callback (with code) to obtain an access token */ @@ -524,11 +640,6 @@ interface IAPIAccessToken { readonly token_type: string } -/** The partial server response when creating a new authorization on behalf of a user */ -interface IAPIAuthorization { - readonly token: string -} - /** The response we receive from fetching mentionables. */ interface IAPIMentionablesResponse { readonly etag: string | undefined @@ -665,20 +776,23 @@ interface IAPIAliveWebSocket { readonly url: string } +type TokenInvalidatedCallback = (endpoint: string, token: string) => void + /** * An object for making authenticated requests to the GitHub API */ export class API { - private static readonly TOKEN_INVALIDATED_EVENT = 'token-invalidated' + private static readonly tokenInvalidatedListeners = + new Set() - private static readonly emitter = new Emitter() - - public static onTokenInvalidated(callback: (endpoint: string) => void) { - API.emitter.on(API.TOKEN_INVALIDATED_EVENT, callback) + public static onTokenInvalidated(callback: TokenInvalidatedCallback) { + this.tokenInvalidatedListeners.add(callback) } - private static emitTokenInvalidated(endpoint: string) { - API.emitter.emit(API.TOKEN_INVALIDATED_EVENT, endpoint) + private static emitTokenInvalidated(endpoint: string, token: string) { + this.tokenInvalidatedListeners.forEach(callback => + callback(endpoint, token) + ) } /** Create a new API client from the given account. */ @@ -733,6 +847,79 @@ export class API { } } + /** + * Fetch an issue comment (i.e. a comment on an issue or pull request). + * + * @param owner The owner of the repository + * @param name The name of the repository + * @param commentId The ID of the comment + * + * @returns The comment if it was found, null if it wasn't, or an error + * occurred. + */ + public async fetchIssueComment( + owner: string, + name: string, + commentId: string + ): Promise { + try { + const response = await this.request( + 'GET', + `repos/${owner}/${name}/issues/comments/${commentId}` + ) + if (response.status === HttpStatusCode.NotFound) { + log.warn( + `fetchIssueComment: '${owner}/${name}/issues/comments/${commentId}' returned a 404` + ) + return null + } + return await parsedResponse(response) + } catch (e) { + log.warn( + `fetchIssueComment: an error occurred for '${owner}/${name}/issues/comments/${commentId}'`, + e + ) + return null + } + } + + /** + * Fetch a pull request review comment (i.e. a comment that was posted as part + * of a review of a pull request). + * + * @param owner The owner of the repository + * @param name The name of the repository + * @param commentId The ID of the comment + * + * @returns The comment if it was found, null if it wasn't, or an error + * occurred. + */ + public async fetchPullRequestReviewComment( + owner: string, + name: string, + commentId: string + ): Promise { + try { + const response = await this.request( + 'GET', + `repos/${owner}/${name}/pulls/comments/${commentId}` + ) + if (response.status === HttpStatusCode.NotFound) { + log.warn( + `fetchPullRequestReviewComment: '${owner}/${name}/pulls/comments/${commentId}' returned a 404` + ) + return null + } + return await parsedResponse(response) + } catch (e) { + log.warn( + `fetchPullRequestReviewComment: an error occurred for '${owner}/${name}/pulls/comments/${commentId}'`, + e + ) + return null + } + } + /** Fetch a repo by its owner and name. */ public async fetchRepository( owner: string, @@ -793,22 +980,39 @@ export class API { } } - /** Fetch all repos a user has access to. */ - public async fetchRepositories(): Promise | null> { + /** + * Fetch all repos a user has access to in a streaming fashion. The callback + * will be called for each new page fetched from the API. + */ + public async streamUserRepositories( + callback: (repos: ReadonlyArray) => void, + affiliation?: AffiliationFilter, + options?: IFetchAllOptions + ) { try { - const repositories = await this.fetchAll('user/repos') - // "But wait, repositories can't have a null owner" you say. - // Ordinarily you'd be correct but turns out there's super - // rare circumstances where a user has been deleted but the - // repository hasn't. Such cases are usually addressed swiftly - // but in some cases like GitHub Enterprise instances - // they can linger for longer than we'd like so we'll make - // sure to exclude any such dangling repository, chances are - // they won't be cloneable anyway. - return repositories.filter(x => x.owner !== null) + const base = 'user/repos' + const path = affiliation ? `${base}?affiliation=${affiliation}` : base + + await this.fetchAll(path, { + ...options, + // "But wait, repositories can't have a null owner" you say. + // Ordinarily you'd be correct but turns out there's super + // rare circumstances where a user has been deleted but the + // repository hasn't. Such cases are usually addressed swiftly + // but in some cases like GitHub Enterprise instances + // they can linger for longer than we'd like so we'll make + // sure to exclude any such dangling repository, chances are + // they won't be cloneable anyway. + onPage: page => { + callback(page.filter(x => x.owner !== null)) + options?.onPage?.(page) + }, + }) } catch (error) { - log.warn(`fetchRepositories: ${error}`) - return null + log.warn( + `streamUserRepositories: failed with endpoint ${this.endpoint}`, + error + ) } } @@ -1047,17 +1251,97 @@ export class API { } } + /** Fetches all reviews from a given pull request. */ + public async fetchPullRequestReviews( + owner: string, + name: string, + prNumber: string + ) { + try { + const path = `/repos/${owner}/${name}/pulls/${prNumber}/reviews` + const response = await this.request('GET', path) + return await parsedResponse(response) + } catch (e) { + log.debug( + `failed fetching PR reviews for ${owner}/${name}/pulls/${prNumber}`, + e + ) + return [] + } + } + + /** Fetches all review comments from a given pull request. */ + public async fetchPullRequestReviewComments( + owner: string, + name: string, + prNumber: string, + reviewId: string + ) { + try { + const path = `/repos/${owner}/${name}/pulls/${prNumber}/reviews/${reviewId}/comments` + const response = await this.request('GET', path) + return await parsedResponse(response) + } catch (e) { + log.debug( + `failed fetching PR review comments for ${owner}/${name}/pulls/${prNumber}`, + e + ) + return [] + } + } + + /** Fetches all review comments from a given pull request. */ + public async fetchPullRequestComments( + owner: string, + name: string, + prNumber: string + ) { + try { + const path = `/repos/${owner}/${name}/pulls/${prNumber}/comments` + const response = await this.request('GET', path) + return await parsedResponse(response) + } catch (e) { + log.debug( + `failed fetching PR comments for ${owner}/${name}/pulls/${prNumber}`, + e + ) + return [] + } + } + + /** Fetches all comments from a given issue. */ + public async fetchIssueComments( + owner: string, + name: string, + issueNumber: string + ) { + try { + const path = `/repos/${owner}/${name}/issues/${issueNumber}/comments` + const response = await this.request('GET', path) + return await parsedResponse(response) + } catch (e) { + log.debug( + `failed fetching issue comments for ${owner}/${name}/issues/${issueNumber}`, + e + ) + return [] + } + } + /** * Get the combined status for the given ref. */ public async fetchCombinedRefStatus( owner: string, name: string, - ref: string + ref: string, + reloadCache: boolean = false ): Promise { const safeRef = encodeURIComponent(ref) const path = `repos/${owner}/${name}/commits/${safeRef}/status?per_page=100` - const response = await this.request('GET', path) + const response = await this.request('GET', path, { + reloadCache, + }) try { return await parsedResponse(response) @@ -1076,7 +1360,8 @@ export class API { public async fetchRefCheckRuns( owner: string, name: string, - ref: string + ref: string, + reloadCache: boolean = false ): Promise { const safeRef = encodeURIComponent(ref) const path = `repos/${owner}/${name}/commits/${safeRef}/check-runs?per_page=100` @@ -1084,7 +1369,10 @@ export class API { Accept: 'application/vnd.github.antiope-preview+json', } - const response = await this.request('GET', path, { customHeaders: headers }) + const response = await this.request('GET', path, { + customHeaders: headers, + reloadCache, + }) try { return await parsedResponse(response) @@ -1185,31 +1473,6 @@ export class API { return null } - /** - * Get JSZip for a workflow run log archive. - * - * If it fails to retrieve or parse the zip file, it will return null. - */ - public async fetchWorkflowRunJobLogs(logsUrl: string): Promise { - const customHeaders = { - Accept: 'application/vnd.github.antiope-preview+json', - } - const response = await this.request('GET', logsUrl, { - customHeaders, - }) - - try { - const zipBlob = await response.blob() - return new JSZip().loadAsync(zipBlob) - } catch (e) { - // Sometimes a workflow provides a log url, but still returns a 404 - // because a log file doesn't make sense for the workflow. Thus, we just - // want to fail without raising an error. - } - - return null - } - /** * Triggers GitHub to rerequest an existing check suite, without pushing new * code to a repository. @@ -1275,6 +1538,23 @@ export class API { }) } + public async getAvatarToken() { + return this.request('GET', `/desktop/avatar-token`) + .then(x => x.json()) + .then((x: unknown) => + x && + typeof x === 'object' && + 'avatar_token' in x && + typeof x.avatar_token === 'string' + ? x.avatar_token + : null + ) + .catch(err => { + log.debug(`Failed to load avatar token`, err) + return null + }) + } + /** * Gets a single check suite using its id */ @@ -1355,6 +1635,72 @@ export class API { } } + /** + * Fetches all repository rules that apply to the provided branch. + */ + public async fetchRepoRulesForBranch( + owner: string, + name: string, + branch: string + ): Promise> { + const path = `repos/${owner}/${name}/rules/branches/${encodeURIComponent( + branch + )}` + try { + const response = await this.request('GET', path) + return await parsedResponse(response) + } catch (err) { + log.info( + `[fetchRepoRulesForBranch] unable to fetch repo rules for branch: ${branch} | ${path}`, + err + ) + return new Array() + } + } + + /** + * Fetches slim versions of all repo rulesets for the given repository. Utilize the cache + * in IAppState instead of querying this if possible. + */ + public async fetchAllRepoRulesets( + owner: string, + name: string + ): Promise | null> { + const path = `repos/${owner}/${name}/rulesets` + try { + const response = await this.request('GET', path) + return await parsedResponse>(response) + } catch (err) { + log.info( + `[fetchAllRepoRulesets] unable to fetch all repo rulesets | ${path}`, + err + ) + return null + } + } + + /** + * Fetches the repo ruleset with the given ID. Utilize the cache in IAppState + * instead of querying this if possible. + */ + public async fetchRepoRuleset( + owner: string, + name: string, + id: number + ): Promise { + const path = `repos/${owner}/${name}/rulesets/${id}` + try { + const response = await this.request('GET', path) + return await parsedResponse(response) + } catch (err) { + log.info( + `[fetchRepoRuleset] unable to fetch repo ruleset for ID: ${id} | ${path}`, + err + ) + return null + } + } + /** * Authenticated requests to a paginating resource such as issues. * @@ -1368,6 +1714,7 @@ export class API { const params = { per_page: `${opts.perPage}` } let nextPath: string | null = urlWithQueryString(path, params) + let page: ReadonlyArray = [] do { const response: Response = await this.request('GET', nextPath) if (opts.suppressErrors !== false && !response.ok) { @@ -1375,15 +1722,16 @@ export class API { return buf } - const items = await parsedResponse>(response) - if (items) { - buf.push(...items) + page = await parsedResponse>(response) + if (page) { + buf.push(...page) + opts.onPage?.(page) } nextPath = opts.getNextPagePath ? opts.getNextPagePath(response) : getNextPagePathFromLink(response) - } while (nextPath && (!opts.continue || opts.continue(buf))) + } while (nextPath && (!opts.continue || (await opts.continue(buf)))) return buf } @@ -1418,7 +1766,7 @@ export class API { response.headers.has('X-GitHub-Request-Id') && !response.headers.has('X-GitHub-OTP') ) { - API.emitTokenInvalidated(this.endpoint) + API.emitTokenInvalidated(this.endpoint, this.token) } tryUpdateEndpointVersionFromResponse(this.endpoint, response) @@ -1512,139 +1860,23 @@ export class API { } } -export enum AuthorizationResponseKind { - Authorized, - Failed, - TwoFactorAuthenticationRequired, - UserRequiresVerification, - PersonalAccessTokenBlocked, - Error, - EnterpriseTooOld, - /** - * The API has indicated that the user is required to go through - * the web authentication flow. - */ - WebFlowRequired, -} - -export type AuthorizationResponse = - | { kind: AuthorizationResponseKind.Authorized; token: string } - | { kind: AuthorizationResponseKind.Failed; response: Response } - | { - kind: AuthorizationResponseKind.TwoFactorAuthenticationRequired - type: AuthenticationMode - } - | { kind: AuthorizationResponseKind.Error; response: Response } - | { kind: AuthorizationResponseKind.UserRequiresVerification } - | { kind: AuthorizationResponseKind.PersonalAccessTokenBlocked } - | { kind: AuthorizationResponseKind.EnterpriseTooOld } - | { kind: AuthorizationResponseKind.WebFlowRequired } - -/** - * Create an authorization with the given login, password, and one-time - * password. - */ -export async function createAuthorization( - endpoint: string, - login: string, - password: string, - oneTimePassword: string | null -): Promise { - const creds = Buffer.from(`${login}:${password}`, 'utf8').toString('base64') - const authorization = `Basic ${creds}` - const optHeader = oneTimePassword ? { 'X-GitHub-OTP': oneTimePassword } : {} - - const note = await getNote() - - const response = await request( - endpoint, - null, - 'POST', - 'authorizations', - { - scopes: oauthScopes, - client_id: ClientID, - client_secret: ClientSecret, - note: note, - note_url: NoteURL, - fingerprint: uuid(), - }, - { - Authorization: authorization, - ...optHeader, - } - ) - - tryUpdateEndpointVersionFromResponse(endpoint, response) - +export async function deleteToken(account: Account) { try { - const result = await parsedResponse(response) - if (result) { - const token = result.token - if (token && typeof token === 'string' && token.length) { - return { kind: AuthorizationResponseKind.Authorized, token } - } - } - } catch (e) { - if (response.status === 401) { - const otpResponse = response.headers.get('x-github-otp') - if (otpResponse) { - const pieces = otpResponse.split(';') - if (pieces.length === 2) { - const type = pieces[1].trim() - switch (type) { - case 'app': - return { - kind: AuthorizationResponseKind.TwoFactorAuthenticationRequired, - type: AuthenticationMode.App, - } - case 'sms': - return { - kind: AuthorizationResponseKind.TwoFactorAuthenticationRequired, - type: AuthenticationMode.Sms, - } - default: - return { kind: AuthorizationResponseKind.Failed, response } - } - } - } - - return { kind: AuthorizationResponseKind.Failed, response } - } + const creds = Buffer.from(`${ClientID}:${ClientSecret}`).toString('base64') + const response = await request( + account.endpoint, + null, + 'DELETE', + `applications/${ClientID}/token`, + { access_token: account.token }, + { Authorization: `Basic ${creds}` } + ) - const apiError = e instanceof APIError && e.apiError - if (apiError) { - if ( - response.status === 403 && - apiError.message === - 'This API can only be accessed with username and password Basic Auth' - ) { - // Authorization API does not support providing personal access tokens - return { kind: AuthorizationResponseKind.PersonalAccessTokenBlocked } - } else if (response.status === 410) { - return { kind: AuthorizationResponseKind.WebFlowRequired } - } else if (response.status === 422) { - if (apiError.errors) { - for (const error of apiError.errors) { - const isExpectedResource = - error.resource.toLowerCase() === 'oauthaccess' - const isExpectedField = error.field.toLowerCase() === 'user' - if (isExpectedField && isExpectedResource) { - return { - kind: AuthorizationResponseKind.UserRequiresVerification, - } - } - } - } else if ( - apiError.message === 'Invalid OAuth application client_id or secret.' - ) { - return { kind: AuthorizationResponseKind.EnterpriseTooOld } - } - } - } + return response.status === 204 + } catch (e) { + log.error(`deleteToken: failed with endpoint ${account.endpoint}`, e) + return false } - - return { kind: AuthorizationResponseKind.Error, response } } /** Fetch the user authenticated by the token. */ @@ -1664,7 +1896,8 @@ export async function fetchUser( emails, user.avatar_url, user.id, - user.name || user.login + user.name || user.login, + user.plan?.name ) } catch (e) { log.warn(`fetchUser: failed with endpoint ${endpoint}`, e) @@ -1672,49 +1905,6 @@ export async function fetchUser( } } -/** Get metadata from the server. */ -export async function fetchMetadata( - endpoint: string -): Promise { - const url = `${endpoint}/meta` - - try { - const response = await request(endpoint, null, 'GET', 'meta', undefined, { - 'Content-Type': 'application/json', - }) - - tryUpdateEndpointVersionFromResponse(endpoint, response) - - const result = await parsedResponse(response) - if (!result || result.verifiable_password_authentication === undefined) { - return null - } - - return result - } catch (e) { - log.error( - `fetchMetadata: unable to load metadata from '${url}' as a fallback`, - e - ) - return null - } -} - -/** The note used for created authorizations. */ -async function getNote(): Promise { - let localUsername = await username() - - if (localUsername === undefined) { - localUsername = 'unknown' - - log.error( - `getNote: unable to resolve machine username, using '${localUsername}' as a fallback` - ) - } - - return `GitHub Desktop on ${localUsername}@${OS.hostname()}` -} - /** * Map a repository's URL to the endpoint associated with it. For example: * @@ -1752,6 +1942,18 @@ export function getHTMLURL(endpoint: string): string { if (endpoint === getDotComAPIEndpoint() && !envEndpoint) { return 'https://github.com' } else { + if (isGHE(endpoint)) { + const url = new window.URL(endpoint) + + url.pathname = '/' + + if (url.hostname.startsWith('api.')) { + url.hostname = url.hostname.replace(/^api\./, '') + } + + return url.toString() + } + const parsed = URL.parse(endpoint) return `${parsed.protocol}//${parsed.hostname}` } @@ -1763,6 +1965,15 @@ export function getHTMLURL(endpoint: string): string { * http://github.mycompany.com -> http://github.mycompany.com/api/v3 */ export function getEnterpriseAPIURL(endpoint: string): string { + if (isGHE(endpoint)) { + const url = new window.URL(endpoint) + + url.pathname = '/' + url.hostname = `api.${url.hostname}` + + return url.toString() + } + const parsed = URL.parse(endpoint) return `${parsed.protocol}//${parsed.hostname}/api/v3` } @@ -1794,8 +2005,7 @@ export function getOAuthAuthorizationURL( state: string ): string { const urlBase = getHTMLURL(endpoint) - const scopes = oauthScopes - const scope = encodeURIComponent(scopes.join(' ')) + const scope = encodeURIComponent(oauthScopes.join(' ')) return `${urlBase}/login/oauth/authorize?client_id=${ClientID}&scope=${scope}&state=${state}` } @@ -1835,3 +2045,88 @@ function tryUpdateEndpointVersionFromResponse( updateEndpointVersion(endpoint, gheVersion) } } + +const knownThirdPartyHosts = new Set([ + 'dev.azure.com', + 'gitlab.com', + 'bitbucket.org', + 'amazonaws.com', + 'visualstudio.com', +]) + +const isKnownThirdPartyHost = (hostname: string) => { + if (knownThirdPartyHosts.has(hostname)) { + return true + } + + for (const knownHost of knownThirdPartyHosts) { + if (hostname.endsWith(`.${knownHost}`)) { + return true + } + } + + return false +} + +/** + * Attempts to determine whether or not the url belongs to a GitHub host. + * + * This is a best-effort attempt and may return `undefined` if encountering + * an error making the discovery request + */ +export async function isGitHubHost(url: string) { + const { hostname } = new window.URL(url) + + const endpoint = + hostname === 'github.com' || hostname === 'api.github.com' + ? getDotComAPIEndpoint() + : getEnterpriseAPIURL(url) + + if (isDotCom(endpoint) || isGHE(endpoint)) { + return true + } + + if (isKnownThirdPartyHost(hostname)) { + return false + } + + // github.example.com, + if (/(^|\.)(github)\./.test(hostname)) { + return true + } + + // bitbucket.example.com, etc + if (/(^|\.)(bitbucket|gitlab)\./.test(hostname)) { + return false + } + + if (getEndpointVersion(endpoint) !== null) { + return true + } + + // Add a unique identifier to the URL to make sure our certificate error + // supression only catches this request + const metaUrl = `${endpoint}/meta?ghd=${uuid()}` + + const ac = new AbortController() + const timeoutId = setTimeout(() => ac.abort(), 2000) + suppressCertificateErrorFor(metaUrl) + try { + const response = await fetch(metaUrl, { + headers: { 'user-agent': getUserAgent() }, + signal: ac.signal, + credentials: 'omit', + method: 'HEAD', + }) + + tryUpdateEndpointVersionFromResponse(endpoint, response) + + return response.headers.has('x-github-request-id') + } catch (e) { + log.debug(`isGitHubHost: failed with endpoint ${endpoint}`, e) + return undefined + } finally { + clearTimeout(timeoutId) + clearCertificateErrorSuppressionFor(metaUrl) + } +} diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index d02694c324c..e97835cb970 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -11,8 +11,11 @@ import { IMenu } from '../models/app-menu' import { IRemote } from '../models/remote' import { CloneRepositoryTab } from '../models/clone-repository-tab' import { BranchesTab } from '../models/branches-tab' -import { PullRequest } from '../models/pull-request' -import { IAuthor } from '../models/author' +import { + PullRequest, + PullRequestSuggestedNextAction, +} from '../models/pull-request' +import { Author } from '../models/author' import { MergeTreeResult } from '../models/merge' import { ICommitMessage } from '../models/commit-message' import { @@ -22,18 +25,13 @@ import { ICloneProgress, IMultiCommitOperationProgress, } from '../models/progress' -import { Popup } from '../models/popup' import { SignInState } from './stores/sign-in-store' import { WindowState } from './window-state' import { Shell } from './shells' -import { - ApplicableTheme, - ApplicationTheme, - ICustomTheme, -} from '../ui/lib/application-theme' +import { ApplicableTheme, ApplicationTheme } from '../ui/lib/application-theme' import { IAccountRepositories } from './stores/api-repositories-store' import { ManualConflictResolution } from '../models/manual-conflict-resolution' import { Banner } from '../models/banner' @@ -47,6 +45,11 @@ import { MultiCommitOperationStep, } from '../models/multi-commit-operation' import { IChangesetData } from './git' +import { Popup } from '../models/popup' +import { RepoRulesInfo } from '../models/repo-rules' +import { IAPIRepoRuleset } from './api' +import { ICustomIntegration } from './custom-integration' +import { Emoji } from './emoji' export enum SelectionType { Repository, @@ -107,6 +110,16 @@ export interface IAppState { */ readonly windowZoomFactor: number + /** + * Whether or not the currently active element is itself, or is contained + * within, a resizable component. This is used to determine whether or not + * to enable the Expand/Contract pane menu items. Note that this doesn't + * necessarily mean that keyboard resides within the resizable component since + * using the Windows in-app menu bar will steal focus from the currently + * active element (but return it once closed). + */ + readonly resizablePaneActive: boolean + /** * A value indicating whether or not the current application * window has focus. @@ -116,6 +129,7 @@ export interface IAppState { readonly showWelcomeFlow: boolean readonly focusCommitMessage: boolean readonly currentPopup: Popup | null + readonly allPopups: ReadonlyArray readonly currentFoldout: Foldout | null readonly currentBanner: Banner | null @@ -145,10 +159,10 @@ export interface IAppState { */ readonly appMenuState: ReadonlyArray - readonly errors: ReadonlyArray + readonly errorCount: number /** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */ - readonly emoji: Map + readonly emoji: Map /** * The width of the repository sidebar. @@ -170,6 +184,15 @@ export interface IAppState { /** The width of the files list in the stash view */ readonly stashedFilesWidth: IConstrainedValue + /** The width of the files list in the pull request files changed view */ + readonly pullRequestFilesListWidth: IConstrainedValue + + /** The width of the resizable branch drop down button in the toolbar. */ + readonly branchDropdownWidth: IConstrainedValue + + /** The width of the resizable push/pull button in the toolbar. */ + readonly pushPullButtonWidth: IConstrainedValue + /** * Used to highlight access keys throughout the app when the * Alt key is pressed. Only applicable on non-macOS platforms. @@ -185,6 +208,9 @@ export interface IAppState { /** Whether we should ask the user to move the app to /Applications */ readonly askToMoveToApplicationsFolderSetting: boolean + /** Whether we should use an external credential helper for third-party private repositories */ + readonly useExternalCredentialHelper: boolean + /** Whether we should show a confirmation dialog */ readonly askForConfirmationOnRepositoryRemoval: boolean @@ -194,9 +220,21 @@ export interface IAppState { /** Whether we should show a confirmation dialog */ readonly askForConfirmationOnDiscardChangesPermanently: boolean + /** Should the app prompt the user to confirm a discard stash */ + readonly askForConfirmationOnDiscardStash: boolean + + /** Should the app prompt the user to confirm a commit checkout? */ + readonly askForConfirmationOnCheckoutCommit: boolean + /** Should the app prompt the user to confirm a force push? */ readonly askForConfirmationOnForcePush: boolean + /** Should the app prompt the user to confirm an undo commit? */ + readonly askForConfirmationOnUndoCommit: boolean + + /** Should the app prompt the user to confirm they want to commit with changes are hidden by filter? */ + readonly askForConfirmationOnCommitFilteredChanges: boolean + /** How the app should handle uncommitted changes when switching branches */ readonly uncommittedChangesStrategy: UncommittedChangesStrategy @@ -206,6 +244,9 @@ export interface IAppState { /** Whether or not the app should use Windows' OpenSSH client */ readonly useWindowsOpenSSH: boolean + /** Whether or not the app should show the commit length warning */ + readonly showCommitLengthWarning: boolean + /** The current setting for whether the user has disable usage reports */ readonly optOutOfUsageTracking: boolean /** @@ -227,6 +268,9 @@ export interface IAppState { /** Whether we should hide white space changes in history diff */ readonly hideWhitespaceInHistoryDiff: boolean + /** Whether we should hide white space changes in the pull request diff */ + readonly hideWhitespaceInPullRequestDiff: boolean + /** Whether we should show side by side diffs */ readonly showSideBySideDiff: boolean @@ -245,12 +289,12 @@ export interface IAppState { /** The selected appearance (aka theme) preference */ readonly selectedTheme: ApplicationTheme - /** The custom theme */ - readonly customTheme?: ICustomTheme - /** The currently applied appearance (aka theme) */ readonly currentTheme: ApplicableTheme + /** The selected tab size preference */ + readonly selectedTabSize: number + /** * A map keyed on a user account (GitHub.com or GitHub Enterprise) * containing an object with repositories that the authenticated @@ -290,6 +334,18 @@ export interface IAppState { */ readonly lastThankYou: ILastThankYou | undefined + /** Whether or not the user wants to use a custom editor. */ + readonly useCustomEditor: boolean + + /** Info needed to launch a custom editor chosen by the user. */ + readonly customEditor: ICustomIntegration | null + + /** Whether or not the user wants to use a custom shell. */ + readonly useCustomShell: boolean + + /** Info needed to launch a custom shell chosen by the user. */ + readonly customShell: ICustomIntegration | null + /** * Whether or not the CI status popover is visible. */ @@ -299,6 +355,22 @@ export interface IAppState { * Whether or not the user enabled high-signal notifications. */ readonly notificationsEnabled: boolean + + /** The users last chosen pull request suggested next action. */ + readonly pullRequestSuggestedNextAction: + | PullRequestSuggestedNextAction + | undefined + + /** Whether or not the user will see check marks indicating a line is included in the check in the diff */ + readonly showDiffCheckMarks: boolean + + /** + * Cached repo rulesets. Used to prevent repeatedly querying the same + * rulesets to check their bypass status. + */ + readonly cachedRepoRulesets: ReadonlyMap + + readonly underlineLinks: boolean } export enum FoldoutType { @@ -306,6 +378,7 @@ export enum FoldoutType { Branch, AppMenu, AddMenu, + PushPull, } export type AppMenuFoldout = { @@ -317,15 +390,6 @@ export type AppMenuFoldout = { * keyboard navigation by pressing access keys. */ enableAccessKeyNavigation: boolean - - /** - * Whether the menu was opened by pressing Alt (or Alt+X where X is an - * access key for one of the top level menu items). This is used as a - * one-time signal to the AppMenu to use some special semantics for - * selection and focus. Specifically it will ensure that the last opened - * menu will receive focus. - */ - openedWithAccessKey?: boolean } export type BranchFoldout = { @@ -337,6 +401,7 @@ export type Foldout = | { type: FoldoutType.AddMenu } | BranchFoldout | AppMenuFoldout + | { type: FoldoutType.PushPull } export enum RepositorySectionTab { Changes, @@ -418,6 +483,16 @@ export interface IRepositoryState { readonly compareState: ICompareState readonly selectedSection: RepositorySectionTab + /** + * The state of the current pull request view in the repository. + * + * It will be populated when a user initiates a pull request. It may have + * content to retain a users pull request state if they navigate + * away from the current pull request view and then back. It is returned + * to null after a pull request has been opened. + */ + readonly pullRequestState: IPullRequestState | null + /** * The name and email that will be used for the author info * when committing barring any race where user.name/user.email is @@ -512,6 +587,13 @@ export interface IBranchesState { */ readonly defaultBranch: Branch | null + /** + * The default branch of the upstream remote in a forked GitHub repository + * with the ForkContributionTarget.Parent behavior, or null if it cannot be + * inferred or is another kind of repository. + */ + readonly upstreamDefaultBranch: Branch | null + /** * A list of all branches (remote and local) that's currently in * the repository. @@ -553,6 +635,25 @@ export interface ICommitSelection { /** The commits currently selected in the app */ readonly shas: ReadonlyArray + /** + * When multiple commits are selected, the diff is created using the rev range + * of firstSha^..lastSha in the selected shas. Thus comparing the trees of the + * the lastSha and the first parent of the first sha. However, our history + * list shows commits in chronological order. Thus, when a branch is merged, + * the commits from that branch are injected in their chronological order into + * the history list. Therefore, given a branch history of A, B, C, D, + * MergeCommit where B and C are from the merged branch, diffing on the + * selection of A through D would not have the changes from B an C. + * + * This is a list of the shas that are reachable by following the parent links + * (aka the graph) from the lastSha to the firstSha^ in the selection. + * + * Other notes: Given a selection A through D, executing `git diff A..D` would + * give us the changes since A but not including A; since the user will have + * selected A, we do `git diff A^..D` so that we include the changes of A. + * */ + readonly shasInDiff: ReadonlyArray + /** * Whether the a selection of commits are group of adjacent to each other. * Example: Given these are indexes of sha's in history, 3, 4, 5, 6 is contiguous as @@ -626,7 +727,7 @@ export interface IChangesState { * Co-Authored-By commit message trailers depending on whether * the user has chosen to do so. */ - readonly coAuthors: ReadonlyArray + readonly coAuthors: ReadonlyArray /** * Stores information about conflicts in the working directory @@ -651,6 +752,11 @@ export interface IChangesState { /** `true` if the GitHub API reports that the branch is protected */ readonly currentBranchProtected: boolean + + /** + * Repo rules that apply to the current branch. + */ + readonly currentRepoRulesInfo: RepoRulesInfo } /** @@ -717,6 +823,9 @@ export interface ICompareState { /** The SHAs of commits to render in the compare list */ readonly commitSHAs: ReadonlyArray + /** The SHAs of commits to highlight in the compare list */ + readonly shasToHighlight: ReadonlyArray + /** * A list of branches (remote and local) except the current branch, and * Desktop fork remote branches (see `Branch.isDesktopForkRemoteBranch`) @@ -899,3 +1008,31 @@ export interface IConstrainedValue { readonly max: number readonly min: number } + +/** + * The state of the current pull request view in the repository. + */ +export interface IPullRequestState { + /** + * The base branch of a a pull request - the branch the currently checked out + * branch would merge into + */ + readonly baseBranch: Branch | null + + /** The SHAs of commits of the pull request */ + readonly commitSHAs: ReadonlyArray | null + + /** + * The commit selection, file selection and diff of the pull request. + * + * Note: By default the commit selection shas will be all the pull request + * shas and will mean the diff represents the merge base of the current branch + * and the the pull request base branch. This is different than the + * repositories commit selection where the diff of all commits represents the + * diff between the latest commit and the earliest commits parent. + */ + readonly commitSelection: ICommitSelection | null + + /** The result of merging the pull request branch into the base branch */ + readonly mergeStatus: MergeTreeResult | null +} diff --git a/app/src/lib/branch.ts b/app/src/lib/branch.ts index 93ee13b9379..1f0342ba470 100644 --- a/app/src/lib/branch.ts +++ b/app/src/lib/branch.ts @@ -1,43 +1,26 @@ -import { Branch, BranchType } from '../models/branch' -import { UpstreamRemoteName } from './stores' +import { Branch } from '../models/branch' import { - RepositoryWithGitHubRepository, - getNonForkGitHubRepository, + isRepositoryWithGitHubRepository, + Repository, } from '../models/repository' +import { IBranchesState } from './app-state' /** - * Finds the default branch of the upstream repository of the passed repository. - * - * When the passed repository is not a fork or the fork is configured to use itself - * as the ForkContributionTarget, then this method will return null. Otherwise it'll - * return the default branch of the upstream repository. * * @param repository The repository to use. - * @param branches all the branches in the local repo. + * @param branchesState The branches state of the repository. + * @returns The default branch of the user's contribution target, or null if it's not known. + * + * This method will return the fork's upstream default branch, if the user + * is contributing to the parent repository. + * + * Otherwise, this method will return the default branch of the passed in repository. */ -export function findDefaultUpstreamBranch( - repository: RepositoryWithGitHubRepository, - branches: ReadonlyArray +export function findContributionTargetDefaultBranch( + repository: Repository, + { defaultBranch, upstreamDefaultBranch }: IBranchesState ): Branch | null { - const githubRepository = getNonForkGitHubRepository(repository) - - // This is a bit hacky... we checked if the result of calling - // getNonForkGitHubRepository() is the same as the associated - // gitHubRepository. When this happens, we know that either the - // repository is not a fork or that it's a fork with - // ForkContributionTarget=Self. - // - // TODO: Make this method return the default branch of the - // origin repository instead of null in this scenario. - if (githubRepository === repository.gitHubRepository) { - return null - } - - const foundBranch = branches.find( - b => - b.type === BranchType.Remote && - b.name === `${UpstreamRemoteName}/${githubRepository.defaultBranch}` - ) - - return foundBranch !== undefined ? foundBranch : null + return isRepositoryWithGitHubRepository(repository) + ? upstreamDefaultBranch ?? defaultBranch + : defaultBranch } diff --git a/app/src/lib/ci-checks/ci-checks.ts b/app/src/lib/ci-checks/ci-checks.ts index d7e8f8e1fe2..1efb276b1dc 100644 --- a/app/src/lib/ci-checks/ci-checks.ts +++ b/app/src/lib/ci-checks/ci-checks.ts @@ -1,20 +1,20 @@ +import { Account } from '../../models/account' +import { GitHubRepository } from '../../models/github-repository' import { - APICheckStatus, + API, APICheckConclusion, - IAPIWorkflowJobStep, + APICheckStatus, IAPIRefCheckRun, IAPIRefStatusItem, - IAPIWorkflowJob, - API, + IAPIWorkflowJobStep, IAPIWorkflowJobs, IAPIWorkflowRun, } from '../api' -import JSZip from 'jszip' -import { enableCICheckRunsLogs } from '../feature-flag' -import { GitHubRepository } from '../../models/github-repository' -import { Account } from '../../models/account' import { supportsRetrieveActionWorkflowByCheckSuiteId } from '../endpoint-capabilities' -import { formatPreciseDuration } from '../format-duration' +import { + formatLongPreciseDuration, + formatPreciseDuration, +} from '../format-duration' /** * A Desktop-specific model closely related to a GitHub API Check Run. @@ -50,41 +50,6 @@ export interface ICombinedRefCheck { readonly checks: ReadonlyArray } -/** - * Given a zipped list of logs from a workflow job, parses the different job - * steps. - */ -export async function parseJobStepLogs( - logZip: JSZip, - job: IAPIWorkflowJob -): Promise> { - try { - const jobFolder = logZip.folder(job.name) - if (jobFolder === null) { - return job.steps - } - - const stepsWLogs = new Array() - for (const step of job.steps) { - const stepName = step.name.replace('/', '') - const stepFileName = `${step.number}_${stepName}.txt` - const stepLogFile = jobFolder.file(stepFileName) - if (stepLogFile === null) { - stepsWLogs.push(step) - continue - } - - const log = await stepLogFile.async('text') - stepsWLogs.push({ ...step, log }) - } - return stepsWLogs - } catch (e) { - log.warn('Could not parse logs for: ' + job.name) - } - - return job.steps -} - /** * Convert a legacy API commit status to a fake check run */ @@ -321,7 +286,7 @@ export function isSuccess(check: IRefCheck) { * We use the check suite id as a proxy for determining what's * the "latest" of two check runs with the same name. */ -export function getLatestCheckRunsByName( +export function getLatestCheckRunsById( checkRuns: ReadonlyArray ): ReadonlyArray { const latestCheckRunsByName = new Map() @@ -336,7 +301,7 @@ export function getLatestCheckRunsByName( // feels hacky... but we don't have any other meta data on a check run that // differieates these. const nameAndHasPRs = - checkRun.name + + checkRun.id + (checkRun.pull_requests.length > 0 ? 'isPullRequestCheckRun' : 'isPushCheckRun') @@ -361,7 +326,6 @@ export async function getLatestPRWorkflowRunsLogsForCheckRun( repo: string, checkRuns: ReadonlyArray ): Promise> { - const logCache = new Map() const jobsCache = new Map() const mappedCheckRuns = new Array() for (const cr of checkRuns) { @@ -369,12 +333,12 @@ export async function getLatestPRWorkflowRunsLogsForCheckRun( mappedCheckRuns.push(cr) continue } - const { id: wfId, logs_url } = cr.actionsWorkflow + const { id } = cr.actionsWorkflow // Multiple check runs match a single workflow run. // We can prevent several job network calls by caching them. const workFlowRunJobs = - jobsCache.get(wfId) ?? (await api.fetchWorkflowRunJobs(owner, repo, wfId)) - jobsCache.set(wfId, workFlowRunJobs) + jobsCache.get(id) ?? (await api.fetchWorkflowRunJobs(owner, repo, id)) + jobsCache.set(id, workFlowRunJobs) const matchingJob = workFlowRunJobs?.jobs.find(j => j.id === cr.id) if (matchingJob === undefined) { @@ -382,31 +346,10 @@ export async function getLatestPRWorkflowRunsLogsForCheckRun( continue } - if (!enableCICheckRunsLogs()) { - mappedCheckRuns.push({ - ...cr, - htmlUrl: matchingJob.html_url, - actionJobSteps: matchingJob.steps, - }) - - continue - } - - // One workflow can have the logs for multiple check runs.. no need to - // keep retrieving it. So we are hashing it. - const logZip = - logCache.get(logs_url) ?? (await api.fetchWorkflowRunJobLogs(logs_url)) - if (logZip === null) { - mappedCheckRuns.push(cr) - continue - } - - logCache.set(logs_url, logZip) - mappedCheckRuns.push({ ...cr, htmlUrl: matchingJob.html_url, - actionJobSteps: await parseJobStepLogs(logZip, matchingJob), + actionJobSteps: matchingJob.steps, }) } @@ -595,7 +538,7 @@ function mapActionWorkflowsRunsToCheckRuns( /** * Gets the duration of a check run or job step formatted in minutes and - * seconds. + * seconds with short notation (e.g. 1m 30s) */ export function getFormattedCheckRunDuration( checkRun: IAPIRefCheckRun | IAPIWorkflowJobStep @@ -604,6 +547,17 @@ export function getFormattedCheckRunDuration( return isNaN(duration) ? '' : formatPreciseDuration(duration) } +/** + * Gets the duration of a check run or job step formatted in minutes and + * seconds with long notation (e.g. 1 minute 30 seconds) + */ +export function getFormattedCheckRunLongDuration( + checkRun: IAPIRefCheckRun | IAPIWorkflowJobStep +) { + const duration = getCheckDurationInMilliseconds(checkRun) + return isNaN(duration) ? '' : formatLongPreciseDuration(duration) +} + /** * Generates the URL pointing to the details of a given check run. If that check * run has no specific URL, returns the URL of the associated pull request. diff --git a/app/src/lib/cli-action.ts b/app/src/lib/cli-action.ts new file mode 100644 index 00000000000..2e0072b421c --- /dev/null +++ b/app/src/lib/cli-action.ts @@ -0,0 +1,10 @@ +export type CLIAction = + | { + readonly kind: 'open-repository' + readonly path: string + } + | { + readonly kind: 'clone-url' + readonly url: string + readonly branch?: string + } diff --git a/app/src/lib/commit-url.ts b/app/src/lib/commit-url.ts new file mode 100644 index 00000000000..8dd293c8be2 --- /dev/null +++ b/app/src/lib/commit-url.ts @@ -0,0 +1,24 @@ +import * as crypto from 'crypto' +import { GitHubRepository } from '../models/github-repository' + +/** Method to create the url for viewing a commit on dotcom */ +export function createCommitURL( + gitHubRepository: GitHubRepository, + SHA: string, + filePath?: string +): string | null { + const baseURL = gitHubRepository.htmlURL + + if (baseURL === null) { + return null + } + + if (filePath === undefined) { + return `${baseURL}/commit/${SHA}` + } + + const fileHash = crypto.createHash('sha256').update(filePath).digest('hex') + const fileSuffix = '#diff-' + fileHash + + return `${baseURL}/commit/${SHA}${fileSuffix}` +} diff --git a/app/src/lib/create-terminal-stream.ts b/app/src/lib/create-terminal-stream.ts new file mode 100644 index 00000000000..557a2475e88 --- /dev/null +++ b/app/src/lib/create-terminal-stream.ts @@ -0,0 +1,118 @@ +import { Transform } from 'node:stream' + +/** + * Creates a transform stream which removes redundant Git progress output by + * handling carriage returns the same way a terminal would, i.e by + * moving the cursor and (potentially) overwriting text. + * + * Git (and many other CLI tools) use this trick to present the + * user with nice looking progress. When writing something like... + * + * 'Downloading: 1% \r' + * 'Downloading: 2% \r' + * + * ...to the terminal the user is gonna perceive it as if the 1 just + * magically changes to a two. + * + * The carriage return character for all of you kids out there + * that haven't yet played with a manual typewriter refers to the + * "carriage" which held the character arms, see + * + * https://en.wikipedia.org/wiki/Carriage_return#Typewriters + */ +export const createTerminalStream = () => { + // The virtual line buffer, think of this as one long line (1 KiB) in a + // terminal where `l` is the farthest we've written in that line and `p` is + // the current cursor position, i.e. where we'll write the next characters + let buf: Buffer, l: number, p: number + + function reset() { + buf = Buffer.alloc(1024) + p = l = 0 + } + + reset() + + return new Transform({ + decodeStrings: true, + transform(chunk: Buffer, _, callback) { + let i = 0 + let next, cr, lf + + while (i < chunk.length) { + cr = chunk.indexOf(0x0d, i) + + if (cr === -1) { + // Happy path, there's no carriage return so we can jump to the last + // linefeed. Significant performance boost for streams without CRs. + lf = chunk.subarray(i).lastIndexOf(0x0a) + lf = lf === -1 ? -1 : lf + i + } else { + // Slow path, we need to look for the next linefeed to see if it comes + // before or after the carriage return. + lf = chunk.indexOf(0x0a, i) + } + + // The next LF, CR, or the last index if we don't find either + next = Math.min( + cr === -1 ? chunk.length - 1 : cr, + lf === -1 ? chunk.length - 1 : lf + ) + next = next === -1 ? chunk.length - 1 : next + + let sliceLength + let start = i + const end = next + 1 + + // Take the chunk and copy it into the buffer, if we can't fit it + while ((sliceLength = end - start) > 0) { + // Writing the chunk from the current cursor position will overflow + // the "line" (buf). When this happens in a terminal the line will + // wrap and the cursor will be moved to the next line. We simulate + // this by pushing our current "line" (if any) and the chunk + if (p + sliceLength > buf.length) { + // It's possible that our cursor has just been reset to 0, in that + // case we don't want to push because the chunk will "overwrite" + // the content in our buf. + if (p > 0) { + this.push(buf.subarray(0, p)) + } + + // Push at most however many bytes is left on the "line" + const remaining = buf.length - p + this.push(chunk.subarray(start, start + remaining)) + start += remaining + reset() + } else { + // We can fit the entire chunk into the buffer, so just copy it + chunk.copy(buf, p, start) + p += sliceLength + // We may have written over only parts of the previous line + // contents, for example, with this input: + // 1. "foo bar\r" + // 2. "baz" + // the buffer should contain "baz bar" + l = Math.max(p, l) + break + } + } + + if (chunk[next] === 0x0a /* \n */ && l > 0) { + // We found a line feed; push the current "line" and reset + this.push(buf.subarray(0, l)) + reset() + } else if (chunk[next] === 0x0d /* \r */) { + // We found a carriage return, reset the cursor + p = 0 + } + + i = next + 1 + } + + callback() + }, + flush(callback) { + callback(null, l > 0 ? buf.subarray(0, l) : null) + }, + }) +} diff --git a/app/src/lib/custom-integration.ts b/app/src/lib/custom-integration.ts new file mode 100644 index 00000000000..116ac44397d --- /dev/null +++ b/app/src/lib/custom-integration.ts @@ -0,0 +1,197 @@ +import { ChildProcess, SpawnOptions, spawn } from 'child_process' +import { parseCommandLineArgv } from 'windows-argv-parser' +import stringArgv from 'string-argv' +import { promisify } from 'util' +import { exec } from 'child_process' +import { access, lstat } from 'fs/promises' +import * as fs from 'fs' + +const execAsync = promisify(exec) + +/** The string that will be replaced by the target path in the custom integration arguments */ +export const TargetPathArgument = '%TARGET_PATH%' + +/** The interface representing a custom integration (external editor or shell) */ +export interface ICustomIntegration { + /** The path to the custom integration */ + readonly path: string + /** The arguments to pass to the custom integration */ + readonly arguments: string + /** The bundle ID of the custom integration (macOS only) */ + readonly bundleID?: string +} + +/** + * Parse the arguments string of a custom integration into an array of strings. + * + * @param args The arguments string to parse + */ +export function parseCustomIntegrationArguments( + args: string +): ReadonlyArray { + return __WIN32__ ? parseCommandLineArgv(args) : stringArgv(args) +} + +// Function to retrieve, on macOS, the bundleId of an app given its path +async function getAppBundleID(path: string) { + try { + // Ensure the path ends with `.app` for applications + if (!path.endsWith('.app')) { + throw new Error( + 'The provided path does not point to a macOS application.' + ) + } + + // Use mdls to query the kMDItemCFBundleIdentifier attribute + const { stdout } = await execAsync( + `mdls -name kMDItemCFBundleIdentifier -raw "${path}"` + ) + const bundleId = stdout.trim() + + // Check for valid output + if (!bundleId || bundleId === '(null)') { + return undefined + } + + return bundleId + } catch (error) { + console.error('Failed to retrieve bundle ID:', error) + return undefined + } +} + +/** + * Replace the target path placeholder in the custom integration arguments with + * the actual target path. + * + * @param args The custom integration arguments + * @param repoPath The target path to replace the placeholder with + */ +export function expandTargetPathArgument( + args: ReadonlyArray, + repoPath: string +): ReadonlyArray { + return args.map(arg => arg.replaceAll(TargetPathArgument, repoPath)) +} + +/** + * Check if the custom integration arguments contain the target path placeholder. + * + * @param args The custom integration arguments + */ +export function checkTargetPathArgument(args: ReadonlyArray): boolean { + return args.some(arg => arg.includes(TargetPathArgument)) +} + +/** + * Validate the path of a custom integration. + * + * @param path The path to the custom integration + * + * @returns An object with a boolean indicating if the path is valid and, if + * the path is a macOS app, the bundle ID of the app. + */ +export async function validateCustomIntegrationPath( + path: string +): Promise<{ isValid: boolean; bundleID?: string }> { + if (path.length === 0) { + return { isValid: false } + } + + let bundleID = undefined + + try { + const pathStat = await lstat(path) + const canBeExecuted = await access(path, fs.constants.X_OK) + .then(() => true) + .catch(() => false) + + const isExecutableFile = + (pathStat.isFile() || pathStat.isSymbolicLink()) && canBeExecuted + + // On macOS, not only executable files are valid, but also apps (which are + // directories with a `.app` extension and from which we can retrieve + // the app bundle ID) + if (__DARWIN__ && !isExecutableFile && pathStat.isDirectory()) { + bundleID = await getAppBundleID(path) + } + + return { isValid: isExecutableFile || !!bundleID, bundleID } + } catch (e) { + log.error(`Failed to validate path: ${path}`, e) + return { isValid: false } + } +} + +/** + * Check if a custom integration is valid (meaning both the path and the + * arguments are valid). + * + * @param customIntegration The custom integration to validate + */ +export async function isValidCustomIntegration( + customIntegration: ICustomIntegration +): Promise { + try { + const pathResult = await validateCustomIntegrationPath( + customIntegration.path + ) + const argv = parseCustomIntegrationArguments(customIntegration.arguments) + const targetPathPresent = checkTargetPathArgument(argv) + return pathResult.isValid && targetPathPresent + } catch (e) { + log.error('Failed to validate custom integration:', e) + return false + } +} + +/** + * Migrates custom integrations stored with the old format (with the arguments + * stored as an array of strings) to the new format (with the arguments stored + * as a single string). + * + * @param customIntegration The custom integration to migrate + * + * @returns The migrated custom integration, or `null` if the custom integration + * is already in the new format. + */ +export function migratedCustomIntegration( + customIntegration: ICustomIntegration | null +): ICustomIntegration | null { + if (customIntegration === null) { + return null + } + + // The first public release of the custom integrations feature stored the + // arguments as an array of strings. This caused some issues because the + // APIs used to parse them and split them into an array would remove any + // quotes. Storing exactly the same string as the user entered and then parse + // it right before invoking the custom integration is a better approach. + if (!Array.isArray(customIntegration.arguments)) { + return null + } + + return { + ...customIntegration, + arguments: customIntegration.arguments.join(' '), + } +} + +/** + * This helper function will use spawn to launch an integration (editor or shell). + * Its main purpose is to do some platform-specific argument handling, for example + * on Windows, where we need to wrap the command and arguments in quotes when + * the shell option is enabled. + * + * @param command Command to spawn + * @param args Arguments to pass to the command + * @param options Options to pass to spawn (optional) + * @returns The ChildProcess object returned by spawn + */ +export function spawnCustomIntegration( + command: string, + args: readonly string[], + options?: SpawnOptions +): ChildProcess { + return options ? spawn(command, args, options) : spawn(command, args) +} diff --git a/app/src/lib/databases/repositories-database.ts b/app/src/lib/databases/repositories-database.ts index 76fbebd833e..2f57ba1df03 100644 --- a/app/src/lib/databases/repositories-database.ts +++ b/app/src/lib/databases/repositories-database.ts @@ -22,7 +22,6 @@ export interface IDatabaseGitHubRepository { readonly name: string readonly private: boolean | null readonly htmlURL: string | null - readonly defaultBranch: string | null readonly cloneURL: string | null /** The database ID of the parent repository if the repository is a fork. */ diff --git a/app/src/lib/diff-parser.ts b/app/src/lib/diff-parser.ts index 7b82932c548..3598ddb065c 100644 --- a/app/src/lib/diff-parser.ts +++ b/app/src/lib/diff-parser.ts @@ -311,7 +311,6 @@ export class DiffParser { let diffLineNumber = linesConsumed while ((c = this.parseLinePrefix(this.peek()))) { const line = this.readLine() - diffLineNumber++ if (!line) { throw new Error('Expected unified diff line but reached end of diff') @@ -338,6 +337,12 @@ export class DiffParser { continue } + // We must increase `diffLineNumber` only when we're certain that the line + // is not a "no newline" marker. Otherwise, we'll end up with a wrong + // `diffLineNumber` for the next line. This could happen if the last line + // in the file doesn't have a newline before the change. + diffLineNumber++ + let diffLine: DiffLine if (c === DiffPrefixAdd) { diff --git a/app/src/lib/editors/darwin.ts b/app/src/lib/editors/darwin.ts index 15dc96f2d1b..dd30b20b661 100644 --- a/app/src/lib/editors/darwin.ts +++ b/app/src/lib/editors/darwin.ts @@ -27,10 +27,62 @@ const editors: IDarwinExternalEditor[] = [ name: 'Aptana Studio', bundleIdentifiers: ['aptana.studio'], }, + { + name: 'Eclipse IDE for Java Developers', + bundleIdentifiers: ['epp.package.java'], + }, + { + name: 'Eclipse IDE for Enterprise Java and Web Developers', + bundleIdentifiers: ['epp.package.jee'], + }, + { + name: 'Eclipse IDE for C/C++ Developers', + bundleIdentifiers: ['epp.package.cpp'], + }, + { + name: 'Eclipse IDE for Eclipse Committers', + bundleIdentifiers: ['epp.package.committers'], + }, + { + name: 'Eclipse IDE for Embedded C/C++ Developers', + bundleIdentifiers: ['epp.package.embedcpp'], + }, + { + name: 'Eclipse IDE for PHP Developers', + bundleIdentifiers: ['epp.package.php'], + }, + { + name: 'Eclipse IDE for Java and DSL Developers', + bundleIdentifiers: ['epp.package.dsl'], + }, + { + name: 'Eclipse IDE for RCP and RAP Developers', + bundleIdentifiers: ['epp.package.rcp'], + }, + { + name: 'Eclipse Modeling Tools', + bundleIdentifiers: ['epp.package.modeling'], + }, + { + name: 'Eclipse IDE for Scientific Computing', + bundleIdentifiers: ['epp.package.parallel'], + }, + { + name: 'Eclipse IDE for Scout Developers', + bundleIdentifiers: ['epp.package.scout'], + }, { name: 'MacVim', bundleIdentifiers: ['org.vim.MacVim'], }, + { + name: 'Neovide', + bundleIdentifiers: ['com.neovide.neovide'], + }, + { + name: 'VimR', + bundleIdentifiers: ['com.qvacua.VimR'], + }, { name: 'Visual Studio Code', bundleIdentifiers: ['com.microsoft.VSCode'], @@ -41,7 +93,7 @@ const editors: IDarwinExternalEditor[] = [ }, { name: 'VSCodium', - bundleIdentifiers: ['com.visualstudio.code.oss'], + bundleIdentifiers: ['com.visualstudio.code.oss', 'com.vscodium'], }, { name: 'Sublime Text', @@ -63,13 +115,25 @@ const editors: IDarwinExternalEditor[] = [ name: 'PyCharm', bundleIdentifiers: ['com.jetbrains.PyCharm'], }, + { + name: 'PyCharm Community Edition', + bundleIdentifiers: ['com.jetbrains.pycharm.ce'], + }, + { + name: 'DataSpell', + bundleIdentifiers: ['com.jetbrains.DataSpell'], + }, { name: 'RubyMine', bundleIdentifiers: ['com.jetbrains.RubyMine'], }, + { + name: 'RustRover', + bundleIdentifiers: ['com.jetbrains.RustRover'], + }, { name: 'RStudio', - bundleIdentifiers: ['org.rstudio.RStudio'], + bundleIdentifiers: ['org.rstudio.RStudio', 'com.rstudio.desktop'], }, { name: 'TextMate', @@ -83,6 +147,10 @@ const editors: IDarwinExternalEditor[] = [ name: 'WebStorm', bundleIdentifiers: ['com.jetbrains.WebStorm'], }, + { + name: 'CLion', + bundleIdentifiers: ['com.jetbrains.CLion'], + }, { name: 'Typora', bundleIdentifiers: ['abnerworks.Typora'], @@ -128,6 +196,34 @@ const editors: IDarwinExternalEditor[] = [ name: 'Nova', bundleIdentifiers: ['com.panic.Nova'], }, + { + name: 'Emacs', + bundleIdentifiers: ['org.gnu.Emacs'], + }, + { + name: 'Lite XL', + bundleIdentifiers: ['com.lite-xl'], + }, + { + name: 'Fleet', + bundleIdentifiers: ['Fleet.app'], + }, + { + name: 'Pulsar', + bundleIdentifiers: ['dev.pulsar-edit.pulsar'], + }, + { + name: 'Zed', + bundleIdentifiers: ['dev.zed.Zed'], + }, + { + name: 'Zed (Preview)', + bundleIdentifiers: ['dev.zed.Zed-Preview'], + }, + { + name: 'Cursor', + bundleIdentifiers: ['com.todesktop.230313mzl4w4u92'], + }, ] async function findApplication( @@ -144,11 +240,7 @@ async function findApplication( : Promise.reject(e) ) - if (installPath === null) { - return null - } - - if (await pathExists(installPath)) { + if (installPath && (await pathExists(installPath))) { return installPath } diff --git a/app/src/lib/editors/found-editor.ts b/app/src/lib/editors/found-editor.ts index a2c7f2bb5ad..f64241749f4 100644 --- a/app/src/lib/editors/found-editor.ts +++ b/app/src/lib/editors/found-editor.ts @@ -1,5 +1,4 @@ export interface IFoundEditor { readonly editor: T readonly path: string - readonly usesShell?: boolean } diff --git a/app/src/lib/editors/launch.ts b/app/src/lib/editors/launch.ts index 63bcfdbcd11..68099797156 100644 --- a/app/src/lib/editors/launch.ts +++ b/app/src/lib/editors/launch.ts @@ -1,6 +1,12 @@ import { spawn, SpawnOptions } from 'child_process' import { pathExists } from '../../ui/lib/path-exists' import { ExternalEditorError, FoundEditor } from './shared' +import { + expandTargetPathArgument, + ICustomIntegration, + parseCustomIntegrationArguments, + spawnCustomIntegration, +} from '../custom-integration' /** * Open a given file or folder in the desired external editor. @@ -14,8 +20,8 @@ export async function launchExternalEditor( ): Promise { const editorPath = editor.path const exists = await pathExists(editorPath) + const label = __DARWIN__ ? 'Settings' : 'Options' if (!exists) { - const label = __DARWIN__ ? 'Preferences' : 'Options' throw new ExternalEditorError( `Could not find executable for '${editor.editor}' at path '${editor.path}'. Please open ${label} and select an available editor.`, { openPreferences: true } @@ -29,13 +35,86 @@ export async function launchExternalEditor( detached: true, } - if (editor.usesShell) { - spawn(`"${editorPath}"`, [`"${fullPath}"`], { ...opts, shell: true }) - } else if (__DARWIN__) { - // In macOS we can use `open`, which will open the right executable file - // for us, we only need the path to the editor .app folder. - spawn('open', ['-a', editorPath, fullPath], opts) - } else { - spawn(editorPath, [fullPath], opts) + try { + if (__DARWIN__) { + // In macOS we can use `open`, which will open the right executable file + // for us, we only need the path to the editor .app folder. + spawn('open', ['-a', editorPath, fullPath], opts) + } else { + spawn(editorPath, [fullPath], opts) + } + } catch (error) { + log.error(`Error while launching ${editor.editor}`, error) + if (error?.code === 'EACCES') { + throw new ExternalEditorError( + `GitHub Desktop doesn't have the proper permissions to start '${editor.editor}'. Please open ${label} and try another editor.`, + { openPreferences: true } + ) + } else { + throw new ExternalEditorError( + `Something went wrong while trying to start '${editor.editor}'. Please open ${label} and try another editor.`, + { openPreferences: true } + ) + } + } +} + +/** + * Open a given file or folder in the desired custom external editor. + * + * @param fullPath A folder or file path to pass as an argument when launching the editor. + * @param customEditor The external editor to launch. + */ +export async function launchCustomExternalEditor( + fullPath: string, + customEditor: ICustomIntegration +): Promise { + const editorPath = customEditor.path + const exists = await pathExists(editorPath) + const label = __DARWIN__ ? 'Settings' : 'Options' + if (!exists) { + throw new ExternalEditorError( + `Could not find executable for custom editor at path '${customEditor.path}'. Please open ${label} and select an available editor.`, + { openPreferences: true } + ) + } + + const opts: SpawnOptions = { + // Make sure the editor processes are detached from the Desktop app. + // Otherwise, some editors (like Notepad++) will be killed when the + // Desktop app is closed. + detached: true, + } + + const argv = parseCustomIntegrationArguments(customEditor.arguments) + + // Replace instances of RepoPathArgument with fullPath in customEditor.arguments + const args = expandTargetPathArgument(argv, fullPath) + + try { + if (__DARWIN__ && customEditor.bundleID) { + // In macOS we can use `open` if it's an app (i.e. if we have a bundleID), + // which will open the right executable file for us, we only need the path + // to the editor .app folder. + spawnCustomIntegration('open', ['-a', editorPath, ...args], opts) + } else { + spawnCustomIntegration(editorPath, args, opts) + } + } catch (error) { + log.error( + `Error while launching custom editor at path ${customEditor.path} with arguments ${args}`, + error + ) + if (error?.code === 'EACCES') { + throw new ExternalEditorError( + `GitHub Desktop doesn't have the proper permissions to start custom editor at path ${customEditor.path}. Please open ${label} and try another editor.`, + { openPreferences: true } + ) + } else { + throw new ExternalEditorError( + `Something went wrong while trying to start custom editor at path ${customEditor.path}. Please open ${label} and try another editor.`, + { openPreferences: true } + ) + } } } diff --git a/app/src/lib/editors/linux.ts b/app/src/lib/editors/linux.ts index 7104b42440c..d3574b46f5b 100644 --- a/app/src/lib/editors/linux.ts +++ b/app/src/lib/editors/linux.ts @@ -23,17 +23,51 @@ const editors: ILinuxExternalEditor[] = [ name: 'Neovim', paths: ['/usr/bin/nvim'], }, + { + name: 'Neovim-Qt', + paths: ['/usr/bin/nvim-qt'], + }, + { + name: 'Neovide', + paths: ['/usr/bin/neovide'], + }, + { + name: 'gVim', + paths: ['/usr/bin/gvim'], + }, { name: 'Visual Studio Code', - paths: ['/usr/share/code/bin/code', '/snap/bin/code', '/usr/bin/code'], + paths: [ + '/usr/share/code/bin/code', + '/snap/bin/code', + '/usr/bin/code', + '/mnt/c/Program Files/Microsoft VS Code/bin/code', + '/var/lib/flatpak/app/com.visualstudio.code/current/active/export/bin/com.visualstudio.code', + '.local/share/flatpak/app/com.visualstudio.code/current/active/export/bin/com.visualstudio.code', + ], }, { name: 'Visual Studio Code (Insiders)', - paths: ['/snap/bin/code-insiders', '/usr/bin/code-insiders'], + paths: [ + '/snap/bin/code-insiders', + '/usr/bin/code-insiders', + '/var/lib/flatpak/app/com.visualstudio.code.insiders/current/active/export/bin/com.visualstudio.code.insiders', + '.local/share/flatpak/app/com.visualstudio.code.insiders/current/active/export/bin/com.visualstudio.code.insiders', + ], }, { name: 'VSCodium', - paths: ['/usr/bin/codium', '/var/lib/flatpak/app/com.vscodium.codium'], + paths: [ + '/usr/bin/codium', + '/var/lib/flatpak/app/com.vscodium.codium/current/active/export/bin/com.vscodium.codium', + '/usr/share/vscodium-bin/bin/codium', + '.local/share/flatpak/app/com.vscodium.codium/current/active/export/bin/com.vscodium.codium', + '/snap/bin/codium', + ], + }, + { + name: 'VSCodium (Insiders)', + paths: ['/usr/bin/codium-insiders'], }, { name: 'Sublime Text', @@ -58,6 +92,125 @@ const editors: ILinuxExternalEditor[] = [ name: 'Code', paths: ['/usr/bin/io.elementary.code'], }, + { + name: 'Lite XL', + paths: ['/usr/bin/lite-xl'], + }, + { + name: 'JetBrains PhpStorm', + paths: [ + '/snap/bin/phpstorm', + '.local/share/JetBrains/Toolbox/scripts/PhpStorm', + ], + }, + { + name: 'JetBrains WebStorm', + paths: [ + '/snap/bin/webstorm', + '.local/share/JetBrains/Toolbox/scripts/webstorm', + ], + }, + { + name: 'IntelliJ IDEA', + paths: ['/snap/bin/idea', '.local/share/JetBrains/Toolbox/scripts/idea'], + }, + { + name: 'IntelliJ IDEA Ultimate Edition', + paths: [ + '/snap/bin/intellij-idea-ultimate', + '.local/share/JetBrains/Toolbox/scripts/intellij-idea-ultimate', + ], + }, + { + name: 'JetBrains Goland', + paths: [ + '/snap/bin/goland', + '.local/share/JetBrains/Toolbox/scripts/goland', + ], + }, + { + name: 'JetBrains CLion', + paths: ['/snap/bin/clion', '.local/share/JetBrains/Toolbox/scripts/clion1'], + }, + { + name: 'JetBrains Rider', + paths: ['/snap/bin/rider', '.local/share/JetBrains/Toolbox/scripts/rider'], + }, + { + name: 'JetBrains RubyMine', + paths: [ + '/snap/bin/rubymine', + '.local/share/JetBrains/Toolbox/scripts/rubymine', + ], + }, + { + name: 'JetBrains PyCharm', + paths: [ + '/snap/bin/pycharm', + '/snap/bin/pycharm-professional', + '.local/share/JetBrains/Toolbox/scripts/pycharm', + ], + }, + { + name: 'JetBrains RustRover', + paths: [ + '/snap/bin/rustrover', + '.local/share/JetBrains/Toolbox/scripts/rustrover', + ], + }, + { + name: 'Android Studio', + paths: [ + '/snap/bin/studio', + '.local/share/JetBrains/Toolbox/scripts/studio', + ], + }, + { + name: 'Emacs', + paths: ['/snap/bin/emacs', '/usr/local/bin/emacs', '/usr/bin/emacs'], + }, + { + name: 'Kate', + paths: ['/usr/bin/kate'], + }, + { + name: 'GEdit', + paths: ['/usr/bin/gedit'], + }, + { + name: 'GNOME Text Editor', + paths: ['/usr/bin/gnome-text-editor'], + }, + { + name: 'GNOME Builder', + paths: ['/usr/bin/gnome-builder'], + }, + { + name: 'Notepadqq', + paths: ['/usr/bin/notepadqq'], + }, + { + name: 'Mousepad', + paths: ['/usr/bin/mousepad'], + }, + { + name: 'Pulsar', + paths: ['/usr/bin/pulsar'], + }, + { + name: 'Pluma', + paths: ['/usr/bin/pluma'], + }, + { + name: 'Zed', + paths: [ + '/usr/bin/zedit', + '/usr/bin/zeditor', + '/usr/bin/zed-editor', + '~/.local/bin/zed', + '/usr/bin/zed', + ], + }, ] async function getAvailablePath(paths: string[]): Promise { diff --git a/app/src/lib/editors/lookup.ts b/app/src/lib/editors/lookup.ts index 95fdf8ce39a..652e51f8b1c 100644 --- a/app/src/lib/editors/lookup.ts +++ b/app/src/lib/editors/lookup.ts @@ -57,7 +57,7 @@ export async function findEditorOrDefault( if (name) { const match = editors.find(p => p.editor === name) || null if (!match) { - const menuItemName = __DARWIN__ ? 'Preferences' : 'Options' + const menuItemName = __DARWIN__ ? 'Settings' : 'Options' const message = `The editor '${name}' could not be found. Please open ${menuItemName} and choose an available editor.` throw new ExternalEditorError(message, { openPreferences: true }) diff --git a/app/src/lib/editors/shared.ts b/app/src/lib/editors/shared.ts index 3375efdddcc..0440cddbe9f 100644 --- a/app/src/lib/editors/shared.ts +++ b/app/src/lib/editors/shared.ts @@ -10,10 +10,6 @@ export type FoundEditor = { * The executable associated with the editor to launch */ path: string - /** - * the editor requires a shell spawn to launch - */ - usesShell?: boolean } interface IErrorMetadata { diff --git a/app/src/lib/editors/win32.ts b/app/src/lib/editors/win32.ts index d24776e5ee3..4df4a5e9ebf 100644 --- a/app/src/lib/editors/win32.ts +++ b/app/src/lib/editors/win32.ts @@ -1,6 +1,7 @@ import * as Path from 'path' import { + enumerateKeys, enumerateValues, HKEY, RegistryValue, @@ -9,6 +10,7 @@ import { import { pathExists } from '../../ui/lib/path-exists' import { IFoundEditor } from './found-editor' +import memoizeOne from 'memoize-one' interface IWindowsAppInformation { displayName: string @@ -56,10 +58,10 @@ type WindowsExternalEditor = { readonly registryKeys: ReadonlyArray /** Prefix of the DisplayName registry key that belongs to this editor. */ - readonly displayNamePrefix: string + readonly displayNamePrefixes: string[] /** Value of the Publisher registry key that belongs to this editor. */ - readonly publisher: string + readonly publishers: string[] } & WindowsExternalEditorPathInfo const registryKey = (key: HKEY, ...subKeys: string[]): RegistryKey => ({ @@ -130,32 +132,32 @@ const executableShimPathsForJetBrainsIDE = ( ] } +// Function to allow for validating a string against the start of strings +// in an array. Used for validating publisher and display name +const validateStartsWith = ( + registryVal: string, + definedVal: string[] +): boolean => { + return definedVal.some(subString => registryVal.startsWith(subString)) +} + +/** + * Handles cases where the value includes: + * - An icon index after a comma (e.g., "C:\Path\app.exe,0") + * - Surrounding quotes (e.g., ""C:\Path\app.exe",0") + * and returns only the path to the executable. + */ +const getCleanInstallLocationFromDisplayIcon = ( + displayIconValue: string +): string => { + return displayIconValue.split(',')[0].replace(/"/g, '') +} + /** * This list contains all the external editors supported on Windows. Add a new * entry here to add support for your favorite editor. **/ const editors: WindowsExternalEditor[] = [ - { - name: 'Atom', - registryKeys: [CurrentUserUninstallKey('atom')], - executableShimPaths: [['bin', 'atom.cmd']], - displayNamePrefix: 'Atom', - publisher: 'GitHub Inc.', - }, - { - name: 'Atom Beta', - registryKeys: [CurrentUserUninstallKey('atom-beta')], - executableShimPaths: [['bin', 'atom-beta.cmd']], - displayNamePrefix: 'Atom Beta', - publisher: 'GitHub Inc.', - }, - { - name: 'Atom Nightly', - registryKeys: [CurrentUserUninstallKey('atom-nightly')], - executableShimPaths: [['bin', 'atom-nightly.cmd']], - displayNamePrefix: 'Atom Nightly', - publisher: 'GitHub Inc.', - }, { name: 'Visual Studio Code', registryKeys: [ @@ -174,9 +176,9 @@ const editors: WindowsExternalEditor[] = [ // ARM64 version of VSCode (system) LocalMachineUninstallKey('{A5270FC5-65AD-483E-AC30-2C276B63D0AC}_is1'), ], - executableShimPaths: [['bin', 'code.cmd']], - displayNamePrefix: 'Microsoft Visual Studio Code', - publisher: 'Microsoft Corporation', + executableShimPaths: [['code.exe']], + displayNamePrefixes: ['Microsoft Visual Studio Code'], + publishers: ['Microsoft Corporation'], }, { name: 'Visual Studio Code (Insiders)', @@ -196,31 +198,65 @@ const editors: WindowsExternalEditor[] = [ // ARM64 version of VSCode (system) LocalMachineUninstallKey('{0AEDB616-9614-463B-97D7-119DD86CCA64}_is1'), ], - executableShimPaths: [['bin', 'code-insiders.cmd']], - displayNamePrefix: 'Microsoft Visual Studio Code Insiders', - publisher: 'Microsoft Corporation', + executableShimPaths: [['Code - Insiders.exe']], + displayNamePrefixes: ['Microsoft Visual Studio Code Insiders'], + publishers: ['Microsoft Corporation'], }, { - name: 'Visual Studio Codium', + name: 'VSCodium', registryKeys: [ // 64-bit version of VSCodium (user) CurrentUserUninstallKey('{2E1F05D1-C245-4562-81EE-28188DB6FD17}_is1'), - // 32-bit version of VSCodium (user) + // 32-bit version of VSCodium (user) - new key + CurrentUserUninstallKey('{0FD05EB4-651E-4E78-A062-515204B47A3A}_is1'), + // ARM64 version of VSCodium (user) - new key + CurrentUserUninstallKey('{57FD70A5-1B8D-4875-9F40-C5553F094828}_is1'), + // 64-bit version of VSCodium (system) - new key + LocalMachineUninstallKey('{88DA3577-054F-4CA1-8122-7D820494CFFB}_is1'), + // 32-bit version of VSCodium (system) - new key + Wow64LocalMachineUninstallKey( + '{763CBF88-25C6-4B10-952F-326AE657F16B}_is1' + ), + // ARM64 version of VSCodium (system) - new key + LocalMachineUninstallKey('{67DEE444-3D04-4258-B92A-BC1F0FF2CAE4}_is1'), + // 32-bit version of VSCodium (user) - old key CurrentUserUninstallKey('{C6065F05-9603-4FC4-8101-B9781A25D88E}}_is1'), - // ARM64 version of VSCodium (user) + // ARM64 version of VSCodium (user) - old key CurrentUserUninstallKey('{3AEBF0C8-F733-4AD4-BADE-FDB816D53D7B}_is1'), - // 64-bit version of VSCodium (system) + // 64-bit version of VSCodium (system) - old key LocalMachineUninstallKey('{D77B7E06-80BA-4137-BCF4-654B95CCEBC5}_is1'), - // 32-bit version of VSCodium (system) + // 32-bit version of VSCodium (system) - old key Wow64LocalMachineUninstallKey( '{E34003BB-9E10-4501-8C11-BE3FAA83F23F}_is1' ), - // ARM64 version of VSCodium (system) + // ARM64 version of VSCodium (system) - old key LocalMachineUninstallKey('{D1ACE434-89C5-48D1-88D3-E2991DF85475}_is1'), ], - executableShimPaths: [['bin', 'codium.cmd']], - displayNamePrefix: 'VSCodium', - publisher: 'Microsoft Corporation', + executableShimPaths: [['VSCodium.exe']], + displayNamePrefixes: ['VSCodium'], + publishers: ['VSCodium', 'Microsoft Corporation'], + }, + { + name: 'VSCodium (Insiders)', + registryKeys: [ + // 64-bit version of VSCodium - Insiders (user) + CurrentUserUninstallKey('{20F79D0D-A9AC-4220-9A81-CE675FFB6B41}_is1'), + // 32-bit version of VSCodium - Insiders (user) + CurrentUserUninstallKey('{ED2E5618-3E7E-4888-BF3C-A6CCC84F586F}_is1'), + // ARM64 version of VSCodium - Insiders (user) + CurrentUserUninstallKey('{2E362F92-14EA-455A-9ABD-3E656BBBFE71}_is1'), + // 64-bit version of VSCodium - Insiders (system) + LocalMachineUninstallKey('{B2E0DDB2-120E-4D34-9F7E-8C688FF839A2}_is1'), + // 32-bit version of VSCodium - Insiders (system) + Wow64LocalMachineUninstallKey( + '{EF35BB36-FA7E-4BB9-B7DA-D1E09F2DA9C9}_is1' + ), + // ARM64 version of VSCodium - Insiders (system) + LocalMachineUninstallKey('{44721278-64C6-4513-BC45-D48E07830599}_is1'), + ], + executableShimPaths: [['VSCodium - Insiders.exe']], + displayNamePrefixes: ['VSCodium Insiders', 'VSCodium (Insiders)'], + publishers: ['VSCodium'], }, { name: 'Sublime Text', @@ -231,8 +267,8 @@ const editors: WindowsExternalEditor[] = [ LocalMachineUninstallKey('Sublime Text 3_is1'), ], executableShimPaths: [['subl.exe']], - displayNamePrefix: 'Sublime Text', - publisher: 'Sublime HQ Pty Ltd', + displayNamePrefixes: ['Sublime Text'], + publishers: ['Sublime HQ Pty Ltd'], }, { name: 'Brackets', @@ -240,8 +276,8 @@ const editors: WindowsExternalEditor[] = [ Wow64LocalMachineUninstallKey('{4F3B6E8C-401B-4EDE-A423-6481C239D6FF}'), ], executableShimPaths: [['Brackets.exe']], - displayNamePrefix: 'Brackets', - publisher: 'brackets.io', + displayNamePrefixes: ['Brackets'], + publishers: ['brackets.io'], }, { name: 'ColdFusion Builder', @@ -252,8 +288,8 @@ const editors: WindowsExternalEditor[] = [ LocalMachineUninstallKey('Adobe ColdFusion Builder 2016'), ], executableShimPaths: [['CFBuilder.exe']], - displayNamePrefix: 'Adobe ColdFusion Builder', - publisher: 'Adobe Systems Incorporated', + displayNamePrefixes: ['Adobe ColdFusion Builder'], + publishers: ['Adobe Systems Incorporated'], }, { name: 'Typora', @@ -266,8 +302,8 @@ const editors: WindowsExternalEditor[] = [ ), ], executableShimPaths: [['typora.exe']], - displayNamePrefix: 'Typora', - publisher: 'typora.io', + displayNamePrefixes: ['Typora'], + publishers: ['typora.io'], }, { name: 'SlickEdit', @@ -296,8 +332,8 @@ const editors: WindowsExternalEditor[] = [ LocalMachineUninstallKey('{7CC0E567-ACD6-41E8-95DA-154CEEDB0A18}'), ], executableShimPaths: [['win', 'vs.exe']], - displayNamePrefix: 'SlickEdit', - publisher: 'SlickEdit Inc.', + displayNamePrefixes: ['SlickEdit'], + publishers: ['SlickEdit Inc.'], }, { name: 'Aptana Studio 3', @@ -305,22 +341,22 @@ const editors: WindowsExternalEditor[] = [ Wow64LocalMachineUninstallKey('{2D6C1116-78C6-469C-9923-3E549218773F}'), ], executableShimPaths: [['AptanaStudio3.exe']], - displayNamePrefix: 'Aptana Studio', - publisher: 'Appcelerator', + displayNamePrefixes: ['Aptana Studio'], + publishers: ['Appcelerator'], }, { name: 'JetBrains Webstorm', registryKeys: registryKeysForJetBrainsIDE('WebStorm'), executableShimPaths: executableShimPathsForJetBrainsIDE('webstorm'), - displayNamePrefix: 'WebStorm', - publisher: 'JetBrains s.r.o.', + displayNamePrefixes: ['WebStorm'], + publishers: ['JetBrains s.r.o.'], }, { - name: 'JetBrains Phpstorm', + name: 'JetBrains PhpStorm', registryKeys: registryKeysForJetBrainsIDE('PhpStorm'), executableShimPaths: executableShimPathsForJetBrainsIDE('phpstorm'), - displayNamePrefix: 'PhpStorm', - publisher: 'JetBrains s.r.o.', + displayNamePrefixes: ['PhpStorm'], + publishers: ['JetBrains s.r.o.'], }, { name: 'Android Studio', @@ -330,8 +366,8 @@ const editors: WindowsExternalEditor[] = [ ['..', 'bin', `studio64.exe`], ['..', 'bin', `studio.exe`], ], - displayNamePrefix: 'Android Studio', - publisher: 'Google LLC', + displayNamePrefixes: ['Android Studio'], + publishers: ['Google LLC'], }, { name: 'Notepad++', @@ -342,29 +378,29 @@ const editors: WindowsExternalEditor[] = [ Wow64LocalMachineUninstallKey('Notepad++'), ], installLocationRegistryKey: 'DisplayIcon', - displayNamePrefix: 'Notepad++', - publisher: 'Notepad++ Team', + displayNamePrefixes: ['Notepad++'], + publishers: ['Notepad++ Team'], }, { name: 'JetBrains Rider', registryKeys: registryKeysForJetBrainsIDE('JetBrains Rider'), executableShimPaths: executableShimPathsForJetBrainsIDE('rider'), - displayNamePrefix: 'JetBrains Rider', - publisher: 'JetBrains s.r.o.', + displayNamePrefixes: ['JetBrains Rider'], + publishers: ['JetBrains s.r.o.'], }, { name: 'RStudio', registryKeys: [Wow64LocalMachineUninstallKey('RStudio')], installLocationRegistryKey: 'DisplayIcon', - displayNamePrefix: 'RStudio', - publisher: 'RStudio', + displayNamePrefixes: ['RStudio'], + publishers: ['RStudio', 'Posit Software'], }, { name: 'JetBrains IntelliJ Idea', registryKeys: registryKeysForJetBrainsIDE('IntelliJ IDEA'), executableShimPaths: executableShimPathsForJetBrainsIDE('idea'), - displayNamePrefix: 'IntelliJ IDEA ', - publisher: 'JetBrains s.r.o.', + displayNamePrefixes: ['IntelliJ IDEA '], + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains IntelliJ Idea Community Edition', @@ -372,43 +408,83 @@ const editors: WindowsExternalEditor[] = [ 'IntelliJ IDEA Community Edition' ), executableShimPaths: executableShimPathsForJetBrainsIDE('idea'), - displayNamePrefix: 'IntelliJ IDEA Community Edition ', - publisher: 'JetBrains s.r.o.', + displayNamePrefixes: ['IntelliJ IDEA Community Edition '], + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains PyCharm', registryKeys: registryKeysForJetBrainsIDE('PyCharm'), executableShimPaths: executableShimPathsForJetBrainsIDE('pycharm'), - displayNamePrefix: 'PyCharm ', - publisher: 'JetBrains s.r.o.', + displayNamePrefixes: ['PyCharm '], + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains PyCharm Community Edition', registryKeys: registryKeysForJetBrainsIDE('PyCharm Community Edition'), executableShimPaths: executableShimPathsForJetBrainsIDE('pycharm'), - displayNamePrefix: 'PyCharm Community Edition', - publisher: 'JetBrains s.r.o.', + displayNamePrefixes: ['PyCharm Community Edition'], + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains CLion', registryKeys: registryKeysForJetBrainsIDE('CLion'), executableShimPaths: executableShimPathsForJetBrainsIDE('clion'), - displayNamePrefix: 'CLion ', - publisher: 'JetBrains s.r.o.', + displayNamePrefixes: ['CLion '], + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains RubyMine', registryKeys: registryKeysForJetBrainsIDE('RubyMine'), executableShimPaths: executableShimPathsForJetBrainsIDE('rubymine'), - displayNamePrefix: 'RubyMine ', - publisher: 'JetBrains s.r.o.', + displayNamePrefixes: ['RubyMine '], + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains GoLand', registryKeys: registryKeysForJetBrainsIDE('GoLand'), executableShimPaths: executableShimPathsForJetBrainsIDE('goland'), - displayNamePrefix: 'GoLand ', - publisher: 'JetBrains s.r.o.', + displayNamePrefixes: ['GoLand '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains Fleet', + registryKeys: [LocalMachineUninstallKey('Fleet')], + installLocationRegistryKey: 'DisplayIcon', + displayNamePrefixes: ['Fleet '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains DataSpell', + registryKeys: registryKeysForJetBrainsIDE('DataSpell'), + executableShimPaths: executableShimPathsForJetBrainsIDE('dataspell'), + displayNamePrefixes: ['DataSpell '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains RustRover', + registryKeys: registryKeysForJetBrainsIDE('RustRover'), + executableShimPaths: executableShimPathsForJetBrainsIDE('rustrover'), + displayNamePrefixes: ['RustRover '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'Pulsar', + registryKeys: [ + CurrentUserUninstallKey('0949b555-c22c-56b7-873a-a960bdefa81f'), + LocalMachineUninstallKey('0949b555-c22c-56b7-873a-a960bdefa81f'), + ], + executableShimPaths: [['..', 'pulsar', 'Pulsar.exe']], + displayNamePrefixes: ['Pulsar'], + publishers: ['Pulsar-Edit'], + }, + { + name: 'Cursor', + registryKeys: [ + CurrentUserUninstallKey('62625861-8486-5be9-9e46-1da50df5f8ff'), + ], + installLocationRegistryKey: 'DisplayIcon', + displayNamePrefixes: ['Cursor'], + publishers: ['Cursor AI, Inc.'], }, ] @@ -443,8 +519,8 @@ async function findApplication(editor: WindowsExternalEditor) { const { displayName, publisher, installLocation } = getAppInfo(editor, keys) if ( - !displayName.startsWith(editor.displayNamePrefix) || - publisher !== editor.publisher + !validateStartsWith(displayName, editor.displayNamePrefixes) || + !editor.publishers.includes(publisher) ) { log.debug(`Unexpected registry entries for ${editor.name}`) continue @@ -452,7 +528,7 @@ async function findApplication(editor: WindowsExternalEditor) { const executableShimPaths = editor.installLocationRegistryKey === 'DisplayIcon' - ? [installLocation] + ? [getCleanInstallLocationFromDisplayIcon(installLocation)] : editor.executableShimPaths.map(p => Path.join(installLocation, ...p)) for (const path of executableShimPaths) { @@ -465,9 +541,37 @@ async function findApplication(editor: WindowsExternalEditor) { } } - return null + return undefined } +const getJetBrainsToolboxEditors = memoizeOne(async () => { + const re = /^JetBrains Toolbox \((.*)\)/ + const editors = new Array() + + for (const parent of [uninstallSubKey, wow64UninstallSubKey]) { + for (const key of enumerateKeys(HKEY.HKEY_CURRENT_USER, parent)) { + const m = re.exec(key) + if (m) { + const [name, product] = m + editors.push({ + name, + installLocationRegistryKey: 'DisplayIcon', + registryKeys: [ + { + key: HKEY.HKEY_CURRENT_USER, + subKey: `${parent}\\${key}`, + }, + ], + displayNamePrefixes: [product], + publishers: ['JetBrains s.r.o.'], + }) + } + } + } + + return editors +}) + /** * Lookup known external editors using the Windows registry to find installed * applications and their location on disk for Desktop to launch. @@ -476,16 +580,19 @@ export async function getAvailableEditors(): Promise< ReadonlyArray> > { const results: Array> = [] + const candidates = [ + ...editors, + ...(await getJetBrainsToolboxEditors().catch(e => { + log.error(`Failed resolving JetBrains Toolbox products`, e) + return [] + })), + ] - for (const editor of editors) { + for (const editor of candidates) { const path = await findApplication(editor) if (path) { - results.push({ - editor: editor.name, - path, - usesShell: path.endsWith('.cmd'), - }) + results.push({ editor: editor.name, path }) } } diff --git a/app/src/lib/email.ts b/app/src/lib/email.ts index 2229a4709e2..565a0825623 100644 --- a/app/src/lib/email.ts +++ b/app/src/lib/email.ts @@ -1,7 +1,6 @@ -import * as URL from 'url' - -import { IAPIEmail, getDotComAPIEndpoint } from './api' +import { IAPIEmail } from './api' import { Account } from '../models/account' +import { isGHES } from './endpoint-capabilities' /** * Lookup a suitable email address to display in the application, based on the @@ -53,11 +52,10 @@ function isEmailPublic(email: IAPIEmail): boolean { * email host is hardcoded to the subdomain users.noreply under the * endpoint host. */ -function getStealthEmailHostForEndpoint(endpoint: string) { - return getDotComAPIEndpoint() !== endpoint - ? `users.noreply.${URL.parse(endpoint).hostname}` +const getStealthEmailHostForEndpoint = (endpoint: string) => + isGHES(endpoint) + ? `users.noreply.${new URL(endpoint).hostname}` : 'users.noreply.github.com' -} /** * Generate a legacy stealth email address for the user @@ -122,3 +120,24 @@ export const isAttributableEmailFor = (account: Account, email: string) => { getLegacyStealthEmailForUser(login, endpoint).toLowerCase() === needle ) } + +/** + * A regular expression meant to match both the legacy format GitHub.com + * stealth email address and the modern format (login@ vs id+login@). + * + * Yields two capture groups, the first being an optional capture of the + * user id and the second being the mandatory login. + */ +const StealthEmailRegexp = /^(?:(\d+)\+)?(.+?)@(users\.noreply\..+)$/i + +export const parseStealthEmail = (email: string, endpoint: string) => { + const stealthEmailHost = getStealthEmailHostForEndpoint(endpoint) + const match = StealthEmailRegexp.exec(email) + + if (!match || stealthEmailHost !== match[3]) { + return null + } + + const [, id, login] = match + return { id: id ? parseInt(id, 10) : undefined, login } +} diff --git a/app/src/lib/emoji.ts b/app/src/lib/emoji.ts new file mode 100644 index 00000000000..3c2f02454f1 --- /dev/null +++ b/app/src/lib/emoji.ts @@ -0,0 +1,18 @@ +/** Represents an emoji */ +export type Emoji = { + /** + * The unicode string of the emoji if emoji is part of + * the unicode specification. If missing this emoji is + * a GitHub custom emoji such as :shipit: + */ + readonly emoji?: string + + /** URL of the image of the emoji (alternative to the unicode character) */ + readonly url: string + + /** One or more human readable aliases for the emoji character */ + readonly aliases: ReadonlyArray + + /** An optional, human readable, description of the emoji */ + readonly description?: string +} diff --git a/app/src/lib/endpoint-capabilities.ts b/app/src/lib/endpoint-capabilities.ts index c929e7530c2..b39d0372a7e 100644 --- a/app/src/lib/endpoint-capabilities.ts +++ b/app/src/lib/endpoint-capabilities.ts @@ -3,19 +3,22 @@ import { getDotComAPIEndpoint } from './api' import { assertNonNullable } from './fatal-error' export type VersionConstraint = { - /** Whether this constrain will be satisfied when using GitHub.com */ - dotcom: boolean /** - * Whether this constrain will be satisfied when using GitHub AE - * Supports specifying a version constraint as a SemVer Range (ex: >= 3.1.0) + * Whether this constrain will be satisfied when using GitHub.com, defaults + * to false + **/ + dotcom?: boolean + /** + * Whether this constrain will be satisfied when using ghe.com, defaults to + * the value of `dotcom` if not specified */ - ae: boolean | string + ghe?: boolean /** * Whether this constrain will be satisfied when using GitHub Enterprise * Server. Supports specifying a version constraint as a SemVer Range (ex: >= - * 3.1.0) + * 3.1.0), defaults to false */ - es: boolean | string + es?: boolean | string } /** @@ -29,16 +32,6 @@ export type VersionConstraint = { */ const assumedGHESVersion = new semver.SemVer('3.1.0') -/** - * If we're connected to a GHAE instance we won't know its version number - * since it doesn't report that so we'll use this substitute GHES equivalent - * version number. - * - * This should correspond loosely with the most recent GHES series and - * needs to be updated manually. - */ -const assumedGHAEVersion = new semver.SemVer('3.2.0') - /** Stores raw x-github-enterprise-version headers keyed on endpoint */ const rawVersionCache = new Map() @@ -49,29 +42,32 @@ const versionCache = new Map() const endpointVersionKey = (ep: string) => `endpoint-version:${ep}` /** - * Whether or not the given endpoint URI matches GitHub.com's - * - * I.e. https://api.github.com/ - * - * Most often used to check if an endpoint _isn't_ GitHub.com meaning it's - * either GitHub Enterprise Server or GitHub AE + * Whether or not the given endpoint belong's to GitHub.com */ -export const isDotCom = (ep: string) => ep === getDotComAPIEndpoint() +export const isDotCom = (ep: string) => { + if (ep === getDotComAPIEndpoint()) { + return true + } -/** - * Whether or not the given endpoint URI appears to point to a GitHub AE - * instance - */ -export const isGHAE = (ep: string) => - /^https:\/\/[a-z0-9-]+\.ghe\.com$/i.test(ep) + const { hostname } = new URL(ep) + return hostname === 'api.github.com' || hostname === 'github.com' +} + +export const isGist = (ep: string) => { + const { hostname } = new URL(ep) + return hostname === 'gist.github.com' || hostname === 'gist.ghe.io' +} + +/** Whether or not the given endpoint URI is under the ghe.com domain */ +export const isGHE = (ep: string) => new URL(ep).hostname.endsWith('.ghe.com') /** * Whether or not the given endpoint URI appears to point to a GitHub Enterprise * Server instance */ -export const isGHES = (ep: string) => !isDotCom(ep) && !isGHAE(ep) +export const isGHES = (ep: string) => !isDotCom(ep) && !isGHE(ep) -function getEndpointVersion(endpoint: string) { +export function getEndpointVersion(endpoint: string) { const key = endpointVersionKey(endpoint) const cached = versionCache.get(key) @@ -104,12 +100,12 @@ export function updateEndpointVersion(endpoint: string, version: string) { } function checkConstraint( - epConstraint: string | boolean, + epConstraint: string | boolean | undefined, epMatchesType: boolean, epVersion?: semver.SemVer ) { // Denial of endpoint type regardless of version - if (epConstraint === false) { + if (epConstraint === undefined || epConstraint === false) { return false } @@ -131,32 +127,25 @@ function checkConstraint( * Consumers should use the various `supports*` methods instead. */ export const endpointSatisfies = - ({ dotcom, ae, es }: VersionConstraint, getVersion = getEndpointVersion) => + ({ dotcom, ghe, es }: VersionConstraint, getVersion = getEndpointVersion) => (ep: string) => checkConstraint(dotcom, isDotCom(ep)) || - checkConstraint(ae, isGHAE(ep), assumedGHAEVersion) || + checkConstraint(ghe ?? dotcom, isGHE(ep)) || checkConstraint(es, isGHES(ep), getVersion(ep) ?? assumedGHESVersion) /** * Whether or not the endpoint supports the internal GitHub Enterprise Server * avatars API */ -export const supportsAvatarsAPI = endpointSatisfies({ - dotcom: false, - ae: '>= 3.0.0', - es: '>= 3.0.0', -}) +export const supportsAvatarsAPI = endpointSatisfies({ es: '>= 3.0.0' }) export const supportsRerunningChecks = endpointSatisfies({ dotcom: true, - ae: '>= 3.4.0', es: '>= 3.4.0', }) export const supportsRerunningIndividualOrFailedChecks = endpointSatisfies({ dotcom: true, - ae: false, - es: false, }) /** @@ -165,12 +154,8 @@ export const supportsRerunningIndividualOrFailedChecks = endpointSatisfies({ */ export const supportsRetrieveActionWorkflowByCheckSuiteId = endpointSatisfies({ dotcom: true, - ae: false, - es: false, }) -export const supportsAliveSessions = endpointSatisfies({ - dotcom: true, - ae: false, - es: false, -}) +export const supportsAliveSessions = endpointSatisfies({ dotcom: true }) + +export const supportsRepoRules = endpointSatisfies({ dotcom: true }) diff --git a/app/src/lib/enterprise.ts b/app/src/lib/enterprise.ts deleted file mode 100644 index 8390cd6e0c4..00000000000 --- a/app/src/lib/enterprise.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * The oldest officially supported version of GitHub Enterprise. - * This information is used in user-facing text and shouldn't be - * considered a hard limit, i.e. older versions of GitHub Enterprise - * might (and probably do) work just fine but this should be a fairly - * recent version that we can safely say that we'll work well with. - */ -export const minimumSupportedEnterpriseVersion = '3.0.0' diff --git a/app/src/lib/fatal-error.ts b/app/src/lib/fatal-error.ts index 4013b5d8079..72f49755206 100644 --- a/app/src/lib/fatal-error.ts +++ b/app/src/lib/fatal-error.ts @@ -14,7 +14,6 @@ export function fatalError(msg: string): never { * in an exhaustive check. * * @param message The message to be used in the runtime exception. - * */ export function assertNever(x: never, message: string): never { throw new Error(message) diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index 67d44dacb1b..5bc16eda80b 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -28,9 +28,20 @@ function enableBetaFeatures(): boolean { return enableDevelopmentFeatures() || __RELEASE_CHANNEL__ === 'beta' } +/** + * Should the app show menu items that are used for testing various parts of the + * UI + * + * For our own testing purposes, this will likely remain enabled. But, sometimes + * we may want to create a test release for a user to test a fix in which case + * they should not need access to the test menu items. + */ +export const enableTestMenuItems = () => + enableDevelopmentFeatures() || __RELEASE_CHANNEL__ === 'test' + /** Should git pass `--recurse-submodules` when performing operations? */ export function enableRecurseSubmodulesFlag(): boolean { - return enableBetaFeatures() + return true } export function enableReadmeOverwriteWarning(): boolean { @@ -42,18 +53,6 @@ export function enableWSLDetection(): boolean { return enableBetaFeatures() } -/** Should the app show hide whitespace in changes tab */ -export function enableHideWhitespaceInDiffOption(): boolean { - return true -} - -/** - * Should we use the new diff viewer for unified diffs? - */ -export function enableExperimentalDiffViewer(): boolean { - return false -} - /** * Should we allow reporting unhandled rejections as if they were crashes? */ @@ -61,11 +60,6 @@ export function enableUnhandledRejectionReporting(): boolean { return enableBetaFeatures() } -/** Should we allow expanding text diffs? */ -export function enableTextDiffExpansion(): boolean { - return true -} - /** * Should we allow x64 apps running under ARM translation to auto-update to * ARM64 builds? @@ -78,76 +72,16 @@ export function enableUpdateFromEmulatedX64ToARM64(): boolean { return enableBetaFeatures() } -/** Should we allow setting repository aliases? */ -export function enableRepositoryAliases(): boolean { - return true -} - -/** Should we allow to create branches from a commit? */ -export function enableBranchFromCommit(): boolean { - return true -} - -/** Should we allow squashing? */ -export function enableSquashing(): boolean { - return true -} - -/** Should we allow squash-merging? */ -export function enableSquashMerging(): boolean { - return true -} - -/** Should we allow amending commits? */ -export function enableAmendingCommits(): boolean { - return true -} - -/** Should we allow reordering commits? */ -export function enableCommitReordering(): boolean { - return true -} - /** Should we allow resetting to a previous commit? */ export function enableResetToCommit(): boolean { - return enableDevelopmentFeatures() -} - -/** Should we show line changes (added/deleted) in commits? */ -export function enableLineChangesInCommit(): boolean { - return true -} - -/** Should we allow high contrast theme option */ -export function enableHighContrastTheme(): boolean { - return enableBetaFeatures() -} - -/** Should we allow customizing a theme */ -export function enableCustomizeTheme(): boolean { - return enableBetaFeatures() -} - -/** Should we allow using Windows' OpenSSH? */ -export function enableWindowsOpenSSH(): boolean { - return true -} - -/** Should we use SSH askpass? */ -export function enableSSHAskPass(): boolean { return true } -/** Should we show ci check runs? */ -export function enableCICheckRuns(): boolean { +/** Should we allow checking out a single commit? */ +export function enableCheckoutCommit(): boolean { return true } -/** Should ci check runs show logs? */ -export function enableCICheckRunsLogs(): boolean { - return false -} - /** Should we show previous tags as suggestions? */ export function enablePreviousTagSuggestions(): boolean { return enableBetaFeatures() @@ -158,22 +92,14 @@ export function enablePullRequestQuickView(): boolean { return enableDevelopmentFeatures() } -/** Should we enable high-signal notifications? */ -export function enableHighSignalNotifications(): boolean { - return true +/** Should we support image previews for dds files? */ +export function enableImagePreviewsForDDSFiles(): boolean { + return enableBetaFeatures() } -/** Should we enable PR review notifications? */ -export function enablePullRequestReviewNotifications(): boolean { - return true -} +export const enableCustomIntegration = () => true -/** Should we enable the rerunning of failed and single jobs aka action based checks */ -export function enableReRunFailedAndSingleCheckJobs(): boolean { - return true -} +export const enableResizingToolbarButtons = () => true +export const enableGitConfigParameters = enableBetaFeatures -/** Should we enable displaying multi commit diffs. This also switches diff logic from one commit */ -export function enableMultiCommitDiffs(): boolean { - return enableDevelopmentFeatures() -} +export const enableFilteredChangesList = enableDevelopmentFeatures diff --git a/app/src/lib/file-system.ts b/app/src/lib/file-system.ts index e57248578b8..360f20397cd 100644 --- a/app/src/lib/file-system.ts +++ b/app/src/lib/file-system.ts @@ -3,7 +3,6 @@ import * as Path from 'path' import { Disposable } from 'event-kit' import { Tailer } from './tailer' import byline from 'byline' -import * as Crypto from 'crypto' import { createReadStream } from 'fs' import { mkdtemp } from 'fs/promises' @@ -80,23 +79,3 @@ export async function readPartialFile( .on('end', () => resolve(Buffer.concat(chunks, total))) }) } - -export async function getFileHash( - path: string, - type: 'sha1' | 'sha256' -): Promise { - return new Promise((resolve, reject) => { - const hash = Crypto.createHash(type) - hash.setEncoding('hex') - const input = createReadStream(path) - - hash.on('finish', () => { - resolve(hash.read() as string) - }) - - input.on('error', reject) - hash.on('error', reject) - - input.pipe(hash) - }) -} diff --git a/app/src/lib/find-account.ts b/app/src/lib/find-account.ts index 84c12257fde..de6bc675b5c 100644 --- a/app/src/lib/find-account.ts +++ b/app/src/lib/find-account.ts @@ -80,7 +80,7 @@ export async function findAccountForRemoteURL( // As this needs to be done efficiently, we consider endpoints not matching // `getDotComAPIEndpoint()` to be GitHub Enterprise accounts, and accounts // without a token to be unauthenticated. - const sortedAccounts = Array.from(allAccounts).sort((a1, a2) => { + const sortedAccounts = allAccounts.toSorted((a1, a2) => { if (a1.endpoint === getDotComAPIEndpoint()) { return a1.token.length ? -1 : 1 } else if (a2.endpoint === getDotComAPIEndpoint()) { diff --git a/app/src/lib/find-default-branch.ts b/app/src/lib/find-default-branch.ts new file mode 100644 index 00000000000..0c08347ef0e --- /dev/null +++ b/app/src/lib/find-default-branch.ts @@ -0,0 +1,68 @@ +import { Branch, BranchType } from '../models/branch' +import { + Repository, + isForkedRepositoryContributingToParent, +} from '../models/repository' +import { getRemoteHEAD } from './git' +import { getDefaultBranch } from './helpers/default-branch' +import { UpstreamRemoteName } from './stores/helpers/find-upstream-remote' + +/** + * Attempts to locate the default branch as determined by the HEAD symbolic link + * in the contribution target remote (origin or upstream) if such a ref exists, + * falling back to the value of the `init.defaultBranch` configuration and + * finally a const value of `main`. + * + * In determining the default branch we prioritize finding a local branch but if + * no local branch matches the default branch name nor is tracking the + * contribution target remote HEAD we'll fall back to looking for the remote + * branch itself. + */ +export async function findDefaultBranch( + repository: Repository, + branches: ReadonlyArray, + defaultRemoteName: string | undefined +) { + const remoteName = isForkedRepositoryContributingToParent(repository) + ? UpstreamRemoteName + : defaultRemoteName + + const remoteHead = remoteName + ? await getRemoteHEAD(repository, remoteName) + : null + + const defaultBranchName = remoteHead ?? (await getDefaultBranch()) + const remoteRef = remoteHead ? `${remoteName}/${remoteHead}` : undefined + + let localHit: Branch | undefined = undefined + let localTrackingHit: Branch | undefined = undefined + let remoteHit: Branch | undefined = undefined + + for (const branch of branches) { + if (branch.type === BranchType.Local) { + if (branch.name === defaultBranchName) { + localHit = branch + } + + if (remoteRef && branch.upstream === remoteRef) { + // Give preference to local branches that target the upstream + // default branch that also match the name. In other words, if there + // are two local branches which both track the origin default branch + // we'll prefer a branch which is also named the same as the default + // branch name. + if (!localTrackingHit || branch.name === defaultBranchName) { + localTrackingHit = branch + } + } + } else if (remoteRef && branch.name === remoteRef) { + remoteHit = branch + } + } + + // When determining what the default branch is we give priority to local + // branches tracking the default branch of the contribution target (think + // origin) remote, then we consider local branches that are named the same + // as the default branch, and finally we look for the remote branch + // representing the default branch of the contribution target + return localTrackingHit ?? localHit ?? remoteHit ?? null +} diff --git a/app/src/lib/format-duration.ts b/app/src/lib/format-duration.ts index e36efd8c8a5..6659c46e297 100644 --- a/app/src/lib/format-duration.ts +++ b/app/src/lib/format-duration.ts @@ -1,8 +1,14 @@ -export const units: [string, number][] = [ - ['d', 86400000], - ['h', 3600000], - ['m', 60000], - ['s', 1000], +type TimeUnitDescriptor = { + shortUnit: string + longUnit: string + ms: number +} + +const units: TimeUnitDescriptor[] = [ + { shortUnit: 'd', longUnit: 'day', ms: 86400000 }, + { shortUnit: 'h', longUnit: 'hour', ms: 3600000 }, + { shortUnit: 'm', longUnit: 'minute', ms: 60000 }, + { shortUnit: 's', longUnit: 'second', ms: 1000 }, ] /** @@ -17,11 +23,34 @@ export const formatPreciseDuration = (ms: number) => { const parts = new Array() ms = Math.abs(ms) - for (const [unit, value] of units) { - if (parts.length > 0 || ms >= value || unit === 's') { - const qty = Math.floor(ms / value) - ms -= qty * value - parts.push(`${qty}${unit}`) + for (const unit of units) { + if (parts.length > 0 || ms >= unit.ms || unit.shortUnit === 's') { + const qty = Math.floor(ms / unit.ms) + ms -= qty * unit.ms + parts.push(`${qty}${unit.shortUnit}`) + } + } + + return parts.join(' ') +} + +/** + * Creates a long style precise duration format used for displaying things + * like check run durations that typically only last for a few minutes. + * + * Example: formatLongPreciseDuration(3670000) -> "1 hour 1 minute 10 seconds" + * + * @param ms The duration in milliseconds + */ +export const formatLongPreciseDuration = (ms: number) => { + const parts = new Array() + ms = Math.abs(ms) + + for (const unit of units) { + if (parts.length > 0 || ms >= unit.ms || unit.shortUnit === 's') { + const qty = Math.floor(ms / unit.ms) + ms -= qty * unit.ms + parts.push(`${qty} ${unit.longUnit}${qty === 1 ? '' : 's'}`) } } diff --git a/app/src/lib/generic-git-auth.ts b/app/src/lib/generic-git-auth.ts index c234d70fb31..4eb5344f34f 100644 --- a/app/src/lib/generic-git-auth.ts +++ b/app/src/lib/generic-git-auth.ts @@ -1,45 +1,49 @@ -import * as URL from 'url' -import { parseRemote } from './remote-parsing' import { getKeyForEndpoint } from './auth' import { TokenStore } from './stores/token-store' -/** Get the hostname to use for the given remote. */ -export function getGenericHostname(remoteURL: string): string { - const parsed = parseRemote(remoteURL) - if (parsed) { - return parsed.hostname - } +export const genericGitAuthUsernameKeyPrefix = 'genericGitAuth/username/' - const urlHostname = URL.parse(remoteURL).hostname - if (urlHostname) { - return urlHostname - } - - return remoteURL -} - -function getKeyForUsername(hostname: string): string { - return `genericGitAuth/username/${hostname}` +function getKeyForUsername(endpoint: string): string { + return `${genericGitAuthUsernameKeyPrefix}${endpoint}` } /** Get the username for the host. */ -export function getGenericUsername(hostname: string): string | null { - const key = getKeyForUsername(hostname) +export function getGenericUsername(endpoint: string): string | null { + const key = getKeyForUsername(endpoint) return localStorage.getItem(key) } /** Set the username for the host. */ -export function setGenericUsername(hostname: string, username: string) { - const key = getKeyForUsername(hostname) +export function setGenericUsername(endpoint: string, username: string) { + const key = getKeyForUsername(endpoint) return localStorage.setItem(key, username) } /** Set the password for the username and host. */ export function setGenericPassword( - hostname: string, + endpoint: string, username: string, password: string ): Promise { - const key = getKeyForEndpoint(hostname) + const key = getKeyForEndpoint(endpoint) return TokenStore.setItem(key, username, password) } + +export function setGenericCredential( + endpoint: string, + username: string, + password: string +) { + setGenericUsername(endpoint, username) + return setGenericPassword(endpoint, username, password) +} + +/** Get the password for the given username and host. */ +export const getGenericPassword = (endpoint: string, username: string) => + TokenStore.getItem(getKeyForEndpoint(endpoint), username) + +/** Delete a generic credential */ +export function deleteGenericCredential(endpoint: string, username: string) { + localStorage.removeItem(getKeyForUsername(endpoint)) + return TokenStore.deleteItem(getKeyForEndpoint(endpoint), username) +} diff --git a/app/src/lib/get-file-hash.ts b/app/src/lib/get-file-hash.ts new file mode 100644 index 00000000000..f7da29909a8 --- /dev/null +++ b/app/src/lib/get-file-hash.ts @@ -0,0 +1,15 @@ +import { createHash } from 'crypto' +import { createReadStream } from 'fs' + +/** + * Calculates the hex encoded hash digest of a given file on disk. + */ +export const getFileHash = (path: string, type: 'sha1' | 'sha256') => + new Promise((resolve, reject) => { + const hash = createHash(type) + + hash.on('finish', () => resolve(hash.digest('hex'))) + hash.on('error', reject) + + createReadStream(path).on('error', reject).pipe(hash) + }) diff --git a/app/src/lib/get-os.ts b/app/src/lib/get-os.ts index 9d59260ff68..b8d55bffd43 100644 --- a/app/src/lib/get-os.ts +++ b/app/src/lib/get-os.ts @@ -27,7 +27,7 @@ function systemVersionLessThan(version: string) { } /** Get the OS we're currently running on. */ -export function getOS() { +export function getOS(): string { const version = getSystemVersionSafe() if (__DARWIN__) { return `Mac OS ${version}` @@ -38,6 +38,30 @@ export function getOS() { } } +/** We're currently running macOS and it is macOS Ventura. */ +export const isMacOSVentura = memoizeOne( + () => + __DARWIN__ && + systemVersionGreaterThanOrEqualTo('13.0') && + systemVersionLessThan('14.0') +) + +/** We're currently running macOS and it is macOS Sonoma. */ +export const isMacOSSonoma = memoizeOne( + () => + __DARWIN__ && + systemVersionGreaterThanOrEqualTo('14.0') && + systemVersionLessThan('15.0') +) + +/** We're currently running macOS and it is macOS Sequoia. */ +export const isMacOSSequoia = memoizeOne( + () => + __DARWIN__ && + systemVersionGreaterThanOrEqualTo('15.0') && + systemVersionLessThan('16.0') +) + /** We're currently running macOS and it is macOS Catalina or earlier. */ export const isMacOSCatalinaOrEarlier = memoizeOne( () => __DARWIN__ && systemVersionLessThan('10.16') @@ -59,3 +83,17 @@ export const isMacOSBigSurOrLater = memoizeOne( export const isWindows10And1809Preview17666OrLater = memoizeOne( () => __WIN32__ && systemVersionGreaterThanOrEqualTo('10.0.17666') ) + +export const isWindowsAndNoLongerSupportedByElectron = memoizeOne( + () => __WIN32__ && systemVersionLessThan('10') +) + +export const isMacOSAndNoLongerSupportedByElectron = memoizeOne( + () => __DARWIN__ && systemVersionLessThan('10.15') +) + +export const isOSNoLongerSupportedByElectron = memoizeOne( + () => + isMacOSAndNoLongerSupportedByElectron() || + isWindowsAndNoLongerSupportedByElectron() +) diff --git a/app/src/lib/get-updater-guid.ts b/app/src/lib/get-updater-guid.ts new file mode 100644 index 00000000000..0468e273c93 --- /dev/null +++ b/app/src/lib/get-updater-guid.ts @@ -0,0 +1,25 @@ +import { app } from 'electron' +import { readFile, writeFile } from 'fs/promises' +import { join } from 'path' +import { uuid } from './uuid' + +let cachedGUID: string | undefined = undefined + +const getUpdateGUIDPath = () => join(app.getPath('userData'), '.update-id') +const writeUpdateGUID = (id: string) => + writeFile(getUpdateGUIDPath(), id).then(() => id) + +export const getUpdaterGUID = async () => { + return ( + cachedGUID ?? + readFile(getUpdateGUIDPath(), 'utf8') + .then(id => id.trim()) + .then(id => (id.length === 36 ? id : writeUpdateGUID(uuid()))) + .catch(() => writeUpdateGUID(uuid())) + .catch(e => { + log.error(`Could not read update id`, e) + return undefined + }) + .then(id => (cachedGUID = id)) + ) +} diff --git a/app/src/lib/git/apply.ts b/app/src/lib/git/apply.ts index 85467312822..bf3680cce19 100644 --- a/app/src/lib/git/apply.ts +++ b/app/src/lib/git/apply.ts @@ -64,6 +64,7 @@ export async function applyPatchToIndex( const { kind } = diff switch (diff.kind) { case DiffType.Binary: + case DiffType.Submodule: case DiffType.Image: throw new Error( `Can't create partial commit in binary file: ${file.path}` diff --git a/app/src/lib/git/authentication.ts b/app/src/lib/git/authentication.ts index 59d2b0f71b0..a6961d18a62 100644 --- a/app/src/lib/git/authentication.ts +++ b/app/src/lib/git/authentication.ts @@ -1,24 +1,13 @@ import { GitError as DugiteError } from 'dugite' -import { IGitAccount } from '../../models/git-account' /** Get the environment for authenticating remote operations. */ -export function envForAuthentication(auth: IGitAccount | null): Object { - const env = { +export function envForAuthentication(): Record { + return { // supported since Git 2.3, this is used to ensure we never interactively prompt // for credentials - even as a fallback GIT_TERMINAL_PROMPT: '0', GIT_TRACE: localStorage.getItem('git-trace') || '0', } - - if (!auth) { - return env - } - - return { - ...env, - DESKTOP_USERNAME: auth.login, - DESKTOP_ENDPOINT: auth.endpoint, - } } /** The set of errors which fit under the "authentication failed" umbrella. */ diff --git a/app/src/lib/git/branch.ts b/app/src/lib/git/branch.ts index 936c792191a..d1988ee3d0c 100644 --- a/app/src/lib/git/branch.ts +++ b/app/src/lib/git/branch.ts @@ -1,16 +1,12 @@ -import { git, gitNetworkArguments } from './core' +import { git } from './core' import { Repository } from '../../models/repository' import { Branch } from '../../models/branch' -import { IGitAccount } from '../../models/git-account' import { formatAsLocalRef } from './refs' import { deleteRef } from './update-ref' import { GitError as DugiteError } from 'dugite' -import { getRemoteURL } from './remote' -import { - envForRemoteOperation, - getFallbackUrlForProxyResolve, -} from './environment' +import { envForRemoteOperation } from './environment' import { createForEachRefParser } from './git-delimiter-parser' +import { IRemote } from '../../models/remote' /** * Create a new branch from the given start point. @@ -72,30 +68,15 @@ export async function deleteLocalBranch( */ export async function deleteRemoteBranch( repository: Repository, - account: IGitAccount | null, - remoteName: string, + remote: IRemote, remoteBranchName: string ): Promise { - const remoteUrl = - (await getRemoteURL(repository, remoteName).catch(err => { - // If we can't get the URL then it's very unlikely Git will be able to - // either and the push will fail. The URL is only used to resolve the - // proxy though so it's not critical. - log.error(`Could not resolve remote url for remote ${remoteName}`, err) - return null - })) || getFallbackUrlForProxyResolve(account, repository) - - const args = [ - ...gitNetworkArguments(), - 'push', - remoteName, - `:${remoteBranchName}`, - ] + const args = ['push', remote.name, `:${remoteBranchName}`] // If the user is not authenticated, the push is going to fail // Let this propagate and leave it to the caller to handle const result = await git(args, repository.path, 'deleteRemoteBranch', { - env: await envForRemoteOperation(account, remoteUrl), + env: await envForRemoteOperation(remote.url), expectedErrors: new Set([DugiteError.BranchDeletionFailed]), }) @@ -104,7 +85,7 @@ export async function deleteRemoteBranch( // error we can safely remove our remote ref which is what would // happen if the push didn't fail. if (result.gitError === DugiteError.BranchDeletionFailed) { - const ref = `refs/remotes/${remoteName}/${remoteBranchName}` + const ref = `refs/remotes/${remote.name}/${remoteBranchName}` await deleteRef(repository, ref) } diff --git a/app/src/lib/git/checkout.ts b/app/src/lib/git/checkout.ts index 5ffbdc46f3c..7a026ccd80e 100644 --- a/app/src/lib/git/checkout.ts +++ b/app/src/lib/git/checkout.ts @@ -1,8 +1,7 @@ -import { git, IGitExecutionOptions, gitNetworkArguments } from './core' +import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { Branch, BranchType } from '../../models/branch' import { ICheckoutProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { CheckoutProgressParser, executionOptionsWithProgress, @@ -15,35 +14,74 @@ import { } from './environment' import { WorkingDirectoryFileChange } from '../../models/status' import { ManualConflictResolution } from '../../models/manual-conflict-resolution' +import { CommitOneLine, shortenSHA } from '../../models/commit' +import { IRemote } from '../../models/remote' export type ProgressCallback = (progress: ICheckoutProgress) => void -async function getCheckoutArgs( +function getCheckoutArgs(progressCallback?: ProgressCallback) { + return ['checkout', ...(progressCallback ? ['--progress'] : [])] +} + +async function getBranchCheckoutArgs(branch: Branch) { + return [ + branch.name, + ...(branch.type === BranchType.Remote + ? ['-b', branch.nameWithoutRemote] + : []), + ...(enableRecurseSubmodulesFlag() ? ['--recurse-submodules'] : []), + '--', + ] +} + +async function getCheckoutOpts( repository: Repository, - branch: Branch, - account: IGitAccount | null, - progressCallback?: ProgressCallback -) { - const baseArgs = - progressCallback != null - ? [...gitNetworkArguments(), 'checkout', '--progress'] - : [...gitNetworkArguments(), 'checkout'] - - if (enableRecurseSubmodulesFlag()) { - return branch.type === BranchType.Remote - ? baseArgs.concat( - branch.name, - '-b', - branch.nameWithoutRemote, - '--recurse-submodules', - '--' - ) - : baseArgs.concat(branch.name, '--recurse-submodules', '--') - } else { - return branch.type === BranchType.Remote - ? baseArgs.concat(branch.name, '-b', branch.nameWithoutRemote, '--') - : baseArgs.concat(branch.name, '--') + title: string, + target: string, + currentRemote: IRemote | null, + progressCallback?: ProgressCallback, + initialDescription?: string +): Promise { + const opts: IGitStringExecutionOptions = { + env: await envForRemoteOperation( + getFallbackUrlForProxyResolve(repository, currentRemote) + ), + expectedErrors: AuthenticationErrors, } + + if (!progressCallback) { + return opts + } + + const kind = 'checkout' + + // Initial progress + progressCallback({ + kind, + title, + description: initialDescription ?? title, + value: 0, + target, + }) + + return await executionOptionsWithProgress( + { ...opts, trackLFSProgress: true }, + new CheckoutProgressParser(), + progress => { + if (progress.kind === 'progress') { + const description = progress.details.text + const value = progress.percent + + progressCallback({ + kind, + title, + description, + value, + target, + }) + } + } + ) } /** @@ -62,48 +100,63 @@ async function getCheckoutArgs( */ export async function checkoutBranch( repository: Repository, - account: IGitAccount | null, branch: Branch, + currentRemote: IRemote | null, progressCallback?: ProgressCallback ): Promise { - let opts: IGitExecutionOptions = { - env: await envForRemoteOperation( - account, - getFallbackUrlForProxyResolve(account, repository) - ), - expectedErrors: AuthenticationErrors, - } + const opts = await getCheckoutOpts( + repository, + `Checking out branch ${branch.name}`, + branch.name, + currentRemote, + progressCallback, + `Switching to ${__DARWIN__ ? 'Branch' : 'branch'}` + ) - if (progressCallback) { - const title = `Checking out branch ${branch.name}` - const kind = 'checkout' - const targetBranch = branch.name - - opts = await executionOptionsWithProgress( - { ...opts, trackLFSProgress: true }, - new CheckoutProgressParser(), - progress => { - if (progress.kind === 'progress') { - const description = progress.details.text - const value = progress.percent - - progressCallback({ kind, title, description, value, targetBranch }) - } - } - ) + const baseArgs = getCheckoutArgs(progressCallback) + const args = [...baseArgs, ...(await getBranchCheckoutArgs(branch))] - // Initial progress - progressCallback({ kind, title, value: 0, targetBranch }) - } + await git(args, repository.path, 'checkoutBranch', opts) + + // we return `true` here so `GitStore.performFailableGitOperation` + // will return _something_ differentiable from `undefined` if this succeeds + return true +} - const args = await getCheckoutArgs( +/** + * Check out the given commit. + * Literally invokes `git checkout `. + * + * @param repository - The repository in which the branch checkout should + * take place + * + * @param commit - The commit that should be checked out + * + * @param progressCallback - An optional function which will be invoked + * with information about the current progress + * of the checkout operation. When provided this + * enables the '--progress' command line flag for + * 'git checkout'. + */ +export async function checkoutCommit( + repository: Repository, + commit: CommitOneLine, + currentRemote: IRemote | null, + progressCallback?: ProgressCallback +): Promise { + const title = `Checking out ${__DARWIN__ ? 'Commit' : 'commit'}` + const opts = await getCheckoutOpts( repository, - branch, - account, + title, + shortenSHA(commit.sha), + currentRemote, progressCallback ) - await git(args, repository.path, 'checkoutBranch', opts) + const baseArgs = getCheckoutArgs(progressCallback) + const args = [...baseArgs, commit.sha] + + await git(args, repository.path, 'checkoutCommit', opts) // we return `true` here so `GitStore.performFailableGitOperation` // will return _something_ differentiable from `undefined` if this succeeds diff --git a/app/src/lib/git/cherry-pick.ts b/app/src/lib/git/cherry-pick.ts index 2ddacd68053..b1ef16800d8 100644 --- a/app/src/lib/git/cherry-pick.ts +++ b/app/src/lib/git/cherry-pick.ts @@ -5,7 +5,12 @@ import { AppFileStatusKind, WorkingDirectoryFileChange, } from '../../models/status' -import { git, IGitExecutionOptions, IGitResult } from './core' +import { + git, + IGitExecutionOptions, + IGitResult, + IGitStringExecutionOptions, +} from './core' import { getStatus } from './status' import { stageFiles } from './update-index' import { getCommitsInRange, revRange } from './rev-list' @@ -101,8 +106,8 @@ class GitCherryPickParser { * @param progressCallback - the callback method that accepts an * `ICherryPickProgress` instance created by the parser */ -function configureOptionsWithCallBack( - baseOptions: IGitExecutionOptions, +function configureOptionsWithCallBack( + baseOptions: T, commits: readonly CommitOneLine[], progressCallback: (progress: IMultiCommitOperationProgress) => void, cherryPickedCount: number = 0 @@ -142,7 +147,7 @@ export async function cherryPick( return CherryPickResult.UnableToStart } - let baseOptions: IGitExecutionOptions = { + let baseOptions: IGitStringExecutionOptions = { expectedErrors: new Set([ GitError.MergeConflicts, GitError.ConflictModifyDeletedInBranch, @@ -157,7 +162,7 @@ export async function cherryPick( ) } - // --keep-redundant-commits follows pattern of making sure someone cherry + // --empty=keep follows pattern of making sure someone cherry // picked commit summaries appear in target branch history even tho they may // be empty. This flag also results in the ability to cherry pick empty // commits (thus, --allow-empty is not required.) @@ -167,12 +172,7 @@ export async function cherryPick( // there could be multiple empty commits. I.E. If user does a range that // includes commits from that merge. const result = await git( - [ - 'cherry-pick', - ...commits.map(c => c.sha), - '--keep-redundant-commits', - '-m 1', - ], + ['cherry-pick', ...commits.map(c => c.sha), '--empty=keep', '-m 1'], repository.path, 'cherry-pick', baseOptions @@ -401,7 +401,7 @@ export async function continueCherryPick( const otherFiles = trackedFiles.filter(f => !manualResolutions.has(f.path)) await stageFiles(repository, otherFiles) - const status = await getStatus(repository) + const status = await getStatus(repository, false) if (status == null) { log.warn( `[continueCherryPick] unable to get status after staging changes, @@ -415,7 +415,7 @@ export async function continueCherryPick( return CherryPickResult.UnableToStart } - let options: IGitExecutionOptions = { + let options: IGitStringExecutionOptions = { expectedErrors: new Set([ GitError.MergeConflicts, GitError.ConflictModifyDeletedInBranch, @@ -465,12 +465,8 @@ export async function continueCherryPick( return parseCherryPickResult(result) } - // --keep-redundant-commits follows pattern of making sure someone cherry - // picked commit summaries appear in target branch history even tho they may - // be empty. This flag also results in the ability to cherry pick empty - // commits (thus, --allow-empty is not required.) const result = await git( - ['cherry-pick', '--continue', '--keep-redundant-commits'], + ['cherry-pick', '--continue'], repository.path, 'continueCherryPick', options diff --git a/app/src/lib/git/clone.ts b/app/src/lib/git/clone.ts index e05a2bf151e..954f85cc078 100644 --- a/app/src/lib/git/clone.ts +++ b/app/src/lib/git/clone.ts @@ -1,4 +1,4 @@ -import { git, IGitExecutionOptions, gitNetworkArguments } from './core' +import { git, IGitStringExecutionOptions } from './core' import { ICloneProgress } from '../../models/progress' import { CloneOptions } from '../../models/clone-options' import { CloneProgressParser, executionOptionsWithProgress } from '../progress' @@ -23,7 +23,6 @@ import { envForRemoteOperation } from './environment' * of the clone operation. When provided this enables * the '--progress' command line flag for * 'git clone'. - * */ export async function clone( url: string, @@ -31,19 +30,21 @@ export async function clone( options: CloneOptions, progressCallback?: (progress: ICloneProgress) => void ): Promise { - const env = await envForRemoteOperation(options.account, url) + const env = { + ...(await envForRemoteOperation(url)), + GIT_CLONE_PROTECTION_ACTIVE: 'false', + } const defaultBranch = options.defaultBranch ?? (await getDefaultBranch()) const args = [ - ...gitNetworkArguments(), '-c', `init.defaultBranch=${defaultBranch}`, 'clone', '--recursive', ] - let opts: IGitExecutionOptions = { env } + let opts: IGitStringExecutionOptions = { env } if (progressCallback) { args.push('--progress') diff --git a/app/src/lib/git/config.ts b/app/src/lib/git/config.ts index 2041dac7e62..08519747d03 100644 --- a/app/src/lib/git/config.ts +++ b/app/src/lib/git/config.ts @@ -28,6 +28,31 @@ export function getGlobalConfigValue( return getConfigValueInPath(name, null, false, undefined, env) } +/** + * Look up a config value by name. + * + * Treats the returned value as a boolean as per Git's + * own definition of a boolean configuration value (i.e. + * 0 -> false, "off" -> false, "yes" -> true etc) + */ +export async function getBooleanConfigValue( + repository: Repository, + name: string, + onlyLocal: boolean = false, + env?: { + HOME: string + } +): Promise { + const value = await getConfigValueInPath( + name, + repository.path, + onlyLocal, + 'bool', + env + ) + return value === null ? null : value !== 'false' +} + /** * Look up a global config value by name. * @@ -97,34 +122,21 @@ async function getConfigValueInPath( return pieces[0] } -/** Get the path to the global git config. */ -export async function getGlobalConfigPath(env?: { - HOME: string -}): Promise { - const options = env ? { env } : undefined - const result = await git( - ['config', '--global', '--list', '--show-origin', '--name-only', '-z'], - __dirname, - 'getGlobalConfigPath', - options - ) - const segments = result.stdout.split('\0') - if (segments.length < 1) { - return null - } - - const pathSegment = segments[0] - if (!pathSegment.length) { - return null - } - - const path = pathSegment.match(/file:(.+)/i) - if (!path || path.length < 2) { - return null - } - - return normalize(path[1]) -} +/** + * Get the path to the global git config + * + * Note: this uses git config --edit which will automatically create the global + * config file if it doesn't exist yet. The primary purpose behind this method + * is to support opening the global git config for editing. + */ +export const getGlobalConfigPath = (env?: { HOME: string }) => + git(['config', '--edit', '--global'], __dirname, 'getGlobalConfigPath', { + // We're using printf instead of echo because echo could attempt to decode + // escape sequences like \n which would be bad in a case like + // c:\Users\niik\.gitconfig + // ^^ + env: { ...env, GIT_EDITOR: 'printf %s' }, + }).then(x => normalize(x.stdout)) /** Set the local config value by name. */ export async function setConfigValue( diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index a3756b44d35..ace69eb88da 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -1,20 +1,41 @@ import { - GitProcess, - IGitResult as DugiteResult, + exec, GitError as DugiteError, + parseError, + IGitResult as DugiteResult, IGitExecutionOptions as DugiteExecutionOptions, + parseBadConfigValueErrorInfo, + ExecError, } from 'dugite' import { assertNever } from '../fatal-error' import * as GitPerf from '../../ui/lib/git-perf' import * as Path from 'path' import { isErrnoException } from '../errno-exception' -import { ChildProcess } from 'child_process' -import { Readable } from 'stream' -import split2 from 'split2' -import { getFileFromExceedsError } from '../helpers/regex' import { merge } from '../merge' import { withTrampolineEnv } from '../trampoline/trampoline-environment' +import { createTailStream } from './create-tail-stream' +import { createTerminalStream } from '../create-terminal-stream' +import { kStringMaxLength } from 'buffer' + +export const coerceToString = ( + value: string | Buffer, + encoding: BufferEncoding = 'utf8' +) => (Buffer.isBuffer(value) ? value.toString(encoding) : value) + +export const coerceToBuffer = ( + value: string | Buffer, + encoding: BufferEncoding = 'utf8' +) => (Buffer.isBuffer(value) ? value : Buffer.from(value, encoding)) + +export const isMaxBufferExceededError = ( + error: unknown +): error is ExecError & { code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' } => { + return ( + error instanceof ExecError && + error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + ) +} /** * An extension of the execution options in dugite that @@ -37,6 +58,12 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { /** Should it track & report LFS progress? */ readonly trackLFSProgress?: boolean + + /** + * Whether the command about to run is part of a background task or not. + * This affects error handling and UI such as credential prompts. + */ + readonly isBackgroundTask?: boolean } /** @@ -54,9 +81,6 @@ export interface IGitResult extends DugiteResult { /** The human-readable error description, based on `gitError`. */ readonly gitErrorDescription: string | null - /** Both stdout and stderr combined. */ - readonly combinedOutput: string - /** * The path that the Git command was executed from, i.e. the * process working directory (not to be confused with the Git @@ -64,6 +88,33 @@ export interface IGitResult extends DugiteResult { */ readonly path: string } + +/** The result of shelling out to git using a string encoding (default) */ +export interface IGitStringResult extends IGitResult { + /** The standard output from git. */ + readonly stdout: string + + /** The standard error output from git. */ + readonly stderr: string +} + +export interface IGitStringExecutionOptions extends IGitExecutionOptions { + readonly encoding?: BufferEncoding +} + +export interface IGitBufferExecutionOptions extends IGitExecutionOptions { + readonly encoding: 'buffer' +} + +/** The result of shelling out to git using a buffer encoding */ +export interface IGitBufferResult extends IGitResult { + /** The standard output from git. */ + readonly stdout: Buffer + + /** The standard error output from git. */ + readonly stderr: Buffer +} + export class GitError extends Error { /** The result from the failed command. */ public readonly result: IGitResult @@ -76,21 +127,25 @@ export class GitError extends Error { */ public readonly isRawMessage: boolean - public constructor(result: IGitResult, args: ReadonlyArray) { + public constructor( + result: IGitResult, + args: ReadonlyArray, + terminalOutput: string + ) { let rawMessage = true let message if (result.gitErrorDescription) { message = result.gitErrorDescription rawMessage = false - } else if (result.combinedOutput.length > 0) { - message = result.combinedOutput + } else if (terminalOutput.length > 0) { + message = terminalOutput } else if (result.stderr.length) { - message = result.stderr + message = coerceToString(result.stderr) } else if (result.stdout.length) { - message = result.stdout + message = coerceToString(result.stdout) } else { - message = 'Unknown error' + message = `Unknown error (exit code ${result.exitCode})` rawMessage = false } @@ -103,6 +158,16 @@ export class GitError extends Error { } } +export const isGitError = ( + e: unknown, + parsedError?: DugiteError +): e is GitError => { + return ( + e instanceof GitError && + (parsedError === undefined || e.result.gitError === parsedError) + ) +} + /** * Shell out to git with the given arguments, at the given path. * @@ -122,6 +187,18 @@ export class GitError extends Error { * `successExitCodes` or an error not in `expectedErrors`, a `GitError` will be * thrown. */ +export async function git( + args: string[], + path: string, + name: string, + options?: IGitStringExecutionOptions +): Promise +export async function git( + args: string[], + path: string, + name: string, + options?: IGitBufferExecutionOptions +): Promise export async function git( args: string[], path: string, @@ -131,121 +208,135 @@ export async function git( const defaultOptions: IGitExecutionOptions = { successExitCodes: new Set([0]), expectedErrors: new Set(), + maxBuffer: options?.encoding === 'buffer' ? Infinity : kStringMaxLength, } - let combinedOutput = '' - const opts = { - ...defaultOptions, - ...options, - } - - opts.processCallback = (process: ChildProcess) => { + const opts = { ...defaultOptions, ...options } + + // The combined contents of stdout and stderr with some light processing + // applied to remove redundant lines caused by Git's use of `\r` to "erase" + // the current line while writing progress output. See createTerminalOutput. + // + // Note: The output is capped at a maximum of 256kb and the sole intent of + // this property is to provide "terminal-like" output to the user when a Git + // command fails. + let terminalOutput = '' + + // Keep at most 256kb of combined stderr and stdout output. This is used + // to provide more context in error messages. + opts.processCallback = process => { + const terminalStream = createTerminalStream() + const tailStream = createTailStream(256 * 1024, { encoding: 'utf8' }) + + terminalStream + .pipe(tailStream) + .on('data', (data: string) => (terminalOutput = data)) + .on('error', e => log.error(`Terminal output error`, e)) + + process.stdout?.pipe(terminalStream, { end: false }) + process.stderr?.pipe(terminalStream, { end: false }) + process.on('close', () => terminalStream.end()) options?.processCallback?.(process) - - const combineOutput = (readable: Readable | null) => { - if (readable) { - readable.pipe(split2()).on('data', (line: string) => { - combinedOutput += line + '\n' - }) - } - } - - combineOutput(process.stderr) - combineOutput(process.stdout) } - return withTrampolineEnv(async env => { - const combinedEnv = merge(opts.env, env) - - // Explicitly set TERM to 'dumb' so that if Desktop was launched - // from a terminal or if the system environment variables - // have TERM set Git won't consider us as a smart terminal. - // See https://github.com/git/git/blob/a7312d1a2/editor.c#L11-L15 - opts.env = { TERM: 'dumb', ...combinedEnv } as Object - - const commandName = `${name}: git ${args.join(' ')}` - - const result = await GitPerf.measure(commandName, () => - GitProcess.exec(args, path, opts) - ).catch(err => { - // If this is an exception thrown by Node.js (as opposed to - // dugite) let's keep the salient details but include the name of - // the operation. - if (isErrnoException(err)) { - throw new Error(`Failed to execute ${name}: ${err.code}`) + return withTrampolineEnv( + async env => { + const combinedEnv = merge(opts.env, env) + + // Explicitly set TERM to 'dumb' so that if Desktop was launched + // from a terminal or if the system environment variables + // have TERM set Git won't consider us as a smart terminal. + // See https://github.com/git/git/blob/a7312d1a2/editor.c#L11-L15 + opts.env = { TERM: 'dumb', ...combinedEnv } + + const commandName = `${name}: git ${args.join(' ')}` + + const result = await GitPerf.measure(commandName, () => + exec(args, path, opts) + ).catch(err => { + // If this is an exception thrown by Node.js (as opposed to + // dugite) let's keep the salient details but include the name of + // the operation. + if (isErrnoException(err)) { + throw new Error(`Failed to execute ${name}: ${err.code}`) + } + + if (isMaxBufferExceededError(err)) { + throw new ExecError( + `${err.message} for ${name}`, + err.stdout, + err.stderr, + // Dugite stores the original Node error in the cause property, by + // passing that along we ensure that all we're doing here is + // changing the error message (and capping the stack but that's + // okay since we know exactly where this error is coming from). + // The null coalescing here is a safety net in case dugite's + // behavior changes from underneath us. + err.cause ?? err + ) + } + + throw err + }) + + const exitCode = result.exitCode + + let gitError: DugiteError | null = null + const acceptableExitCode = opts.successExitCodes + ? opts.successExitCodes.has(exitCode) + : false + if (!acceptableExitCode) { + gitError = parseError(coerceToString(result.stderr)) + if (gitError === null) { + gitError = parseError(coerceToString(result.stdout)) + } } - throw err - }) - - const exitCode = result.exitCode - - let gitError: DugiteError | null = null - const acceptableExitCode = opts.successExitCodes - ? opts.successExitCodes.has(exitCode) - : false - if (!acceptableExitCode) { - gitError = GitProcess.parseError(result.stderr) - if (!gitError) { - gitError = GitProcess.parseError(result.stdout) + const gitErrorDescription = + gitError !== null + ? getDescriptionForError(gitError, coerceToString(result.stderr)) + : null + const gitResult = { + ...result, + gitError, + gitErrorDescription, + path, } - } - - const gitErrorDescription = gitError - ? getDescriptionForError(gitError) - : null - const gitResult = { - ...result, - gitError, - gitErrorDescription, - combinedOutput, - path, - } - - let acceptableError = true - if (gitError && opts.expectedErrors) { - acceptableError = opts.expectedErrors.has(gitError) - } - - if ((gitError && acceptableError) || acceptableExitCode) { - return gitResult - } - - // The caller should either handle this error, or expect that exit code. - const errorMessage = new Array() - errorMessage.push( - `\`git ${args.join(' ')}\` exited with an unexpected code: ${exitCode}.` - ) - if (result.stdout) { - errorMessage.push('stdout:') - errorMessage.push(result.stdout) - } + let acceptableError = true + if (gitError !== null && opts.expectedErrors) { + acceptableError = opts.expectedErrors.has(gitError) + } - if (result.stderr) { - errorMessage.push('stderr:') - errorMessage.push(result.stderr) - } + if ((gitError !== null && acceptableError) || acceptableExitCode) { + return gitResult + } - if (gitError) { + // The caller should either handle this error, or expect that exit code. + const errorMessage = new Array() errorMessage.push( - `(The error was parsed as ${gitError}: ${gitErrorDescription})` + `\`git ${args.join(' ')}\` exited with an unexpected code: ${exitCode}.` ) - } - - log.error(errorMessage.join('\n')) - if (gitError === DugiteError.PushWithFileSizeExceedingLimit) { - const result = getFileFromExceedsError(errorMessage.join()) - const files = result.join('\n') + if (terminalOutput.length > 0) { + // Leave even less of the combined output in the log + errorMessage.push(terminalOutput.slice(-1024)) + } - if (files !== '') { - gitResult.gitErrorDescription += '\n\nFile causing error:\n\n' + files + if (gitError !== null) { + errorMessage.push( + `(The error was parsed as ${gitError}: ${gitErrorDescription})` + ) } - } - throw new GitError(gitResult, args) - }) + log.error(errorMessage.join('\n')) + + throw new GitError(gitResult, args, terminalOutput) + }, + path, + options?.isBackgroundTask ?? false, + options?.env + ) } /** @@ -290,7 +381,7 @@ const lockFilePathRe = /^error: could not lock config file (.+?): File exists$/m * output. */ export function parseConfigLockFilePathFromError(result: IGitResult) { - const match = lockFilePathRe.exec(result.stderr) + const match = lockFilePathRe.exec(coerceToString(result.stderr)) if (match === null) { return null @@ -306,10 +397,13 @@ export function parseConfigLockFilePathFromError(result: IGitResult) { return Path.resolve(result.path, `${normalized}.lock`) } -function getDescriptionForError(error: DugiteError): string | null { +export function getDescriptionForError( + error: DugiteError, + stderr: string +): string | null { if (isAuthFailureError(error)) { const menuHint = __DARWIN__ - ? 'GitHub Desktop > Preferences.' + ? 'GitHub Desktop > Settings.' : 'File > Options.' return `Authentication failed. Some common reasons include: @@ -323,6 +417,13 @@ function getDescriptionForError(error: DugiteError): string | null { } switch (error) { + case DugiteError.BadConfigValue: + const errorInfo = parseBadConfigValueErrorInfo(stderr) + if (errorInfo === null) { + return 'Unsupported git configuration value.' + } + + return `Unsupported value '${errorInfo.value}' for git config key '${errorInfo.key}'` case DugiteError.SSHKeyAuditUnverified: return 'The SSH key is unverified.' case DugiteError.RemoteDisconnection: @@ -429,27 +530,13 @@ function getDescriptionForError(error: DugiteError): string | null { case DugiteError.ConflictModifyDeletedInBranch: case DugiteError.MergeCommitNoMainlineOption: case DugiteError.UnsafeDirectory: + case DugiteError.PathExistsButNotInRef: return null default: return assertNever(error, `Unknown error: ${error}`) } } -/** - * Return an array of command line arguments for network operation that override - * the default git configuration values provided by local, global, or system - * level git configs. - * - * These arguments should be inserted before the subcommand, i.e in the case of - * `git pull` these arguments needs to go before the `pull` argument. - */ -export const gitNetworkArguments = () => [ - // Explicitly unset any defined credential helper, we rely on our - // own askpass for authentication. - '-c', - 'credential.helper=', -] - /** * Returns the arguments to use on any git operation that can end up * triggering a rebase. @@ -461,14 +548,13 @@ export function gitRebaseArguments() { // uses the merge backend even if the user has the apply backend // configured, since this is the only one supported. // This can go away once git deprecates the apply backend. - '-c', - 'rebase.backend=merge', + ...['-c', 'rebase.backend=merge'], ] } /** * Returns the SHA of the passed in IGitResult */ -export function parseCommitSHA(result: IGitResult): string { +export function parseCommitSHA(result: IGitStringResult): string { return result.stdout.split(']')[0].split(' ')[1] } diff --git a/app/src/lib/git/create-tail-stream.ts b/app/src/lib/git/create-tail-stream.ts new file mode 100644 index 00000000000..0592f9f8e95 --- /dev/null +++ b/app/src/lib/git/create-tail-stream.ts @@ -0,0 +1,36 @@ +import assert from 'assert' +import { Transform, TransformOptions } from 'stream' + +type Options = Pick + +export function createTailStream(capacity: number, options?: Options) { + assert.ok(capacity > 0, 'The "capacity" argument must be greater than 0') + + const chunks: Buffer[] = [] + let length = 0 + + return new Transform({ + ...options, + decodeStrings: true, + transform(chunk, _, cb) { + chunks.push(chunk) + length += chunk.length + + while (length > capacity) { + const firstChunk = chunks[0] + const overrun = length - capacity + + if (overrun >= firstChunk.length) { + chunks.shift() + length -= firstChunk.length + } else { + chunks[0] = firstChunk.subarray(overrun) + length -= overrun + } + } + + cb() + }, + flush: cb => cb(null, Buffer.concat(chunks)), + }) +} diff --git a/app/src/lib/git/credential.ts b/app/src/lib/git/credential.ts new file mode 100644 index 00000000000..943ea76692e --- /dev/null +++ b/app/src/lib/git/credential.ts @@ -0,0 +1,68 @@ +import { exec as git } from 'dugite' + +export const parseCredential = (value: string) => { + const cred = new Map() + + // The credential helper protocol is a simple key=value format but some of its + // keys are actually arrays which are represented as multiple key[] entries. + // Since we're currently storing credentials as a Map we need to handle this + // and expand multiple key[] entries into a key[0], key[1]... key[n] sequence. + // We then remove the number from the key when we're formatting the credential + for (const [, k, v] of value.matchAll(/^(.*?)=(.*)$/gm)) { + if (k.endsWith('[]')) { + let i = 0 + let newKey + + do { + newKey = `${k.slice(0, -2)}[${i}]` + i++ + } while (cred.has(newKey)) + + cred.set(newKey, v) + } else { + cred.set(k, v) + } + } + + return cred +} + +export const formatCredential = (credential: Map) => + [...credential] + .map(([k, v]) => `${k.replace(/\[\d+\]$/, '[]')}=${v}\n`) + .join('') + +// Can't use git() as that will call withTrampolineEnv which calls this method +const exec = ( + cmd: string, + cred: Map, + path: string, + env: Record = {} +) => + git( + [ + ...['-c', 'credential.helper='], + ...['-c', `credential.helper=manager`], + 'credential', + cmd, + ], + path, + { + stdin: formatCredential(cred), + env: { + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + TERM: 'dumb', + ...env, + }, + } + ).then(({ exitCode, stderr, stdout }) => { + if (exitCode !== 0) { + throw new Error(stderr) + } + return parseCredential(stdout) + }) + +export const fillCredential = exec.bind(null, 'fill') +export const approveCredential = exec.bind(null, 'approve') +export const rejectCredential = exec.bind(null, 'reject') diff --git a/app/src/lib/git/diff-check.ts b/app/src/lib/git/diff-check.ts index 131773078d7..3e418897fd1 100644 --- a/app/src/lib/git/diff-check.ts +++ b/app/src/lib/git/diff-check.ts @@ -1,5 +1,4 @@ -import { spawnAndComplete } from './spawn' -import { getCaptures } from '../helpers/regex' +import { git } from './core' /** * Returns a list of files with conflict markers present @@ -10,33 +9,19 @@ import { getCaptures } from '../helpers/regex' export async function getFilesWithConflictMarkers( repositoryPath: string ): Promise> { - // git operation - const args = ['diff', '--check'] - const { output } = await spawnAndComplete( - args, + const { stdout } = await git( + ['diff', '--check'], repositoryPath, 'getFilesWithConflictMarkers', - new Set([0, 2]) + { successExitCodes: new Set([0, 2]) } ) - // result parsing - const outputStr = output.toString('utf8') - const captures = getCaptures(outputStr, fileNameCaptureRe) - if (captures.length === 0) { - return new Map() + const files = new Map() + const matches = stdout.matchAll(/^(.+):\d+: leftover conflict marker/gm) + + for (const [, path] of matches) { + files.set(path, (files.get(path) ?? 0) + 1) } - // flatten the list (only does one level deep) - const flatCaptures = captures.reduce((acc, val) => acc.concat(val)) - // count number of occurrences - const counted = flatCaptures.reduce( - (acc, val) => acc.set(val, (acc.get(val) || 0) + 1), - new Map() - ) - return counted -} -/** - * matches a line reporting a leftover conflict marker - * and captures the name of the file - */ -const fileNameCaptureRe = /(.+):\d+: leftover conflict marker/gi + return files +} diff --git a/app/src/lib/git/diff.ts b/app/src/lib/git/diff.ts index b2e0080a07d..2322d90fc9b 100644 --- a/app/src/lib/git/diff.ts +++ b/app/src/lib/git/diff.ts @@ -7,6 +7,7 @@ import { WorkingDirectoryFileChange, FileChange, AppFileStatusKind, + SubmoduleStatus, CommittedFileChange, } from '../../models/status' import { @@ -18,20 +19,21 @@ import { LineEndingsChange, parseLineEndingText, ILargeTextDiff, - IUnrenderableDiff, } from '../../models/diff' -import { spawnAndComplete } from './spawn' - import { DiffParser } from '../diff-parser' import { getOldPathOrDefault } from '../get-old-path' -import { getCaptures } from '../helpers/regex' import { readFile } from 'fs/promises' import { forceUnwrap } from '../fatal-error' import { git } from './core' import { NullTreeSHA } from './diff-index' import { GitError } from 'dugite' -import { mapStatus } from './log' +import { IChangesetData, parseRawLogWithNumstat } from './log' +import { getConfigValue } from './config' +import { getMergeBase } from './merge' +import { IStatusEntry } from '../status-parser' +import { createLogParser } from './git-delimiter-parser' +import { enableImagePreviewsForDDSFiles } from '../feature-flag' /** * V8 has a limit on the size of string it can create (~256MB), and unless we want to @@ -96,6 +98,10 @@ const imageFileExtensions = new Set([ '.avif', ]) +if (enableImagePreviewsForDDSFiles()) { + imageFileExtensions.add('.dds') +} + /** * Render the difference between a file in the given commit and its parent * @@ -116,6 +122,7 @@ export async function getCommitDiff( '-1', '--first-parent', '--patch-with-raw', + '--format=', '-z', '--no-color', '--', @@ -129,13 +136,50 @@ export async function getCommitDiff( args.push(file.status.oldPath) } - const { output } = await spawnAndComplete( - args, - repository.path, - 'getCommitDiff' - ) + const { stdout } = await git(args, repository.path, 'getCommitDiff', { + encoding: 'buffer', + }) - return buildDiff(output, repository, file, commitish) + return buildDiff(stdout, repository, file, commitish) +} + +/** + * Render the diff between two branches with --merge-base for a file + * (Show what would be the result of merge) + */ +export async function getBranchMergeBaseDiff( + repository: Repository, + file: FileChange, + baseBranchName: string, + comparisonBranchName: string, + hideWhitespaceInDiff: boolean = false, + latestCommit: string +): Promise { + const args = [ + 'diff', + '--merge-base', + baseBranchName, + comparisonBranchName, + ...(hideWhitespaceInDiff ? ['-w'] : []), + '--patch-with-raw', + '-z', + '--no-color', + '--', + file.path, + ] + + if ( + file.status.kind === AppFileStatusKind.Renamed || + file.status.kind === AppFileStatusKind.Copied + ) { + args.push(file.status.oldPath) + } + + const result = await git(args, repository.path, 'getBranchMergeBaseDiff', { + encoding: 'buffer', + }) + + return buildDiff(result.stdout, repository, file, latestCommit) } /** @@ -161,6 +205,7 @@ export async function getCommitRangeDiff( latestCommit, ...(hideWhitespaceInDiff ? ['-w'] : []), '--patch-with-raw', + '--format=', '-z', '--no-color', '--', @@ -175,7 +220,7 @@ export async function getCommitRangeDiff( } const result = await git(args, repository.path, 'getCommitsDiff', { - maxBuffer: Infinity, + encoding: 'buffer', expectedErrors: new Set([GitError.BadRevision]), }) @@ -192,11 +237,52 @@ export async function getCommitRangeDiff( ) } - return buildDiff( - Buffer.from(result.combinedOutput), + return buildDiff(result.stdout, repository, file, latestCommit) +} + +/** + * Get the files that were changed for the merge base comparison of two branches. + * (What would be the result of a merge) + */ +export async function getBranchMergeBaseChangedFiles( + repository: Repository, + baseBranchName: string, + comparisonBranchName: string, + latestComparisonBranchCommitRef: string +): Promise { + const baseArgs = [ + 'diff', + '--merge-base', + baseBranchName, + comparisonBranchName, + '-C', + '-M', + '-z', + '--raw', + '--numstat', + '--', + ] + + const mergeBaseCommit = await getMergeBase( repository, - file, - latestCommit + baseBranchName, + comparisonBranchName + ) + + if (mergeBaseCommit === null) { + return null + } + + const result = await git( + baseArgs, + repository.path, + 'getBranchMergeBaseChangedFiles' + ) + + return parseRawLogWithNumstat( + result.stdout, + `${latestComparisonBranchCommitRef}`, + mergeBaseCommit ) } @@ -204,11 +290,7 @@ export async function getCommitRangeChangedFiles( repository: Repository, shas: ReadonlyArray, useNullTreeSHA: boolean = false -): Promise<{ - files: ReadonlyArray - linesAdded: number - linesDeleted: number -}> { +): Promise { if (shas.length === 0) { throw new Error('No commits to diff...') } @@ -227,7 +309,7 @@ export async function getCommitRangeChangedFiles( '--', ] - const result = await git( + const { stdout, gitError } = await git( baseArgs, repository.path, 'getCommitRangeChangedFiles', @@ -239,98 +321,12 @@ export async function getCommitRangeChangedFiles( // This should only happen if the oldest commit does not have a parent (ex: // initial commit of a branch) and therefore `SHA^` is not a valid reference. // In which case, we will retry with the null tree sha. - if (result.gitError === GitError.BadRevision && useNullTreeSHA === false) { + if (gitError === GitError.BadRevision && useNullTreeSHA === false) { const useNullTreeSHA = true return getCommitRangeChangedFiles(repository, shas, useNullTreeSHA) } - return parseChangedFilesAndNumStat( - result.combinedOutput, - `${oldestCommitRef}..${latestCommitRef}` - ) -} - -/** - * Parses output of diff flags -z --raw --numstat. - * - * Given the -z flag the new lines are separated by \0 character (left them as - * new lines below for ease of reading) - * - * For modified, added, deleted, untracked: - * 100644 100644 5716ca5 db3c77d M - * file_one_path - * :100644 100644 0835e4f 28096ea M - * file_two_path - * 1 0 file_one_path - * 1 0 file_two_path - * - * For copied or renamed: - * 100644 100644 5716ca5 db3c77d M - * file_one_original_path - * file_one_new_path - * :100644 100644 0835e4f 28096ea M - * file_two_original_path - * file_two_new_path - * 1 0 - * file_one_original_path - * file_one_new_path - * 1 0 - * file_two_original_path - * file_two_new_path - */ -function parseChangedFilesAndNumStat(stdout: string, committish: string) { - const lines = stdout.split('\0') - // Remove the trailing empty line - lines.splice(-1, 1) - - const files: CommittedFileChange[] = [] - let linesAdded = 0 - let linesDeleted = 0 - - for (let i = 0; i < lines.length; i++) { - const parts = lines[i].split('\t') - - if (parts.length === 1) { - const statusParts = parts[0].split(' ') - const statusText = statusParts.at(-1) ?? '' - let oldPath: string | undefined = undefined - - if ( - statusText.length > 0 && - (statusText[0] === 'R' || statusText[0] === 'C') - ) { - oldPath = lines[++i] - } - - const status = mapStatus(statusText, oldPath) - const path = lines[++i] - - files.push(new CommittedFileChange(path, status, committish)) - } - - if (parts.length === 3) { - const [added, deleted, file] = parts - - if (added === '-' || deleted === '-') { - continue - } - - linesAdded += parseInt(added, 10) - linesDeleted += parseInt(deleted, 10) - - // If a file is not renamed or copied, the file name is with the - // add/deleted lines other wise the 2 files names are the next two lines - if (file === '' && lines[i + 1].split('\t').length === 1) { - i = i + 2 - } - } - } - - return { - files, - linesAdded, - linesDeleted, - } + return parseRawLogWithNumstat(stdout, latestCommitRef, oldestCommitRef) } /** @@ -354,10 +350,14 @@ export async function getWorkingDirectoryDiff( '--no-color', ] const successExitCodes = new Set([0]) + const isSubmodule = file.status.submoduleStatus !== undefined + // For added submodules, we'll use the "default" parameters, which are able + // to output the submodule commit. if ( - file.status.kind === AppFileStatusKind.New || - file.status.kind === AppFileStatusKind.Untracked + !isSubmodule && + (file.status.kind === AppFileStatusKind.New || + file.status.kind === AppFileStatusKind.Untracked) ) { // `git diff --no-index` seems to emulate the exit codes from `diff` irrespective of // whether you set --exit-code @@ -384,15 +384,15 @@ export async function getWorkingDirectoryDiff( args.push('HEAD', '--', file.path) } - const { output, error } = await spawnAndComplete( + const { stdout, stderr } = await git( args, repository.path, 'getWorkingDirectoryDiff', - successExitCodes + { successExitCodes, encoding: 'buffer' } ) - const lineEndingsChange = parseLineEndingsWarning(error) + const lineEndingsChange = parseLineEndingsWarning(stderr) - return buildDiff(output, repository, file, 'HEAD', lineEndingsChange) + return buildDiff(stdout, repository, file, 'HEAD', lineEndingsChange) } async function getImageDiff( @@ -438,7 +438,8 @@ async function getImageDiff( // File status can't be conflicted for a file in a commit if ( file.status.kind !== AppFileStatusKind.New && - file.status.kind !== AppFileStatusKind.Untracked + file.status.kind !== AppFileStatusKind.Untracked && + file.status.kind !== AppFileStatusKind.Deleted ) { // TODO: commitish^ won't work for the first commit // @@ -450,6 +451,17 @@ async function getImageDiff( `${oldestCommitish}^` ) } + + if ( + file instanceof CommittedFileChange && + file.status.kind === AppFileStatusKind.Deleted + ) { + previous = await getBlobImage( + repository, + getOldPathOrDefault(file), + file.parentCommitish + ) + } } return { @@ -514,6 +526,9 @@ function getMediaType(extension: string) { if (extension === '.avif') { return 'image/avif' } + if (extension === '.dds') { + return 'image/vnd-ms.dds' + } // fallback value as per the spec return 'text/plain' @@ -525,7 +540,7 @@ function getMediaType(extension: string) { * changes based on what the user has configured. */ const lineEndingsChangeRegex = - /warning: (CRLF|CR|LF) will be replaced by (CRLF|CR|LF) in .*/ + /', (CRLF|CR|LF) will be replaced by (CRLF|CR|LF) the .*/ /** * Utility function for inspecting the stderr output for the line endings @@ -566,16 +581,71 @@ function diffFromRawDiffOutput(output: Buffer): IRawDiff { return parser.parse(forceUnwrap(`Invalid diff output`, pieces.at(-1))) } -function buildDiff( +async function buildSubmoduleDiff( + buffer: Buffer, + repository: Repository, + file: FileChange, + status: SubmoduleStatus +): Promise { + const path = file.path + const fullPath = Path.join(repository.path, path) + const url = await getConfigValue(repository, `submodule.${path}.url`, true) + + let oldSHA = null + let newSHA = null + + if ( + status.commitChanged || + file.status.kind === AppFileStatusKind.New || + file.status.kind === AppFileStatusKind.Deleted + ) { + const diff = buffer.toString('utf-8') + const lines = diff.split('\n') + const baseRegex = 'Subproject commit ([^-]+)(-dirty)?$' + const oldSHARegex = new RegExp('-' + baseRegex) + const newSHARegex = new RegExp('\\+' + baseRegex) + const lineMatch = (regex: RegExp) => + lines + .flatMap(line => { + const match = line.match(regex) + return match ? match[1] : [] + }) + .at(0) ?? null + + oldSHA = lineMatch(oldSHARegex) + newSHA = lineMatch(newSHARegex) + } + + return { + kind: DiffType.Submodule, + fullPath, + path, + url, + status, + oldSHA, + newSHA, + } +} + +async function buildDiff( buffer: Buffer, repository: Repository, file: FileChange, oldestCommitish: string, lineEndingsChange?: LineEndingsChange ): Promise { + if (file.status.submoduleStatus !== undefined) { + return buildSubmoduleDiff( + buffer, + repository, + file, + file.status.submoduleStatus + ) + } + if (!isValidBuffer(buffer)) { // the buffer's diff is too large to be renderable in the UI - return Promise.resolve({ kind: DiffType.Unrenderable }) + return { kind: DiffType.Unrenderable } } const diff = diffFromRawDiffOutput(buffer) @@ -593,7 +663,7 @@ function buildDiff( hasHiddenBidiChars: diff.hasHiddenBidiChars, } - return Promise.resolve(largeTextDiff) + return largeTextDiff } return convertDiff(repository, file, diff, oldestCommitish, lineEndingsChange) @@ -616,6 +686,7 @@ export async function getBlobImage( const extension = Path.extname(path) const contents = await getBlobContents(repository, commitish, path) return new Image( + contents.buffer, contents.toString('base64'), getMediaType(extension), contents.length @@ -636,6 +707,7 @@ export async function getWorkingDirectoryImage( ): Promise { const contents = await readFile(Path.join(repository.path, file.path)) return new Image( + contents.buffer, contents.toString('base64'), getMediaType(Path.extname(file.path)), contents.length @@ -653,20 +725,51 @@ export async function getWorkingDirectoryImage( */ export async function getBinaryPaths( repository: Repository, - ref: string + ref: string, + conflictedFilesInIndex: ReadonlyArray ): Promise> { - const { output } = await spawnAndComplete( + const [detectedBinaryFiles, conflictedFilesUsingBinaryMergeDriver] = + await Promise.all([ + getDetectedBinaryFiles(repository, ref), + getFilesUsingBinaryMergeDriver(repository, conflictedFilesInIndex), + ]) + + return Array.from( + new Set([...detectedBinaryFiles, ...conflictedFilesUsingBinaryMergeDriver]) + ) +} + +/** + * Runs diff --numstat to get the list of files that have changed and which + * Git have detected as binary files + */ +async function getDetectedBinaryFiles(repository: Repository, ref: string) { + const { stdout } = await git( ['diff', '--numstat', '-z', ref], repository.path, 'getBinaryPaths' ) - const captures = getCaptures(output.toString('utf8'), binaryListRegex) - if (captures.length === 0) { - return [] - } - // flatten the list (only does one level deep) - const flatCaptures = captures.reduce((acc, val) => acc.concat(val)) - return flatCaptures + + return Array.from(stdout.matchAll(binaryListRegex), m => m[1]) } const binaryListRegex = /-\t-\t(?:\0.+\0)?([^\0]*)/gi + +async function getFilesUsingBinaryMergeDriver( + repository: Repository, + files: ReadonlyArray +) { + const { stdout } = await git( + ['check-attr', '--stdin', '-z', 'merge'], + repository.path, + 'getConflictedFilesUsingBinaryMergeDriver', + { + stdin: files.map(f => f.path).join('\0'), + } + ) + + return createLogParser({ path: '', attr: '', value: '' }) + .parse(stdout) + .filter(x => x.attr === 'merge' && x.value === 'binary') + .map(x => x.path) +} diff --git a/app/src/lib/git/environment.ts b/app/src/lib/git/environment.ts index ad05be9c779..fedefb09aaa 100644 --- a/app/src/lib/git/environment.ts +++ b/app/src/lib/git/environment.ts @@ -1,8 +1,11 @@ import { envForAuthentication } from './authentication' -import { IGitAccount } from '../../models/git-account' import { resolveGitProxy } from '../resolve-git-proxy' -import { getDotComAPIEndpoint } from '../api' -import { Repository } from '../../models/repository' +import { getHTMLURL } from '../api' +import { + Repository, + isRepositoryWithGitHubRepository, +} from '../../models/repository' +import { IRemote } from '../../models/remote' /** * For many remote operations it's well known what the primary remote @@ -17,25 +20,34 @@ import { Repository } from '../../models/repository' * be on a different server as well. That's too advanced for our usage * at the moment though so we'll just need to figure out some reasonable * url to fall back on. + * + * @param branchName If the operation we're about to undertake is related to a + * local ref (i.e branch) then we can use that to resolve its + * upstream tracking branch (and thereby its remote) and use + * that as the probable url to resolve a proxy for. */ export function getFallbackUrlForProxyResolve( - account: IGitAccount | null, - repository: Repository + repository: Repository, + currentRemote: IRemote | null ) { - // If we've got an account with an endpoint that means we've already done the - // heavy lifting to figure out what the most likely endpoint is gonna be - // so we'll try to leverage that. - if (account !== null) { - // A GitHub.com Account will have api.github.com as its endpoint - return account.endpoint === getDotComAPIEndpoint() - ? 'https://github.com' - : account.endpoint + // We used to use account.endpoint here but we look up account by the + // repository endpoint (see getAccountForRepository) so we can skip the use + // of the account here and just use the repository endpoint directly. + if (isRepositoryWithGitHubRepository(repository)) { + return getHTMLURL(repository.gitHubRepository.endpoint) } - if (repository.gitHubRepository !== null) { - if (repository.gitHubRepository.cloneURL !== null) { - return repository.gitHubRepository.cloneURL - } + // This is a carry-over from the old code where we would use the current + // remote to resolve an account and then use that account's endpoint here. + // We've since removed the need to pass an account down here but unfortunately + // that means we need to pass the current remote instead. Note that ideally + // this should be looking up the remote url either based on the currently + // checked out branch, the upstream tracking branch of the branch being + // checked out, or the default remote if neither of those are available. + // Doing so by shelling out to Git here was deemed to costly and in order to + // finish this refactor we've opted to replicate the previous behavior here. + if (currentRemote) { + return currentRemote.url } // If all else fails let's assume that whatever network resource @@ -61,12 +73,9 @@ export function getFallbackUrlForProxyResolve( * pointing to another host entirely. Used to resolve which * proxy (if any) should be used for the operation. */ -export async function envForRemoteOperation( - account: IGitAccount | null, - remoteUrl: string -) { +export async function envForRemoteOperation(remoteUrl: string) { return { - ...envForAuthentication(account), + ...envForAuthentication(), ...(await envForProxy(remoteUrl)), } } @@ -85,7 +94,7 @@ export async function envForProxy( remoteUrl: string, env: NodeJS.ProcessEnv = process.env, resolve: (url: string) => Promise = resolveGitProxy -): Promise { +): Promise | undefined> { const protocolMatch = /^(https?):\/\//i.exec(remoteUrl) // We can only resolve and use a proxy for the protocols where cURL diff --git a/app/src/lib/git/fetch.ts b/app/src/lib/git/fetch.ts index 0049df0296e..408b29015a3 100644 --- a/app/src/lib/git/fetch.ts +++ b/app/src/lib/git/fetch.ts @@ -1,6 +1,5 @@ -import { git, IGitExecutionOptions, gitNetworkArguments } from './core' +import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' -import { IGitAccount } from '../../models/git-account' import { IFetchProgress } from '../../models/progress' import { FetchProgressParser, executionOptionsWithProgress } from '../progress' import { enableRecurseSubmodulesFlag } from '../feature-flag' @@ -9,33 +8,18 @@ import { ITrackingBranch } from '../../models/branch' import { envForRemoteOperation } from './environment' async function getFetchArgs( - repository: Repository, remote: string, - account: IGitAccount | null, progressCallback?: (progress: IFetchProgress) => void ) { - if (enableRecurseSubmodulesFlag()) { - return progressCallback != null - ? [ - ...gitNetworkArguments(), - 'fetch', - '--progress', - '--prune', - '--recurse-submodules=on-demand', - remote, - ] - : [ - ...gitNetworkArguments(), - 'fetch', - '--prune', - '--recurse-submodules=on-demand', - remote, - ] - } else { - return progressCallback != null - ? [...gitNetworkArguments(), 'fetch', '--progress', '--prune', remote] - : [...gitNetworkArguments(), 'fetch', '--prune', remote] - } + return [ + 'fetch', + ...(progressCallback ? ['--progress'] : []), + '--prune', + ...(enableRecurseSubmodulesFlag() + ? ['--recurse-submodules=on-demand'] + : []), + remote, + ] } /** @@ -52,16 +36,18 @@ async function getFetchArgs( * of the fetch operation. When provided this enables * the '--progress' command line flag for * 'git fetch'. + * @param isBackgroundTask - Whether the fetch is being performed as a + * background task as opposed to being user initiated */ export async function fetch( repository: Repository, - account: IGitAccount | null, remote: IRemote, - progressCallback?: (progress: IFetchProgress) => void + progressCallback?: (progress: IFetchProgress) => void, + isBackgroundTask = false ): Promise { - let opts: IGitExecutionOptions = { + let opts: IGitStringExecutionOptions = { successExitCodes: new Set([0]), - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), } if (progressCallback) { @@ -69,7 +55,7 @@ export async function fetch( const kind = 'fetch' opts = await executionOptionsWithProgress( - { ...opts, trackLFSProgress: true }, + { ...opts, trackLFSProgress: true, isBackgroundTask }, new FetchProgressParser(), progress => { // In addition to progress output from the remote end and from @@ -100,12 +86,7 @@ export async function fetch( progressCallback({ kind, title, value: 0, remote: remote.name }) } - const args = await getFetchArgs( - repository, - remote.name, - account, - progressCallback - ) + const args = await getFetchArgs(remote.name, progressCallback) await git(args, repository.path, 'fetch', opts) } @@ -113,19 +94,13 @@ export async function fetch( /** Fetch a given refspec from the given remote. */ export async function fetchRefspec( repository: Repository, - account: IGitAccount | null, remote: IRemote, refspec: string ): Promise { - await git( - [...gitNetworkArguments(), 'fetch', remote.name, refspec], - repository.path, - 'fetchRefspec', - { - successExitCodes: new Set([0, 128]), - env: await envForRemoteOperation(account, remote.url), - } - ) + await git(['fetch', remote.name, refspec], repository.path, 'fetchRefspec', { + successExitCodes: new Set([0, 128]), + env: await envForRemoteOperation(remote.url), + }) } export async function fastForwardBranches( @@ -138,18 +113,6 @@ export async function fastForwardBranches( const refPairs = branches.map(branch => `${branch.upstreamRef}:${branch.ref}`) - const opts: IGitExecutionOptions = { - // Fetch exits with an exit code of 1 if one or more refs failed to update - // which is what we expect will happen - successExitCodes: new Set([0, 1]), - env: { - // This will make sure the reflog entries are correct after - // fast-forwarding the branches. - GIT_REFLOG_ACTION: 'pull', - }, - stdin: refPairs.join('\n'), - } - await git( [ 'fetch', @@ -166,6 +129,16 @@ export async function fastForwardBranches( ], repository.path, 'fastForwardBranches', - opts + { + // Fetch exits with an exit code of 1 if one or more refs failed to update + // which is what we expect will happen + successExitCodes: new Set([0, 1]), + env: { + // This will make sure the reflog entries are correct after + // fast-forwarding the branches. + GIT_REFLOG_ACTION: 'pull', + }, + stdin: refPairs.join('\n'), + } ) } diff --git a/app/src/lib/git/for-each-ref.ts b/app/src/lib/git/for-each-ref.ts index 5f477a07581..d862e24068a 100644 --- a/app/src/lib/git/for-each-ref.ts +++ b/app/src/lib/git/for-each-ref.ts @@ -7,7 +7,6 @@ import { IBranchTip, ITrackingBranch, } from '../../models/branch' -import { CommitIdentity } from '../../models/commit-identity' import { createForEachRefParser } from './git-delimiter-parser' /** Get all the branches. */ @@ -20,7 +19,6 @@ export async function getBranches( shortName: '%(refname:short)', upstreamShortName: '%(upstream:short)', sha: '%(objectname)', - author: '%(author)', symRef: '%(symref)', }) @@ -50,8 +48,7 @@ export async function getBranches( continue } - const author = CommitIdentity.parseIdentity(ref.author) - const tip: IBranchTip = { sha: ref.sha, author } + const tip: IBranchTip = { sha: ref.sha } const type = ref.fullName.startsWith('refs/heads') ? BranchType.Local diff --git a/app/src/lib/git/format-patch.ts b/app/src/lib/git/format-patch.ts index 28cd1173350..27c068477ca 100644 --- a/app/src/lib/git/format-patch.ts +++ b/app/src/lib/git/format-patch.ts @@ -1,6 +1,6 @@ import { revRange } from './rev-list' import { Repository } from '../../models/repository' -import { spawnAndComplete } from './spawn' +import { git } from '.' /** * Generate a patch representing the changes associated with a range of commits @@ -10,16 +10,8 @@ import { spawnAndComplete } from './spawn' * @param head ending commit in rage * @returns patch generated */ -export async function formatPatch( - repository: Repository, - base: string, - head: string -): Promise { +export function formatPatch({ path }: Repository, base: string, head: string) { const range = revRange(base, head) - const { output } = await spawnAndComplete( - ['format-patch', '--unified=1', '--minimal', '--stdout', range], - repository.path, - 'formatPatch' - ) - return output.toString('utf8') + const args = ['format-patch', '--unified=1', '--minimal', '--stdout', range] + return git(args, path, 'formatPatch').then(x => x.stdout) } diff --git a/app/src/lib/git/git-delimiter-parser.ts b/app/src/lib/git/git-delimiter-parser.ts index 4faf944598a..a56d4cfc23a 100644 --- a/app/src/lib/git/git-delimiter-parser.ts +++ b/app/src/lib/git/git-delimiter-parser.ts @@ -1,3 +1,5 @@ +import { splitBuffer } from '../split-buffer' + /** * Create a new parser suitable for parsing --format output from commands such * as `git log`, `git stash`, and other commands that are not derived from @@ -12,19 +14,20 @@ * Example: * * `const { args, parse } = createLogParser({ sha: '%H' })` - * */ export function createLogParser>(fields: T) { const keys: Array = Object.keys(fields) const format = Object.values(fields).join('%x00') const formatArgs = ['-z', `--format=${format}`] - const parse = (value: string) => { - const records = value.split('\0') + const parse = (value: V) => { + const records = ( + Buffer.isBuffer(value) ? splitBuffer(value, '\0') : value.split('\0') + ) as V[] const entries = [] for (let i = 0; i < records.length - keys.length; i += keys.length) { - const entry = {} as { [K in keyof T]: string } + const entry = {} as { [K in keyof T]: V } keys.forEach((key, ix) => (entry[key] = records[i + ix])) entries.push(entry) } @@ -49,7 +52,6 @@ export function createLogParser>(fields: T) { * Example: * * `const { args, parse } = createForEachRefParser({ sha: '%(objectname)' })` - * */ export function createForEachRefParser>( fields: T diff --git a/app/src/lib/git/gitignore.ts b/app/src/lib/git/gitignore.ts index 540561f1e30..d43cdbd0a89 100644 --- a/app/src/lib/git/gitignore.ts +++ b/app/src/lib/git/gitignore.ts @@ -137,7 +137,7 @@ async function formatGitIgnoreContents( return } - if (text.endsWith('\n')) { + if (text === '' || text.endsWith('\n')) { resolve(text) return } diff --git a/app/src/lib/git/interpret-trailers.ts b/app/src/lib/git/interpret-trailers.ts index ef9c69fd94c..d3c1d09af03 100644 --- a/app/src/lib/git/interpret-trailers.ts +++ b/app/src/lib/git/interpret-trailers.ts @@ -157,6 +157,9 @@ export async function mergeTrailers( ) { const args = ['interpret-trailers'] + // See https://github.com/git/git/blob/ebf3c04b262aa/Documentation/git-interpret-trailers.txt#L129-L132 + args.push('--no-divider') + if (unfold) { args.push('--unfold') } diff --git a/app/src/lib/git/log.ts b/app/src/lib/git/log.ts index 0de475dc8f0..b54f2924b9b 100644 --- a/app/src/lib/git/log.ts +++ b/app/src/lib/git/log.ts @@ -5,61 +5,115 @@ import { PlainFileStatus, CopiedOrRenamedFileStatus, UntrackedFileStatus, + AppFileStatus, + SubmoduleStatus, } from '../../models/status' import { Repository } from '../../models/repository' import { Commit } from '../../models/commit' import { CommitIdentity } from '../../models/commit-identity' -import { - getTrailerSeparatorCharacters, - parseRawUnfoldedTrailers, -} from './interpret-trailers' -import { getCaptures } from '../helpers/regex' +import { parseRawUnfoldedTrailers } from './interpret-trailers' import { createLogParser } from './git-delimiter-parser' -import { revRange } from '.' -import { enableLineChangesInCommit } from '../feature-flag' +import { forceUnwrap } from '../fatal-error' +import assert from 'assert' + +// File mode 160000 is used by git specifically for submodules: +// https://github.com/git/git/blob/v2.37.3/cache.h#L62-L69 +const SubmoduleFileMode = '160000' + +function mapSubmoduleStatusFileModes( + status: string, + srcMode: string, + dstMode: string +): SubmoduleStatus | undefined { + return srcMode === SubmoduleFileMode && + dstMode === SubmoduleFileMode && + status === 'M' + ? { + commitChanged: true, + untrackedChanges: false, + modifiedChanges: false, + } + : (srcMode === SubmoduleFileMode && status === 'D') || + (dstMode === SubmoduleFileMode && status === 'A') + ? { + commitChanged: false, + untrackedChanges: false, + modifiedChanges: false, + } + : undefined +} /** * Map the raw status text from Git to an app-friendly value * shamelessly borrowed from GitHub Desktop (Windows) */ -export function mapStatus( +function mapStatus( rawStatus: string, - oldPath?: string + oldPath: string | undefined, + srcMode: string, + dstMode: string ): PlainFileStatus | CopiedOrRenamedFileStatus | UntrackedFileStatus { const status = rawStatus.trim() + const submoduleStatus = mapSubmoduleStatusFileModes(status, srcMode, dstMode) if (status === 'M') { - return { kind: AppFileStatusKind.Modified } + return { kind: AppFileStatusKind.Modified, submoduleStatus } } // modified if (status === 'A') { - return { kind: AppFileStatusKind.New } + return { kind: AppFileStatusKind.New, submoduleStatus } } // added if (status === '?') { - return { kind: AppFileStatusKind.Untracked } + return { kind: AppFileStatusKind.Untracked, submoduleStatus } } // untracked if (status === 'D') { - return { kind: AppFileStatusKind.Deleted } + return { kind: AppFileStatusKind.Deleted, submoduleStatus } } // deleted if (status === 'R' && oldPath != null) { - return { kind: AppFileStatusKind.Renamed, oldPath } + return { + kind: AppFileStatusKind.Renamed, + oldPath, + submoduleStatus, + renameIncludesModifications: false, + } } // renamed if (status === 'C' && oldPath != null) { - return { kind: AppFileStatusKind.Copied, oldPath } + return { + kind: AppFileStatusKind.Copied, + oldPath, + submoduleStatus, + renameIncludesModifications: false, + } } // copied // git log -M --name-status will return a RXXX - where XXX is a percentage if (status.match(/R[0-9]+/) && oldPath != null) { - return { kind: AppFileStatusKind.Renamed, oldPath } + return { + kind: AppFileStatusKind.Renamed, + oldPath, + submoduleStatus, + renameIncludesModifications: status !== 'R100', + } } // git log -C --name-status will return a CXXX - where XXX is a percentage if (status.match(/C[0-9]+/) && oldPath != null) { - return { kind: AppFileStatusKind.Copied, oldPath } + return { + kind: AppFileStatusKind.Copied, + oldPath, + submoduleStatus, + renameIncludesModifications: false, + } } - return { kind: AppFileStatusKind.Modified } + return { kind: AppFileStatusKind.Modified, submoduleStatus } } +const isCopyOrRename = ( + status: AppFileStatus +): status is CopiedOrRenamedFileStatus => + status.kind === AppFileStatusKind.Copied || + status.kind === AppFileStatusKind.Renamed + /** * Get the repository's commits using `revisionRange` and limited to `limit` */ @@ -110,6 +164,7 @@ export async function getCommits( ) const result = await git(args, repository.path, 'getCommits', { successExitCodes: new Set([0, 128]), + encoding: 'buffer', }) // if the repository has an unborn HEAD, return an empty history of commits @@ -117,23 +172,33 @@ export async function getCommits( return new Array() } - const trailerSeparators = await getTrailerSeparatorCharacters(repository) const parsed = parse(result.stdout) return parsed.map(commit => { - const tags = getCaptures(commit.refs, /tag: ([^\s,]+)/g) - .filter(i => i[0] !== undefined) - .map(i => i[0]) + // Ref is of the format: (HEAD -> master, tag: some-tag-name, tag: some-other-tag,with-a-comma, origin/master, origin/HEAD) + // Refs are comma separated, but some like tags can also contain commas in the name, so we split on the pattern ", " and then + // check each ref for the tag prefix. We used to use the regex /tag: ([^\s,]+)/g)`, but will clip a tag with a comma short. + const tags = commit.refs + .toString() + .split(', ') + .flatMap(ref => (ref.startsWith('tag: ') ? ref.substring(5) : [])) return new Commit( - commit.sha, - commit.shortSha, - commit.summary, - commit.body, - CommitIdentity.parseIdentity(commit.author), - CommitIdentity.parseIdentity(commit.committer), - commit.parents.length > 0 ? commit.parents.split(' ') : [], - parseRawUnfoldedTrailers(commit.trailers, trailerSeparators), + commit.sha.toString(), + commit.shortSha.toString(), + commit.summary.subarray(0, 100 * 1024).toString(), + commit.body.subarray(0, 100 * 1024).toString(), + CommitIdentity.parseIdentity(commit.author.toString()), + CommitIdentity.parseIdentity(commit.committer.toString()), + commit.parents.length > 0 ? commit.parents.toString().split(' ') : [], + // We know for sure that the trailer separator will be ':' since we got + // them from %(trailers:unfold) above, see `git help log`: + // + // "key_value_separator=: specify a separator inserted between + // trailer lines. When this option is not given each trailer key-value + // pair is separated by ": ". Otherwise it shares the same semantics as + // separator= above." + parseRawUnfoldedTrailers(commit.trailers.toString(), ':'), tags ) }) @@ -159,7 +224,7 @@ export async function getChangedFiles( // opt-in for rename detection (-M) and copies detection (-C) // this is equivalent to the user configuring 'diff.renames' to 'copies' // NOTE: order here matters - doing -M before -C means copies aren't detected - const baseArgs = [ + const args = [ 'log', sha, '-C', @@ -168,101 +233,104 @@ export async function getChangedFiles( '-1', '--no-show-signature', '--first-parent', + '--raw', '--format=format:', + '--numstat', '-z', + '--', ] - // Run `git log` to obtain the file names and their state - const resultNameStatus = await git( - [...baseArgs, '--name-status', '--'], - repository.path, - 'getChangedFilesNameStatus' - ) - - const files = parseChangedFiles(resultNameStatus.stdout, sha) - - if (!enableLineChangesInCommit()) { - return { files, linesAdded: 0, linesDeleted: 0 } - } - - // Run `git log` again, but this time to get the number of lines added/deleted - // per file - const resultNumStat = await git( - [...baseArgs, '--numstat', '--'], - repository.path, - 'getChangedFilesNumStats' - ) - - const linesChanged = parseChangedFilesNumStat(resultNumStat.stdout) - - return { - files, - ...linesChanged, - } -} - -function parseChangedFilesNumStat(stdout: string): { - linesAdded: number - linesDeleted: number -} { - const lines = stdout.split('\0') - let totalLinesAdded = 0 - let totalLinesDeleted = 0 - - for (const line of lines) { - const parts = line.split('\t') - if (parts.length !== 3) { - continue - } - - const [added, deleted] = parts - - if (added === '-' || deleted === '-') { - continue - } - - totalLinesAdded += parseInt(added, 10) - totalLinesDeleted += parseInt(deleted, 10) - } - - return { linesAdded: totalLinesAdded, linesDeleted: totalLinesDeleted } + const { stdout } = await git(args, repository.path, 'getChangesFiles') + return parseRawLogWithNumstat(stdout, sha, `${sha}^`) } /** - * Parses git `log` or `diff` output into a list of changed files - * (see `getChangedFiles` for an example of use) + * Parses output of diff flags -z --raw --numstat. + * + * Given the -z flag the new lines are separated by \0 character (left them as + * new lines below for ease of reading) + * + * For modified, added, deleted, untracked: + * 100644 100644 5716ca5 db3c77d M + * file_one_path + * :100644 100644 0835e4f 28096ea M + * file_two_path + * 1 0 file_one_path + * 1 0 file_two_path * - * @param stdout raw output from a git `-z` and `--name-status` flags - * @param committish commitish command was run against + * For copied or renamed: + * 100644 100644 5716ca5 db3c77d M + * file_one_original_path + * file_one_new_path + * :100644 100644 0835e4f 28096ea M + * file_two_original_path + * file_two_new_path + * 1 0 + * file_one_original_path + * file_one_new_path + * 1 0 + * file_two_original_path + * file_two_new_path */ -export function parseChangedFiles( + +export function parseRawLogWithNumstat( stdout: string, - committish: string -): ReadonlyArray { + sha: string, + parentCommitish: string +) { + const files = new Array() + let linesAdded = 0 + let linesDeleted = 0 + let numStatCount = 0 const lines = stdout.split('\0') - // Remove the trailing empty line - lines.splice(-1, 1) - const files: CommittedFileChange[] = [] - for (let i = 0; i < lines.length; i++) { - const statusText = lines[i] - - let oldPath: string | undefined = undefined - - if ( - statusText.length > 0 && - (statusText[0] === 'R' || statusText[0] === 'C') - ) { - oldPath = lines[++i] - } - const status = mapStatus(statusText, oldPath) - - const path = lines[++i] - - files.push(new CommittedFileChange(path, status, committish)) + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i] + if (line.startsWith(':')) { + const lineComponents = line.split(' ') + const srcMode = forceUnwrap( + 'Invalid log output (srcMode)', + lineComponents[0]?.replace(':', '') + ) + const dstMode = forceUnwrap( + 'Invalid log output (dstMode)', + lineComponents[1] + ) + const status = forceUnwrap( + 'Invalid log output (status)', + lineComponents.at(-1) + ) + const oldPath = /^R|C/.test(status) + ? forceUnwrap('Missing old path', lines.at(++i)) + : undefined + + const path = forceUnwrap('Missing path', lines.at(++i)) + + files.push( + new CommittedFileChange( + path, + mapStatus(status, oldPath, srcMode, dstMode), + sha, + parentCommitish + ) + ) + } else { + const match = /^(\d+|-)\t(\d+|-)\t/.exec(line) + const [, added, deleted] = forceUnwrap('Invalid numstat line', match) + linesAdded += added === '-' ? 0 : parseInt(added, 10) + linesDeleted += deleted === '-' ? 0 : parseInt(deleted, 10) + + // If this entry denotes a rename or copy the old and new paths are on + // two separate fields (separated by \0). Otherwise they're on the same + // line as the added and deleted lines. + if (isCopyOrRename(files[numStatCount].status)) { + i += 2 + } + numStatCount++ + } } - return files + return { files, linesAdded, linesDeleted } } /** Get the commit for the given ref. */ @@ -278,24 +346,31 @@ export async function getCommit( return commits[0] } -/** - * Determine if merge commits exist in history after given commit - * If no commitRef is null, goes back to HEAD of branch. - */ -export async function doMergeCommitsExistAfterCommit( - repository: Repository, - commitRef: string | null -): Promise { - const commitRevRange = - commitRef === null ? undefined : revRange(commitRef, 'HEAD') - - const mergeCommits = await getCommits( - repository, - commitRevRange, - undefined, - undefined, - ['--merges'] +/** Get the author identity for the given shas */ +export async function getAuthors(repository: Repository, shas: string[]) { + if (shas.length === 0) { + return [] + } + + const { stdout } = await git( + [ + 'log', + '--format=format:%an <%ae> %ad', + '--no-walk=unsorted', + '--date=raw', + '-z', + '--stdin', + ], + repository.path, + 'getAuthors', + { stdin: shas.join('\n') } ) - return mergeCommits.length > 0 + const authors = stdout.split('\0').map(CommitIdentity.parseIdentity) + + // This can happen if there are duplicate shas in the input, git log will only + // return the author once for each sha. + assert.equal(authors.length, shas.length, 'Commit to author mismatch') + + return authors } diff --git a/app/src/lib/git/merge-tree.ts b/app/src/lib/git/merge-tree.ts index eb6b5a58770..a6e469901c1 100644 --- a/app/src/lib/git/merge-tree.ts +++ b/app/src/lib/git/merge-tree.ts @@ -1,117 +1,41 @@ -import byline from 'byline' import { Branch } from '../../models/branch' import { ComputedAction } from '../../models/computed-action' import { MergeTreeResult } from '../../models/merge' import { Repository } from '../../models/repository' -import { isErrnoException } from '../errno-exception' -import { getMergeBase } from './merge' -import { spawnGit } from './spawn' - -// the merge-tree output is a collection of entries like this -// -// changed in both -// base 100644 f69fbc5c40409a1db7a3f8353bfffe46a21d6054 atom/browser/resources/mac/Info.plist -// our 100644 9094f0f7335edf833d51f688851e6a105de60433 atom/browser/resources/mac/Info.plist -// their 100644 2dd8bc646cff3869557549a39477e30022e6cfdd atom/browser/resources/mac/Info.plist -// @@ -17,9 +17,15 @@ -// CFBundleIconFile -// electron.icns -// CFBundleVersion -// +<<<<<<< .our -// 4.0.0 -// CFBundleShortVersionString -// 4.0.0 -// +======= -// + 1.4.16 -// + CFBundleShortVersionString -// + 1.4.16 -// +>>>>>>> .their -// LSApplicationCategoryType -//public.app-category.developer-tools -// LSMinimumSystemVersion - -// The first line for each entry is what I'm referring to as the the header -// This regex filters on the known entries that can appear -const contextHeaderRe = - /^(merged|added in remote|removed in remote|changed in both|removed in local|added in both)$/ - -const conflictMarkerRe = /^\+[<>=]{7}$/ +import { git, isGitError } from './core' +import { GitError } from 'dugite' export async function determineMergeability( repository: Repository, ours: Branch, theirs: Branch -): Promise { - const mergeBase = await getMergeBase(repository, ours.tip.sha, theirs.tip.sha) - - if (mergeBase === null) { - return { kind: ComputedAction.Invalid } - } - - if (mergeBase === ours.tip.sha || mergeBase === theirs.tip.sha) { - return { kind: ComputedAction.Clean } - } - - const process = await spawnGit( - ['merge-tree', mergeBase, ours.tip.sha, theirs.tip.sha], +) { + return git( + [ + 'merge-tree', + '--write-tree', + '--name-only', + '--no-messages', + '-z', + ours.tip.sha, + theirs.tip.sha, + ], repository.path, - 'mergeTree' + 'determineMergeability', + { successExitCodes: new Set([0, 1]) } ) - - return await new Promise((resolve, reject) => { - const mergeTreeResultPromise: Promise = - process.stdout !== null - ? parseMergeTreeResult(process.stdout) - : Promise.reject(new Error('Failed reading merge-tree output')) - - // If this is an exception thrown by Node.js while attempting to - // spawn let's keep the salient details but include the name of - // the operation. - process.on('error', e => - reject( - isErrnoException(e) ? new Error(`merge-tree failed: ${e.code}`) : e - ) - ) - - process.on('exit', code => { - if (code !== 0) { - reject(new Error(`merge-tree exited with code '${code}'`)) - } else { - mergeTreeResultPromise.then(resolve, reject) - } + .then(({ stdout }) => { + // The output will be "\0[\0]*" so we can get the + // number of conflicted files by counting the number of null bytes and + // subtracting one for the tree id. + const conflictedFiles = (stdout.match(/\0/g)?.length ?? 0) - 1 + return conflictedFiles > 0 + ? { kind: ComputedAction.Conflicts, conflictedFiles } + : { kind: ComputedAction.Clean } }) - }) -} - -export function parseMergeTreeResult(stream: NodeJS.ReadableStream) { - return new Promise(resolve => { - let seenConflictMarker = false - let conflictedFiles = 0 - - stream - .pipe(byline()) - .on('data', (line: string) => { - // New header means new file, reset conflict flag and record if we've - // seen a conflict in this file or not - if (contextHeaderRe.test(line)) { - if (seenConflictMarker) { - conflictedFiles++ - seenConflictMarker = false - } - } else if (conflictMarkerRe.test(line)) { - seenConflictMarker = true - } - }) - .on('end', () => { - if (seenConflictMarker) { - conflictedFiles++ - } - - resolve( - conflictedFiles > 0 - ? { kind: ComputedAction.Conflicts, conflictedFiles } - : { kind: ComputedAction.Clean } - ) - }) - }) + .catch(e => + isGitError(e, GitError.CannotMergeUnrelatedHistories) + ? Promise.resolve({ kind: ComputedAction.Invalid }) + : Promise.reject(e) + ) } diff --git a/app/src/lib/git/pull.ts b/app/src/lib/git/pull.ts index fa95a8c9104..3be7ab34ec2 100644 --- a/app/src/lib/git/pull.ts +++ b/app/src/lib/git/pull.ts @@ -1,15 +1,7 @@ -import { - git, - GitError, - IGitExecutionOptions, - gitNetworkArguments, - gitRebaseArguments, -} from './core' +import { git, gitRebaseArguments, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { IPullProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { PullProgressParser, executionOptionsWithProgress } from '../progress' -import { AuthenticationErrors } from './authentication' import { enableRecurseSubmodulesFlag } from '../feature-flag' import { IRemote } from '../../models/remote' import { envForRemoteOperation } from './environment' @@ -18,31 +10,16 @@ import { getConfigValue } from './config' async function getPullArgs( repository: Repository, remote: string, - account: IGitAccount | null, progressCallback?: (progress: IPullProgress) => void ) { - const divergentPathArgs = await getDefaultPullDivergentBranchArguments( - repository - ) - - const args = [ - ...gitNetworkArguments(), + return [ ...gitRebaseArguments(), 'pull', - ...divergentPathArgs, + ...(await getDefaultPullDivergentBranchArguments(repository)), + ...(enableRecurseSubmodulesFlag() ? ['--recurse-submodules'] : []), + ...(progressCallback ? ['--progress'] : []), + remote, ] - - if (enableRecurseSubmodulesFlag()) { - args.push('--recurse-submodules') - } - - if (progressCallback != null) { - args.push('--progress') - } - - args.push(remote) - - return args } /** @@ -60,13 +37,11 @@ async function getPullArgs( */ export async function pull( repository: Repository, - account: IGitAccount | null, remote: IRemote, progressCallback?: (progress: IPullProgress) => void ): Promise { - let opts: IGitExecutionOptions = { - env: await envForRemoteOperation(account, remote.url), - expectedErrors: AuthenticationErrors, + let opts: IGitStringExecutionOptions = { + env: await envForRemoteOperation(remote.url), } if (progressCallback) { @@ -106,17 +81,8 @@ export async function pull( progressCallback({ kind, title, value: 0, remote: remote.name }) } - const args = await getPullArgs( - repository, - remote.name, - account, - progressCallback - ) - const result = await git(args, repository.path, 'pull', opts) - - if (result.gitErrorDescription) { - throw new GitError(result, args) - } + const args = await getPullArgs(repository, remote.name, progressCallback) + await git(args, repository.path, 'pull', opts) } /** diff --git a/app/src/lib/git/push.ts b/app/src/lib/git/push.ts index eb81701baf1..a58f89e8377 100644 --- a/app/src/lib/git/push.ts +++ b/app/src/lib/git/push.ts @@ -1,18 +1,10 @@ -import { GitError as DugiteError } from 'dugite' - -import { - git, - IGitExecutionOptions, - gitNetworkArguments, - GitError, -} from './core' +import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { IPushProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { PushProgressParser, executionOptionsWithProgress } from '../progress' -import { AuthenticationErrors } from './authentication' import { IRemote } from '../../models/remote' import { envForRemoteOperation } from './environment' +import { Branch } from '../../models/branch' export type PushOptions = { /** @@ -22,6 +14,9 @@ export type PushOptions = { * See https://git-scm.com/docs/git-push#Documentation/git-push.txt---no-force-with-lease */ readonly forceWithLease: boolean + + /** A branch to push instead of the current branch */ + readonly branch?: Branch } /** @@ -50,7 +45,6 @@ export type PushOptions = { */ export async function push( repository: Repository, - account: IGitAccount | null, remote: IRemote, localBranch: string, remoteBranch: string | null, @@ -61,7 +55,6 @@ export async function push( progressCallback?: (progress: IPushProgress) => void ): Promise { const args = [ - ...gitNetworkArguments(), 'push', remote.name, remoteBranch ? `${localBranch}:${remoteBranch}` : localBranch, @@ -76,12 +69,8 @@ export async function push( args.push('--force-with-lease') } - const expectedErrors = new Set(AuthenticationErrors) - expectedErrors.add(DugiteError.ProtectedBranchForcePush) - - let opts: IGitExecutionOptions = { - env: await envForRemoteOperation(account, remote.url), - expectedErrors, + let opts: IGitStringExecutionOptions = { + env: await envForRemoteOperation(remote.url), } if (progressCallback) { @@ -118,9 +107,5 @@ export async function push( }) } - const result = await git(args, repository.path, 'push', opts) - - if (result.gitErrorDescription) { - throw new GitError(result, args) - } + await git(args, repository.path, 'push', opts) } diff --git a/app/src/lib/git/rebase.ts b/app/src/lib/git/rebase.ts index 5e84cca5bf1..502d1d4c522 100644 --- a/app/src/lib/git/rebase.ts +++ b/app/src/lib/git/rebase.ts @@ -18,9 +18,10 @@ import { formatRebaseValue } from '../rebase' import { git, - IGitResult, IGitExecutionOptions, gitRebaseArguments, + IGitStringExecutionOptions, + IGitStringResult, } from './core' import { stageManualConflictResolution } from './stage' import { stageFiles } from './update-index' @@ -37,6 +38,11 @@ export enum RebaseResult { * signal success to the user. */ CompletedWithoutError = 'CompletedWithoutError', + /** + * Git completed the rebase without reporting any errors, but the branch was + * already up to date and there was nothing to do. + */ + AlreadyUpToDate = 'AlreadyUpToDate', /** * The rebase encountered conflicts while attempting to rebase, and these * need to be resolved by the user before the rebase can continue. @@ -308,8 +314,8 @@ class GitRebaseParser { } } -function configureOptionsForRebase( - options: IGitExecutionOptions, +function configureOptionsForRebase( + options: T, progress?: RebaseProgressOptions ) { if (progress === undefined) { @@ -356,7 +362,7 @@ export async function rebase( targetBranch: Branch, progressCallback?: (progress: IMultiCommitOperationProgress) => void ): Promise { - const baseOptions: IGitExecutionOptions = { + const baseOptions: IGitStringExecutionOptions = { expectedErrors: new Set([GitError.RebaseConflicts]), } @@ -400,8 +406,12 @@ export async function abortRebase(repository: Repository) { await git(['rebase', '--abort'], repository.path, 'abortRebase') } -function parseRebaseResult(result: IGitResult): RebaseResult { +function parseRebaseResult(result: IGitStringResult): RebaseResult { if (result.exitCode === 0) { + if (result.stdout.trim().match(/^Current branch [^ ]+ is up to date.$/i)) { + return RebaseResult.AlreadyUpToDate + } + return RebaseResult.CompletedWithoutError } @@ -451,7 +461,7 @@ export async function continueRebase( await stageFiles(repository, otherFiles) - const status = await getStatus(repository) + const status = await getStatus(repository, false) if (status == null) { log.warn( `[continueRebase] unable to get status after staging changes, skipping any other steps` @@ -468,7 +478,7 @@ export async function continueRebase( f => f.status.kind !== AppFileStatusKind.Untracked ) - const baseOptions: IGitExecutionOptions = { + const baseOptions: IGitStringExecutionOptions = { expectedErrors: new Set([ GitError.RebaseConflicts, GitError.UnresolvedConflicts, @@ -545,9 +555,10 @@ export async function rebaseInteractive( progressCallback?: (progress: IMultiCommitOperationProgress) => void, commits?: ReadonlyArray ): Promise { - const baseOptions: IGitExecutionOptions = { + const baseOptions: IGitStringExecutionOptions = { expectedErrors: new Set([GitError.RebaseConflicts]), env: { + GIT_SEQUENCE_EDITOR: undefined, GIT_EDITOR: gitEditor, }, } diff --git a/app/src/lib/git/remote.ts b/app/src/lib/git/remote.ts index 7032bd7d77c..dc07b6a51e2 100644 --- a/app/src/lib/git/remote.ts +++ b/app/src/lib/git/remote.ts @@ -3,6 +3,8 @@ import { GitError } from 'dugite' import { Repository } from '../../models/repository' import { IRemote } from '../../models/remote' +import { envForRemoteOperation } from './environment' +import { getSymbolicRef } from './refs' /** * List the remotes, sorted alphabetically by `name`, for a repository. @@ -18,14 +20,9 @@ export async function getRemotes( return [] } - const output = result.stdout - const lines = output.split('\n') - const remotes = lines - .filter(x => x.endsWith('(fetch)')) - .map(x => x.split(/\s+/)) - .map(x => ({ name: x[0], url: x[1] })) - - return remotes + return [...result.stdout.matchAll(/^(.+)\t(.+)\s\(fetch\)/gm)].map( + ([, name, url]) => ({ name, url }) + ) } /** Add a new remote with the given URL. */ @@ -88,3 +85,48 @@ export async function getRemoteURL( return result.stdout } + +/** + * Update the HEAD ref of the remote, which is the default branch. + * + * @param isBackgroundTask Whether the fetch is being performed as a + * background task as opposed to being user initiated + */ +export async function updateRemoteHEAD( + repository: Repository, + remote: IRemote, + isBackgroundTask: boolean +): Promise { + const options = { + successExitCodes: new Set([0, 1, 128]), + env: await envForRemoteOperation(remote.url), + isBackgroundTask, + } + + await git( + ['remote', 'set-head', '-a', remote.name], + repository.path, + 'updateRemoteHEAD', + options + ) +} + +export async function getRemoteHEAD( + repository: Repository, + remote: string +): Promise { + const remoteNamespace = `refs/remotes/${remote}/` + const match = await getSymbolicRef(repository, `${remoteNamespace}HEAD`) + if ( + match != null && + match.length > remoteNamespace.length && + match.startsWith(remoteNamespace) + ) { + // strip out everything related to the remote because this + // is likely to be a tracked branch locally + // e.g. `main`, `develop`, etc + return match.substring(remoteNamespace.length) + } + + return null +} diff --git a/app/src/lib/git/rev-list.ts b/app/src/lib/git/rev-list.ts index 6e87fe31c2a..cdb65b75e14 100644 --- a/app/src/lib/git/rev-list.ts +++ b/app/src/lib/git/rev-list.ts @@ -182,3 +182,20 @@ export async function getCommitsInRange( return commits } + +/** + * Determine if merge commits exist in history after given commit + * If commitRef is null, goes back to HEAD of branch. + */ +export async function doMergeCommitsExistAfterCommit( + repository: Repository, + commitRef: string | null +): Promise { + const revision = commitRef === null ? 'HEAD' : revRange(commitRef, 'HEAD') + const args = ['rev-list', '-1', '--merges', revision, '--'] + + return git(args, repository.path, 'doMergeCommitsExistAfterCommit', { + // 128 here means there's no HEAD, i.e we're on an unborn branch + successExitCodes: new Set([0, 128]), + }).then(x => x.stdout.length > 0) +} diff --git a/app/src/lib/git/rev-parse.ts b/app/src/lib/git/rev-parse.ts index fb59ece94a3..27414411a5e 100644 --- a/app/src/lib/git/rev-parse.ts +++ b/app/src/lib/git/rev-parse.ts @@ -37,7 +37,7 @@ export async function getRepositoryType(path: string): Promise { } const unsafeMatch = - /fatal: unsafe repository \('(.+)\' is owned by someone else\)/.exec( + /fatal: detected dubious ownership in repository at '(.+)'/.exec( result.stderr ) if (unsafeMatch) { @@ -56,3 +56,23 @@ export async function getRepositoryType(path: string): Promise { throw err } } + +export async function getUpstreamRefForRef(path: string, ref?: string) { + const rev = (ref ?? '') + '@{upstream}' + const args = ['rev-parse', '--symbolic-full-name', rev] + const opts = { successExitCodes: new Set([0, 128]) } + const result = await git(args, path, 'getUpstreamRefForRef', opts) + + return result.exitCode === 0 ? result.stdout.trim() : null +} + +export async function getUpstreamRemoteNameForRef(path: string, ref?: string) { + const remoteRef = await getUpstreamRefForRef(path, ref) + return remoteRef?.match(/^refs\/remotes\/([^/]+)\//)?.[1] ?? null +} + +export const getCurrentUpstreamRef = (path: string) => + getUpstreamRefForRef(path) + +export const getCurrentUpstreamRemoteName = (path: string) => + getUpstreamRemoteNameForRef(path) diff --git a/app/src/lib/git/revert.ts b/app/src/lib/git/revert.ts index 8cfeadd320a..6c8129f57a8 100644 --- a/app/src/lib/git/revert.ts +++ b/app/src/lib/git/revert.ts @@ -1,9 +1,8 @@ -import { git, gitNetworkArguments, IGitExecutionOptions } from './core' +import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { Commit } from '../../models/commit' import { IRevertProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { executionOptionsWithProgress } from '../progress/from-process' import { RevertProgressParser } from '../progress/revert' @@ -11,6 +10,7 @@ import { envForRemoteOperation, getFallbackUrlForProxyResolve, } from './environment' +import { IRemote } from '../../models/remote' /** * Creates a new commit that reverts the changes of a previous commit @@ -18,26 +18,24 @@ import { * @param repository - The repository to update * * @param commit - The SHA of the commit to be reverted - * */ export async function revertCommit( repository: Repository, commit: Commit, - account: IGitAccount | null, + currentRemote: IRemote | null, progressCallback?: (progress: IRevertProgress) => void ) { - const args = [...gitNetworkArguments(), 'revert'] + const args = ['revert'] if (commit.parentSHAs.length > 1) { args.push('-m', '1') } args.push(commit.sha) - let opts: IGitExecutionOptions = {} + let opts: IGitStringExecutionOptions = {} if (progressCallback) { const env = await envForRemoteOperation( - account, - getFallbackUrlForProxyResolve(account, repository) + getFallbackUrlForProxyResolve(repository, currentRemote) ) opts = await executionOptionsWithProgress( { env, trackLFSProgress: true }, diff --git a/app/src/lib/git/show.ts b/app/src/lib/git/show.ts index cc7a35bbe77..ec91683609d 100644 --- a/app/src/lib/git/show.ts +++ b/app/src/lib/git/show.ts @@ -1,9 +1,7 @@ -import { ChildProcess } from 'child_process' - -import { git } from './core' -import { spawnAndComplete } from './spawn' +import { coerceToBuffer, git, isMaxBufferExceededError } from './core' import { Repository } from '../../models/repository' +import { GitError } from 'dugite' /** * Retrieve the binary contents of a blob from the repository at a given @@ -21,30 +19,15 @@ import { Repository } from '../../models/repository' * @param path - The file path, relative to the repository * root from where to read the blob contents */ -export async function getBlobContents( +export const getBlobContents = ( repository: Repository, commitish: string, path: string -): Promise { - const successExitCodes = new Set([0, 1]) - const setBinaryEncoding: (process: ChildProcess) => void = cb => { - // If Node.js encounters a synchronous runtime error while spawning - // `stdout` will be undefined and the error will be emitted asynchronously - if (cb.stdout) { - cb.stdout.setEncoding('binary') - } - } - - const args = ['show', `${commitish}:${path}`] - const opts = { - successExitCodes, - processCallback: setBinaryEncoding, - } - - const blobContents = await git(args, repository.path, 'getBlobContents', opts) - - return Buffer.from(blobContents.stdout, 'binary') -} +) => + git(['show', `${commitish}:${path}`], repository.path, 'getBlobContents', { + successExitCodes: new Set([0, 1]), + encoding: 'buffer', + }).then(r => r.stdout) /** * Retrieve some or all binary contents of a blob from the repository @@ -73,18 +56,32 @@ export async function getPartialBlobContents( commitish: string, path: string, length: number -): Promise { - const successExitCodes = new Set([0, 1]) - - const args = ['show', `${commitish}:${path}`] - - const { output } = await spawnAndComplete( - args, - repository.path, - 'getPartialBlobContents', - successExitCodes, +): Promise { + return getPartialBlobContentsCatchPathNotInRef( + repository, + commitish, + path, length ) +} + +export async function getPartialBlobContentsCatchPathNotInRef( + repository: Repository, + commitish: string, + path: string, + length: number +): Promise { + const args = ['show', `${commitish}:${path}`] - return output + return git(args, repository.path, 'getPartialBlobContentsCatchPathNotInRef', { + maxBuffer: length, + expectedErrors: new Set([GitError.PathExistsButNotInRef]), + encoding: 'buffer', + }) + .then(r => + r.gitError === GitError.PathExistsButNotInRef ? null : r.stdout + ) + .catch(e => + isMaxBufferExceededError(e) ? coerceToBuffer(e.stdout) : Promise.reject(e) + ) } diff --git a/app/src/lib/git/spawn.ts b/app/src/lib/git/spawn.ts index 5eb2831274d..73fe80bce43 100644 --- a/app/src/lib/git/spawn.ts +++ b/app/src/lib/git/spawn.ts @@ -1,16 +1,13 @@ -import { GitProcess } from 'dugite' -import { IGitSpawnExecutionOptions } from 'dugite/build/lib/git-process' +import { spawn, IGitSpawnOptions } from 'dugite' import * as GitPerf from '../../ui/lib/git-perf' -import { isErrnoException } from '../errno-exception' import { withTrampolineEnv } from '../trampoline/trampoline-environment' -type ProcessOutput = { - /** The contents of stdout received from the spawned process */ - output: Buffer - /** The contents of stderr received from the spawned process */ - error: Buffer - /** The exit code returned by the spawned process */ - exitCode: number | null +type SpawnOptions = IGitSpawnOptions & { + /** + * Whether the command about to run is part of a background task or not. + * This affects error handling and UI such as credential prompts. + */ + readonly isBackgroundTask?: boolean } /** @@ -20,124 +17,22 @@ type ProcessOutput = { * @param path The path to execute the command from. * @param name The name of the operation - for tracing purposes. * @param successExitCodes An optional array of exit codes that indicate success. - * @param stdOutMaxLength An optional maximum number of bytes to read from stdout. - * If the process writes more than this number of bytes it - * will be killed silently and the truncated output is - * returned. */ export const spawnGit = ( args: string[], path: string, name: string, - options?: IGitSpawnExecutionOptions + options?: SpawnOptions ) => - withTrampolineEnv(trampolineEnv => - GitPerf.measure(`${name}: git ${args.join(' ')}`, async () => - GitProcess.spawn(args, path, { - ...options, - env: { ...options?.env, ...trampolineEnv }, - }) - ) - ) - -/** - * Spawn a Git process and buffer the stdout and stderr streams, deferring - * all processing work to the caller. - * - * @param args Array of strings to pass to the Git executable. - * @param path The path to execute the command from. - * @param name The name of the operation - for tracing purposes. - * @param successExitCodes An optional array of exit codes that indicate success. - * @param stdOutMaxLength An optional maximum number of bytes to read from stdout. - * If the process writes more than this number of bytes it - * will be killed silently and the truncated output is - * returned. - */ -export async function spawnAndComplete( - args: string[], - path: string, - name: string, - successExitCodes?: Set, - stdOutMaxLength?: number -): Promise { - return new Promise(async (resolve, reject) => { - const process = await spawnGit(args, path, name) - - process.on('error', err => { - // If this is an exception thrown by Node.js while attempting to - // spawn let's keep the salient details but include the name of - // the operation. - if (isErrnoException(err)) { - reject(new Error(`Failed to execute ${name}: ${err.code}`)) - } else { - // for unhandled errors raised by the process, let's surface this in the - // promise and make the caller handle it - reject(err) - } - }) - - let totalStdoutLength = 0 - let killSignalSent = false - - const stdoutChunks = new Array() - - // If Node.js encounters a synchronous runtime error while spawning - // `stdout` will be undefined and the error will be emitted asynchronously - if (process.stdout) { - process.stdout.on('data', (chunk: Buffer) => { - if (!stdOutMaxLength || totalStdoutLength < stdOutMaxLength) { - stdoutChunks.push(chunk) - totalStdoutLength += chunk.length - } - - if ( - stdOutMaxLength && - totalStdoutLength >= stdOutMaxLength && - !killSignalSent - ) { - process.kill() - killSignalSent = true - } - }) - } - - const stderrChunks = new Array() - - // See comment above about stdout and asynchronous errors. - if (process.stderr) { - process.stderr.on('data', (chunk: Buffer) => { - stderrChunks.push(chunk) - }) - } - - process.on('close', (code, signal) => { - const stdout = Buffer.concat( - stdoutChunks, - stdOutMaxLength - ? Math.min(stdOutMaxLength, totalStdoutLength) - : totalStdoutLength - ) - - const stderr = Buffer.concat(stderrChunks) - - // mimic the experience of GitProcess.exec for handling known codes when - // the process terminates - const exitCodes = successExitCodes || new Set([0]) - - if ((code !== null && exitCodes.has(code)) || signal) { - resolve({ - output: stdout, - error: stderr, - exitCode: code, + withTrampolineEnv( + trampolineEnv => + GitPerf.measure(`${name}: git ${args.join(' ')}`, async () => + spawn(args, path, { + ...options, + env: { ...options?.env, ...trampolineEnv }, }) - return - } else { - reject( - new Error( - `Git returned an unexpected exit code '${code}' which should be handled by the caller (${name}).'` - ) - ) - } - }) - }) -} + ), + path, + options?.isBackgroundTask ?? false, + options?.env + ) diff --git a/app/src/lib/git/stash.ts b/app/src/lib/git/stash.ts index d7bc81131c6..8667850a270 100644 --- a/app/src/lib/git/stash.ts +++ b/app/src/lib/git/stash.ts @@ -1,5 +1,5 @@ import { GitError as DugiteError } from 'dugite' -import { git, GitError } from './core' +import { coerceToString, git, GitError } from './core' import { Repository } from '../../models/repository' import { IStashEntry, @@ -10,9 +10,10 @@ import { WorkingDirectoryFileChange, CommittedFileChange, } from '../../models/status' -import { parseChangedFiles } from './log' +import { parseRawLogWithNumstat } from './log' import { stageFiles } from './update-index' import { Branch } from '../../models/branch' +import { createLogParser } from './git-delimiter-parser' export const DesktopStashEntryMarker = '!!GitHub_Desktop' @@ -41,17 +42,19 @@ type StashResult = { * as well as the total amount of stash entries. */ export async function getStashes(repository: Repository): Promise { - const delimiter = '1F' - const delimiterString = String.fromCharCode(parseInt(delimiter, 16)) - const format = ['%gD', '%H', '%gs'].join(`%x${delimiter}`) + const { formatArgs, parse } = createLogParser({ + name: '%gD', + stashSha: '%H', + message: '%gs', + tree: '%T', + parents: '%P', + }) const result = await git( - ['log', '-g', '-z', `--pretty=${format}`, 'refs/stash'], + ['log', '-g', ...formatArgs, 'refs/stash', '--'], repository.path, 'getStashEntries', - { - successExitCodes: new Set([0, 128]), - } + { successExitCodes: new Set([0, 128]) } ) // There's no refs/stashes reflog in the repository or it's not @@ -60,34 +63,55 @@ export async function getStashes(repository: Repository): Promise { return { desktopEntries: [], stashEntryCount: 0 } } - const desktopStashEntries: Array = [] - const files: StashedFileChanges = { - kind: StashedChangesLoadStates.NotLoaded, - } + const desktopEntries: Array = [] + const files: StashedFileChanges = { kind: StashedChangesLoadStates.NotLoaded } - const entries = result.stdout.split('\0').filter(s => s !== '') - for (const entry of entries) { - const pieces = entry.split(delimiterString) - - if (pieces.length === 3) { - const [name, stashSha, message] = pieces - const branchName = extractBranchFromMessage(message) - - if (branchName !== null) { - desktopStashEntries.push({ - name, - branchName, - stashSha, - files, - }) - } + const entries = parse(result.stdout) + + for (const { name, message, stashSha, tree, parents } of entries) { + const branchName = extractBranchFromMessage(message) + + if (branchName !== null) { + desktopEntries.push({ + name, + stashSha, + branchName, + tree, + parents: parents.length > 0 ? parents.split(' ') : [], + files, + }) } } - return { - desktopEntries: desktopStashEntries, - stashEntryCount: entries.length - 1, - } + return { desktopEntries, stashEntryCount: entries.length - 1 } +} + +/** + * Moves a stash entry to a different branch by means of creating + * a new stash entry associated with the new branch and dropping the old + * stash entry. + */ +export async function moveStashEntry( + repository: Repository, + { stashSha, parents, tree }: IStashEntry, + branchName: string +) { + const message = `On ${branchName}: ${createDesktopStashMessage(branchName)}` + const parentArgs = parents.flatMap(p => ['-p', p]) + + const { stdout: commitId } = await git( + ['commit-tree', ...parentArgs, '-m', message, '--no-gpg-sign', tree], + repository.path, + 'moveStashEntryToBranch' + ) + + await git( + ['stash', 'store', '-m', message, commitId.trim()], + repository.path, + 'moveStashEntryToBranch' + ) + + await dropDesktopStashEntry(repository, stashSha) } /** @@ -133,28 +157,45 @@ export async function createDesktopStashEntry( const message = createDesktopStashMessage(branchName) const args = ['stash', 'push', '-m', message] - const result = await git(args, repository.path, 'createStashEntry', { - successExitCodes: new Set([0, 1]), - }) - - if (result.exitCode === 1) { - // search for any line starting with `error:` - /m here to ensure this is - // applied to each line, without needing to split the text - const errorPrefixRe = /^error: /m - - const matches = errorPrefixRe.exec(result.stderr) - if (matches !== null && matches.length > 0) { - // rethrow, because these messages should prevent the stash from being created - throw new GitError(result, args) + const result = await git(args, repository.path, 'createStashEntry').catch( + e => { + // Note: 2024: Here be dragons. As I converted this code to get rid of the + // successExitCode use I got curious about the assumptions made in the + // following logic. It assumes that as long as the exit code for `git + // stash push` is 1 and there are no lines beginning with "error: " then + // a stash was created. That didn't hold up to a quick read of the stash + // code. For example, running git stash push in an unborn repository will + // get you an exit code of 1 but no stash was created: + // + // % git stash push -m foo ; echo $? + // You do not have the initial commit yet + // 1 + // + // I'm not going to mess with this now but I felt the need to document + // my findings should I or any other brave soul choose to tackle this in + // the future. + if (e instanceof GitError && e.result.exitCode === 1) { + // search for any line starting with `error:` - /m here to ensure this is + // applied to each line, without needing to split the text + const errorPrefixRe = /^error: /m + + const matches = errorPrefixRe.exec(coerceToString(e.result.stderr)) + if (matches !== null && matches.length > 0) { + // rethrow, because these messages should prevent the stash from being created + return Promise.reject(e) + } + + // if no error messages were emitted by Git, we should log but continue because + // a valid stash was created and this should not interfere with the checkout + + log.info( + `[createDesktopStashEntry] a stash was created successfully but exit code ${result.exitCode} reported. stderr: ${result.stderr}` + ) + return e.result + } + return Promise.reject(e) } - - // if no error messages were emitted by Git, we should log but continue because - // a valid stash was created and this should not interfere with the checkout - - log.info( - `[createDesktopStashEntry] a stash was created successfully but exit code ${result.exitCode} reported. stderr: ${result.stderr}` - ) - } + ) // Stash doesn't consider it an error that there aren't any local changes to save. if (result.stdout === 'No local changes to save\n') { @@ -200,31 +241,31 @@ export async function popStashEntry( // ignoring these git errors for now, this will change when we start // implementing the stash conflict flow const expectedErrors = new Set([DugiteError.MergeConflicts]) - const successExitCodes = new Set([0, 1]) const stashToPop = await getStashEntryMatchingSha(repository, stashSha) if (stashToPop !== null) { const args = ['stash', 'pop', '--quiet', `${stashToPop.name}`] - const result = await git(args, repository.path, 'popStashEntry', { + await git(args, repository.path, 'popStashEntry', { expectedErrors, - successExitCodes, - }) - - // popping a stashes that create conflicts in the working directory - // report an exit code of `1` and are not dropped after being applied. - // so, we check for this case and drop them manually - if (result.exitCode === 1) { - if (result.stderr.length > 0) { - // rethrow, because anything in stderr should prevent the stash from being popped - throw new GitError(result, args) + }).catch(e => { + // popping a stashes that create conflicts in the working directory + // report an exit code of `1` and are not dropped after being applied. + // so, we check for this case and drop them manually unless there's + // anything in stderr as that could have prevented the stash from being + // popped. Not the greatest approach but stash isn't very communicative + if ( + e instanceof GitError && + e.result.exitCode === 1 && + e.result.stderr.length === 0 + ) { + log.info( + `[popStashEntry] a stash was popped successfully but exit code ${e.result.exitCode} reported.` + ) + // bye bye + return dropDesktopStashEntry(repository, stashSha) } - - log.info( - `[popStashEntry] a stash was popped successfully but exit code ${result.exitCode} reported.` - ) - // bye bye - await dropDesktopStashEntry(repository, stashSha) - } + return Promise.reject(e) + }) } } @@ -233,59 +274,24 @@ function extractBranchFromMessage(message: string): string | null { return match === null || match[1].length === 0 ? null : match[1] } -/** - * Get the files that were changed in the given stash commit. - * - * This is different than `getChangedFiles` because stashes - * have _3 parents(!!!)_ - */ +/** Get the files that were changed in the given stash commit */ export async function getStashedFiles( repository: Repository, stashSha: string ): Promise> { - const [trackedFiles, untrackedFiles] = await Promise.all([ - getChangedFilesWithinStash(repository, stashSha), - getChangedFilesWithinStash(repository, `${stashSha}^3`), - ]) - - const files = new Map() - trackedFiles.forEach(x => files.set(x.path, x)) - untrackedFiles.forEach(x => files.set(x.path, x)) - return [...files.values()].sort((x, y) => x.path.localeCompare(y.path)) -} - -/** - * Same thing as `getChangedFiles` but with extra handling for 128 exit code - * (which happens if the commit's parent is not valid) - * - * **TODO:** merge this with `getChangedFiles` in `log.ts` - */ -async function getChangedFilesWithinStash(repository: Repository, sha: string) { - // opt-in for rename detection (-M) and copies detection (-C) - // this is equivalent to the user configuring 'diff.renames' to 'copies' - // NOTE: order here matters - doing -M before -C means copies aren't detected const args = [ - 'log', - sha, - '-C', - '-M', - '-m', - '-1', - '--no-show-signature', - '--first-parent', - '--name-status', - '--format=format:', + 'stash', + 'show', + stashSha, + '--raw', + '--numstat', '-z', + '--format=format:', + '--no-show-signature', '--', ] - const result = await git(args, repository.path, 'getChangedFilesForStash', { - // if this fails, its most likely - // because there weren't any untracked files, - // and that's okay! - successExitCodes: new Set([0, 128]), - }) - if (result.exitCode === 0 && result.stdout.length > 0) { - return parseChangedFiles(result.stdout, sha) - } - return [] + + const { stdout } = await git(args, repository.path, 'getStashedFiles') + + return parseRawLogWithNumstat(stdout, stashSha, `${stashSha}^`).files } diff --git a/app/src/lib/git/status.ts b/app/src/lib/git/status.ts index b5008a8537e..c2837aa562f 100644 --- a/app/src/lib/git/status.ts +++ b/app/src/lib/git/status.ts @@ -1,4 +1,3 @@ -import { spawnAndComplete } from './spawn' import { getFilesWithConflictMarkers } from './diff-check' import { WorkingDirectoryStatus, @@ -28,14 +27,7 @@ import { getBinaryPaths } from './diff' import { getRebaseInternalState } from './rebase' import { RebaseInternalState } from '../../models/rebase' import { isCherryPickHeadFound } from './cherry-pick' - -/** - * V8 has a limit on the size of string it can create (~256MB), and unless we want to - * trigger an unhandled exception we need to do the encoding conversion by hand. - * - * As we may be executing status often, we should keep this to a reasonable threshold. - */ -const MaxStatusBufferSize = 20e6 // 20MB in decimal +import { git } from '.' /** The encapsulation of the result from 'git status' */ export interface IStatusResult { @@ -104,7 +96,10 @@ function parseConflictedState( conflictDetails.conflictCountsByPath.get(path) || 0, } } else { - return { kind: AppFileStatusKind.Conflicted, entry } + return { + kind: AppFileStatusKind.Conflicted, + entry, + } } } case UnmergedEntrySummary.BothModified: { @@ -140,18 +135,43 @@ function convertToAppStatus( if (entry.kind === 'ordinary') { switch (entry.type) { case 'added': - return { kind: AppFileStatusKind.New } + return { + kind: AppFileStatusKind.New, + submoduleStatus: entry.submoduleStatus, + } case 'modified': - return { kind: AppFileStatusKind.Modified } + return { + kind: AppFileStatusKind.Modified, + submoduleStatus: entry.submoduleStatus, + } case 'deleted': - return { kind: AppFileStatusKind.Deleted } + return { + kind: AppFileStatusKind.Deleted, + submoduleStatus: entry.submoduleStatus, + } } } else if (entry.kind === 'copied' && oldPath != null) { - return { kind: AppFileStatusKind.Copied, oldPath } + return { + kind: AppFileStatusKind.Copied, + oldPath, + submoduleStatus: entry.submoduleStatus, + renameIncludesModifications: false, + } } else if (entry.kind === 'renamed' && oldPath != null) { - return { kind: AppFileStatusKind.Renamed, oldPath } + return { + kind: AppFileStatusKind.Renamed, + oldPath, + submoduleStatus: entry.submoduleStatus, + renameIncludesModifications: + entry.workingTree === GitStatusEntry.Modified || + (entry.renameOrCopyScore !== undefined && + entry.renameOrCopyScore < 100), + } } else if (entry.kind === 'untracked') { - return { kind: AppFileStatusKind.Untracked } + return { + kind: AppFileStatusKind.Untracked, + submoduleStatus: entry.submoduleStatus, + } } else if (entry.kind === 'conflicted') { return parseConflictedState(entry, path, conflictDetails) } @@ -168,46 +188,37 @@ const conflictStatusCodes = ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'] * and fail gracefully if the location is not a Git repository */ export async function getStatus( - repository: Repository + repository: Repository, + includeUntracked = true ): Promise { const args = [ '--no-optional-locks', 'status', - '--untracked-files=all', + ...(includeUntracked ? ['--untracked-files=all'] : []), '--branch', '--porcelain=2', '-z', ] - const result = await spawnAndComplete( - args, - repository.path, - 'getStatus', - new Set([0, 128]) - ) + const { stdout, exitCode } = await git(args, repository.path, 'getStatus', { + successExitCodes: new Set([0, 128]), + encoding: 'buffer', + }) - if (result.exitCode === 128) { + if (exitCode === 128) { log.debug( `'git status' returned 128 for '${repository.path}' and is likely missing its .git directory` ) return null } - if (result.output.length > MaxStatusBufferSize) { - log.error( - `'git status' emitted ${result.output.length} bytes, which is beyond the supported threshold of ${MaxStatusBufferSize} bytes` - ) - return null - } - - const stdout = result.output.toString('utf8') const parsed = parsePorcelainStatus(stdout) const headers = parsed.filter(isStatusHeader) const entries = parsed.filter(isStatusEntry) const mergeHeadFound = await isMergeHeadSet(repository) - const conflictedFilesInIndex = entries.some( - e => conflictStatusCodes.indexOf(e.statusCode) > -1 + const conflictedFilesInIndex = entries.filter(e => + conflictStatusCodes.includes(e.statusCode) ) const rebaseInternalState = await getRebaseInternalState(repository) @@ -254,7 +265,7 @@ export async function getStatus( workingDirectory, isCherryPickingHeadFound, squashMsgFound, - doConflictedFilesExist: conflictedFilesInIndex, + doConflictedFilesExist: conflictedFilesInIndex.length > 0, } } @@ -270,7 +281,11 @@ function buildStatusMap( entry: IStatusEntry, conflictDetails: ConflictFilesDetails ): Map { - const status = mapStatus(entry.statusCode) + const status = mapStatus( + entry.statusCode, + entry.submoduleStatusCode, + entry.renameOrCopyScore + ) if (status.kind === 'ordinary') { // when a file is added in the index but then removed in the working @@ -300,7 +315,14 @@ function buildStatusMap( entry.oldPath ) - const selection = DiffSelection.fromInitialSelection(DiffSelectionType.All) + const initialSelectionType = + appStatus.kind === AppFileStatusKind.Modified && + appStatus.submoduleStatus !== undefined && + !appStatus.submoduleStatus.commitChanged + ? DiffSelectionType.None + : DiffSelectionType.All + + const selection = DiffSelection.fromInitialSelection(initialSelectionType) files.set( entry.path, @@ -349,22 +371,36 @@ function parseStatusHeader(results: IStatusHeadersData, header: IStatusHeader) { } } -async function getMergeConflictDetails(repository: Repository) { +async function getMergeConflictDetails( + repository: Repository, + conflictedFilesInIndex: ReadonlyArray +) { const conflictCountsByPath = await getFilesWithConflictMarkers( repository.path ) - const binaryFilePaths = await getBinaryPaths(repository, 'MERGE_HEAD') + const binaryFilePaths = await getBinaryPaths( + repository, + 'MERGE_HEAD', + conflictedFilesInIndex + ) return { conflictCountsByPath, binaryFilePaths, } } -async function getRebaseConflictDetails(repository: Repository) { +async function getRebaseConflictDetails( + repository: Repository, + conflictedFilesInIndex: ReadonlyArray +) { const conflictCountsByPath = await getFilesWithConflictMarkers( repository.path ) - const binaryFilePaths = await getBinaryPaths(repository, 'REBASE_HEAD') + const binaryFilePaths = await getBinaryPaths( + repository, + 'REBASE_HEAD', + conflictedFilesInIndex + ) return { conflictCountsByPath, binaryFilePaths, @@ -375,14 +411,21 @@ async function getRebaseConflictDetails(repository: Repository) { * We need to do these operations to detect conflicts that were the result * of popping a stash into the index */ -async function getWorkingDirectoryConflictDetails(repository: Repository) { +async function getWorkingDirectoryConflictDetails( + repository: Repository, + conflictedFilesInIndex: ReadonlyArray +) { const conflictCountsByPath = await getFilesWithConflictMarkers( repository.path ) let binaryFilePaths: ReadonlyArray = [] try { // its totally fine if HEAD doesn't exist, which throws an error - binaryFilePaths = await getBinaryPaths(repository, 'HEAD') + binaryFilePaths = await getBinaryPaths( + repository, + 'HEAD', + conflictedFilesInIndex + ) } catch (error) {} return { @@ -397,26 +440,36 @@ async function getWorkingDirectoryConflictDetails(repository: Repository) { * * @param repository to get details from * @param mergeHeadFound whether a merge conflict has been detected - * @param lookForStashConflicts whether it looks like a stash has introduced conflicts - * @param rebaseInternalState details about the current rebase operation (if found) + * @param conflictedFilesInIndex all files marked as being conflicted in the + * index. Used to check for files using the binary + * merge driver and whether it looks like a stash + * has introduced conflicts + * @param rebaseInternalState details about the current rebase operation (if + * found) */ async function getConflictDetails( repository: Repository, mergeHeadFound: boolean, - lookForStashConflicts: boolean, + conflictedFilesInIndex: ReadonlyArray, rebaseInternalState: RebaseInternalState | null ): Promise { try { if (mergeHeadFound) { - return await getMergeConflictDetails(repository) + return await getMergeConflictDetails(repository, conflictedFilesInIndex) } if (rebaseInternalState !== null) { - return await getRebaseConflictDetails(repository) + return await getRebaseConflictDetails(repository, conflictedFilesInIndex) } - if (lookForStashConflicts) { - return await getWorkingDirectoryConflictDetails(repository) + // If there's conflicted files in the index but we don't have a merge head + // or a rebase internal state, then we're likely in a situation where a + // stash has introduced conflicts + if (conflictedFilesInIndex.length > 0) { + return await getWorkingDirectoryConflictDetails( + repository, + conflictedFilesInIndex + ) } } catch (error) { log.error( diff --git a/app/src/lib/git/tag.ts b/app/src/lib/git/tag.ts index 50f8d38c79b..cdb775edc6f 100644 --- a/app/src/lib/git/tag.ts +++ b/app/src/lib/git/tag.ts @@ -1,6 +1,5 @@ -import { git, gitNetworkArguments } from './core' +import { git } from './core' import { Repository } from '../../models/repository' -import { IGitAccount } from '../../models/git-account' import { IRemote } from '../../models/remote' import { envForRemoteOperation } from './environment' @@ -86,12 +85,10 @@ export async function getAllTags( */ export async function fetchTagsToPush( repository: Repository, - account: IGitAccount | null, remote: IRemote, branchName: string ): Promise> { const args = [ - ...gitNetworkArguments(), 'push', remote.name, branchName, @@ -102,7 +99,7 @@ export async function fetchTagsToPush( ] const result = await git(args, repository.path, 'fetchTagsToPush', { - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), successExitCodes: new Set([0, 1, 128]), }) diff --git a/app/src/lib/globals.d.ts b/app/src/lib/globals.d.ts index ea4b3c0416a..7c065f2ed85 100644 --- a/app/src/lib/globals.d.ts +++ b/app/src/lib/globals.d.ts @@ -44,8 +44,6 @@ declare const __RELEASE_CHANNEL__: | 'test' | 'development' -declare const __CLI_COMMANDS__: ReadonlyArray - /** The URL for Squirrel's updates. */ declare const __UPDATES_URL__: string @@ -162,6 +160,8 @@ interface Window { interface HTMLDialogElement { showModal: () => void + close: (returnValue?: string | undefined) => void + open: boolean } /** * Obtain the number of elements of a tuple type diff --git a/app/src/lib/gravatar.ts b/app/src/lib/gravatar.ts deleted file mode 100644 index 8b6338000b6..00000000000 --- a/app/src/lib/gravatar.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as crypto from 'crypto' - -/** - * Convert an email address to a Gravatar URL format - * - * @param email The email address associated with a user - * @param size The size (in pixels) of the avatar to render - */ -export function generateGravatarUrl(email: string, size: number = 60): string { - const input = email.trim().toLowerCase() - const hash = crypto.createHash('md5').update(input).digest('hex') - - return `https://www.gravatar.com/avatar/${hash}?s=${size}` -} diff --git a/app/src/lib/helpers/default-branch.ts b/app/src/lib/helpers/default-branch.ts index a21328e20fc..521666c4647 100644 --- a/app/src/lib/helpers/default-branch.ts +++ b/app/src/lib/helpers/default-branch.ts @@ -12,12 +12,6 @@ const DefaultBranchInDesktop = 'main' */ const DefaultBranchSettingName = 'init.defaultBranch' -/** - * The branch names that Desktop shows by default as radio buttons on the - * form that allows users to change default branch name. - */ -export const SuggestedBranchNames: ReadonlyArray = ['main', 'master'] - /** * Returns the configured default branch when creating new repositories */ diff --git a/app/src/lib/helpers/non-fatal-exception.ts b/app/src/lib/helpers/non-fatal-exception.ts index cdc2d3ca853..c55fdf52c69 100644 --- a/app/src/lib/helpers/non-fatal-exception.ts +++ b/app/src/lib/helpers/non-fatal-exception.ts @@ -26,7 +26,23 @@ let lastNonFatalException: number | undefined = undefined /** Max one non fatal exeception per minute */ const minIntervalBetweenNonFatalExceptions = 60 * 1000 -export function sendNonFatalException(kind: string, error: Error) { +export type ExceptionKinds = + | 'invalidListSelection' + | 'TooManyPopups' + | 'remoteNameMismatch' + | 'tutorialRepoCreation' + | 'multiCommitOperation' + | 'PullRequestState' + | 'trampolineCommandParser' + | 'trampolineServer' + | 'PopupNoId' + | 'FailedToStartPullRequest' + | 'unhandledRejection' + | 'rebaseConflictsWithBranchAlreadyUpToDate' + | 'forkCreation' + | 'NoSuggestedActionsProvided' + +export function sendNonFatalException(kind: ExceptionKinds, error: Error) { if (getHasOptedOutOfStats()) { return } diff --git a/app/src/lib/helpers/regex.ts b/app/src/lib/helpers/regex.ts index 9e479c45edc..130ff6002b2 100644 --- a/app/src/lib/helpers/regex.ts +++ b/app/src/lib/helpers/regex.ts @@ -1,47 +1,3 @@ -/** - * Get all regex captures within a body of text - * - * @param text string to search - * @param re regex to search with. must have global option and one capture - * - * @returns arrays of strings captured by supplied regex - */ -export function getCaptures( - text: string, - re: RegExp -): ReadonlyArray> { - const matches = getMatches(text, re) - const captures = matches.reduce( - (acc, match) => acc.concat([match.slice(1)]), - new Array>() - ) - return captures -} - -/** - * Get all regex matches within a body of text - * - * @param text string to search - * @param re regex to search with. must have global option - * @returns set of strings captured by supplied regex - */ -export function getMatches(text: string, re: RegExp): Array { - if (re.global === false) { - throw new Error( - 'A regex has been provided that is not marked as global, and has the potential to execute forever if it finds a match' - ) - } - - const matches = new Array() - let match = re.exec(text) - - while (match !== null) { - matches.push(match) - match = re.exec(text) - } - return matches -} - /* * Looks for the phrases "remote: error File " and " is (file size I.E. 106.5 MB); this exceeds GitHub's file size limit of 100.00 MB" * inside of a string containing errors and return an array of all the filenames and their sizes located between these two strings. diff --git a/app/src/lib/helpers/repo-rules.ts b/app/src/lib/helpers/repo-rules.ts new file mode 100644 index 00000000000..0ad6eee48b7 --- /dev/null +++ b/app/src/lib/helpers/repo-rules.ts @@ -0,0 +1,222 @@ +import { RE2JS } from 're2js' +import { + RepoRulesInfo, + IRepoRulesMetadataRule, + RepoRulesMetadataMatcher, + RepoRuleEnforced, +} from '../../models/repo-rules' +import { + APIRepoRuleMetadataOperator, + APIRepoRuleType, + IAPIRepoRule, + IAPIRepoRuleMetadataParameters, + IAPIRepoRuleset, +} from '../api' +import { supportsRepoRules } from '../endpoint-capabilities' +import { Account } from '../../models/account' +import { + Repository, + isRepositoryWithGitHubRepository, +} from '../../models/repository' +import { getBooleanConfigValue } from '../git' + +/** + * Returns whether repo rules could potentially exist for the provided account and repository. + * This only performs client-side checks, such as whether the user is on a free plan + * and the repo is public. + */ +export function useRepoRulesLogic( + account: Account | null, + repository: Repository +): boolean { + if ( + !account || + !repository || + !isRepositoryWithGitHubRepository(repository) + ) { + return false + } + + const { endpoint, owner, isPrivate } = repository.gitHubRepository + + if (!supportsRepoRules(endpoint)) { + return false + } + + // repo owner's plan can't be checked, only the current user's. purposely return true + // if the repo owner is someone else, because if the current user is a collaborator on + // the free plan but the owner is a pro member, then repo rules could still be enabled. + // errors will be thrown by the API in this case, but there's no way to preemptively + // check for that. + if ( + account.login === owner.login && + (!account.plan || account.plan === 'free') && + isPrivate + ) { + return false + } + + return true +} + +/** + * Parses the GitHub API response for a branch's repo rules into a more useable + * format. + */ +export async function parseRepoRules( + rules: ReadonlyArray, + rulesets: ReadonlyMap, + repository: Repository +): Promise { + const info = new RepoRulesInfo() + let gpgSignEnabled: boolean | undefined = undefined + + for (const rule of rules) { + // if a ruleset is null/undefined, then act as if the rule doesn't exist because + // we don't know what will happen when they push + const ruleset = rulesets.get(rule.ruleset_id) + if (ruleset == null) { + continue + } + + // a rule may be configured multiple times, and the strictest value always applies. + // since the rule will not exist in the API response if it's not enforced, we know + // we're always assigning either 'bypass' or true below. therefore, we only need + // to check if the existing value is true, otherwise it can always be overridden. + const enforced = + ruleset.current_user_can_bypass === 'always' ? 'bypass' : true + + switch (rule.type) { + case APIRepoRuleType.Update: + case APIRepoRuleType.RequiredDeployments: + case APIRepoRuleType.RequiredStatusChecks: + info.basicCommitWarning = + info.basicCommitWarning !== true ? enforced : true + break + + case APIRepoRuleType.Creation: + info.creationRestricted = + info.creationRestricted !== true ? enforced : true + break + + case APIRepoRuleType.RequiredSignatures: + // check if the user has commit signing configured. if they do, the rule + // passes and doesn't need to be warned about. + gpgSignEnabled ??= + (await getBooleanConfigValue(repository, 'commit.gpgsign')) ?? false + + if (gpgSignEnabled !== true) { + info.signedCommitsRequired = + info.signedCommitsRequired !== true ? enforced : true + } + break + + case APIRepoRuleType.PullRequest: + info.pullRequestRequired = + info.pullRequestRequired !== true ? enforced : true + break + + case APIRepoRuleType.CommitMessagePattern: + info.commitMessagePatterns.push(toMetadataRule(rule, enforced)) + break + + case APIRepoRuleType.CommitAuthorEmailPattern: + info.commitAuthorEmailPatterns.push(toMetadataRule(rule, enforced)) + break + + case APIRepoRuleType.CommitterEmailPattern: + info.committerEmailPatterns.push(toMetadataRule(rule, enforced)) + break + + case APIRepoRuleType.BranchNamePattern: + info.branchNamePatterns.push(toMetadataRule(rule, enforced)) + break + } + } + + return info +} + +function toMetadataRule( + rule: IAPIRepoRule | undefined, + enforced: RepoRuleEnforced +): IRepoRulesMetadataRule | undefined { + if (!rule?.parameters) { + return undefined + } + + return { + enforced, + matcher: toMatcher(rule.parameters), + humanDescription: toHumanDescription(rule.parameters), + rulesetId: rule.ruleset_id, + } +} + +function toHumanDescription(apiParams: IAPIRepoRuleMetadataParameters): string { + let description = 'must ' + if (apiParams.negate) { + description += 'not ' + } + + if (apiParams.operator === APIRepoRuleMetadataOperator.RegexMatch) { + return description + `match the regular expression "${apiParams.pattern}"` + } + + switch (apiParams.operator) { + case APIRepoRuleMetadataOperator.StartsWith: + description += 'start with ' + break + + case APIRepoRuleMetadataOperator.EndsWith: + description += 'end with ' + break + + case APIRepoRuleMetadataOperator.Contains: + description += 'contain ' + break + } + + return description + `"${apiParams.pattern}"` +} + +/** + * Converts the given metadata rule into a matcher function that uses regex to test the rule. + */ +function toMatcher( + rule: IAPIRepoRuleMetadataParameters | undefined +): RepoRulesMetadataMatcher { + if (!rule) { + return () => false + } + + let regex: RE2JS + + switch (rule.operator) { + case APIRepoRuleMetadataOperator.StartsWith: + regex = RE2JS.compile(`^${RE2JS.quote(rule.pattern)}`) + break + + case APIRepoRuleMetadataOperator.EndsWith: + regex = RE2JS.compile(`${RE2JS.quote(rule.pattern)}$`) + break + + case APIRepoRuleMetadataOperator.Contains: + regex = RE2JS.compile(`.*${RE2JS.quote(rule.pattern)}.*`) + break + + case APIRepoRuleMetadataOperator.RegexMatch: + regex = RE2JS.compile(rule.pattern) + break + } + + if (regex) { + if (rule.negate) { + return (toMatch: string) => !regex.matcher(toMatch).find() + } else { + return (toMatch: string) => regex.matcher(toMatch).find() + } + } else { + return () => false + } +} diff --git a/app/src/lib/http.ts b/app/src/lib/http.ts index af011b326b8..f0d5ac1a409 100644 --- a/app/src/lib/http.ts +++ b/app/src/lib/http.ts @@ -2,7 +2,7 @@ import * as appProxy from '../ui/lib/app-proxy' import { URL } from 'url' /** The HTTP methods available. */ -export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'HEAD' +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'HEAD' | 'DELETE' /** * The structure of error messages returned from the GitHub API. @@ -153,7 +153,7 @@ export function request( } /** Get the user agent to use for all requests. */ -function getUserAgent() { +export function getUserAgent() { const platform = __DARWIN__ ? 'Macintosh' : 'Windows' return `GitHubDesktop/${appProxy.getVersion()} (${platform})` } diff --git a/app/src/lib/ipc-shared.ts b/app/src/lib/ipc-shared.ts index 07ffdb7cc09..f6539023518 100644 --- a/app/src/lib/ipc-shared.ts +++ b/app/src/lib/ipc-shared.ts @@ -16,6 +16,7 @@ import { ThemeSource } from '../ui/lib/theme-source' import { DesktopNotificationPermission } from 'desktop-notifications/dist/notification-permission' import { NotificationCallback } from 'desktop-notifications/dist/notification-callback' import { DesktopAliveEvent } from './stores/alive-store' +import { CLIAction } from './cli-action' /** * Defines the simplex IPC channel names we use from the renderer @@ -25,6 +26,7 @@ import { DesktopAliveEvent } from './stores/alive-store' */ export type RequestChannels = { 'select-all-window-contents': () => void + 'dialog-did-open': () => void 'update-menu-state': ( state: Array<{ id: MenuIDs; state: IMenuItemState }> ) => void @@ -46,6 +48,8 @@ export type RequestChannels = { 'menu-event': (name: MenuEvent) => void log: (level: LogLevel, message: string) => void 'will-quit': () => void + 'will-quit-even-if-updating': () => void + 'cancel-quitting': () => void 'crash-ready': () => void 'crash-quit': () => void 'window-state-changed': (windowState: WindowState) => void @@ -54,6 +58,7 @@ export type RequestChannels = { 'app-menu': (menu: IMenu) => void 'launch-timing-stats': (stats: ILaunchStats) => void 'url-action': (action: URLActionType) => void + 'cli-action': (action: CLIAction) => void 'certificate-error': ( certificate: Electron.Certificate, error: string, @@ -63,6 +68,7 @@ export type RequestChannels = { blur: () => void 'update-accounts': (accounts: ReadonlyArray) => void 'quit-and-install-updates': () => void + 'quit-app': () => void 'minimize-window': () => void 'maximize-window': () => void 'unmaximize-window': () => void @@ -77,6 +83,9 @@ export type RequestChannels = { 'focus-window': () => void 'notification-event': NotificationCallback 'set-window-zoom-factor': (zoomFactor: number) => void + 'show-installing-update': () => void + 'install-windows-cli': () => void + 'uninstall-windows-cli': () => void } /** diff --git a/app/src/lib/markdown-filters/commit-mention-link-filter.ts b/app/src/lib/markdown-filters/commit-mention-link-filter.ts index f32669df2e5..2131a19c433 100644 --- a/app/src/lib/markdown-filters/commit-mention-link-filter.ts +++ b/app/src/lib/markdown-filters/commit-mention-link-filter.ts @@ -1,4 +1,4 @@ -import { escapeRegExp } from 'lodash' +import escapeRegExp from 'lodash/escapeRegExp' import { GitHubRepository } from '../../models/github-repository' import { getHTMLURL } from '../api' import { INodeFilter } from './node-filter' diff --git a/app/src/lib/markdown-filters/emoji-filter.ts b/app/src/lib/markdown-filters/emoji-filter.ts index 82d00dd4aa5..a42c83f5901 100644 --- a/app/src/lib/markdown-filters/emoji-filter.ts +++ b/app/src/lib/markdown-filters/emoji-filter.ts @@ -1,7 +1,8 @@ import { INodeFilter } from './node-filter' import { fileURLToPath } from 'url' import { readFile } from 'fs/promises' -import { escapeRegExp } from 'lodash' +import escapeRegExp from 'lodash/escapeRegExp' +import { Emoji } from '../emoji' /** * The Emoji Markdown filter will take a text node and create multiple text and @@ -17,15 +18,15 @@ import { escapeRegExp } from 'lodash' */ export class EmojiFilter implements INodeFilter { private readonly emojiRegex: RegExp - private readonly emojiFilePath: Map + private readonly allEmoji: Map private readonly emojiBase64URICache: Map = new Map() /** * @param emoji Map from the emoji ref (e.g., :+1:) to the image's local path. */ - public constructor(emojiFilePath: Map) { - this.emojiFilePath = emojiFilePath - this.emojiRegex = this.buildEmojiRegExp(emojiFilePath) + public constructor(emoji: Map) { + this.allEmoji = emoji + this.emojiRegex = this.buildEmojiRegExp(emoji) } /** @@ -66,16 +67,16 @@ export class EmojiFilter implements INodeFilter { return null } - const nodes = new Array() + const nodes = new Array() for (let i = 0; i < emojiMatches.length; i++) { const emojiKey = emojiMatches[i] - const emojiPath = this.emojiFilePath.get(emojiKey) - if (emojiPath === undefined) { + const emoji = this.allEmoji.get(emojiKey) + if (emoji === undefined) { continue } - const emojiImg = await this.createEmojiNode(emojiPath) - if (emojiImg === null) { + const emojiNode = await this.createEmojiNode(emoji) + if (emojiNode === null) { continue } @@ -83,7 +84,7 @@ export class EmojiFilter implements INodeFilter { const textBeforeEmoji = text.slice(0, emojiPosition) const textNodeBeforeEmoji = document.createTextNode(textBeforeEmoji) nodes.push(textNodeBeforeEmoji) - nodes.push(emojiImg) + nodes.push(emojiNode) text = text.slice(emojiPosition + emojiKey.length) } @@ -97,17 +98,25 @@ export class EmojiFilter implements INodeFilter { } /** - * Method to build an emoji image node to insert in place of the emoji ref. - * If we fail to create the image element, returns null. + * Method to build an emoji node to insert in place of the emoji ref. + * If we fail to create the emoji element, returns null. */ private async createEmojiNode( - emojiPath: string - ): Promise { + emoji: Emoji + ): Promise { try { - const dataURI = await this.getBase64FromImageUrl(emojiPath) + if (emoji.emoji) { + const emojiSpan = document.createElement('span') + emojiSpan.classList.add('emoji') + emojiSpan.textContent = emoji.emoji + return emojiSpan + } + + const dataURI = await this.getBase64FromImageUrl(emoji.url) const emojiImg = new Image() emojiImg.classList.add('emoji') emojiImg.src = dataURI + emojiImg.alt = emoji.description ?? '' return emojiImg } catch (e) {} return null @@ -136,7 +145,7 @@ export class EmojiFilter implements INodeFilter { * * @param emoji Map from the emoji ref (e.g., :+1:) to the image's local path. */ - private buildEmojiRegExp(emoji: Map): RegExp { + private buildEmojiRegExp(emoji: Map): RegExp { const emojiGroups = [...emoji.keys()] .map(emoji => escapeRegExp(emoji)) .join('|') diff --git a/app/src/lib/markdown-filters/issue-link-filter.ts b/app/src/lib/markdown-filters/issue-link-filter.ts index 99599296f7c..5f238ff569e 100644 --- a/app/src/lib/markdown-filters/issue-link-filter.ts +++ b/app/src/lib/markdown-filters/issue-link-filter.ts @@ -1,4 +1,4 @@ -import { escapeRegExp } from 'lodash' +import escapeRegExp from 'lodash/escapeRegExp' import { GitHubRepository } from '../../models/github-repository' import { getHTMLURL } from '../api' import { INodeFilter } from './node-filter' diff --git a/app/src/lib/markdown-filters/node-filter.ts b/app/src/lib/markdown-filters/node-filter.ts index 83ad9782600..fddf80765c5 100644 --- a/app/src/lib/markdown-filters/node-filter.ts +++ b/app/src/lib/markdown-filters/node-filter.ts @@ -14,6 +14,7 @@ import { import { CommitMentionLinkFilter } from './commit-mention-link-filter' import { MarkdownEmitter } from './markdown-filter' import { GitHubRepository } from '../../models/github-repository' +import { Emoji } from '../emoji' export interface INodeFilter { /** @@ -39,7 +40,7 @@ export interface INodeFilter { } export interface ICustomMarkdownFilterOptions { - emoji: Map + emoji: Map repository?: GitHubRepository markdownContext?: MarkdownContext } diff --git a/app/src/lib/markdown-filters/video-url-regex.ts b/app/src/lib/markdown-filters/video-url-regex.ts index c83be25c568..17340c1dd87 100644 --- a/app/src/lib/markdown-filters/video-url-regex.ts +++ b/app/src/lib/markdown-filters/video-url-regex.ts @@ -1,4 +1,4 @@ -import { escapeRegExp } from 'lodash' +import escapeRegExp from 'lodash/escapeRegExp' const user_images_cdn_url = 'https://user-images.githubusercontent.com' diff --git a/app/src/lib/menu-update.ts b/app/src/lib/menu-update.ts index 0c293cefcd2..aa5353be272 100644 --- a/app/src/lib/menu-update.ts +++ b/app/src/lib/menu-update.ts @@ -10,7 +10,7 @@ import { TipState } from '../models/tip' import { updateMenuState as ipcUpdateMenuState } from '../ui/main-process-proxy' import { AppMenu, MenuItem } from '../models/app-menu' import { hasConflictedFiles } from './status' -import { enableSquashMerging } from './feature-flag' +import { findContributionTargetDefaultBranch } from './branch' export interface IMenuItemState { readonly enabled?: boolean @@ -102,17 +102,13 @@ function menuItemStateEqual(state: IMenuItemState, menuItem: MenuItem) { return true } -const squashAndMergeMenuIds: ReadonlyArray = enableSquashMerging() - ? ['squash-and-merge-branch'] - : [] - const allMenuIds: ReadonlyArray = [ 'rename-branch', 'delete-branch', 'discard-all-changes', 'stash-all-changes', 'preferences', - 'update-branch', + 'update-branch-with-contribution-target-branch', 'compare-to-branch', 'merge-branch', 'rebase-branch', @@ -139,7 +135,8 @@ const allMenuIds: ReadonlyArray = [ 'clone-repository', 'about', 'create-pull-request', - ...squashAndMergeMenuIds, + 'preview-pull-request', + 'squash-and-merge-branch', ] function getAllMenusDisabledBuilder(): MenuStateBuilder { @@ -164,13 +161,14 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder { let onDetachedHead = false let hasChangedFiles = false let hasConflicts = false - let hasDefaultBranch = false let hasPublishedBranch = false let networkActionInProgress = false let tipStateIsUnknown = false let branchIsUnborn = false let rebaseInProgress = false let branchHasStashEntry = false + let onContributionTargetDefaultBranch = false + let hasContributionTargetDefaultBranch = false // check that its a github repo and if so, that is has issues enabled const repoIssuesEnabled = @@ -185,12 +183,18 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder { const tip = branchesState.tip const defaultBranch = branchesState.defaultBranch - hasDefaultBranch = Boolean(defaultBranch) - onBranch = tip.kind === TipState.Valid onDetachedHead = tip.kind === TipState.Detached tipStateIsUnknown = tip.kind === TipState.Unknown branchIsUnborn = tip.kind === TipState.Unborn + const contributionTarget = findContributionTargetDefaultBranch( + selectedState.repository, + branchesState + ) + hasContributionTargetDefaultBranch = contributionTarget !== null + onContributionTargetDefaultBranch = + tip.kind === TipState.Valid && + contributionTarget?.name === tip.branch.name // If we are: // 1. on the default branch, or @@ -261,13 +265,13 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder { onNonDefaultBranch && !branchIsUnborn && !onDetachedHead ) menuStateBuilder.setEnabled( - 'update-branch', - onNonDefaultBranch && hasDefaultBranch && !onDetachedHead + 'update-branch-with-contribution-target-branch', + onBranch && + hasContributionTargetDefaultBranch && + !onContributionTargetDefaultBranch ) menuStateBuilder.setEnabled('merge-branch', onBranch) - if (enableSquashMerging()) { - menuStateBuilder.setEnabled('squash-and-merge-branch', onBranch) - } + menuStateBuilder.setEnabled('squash-and-merge-branch', onBranch) menuStateBuilder.setEnabled('rebase-branch', onBranch) menuStateBuilder.setEnabled( 'compare-on-github', @@ -288,6 +292,11 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder { 'create-pull-request', isHostedOnGitHub && !branchIsUnborn && !onDetachedHead ) + menuStateBuilder.setEnabled( + 'preview-pull-request', + !branchIsUnborn && !onDetachedHead && isHostedOnGitHub + ) + menuStateBuilder.setEnabled( 'push', !branchIsUnborn && !onDetachedHead && !networkActionInProgress @@ -327,7 +336,7 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder { menuStateBuilder.disable('view-repository-on-github') menuStateBuilder.disable('create-pull-request') - + menuStateBuilder.disable('preview-pull-request') if ( selectedState && selectedState.type === SelectionType.MissingRepository @@ -343,11 +352,9 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder { menuStateBuilder.disable('delete-branch') menuStateBuilder.disable('discard-all-changes') menuStateBuilder.disable('stash-all-changes') - menuStateBuilder.disable('update-branch') + menuStateBuilder.disable('update-branch-with-contribution-target-branch') menuStateBuilder.disable('merge-branch') - if (enableSquashMerging()) { - menuStateBuilder.disable('squash-and-merge-branch') - } + menuStateBuilder.disable('squash-and-merge-branch') menuStateBuilder.disable('rebase-branch') menuStateBuilder.disable('push') @@ -368,6 +375,7 @@ function getMenuState(state: IAppState): Map { return getAllMenusEnabledBuilder() .merge(getRepositoryMenuBuilder(state)) + .merge(getAppMenuBuilder(state)) .merge(getInWelcomeFlowBuilder(state.showWelcomeFlow)) .merge(getNoRepositoriesBuilder(state)).state } @@ -418,6 +426,16 @@ function getNoRepositoriesBuilder(state: IAppState): MenuStateBuilder { return menuStateBuilder } +function getAppMenuBuilder(state: IAppState): MenuStateBuilder { + const menuStateBuilder = new MenuStateBuilder() + const enabled = state.resizablePaneActive + + menuStateBuilder.setEnabled('increase-active-resizable-width', enabled) + menuStateBuilder.setEnabled('decrease-active-resizable-width', enabled) + + return menuStateBuilder +} + function getRepoIssuesEnabled(repository: Repository): boolean { if (isRepositoryWithGitHubRepository(repository)) { const ghRepo = repository.gitHubRepository diff --git a/app/src/lib/merge.ts b/app/src/lib/merge.ts index b370ea89c55..79e912edca3 100644 --- a/app/src/lib/merge.ts +++ b/app/src/lib/merge.ts @@ -1,5 +1,8 @@ /** Create a copy of an object by merging it with a subset of its properties. */ -export function merge(obj: T, subset: Pick): T { +export function merge( + obj: T | null | undefined, + subset: Pick +): T { const copy = Object.assign({}, obj) for (const k in subset) { copy[k] = subset[k] diff --git a/app/src/lib/multi-commit-operation.ts b/app/src/lib/multi-commit-operation.ts index 897c2909e2d..c1799a1dd1f 100644 --- a/app/src/lib/multi-commit-operation.ts +++ b/app/src/lib/multi-commit-operation.ts @@ -4,7 +4,6 @@ import { conflictSteps, MultiCommitOperationStepKind, } from '../models/multi-commit-operation' -import { Popup, PopupType } from '../models/popup' import { TipState } from '../models/tip' import { IMultiCommitOperationState, IRepositoryState } from './app-state' @@ -39,12 +38,11 @@ export function getMultiCommitOperationChooseBranchStep( } export function isConflictsFlow( - currentPopup: Popup | null, + isMultiCommitOperationPopupOpen: boolean, multiCommitOperationState: IMultiCommitOperationState | null ): boolean { return ( - currentPopup !== null && - currentPopup.type === PopupType.MultiCommitOperation && + isMultiCommitOperationPopupOpen && multiCommitOperationState !== null && conflictSteps.includes(multiCommitOperationState.step.kind) ) diff --git a/app/src/lib/oauth.ts b/app/src/lib/oauth.ts deleted file mode 100644 index f2692cc92c0..00000000000 --- a/app/src/lib/oauth.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { shell } from './app-shell' -import { Account } from '../models/account' -import { fatalError } from './fatal-error' -import { getOAuthAuthorizationURL, requestOAuthToken, fetchUser } from './api' -import { uuid } from './uuid' - -interface IOAuthState { - readonly state: string - readonly endpoint: string - readonly resolve: (account: Account) => void - readonly reject: (error: Error) => void -} - -let oauthState: IOAuthState | null = null - -/** - * Ask the user to auth with the given endpoint. This will open their browser. - * - * @param endpoint - The endpoint to auth against. - * - * Returns a {Promise} which will resolve when the OAuth flow as been completed. - * Note that the promise may not complete if the user doesn't complete the OAuth - * flow. - */ -export function askUserToOAuth(endpoint: string) { - return new Promise((resolve, reject) => { - oauthState = { state: uuid(), endpoint, resolve, reject } - - const oauthURL = getOAuthAuthorizationURL(endpoint, oauthState.state) - shell.openExternal(oauthURL) - }) -} - -/** - * Request the authenticated using, using the code given to us by the OAuth - * callback. - * - * @returns `undefined` if there is no valid OAuth state to use, or `null` if - * the code cannot be used to retrieve a valid GitHub user. - */ -export async function requestAuthenticatedUser( - code: string, - state: string -): Promise { - if (!oauthState || state !== oauthState.state) { - log.warn( - 'requestAuthenticatedUser was not called with valid OAuth state. This is likely due to a browser reloading the callback URL. Contact GitHub Support if you believe this is an error' - ) - return undefined - } - - const token = await requestOAuthToken(oauthState.endpoint, code) - if (token) { - return fetchUser(oauthState.endpoint, token) - } else { - return null - } -} - -/** - * Resolve the current OAuth request with the given account. - * - * Note that this can only be called after `askUserToOAuth` has been called and - * must only be called once. - */ -export function resolveOAuthRequest(account: Account) { - if (!oauthState) { - fatalError( - '`askUserToOAuth` must be called before resolving an auth request.' - ) - } - - oauthState.resolve(account) - - oauthState = null -} - -/** - * Reject the current OAuth request with the given error. - * - * Note that this can only be called after `askUserToOAuth` has been called and - * must only be called once. - */ -export function rejectOAuthRequest(error: Error) { - if (!oauthState) { - fatalError( - '`askUserToOAuth` must be called before rejecting an auth request.' - ) - } - - oauthState.reject(error) - - oauthState = null -} diff --git a/app/src/lib/parse-app-url.ts b/app/src/lib/parse-app-url.ts index 50f115e8c2f..a81d2f418ef 100644 --- a/app/src/lib/parse-app-url.ts +++ b/app/src/lib/parse-app-url.ts @@ -23,13 +23,6 @@ export interface IOpenRepositoryFromURLAction { readonly filepath: string | null } -export interface IOpenRepositoryFromPathAction { - readonly name: 'open-repository-from-path' - - /** The local path to open. */ - readonly path: string -} - export interface IUnknownAction { readonly name: 'unknown' readonly url: string @@ -38,7 +31,6 @@ export interface IUnknownAction { export type URLActionType = | IOAuthAction | IOpenRepositoryFromURLAction - | IOpenRepositoryFromPathAction | IUnknownAction // eslint-disable-next-line @typescript-eslint/naming-convention @@ -132,12 +124,5 @@ export function parseAppURL(url: string): URLActionType { } } - if (actionName === 'openlocalrepo') { - return { - name: 'open-repository-from-path', - path: decodeURIComponent(parsedPath), - } - } - return unknown } diff --git a/app/src/lib/parse-carriage-return.ts b/app/src/lib/parse-carriage-return.ts deleted file mode 100644 index 4d0b532d14d..00000000000 --- a/app/src/lib/parse-carriage-return.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Parses carriage returns the same way a terminal would, i.e by - * moving the cursor and (potentially) overwriting text. - * - * Git (and many other CLI tools) use this trick to present the - * user with nice looking progress. When writing something like... - * - * 'Downloading: 1% \r' - * 'Downloading: 2% \r' - * - * ...to the terminal the user is gonna perceive it as if the 1 just - * magically changes to a two. - * - * The carriage return character for all of you kids out there - * that haven't yet played with a manual typewriter refers to the - * "carriage" which held the character arms, see - * - * https://en.wikipedia.org/wiki/Carriage_return#Typewriters - */ -export function parseCarriageReturn(text: string) { - // Happy path, there are no carriage returns in - // the text, making this method a noop. - if (text.indexOf('\r') < 0) { - return text - } - - return text - .split('\n') - .map(line => - line.split('\r').reduce((buf, cur) => - // Happy path, if the new line is equal to or longer - // than the previous, we can just use the new one - // without creating any new strings. - cur.length >= buf.length ? cur : cur + buf.substring(cur.length) - ) - ) - .join('\n') -} diff --git a/app/src/lib/popup-manager.ts b/app/src/lib/popup-manager.ts new file mode 100644 index 00000000000..4025ee90eda --- /dev/null +++ b/app/src/lib/popup-manager.ts @@ -0,0 +1,207 @@ +import { Popup, PopupType } from '../models/popup' +import { sendNonFatalException } from './helpers/non-fatal-exception' +import { uuid } from './uuid' + +/** + * The limit of how many popups allowed in the stack. Working under the + * assumption that a user should only be dealing with a couple of popups at a + * time, if a user hits the limit this would indicate a problem. + */ +const defaultPopupStackLimit = 50 + +/** + * The popup manager is to manage the stack of currently open popups. + * + * Popup Flow Notes: + * 1. We have many types of popups. We only support opening one popup type at a + * time with the exception of PopupType.Error. If the app is to produce + * multiple errors, we want the user to be able to be informed of all them. + * 2. Error popups are viewed first ahead of any other popup types. Otherwise, + * popups ordered by last on last off. + * 3. There are custom error handling popups that are not categorized as errors: + * - When a error is captured in the app, we use the dispatcher method + * 'postError` to run through all the error handlers defined in + * `errorHandler.ts`. + * - If a custom error handler picks the error up, it handles it in a custom + * way. Commonly, it users the dispatcher to open a popup specific to the + * error - likely to allow interaction with the user. This is not an error + * popup. + * - Otherwise, the error is captured by the `defaultErrorHandler` defined + * in `errorHandler.ts` which simply dispatches to `presentError`. This + * method requests ends up in the app-store to add a popup of type `Error` + * to the stack. Then, it is rendered as a popup with the AppError + * component. + * - The AppError component additionally does some custom error handling for + * cloning errors and for author errors. But, most errors are just + * displayed as error text with a ok button. + */ +export class PopupManager { + private popupStack: ReadonlyArray = [] + + public constructor(private readonly popupLimit = defaultPopupStackLimit) {} + + /** + * Returns the last popup in the stack. + * + * The stack is sorted such that: + * If there are error popups, it returns the last popup of type error, + * otherwise returns the first non-error type popup. + */ + public get currentPopup(): Popup | null { + return this.popupStack.at(-1) ?? null + } + + /** + * Returns all the popups in the stack. + * + * The stack is sorted such that: + * If there are error popups, they will be the last on the stack. + */ + public get allPopups(): ReadonlyArray { + return this.popupStack + } + + /** + * Returns whether there are any popups in the stack. + */ + public get isAPopupOpen(): boolean { + return this.currentPopup !== null + } + + /** + * Returns an array of all popups in the stack of the provided type. + **/ + public getPopupsOfType(popupType: PopupType): ReadonlyArray { + return this.popupStack.filter(p => p.type === popupType) + } + + /** + * Returns whether there are any popups of a given type in the stack. + */ + public areTherePopupsOfType(popupType: PopupType): boolean { + return this.popupStack.some(p => p.type === popupType) + } + + /** + * Adds a popup to the stack. + * - The popup will be given a unique id and returned. + * - It will not add multiple popups of the same type onto the stack + * - NB: Error types are the only duplicates allowed + **/ + public addPopup(popupToAdd: Popup): Popup { + if (popupToAdd.type === PopupType.Error) { + return this.addErrorPopup(popupToAdd.error) + } + + const existingPopup = this.getPopupsOfType(popupToAdd.type) + + const popup = { id: uuid(), ...popupToAdd } + + if (existingPopup.length > 0) { + log.warn( + `Attempted to add a popup of already existing type - ${popupToAdd.type}.` + ) + return popupToAdd + } + + this.insertBeforeErrorPopups(popup) + this.checkStackLength() + return popup + } + + /** Adds a non-Error type popup before any error popups. */ + private insertBeforeErrorPopups(popup: Popup) { + if (this.popupStack.at(-1)?.type !== PopupType.Error) { + this.popupStack = this.popupStack.concat(popup) + return + } + + const errorPopups = this.getPopupsOfType(PopupType.Error) + const nonErrorPopups = this.popupStack.filter( + p => p.type !== PopupType.Error + ) + this.popupStack = [...nonErrorPopups, popup, ...errorPopups] + } + + /* + * Adds an Error Popup to the stack + * - The popup will be given a unique id. + * - Multiple popups of a type error. + **/ + public addErrorPopup(error: Error): Popup { + const popup: Popup = { id: uuid(), type: PopupType.Error, error } + this.popupStack = this.popupStack.concat(popup) + this.checkStackLength() + return popup + } + + private checkStackLength() { + if (this.popupStack.length > this.popupLimit) { + // Remove the oldest + const oldest = this.popupStack[0] + const oldestError = + oldest.type === PopupType.Error ? `: ${oldest.error.message}` : null + const justAddedError = + this.currentPopup?.type === PopupType.Error + ? `Just added another Error: ${this.currentPopup.error.message}.` + : null + sendNonFatalException( + 'TooManyPopups', + new Error( + `Max number of ${this.popupLimit} popups reached while adding popup of type ${this.currentPopup?.type}. + Removing last popup from the stack. Type ${oldest.type}${oldestError}. + ${justAddedError}` + ) + ) + this.popupStack = this.popupStack.slice(1) + } + } + + /** + * Updates a popup in the stack and returns it. + * - It uses the popup id to find and update the popup. + */ + public updatePopup(popupToUpdate: Popup) { + if (popupToUpdate.id === undefined) { + log.warn(`Attempted to update a popup without an id.`) + return + } + + const index = this.popupStack.findIndex(p => p.id === popupToUpdate.id) + if (index < 0) { + log.warn(`Attempted to update a popup not in the stack.`) + return + } + + this.popupStack = [ + ...this.popupStack.slice(0, index), + popupToUpdate, + ...this.popupStack.slice(index + 1), + ] + } + + /** + * Removes a popup based on it's id. + */ + public removePopup(popup: Popup) { + if (popup.id === undefined) { + log.warn(`Attempted to remove a popup without an id.`) + return + } + this.popupStack = this.popupStack.filter(p => p.id !== popup.id) + } + + /** + * Removes any popup of the given type from the stack + */ + public removePopupByType(popupType: PopupType) { + this.popupStack = this.popupStack.filter(p => p.type !== popupType) + } + + /** + * Removes popup from the stack by it's id + */ + public removePopupById(popupId: string) { + this.popupStack = this.popupStack.filter(p => p.id !== popupId) + } +} diff --git a/app/src/lib/process/win32.ts b/app/src/lib/process/win32.ts index a49358df2f0..ca62be737a4 100644 --- a/app/src/lib/process/win32.ts +++ b/app/src/lib/process/win32.ts @@ -1,11 +1,11 @@ import { spawn as spawnInternal } from 'child_process' -import * as Path from 'path' import { HKEY, RegistryValueType, RegistryValue, RegistryStringEntry, enumerateValues, + setValue, } from 'registry-js' function isStringRegistryValue(rv: RegistryValue): rv is RegistryStringEntry { @@ -15,31 +15,49 @@ function isStringRegistryValue(rv: RegistryValue): rv is RegistryStringEntry { ) } -/** Get the path segments in the user's `Path`. */ -export function getPathSegments(): ReadonlyArray { +export function getPathRegistryValue(): RegistryStringEntry | null { for (const value of enumerateValues(HKEY.HKEY_CURRENT_USER, 'Environment')) { if (value.name === 'Path' && isStringRegistryValue(value)) { - return value.data.split(';').filter(x => x.length > 0) + return value } } - throw new Error('Could not find PATH environment variable') + return null +} + +/** Get the path segments in the user's `Path`. */ +export function getPathSegments(): ReadonlyArray { + const value = getPathRegistryValue() + + if (value === null) { + throw new Error('Could not find PATH environment variable') + } + + return value.data.split(';').filter(x => x.length > 0) } /** Set the user's `Path`. */ export async function setPathSegments( paths: ReadonlyArray ): Promise { - let setxPath: string - const systemRoot = process.env['SystemRoot'] - if (systemRoot) { - const system32Path = Path.join(systemRoot, 'System32') - setxPath = Path.join(system32Path, 'setx.exe') - } else { - setxPath = 'setx.exe' + const value = getPathRegistryValue() + if (value === null) { + throw new Error('Could not find PATH environment variable') } - await spawn(setxPath, ['Path', paths.join(';')]) + try { + setValue( + HKEY.HKEY_CURRENT_USER, + 'Environment', + 'Path', + value.type, + paths.join(';') + ) + } catch (e) { + log.error('Failed setting PATH environment variable', e) + + throw new Error('Could not set the PATH environment variable') + } } /** Spawn a command with arguments and capture its output. */ diff --git a/app/src/lib/progress/from-process.ts b/app/src/lib/progress/from-process.ts index d86de2e43be..4c19dc8284e 100644 --- a/app/src/lib/progress/from-process.ts +++ b/app/src/lib/progress/from-process.ts @@ -16,11 +16,13 @@ import { tailByLine } from '../file-system' * If the given options object already has a processCallback specified it will * be overwritten. */ -export async function executionOptionsWithProgress( - options: IGitExecutionOptions, +export async function executionOptionsWithProgress< + T extends IGitExecutionOptions +>( + options: T, parser: GitProgressParser, progressCallback: (progress: IGitProgress | IGitOutput) => void -): Promise { +): Promise { let lfsProgressPath = null let env = {} if (options.trackLFSProgress) { diff --git a/app/src/lib/read-emoji.ts b/app/src/lib/read-emoji.ts index d5a67f2b970..2cc229ae7d2 100644 --- a/app/src/lib/read-emoji.ts +++ b/app/src/lib/read-emoji.ts @@ -1,6 +1,7 @@ import * as Fs from 'fs' import * as Path from 'path' import { encodePathAsUrl } from './path' +import { Emoji } from './emoji' /** * Type representing the contents of the gemoji json database @@ -81,8 +82,8 @@ function getUrlFromUnicodeEmoji(emoji: string): string | null { * * @param rootDir - The folder containing the entry point (index.html or main.js) of the application. */ -export function readEmoji(rootDir: string): Promise> { - return new Promise>((resolve, reject) => { +export function readEmoji(rootDir: string): Promise> { + return new Promise>((resolve, reject) => { const path = Path.join(rootDir, 'emoji.json') Fs.readFile(path, 'utf8', (err, data) => { if (err) { @@ -90,7 +91,7 @@ export function readEmoji(rootDir: string): Promise> { return } - const tmp = new Map() + const tmp = new Map() try { const db: IGemojiDb = JSON.parse(data) @@ -107,14 +108,17 @@ export function readEmoji(rootDir: string): Promise> { } emoji.aliases.forEach(alias => { - tmp.set(`:${alias}:`, url) + tmp.set(`:${alias}:`, { + ...emoji, + url, + }) }) }) } catch (e) { reject(e) } - const emoji = new Map() + const emoji = new Map() // Sort and insert into actual map const keys = Array.from(tmp.keys()).sort() diff --git a/app/src/lib/rebase.ts b/app/src/lib/rebase.ts index 6ab9a6ddb1d..2f8e68179f3 100644 --- a/app/src/lib/rebase.ts +++ b/app/src/lib/rebase.ts @@ -3,6 +3,26 @@ import { IAheadBehind } from '../models/branch' import { TipState } from '../models/tip' import { clamp } from './clamp' +/** Represents the force-push availability state of a branch. */ +export enum ForcePushBranchState { + /** The branch cannot be force-pushed (it hasn't diverged from its upstream) */ + NotAvailable, + + /** + * The branch can be force-pushed, but the user didn't do any operation that + * we consider should be followed by a force-push, like rebasing or amending a + * pushed commit. + */ + Available, + + /** + * The branch can be force-pushed, and the user did some operation that we + * consider should be followed by a force-push, like rebasing or amending a + * pushed commit. + */ + Recommended, +} + /** * Format rebase percentage to ensure it's a value between 0 and 1, but to also * constrain it to two significant figures, avoiding the remainder that comes @@ -16,18 +36,24 @@ export function formatRebaseValue(value: number) { * Check application state to see whether the action applied to the current * branch should be a force push */ -export function isCurrentBranchForcePush( +export function getCurrentBranchForcePushState( branchesState: IBranchesState, aheadBehind: IAheadBehind | null -) { +): ForcePushBranchState { if (aheadBehind === null) { // no tracking branch found - return false + return ForcePushBranchState.NotAvailable } - const { tip, forcePushBranches } = branchesState const { ahead, behind } = aheadBehind + if (behind === 0 || ahead === 0) { + // no a diverged branch to force push + return ForcePushBranchState.NotAvailable + } + + const { tip, forcePushBranches } = branchesState + let canForcePushBranch = false if (tip.kind === TipState.Valid) { const localBranchName = tip.branch.nameWithoutRemote @@ -36,5 +62,7 @@ export function isCurrentBranchForcePush( canForcePushBranch = foundEntry === sha } - return canForcePushBranch && behind > 0 && ahead > 0 + return canForcePushBranch + ? ForcePushBranchState.Recommended + : ForcePushBranchState.Available } diff --git a/app/src/lib/release-notes.ts b/app/src/lib/release-notes.ts index c9590800fee..6043c3a1250 100644 --- a/app/src/lib/release-notes.ts +++ b/app/src/lib/release-notes.ts @@ -10,6 +10,7 @@ import { getVersion } from '../ui/lib/app-proxy' import { formatDate } from './format-date' import { offsetFromNow } from './offset-from' import { encodePathAsUrl } from './path' +import { getUserAgent } from './http' // expects a release note entry to contain a header and then some text // example: @@ -102,7 +103,9 @@ export async function getChangeLog( changelogURL.searchParams.set('limit', limit.toString()) } - const response = await fetch(changelogURL.toString()) + const response = await fetch(changelogURL.toString(), { + headers: { 'user-agent': getUserAgent() }, + }) if (response.ok) { const releases: ReadonlyArray = await response.json() return releases diff --git a/app/src/lib/remote-parsing.ts b/app/src/lib/remote-parsing.ts index 737097d3723..2e8f15ca75b 100644 --- a/app/src/lib/remote-parsing.ts +++ b/app/src/lib/remote-parsing.ts @@ -27,19 +27,27 @@ interface IGitRemoteURL { const remoteRegexes: ReadonlyArray<{ protocol: GitProtocol; regex: RegExp }> = [ { protocol: 'https', - regex: new RegExp('^https?://(?:.+@)?(.+)/(.+)/(.+?)(?:/|.git/?)?$'), + regex: new RegExp( + '^https?://(?:.+@)?(.+)/([^/]+)/([^/]+?)(?:/|\\.git/?)?$' + ), }, { protocol: 'ssh', - regex: new RegExp('^git@(.+):(.+)/(.+?)(?:/|.git)?$'), + regex: new RegExp('^git@(.+):([^/]+)/([^/]+?)(?:/|\\.git)?$'), }, { protocol: 'ssh', - regex: new RegExp('^git:(.+)/(.+)/(.+?)(?:/|.git)?$'), + regex: new RegExp( + '^(?:.+)@(.+\\.ghe\\.com):([^/]+)/([^/]+?)(?:/|\\.git)?$' + ), }, { protocol: 'ssh', - regex: new RegExp('^ssh://git@(.+)/(.+)/(.+?)(?:/|.git)?$'), + regex: new RegExp('^git:(.+)/([^/]+)/([^/]+?)(?:/|\\.git)?$'), + }, + { + protocol: 'ssh', + regex: new RegExp('^ssh://git@(.+)/(.+)/(.+?)(?:/|\\.git)?$'), }, ] diff --git a/app/src/lib/shells/darwin.ts b/app/src/lib/shells/darwin.ts index f9f482d3db5..0828ccd483b 100644 --- a/app/src/lib/shells/darwin.ts +++ b/app/src/lib/shells/darwin.ts @@ -1,8 +1,14 @@ import { spawn, ChildProcess } from 'child_process' import { assertNever } from '../fatal-error' -import { IFoundShell } from './found-shell' import appPath from 'app-path' import { parseEnumValue } from '../enum' +import { FoundShell } from './shared' +import { + expandTargetPathArgument, + ICustomIntegration, + parseCustomIntegrationArguments, + spawnCustomIntegration, +} from '../custom-integration' export enum Shell { Terminal = 'Terminal', @@ -11,7 +17,9 @@ export enum Shell { PowerShellCore = 'PowerShell Core', Kitty = 'Kitty', Alacritty = 'Alacritty', + Tabby = 'Tabby', WezTerm = 'WezTerm', + Warp = 'Warp', } export const Default = Shell.Terminal @@ -20,95 +28,142 @@ export function parse(label: string): Shell { return parseEnumValue(Shell, label) ?? Default } -function getBundleID(shell: Shell): string { +function getBundleIDs(shell: Shell): ReadonlyArray { switch (shell) { case Shell.Terminal: - return 'com.apple.Terminal' + return ['com.apple.Terminal'] case Shell.iTerm2: - return 'com.googlecode.iterm2' + return ['com.googlecode.iterm2'] case Shell.Hyper: - return 'co.zeit.hyper' + return ['co.zeit.hyper'] case Shell.PowerShellCore: - return 'com.microsoft.powershell' + return ['com.microsoft.powershell'] case Shell.Kitty: - return 'net.kovidgoyal.kitty' + return ['net.kovidgoyal.kitty'] case Shell.Alacritty: - return 'io.alacritty' + return ['org.alacritty', 'io.alacritty'] + case Shell.Tabby: + return ['org.tabby'] case Shell.WezTerm: - return 'com.github.wez.wezterm' + return ['com.github.wez.wezterm'] + case Shell.Warp: + return ['dev.warp.Warp-Stable'] default: return assertNever(shell, `Unknown shell: ${shell}`) } } -async function getShellPath(shell: Shell): Promise { - const bundleId = getBundleID(shell) - try { - return await appPath(bundleId) - } catch (e) { - // `appPath` will raise an error if it cannot find the program. - return null +async function getShellInfo( + shell: Shell +): Promise<{ path: string; bundleID: string } | null> { + const bundleIds = getBundleIDs(shell) + for (const id of bundleIds) { + try { + const path = await appPath(id) + return { path, bundleID: id } + } catch (error) { + log.debug( + `Unable to locate ${shell} installation with bundle id ${id}`, + error + ) + } } + + return null } export async function getAvailableShells(): Promise< - ReadonlyArray> + ReadonlyArray> > { const [ - terminalPath, - hyperPath, - iTermPath, - powerShellCorePath, - kittyPath, - alacrittyPath, - wezTermPath, + terminalInfo, + hyperInfo, + iTermInfo, + powerShellCoreInfo, + kittyInfo, + alacrittyInfo, + tabbyInfo, + wezTermInfo, + warpInfo, ] = await Promise.all([ - getShellPath(Shell.Terminal), - getShellPath(Shell.Hyper), - getShellPath(Shell.iTerm2), - getShellPath(Shell.PowerShellCore), - getShellPath(Shell.Kitty), - getShellPath(Shell.Alacritty), - getShellPath(Shell.WezTerm), + getShellInfo(Shell.Terminal), + getShellInfo(Shell.Hyper), + getShellInfo(Shell.iTerm2), + getShellInfo(Shell.PowerShellCore), + getShellInfo(Shell.Kitty), + getShellInfo(Shell.Alacritty), + getShellInfo(Shell.Tabby), + getShellInfo(Shell.WezTerm), + getShellInfo(Shell.Warp), ]) - const shells: Array> = [] - if (terminalPath) { - shells.push({ shell: Shell.Terminal, path: terminalPath }) + const shells: Array> = [] + if (terminalInfo) { + shells.push({ shell: Shell.Terminal, ...terminalInfo }) + } + + if (hyperInfo) { + shells.push({ shell: Shell.Hyper, ...hyperInfo }) } - if (hyperPath) { - shells.push({ shell: Shell.Hyper, path: hyperPath }) + if (iTermInfo) { + shells.push({ shell: Shell.iTerm2, ...iTermInfo }) } - if (iTermPath) { - shells.push({ shell: Shell.iTerm2, path: iTermPath }) + if (powerShellCoreInfo) { + shells.push({ shell: Shell.PowerShellCore, ...powerShellCoreInfo }) } - if (powerShellCorePath) { - shells.push({ shell: Shell.PowerShellCore, path: powerShellCorePath }) + if (kittyInfo) { + const kittyExecutable = `${kittyInfo.path}/Contents/MacOS/kitty` + shells.push({ + shell: Shell.Kitty, + path: kittyExecutable, + bundleID: kittyInfo.bundleID, + }) } - if (kittyPath) { - const kittyExecutable = `${kittyPath}/Contents/MacOS/kitty` - shells.push({ shell: Shell.Kitty, path: kittyExecutable }) + if (alacrittyInfo) { + const alacrittyExecutable = `${alacrittyInfo.path}/Contents/MacOS/alacritty` + shells.push({ + shell: Shell.Alacritty, + path: alacrittyExecutable, + bundleID: alacrittyInfo.bundleID, + }) } - if (alacrittyPath) { - const alacrittyExecutable = `${alacrittyPath}/Contents/MacOS/alacritty` - shells.push({ shell: Shell.Alacritty, path: alacrittyExecutable }) + if (tabbyInfo) { + const tabbyExecutable = `${tabbyInfo.path}/Contents/MacOS/Tabby` + shells.push({ + shell: Shell.Tabby, + path: tabbyExecutable, + bundleID: tabbyInfo.bundleID, + }) } - if (wezTermPath) { - const wezTermExecutable = `${wezTermPath}/Contents/MacOS/wezterm` - shells.push({ shell: Shell.WezTerm, path: wezTermExecutable }) + if (wezTermInfo) { + const wezTermExecutable = `${wezTermInfo.path}/Contents/MacOS/wezterm` + shells.push({ + shell: Shell.WezTerm, + path: wezTermExecutable, + bundleID: wezTermInfo.bundleID, + }) + } + + if (warpInfo) { + const warpExecutable = `${warpInfo.path}/Contents/MacOS/stable` + shells.push({ + shell: Shell.Warp, + path: warpExecutable, + bundleID: warpInfo.bundleID, + }) } return shells } export function launch( - foundShell: IFoundShell, + foundShell: FoundShell, path: string ): ChildProcess { if (foundShell.shell === Shell.Kitty) { @@ -125,6 +180,12 @@ export function launch( // It uses --working-directory command to start the shell // in the specified working directory. return spawn(foundShell.path, ['--working-directory', path]) + } else if (foundShell.shell === Shell.Tabby) { + // Tabby cannot open files in the folder format. + // + // It uses open command to start the shell + // in the specified working directory. + return spawn(foundShell.path, ['open', path]) } else if (foundShell.shell === Shell.WezTerm) { // WezTerm, like Alacritty, "cannot open files in the 'folder' format." // @@ -132,7 +193,18 @@ export function launch( // the working directory, followed by the path. return spawn(foundShell.path, ['start', '--cwd', path]) } else { - const bundleID = getBundleID(foundShell.shell) - return spawn('open', ['-b', bundleID, path]) + return spawn('open', ['-b', foundShell.bundleID, path]) } } + +export function launchCustomShell( + customShell: ICustomIntegration, + path: string +): ChildProcess { + const argv = parseCustomIntegrationArguments(customShell.arguments) + const args = expandTargetPathArgument(argv, path) + + return customShell.bundleID + ? spawnCustomIntegration('open', ['-b', customShell.bundleID, ...args]) + : spawnCustomIntegration(customShell.path, args) +} diff --git a/app/src/lib/shells/found-shell.ts b/app/src/lib/shells/found-shell.ts deleted file mode 100644 index 89934594313..00000000000 --- a/app/src/lib/shells/found-shell.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface IFoundShell { - readonly shell: T - readonly path: string - readonly extraArgs?: string[] -} diff --git a/app/src/lib/shells/linux.ts b/app/src/lib/shells/linux.ts index 7ab0a4a5e89..afb8087cc31 100644 --- a/app/src/lib/shells/linux.ts +++ b/app/src/lib/shells/linux.ts @@ -1,11 +1,18 @@ import { spawn, ChildProcess } from 'child_process' import { assertNever } from '../fatal-error' -import { IFoundShell } from './found-shell' import { parseEnumValue } from '../enum' import { pathExists } from '../../ui/lib/path-exists' +import { FoundShell } from './shared' +import { + expandTargetPathArgument, + ICustomIntegration, + parseCustomIntegrationArguments, + spawnCustomIntegration, +} from '../custom-integration' export enum Shell { Gnome = 'GNOME Terminal', + GnomeConsole = 'GNOME Console', Mate = 'MATE Terminal', Tilix = 'Tilix', Terminator = 'Terminator', @@ -18,6 +25,8 @@ export enum Shell { XFCE = 'XFCE Terminal', Alacritty = 'Alacritty', Kitty = 'Kitty', + LXTerminal = 'LXDE Terminal', + Warp = 'Warp', } export const Default = Shell.Gnome @@ -34,6 +43,8 @@ function getShellPath(shell: Shell): Promise { switch (shell) { case Shell.Gnome: return getPathIfAvailable('/usr/bin/gnome-terminal') + case Shell.GnomeConsole: + return getPathIfAvailable('/usr/bin/kgx') case Shell.Mate: return getPathIfAvailable('/usr/bin/mate-terminal') case Shell.Tilix: @@ -58,16 +69,21 @@ function getShellPath(shell: Shell): Promise { return getPathIfAvailable('/usr/bin/alacritty') case Shell.Kitty: return getPathIfAvailable('/usr/bin/kitty') + case Shell.LXTerminal: + return getPathIfAvailable('/usr/bin/lxterminal') + case Shell.Warp: + return getPathIfAvailable('/usr/bin/warp-terminal') default: return assertNever(shell, `Unknown shell: ${shell}`) } } export async function getAvailableShells(): Promise< - ReadonlyArray> + ReadonlyArray> > { const [ gnomeTerminalPath, + gnomeConsolePath, mateTerminalPath, tilixPath, terminatorPath, @@ -80,8 +96,11 @@ export async function getAvailableShells(): Promise< xfcePath, alacrittyPath, kittyPath, + lxterminalPath, + warpPath, ] = await Promise.all([ getShellPath(Shell.Gnome), + getShellPath(Shell.GnomeConsole), getShellPath(Shell.Mate), getShellPath(Shell.Tilix), getShellPath(Shell.Terminator), @@ -94,13 +113,19 @@ export async function getAvailableShells(): Promise< getShellPath(Shell.XFCE), getShellPath(Shell.Alacritty), getShellPath(Shell.Kitty), + getShellPath(Shell.LXTerminal), + getShellPath(Shell.Warp), ]) - const shells: Array> = [] + const shells: Array> = [] if (gnomeTerminalPath) { shells.push({ shell: Shell.Gnome, path: gnomeTerminalPath }) } + if (gnomeConsolePath) { + shells.push({ shell: Shell.GnomeConsole, path: gnomeConsolePath }) + } + if (mateTerminalPath) { shells.push({ shell: Shell.Mate, path: mateTerminalPath }) } @@ -149,16 +174,25 @@ export async function getAvailableShells(): Promise< shells.push({ shell: Shell.Kitty, path: kittyPath }) } + if (lxterminalPath) { + shells.push({ shell: Shell.LXTerminal, path: lxterminalPath }) + } + + if (warpPath) { + shells.push({ shell: Shell.Warp, path: warpPath }) + } + return shells } export function launch( - foundShell: IFoundShell, + foundShell: FoundShell, path: string ): ChildProcess { const shell = foundShell.shell switch (shell) { case Shell.Gnome: + case Shell.GnomeConsole: case Shell.Mate: case Shell.Tilix: case Shell.Terminator: @@ -179,7 +213,20 @@ export function launch( return spawn(foundShell.path, ['-w', path]) case Shell.Kitty: return spawn(foundShell.path, ['--single-instance', '--directory', path]) + case Shell.LXTerminal: + return spawn(foundShell.path, ['--working-directory=' + path]) + case Shell.Warp: + return spawn(foundShell.path, [], { cwd: path }) default: return assertNever(shell, `Unknown shell: ${shell}`) } } + +export function launchCustomShell( + customShell: ICustomIntegration, + path: string +): ChildProcess { + const argv = parseCustomIntegrationArguments(customShell.arguments) + const args = expandTargetPathArgument(argv, path) + return spawnCustomIntegration(customShell.path, args) +} diff --git a/app/src/lib/shells/shared.ts b/app/src/lib/shells/shared.ts index 8d9dad37f7c..25e52bfd3ea 100644 --- a/app/src/lib/shells/shared.ts +++ b/app/src/lib/shells/shared.ts @@ -3,13 +3,23 @@ import { ChildProcess } from 'child_process' import * as Darwin from './darwin' import * as Win32 from './win32' import * as Linux from './linux' -import { IFoundShell } from './found-shell' import { ShellError } from './error' import { pathExists } from '../../ui/lib/path-exists' +import { ICustomIntegration } from '../custom-integration' export type Shell = Darwin.Shell | Win32.Shell | Linux.Shell -export type FoundShell = IFoundShell +export type FoundShell = { + readonly shell: T + readonly path: string + readonly extraArgs?: ReadonlyArray +} & (T extends Darwin.Shell + ? { + readonly bundleID: string + } + : {}) + +type AnyFoundShell = FoundShell /** The default shell. */ export const Default = (function () { @@ -22,7 +32,7 @@ export const Default = (function () { } })() -let shellCache: ReadonlyArray | null = null +let shellCache: ReadonlyArray | null = null /** Parse the label into the specified shell type. */ export function parse(label: string): Shell { @@ -40,7 +50,9 @@ export function parse(label: string): Shell { } /** Get the shells available for the user. */ -export async function getAvailableShells(): Promise> { +export async function getAvailableShells(): Promise< + ReadonlyArray +> { if (shellCache) { return shellCache } @@ -62,7 +74,7 @@ export async function getAvailableShells(): Promise> { } /** Find the given shell or the default if the given shell can't be found. */ -export async function findShellOrDefault(shell: Shell): Promise { +export async function findShellOrDefault(shell: Shell): Promise { const available = await getAvailableShells() const found = available.find(s => s.shell === shell) if (found) { @@ -74,7 +86,7 @@ export async function findShellOrDefault(shell: Shell): Promise { /** Launch the given shell at the path. */ export async function launchShell( - shell: FoundShell, + shell: AnyFoundShell, path: string, onError: (error: Error) => void ): Promise { @@ -83,7 +95,7 @@ export async function launchShell( // platform-specific build targets. const exists = await pathExists(shell.path) if (!exists) { - const label = __DARWIN__ ? 'Preferences' : 'Options' + const label = __DARWIN__ ? 'Settings' : 'Options' throw new ShellError( `Could not find executable for '${shell.shell}' at path '${shell.path}'. Please open ${label} and select an available shell.` ) @@ -92,11 +104,11 @@ export async function launchShell( let cp: ChildProcess | null = null if (__DARWIN__) { - cp = Darwin.launch(shell as IFoundShell, path) + cp = Darwin.launch(shell as FoundShell, path) } else if (__WIN32__) { - cp = Win32.launch(shell as IFoundShell, path) + cp = Win32.launch(shell as FoundShell, path) } else if (__LINUX__) { - cp = Linux.launch(shell as IFoundShell, path) + cp = Linux.launch(shell as FoundShell, path) } if (cp != null) { @@ -109,8 +121,45 @@ export async function launchShell( } } +/** Launch custom shell at the path. */ +export async function launchCustomShell( + customShell: ICustomIntegration, + path: string, + onError: (error: Error) => void +): Promise { + // We have to manually cast the wider `Shell` type into the platform-specific + // type. This is less than ideal, but maybe the best we can do without + // platform-specific build targets. + const exists = await pathExists(customShell.path) + if (!exists) { + const label = __DARWIN__ ? 'Settings' : 'Options' + throw new ShellError( + `Could not find executable for custom shell at path '${customShell.path}'. Please open ${label} and select an available shell.` + ) + } + + let cp: ChildProcess | null = null + + if (__DARWIN__) { + cp = Darwin.launchCustomShell(customShell, path) + } else if (__WIN32__) { + cp = Win32.launchCustomShell(customShell, path) + } else if (__LINUX__) { + cp = Linux.launchCustomShell(customShell, path) + } + + if (cp != null) { + addErrorTracing('Custom Shell', cp, onError) + return Promise.resolve() + } else { + return Promise.reject( + `Platform not currently supported for launching shells: ${process.platform}` + ) + } +} + function addErrorTracing( - shell: Shell, + shell: Shell | 'Custom Shell', cp: ChildProcess, onError: (error: Error) => void ) { diff --git a/app/src/lib/shells/win32.ts b/app/src/lib/shells/win32.ts index e3c058eb120..765dd687064 100644 --- a/app/src/lib/shells/win32.ts +++ b/app/src/lib/shells/win32.ts @@ -2,11 +2,17 @@ import { spawn, ChildProcess } from 'child_process' import * as Path from 'path' import { enumerateValues, HKEY, RegistryValueType } from 'registry-js' import { assertNever } from '../fatal-error' -import { IFoundShell } from './found-shell' import { enableWSLDetection } from '../feature-flag' import { findGitOnPath } from '../is-git-on-path' import { parseEnumValue } from '../enum' import { pathExists } from '../../ui/lib/path-exists' +import { FoundShell } from './shared' +import { + expandTargetPathArgument, + ICustomIntegration, + parseCustomIntegrationArguments, + spawnCustomIntegration, +} from '../custom-integration' export enum Shell { Cmd = 'Command Prompt', @@ -16,7 +22,7 @@ export enum Shell { GitBash = 'Git Bash', Cygwin = 'Cygwin', WSL = 'WSL', - WindowTerminal = 'Windows Terminal', + WindowsTerminal = 'Windows Terminal', FluentTerminal = 'Fluent Terminal', Alacritty = 'Alacritty', } @@ -28,14 +34,16 @@ export function parse(label: string): Shell { } export async function getAvailableShells(): Promise< - ReadonlyArray> + ReadonlyArray> > { const gitPath = await findGitOnPath() - const shells: IFoundShell[] = [ + const rootDir = process.env.WINDIR || 'C:\\Windows' + const dosKeyExePath = `"${rootDir}\\system32\\doskey.exe git=^"${gitPath}^" $*"` + const shells: FoundShell[] = [ { shell: Shell.Cmd, path: process.env.comspec || 'C:\\Windows\\System32\\cmd.exe', - extraArgs: gitPath ? ['/K', `"doskey git=^"${gitPath}^" $*"`] : [], + extraArgs: gitPath ? ['/K', dosKeyExePath] : [], }, ] @@ -100,7 +108,7 @@ export async function getAvailableShells(): Promise< const windowsTerminal = await findWindowsTerminal() if (windowsTerminal != null) { shells.push({ - shell: Shell.WindowTerminal, + shell: Shell.WindowsTerminal, path: windowsTerminal, }) } @@ -390,7 +398,7 @@ async function findFluentTerminal(): Promise { } export function launch( - foundShell: IFoundShell, + foundShell: FoundShell, path: string ): ChildProcess { const shell = foundShell.shell @@ -461,7 +469,7 @@ export function launch( cwd: path, } ) - case Shell.WindowTerminal: + case Shell.WindowsTerminal: const windowsTerminalPath = `"${foundShell.path}"` log.info(`launching ${shell} at path: ${windowsTerminalPath}`) return spawn(windowsTerminalPath, ['-d .'], { shell: true, cwd: path }) @@ -473,3 +481,16 @@ export function launch( return assertNever(shell, `Unknown shell: ${shell}`) } } + +export function launchCustomShell( + customShell: ICustomIntegration, + path: string +): ChildProcess { + log.info(`launching custom shell at path: ${customShell.path}`) + const argv = parseCustomIntegrationArguments(customShell.arguments) + const args = expandTargetPathArgument(argv, path) + return spawnCustomIntegration(`"${customShell.path}"`, args, { + shell: true, + cwd: path, + }) +} diff --git a/app/src/lib/split-buffer.ts b/app/src/lib/split-buffer.ts new file mode 100644 index 00000000000..ca082a00b6c --- /dev/null +++ b/app/src/lib/split-buffer.ts @@ -0,0 +1,14 @@ +export function splitBuffer(buffer: Buffer, delimiter: string): Buffer[] { + const result = [] + let start = 0 + let index = buffer.indexOf(delimiter, start) + while (index !== -1) { + result.push(buffer.subarray(start, index)) + start = index + delimiter.length + index = buffer.indexOf(delimiter, start) + } + if (start <= buffer.length) { + result.push(buffer.subarray(start)) + } + return result +} diff --git a/app/src/lib/squirrel-error-parser.ts b/app/src/lib/squirrel-error-parser.ts index 89665443a14..337a14865fa 100644 --- a/app/src/lib/squirrel-error-parser.ts +++ b/app/src/lib/squirrel-error-parser.ts @@ -14,7 +14,6 @@ const squirrelTimeoutRegex = * friendlier message to the user. * * @param error The underlying error from Squirrel. - * */ export function parseError(error: Error): Error | null { if (squirrelMissingRegex.test(error.message)) { diff --git a/app/src/lib/ssh/ssh-credential-storage.ts b/app/src/lib/ssh/ssh-credential-storage.ts new file mode 100644 index 00000000000..35c7ba11f1a --- /dev/null +++ b/app/src/lib/ssh/ssh-credential-storage.ts @@ -0,0 +1,87 @@ +import { TokenStore } from '../stores' + +const appName = __DEV__ ? 'GitHub Desktop Dev' : 'GitHub Desktop' + +export function getSSHCredentialStoreKey(name: string) { + return `${appName} - ${name}` +} + +type SSHCredentialEntry = { + /** Store where this entry is stored. */ + store: string + + /** Key used to identify the credential in the store (e.g. username or hash). */ + key: string +} + +/** + * This map contains the SSH credentials that are pending to be stored. What this + * means is that a git operation is currently in progress, and the user wanted + * to store the passphrase for the SSH key, however we don't want to store it + * until we know the git operation finished successfully. + */ +const mostRecentSSHCredentials = new Map() + +/** + * Stores an SSH credential and also keeps it in memory to be deleted later if + * the ongoing git operation fails to authenticate. + * + * @param operationGUID A unique identifier for the ongoing git operation. In + * practice, it will always be the trampoline token for the + * ongoing git operation. + * @param store Store where the SSH credential is stored. + * @param key Key that identifies the SSH credential (e.g. username or + * key hash). + * @param password Password for the SSH key / user. + */ +export async function setSSHCredential( + operationGUID: string, + store: string, + key: string, + password: string +) { + setMostRecentSSHCredential(operationGUID, store, key) + await TokenStore.setItem(store, key, password) +} + +/** + * Keeps the SSH credential details in memory to be deleted later if the ongoing + * git operation fails to authenticate. + * + * @param operationGUID A unique identifier for the ongoing git operation. In + * practice, it will always be the trampoline token for the + * ongoing git operation. + * @param store Store where the SSH credential is stored. + * @param key Key that identifies the SSH credential (e.g. username or + * key hash). + */ +export function setMostRecentSSHCredential( + operationGUID: string, + store: string, + key: string +) { + mostRecentSSHCredentials.set(operationGUID, { store, key }) +} + +/** + * Removes the SSH credential from memory. This must be used after a git + * operation finished, regardless the result. + */ +export function removeMostRecentSSHCredential(operationGUID: string) { + mostRecentSSHCredentials.delete(operationGUID) +} + +/** + * Deletes the SSH credential from the TokenStore. Used when the git operation + * fails to authenticate. + */ +export async function deleteMostRecentSSHCredential(operationGUID: string) { + const entry = mostRecentSSHCredentials.get(operationGUID) + if (entry) { + log.info( + `SSH auth failed, deleting credential for ${entry.store}:${entry.key}` + ) + + await TokenStore.deleteItem(entry.store, entry.key) + } +} diff --git a/app/src/lib/ssh/ssh-key-passphrase.ts b/app/src/lib/ssh/ssh-key-passphrase.ts index 7782a1f530c..b20c4569534 100644 --- a/app/src/lib/ssh/ssh-key-passphrase.ts +++ b/app/src/lib/ssh/ssh-key-passphrase.ts @@ -1,11 +1,12 @@ -import { getFileHash } from '../file-system' +import { getFileHash } from '../get-file-hash' import { TokenStore } from '../stores' import { - getSSHSecretStoreKey, - keepSSHSecretToStore, -} from './ssh-secret-storage' + getSSHCredentialStoreKey, + setMostRecentSSHCredential, + setSSHCredential, +} from './ssh-credential-storage' -const SSHKeyPassphraseTokenStoreKey = getSSHSecretStoreKey( +const SSHKeyPassphraseTokenStoreKey = getSSHCredentialStoreKey( 'SSH key passphrases' ) @@ -25,8 +26,7 @@ export async function getSSHKeyPassphrase(keyPath: string) { } /** - * Keeps the SSH key passphrase in memory to be stored later if the ongoing git - * operation succeeds. + * Stores the SSH key passphrase. * * @param operationGUID A unique identifier for the ongoing git operation. In * practice, it will always be the trampoline token for the @@ -34,14 +34,15 @@ export async function getSSHKeyPassphrase(keyPath: string) { * @param keyPath Path to the SSH key. * @param passphrase Passphrase for the SSH key. */ -export async function keepSSHKeyPassphraseToStore( +export async function setSSHKeyPassphrase( operationGUID: string, keyPath: string, passphrase: string ) { try { const keyHash = await getHashForSSHKey(keyPath) - keepSSHSecretToStore( + + await setSSHCredential( operationGUID, SSHKeyPassphraseTokenStoreKey, keyHash, @@ -51,3 +52,29 @@ export async function keepSSHKeyPassphraseToStore( log.error('Could not store passphrase for SSH key:', e) } } + +/** + * Keeps the SSH credential details in memory to be deleted later if the ongoing + * git operation fails to authenticate. + * + * @param operationGUID A unique identifier for the ongoing git operation. In + * practice, it will always be the trampoline secret for the + * ongoing git operation. + * @param keyPath Path of the SSH key. + */ +export async function setMostRecentSSHKeyPassphrase( + operationGUID: string, + keyPath: string +) { + try { + const keyHash = await getHashForSSHKey(keyPath) + + setMostRecentSSHCredential( + operationGUID, + SSHKeyPassphraseTokenStoreKey, + keyHash + ) + } catch (e) { + log.error('Could not store passphrase for SSH key:', e) + } +} diff --git a/app/src/lib/ssh/ssh-secret-storage.ts b/app/src/lib/ssh/ssh-secret-storage.ts deleted file mode 100644 index 7f651591f53..00000000000 --- a/app/src/lib/ssh/ssh-secret-storage.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { TokenStore } from '../stores' - -const appName = __DEV__ ? 'GitHub Desktop Dev' : 'GitHub Desktop' - -export function getSSHSecretStoreKey(name: string) { - return `${appName} - ${name}` -} - -type SSHSecretEntry = { - /** Store where this entry will be stored. */ - store: string - - /** Key used to identify the secret in the store (e.g. username or hash). */ - key: string - - /** Actual secret to be stored (password). */ - secret: string -} - -/** - * This map contains the SSH secrets that are pending to be stored. What this - * means is that a git operation is currently in progress, and the user wanted - * to store the passphrase for the SSH key, however we don't want to store it - * until we know the git operation finished successfully. - */ -const SSHSecretsToStore = new Map() - -/** - * Keeps the SSH secret in memory to be stored later if the ongoing git operation - * succeeds. - * - * @param operationGUID A unique identifier for the ongoing git operation. In - * practice, it will always be the trampoline secret for the - * ongoing git operation. - * @param key Key that identifies the SSH secret (e.g. username or key - * hash). - * @param secret Actual SSH secret to store. - */ -export async function keepSSHSecretToStore( - operationGUID: string, - store: string, - key: string, - secret: string -) { - SSHSecretsToStore.set(operationGUID, { store, key, secret }) -} - -/** Removes the SSH key passphrase from memory. */ -export function removePendingSSHSecretToStore(operationGUID: string) { - SSHSecretsToStore.delete(operationGUID) -} - -/** Stores a pending SSH key passphrase if the operation succeeded. */ -export async function storePendingSSHSecret(operationGUID: string) { - const entry = SSHSecretsToStore.get(operationGUID) - if (entry === undefined) { - return - } - - await TokenStore.setItem(entry.store, entry.key, entry.secret) -} diff --git a/app/src/lib/ssh/ssh-user-password.ts b/app/src/lib/ssh/ssh-user-password.ts index 4bb25c43012..e4f930183c3 100644 --- a/app/src/lib/ssh/ssh-user-password.ts +++ b/app/src/lib/ssh/ssh-user-password.ts @@ -1,10 +1,12 @@ import { TokenStore } from '../stores' import { - getSSHSecretStoreKey, - keepSSHSecretToStore, -} from './ssh-secret-storage' + getSSHCredentialStoreKey, + setMostRecentSSHCredential, + setSSHCredential, +} from './ssh-credential-storage' -const SSHUserPasswordTokenStoreKey = getSSHSecretStoreKey('SSH user password') +const SSHUserPasswordTokenStoreKey = + getSSHCredentialStoreKey('SSH user password') /** Retrieves the password for the given SSH username. */ export async function getSSHUserPassword(username: string) { @@ -17,8 +19,7 @@ export async function getSSHUserPassword(username: string) { } /** - * Keeps the SSH user password in memory to be stored later if the ongoing git - * operation succeeds. + * Stores the SSH user password. * * @param operationGUID A unique identifier for the ongoing git operation. In * practice, it will always be the trampoline token for the @@ -26,15 +27,35 @@ export async function getSSHUserPassword(username: string) { * @param username SSH user name. Usually in the form of `user@hostname`. * @param password Password for the given user. */ -export async function keepSSHUserPasswordToStore( +export async function setSSHUserPassword( operationGUID: string, username: string, password: string ) { - keepSSHSecretToStore( + await setSSHCredential( operationGUID, SSHUserPasswordTokenStoreKey, username, password ) } + +/** + * Keeps the SSH credential details in memory to be deleted later if the ongoing + * git operation fails to authenticate. + * + * @param operationGUID A unique identifier for the ongoing git operation. In + * practice, it will always be the trampoline secret for the + * ongoing git operation. + * @param username SSH user name. + */ +export function setMostRecentSSHUserPassword( + operationGUID: string, + username: string +) { + setMostRecentSSHCredential( + operationGUID, + SSHUserPasswordTokenStoreKey, + username + ) +} diff --git a/app/src/lib/ssh/ssh.ts b/app/src/lib/ssh/ssh.ts index d9589d1866e..856cc0b11d5 100644 --- a/app/src/lib/ssh/ssh.ts +++ b/app/src/lib/ssh/ssh.ts @@ -1,9 +1,8 @@ import memoizeOne from 'memoize-one' import { pathExists } from '../../ui/lib/path-exists' -import { enableSSHAskPass, enableWindowsOpenSSH } from '../feature-flag' import { getBoolean } from '../local-storage' import { - getDesktopTrampolinePath, + getDesktopAskpassTrampolinePath, getSSHWrapperPath, } from '../trampoline/trampoline-environment' @@ -13,7 +12,7 @@ export const UseWindowsOpenSSHKey: string = 'useWindowsOpenSSH' export const isWindowsOpenSSHAvailable = memoizeOne( async (): Promise => { - if (!__WIN32__ || !enableWindowsOpenSSH()) { + if (!__WIN32__) { return false } @@ -43,13 +42,11 @@ function isWindowsOpenSSHUseEnabled() { * context (OS and user settings). */ export async function getSSHEnvironment() { - const baseEnv = enableSSHAskPass() - ? { - SSH_ASKPASS: getDesktopTrampolinePath(), - // DISPLAY needs to be set to _something_ so ssh actually uses SSH_ASKPASS - DISPLAY: '.', - } - : {} + const baseEnv = { + SSH_ASKPASS: getDesktopAskpassTrampolinePath(), + // DISPLAY needs to be set to _something_ so ssh actually uses SSH_ASKPASS + DISPLAY: '.', + } const canUseWindowsSSH = await isWindowsOpenSSHAvailable() if (canUseWindowsSSH && isWindowsOpenSSHUseEnabled()) { @@ -60,7 +57,7 @@ export async function getSSHEnvironment() { } } - if (__DARWIN__ && __DEV__ && enableSSHAskPass()) { + if (__DARWIN__ && __DEV__) { // Replace git ssh command with our wrapper in dev builds, since they are // launched from a command line. return { diff --git a/app/src/lib/stats/stats-database.ts b/app/src/lib/stats/stats-database.ts index 598147c56da..66bff8a4610 100644 --- a/app/src/lib/stats/stats-database.ts +++ b/app/src/lib/stats/stats-database.ts @@ -122,7 +122,7 @@ export interface IDailyMeasures { */ readonly enterpriseCommits: number - /** The number of times the user made a commit to a repo hosted on Github.com */ + /** The number of times the user made a commit to a repo hosted on GitHub.com */ readonly dotcomCommits: number /** The number of times the user made a commit to a protected GitHub or GitHub Enterprise repository */ @@ -146,9 +146,16 @@ export interface IDailyMeasures { /** The number of times the user committed a conflicted merge outside the merge conflicts dialog */ readonly unguidedConflictedMergeCompletionCount: number - /** The number of times the user is taken to the create pull request page on dotcom */ + /** The number of times the user is taken to the create pull request page on dotcom including. + * + * NB - This metric tracks all times including when + * `createPullRequestFromPreviewCount` this is tracked. + * */ readonly createPullRequestCount: number + /** The number of times the user is taken to the create pull request page on dotcom from the preview dialog */ + readonly createPullRequestFromPreviewCount: number + /** The number of times the rebase conflicts dialog is dismissed */ readonly rebaseConflictsDialogDismissalCount: number @@ -164,6 +171,9 @@ export interface IDailyMeasures { /** The number of times a successful rebase without conflicts is detected */ readonly rebaseSuccessWithoutConflictsCount: number + /** The number of times a rebase finishes without effect because the branch was already up-to-date */ + readonly rebaseWithBranchAlreadyUpToDateCount: number + /** The number of times a user performed a pull with `pull.rebase` in config set to `true` */ readonly pullWithRebaseCount: number @@ -467,6 +477,18 @@ export interface IDailyMeasures { /** The number of "checks failed" notifications the user received */ readonly checksFailedNotificationCount: number + /** + * The number of "checks failed" notifications the user received for a recent + * repository other than the selected one. + */ + readonly checksFailedNotificationFromRecentRepoCount: number + + /** + * The number of "checks failed" notifications the user received for a + * non-recent repository other than the selected one. + */ + readonly checksFailedNotificationFromNonRecentRepoCount: number + /** The number of "checks failed" notifications the user clicked */ readonly checksFailedNotificationClicked: number @@ -485,6 +507,18 @@ export interface IDailyMeasures { */ readonly checksFailedDialogRerunChecksCount: number + /** + * The number of PR review notifications the user received for a recent + * repository other than the selected one. + */ + readonly pullRequestReviewNotificationFromRecentRepoCount: number + + /** + * The number of PR review notifications the user received for a non-recent + * repository other than the selected one. + */ + readonly pullRequestReviewNotificationFromNonRecentRepoCount: number + /** The number of "approved PR" notifications the user received */ readonly pullRequestReviewApprovedNotificationCount: number @@ -520,6 +554,53 @@ export interface IDailyMeasures { * from the "changes requested" dialog. */ readonly pullRequestReviewChangesRequestedDialogSwitchToPullRequestCount: number + + /** The number of "commented PR" notifications the user received */ + readonly pullRequestCommentNotificationCount: number + + /** The number of "commented PR" notifications the user clicked */ + readonly pullRequestCommentNotificationClicked: number + + /** + * The number of PR comment notifications the user received for a non-recent + * repository other than the selected one. + */ + readonly pullRequestCommentNotificationFromNonRecentRepoCount: number + /** + * The number of PR comment notifications the user received for a recent + * repository other than the selected one. + */ + readonly pullRequestCommentNotificationFromRecentRepoCount: number + + /** + * The number of times the user decided to switch to the affected pull request + * from the PR comment dialog. + */ + readonly pullRequestCommentDialogSwitchToPullRequestCount: number + + /** The number of times the user did a multi commit diff where there were unreachable commits */ + readonly multiCommitDiffWithUnreachableCommitWarningCount: number + + /** The number of times the user does a multi commit diff from the history view */ + readonly multiCommitDiffFromHistoryCount: number + + /** The number of times the user does a multi commit diff from the compare */ + readonly multiCommitDiffFromCompareCount: number + + /** The number of times the user opens the unreachable commits dialog */ + readonly multiCommitDiffUnreachableCommitsDialogOpenedCount: number + + /** The number of times the user opens a submodule diff from the changes list */ + readonly submoduleDiffViewedFromChangesListCount: number + + /** The number of times the user opens a submodule diff from the History view */ + readonly submoduleDiffViewedFromHistoryCount: number + + /** The number of times the user opens a submodule repository from its diff */ + readonly openSubmoduleFromDiffCount: number + + /** The number of times a user has opened the preview pull request dialog */ + readonly previewedPullRequestCount: number } export class StatsDatabase extends Dexie { diff --git a/app/src/lib/stats/stats-store.ts b/app/src/lib/stats/stats-store.ts index e39a36aac04..6bb2cb08189 100644 --- a/app/src/lib/stats/stats-store.ts +++ b/app/src/lib/stats/stats-store.ts @@ -9,7 +9,14 @@ import { merge } from '../../lib/merge' import { getPersistedThemeName } from '../../ui/lib/application-theme' import { IUiActivityMonitor } from '../../ui/lib/ui-activity-monitor' import { Disposable } from 'event-kit' -import { SignInMethod } from '../stores' +import { + showDiffCheckMarksDefault, + showDiffCheckMarksKey, + underlineLinksDefault, + underlineLinksKey, + useCustomEditorKey, + useCustomShellKey, +} from '../stores' import { assertNever } from '../fatal-error' import { getNumber, @@ -28,6 +35,8 @@ import { getNotificationsEnabled } from '../stores/notifications-store' import { isInApplicationFolder } from '../../ui/main-process-proxy' import { getRendererGUID } from '../get-renderer-guid' import { ValidNotificationPullRequestReviewState } from '../valid-notification-pull-request-review' +import { useExternalCredentialHelperKey } from '../trampoline/use-external-credential-helper' +import { getUserAgent } from '../http' type PullRequestReviewStatFieldInfix = | 'Approved' @@ -64,7 +73,6 @@ const FirstCommitCreatedAtKey = 'first-commit-created-at' const FirstPushToGitHubAtKey = 'first-push-to-github-at' const FirstNonDefaultBranchCheckoutAtKey = 'first-non-default-branch-checkout-at' -const WelcomeWizardSignInMethodKey = 'welcome-wizard-sign-in-method' const terminalEmulatorKey = 'shell' const textEditorKey: string = 'externalEditor' @@ -112,11 +120,13 @@ const DefaultDailyMeasures: IDailyMeasures = { guidedConflictedMergeCompletionCount: 0, unguidedConflictedMergeCompletionCount: 0, createPullRequestCount: 0, + createPullRequestFromPreviewCount: 0, rebaseConflictsDialogDismissalCount: 0, rebaseConflictsDialogReopenedCount: 0, rebaseAbortedAfterConflictsCount: 0, rebaseSuccessAfterConflictsCount: 0, rebaseSuccessWithoutConflictsCount: 0, + rebaseWithBranchAlreadyUpToDateCount: 0, pullWithRebaseCount: 0, pullWithDefaultSettingCount: 0, stashEntriesCreatedOutsideDesktop: 0, @@ -195,10 +205,14 @@ const DefaultDailyMeasures: IDailyMeasures = { viewsCheckJobStepOnline: 0, rerunsChecks: 0, checksFailedNotificationCount: 0, + checksFailedNotificationFromRecentRepoCount: 0, + checksFailedNotificationFromNonRecentRepoCount: 0, checksFailedNotificationClicked: 0, checksFailedDialogOpenCount: 0, checksFailedDialogSwitchToPullRequestCount: 0, checksFailedDialogRerunChecksCount: 0, + pullRequestReviewNotificationFromRecentRepoCount: 0, + pullRequestReviewNotificationFromNonRecentRepoCount: 0, pullRequestReviewApprovedNotificationCount: 0, pullRequestReviewApprovedNotificationClicked: 0, pullRequestReviewApprovedDialogSwitchToPullRequestCount: 0, @@ -208,109 +222,102 @@ const DefaultDailyMeasures: IDailyMeasures = { pullRequestReviewChangesRequestedNotificationCount: 0, pullRequestReviewChangesRequestedNotificationClicked: 0, pullRequestReviewChangesRequestedDialogSwitchToPullRequestCount: 0, + pullRequestCommentNotificationCount: 0, + pullRequestCommentNotificationClicked: 0, + pullRequestCommentNotificationFromRecentRepoCount: 0, + pullRequestCommentNotificationFromNonRecentRepoCount: 0, + pullRequestCommentDialogSwitchToPullRequestCount: 0, + multiCommitDiffWithUnreachableCommitWarningCount: 0, + multiCommitDiffFromHistoryCount: 0, + multiCommitDiffFromCompareCount: 0, + multiCommitDiffUnreachableCommitsDialogOpenedCount: 0, + submoduleDiffViewedFromChangesListCount: 0, + submoduleDiffViewedFromHistoryCount: 0, + openSubmoduleFromDiffCount: 0, + previewedPullRequestCount: 0, +} + +// A subtype of IDailyMeasures filtered to contain only its numeric properties +type NumericMeasures = { + [P in keyof IDailyMeasures as IDailyMeasures[P] extends number + ? P + : never]: IDailyMeasures[P] } interface IOnboardingStats { /** - * Time (in seconds) from when the user first launched - * the application and entered the welcome wizard until - * the user added their first existing repository. + * Time (in seconds) from when the user first launched the application and + * entered the welcome wizard until the user added their first existing + * repository. * - * A negative value means that this action hasn't yet - * taken place while undefined means that the current - * user installed desktop prior to this metric being - * added and we will thus never be able to provide a - * value. + * A negative value means that this action hasn't yet taken place while + * undefined means that the current user installed desktop prior to this + * metric being added and we will thus never be able to provide a value. */ readonly timeToFirstAddedRepository?: number /** - * Time (in seconds) from when the user first launched - * the application and entered the welcome wizard until - * the user cloned their first repository. + * Time (in seconds) from when the user first launched the application and + * entered the welcome wizard until the user cloned their first repository. * - * A negative value means that this action hasn't yet - * taken place while undefined means that the current - * user installed desktop prior to this metric being - * added and we will thus never be able to provide a - * value. + * A negative value means that this action hasn't yet taken place while + * undefined means that the current user installed desktop prior to this + * metric being added and we will thus never be able to provide a value. */ readonly timeToFirstClonedRepository?: number /** - * Time (in seconds) from when the user first launched - * the application and entered the welcome wizard until - * the user created their first new repository. + * Time (in seconds) from when the user first launched the application and + * entered the welcome wizard until the user created their first new + * repository. * - * A negative value means that this action hasn't yet - * taken place while undefined means that the current - * user installed desktop prior to this metric being - * added and we will thus never be able to provide a - * value. + * A negative value means that this action hasn't yet taken place while + * undefined means that the current user installed desktop prior to this + * metric being added and we will thus never be able to provide a value. */ readonly timeToFirstCreatedRepository?: number /** - * Time (in seconds) from when the user first launched - * the application and entered the welcome wizard until - * the user crafted their first commit. + * Time (in seconds) from when the user first launched the application and + * entered the welcome wizard until the user crafted their first commit. * - * A negative value means that this action hasn't yet - * taken place while undefined means that the current - * user installed desktop prior to this metric being - * added and we will thus never be able to provide a - * value. + * A negative value means that this action hasn't yet taken place while + * undefined means that the current user installed desktop prior to this + * metric being added and we will thus never be able to provide a value. */ readonly timeToFirstCommit?: number /** - * Time (in seconds) from when the user first launched - * the application and entered the welcome wizard until - * the user performed their first push of a repository - * to GitHub.com or GitHub Enterprise. This metric - * does not track pushes to non-GitHub remotes. + * Time (in seconds) from when the user first launched the application and + * entered the welcome wizard until the user performed their first push of a + * repository to GitHub.com or GitHub Enterprise. This metric does not track + * pushes to non-GitHub remotes. */ readonly timeToFirstGitHubPush?: number /** - * Time (in seconds) from when the user first launched - * the application and entered the welcome wizard until - * the user first checked out a branch in any repository - * which is not the default branch of that repository. + * Time (in seconds) from when the user first launched the application and + * entered the welcome wizard until the user first checked out a branch in any + * repository which is not the default branch of that repository. * - * Note that this metric will be set regardless of whether - * that repository was a GitHub.com/GHE repository, local - * repository or has a non-GitHub remote. + * Note that this metric will be set regardless of whether that repository was + * a GitHub.com/GHE repository, local repository or has a non-GitHub remote. * - * A negative value means that this action hasn't yet - * taken place while undefined means that the current - * user installed desktop prior to this metric being - * added and we will thus never be able to provide a - * value. + * A negative value means that this action hasn't yet taken place while + * undefined means that the current user installed desktop prior to this + * metric being added and we will thus never be able to provide a value. */ readonly timeToFirstNonDefaultBranchCheckout?: number /** - * Time (in seconds) from when the user first launched - * the application and entered the welcome wizard until - * the user completed the wizard. + * Time (in seconds) from when the user first launched the application and + * entered the welcome wizard until the user completed the wizard. * - * A negative value means that this action hasn't yet - * taken place while undefined means that the current - * user installed desktop prior to this metric being - * added and we will thus never be able to provide a - * value. + * A negative value means that this action hasn't yet taken place while + * undefined means that the current user installed desktop prior to this + * metric being added and we will thus never be able to provide a value. */ readonly timeToWelcomeWizardTerminated?: number - - /** - * The method that was used when authenticating a - * user in the welcome flow. If multiple successful - * authentications happened during the welcome flow - * due to the user stepping back and signing in to - * another account this will reflect the last one. - */ - readonly welcomeWizardSignInMethod?: 'basic' | 'web' } interface ICalculatedStats { @@ -342,8 +349,8 @@ interface ICalculatedStats { readonly enterpriseAccount: boolean /** - * The name of the currently selected theme/application - * appearance as set at time of stats submission. + * The name of the currently selected theme/application appearance as set at + * time of stats submission. */ readonly theme: string @@ -356,12 +363,12 @@ interface ICalculatedStats { readonly eventType: 'usage' /** - * _[Forks]_ - * How many repos did the user commit in without having `write` access? + * _[Forks]_ How many repos did the user commit in without having `write` + * access? * - * This is a hack in that its really a "computed daily measure" and the - * moment we have another one of those we should consider refactoring - * them into their own interface + * This is a hack in that its really a "computed daily measure" and the moment + * we have another one of those we should consider refactoring them into their + * own interface */ readonly repositoriesCommittedInWithoutWriteAccess: number @@ -379,6 +386,18 @@ interface ICalculatedStats { /** Whether or not the user has enabled high-signal notifications */ readonly notificationsEnabled: boolean + + /** Whether or not the user has their accessibility setting set for viewing link underlines */ + readonly linkUnderlinesVisible: boolean + + /** Whether or not the user has their accessibility setting set for viewing diff check marks */ + readonly diffCheckMarksVisible: boolean + + /** + * Whether or not the user has enabled the external credential helper or null + * if the user has not yet made an active decision + **/ + readonly useExternalCredentialHelper?: boolean | null } type DailyStats = ICalculatedStats & @@ -394,25 +413,37 @@ type DailyStats = ICalculatedStats & * */ export interface IStatsStore { - recordMergeAbortedAfterConflicts: () => void - recordMergeSuccessAfterConflicts: () => void - recordRebaseAbortedAfterConflicts: () => void - recordRebaseSuccessAfterConflicts: () => void + increment: ( + metric: + | 'mergeAbortedAfterConflictsCount' + | 'rebaseAbortedAfterConflictsCount' + | 'mergeSuccessAfterConflictsCount' + | 'rebaseSuccessAfterConflictsCount' + ) => void } +const defaultPostImplementation = (body: Record) => + fetch(StatsEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'user-agent': getUserAgent(), + }, + body: JSON.stringify(body), + }) + /** The store for the app's stats. */ export class StatsStore implements IStatsStore { - private readonly db: StatsDatabase - private readonly uiActivityMonitor: IUiActivityMonitor private uiActivityMonitorSubscription: Disposable | null = null /** Has the user opted out of stats reporting? */ private optOut: boolean - public constructor(db: StatsDatabase, uiActivityMonitor: IUiActivityMonitor) { - this.db = db - this.uiActivityMonitor = uiActivityMonitor - + public constructor( + private readonly db: StatsDatabase, + private readonly uiActivityMonitor: IUiActivityMonitor, + private readonly post = defaultPostImplementation + ) { const storedValue = getHasOptedOutOfStats() this.optOut = storedValue || false @@ -427,7 +458,7 @@ export class StatsStore implements IStatsStore { window.addEventListener('unhandledrejection', async () => { try { - this.recordUnhandledRejection() + this.increment('unhandledRejectionCount') } catch (err) { log.error(`Failed recording unhandled rejection`, err) } @@ -491,18 +522,16 @@ export class StatsStore implements IStatsStore { } /** - * Clear the stored daily stats. Not meant to be called - * directly. Marked as public in order to enable testing - * of a specific scenario, see stats-store-tests for more - * detail. + * Clear the stored daily stats. Not meant to be called directly. Marked as + * public in order to enable testing of a specific scenario, see + * stats-store-tests for more detail. */ public async clearDailyStats() { await this.db.launches.clear() await this.db.dailyMeasures.clear() - // This is a one-off, and the moment we have another - // computed daily measure we should consider refactoring - // them into their own interface + // This is a one-off, and the moment we have another computed daily measure + // we should consider refactoring them into their own interface localStorage.removeItem(RepositoriesCommittedInWithoutWriteAccessKey) this.enableUiActivityMonitoring() @@ -537,13 +566,29 @@ export class StatsStore implements IStatsStore { const userType = this.determineUserType(accounts) const repositoryCounts = this.categorizedRepositoryCounts(repositories) const onboardingStats = this.getOnboardingStats() - const selectedTerminalEmulator = - localStorage.getItem(terminalEmulatorKey) || 'none' - const selectedTextEditor = localStorage.getItem(textEditorKey) || 'none' + const useCustomShell = getBoolean(useCustomShellKey, false) + const selectedTerminalEmulator = useCustomShell + ? 'custom' + : localStorage.getItem(terminalEmulatorKey) || 'none' + const useCustomEditor = getBoolean(useCustomEditorKey, false) + const selectedTextEditor = useCustomEditor + ? 'custom' + : localStorage.getItem(textEditorKey) || 'none' const repositoriesCommittedInWithoutWriteAccess = getNumberArray( RepositoriesCommittedInWithoutWriteAccessKey ).length const diffMode = getShowSideBySideDiff() ? 'split' : 'unified' + const linkUnderlinesVisible = getBoolean( + underlineLinksKey, + underlineLinksDefault + ) + const diffCheckMarksVisible = getBoolean( + showDiffCheckMarksKey, + showDiffCheckMarksDefault + ) + + const useExternalCredentialHelper = + getBoolean(useExternalCredentialHelperKey) ?? null // isInApplicationsFolder is undefined when not running on Darwin const launchedFromApplicationsFolder = __DARWIN__ @@ -569,6 +614,9 @@ export class StatsStore implements IStatsStore { repositoriesCommittedInWithoutWriteAccess, diffMode, launchedFromApplicationsFolder, + linkUnderlinesVisible, + diffCheckMarksVisible, + useExternalCredentialHelper, } } @@ -578,8 +626,8 @@ export class StatsStore implements IStatsStore { ) // If we don't have a start time for the wizard none of our other metrics - // makes sense. This will happen for users who installed the app before - // we started tracking onboarding stats. + // makes sense. This will happen for users who installed the app before we + // started tracking onboarding stats. if (wizardInitiatedAt === null) { return {} } @@ -594,8 +642,6 @@ export class StatsStore implements IStatsStore { FirstNonDefaultBranchCheckoutAtKey ) - const welcomeWizardSignInMethod = getWelcomeWizardSignInMethod() - return { timeToWelcomeWizardTerminated, timeToFirstAddedRepository, @@ -604,7 +650,6 @@ export class StatsStore implements IStatsStore { timeToFirstCommit, timeToFirstGitHubPush, timeToFirstNonDefaultBranchCheckout, - welcomeWizardSignInMethod, } } @@ -696,207 +741,49 @@ export class StatsStore implements IStatsStore { /** Record that a commit was accomplished. */ public async recordCommit(): Promise { - await this.updateDailyMeasures(m => ({ - commits: m.commits + 1, - })) - + await this.increment('commits') createLocalStorageTimestamp(FirstCommitCreatedAtKey) } - /** Record that a partial commit was accomplished. */ - public recordPartialCommit(): Promise { - return this.updateDailyMeasures(m => ({ - partialCommits: m.partialCommits + 1, - })) - } - - /** Record that a commit was created with one or more co-authors. */ - public recordCoAuthoredCommit(): Promise { - return this.updateDailyMeasures(m => ({ - coAuthoredCommits: m.coAuthoredCommits + 1, - })) - } - /** * Record that a commit was undone. * * @param cleanWorkingDirectory Whether the working directory is clean. */ - public recordCommitUndone(cleanWorkingDirectory: boolean): Promise { - if (cleanWorkingDirectory) { - return this.updateDailyMeasures(m => ({ - commitsUndoneWithoutChanges: m.commitsUndoneWithoutChanges + 1, - })) - } - return this.updateDailyMeasures(m => ({ - commitsUndoneWithChanges: m.commitsUndoneWithChanges + 1, - })) - } - - /** Record that the user started amending a commit */ - public recordAmendCommitStarted(): Promise { - return this.updateDailyMeasures(m => ({ - amendCommitStartedCount: m.amendCommitStartedCount + 1, - })) - } + public recordCommitUndone = (cleanWorkingDirectory: boolean) => + this.increment( + cleanWorkingDirectory + ? 'commitsUndoneWithoutChanges' + : 'commitsUndoneWithChanges' + ) /** * Record that the user amended a commit. * * @param withFileChanges Whether the amendment included file changes or not. */ - public recordAmendCommitSuccessful(withFileChanges: boolean): Promise { - if (withFileChanges) { - return this.updateDailyMeasures(m => ({ - amendCommitSuccessfulWithFileChangesCount: - m.amendCommitSuccessfulWithFileChangesCount + 1, - })) - } - - return this.updateDailyMeasures(m => ({ - amendCommitSuccessfulWithoutFileChangesCount: - m.amendCommitSuccessfulWithoutFileChangesCount + 1, - })) - } - - /** Record that the user reset to a previous commit */ - public recordResetToCommitCount(): Promise { - return this.updateDailyMeasures(m => ({ - resetToCommitCount: m.resetToCommitCount + 1, - })) - } - - /** Record that the user opened a shell. */ - public recordOpenShell(): Promise { - return this.updateDailyMeasures(m => ({ - openShellCount: m.openShellCount + 1, - })) - } - - /** Record that a branch comparison has been made */ - public recordBranchComparison(): Promise { - return this.updateDailyMeasures(m => ({ - branchComparisons: m.branchComparisons + 1, - })) - } - - /** Record that a branch comparison has been made to the default branch */ - public recordDefaultBranchComparison(): Promise { - return this.updateDailyMeasures(m => ({ - defaultBranchComparisons: m.defaultBranchComparisons + 1, - })) - } - - /** Record that a merge has been initiated from the `compare` sidebar */ - public recordCompareInitiatedMerge(): Promise { - return this.updateDailyMeasures(m => ({ - mergesInitiatedFromComparison: m.mergesInitiatedFromComparison + 1, - })) - } - - /** Record that a merge has been initiated from the `Branch -> Update From Default Branch` menu item */ - public recordMenuInitiatedUpdate(): Promise { - return this.updateDailyMeasures(m => ({ - updateFromDefaultBranchMenuCount: m.updateFromDefaultBranchMenuCount + 1, - })) - } - - /** Record that conflicts were detected by a merge initiated by Desktop */ - public recordMergeConflictFromPull(): Promise { - return this.updateDailyMeasures(m => ({ - mergeConflictFromPullCount: m.mergeConflictFromPullCount + 1, - })) - } - - /** Record that conflicts were detected by a merge initiated by Desktop */ - public recordMergeConflictFromExplicitMerge(): Promise { - return this.updateDailyMeasures(m => ({ - mergeConflictFromExplicitMergeCount: - m.mergeConflictFromExplicitMergeCount + 1, - })) - } - - /** Record that a merge has been initiated from the `Branch -> Merge Into Current Branch` menu item */ - public recordMenuInitiatedMerge(isSquash: boolean = false): Promise { - if (isSquash) { - return this.updateDailyMeasures(m => ({ - squashMergeIntoCurrentBranchMenuCount: - m.squashMergeIntoCurrentBranchMenuCount + 1, - })) - } - - return this.updateDailyMeasures(m => ({ - mergeIntoCurrentBranchMenuCount: m.mergeIntoCurrentBranchMenuCount + 1, - })) - } - - public recordMenuInitiatedRebase(): Promise { - return this.updateDailyMeasures(m => ({ - rebaseCurrentBranchMenuCount: m.rebaseCurrentBranchMenuCount + 1, - })) - } - - /** Record that the user checked out a PR branch */ - public recordPRBranchCheckout(): Promise { - return this.updateDailyMeasures(m => ({ - prBranchCheckouts: m.prBranchCheckouts + 1, - })) - } - - public recordRepoClicked(repoHasIndicator: boolean): Promise { - if (repoHasIndicator) { - return this.updateDailyMeasures(m => ({ - repoWithIndicatorClicked: m.repoWithIndicatorClicked + 1, - })) - } - return this.updateDailyMeasures(m => ({ - repoWithoutIndicatorClicked: m.repoWithoutIndicatorClicked + 1, - })) - } - - /** - * Records that the user made a commit using an email address that - * was not associated with the user's account on GitHub.com or GitHub - * Enterprise, meaning that the commit will not be attributed to the - * user's account. - */ - public recordUnattributedCommit(): Promise { - return this.updateDailyMeasures(m => ({ - unattributedCommits: m.unattributedCommits + 1, - })) - } - - /** - * Records that the user made a commit to a repository hosted on - * a GitHub Enterprise instance - */ - public recordCommitToEnterprise(): Promise { - return this.updateDailyMeasures(m => ({ - enterpriseCommits: m.enterpriseCommits + 1, - })) - } - - /** Records that the user made a commit to a repository hosted on GitHub.com */ - public recordCommitToDotcom(): Promise { - return this.updateDailyMeasures(m => ({ - dotcomCommits: m.dotcomCommits + 1, - })) - } + public recordAmendCommitSuccessful = (withFileChanges: boolean) => + this.increment( + withFileChanges + ? 'amendCommitSuccessfulWithFileChangesCount' + : 'amendCommitSuccessfulWithoutFileChangesCount' + ) - /** Record the user made a commit to a protected GitHub or GitHub Enterprise repository */ - public recordCommitToProtectedBranch(): Promise { - return this.updateDailyMeasures(m => ({ - commitsToProtectedBranch: m.commitsToProtectedBranch + 1, - })) - } + /** Record that a merge has been initiated from the `Branch -> Merge Into + * Current Branch` menu item */ + public recordMenuInitiatedMerge = (isSquash: boolean = false) => + this.increment( + isSquash + ? 'squashMergeIntoCurrentBranchMenuCount' + : 'mergeIntoCurrentBranchMenuCount' + ) - /** Record the user made a commit to repository which has branch protections enabled */ - public recordCommitToRepositoryWithBranchProtections(): Promise { - return this.updateDailyMeasures(m => ({ - commitsToRepositoryWithBranchProtections: - m.commitsToRepositoryWithBranchProtections + 1, - })) - } + public recordRepoClicked = (repoHasIndicator: boolean) => + this.increment( + repoHasIndicator + ? 'repoWithIndicatorClicked' + : 'repoWithoutIndicatorClicked' + ) /** Set whether the user has opted out of stats reporting. */ public async setOptOut( @@ -936,16 +823,11 @@ export class StatsStore implements IStatsStore { /** Record that the user pushed to GitHub.com */ private async recordPushToGitHub(options?: PushOptions): Promise { - if (options && options.forceWithLease) { - await this.updateDailyMeasures(m => ({ - dotcomForcePushCount: m.dotcomForcePushCount + 1, - })) - } - - await this.updateDailyMeasures(m => ({ - dotcomPushCount: m.dotcomPushCount + 1, - })) - + await this.increment( + options && options.forceWithLease + ? 'dotcomForcePushCount' + : 'dotcomPushCount' + ) createLocalStorageTimestamp(FirstPushToGitHubAtKey) } @@ -953,696 +835,172 @@ export class StatsStore implements IStatsStore { private async recordPushToGitHubEnterprise( options?: PushOptions ): Promise { - if (options && options.forceWithLease) { - await this.updateDailyMeasures(m => ({ - enterpriseForcePushCount: m.enterpriseForcePushCount + 1, - })) - } - - await this.updateDailyMeasures(m => ({ - enterprisePushCount: m.enterprisePushCount + 1, - })) + await this.increment( + options && options.forceWithLease + ? 'enterpriseForcePushCount' + : 'enterprisePushCount' + ) - // Note, this is not a typo. We track both GitHub.com and - // GitHub Enterprise under the same key + // Note, this is not a typo. We track both GitHub.com and GitHub Enterprise + // under the same key createLocalStorageTimestamp(FirstPushToGitHubAtKey) } /** Record that the user pushed to a generic remote */ - private async recordPushToGenericRemote( - options?: PushOptions - ): Promise { - if (options && options.forceWithLease) { - await this.updateDailyMeasures(m => ({ - externalForcePushCount: m.externalForcePushCount + 1, - })) - } + private recordPushToGenericRemote = (options?: PushOptions) => + this.increment( + options && options.forceWithLease + ? 'externalForcePushCount' + : 'externalPushCount' + ) - await this.updateDailyMeasures(m => ({ - externalPushCount: m.externalPushCount + 1, - })) + public recordWelcomeWizardInitiated() { + setNumber(WelcomeWizardInitiatedAtKey, Date.now()) + localStorage.removeItem(WelcomeWizardCompletedAtKey) } - /** Record that the user saw a 'merge conflicts' warning but continued with the merge */ - public recordUserProceededWhileLoading(): Promise { - return this.updateDailyMeasures(m => ({ - mergedWithLoadingHintCount: m.mergedWithLoadingHintCount + 1, - })) + public recordWelcomeWizardTerminated() { + setNumber(WelcomeWizardCompletedAtKey, Date.now()) } - /** Record that the user saw a 'merge conflicts' warning but continued with the merge */ - public recordMergeHintSuccessAndUserProceeded(): Promise { - return this.updateDailyMeasures(m => ({ - mergedWithCleanMergeHintCount: m.mergedWithCleanMergeHintCount + 1, - })) + public recordAddExistingRepository() { + createLocalStorageTimestamp(FirstRepositoryAddedAtKey) } - /** Record that the user saw a 'merge conflicts' warning but continued with the merge */ - public recordUserProceededAfterConflictWarning(): Promise { - return this.updateDailyMeasures(m => ({ - mergedWithConflictWarningHintCount: - m.mergedWithConflictWarningHintCount + 1, - })) + public recordCloneRepository() { + createLocalStorageTimestamp(FirstRepositoryClonedAtKey) } - /** - * Increments the `mergeConflictsDialogDismissalCount` metric - */ - public recordMergeConflictsDialogDismissal(): Promise { - return this.updateDailyMeasures(m => ({ - mergeConflictsDialogDismissalCount: - m.mergeConflictsDialogDismissalCount + 1, - })) + public recordCreateRepository() { + createLocalStorageTimestamp(FirstRepositoryCreatedAtKey) } - /** - * Increments the `anyConflictsLeftOnMergeConflictsDialogDismissalCount` metric - */ - public recordAnyConflictsLeftOnMergeConflictsDialogDismissal(): Promise { - return this.updateDailyMeasures(m => ({ - anyConflictsLeftOnMergeConflictsDialogDismissalCount: - m.anyConflictsLeftOnMergeConflictsDialogDismissalCount + 1, - })) + public recordNonDefaultBranchCheckout() { + createLocalStorageTimestamp(FirstNonDefaultBranchCheckoutAtKey) } - /** - * Increments the `mergeConflictsDialogReopenedCount` metric - */ - public recordMergeConflictsDialogReopened(): Promise { - return this.updateDailyMeasures(m => ({ - mergeConflictsDialogReopenedCount: - m.mergeConflictsDialogReopenedCount + 1, - })) + /** Record the number of stash entries created outside of Desktop for the day + * */ + public addStashEntriesCreatedOutsideDesktop = (stashCount: number) => + this.increment('stashEntriesCreatedOutsideDesktop', stashCount) + + private onUiActivity = async () => { + this.disableUiActivityMonitoring() + + return this.updateDailyMeasures(m => ({ active: true })) } - /** - * Increments the `guidedConflictedMergeCompletionCount` metric + /* + * Onboarding tutorial metrics */ - public recordGuidedConflictedMergeCompletion(): Promise { - return this.updateDailyMeasures(m => ({ - guidedConflictedMergeCompletionCount: - m.guidedConflictedMergeCompletionCount + 1, - })) - } /** - * Increments the `unguidedConflictedMergeCompletionCount` metric + * Onboarding tutorial has been started, the user has clicked the button to + * start the onboarding tutorial. */ - public recordUnguidedConflictedMergeCompletion(): Promise { - return this.updateDailyMeasures(m => ({ - unguidedConflictedMergeCompletionCount: - m.unguidedConflictedMergeCompletionCount + 1, - })) + public recordTutorialStarted() { + return this.updateDailyMeasures(() => ({ tutorialStarted: true })) } - /** - * Increments the `createPullRequestCount` metric - */ - public recordCreatePullRequest(): Promise { - return this.updateDailyMeasures(m => ({ - createPullRequestCount: m.createPullRequestCount + 1, - })) + /** Onboarding tutorial has been successfully created */ + public recordTutorialRepoCreated() { + return this.updateDailyMeasures(() => ({ tutorialRepoCreated: true })) } - /** - * Increments the `rebaseConflictsDialogDismissalCount` metric - */ - public recordRebaseConflictsDialogDismissal(): Promise { - return this.updateDailyMeasures(m => ({ - rebaseConflictsDialogDismissalCount: - m.rebaseConflictsDialogDismissalCount + 1, + public recordTutorialEditorInstalled() { + return this.updateDailyMeasures(() => ({ tutorialEditorInstalled: true })) + } + + public recordTutorialBranchCreated() { + return this.updateDailyMeasures(() => ({ + tutorialEditorInstalled: true, + tutorialBranchCreated: true, })) } - /** - * Increments the `rebaseConflictsDialogReopenedCount` metric - */ - public recordRebaseConflictsDialogReopened(): Promise { - return this.updateDailyMeasures(m => ({ - rebaseConflictsDialogReopenedCount: - m.rebaseConflictsDialogReopenedCount + 1, + public recordTutorialFileEdited() { + return this.updateDailyMeasures(() => ({ + tutorialEditorInstalled: true, + tutorialBranchCreated: true, + tutorialFileEdited: true, })) } - /** - * Increments the `rebaseAbortedAfterConflictsCount` metric - */ - public recordRebaseAbortedAfterConflicts(): Promise { - return this.updateDailyMeasures(m => ({ - rebaseAbortedAfterConflictsCount: m.rebaseAbortedAfterConflictsCount + 1, + public recordTutorialCommitCreated() { + return this.updateDailyMeasures(() => ({ + tutorialEditorInstalled: true, + tutorialBranchCreated: true, + tutorialFileEdited: true, + tutorialCommitCreated: true, })) } - /** - * Increments the `pullWithRebaseCount` metric - */ - public recordPullWithRebaseEnabled() { - return this.updateDailyMeasures(m => ({ - pullWithRebaseCount: m.pullWithRebaseCount + 1, + + public recordTutorialBranchPushed() { + return this.updateDailyMeasures(() => ({ + tutorialEditorInstalled: true, + tutorialBranchCreated: true, + tutorialFileEdited: true, + tutorialCommitCreated: true, + tutorialBranchPushed: true, })) } - /** - * Increments the `rebaseSuccessWithoutConflictsCount` metric - */ - public recordRebaseSuccessWithoutConflicts(): Promise { - return this.updateDailyMeasures(m => ({ - rebaseSuccessWithoutConflictsCount: - m.rebaseSuccessWithoutConflictsCount + 1, + public recordTutorialPrCreated() { + return this.updateDailyMeasures(() => ({ + tutorialEditorInstalled: true, + tutorialBranchCreated: true, + tutorialFileEdited: true, + tutorialCommitCreated: true, + tutorialBranchPushed: true, + tutorialPrCreated: true, })) } - /** - * Increments the `rebaseSuccessAfterConflictsCount` metric - */ - public recordRebaseSuccessAfterConflicts(): Promise { + public recordTutorialCompleted() { + return this.updateDailyMeasures(() => ({ tutorialCompleted: true })) + } + + public recordHighestTutorialStepCompleted(step: number) { return this.updateDailyMeasures(m => ({ - rebaseSuccessAfterConflictsCount: m.rebaseSuccessAfterConflictsCount + 1, + highestTutorialStepCompleted: Math.max( + step, + m.highestTutorialStepCompleted + ), })) } /** - * Increments the `pullWithDefaultSettingCount` metric + * Record that the user made a commit in a repository they don't have `write` + * access to. Dedupes based on the database ID provided + * + * @param gitHubRepositoryDbId database ID for the GitHubRepository of the + * local repo this commit was made in */ - public recordPullWithDefaultSetting() { - return this.updateDailyMeasures(m => ({ - pullWithDefaultSettingCount: m.pullWithDefaultSettingCount + 1, - })) + public recordRepositoryCommitedInWithoutWriteAccess( + gitHubRepositoryDbId: number + ) { + const ids = getNumberArray(RepositoriesCommittedInWithoutWriteAccessKey) + if (!ids.includes(gitHubRepositoryDbId)) { + setNumberArray(RepositoriesCommittedInWithoutWriteAccessKey, [ + ...ids, + gitHubRepositoryDbId, + ]) + } } - public recordWelcomeWizardInitiated() { - setNumber(WelcomeWizardInitiatedAtKey, Date.now()) - localStorage.removeItem(WelcomeWizardCompletedAtKey) - } + public recordTagCreated = (numCreatedTags: number) => + this.increment('tagsCreated', numCreatedTags) - public recordWelcomeWizardTerminated() { - setNumber(WelcomeWizardCompletedAtKey, Date.now()) - } - - public recordAddExistingRepository() { - createLocalStorageTimestamp(FirstRepositoryAddedAtKey) - } - - public recordCloneRepository() { - createLocalStorageTimestamp(FirstRepositoryClonedAtKey) - } - - public recordCreateRepository() { - createLocalStorageTimestamp(FirstRepositoryCreatedAtKey) - } - - public recordNonDefaultBranchCheckout() { - createLocalStorageTimestamp(FirstNonDefaultBranchCheckoutAtKey) - } - - public recordWelcomeWizardSignInMethod(method: SignInMethod) { - localStorage.setItem(WelcomeWizardSignInMethodKey, method) - } - - /** Record when a conflicted merge was successfully completed by the user */ - public recordMergeSuccessAfterConflicts(): Promise { - return this.updateDailyMeasures(m => ({ - mergeSuccessAfterConflictsCount: m.mergeSuccessAfterConflictsCount + 1, - })) - } - - /** Record when a conflicted merge was aborted by the user */ - public recordMergeAbortedAfterConflicts(): Promise { - return this.updateDailyMeasures(m => ({ - mergeAbortedAfterConflictsCount: m.mergeAbortedAfterConflictsCount + 1, - })) - } - - /** Record when the user views a stash entry after checking out a branch */ - public recordStashViewedAfterCheckout(): Promise { - return this.updateDailyMeasures(m => ({ - stashViewedAfterCheckoutCount: m.stashViewedAfterCheckoutCount + 1, - })) - } - - /** Record when the user **doesn't** view a stash entry after checking out a branch */ - public recordStashNotViewedAfterCheckout(): Promise { - return this.updateDailyMeasures(m => ({ - stashNotViewedAfterCheckoutCount: m.stashNotViewedAfterCheckoutCount + 1, - })) - } - - /** Record when the user elects to take changes to new branch over stashing */ - public recordChangesTakenToNewBranch(): Promise { - return this.updateDailyMeasures(m => ({ - changesTakenToNewBranchCount: m.changesTakenToNewBranchCount + 1, - })) - } - - /** Record when the user elects to stash changes on the current branch */ - public recordStashCreatedOnCurrentBranch(): Promise { - return this.updateDailyMeasures(m => ({ - stashCreatedOnCurrentBranchCount: m.stashCreatedOnCurrentBranchCount + 1, - })) - } - - /** Record when the user discards a stash entry */ - public recordStashDiscard(): Promise { - return this.updateDailyMeasures(m => ({ - stashDiscardCount: m.stashDiscardCount + 1, - })) - } - - /** Record when the user views a stash entry */ - public recordStashView(): Promise { - return this.updateDailyMeasures(m => ({ - stashViewCount: m.stashViewCount + 1, - })) - } - - /** Record when the user restores a stash entry */ - public recordStashRestore(): Promise { - return this.updateDailyMeasures(m => ({ - stashRestoreCount: m.stashRestoreCount + 1, - })) - } - - /** Record when the user takes no action on the stash entry */ - public recordNoActionTakenOnStash(): Promise { - return this.updateDailyMeasures(m => ({ - noActionTakenOnStashCount: m.noActionTakenOnStashCount + 1, - })) - } - - /** Record the number of stash entries created outside of Desktop for the day */ - public addStashEntriesCreatedOutsideDesktop( - stashCount: number - ): Promise { - return this.updateDailyMeasures(m => ({ - stashEntriesCreatedOutsideDesktop: - m.stashEntriesCreatedOutsideDesktop + stashCount, - })) - } - - /** - * Record the number of times the user experiences the error - * "Some of your changes would be overwritten" when switching branches - */ - public recordErrorWhenSwitchingBranchesWithUncommmittedChanges(): Promise { - return this.updateDailyMeasures(m => ({ - errorWhenSwitchingBranchesWithUncommmittedChanges: - m.errorWhenSwitchingBranchesWithUncommmittedChanges + 1, - })) - } - - /** - * Increment the number of times the user has opened their external editor - * from the suggested next steps view - */ - public recordSuggestedStepOpenInExternalEditor(): Promise { - return this.updateDailyMeasures(m => ({ - suggestedStepOpenInExternalEditor: - m.suggestedStepOpenInExternalEditor + 1, - })) - } - - /** - * Increment the number of times the user has opened their repository in - * Finder/Explorer from the suggested next steps view - */ - public recordSuggestedStepOpenWorkingDirectory(): Promise { - return this.updateDailyMeasures(m => ({ - suggestedStepOpenWorkingDirectory: - m.suggestedStepOpenWorkingDirectory + 1, - })) - } - - /** - * Increment the number of times the user has opened their repository on - * GitHub from the suggested next steps view - */ - public recordSuggestedStepViewOnGitHub(): Promise { - return this.updateDailyMeasures(m => ({ - suggestedStepViewOnGitHub: m.suggestedStepViewOnGitHub + 1, - })) - } - - /** - * Increment the number of times the user has used the publish repository - * action from the suggested next steps view - */ - public recordSuggestedStepPublishRepository(): Promise { - return this.updateDailyMeasures(m => ({ - suggestedStepPublishRepository: m.suggestedStepPublishRepository + 1, - })) - } - - /** - * Increment the number of times the user has used the publish branch - * action branch from the suggested next steps view - */ - public recordSuggestedStepPublishBranch(): Promise { - return this.updateDailyMeasures(m => ({ - suggestedStepPublishBranch: m.suggestedStepPublishBranch + 1, - })) - } - - /** - * Increment the number of times the user has used the Create PR suggestion - * in the suggested next steps view. - */ - public recordSuggestedStepCreatePullRequest(): Promise { - return this.updateDailyMeasures(m => ({ - suggestedStepCreatePullRequest: m.suggestedStepCreatePullRequest + 1, - })) - } - - /** - * Increment the number of times the user has used the View Stash suggestion - * in the suggested next steps view. - */ - public recordSuggestedStepViewStash(): Promise { - return this.updateDailyMeasures(m => ({ - suggestedStepViewStash: m.suggestedStepViewStash + 1, - })) - } - - private onUiActivity = async () => { - this.disableUiActivityMonitoring() - - return this.updateDailyMeasures(m => ({ - active: true, - })) - } - - /* - * Onboarding tutorial metrics - */ - - /** - * Onboarding tutorial has been started, the user has - * clicked the button to start the onboarding tutorial. - */ - public recordTutorialStarted() { - return this.updateDailyMeasures(() => ({ - tutorialStarted: true, - })) - } - - /** - * Onboarding tutorial has been successfully created - */ - public recordTutorialRepoCreated() { - return this.updateDailyMeasures(() => ({ - tutorialRepoCreated: true, - })) - } - - public recordTutorialEditorInstalled() { - return this.updateDailyMeasures(() => ({ - tutorialEditorInstalled: true, - })) - } - - public recordTutorialBranchCreated() { - return this.updateDailyMeasures(() => ({ - tutorialEditorInstalled: true, - tutorialBranchCreated: true, - })) - } - - public recordTutorialFileEdited() { - return this.updateDailyMeasures(() => ({ - tutorialEditorInstalled: true, - tutorialBranchCreated: true, - tutorialFileEdited: true, - })) - } - - public recordTutorialCommitCreated() { - return this.updateDailyMeasures(() => ({ - tutorialEditorInstalled: true, - tutorialBranchCreated: true, - tutorialFileEdited: true, - tutorialCommitCreated: true, - })) - } - - public recordTutorialBranchPushed() { - return this.updateDailyMeasures(() => ({ - tutorialEditorInstalled: true, - tutorialBranchCreated: true, - tutorialFileEdited: true, - tutorialCommitCreated: true, - tutorialBranchPushed: true, - })) - } - - public recordTutorialPrCreated() { - return this.updateDailyMeasures(() => ({ - tutorialEditorInstalled: true, - tutorialBranchCreated: true, - tutorialFileEdited: true, - tutorialCommitCreated: true, - tutorialBranchPushed: true, - tutorialPrCreated: true, - })) - } - - public recordTutorialCompleted() { - return this.updateDailyMeasures(() => ({ - tutorialCompleted: true, - })) - } - - public recordHighestTutorialStepCompleted(step: number) { - return this.updateDailyMeasures(m => ({ - highestTutorialStepCompleted: Math.max( - step, - m.highestTutorialStepCompleted - ), - })) - } - - public recordCommitToRepositoryWithoutWriteAccess() { - return this.updateDailyMeasures(m => ({ - commitsToRepositoryWithoutWriteAccess: - m.commitsToRepositoryWithoutWriteAccess + 1, - })) - } - - /** - * Record that the user made a commit in a repository they don't - * have `write` access to. Dedupes based on the database ID provided - * - * @param gitHubRepositoryDbId database ID for the GitHubRepository of - * the local repo this commit was made in - */ - public recordRepositoryCommitedInWithoutWriteAccess( - gitHubRepositoryDbId: number - ) { - const ids = getNumberArray(RepositoriesCommittedInWithoutWriteAccessKey) - if (!ids.includes(gitHubRepositoryDbId)) { - setNumberArray(RepositoriesCommittedInWithoutWriteAccessKey, [ - ...ids, - gitHubRepositoryDbId, - ]) - } - } - - public recordForkCreated() { - return this.updateDailyMeasures(m => ({ - forksCreated: m.forksCreated + 1, - })) - } - - public recordIssueCreationWebpageOpened() { - return this.updateDailyMeasures(m => ({ - issueCreationWebpageOpenedCount: m.issueCreationWebpageOpenedCount + 1, - })) - } - - public recordTagCreatedInDesktop() { - return this.updateDailyMeasures(m => ({ - tagsCreatedInDesktop: m.tagsCreatedInDesktop + 1, - })) - } - - public recordTagCreated(numCreatedTags: number) { - return this.updateDailyMeasures(m => ({ - tagsCreated: m.tagsCreated + numCreatedTags, - })) - } - - public recordTagDeleted() { - return this.updateDailyMeasures(m => ({ - tagsDeleted: m.tagsDeleted + 1, - })) - } - - public recordDiffOptionsViewed() { - return this.updateDailyMeasures(m => ({ - diffOptionsViewedCount: m.diffOptionsViewedCount + 1, - })) - } - - public recordRepositoryViewChanged() { - return this.updateDailyMeasures(m => ({ - repositoryViewChangeCount: m.repositoryViewChangeCount + 1, - })) - } - - public recordDiffModeChanged() { - return this.updateDailyMeasures(m => ({ - diffModeChangeCount: m.diffModeChangeCount + 1, - })) - } - - public recordUnhandledRejection() { - return this.updateDailyMeasures(m => ({ - unhandledRejectionCount: m.unhandledRejectionCount + 1, - })) - } - - private recordCherryPickSuccessful(): Promise { - return this.updateDailyMeasures(m => ({ - cherryPickSuccessfulCount: m.cherryPickSuccessfulCount + 1, - })) - } - - public recordCherryPickViaDragAndDrop(): Promise { - return this.updateDailyMeasures(m => ({ - cherryPickViaDragAndDropCount: m.cherryPickViaDragAndDropCount + 1, - })) - } - - public recordCherryPickViaContextMenu(): Promise { - return this.updateDailyMeasures(m => ({ - cherryPickViaContextMenuCount: m.cherryPickViaContextMenuCount + 1, - })) - } - - public recordDragStartedAndCanceled(): Promise { - return this.updateDailyMeasures(m => ({ - dragStartedAndCanceledCount: m.dragStartedAndCanceledCount + 1, - })) - } - - public recordCherryPickConflictsEncountered(): Promise { - return this.updateDailyMeasures(m => ({ - cherryPickConflictsEncounteredCount: - m.cherryPickConflictsEncounteredCount + 1, - })) - } - - public recordCherryPickSuccessfulWithConflicts(): Promise { - return this.updateDailyMeasures(m => ({ - cherryPickSuccessfulWithConflictsCount: - m.cherryPickSuccessfulWithConflictsCount + 1, - })) - } - - public recordCherryPickMultipleCommits(): Promise { - return this.updateDailyMeasures(m => ({ - cherryPickMultipleCommitsCount: m.cherryPickMultipleCommitsCount + 1, - })) - } - - private recordCherryPickUndone(): Promise { - return this.updateDailyMeasures(m => ({ - cherryPickUndoneCount: m.cherryPickUndoneCount + 1, - })) - } - - public recordCherryPickBranchCreatedCount(): Promise { - return this.updateDailyMeasures(m => ({ - cherryPickBranchCreatedCount: m.cherryPickBranchCreatedCount + 1, - })) - } - - private recordReorderSuccessful(): Promise { - return this.updateDailyMeasures(m => ({ - reorderSuccessfulCount: m.reorderSuccessfulCount + 1, - })) - } - - public recordReorderStarted(): Promise { - return this.updateDailyMeasures(m => ({ - reorderStartedCount: m.reorderStartedCount + 1, - })) - } - - private recordReorderConflictsEncountered(): Promise { - return this.updateDailyMeasures(m => ({ - reorderConflictsEncounteredCount: m.reorderConflictsEncounteredCount + 1, - })) - } - - private recordReorderSuccessfulWithConflicts(): Promise { - return this.updateDailyMeasures(m => ({ - reorderSuccessfulWithConflictsCount: - m.reorderSuccessfulWithConflictsCount + 1, - })) - } - - public recordReorderMultipleCommits(): Promise { - return this.updateDailyMeasures(m => ({ - reorderMultipleCommitsCount: m.reorderMultipleCommitsCount + 1, - })) - } - - private recordReorderUndone(): Promise { - return this.updateDailyMeasures(m => ({ - reorderUndoneCount: m.reorderUndoneCount + 1, - })) - } - - private recordSquashConflictsEncountered(): Promise { - return this.updateDailyMeasures(m => ({ - squashConflictsEncounteredCount: m.squashConflictsEncounteredCount + 1, - })) - } - - public recordSquashMultipleCommitsInvoked(): Promise { - return this.updateDailyMeasures(m => ({ - squashMultipleCommitsInvokedCount: - m.squashMultipleCommitsInvokedCount + 1, - })) - } - - private recordSquashSuccessful(): Promise { - return this.updateDailyMeasures(m => ({ - squashSuccessfulCount: m.squashSuccessfulCount + 1, - })) - } - - private recordSquashSuccessfulWithConflicts(): Promise { - return this.updateDailyMeasures(m => ({ - squashSuccessfulWithConflictsCount: - m.squashSuccessfulWithConflictsCount + 1, - })) - } - - public recordSquashViaContextMenuInvoked(): Promise { - return this.updateDailyMeasures(m => ({ - squashViaContextMenuInvokedCount: m.squashViaContextMenuInvokedCount + 1, - })) - } - - public recordSquashViaDragAndDropInvokedCount(): Promise { - return this.updateDailyMeasures(m => ({ - squashViaDragAndDropInvokedCount: m.squashViaDragAndDropInvokedCount + 1, - })) - } - - private recordSquashUndone(): Promise { - return this.updateDailyMeasures(m => ({ - squashUndoneCount: m.squashUndoneCount + 1, - })) - } + private recordSquashUndone = () => this.increment('squashUndoneCount') public async recordOperationConflictsEncounteredCount( kind: MultiCommitOperationKind ): Promise { switch (kind) { case MultiCommitOperationKind.Squash: - return this.recordSquashConflictsEncountered() + return this.increment('squashConflictsEncounteredCount') case MultiCommitOperationKind.Reorder: - return this.recordReorderConflictsEncountered() + return this.increment('reorderConflictsEncounteredCount') case MultiCommitOperationKind.Rebase: // ignored because rebase records different stats return @@ -1662,11 +1020,11 @@ export class StatsStore implements IStatsStore { ): Promise { switch (kind) { case MultiCommitOperationKind.Squash: - return this.recordSquashSuccessful() + return this.increment('squashSuccessfulCount') case MultiCommitOperationKind.Reorder: - return this.recordReorderSuccessful() + return this.increment('reorderSuccessfulCount') case MultiCommitOperationKind.CherryPick: - return this.recordCherryPickSuccessful() + return this.increment('cherryPickSuccessfulCount') case MultiCommitOperationKind.Rebase: // ignored because rebase records different stats return @@ -1685,11 +1043,11 @@ export class StatsStore implements IStatsStore { ): Promise { switch (kind) { case MultiCommitOperationKind.Squash: - return this.recordSquashSuccessfulWithConflicts() + return this.increment('squashSuccessfulWithConflictsCount') case MultiCommitOperationKind.Reorder: - return this.recordReorderSuccessfulWithConflicts() + return this.increment('reorderSuccessfulWithConflictsCount') case MultiCommitOperationKind.Rebase: - return this.recordRebaseSuccessAfterConflicts() + return this.increment('rebaseSuccessAfterConflictsCount') case MultiCommitOperationKind.CherryPick: case MultiCommitOperationKind.Merge: log.error( @@ -1708,9 +1066,9 @@ export class StatsStore implements IStatsStore { case MultiCommitOperationKind.Squash: return this.recordSquashUndone() case MultiCommitOperationKind.Reorder: - return this.recordReorderUndone() + return this.increment('reorderUndoneCount') case MultiCommitOperationKind.CherryPick: - return this.recordCherryPickUndone() + return this.increment('cherryPickUndoneCount') case MultiCommitOperationKind.Rebase: case MultiCommitOperationKind.Merge: log.error(`[recordOperationUndone] - Operation not supported: ${kind}`) @@ -1720,81 +1078,6 @@ export class StatsStore implements IStatsStore { } } - public recordSquashMergeSuccessfulWithConflicts(): Promise { - return this.updateDailyMeasures(m => ({ - squashMergeSuccessfulWithConflictsCount: - m.squashMergeSuccessfulWithConflictsCount + 1, - })) - } - - public recordSquashMergeSuccessful(): Promise { - return this.updateDailyMeasures(m => ({ - squashMergeSuccessfulCount: m.squashMergeSuccessfulCount + 1, - })) - } - - public recordSquashMergeInvokedCount(): Promise { - return this.updateDailyMeasures(m => ({ - squashMergeInvokedCount: m.squashMergeInvokedCount + 1, - })) - } - - public recordCheckRunsPopoverOpened(): Promise { - return this.updateDailyMeasures(m => ({ - opensCheckRunsPopover: m.opensCheckRunsPopover + 1, - })) - } - - public recordCheckViewedOnline(): Promise { - return this.updateDailyMeasures(m => ({ - viewsCheckOnline: m.viewsCheckOnline + 1, - })) - } - - public recordCheckJobStepViewedOnline(): Promise { - return this.updateDailyMeasures(m => ({ - viewsCheckJobStepOnline: m.viewsCheckJobStepOnline + 1, - })) - } - - public recordRerunChecks(): Promise { - return this.updateDailyMeasures(m => ({ - rerunsChecks: m.rerunsChecks + 1, - })) - } - - public recordChecksFailedNotificationShown(): Promise { - return this.updateDailyMeasures(m => ({ - checksFailedNotificationCount: m.checksFailedNotificationCount + 1, - })) - } - - public recordChecksFailedNotificationClicked(): Promise { - return this.updateDailyMeasures(m => ({ - checksFailedNotificationClicked: m.checksFailedNotificationClicked + 1, - })) - } - - public recordChecksFailedDialogOpen(): Promise { - return this.updateDailyMeasures(m => ({ - checksFailedDialogOpenCount: m.checksFailedDialogOpenCount + 1, - })) - } - - public recordChecksFailedDialogSwitchToPullRequest(): Promise { - return this.updateDailyMeasures(m => ({ - checksFailedDialogSwitchToPullRequestCount: - m.checksFailedDialogSwitchToPullRequestCount + 1, - })) - } - - public recordChecksFailedDialogRerunChecks(): Promise { - return this.updateDailyMeasures(m => ({ - checksFailedDialogRerunChecksCount: - m.checksFailedDialogRerunChecksCount + 1, - })) - } - // Generates the stat field name for the given PR review type and suffix. private getStatFieldForRequestReviewState( reviewType: ValidNotificationPullRequestReviewState, @@ -1812,15 +1095,14 @@ export class StatsStore implements IStatsStore { return `pullRequestReview${infixMap[reviewType]}${suffix}` } - // Generic method to record stats related to Pull Request review notifications. + // Generic method to record stats related to Pull Request review + // notifications. private recordPullRequestReviewStat( reviewType: ValidNotificationPullRequestReviewState, suffix: PullRequestReviewStatFieldSuffix ) { const statField = this.getStatFieldForRequestReviewState(reviewType, suffix) - return this.updateDailyMeasures( - m => ({ [statField]: m[statField] + 1 } as any) - ) + return this.increment(statField) } public recordPullRequestReviewNotificationShown( @@ -1844,34 +1126,26 @@ export class StatsStore implements IStatsStore { ) } - /** Post some data to our stats endpoint. */ - private post(body: object): Promise { - const options: RequestInit = { - method: 'POST', - headers: new Headers({ 'Content-Type': 'application/json' }), - body: JSON.stringify(body), - } - - return fetch(StatsEndpoint, options) - } + public increment = (k: keyof NumericMeasures, n = 1) => + this.updateDailyMeasures( + m => ({ [k]: m[k] + n } as Pick) + ) /** * Send opt-in ping with details of previous stored value (if known) * - * @param optOut Whether or not the user has opted - * out of usage reporting. - * @param previousValue The raw, current value stored for the - * "stats-opt-out" localStorage key, or - * undefined if no previously stored value - * exists. + * @param optOut Whether or not the user has opted out of usage + * reporting. + * @param previousValue The raw, current value stored for the "stats-opt-out" + * localStorage key, or undefined if no previously stored + * value exists. */ private async sendOptInStatusPing( optOut: boolean, previousValue: boolean | undefined ): Promise { - // The analytics pipeline expects us to submit `optIn` but we - // track `optOut` so we need to invert the value before we send - // it. + // The analytics pipeline expects us to submit `optIn` but we track `optOut` + // so we need to invert the value before we send it. const optIn = !optOut const previousOptInValue = previousValue === undefined ? null : !previousValue @@ -1901,8 +1175,7 @@ export class StatsStore implements IStatsStore { /** * Store the current date (in unix time) in localStorage. * - * If the provided key already exists it will not be - * overwritten. + * If the provided key already exists it will not be overwritten. */ function createLocalStorageTimestamp(key: string) { if (localStorage.getItem(key) === null) { @@ -1913,25 +1186,22 @@ function createLocalStorageTimestamp(key: string) { /** * Get a time stamp (in unix time) from localStorage. * - * If the key doesn't exist or if the stored value can't - * be converted into a number this method will return null. + * If the key doesn't exist or if the stored value can't be converted into a + * number this method will return null. */ function getLocalStorageTimestamp(key: string): number | null { - const timestamp = getNumber(key) - return timestamp === undefined ? null : timestamp + return getNumber(key) ?? null } /** - * Calculate the duration (in seconds) between the time the - * welcome wizard was initiated to the time for the given - * action. + * Calculate the duration (in seconds) between the time the welcome wizard was + * initiated to the time for the given action. * - * If no time stamp exists for when the welcome wizard was - * initiated, which would be the case if the user completed - * the wizard before we introduced onboarding metrics, or if - * the delta between the two values are negative (which could - * happen if a user manually manipulated localStorage in order - * to run the wizard again) this method will return undefined. + * If no time stamp exists for when the welcome wizard was initiated, which + * would be the case if the user completed the wizard before we introduced + * onboarding metrics, or if the delta between the two values are negative + * (which could happen if a user manually manipulated localStorage in order to + * run the wizard again) this method will return undefined. */ function timeTo(key: string): number | undefined { const startTime = getLocalStorageTimestamp(WelcomeWizardInitiatedAtKey) @@ -1946,34 +1216,6 @@ function timeTo(key: string): number | undefined { : Math.round((endTime - startTime) / 1000) } -/** - * Get a string representing the sign in method that was used - * when authenticating a user in the welcome flow. This method - * ensures that the reported value is known to the analytics - * system regardless of whether the enum value of the SignInMethod - * type changes. - */ -function getWelcomeWizardSignInMethod(): 'basic' | 'web' | undefined { - const method = localStorage.getItem( - WelcomeWizardSignInMethodKey - ) as SignInMethod | null - - try { - switch (method) { - case SignInMethod.Basic: - case SignInMethod.Web: - return method - case null: - return undefined - default: - return assertNever(method, `Unknown sign in method: ${method}`) - } - } catch (ex) { - log.error(`Could not parse welcome wizard sign in method`, ex) - return undefined - } -} - /** * Return a value indicating whether the user has opted out of stats reporting * or not. diff --git a/app/src/lib/status-parser.ts b/app/src/lib/status-parser.ts index c642e97fec8..9df0ca64783 100644 --- a/app/src/lib/status-parser.ts +++ b/app/src/lib/status-parser.ts @@ -1,8 +1,10 @@ import { FileEntry, GitStatusEntry, + SubmoduleStatus, UnmergedEntrySummary, } from '../models/status' +import { splitBuffer } from './split-buffer' type StatusItem = IStatusHeader | IStatusEntry @@ -21,8 +23,14 @@ export interface IStatusEntry { /** The two character long status code */ readonly statusCode: string + /** The four character long submodule status code */ + readonly submoduleStatusCode: string + /** The original path in the case of a renamed file */ readonly oldPath?: string + + /** The rename or copy score in the case of a renamed file */ + readonly renameOrCopyScore?: number } export function isStatusHeader( @@ -45,7 +53,7 @@ const IgnoredEntryType = '!' /** Parses output from git status --porcelain -z into file status entries */ export function parsePorcelainStatus( - output: string + output: Buffer ): ReadonlyArray { const entries = new Array() @@ -63,10 +71,10 @@ export function parsePorcelainStatus( // containing special characters are not specially formatted; no quoting or // backslash-escaping is performed. - const tokens = output.split('\0') + const tokens = splitBuffer(output, '\0') for (let i = 0; i < tokens.length; i++) { - const field = tokens[i] + const field = tokens[i].toString() if (field.startsWith('# ') && field.length > 2) { entries.push({ kind: 'header', value: field.substring(2) }) continue @@ -77,7 +85,7 @@ export function parsePorcelainStatus( if (entryKind === ChangedEntryType) { entries.push(parseChangedEntry(field)) } else if (entryKind === RenamedOrCopiedEntryType) { - entries.push(parsedRenamedOrCopiedEntry(field, tokens[++i])) + entries.push(parsedRenamedOrCopiedEntry(field, tokens[++i].toString())) } else if (entryKind === UnmergedEntryType) { entries.push(parseUnmergedEntry(field)) } else if (entryKind === UntrackedEntryType) { @@ -102,7 +110,12 @@ function parseChangedEntry(field: string): IStatusEntry { throw new Error(`Failed to parse status line for changed entry`) } - return { kind: 'entry', statusCode: match[1], path: match[8] } + return { + kind: 'entry', + statusCode: match[1], + submoduleStatusCode: match[2], + path: match[8], + } } // 2 @@ -126,7 +139,14 @@ function parsedRenamedOrCopiedEntry( ) } - return { kind: 'entry', statusCode: match[1], oldPath, path: match[9] } + return { + kind: 'entry', + statusCode: match[1], + submoduleStatusCode: match[2], + oldPath, + renameOrCopyScore: parseInt(match[8].substring(1), 10), + path: match[9], + } } // u

@@ -144,6 +164,7 @@ function parseUnmergedEntry(field: string): IStatusEntry { return { kind: 'entry', statusCode: match[1], + submoduleStatusCode: match[2], path: match[10], } } @@ -155,198 +176,244 @@ function parseUntrackedEntry(field: string): IStatusEntry { // NOTE: We return ?? instead of ? here to play nice with mapStatus, // might want to consider changing this (and mapStatus) in the future. statusCode: '??', + submoduleStatusCode: '????', path, } } +function mapSubmoduleStatus( + submoduleStatusCode: string +): SubmoduleStatus | undefined { + if (!submoduleStatusCode.startsWith('S')) { + return undefined + } + + return { + commitChanged: submoduleStatusCode[1] === 'C', + modifiedChanges: submoduleStatusCode[2] === 'M', + untrackedChanges: submoduleStatusCode[3] === 'U', + } +} + /** * Map the raw status text from Git to a structure we can work with in the app. */ -export function mapStatus(status: string): FileEntry { - if (status === '??') { - return { kind: 'untracked' } +export function mapStatus( + statusCode: string, + submoduleStatusCode: string, + renameOrCopyScore: number | undefined +): FileEntry { + const submoduleStatus = mapSubmoduleStatus(submoduleStatusCode) + + if (statusCode === '??') { + return { kind: 'untracked', submoduleStatus } } - if (status === '.M') { + if (statusCode === '.M') { return { kind: 'ordinary', type: 'modified', index: GitStatusEntry.Unchanged, workingTree: GitStatusEntry.Modified, + submoduleStatus, } } - if (status === 'M.') { + if (statusCode === 'M.') { return { kind: 'ordinary', type: 'modified', index: GitStatusEntry.Modified, workingTree: GitStatusEntry.Unchanged, + submoduleStatus, } } - if (status === '.A') { + if (statusCode === '.A') { return { kind: 'ordinary', type: 'added', index: GitStatusEntry.Unchanged, workingTree: GitStatusEntry.Added, + submoduleStatus, } } - if (status === 'A.') { + if (statusCode === 'A.') { return { kind: 'ordinary', type: 'added', index: GitStatusEntry.Added, workingTree: GitStatusEntry.Unchanged, + submoduleStatus, } } - if (status === '.D') { + if (statusCode === '.D') { return { kind: 'ordinary', type: 'deleted', index: GitStatusEntry.Unchanged, workingTree: GitStatusEntry.Deleted, + submoduleStatus, } } - if (status === 'D.') { + if (statusCode === 'D.') { return { kind: 'ordinary', type: 'deleted', index: GitStatusEntry.Deleted, workingTree: GitStatusEntry.Unchanged, + submoduleStatus, } } - if (status === 'R.') { + if (statusCode === 'R.') { return { kind: 'renamed', index: GitStatusEntry.Renamed, workingTree: GitStatusEntry.Unchanged, + renameOrCopyScore, + submoduleStatus, } } - if (status === '.R') { + if (statusCode === '.R') { return { kind: 'renamed', index: GitStatusEntry.Unchanged, workingTree: GitStatusEntry.Renamed, + renameOrCopyScore, + submoduleStatus, } } - if (status === 'C.') { + if (statusCode === 'C.') { return { kind: 'copied', index: GitStatusEntry.Copied, workingTree: GitStatusEntry.Unchanged, + submoduleStatus, } } - if (status === '.C') { + if (statusCode === '.C') { return { kind: 'copied', index: GitStatusEntry.Unchanged, workingTree: GitStatusEntry.Copied, + submoduleStatus, } } - if (status === 'AD') { + if (statusCode === 'AD') { return { kind: 'ordinary', type: 'added', index: GitStatusEntry.Added, workingTree: GitStatusEntry.Deleted, + submoduleStatus, } } - if (status === 'AM') { + if (statusCode === 'AM') { return { kind: 'ordinary', type: 'added', index: GitStatusEntry.Added, workingTree: GitStatusEntry.Modified, + submoduleStatus, } } - if (status === 'RM') { + if (statusCode === 'RM') { return { kind: 'renamed', index: GitStatusEntry.Renamed, workingTree: GitStatusEntry.Modified, + renameOrCopyScore, + submoduleStatus, } } - if (status === 'RD') { + if (statusCode === 'RD') { return { kind: 'renamed', index: GitStatusEntry.Renamed, workingTree: GitStatusEntry.Deleted, + renameOrCopyScore, + submoduleStatus, } } - if (status === 'DD') { + if (statusCode === 'DD') { return { kind: 'conflicted', action: UnmergedEntrySummary.BothDeleted, us: GitStatusEntry.Deleted, them: GitStatusEntry.Deleted, + submoduleStatus, } } - if (status === 'AU') { + if (statusCode === 'AU') { return { kind: 'conflicted', action: UnmergedEntrySummary.AddedByUs, us: GitStatusEntry.Added, them: GitStatusEntry.UpdatedButUnmerged, + submoduleStatus, } } - if (status === 'UD') { + if (statusCode === 'UD') { return { kind: 'conflicted', action: UnmergedEntrySummary.DeletedByThem, us: GitStatusEntry.UpdatedButUnmerged, them: GitStatusEntry.Deleted, + submoduleStatus, } } - if (status === 'UA') { + if (statusCode === 'UA') { return { kind: 'conflicted', action: UnmergedEntrySummary.AddedByThem, us: GitStatusEntry.UpdatedButUnmerged, them: GitStatusEntry.Added, + submoduleStatus, } } - if (status === 'DU') { + if (statusCode === 'DU') { return { kind: 'conflicted', action: UnmergedEntrySummary.DeletedByUs, us: GitStatusEntry.Deleted, them: GitStatusEntry.UpdatedButUnmerged, + submoduleStatus, } } - if (status === 'AA') { + if (statusCode === 'AA') { return { kind: 'conflicted', action: UnmergedEntrySummary.BothAdded, us: GitStatusEntry.Added, them: GitStatusEntry.Added, + submoduleStatus, } } - if (status === 'UU') { + if (statusCode === 'UU') { return { kind: 'conflicted', action: UnmergedEntrySummary.BothModified, us: GitStatusEntry.UpdatedButUnmerged, them: GitStatusEntry.UpdatedButUnmerged, + submoduleStatus, } } @@ -354,5 +421,6 @@ export function mapStatus(status: string): FileEntry { return { kind: 'ordinary', type: 'modified', + submoduleStatus, } } diff --git a/app/src/lib/stores/accounts-store.ts b/app/src/lib/stores/accounts-store.ts index 39c3a1dd644..c25251aaed5 100644 --- a/app/src/lib/stores/accounts-store.ts +++ b/app/src/lib/stores/accounts-store.ts @@ -1,9 +1,10 @@ import { IDataStore, ISecureStore } from './stores' import { getKeyForAccount } from '../auth' import { Account } from '../../models/account' -import { fetchUser, EmailVisibility } from '../api' +import { fetchUser, EmailVisibility, getEnterpriseAPIURL } from '../api' import { fatalError } from '../fatal-error' import { TypedBaseStore } from './base-store' +import { isGHE } from '../endpoint-capabilities' /** The data-only interface for storage. */ interface IEmail { @@ -43,6 +44,7 @@ interface IAccount { readonly avatarURL: string readonly id: number readonly name: string + readonly plan?: string } /** The store for logged in accounts. */ @@ -162,6 +164,29 @@ export class AccountsStore extends TypedBaseStore> { this.save() } + private getMigratedGHEAccounts( + accounts: ReadonlyArray + ): ReadonlyArray | null { + let migrated = false + const migratedAccounts = accounts.map(account => { + let endpoint = account.endpoint + const endpointURL = new URL(endpoint) + // Migrate endpoints of subdomains of `.ghe.com` that use the `/api/v3` + // path to the correct URL using the `api.` subdomain. + if (isGHE(endpoint) && !endpointURL.hostname.startsWith('api.')) { + endpoint = getEnterpriseAPIURL(endpoint) + migrated = true + } + + return { + ...account, + endpoint, + } + }) + + return migrated ? migratedAccounts : null + } + /** * Load the users into memory from storage. */ @@ -171,7 +196,10 @@ export class AccountsStore extends TypedBaseStore> { return } - const rawAccounts: ReadonlyArray = JSON.parse(raw) + const parsedAccounts: ReadonlyArray = JSON.parse(raw) + const migratedAccounts = this.getMigratedGHEAccounts(parsedAccounts) + const rawAccounts = migratedAccounts ?? parsedAccounts + const accountsWithTokens = [] for (const account of rawAccounts) { const accountWithoutToken = new Account( @@ -181,7 +209,8 @@ export class AccountsStore extends TypedBaseStore> { account.emails, account.avatarURL, account.id, - account.name + account.name, + account.plan ) const key = getKeyForAccount(accountWithoutToken) @@ -196,7 +225,12 @@ export class AccountsStore extends TypedBaseStore> { } this.accounts = accountsWithTokens - this.emitUpdate(this.accounts) + // If any account was migrated, make sure to persist the new value + if (migratedAccounts !== null) { + this.save() // Save already emits an update + } else { + this.emitUpdate(this.accounts) + } } private save() { diff --git a/app/src/lib/stores/alive-store.ts b/app/src/lib/stores/alive-store.ts index f0c00bacb05..a77cd3eec58 100644 --- a/app/src/lib/stores/alive-store.ts +++ b/app/src/lib/stores/alive-store.ts @@ -3,7 +3,6 @@ import { Account, accountEquals } from '../../models/account' import { API } from '../api' import { AliveSession, AliveEvent, Subscription } from '@github/alive-client' import { Emitter } from 'event-kit' -import { enableHighSignalNotifications } from '../feature-flag' import { supportsAliveSessions } from '../endpoint-capabilities' /** Checks whether or not an account is included in a list of accounts. */ @@ -29,13 +28,23 @@ export interface IDesktopPullRequestReviewSubmitAliveEvent { readonly pull_request_number: number readonly state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' readonly review_id: string - readonly number_of_comments: number +} + +export interface IDesktopPullRequestCommentAliveEvent { + readonly type: 'pr-comment' + readonly subtype: 'review-comment' | 'issue-comment' + readonly timestamp: number + readonly owner: string + readonly repo: string + readonly pull_request_number: number + readonly comment_id: string } /** Represents an Alive event relevant to Desktop. */ export type DesktopAliveEvent = | IDesktopChecksFailedAliveEvent | IDesktopPullRequestReviewSubmitAliveEvent + | IDesktopPullRequestCommentAliveEvent interface IAliveSubscription { readonly account: Account readonly subscription: Subscription @@ -107,7 +116,7 @@ export class AliveStore { } private subscribeToAccounts = async (accounts: ReadonlyArray) => { - if (!this.enabled || !enableHighSignalNotifications()) { + if (!this.enabled) { return } @@ -248,7 +257,11 @@ export class AliveStore { } const data = event.data as any as DesktopAliveEvent - if (data.type === 'pr-checks-failed' || data.type === 'pr-review-submit') { + if ( + data.type === 'pr-checks-failed' || + data.type === 'pr-review-submit' || + data.type === 'pr-comment' + ) { this.emitter.emit(this.ALIVE_EVENT_RECEIVED_EVENT, data) } } diff --git a/app/src/lib/stores/api-repositories-store.ts b/app/src/lib/stores/api-repositories-store.ts index ef20ad4e667..aa702713605 100644 --- a/app/src/lib/stores/api-repositories-store.ts +++ b/app/src/lib/stores/api-repositories-store.ts @@ -154,28 +154,82 @@ export class ApiRepositoriesStore extends BaseStore { this.emitUpdate() } + private getAccountState(account: Account) { + return this.accountState.get(resolveAccount(account, this.accountState)) + } + /** * Request that the store loads the list of repositories that * the provided account has explicit permissions to access. */ public async loadRepositories(account: Account) { - const existingAccount = resolveAccount(account, this.accountState) - const existingRepositories = this.accountState.get(existingAccount) + const currentState = this.getAccountState(account) - if (existingRepositories !== undefined && existingRepositories.loading) { + if (currentState?.loading) { return } - this.updateAccount(existingAccount, { loading: true }) - - const api = API.fromAccount(existingAccount) - const repositories = await api.fetchRepositories() + this.updateAccount(account, { loading: true }) + + // We don't want to throw away the existing list of repositories if we're + // refreshing the list of repositories but we'll need to keep track of + // whether any repositories got deleted on the host so that we can remove + // them from our local state. We start out by adding all the repositories + // that we've seen up until this point to a map and then we'll remove them + // one by one as we load the fresh list from the API. Any repositories + // remaining in the map once we're done loading we can assume have been + // deleted on the host. + const missing = new Map() + const repositories = new Map() + + currentState?.repositories.forEach(r => { + missing.set(r.clone_url, r) + repositories.set(r.clone_url, r) + }) + + const addPage = (page: ReadonlyArray) => { + page.forEach(r => { + repositories.set(r.clone_url, r) + missing.delete(r.clone_url) + }) + this.updateAccount(account, { repositories: [...repositories.values()] }) + } - if (repositories === null) { - this.updateAccount(account, { loading: false }) - } else { - this.updateAccount(account, { loading: false, repositories }) + const api = API.fromAccount(resolveAccount(account, this.accountState)) + + // The vast majority of users have few repositories and no org affiliations. + // We'll start by making one request to load all repositories available to + // the user regardless of affiliation and only if that request isn't enough + // to load all repositories will we divvy up the requests and load + // repositories by owner and collaborator+org affiliation separately. This + // way we can avoid making unnecessary requests to the API for the majority + // of users while still improving the user experience for those users who + // have access to a lot of repositories and orgs. + await api.streamUserRepositories(addPage, undefined, { + async continue() { + // If the continue callback is called we know that the first request + // wasn't enough to load all repositories. + // + // For these users (with access to more than 100 repositories) we'll + // stream each of the three different affiliation types concurrently to + // minimize the time it takes to load all repositories. + await Promise.all([ + api.streamUserRepositories(addPage, 'owner'), + api.streamUserRepositories(addPage, 'collaborator'), + api.streamUserRepositories(addPage, 'organization_member'), + ]) + + // Don't load more than one page in the initial stream request. + return false + }, + }) + + if (missing.size) { + missing.forEach((_, clone_url) => repositories.delete(clone_url)) + this.updateAccount(account, { repositories: [...repositories.values()] }) } + + this.updateAccount(account, { loading: false }) } public getState(): ReadonlyMap { diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 3bc0ff16591..c099e54516b 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -7,16 +7,23 @@ import { IssuesStore, PullRequestCoordinator, RepositoriesStore, + SignInResult, SignInStore, + UpstreamRemoteName, } from '.' import { Account } from '../../models/account' import { AppMenu, IMenu } from '../../models/app-menu' -import { IAuthor } from '../../models/author' +import { Author } from '../../models/author' import { Branch, BranchType, IAheadBehind } from '../../models/branch' import { BranchesTab } from '../../models/branches-tab' import { CloneRepositoryTab } from '../../models/clone-repository-tab' import { CloningRepository } from '../../models/cloning-repository' -import { Commit, ICommitContext, CommitOneLine } from '../../models/commit' +import { + Commit, + ICommitContext, + CommitOneLine, + shortenSHA, +} from '../../models/commit' import { DiffSelection, DiffSelectionType, @@ -29,7 +36,11 @@ import { GitHubRepository, hasWritePermission, } from '../../models/github-repository' -import { PullRequest } from '../../models/pull-request' +import { + defaultPullRequestSuggestedNextAction, + PullRequest, + PullRequestSuggestedNextAction, +} from '../../models/pull-request' import { forkPullRequestRemoteName, IRemote, @@ -42,6 +53,7 @@ import { isRepositoryWithGitHubRepository, RepositoryWithGitHubRepository, getNonForkGitHubRepository, + isForkedRepositoryContributingToParent, } from '../../models/repository' import { CommittedFileChange, @@ -59,7 +71,6 @@ import { IMultiCommitOperationProgress, } from '../../models/progress' import { Popup, PopupType } from '../../models/popup' -import { IGitAccount } from '../../models/git-account' import { themeChangeMonitor } from '../../ui/lib/theme-change-monitor' import { getAppPath } from '../../ui/lib/app-proxy' import { @@ -67,7 +78,6 @@ import { ApplicationTheme, getCurrentlyAppliedTheme, getPersistedThemeName, - ICustomTheme, setPersistedTheme, } from '../../ui/lib/application-theme' import { @@ -77,6 +87,10 @@ import { updatePreferredAppMenuItemLabels, updateAccounts, setWindowZoomFactor, + onShowInstallingUpdate, + sendWillQuitEvenIfUpdatingSync, + quitApp, + sendCancelQuittingSync, } from '../../ui/main-process-proxy' import { API, @@ -85,6 +99,9 @@ import { IAPIOrganization, getEndpointForRepository, IAPIFullRepository, + IAPIComment, + IAPIRepoRuleset, + deleteToken, } from '../api' import { shell } from '../app-shell' import { @@ -108,16 +125,17 @@ import { isMergeConflictState, IMultiCommitOperationState, IConstrainedValue, + ICompareState, } from '../app-state' import { findEditorOrDefault, getAvailableEditors, + launchCustomExternalEditor, launchExternalEditor, } from '../editors' import { assertNever, fatalError, forceUnwrap } from '../fatal-error' import { formatCommitMessage } from '../format-commit-message' -import { getGenericHostname, getGenericUsername } from '../generic-git-auth' import { getAccountForRepository } from '../get-account-for-repository' import { abortMerge, @@ -161,6 +179,12 @@ import { RepositoryType, getCommitRangeDiff, getCommitRangeChangedFiles, + updateRemoteHEAD, + getBranchMergeBaseChangedFiles, + getBranchMergeBaseDiff, + checkoutCommit, + getRemoteURL, + getGlobalConfigPath, } from '../git' import { installGlobalLFSFilters, @@ -176,11 +200,12 @@ import { matchExistingRepository, urlMatchesRemote, } from '../repository-matching' -import { isCurrentBranchForcePush } from '../rebase' +import { ForcePushBranchState, getCurrentBranchForcePushState } from '../rebase' import { RetryAction, RetryActionType } from '../../models/retry-actions' import { Default as DefaultShell, findShellOrDefault, + launchCustomShell, launchShell, parse as parseShell, Shell, @@ -194,6 +219,7 @@ import { promiseWithMinimumTimeout } from '../promise' import { BackgroundFetcher } from './helpers/background-fetcher' import { RepositoryStateCache } from './repository-state-cache' import { readEmoji } from '../read-emoji' +import { Emoji } from '../emoji' import { GitStoreCache } from './git-store-cache' import { GitErrorContext } from '../git-error-context' import { @@ -217,10 +243,7 @@ import { } from './updates/changes-state' import { ManualConflictResolution } from '../../models/manual-conflict-resolution' import { BranchPruner } from './helpers/branch-pruner' -import { - enableHideWhitespaceInDiffOption, - enableMultiCommitDiffs, -} from '../feature-flag' +import { enableCustomIntegration } from '../feature-flag' import { Banner, BannerType } from '../../models/banner' import { ComputedAction } from '../../models/computed-action' import { @@ -228,6 +251,7 @@ import { getLastDesktopStashEntryForBranch, popStashEntry, dropDesktopStashEntry, + moveStashEntry, } from '../git/stash' import { UncommittedChangesStrategy, @@ -300,8 +324,24 @@ import { import * as ipcRenderer from '../ipc-renderer' import { pathExists } from '../../ui/lib/path-exists' import { offsetFromNow } from '../offset-from' +import { findContributionTargetDefaultBranch } from '../branch' import { ValidNotificationPullRequestReview } from '../valid-notification-pull-request-review' import { determineMergeability } from '../git/merge-tree' +import { PopupManager } from '../popup-manager' +import { resizableComponentClass } from '../../ui/resizable' +import { compare } from '../compare' +import { parseRepoRules, useRepoRulesLogic } from '../helpers/repo-rules' +import { RepoRulesInfo } from '../../models/repo-rules' +import { + setUseExternalCredentialHelper, + useExternalCredentialHelper, + useExternalCredentialHelperDefault, +} from '../trampoline/use-external-credential-helper' +import { IOAuthAction } from '../parse-app-url' +import { + ICustomIntegration, + migratedCustomIntegration, +} from '../custom-integration' const LastSelectedRepositoryIDKey = 'last-selected-repository-id' @@ -321,17 +361,37 @@ const commitSummaryWidthConfigKey: string = 'commit-summary-width' const defaultStashedFilesWidth: number = 250 const stashedFilesWidthConfigKey: string = 'stashed-files-width' +const defaultPullRequestFileListWidth: number = 250 +const pullRequestFileListConfigKey: string = 'pull-request-files-width' + +const defaultBranchDropdownWidth: number = 230 +const branchDropdownWidthConfigKey: string = 'branch-dropdown-width' + +const defaultPushPullButtonWidth: number = 230 +const pushPullButtonWidthConfigKey: string = 'push-pull-button-width' + const askToMoveToApplicationsFolderDefault: boolean = true const confirmRepoRemovalDefault: boolean = true +const showCommitLengthWarningDefault: boolean = false const confirmDiscardChangesDefault: boolean = true const confirmDiscardChangesPermanentlyDefault: boolean = true +const confirmDiscardStashDefault: boolean = true +const confirmCheckoutCommitDefault: boolean = true const askForConfirmationOnForcePushDefault = true +const confirmUndoCommitDefault: boolean = true +const confirmCommitFilteredChangesDefault: boolean = true const askToMoveToApplicationsFolderKey: string = 'askToMoveToApplicationsFolder' const confirmRepoRemovalKey: string = 'confirmRepoRemoval' +const showCommitLengthWarningKey: string = 'showCommitLengthWarning' const confirmDiscardChangesKey: string = 'confirmDiscardChanges' +const confirmDiscardStashKey: string = 'confirmDiscardStash' +const confirmCheckoutCommitKey: string = 'confirmCheckoutCommit' const confirmDiscardChangesPermanentlyKey: string = 'confirmDiscardChangesPermanentlyKey' const confirmForcePushKey: string = 'confirmForcePush' +const confirmUndoCommitKey: string = 'confirmUndoCommit' +const confirmCommitFilteredChangesKey: string = + 'confirmCommitFilteredChangesKey' const uncommittedChangesStrategyKey = 'uncommittedChangesStrategyKind' @@ -344,10 +404,16 @@ const hideWhitespaceInChangesDiffDefault = false const hideWhitespaceInChangesDiffKey = 'hide-whitespace-in-changes-diff' const hideWhitespaceInHistoryDiffDefault = false const hideWhitespaceInHistoryDiffKey = 'hide-whitespace-in-diff' +const hideWhitespaceInPullRequestDiffDefault = false +const hideWhitespaceInPullRequestDiffKey = + 'hide-whitespace-in-pull-request-diff' const commitSpellcheckEnabledDefault = true const commitSpellcheckEnabledKey = 'commit-spellcheck-enabled' +export const tabSizeDefault: number = 8 +const tabSizeKey: string = 'tab-size' + const shellKey = 'shell' const repositoryIndicatorsEnabledKey = 'enable-repository-indicators' @@ -365,7 +431,21 @@ const InitialRepositoryIndicatorTimeout = 2 * 60 * 1000 const MaxInvalidFoldersToDisplay = 3 const lastThankYouKey = 'version-and-users-of-last-thank-you' -const customThemeKey = 'custom-theme-key' +const pullRequestSuggestedNextActionKey = + 'pull-request-suggested-next-action-key' + +export const useCustomEditorKey = 'use-custom-editor' +const customEditorKey = 'custom-editor' + +export const useCustomShellKey = 'use-custom-shell' +const customShellKey = 'custom-shell' + +export const underlineLinksKey = 'underline-links' +export const underlineLinksDefault = true + +export const showDiffCheckMarksDefault = true +export const showDiffCheckMarksKey = 'diff-check-marks-visible' + export class AppStore extends TypedBaseStore { private readonly gitStoreCache: GitStoreCache @@ -384,10 +464,8 @@ export class AppStore extends TypedBaseStore { private showWelcomeFlow = false private focusCommitMessage = false - private currentPopup: Popup | null = null private currentFoldout: Foldout | null = null private currentBanner: Banner | null = null - private errors: ReadonlyArray = new Array() private emitQueued = false private readonly localRepositoryStateLookup = new Map< @@ -396,7 +474,7 @@ export class AppStore extends TypedBaseStore { >() /** Map from shortcut (e.g., :+1:) to on disk URL. */ - private emoji = new Map() + private emoji = new Map() /** * The Application menu as an AppMenu instance or null if @@ -420,25 +498,38 @@ export class AppStore extends TypedBaseStore { private sidebarWidth = constrain(defaultSidebarWidth) private commitSummaryWidth = constrain(defaultCommitSummaryWidth) private stashedFilesWidth = constrain(defaultStashedFilesWidth) + private pullRequestFileListWidth = constrain(defaultPullRequestFileListWidth) + private branchDropdownWidth = constrain(defaultBranchDropdownWidth) + private pushPullButtonWidth = constrain(defaultPushPullButtonWidth) private windowState: WindowState | null = null private windowZoomFactor: number = 1 + private resizablePaneActive = false private isUpdateAvailableBannerVisible: boolean = false private isUpdateShowcaseVisible: boolean = false private askToMoveToApplicationsFolderSetting: boolean = askToMoveToApplicationsFolderDefault + private useExternalCredentialHelper: boolean = + useExternalCredentialHelperDefault private askForConfirmationOnRepositoryRemoval: boolean = confirmRepoRemovalDefault private confirmDiscardChanges: boolean = confirmDiscardChangesDefault private confirmDiscardChangesPermanently: boolean = confirmDiscardChangesPermanentlyDefault + private confirmDiscardStash: boolean = confirmDiscardStashDefault + private confirmCheckoutCommit: boolean = confirmCheckoutCommitDefault private askForConfirmationOnForcePush = askForConfirmationOnForcePushDefault + private confirmUndoCommit: boolean = confirmUndoCommitDefault + private confirmCommitFilteredChanges: boolean = + confirmCommitFilteredChangesDefault private imageDiffType: ImageDiffType = imageDiffTypeDefault private hideWhitespaceInChangesDiff: boolean = hideWhitespaceInChangesDiffDefault private hideWhitespaceInHistoryDiff: boolean = hideWhitespaceInHistoryDiffDefault + private hideWhitespaceInPullRequestDiff: boolean = + hideWhitespaceInPullRequestDiffDefault /** Whether or not the spellchecker is enabled for commit summary and description */ private commitSpellcheckEnabled: boolean = commitSpellcheckEnabledDefault private showSideBySideDiff: boolean = ShowSideBySideDiffDefault @@ -450,7 +541,7 @@ export class AppStore extends TypedBaseStore { private resolvedExternalEditor: string | null = null /** The user's preferred shell. */ - private selectedShell = DefaultShell + private selectedShell: Shell = DefaultShell /** The current repository filter text */ private repositoryFilterText: string = '' @@ -466,11 +557,13 @@ export class AppStore extends TypedBaseStore { private selectedBranchesTab = BranchesTab.Branches private selectedTheme = ApplicationTheme.System - private customTheme?: ICustomTheme private currentTheme: ApplicableTheme = ApplicationTheme.Light + private selectedTabSize = tabSizeDefault private useWindowsOpenSSH: boolean = false + private showCommitLengthWarning: boolean = showCommitLengthWarningDefault + private hasUserViewedStash = false private repositoryIndicatorsEnabled: boolean @@ -481,8 +574,28 @@ export class AppStore extends TypedBaseStore { private currentDragElement: DragElement | null = null private lastThankYou: ILastThankYou | undefined + + private useCustomEditor: boolean = false + private customEditor: ICustomIntegration | null = null + + private useCustomShell: boolean = false + private customShell: ICustomIntegration | null = null + private showCIStatusPopover: boolean = false + /** A service for managing the stack of open popups */ + private popupManager = new PopupManager() + + private pullRequestSuggestedNextAction: + | PullRequestSuggestedNextAction + | undefined = undefined + + private showDiffCheckMarks: boolean = showDiffCheckMarksDefault + + private cachedRepoRulesets = new Map() + + private underlineLinks: boolean = underlineLinksDefault + public constructor( private readonly gitHubUserStore: GitHubUserStore, private readonly cloningRepositoriesStore: CloningRepositoriesStore, @@ -565,6 +678,12 @@ export class AppStore extends TypedBaseStore { this.notificationsStore.onPullRequestReviewSubmitNotification( this.onPullRequestReviewSubmitNotification ) + + this.notificationsStore.onPullRequestCommentNotification( + this.onPullRequestCommentNotification + ) + + onShowInstallingUpdate(this.onShowInstallingUpdate) } private initializeWindowState = async () => { @@ -612,17 +731,19 @@ export class AppStore extends TypedBaseStore { return zoomFactor } - private onTokenInvalidated = (endpoint: string) => { + private onTokenInvalidated = (endpoint: string, token: string) => { const account = getAccountForEndpoint(this.accounts, endpoint) if (account === null) { return } - // If there is a currently open popup, don't do anything here. Since the - // app can only show one popup at a time, we don't want to close the current - // one in favor of the error we're about to show. - if (this.currentPopup !== null) { + // If we have a token for the account but it doesn't match the token that + // was invalidated that likely means that someone held onto an account for + // longer than they should have which is bad but what's even worse is if we + // invalidate an active account. + if (account.token && account.token !== token) { + log.error(`Token for ${endpoint} invalidated but token mismatch`) return } @@ -635,6 +756,12 @@ export class AppStore extends TypedBaseStore { }) } + private onShowInstallingUpdate = () => { + this._showPopup({ + type: PopupType.InstallingUpdate, + }) + } + /** Figure out what step of the tutorial the user needs to do next */ private async updateCurrentTutorialStep( repository: Repository @@ -684,6 +811,9 @@ export class AppStore extends TypedBaseStore { this.statsStore.recordTutorialPrCreated() this.statsStore.recordTutorialCompleted() break + case TutorialStep.Announced: + // don't need to record anything for announcment + break default: assertNever(step, 'Unaccounted for step type') } @@ -714,6 +844,11 @@ export class AppStore extends TypedBaseStore { await this.updateCurrentTutorialStep(repository) } + public async _markTutorialCompletionAsAnnounced(repository: Repository) { + this.tutorialAssessor.markTutorialCompletionAsAnnounced() + await this.updateCurrentTutorialStep(repository) + } + private wireupIpcEventHandlers() { ipcRenderer.on('window-state-changed', (_, windowState) => { this.windowState = windowState @@ -738,13 +873,7 @@ export class AppStore extends TypedBaseStore { this.cloningRepositoriesStore.onDidError(e => this.emitError(e)) - this.signInStore.onDidAuthenticate((account, method) => { - this._addAccount(account) - - if (this.showWelcomeFlow) { - this.statsStore.recordWelcomeWizardSignInMethod(method) - } - }) + this.signInStore.onDidAuthenticate(account => this._addAccount(account)) this.signInStore.onDidUpdate(() => this.emitUpdate()) this.signInStore.onDidError(error => this.emitError(error)) @@ -887,15 +1016,19 @@ export class AppStore extends TypedBaseStore { appIsFocused: this.appIsFocused, selectedState: this.getSelectedState(), signInState: this.signInStore.getState(), - currentPopup: this.currentPopup, + currentPopup: this.popupManager.currentPopup, + allPopups: this.popupManager.allPopups, currentFoldout: this.currentFoldout, - errors: this.errors, + errorCount: this.popupManager.getPopupsOfType(PopupType.Error).length, showWelcomeFlow: this.showWelcomeFlow, focusCommitMessage: this.focusCommitMessage, emoji: this.emoji, sidebarWidth: this.sidebarWidth, + branchDropdownWidth: this.branchDropdownWidth, + pushPullButtonWidth: this.pushPullButtonWidth, commitSummaryWidth: this.commitSummaryWidth, stashedFilesWidth: this.stashedFilesWidth, + pullRequestFilesListWidth: this.pullRequestFileListWidth, appMenuState: this.appMenu ? this.appMenu.openMenus : [], highlightAccessKeys: this.highlightAccessKeys, isUpdateAvailableBannerVisible: this.isUpdateAvailableBannerVisible, @@ -903,17 +1036,24 @@ export class AppStore extends TypedBaseStore { currentBanner: this.currentBanner, askToMoveToApplicationsFolderSetting: this.askToMoveToApplicationsFolderSetting, + useExternalCredentialHelper: this.useExternalCredentialHelper, askForConfirmationOnRepositoryRemoval: this.askForConfirmationOnRepositoryRemoval, askForConfirmationOnDiscardChanges: this.confirmDiscardChanges, askForConfirmationOnDiscardChangesPermanently: this.confirmDiscardChangesPermanently, + askForConfirmationOnDiscardStash: this.confirmDiscardStash, + askForConfirmationOnCheckoutCommit: this.confirmCheckoutCommit, askForConfirmationOnForcePush: this.askForConfirmationOnForcePush, + askForConfirmationOnUndoCommit: this.confirmUndoCommit, + askForConfirmationOnCommitFilteredChanges: + this.confirmCommitFilteredChanges, uncommittedChangesStrategy: this.uncommittedChangesStrategy, selectedExternalEditor: this.selectedExternalEditor, imageDiffType: this.imageDiffType, hideWhitespaceInChangesDiff: this.hideWhitespaceInChangesDiff, hideWhitespaceInHistoryDiff: this.hideWhitespaceInHistoryDiff, + hideWhitespaceInPullRequestDiff: this.hideWhitespaceInPullRequestDiff, showSideBySideDiff: this.showSideBySideDiff, selectedShell: this.selectedShell, repositoryFilterText: this.repositoryFilterText, @@ -921,18 +1061,28 @@ export class AppStore extends TypedBaseStore { selectedCloneRepositoryTab: this.selectedCloneRepositoryTab, selectedBranchesTab: this.selectedBranchesTab, selectedTheme: this.selectedTheme, - customTheme: this.customTheme, currentTheme: this.currentTheme, + selectedTabSize: this.selectedTabSize, apiRepositories: this.apiRepositoriesStore.getState(), useWindowsOpenSSH: this.useWindowsOpenSSH, + showCommitLengthWarning: this.showCommitLengthWarning, optOutOfUsageTracking: this.statsStore.getOptOut(), currentOnboardingTutorialStep: this.currentOnboardingTutorialStep, repositoryIndicatorsEnabled: this.repositoryIndicatorsEnabled, commitSpellcheckEnabled: this.commitSpellcheckEnabled, currentDragElement: this.currentDragElement, lastThankYou: this.lastThankYou, + useCustomEditor: this.useCustomEditor, + customEditor: this.customEditor, + useCustomShell: this.useCustomShell, + customShell: this.customShell, showCIStatusPopover: this.showCIStatusPopover, notificationsEnabled: getNotificationsEnabled(), + pullRequestSuggestedNextAction: this.pullRequestSuggestedNextAction, + resizablePaneActive: this.resizablePaneActive, + cachedRepoRulesets: this.cachedRepoRulesets, + underlineLinks: this.underlineLinks, + showDiffCheckMarks: this.showDiffCheckMarks, } } @@ -988,6 +1138,7 @@ export class AppStore extends TypedBaseStore { return { tip: gitStore.tip, defaultBranch: gitStore.defaultBranch, + upstreamDefaultBranch: gitStore.upstreamDefaultBranch, allBranches: gitStore.allBranches, recentBranches: gitStore.recentBranches, pullWithRebase: gitStore.pullWithRebase, @@ -1049,6 +1200,7 @@ export class AppStore extends TypedBaseStore { private clearBranchProtectionState(repository: Repository) { this.repositoryStateCache.updateChangesState(repository, () => ({ currentBranchProtected: false, + currentRepoRulesInfo: new RepoRulesInfo(), })) this.emitUpdate() } @@ -1080,6 +1232,7 @@ export class AppStore extends TypedBaseStore { if (!hasWritePermission(gitHubRepo)) { this.repositoryStateCache.updateChangesState(repository, () => ({ currentBranchProtected: false, + currentRepoRulesInfo: new RepoRulesInfo(), })) this.emitUpdate() return @@ -1092,13 +1245,61 @@ export class AppStore extends TypedBaseStore { const pushControl = await api.fetchPushControl(owner, name, branchName) const currentBranchProtected = !isBranchPushable(pushControl) + let currentRepoRulesInfo = new RepoRulesInfo() + if (useRepoRulesLogic(account, repository)) { + const slimRulesets = await api.fetchAllRepoRulesets(owner, name) + + // ultimate goal here is to fetch all rulesets that apply to the repo + // so they're already cached when needed later on + if (slimRulesets?.length) { + const rulesetIds = slimRulesets.map(r => r.id) + + const calls: Promise[] = [] + for (const id of rulesetIds) { + // check the cache and don't re-query any that are already in there + if (!this.cachedRepoRulesets.has(id)) { + calls.push(api.fetchRepoRuleset(owner, name, id)) + } + } + + if (calls.length > 0) { + const rulesets = await Promise.all(calls) + this._updateCachedRepoRulesets(rulesets) + } + } + + const branchRules = await api.fetchRepoRulesForBranch( + owner, + name, + branchName + ) + + if (branchRules.length > 0) { + currentRepoRulesInfo = await parseRepoRules( + branchRules, + this.cachedRepoRulesets, + repository + ) + } + } + this.repositoryStateCache.updateChangesState(repository, () => ({ currentBranchProtected, + currentRepoRulesInfo, })) this.emitUpdate() } } + /** This shouldn't be called directly. See `Dispatcher`. */ + public _updateCachedRepoRulesets(rulesets: Array) { + for (const rs of rulesets) { + if (rs !== null) { + this.cachedRepoRulesets.set(rs.id, rs) + } + } + } + private clearSelectedCommit(repository: Repository) { this.repositoryStateCache.updateCommitSelection(repository, () => ({ shas: [], @@ -1109,12 +1310,13 @@ export class AppStore extends TypedBaseStore { } /** This shouldn't be called directly. See `Dispatcher`. */ - public async _changeCommitSelection( + public _changeCommitSelection( repository: Repository, shas: ReadonlyArray, isContiguous: boolean - ): Promise { - const { commitSelection } = this.repositoryStateCache.get(repository) + ): void { + const { commitSelection, commitLookup, compareState } = + this.repositoryStateCache.get(repository) if ( commitSelection.shas.length === shas.length && @@ -1123,8 +1325,19 @@ export class AppStore extends TypedBaseStore { return } + const shasInDiff = this.getShasInDiff( + this.orderShasByHistory(repository, shas), + isContiguous, + commitLookup + ) + + if (shas.length > 1 && isContiguous) { + this.recordMultiCommitDiff(shas, shasInDiff, compareState) + } + this.repositoryStateCache.updateCommitSelection(repository, () => ({ shas, + shasInDiff, isContiguous, file: null, changesetData: { files: [], linesAdded: 0, linesDeleted: 0 }, @@ -1134,6 +1347,83 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } + private recordMultiCommitDiff( + shas: ReadonlyArray, + shasInDiff: ReadonlyArray, + compareState: ICompareState + ) { + const isHistoryTab = compareState.formState.kind === HistoryTabMode.History + + if (isHistoryTab) { + this.statsStore.increment('multiCommitDiffFromHistoryCount') + } else { + this.statsStore.increment('multiCommitDiffFromCompareCount') + } + + const hasUnreachableCommitWarning = !shas.every(s => shasInDiff.includes(s)) + + if (hasUnreachableCommitWarning) { + this.statsStore.increment( + 'multiCommitDiffWithUnreachableCommitWarningCount' + ) + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _updateShasToHighlight( + repository: Repository, + shasToHighlight: ReadonlyArray + ) { + this.repositoryStateCache.updateCompareState(repository, () => ({ + shasToHighlight, + })) + this.emitUpdate() + } + + /** + * When multiple commits are selected, the diff is created using the rev range + * of firstSha^..lastSha in the selected shas. Thus comparing the trees of the + * the lastSha and the first parent of the first sha. However, our history + * list shows commits in chronological order. Thus, when a branch is merged, + * the commits from that branch are injected in their chronological order into + * the history list. Therefore, given a branch history of A, B, C, D, + * MergeCommit where B and C are from the merged branch, diffing on the + * selection of A through D would not have the changes from B an C. + * + * This method traverses the ancestral path from the last commit in the + * selection back to the first commit via checking the parents. The + * commits on this path are the commits whose changes will be seen in the + * diff. This is equivalent to doing `git rev-list firstSha^..lastSha`. + */ + private getShasInDiff( + selectedShas: ReadonlyArray, + isContiguous: boolean, + commitLookup: Map + ) { + if (selectedShas.length <= 1 || !isContiguous) { + return selectedShas + } + + const shasInDiff = new Set() + const selected = new Set(selectedShas) + const shasToTraverse = [selectedShas.at(-1)] + let sha + + while ((sha = shasToTraverse.pop()) !== undefined) { + if (!shasInDiff.has(sha)) { + shasInDiff.add(sha) + + commitLookup.get(sha)?.parentSHAs?.forEach(parentSha => { + if (selected.has(parentSha) && !shasInDiff.has(parentSha)) { + shasToTraverse.push(parentSha) + } + }) + } + } + + return Array.from(shasInDiff) + } + private updateOrSelectFirstCommit( repository: Repository, commitSHAs: ReadonlyArray @@ -1281,14 +1571,14 @@ export class AppStore extends TypedBaseStore { action.comparisonMode ) - this.statsStore.recordBranchComparison() + this.statsStore.increment('branchComparisons') const { branchesState } = this.repositoryStateCache.get(repository) if ( branchesState.defaultBranch !== null && comparisonBranch.name === branchesState.defaultBranch.name ) { - this.statsStore.recordDefaultBranchComparison() + this.statsStore.increment('defaultBranchComparisons') } if (compare == null) { @@ -1307,7 +1597,7 @@ export class AppStore extends TypedBaseStore { aheadBehind, } - this.repositoryStateCache.updateCompareState(repository, s => ({ + this.repositoryStateCache.updateCompareState(repository, () => ({ formState: newState, filterText: comparisonBranch.name, commitSHAs, @@ -1332,17 +1622,11 @@ export class AppStore extends TypedBaseStore { } if (tip.kind === TipState.Valid && aheadBehind.behind > 0) { - const mergeTreePromise = promiseWithMinimumTimeout( - () => determineMergeability(repository, tip.branch, action.branch), - 500 + this.currentMergeTreePromise = this.setupMergabilityPromise( + repository, + tip.branch, + action.branch ) - .catch(err => { - log.warn( - `Error occurred while trying to merge ${tip.branch.name} (${tip.branch.tip.sha}) and ${action.branch.name} (${action.branch.tip.sha})`, - err - ) - return null - }) .then(mergeStatus => { this.repositoryStateCache.updateCompareState(repository, () => ({ mergeStatus, @@ -1350,16 +1634,9 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() }) - - const cleanup = () => { - this.currentMergeTreePromise = null - } - - // TODO: when we have Promise.prototype.finally available we - // should use that here to make this intent clearer - mergeTreePromise.then(cleanup, cleanup) - - this.currentMergeTreePromise = mergeTreePromise + .finally(() => { + this.currentMergeTreePromise = null + }) return this.currentMergeTreePromise } else { @@ -1371,6 +1648,23 @@ export class AppStore extends TypedBaseStore { } } + private setupMergabilityPromise( + repository: Repository, + baseBranch: Branch, + compareBranch: Branch + ) { + return promiseWithMinimumTimeout( + () => determineMergeability(repository, baseBranch, compareBranch), + 500 + ).catch(err => { + log.warn( + `Error occurred while trying to merge ${baseBranch.name} (${baseBranch.tip.sha}) and ${compareBranch.name} (${compareBranch.tip.sha})`, + err + ) + return null + }) + } + /** This shouldn't be called directly. See `Dispatcher`. */ public _updateCompareForm( repository: Repository, @@ -1411,17 +1705,17 @@ export class AppStore extends TypedBaseStore { const state = this.repositoryStateCache.get(repository) const { commitSelection } = state const { shas: currentSHAs, isContiguous } = commitSelection - if ( - currentSHAs.length === 0 || - (currentSHAs.length > 1 && (!enableMultiCommitDiffs() || !isContiguous)) - ) { + if (currentSHAs.length === 0 || (currentSHAs.length > 1 && !isContiguous)) { return } const gitStore = this.gitStoreCache.get(repository) const changesetData = await gitStore.performFailableOperation(() => currentSHAs.length > 1 - ? getCommitRangeChangedFiles(repository, currentSHAs) + ? getCommitRangeChangedFiles( + repository, + this.orderShasByHistory(repository, currentSHAs) + ) : getChangedFiles(repository, currentSHAs[0]) ) if (!changesetData) { @@ -1491,7 +1785,7 @@ export class AppStore extends TypedBaseStore { } } - if (shas.length > 1 && (!enableMultiCommitDiffs() || !isContiguous)) { + if (shas.length > 1 && !isContiguous) { return } @@ -1500,7 +1794,7 @@ export class AppStore extends TypedBaseStore { ? await getCommitRangeDiff( repository, file, - shas, + this.orderShasByHistory(repository, shas), this.hideWhitespaceInHistoryDiff ) : await getCommitDiff( @@ -1623,6 +1917,9 @@ export class AppStore extends TypedBaseStore { ) setNumberArray(RecentRepositoriesKey, slicedRecentRepositories) this.recentRepositories = slicedRecentRepositories + this.notificationsStore.setRecentRepositories( + this.repositories.filter(r => this.recentRepositories.includes(r.id)) + ) this.emitUpdate() } @@ -1808,11 +2105,6 @@ export class AppStore extends TypedBaseStore { ) } - const account = getAccountForRepository(this.accounts, repository) - if (!account) { - return - } - if (!repository.gitHubRepository) { return } @@ -1821,8 +2113,8 @@ export class AppStore extends TypedBaseStore { // similar to what's being done in `refreshAllIndicators` const fetcher = new BackgroundFetcher( repository, - account, - r => this.performFetch(r, account, FetchType.BackgroundTask), + this.accountsStore, + r => this._fetch(r, FetchType.BackgroundTask), r => this.shouldBackgroundFetch(r, null) ) fetcher.start(withInitialSkew) @@ -1857,19 +2149,45 @@ export class AppStore extends TypedBaseStore { this.stashedFilesWidth = constrain( getNumber(stashedFilesWidthConfigKey, defaultStashedFilesWidth) ) + this.pullRequestFileListWidth = constrain( + getNumber(pullRequestFileListConfigKey, defaultPullRequestFileListWidth) + ) + this.branchDropdownWidth = constrain( + getNumber(branchDropdownWidthConfigKey, defaultBranchDropdownWidth) + ) + this.pushPullButtonWidth = constrain( + getNumber(pushPullButtonWidthConfigKey, defaultPushPullButtonWidth) + ) this.updateResizableConstraints() + // TODO: Initiliaze here for now... maybe move to dialog mounting + this.updatePullRequestResizableConstraints() this.askToMoveToApplicationsFolderSetting = getBoolean( askToMoveToApplicationsFolderKey, askToMoveToApplicationsFolderDefault ) + this.useExternalCredentialHelper = useExternalCredentialHelper() + this.askForConfirmationOnRepositoryRemoval = getBoolean( confirmRepoRemovalKey, confirmRepoRemovalDefault ) + // We're planning to flip the default value to false. As such we'll + // start persisting the current behavior to localstorage, so we + // can change the default in the future without affecting current + // users by removing this if statement. + if (getBoolean(showCommitLengthWarningKey) === undefined) { + setBoolean(showCommitLengthWarningKey, true) + } + + this.showCommitLengthWarning = getBoolean( + showCommitLengthWarningKey, + showCommitLengthWarningDefault + ) + this.confirmDiscardChanges = getBoolean( confirmDiscardChangesKey, confirmDiscardChangesDefault @@ -1880,11 +2198,31 @@ export class AppStore extends TypedBaseStore { confirmDiscardChangesPermanentlyDefault ) + this.confirmDiscardStash = getBoolean( + confirmDiscardStashKey, + confirmDiscardStashDefault + ) + + this.confirmCheckoutCommit = getBoolean( + confirmCheckoutCommitKey, + confirmCheckoutCommitDefault + ) + this.askForConfirmationOnForcePush = getBoolean( confirmForcePushKey, askForConfirmationOnForcePushDefault ) + this.confirmUndoCommit = getBoolean( + confirmUndoCommitKey, + confirmUndoCommitDefault + ) + + this.confirmCommitFilteredChanges = getBoolean( + confirmCommitFilteredChangesKey, + confirmCommitFilteredChangesDefault + ) + this.uncommittedChangesStrategy = getEnum(uncommittedChangesStrategyKey, UncommittedChangesStrategy) ?? defaultUncommittedChangesStrategy @@ -1912,6 +2250,10 @@ export class AppStore extends TypedBaseStore { hideWhitespaceInHistoryDiffKey, false ) + this.hideWhitespaceInPullRequestDiff = getBoolean( + hideWhitespaceInPullRequestDiffKey, + false + ) this.commitSpellcheckEnabled = getBoolean( commitSpellcheckEnabledKey, commitSpellcheckEnabledDefault @@ -1919,14 +2261,12 @@ export class AppStore extends TypedBaseStore { this.showSideBySideDiff = getShowSideBySideDiff() this.selectedTheme = getPersistedThemeName() - this.customTheme = getObject(customThemeKey) // Make sure the persisted theme is applied setPersistedTheme(this.selectedTheme) - this.currentTheme = - this.selectedTheme !== ApplicationTheme.HighContrast - ? await getCurrentlyAppliedTheme() - : this.selectedTheme + this.currentTheme = await getCurrentlyAppliedTheme() + + this.selectedTabSize = getNumber(tabSizeKey, tabSizeDefault) themeChangeMonitor.onThemeChanged(theme => { this.currentTheme = theme @@ -1935,9 +2275,45 @@ export class AppStore extends TypedBaseStore { this.lastThankYou = getObject(lastThankYouKey) + this.useCustomEditor = + enableCustomIntegration() && getBoolean(useCustomEditorKey, false) + this.customEditor = getObject(customEditorKey) ?? null + + this.useCustomShell = + enableCustomIntegration() && getBoolean(useCustomShellKey, false) + this.customShell = getObject(customShellKey) ?? null + + // Migrate custom editor and shell to the new format if needed. This + // will persist the new format to local storage. + // Hopefully we can remove this migration in the future. + const migratedCustomEditor = migratedCustomIntegration(this.customEditor) + if (migratedCustomEditor !== null) { + this._setCustomEditor(migratedCustomEditor) + } + const migratedCustomShell = migratedCustomIntegration(this.customShell) + if (migratedCustomShell !== null) { + this._setCustomShell(migratedCustomShell) + } + + this.pullRequestSuggestedNextAction = + getEnum( + pullRequestSuggestedNextActionKey, + PullRequestSuggestedNextAction + ) ?? defaultPullRequestSuggestedNextAction + + // Always false if the feature flag is disabled. + this.underlineLinks = getBoolean(underlineLinksKey, underlineLinksDefault) + + this.showDiffCheckMarks = getBoolean( + showDiffCheckMarksKey, + showDiffCheckMarksDefault + ) + this.emitUpdateNow() this.accountsStore.refresh() + + this.updateMenuLabelsForSelectedRepository() } /** @@ -1945,11 +2321,12 @@ export class AppStore extends TypedBaseStore { * dimensions change. */ private updateResizableConstraints() { - // The combined width of the branch dropdown and the push pull fetch button + // The combined width of the branch dropdown and the push/pull/fetch button // Since the repository list toolbar button width is tied to the width of - // the sidebar we can't let it push the branch, and push/pull/fetch buttons + // the sidebar we can't let it push the branch, and push/pull/fetch button // off screen. - const toolbarButtonsWidth = 460 + const toolbarButtonsMinWidth = + defaultPushPullButtonWidth + defaultBranchDropdownWidth // Start with all the available width let available = window.innerWidth @@ -1960,7 +2337,7 @@ export class AppStore extends TypedBaseStore { // 220 was determined as the minimum value since it is the smallest width // that will still fit the placeholder text in the branch selector textbox // of the history tab - const maxSidebarWidth = available - toolbarButtonsWidth + const maxSidebarWidth = available - toolbarButtonsMinWidth this.sidebarWidth = constrain(this.sidebarWidth, 220, maxSidebarWidth) // Now calculate the width we have left to distribute for the other panes @@ -1976,6 +2353,69 @@ export class AppStore extends TypedBaseStore { this.commitSummaryWidth = constrain(this.commitSummaryWidth, 100, filesMax) this.stashedFilesWidth = constrain(this.stashedFilesWidth, 100, filesMax) + + // Update the maximum width available for the branch dropdown resizable. + // The branch dropdown can only be as wide as the available space after + // taking the sidebar and pull/push/fetch button widths. If the room + // available is less than the default width, we will split the difference + // between the branch dropdown and the push/pull/fetch button so they stay + // visible on the most zoomed view. + const branchDropdownMax = available - defaultPushPullButtonWidth + const minimumBranchDropdownWidth = + defaultBranchDropdownWidth > available / 2 + ? available / 2 - 10 // 10 is to give a little bit of space to see the fetch dropdown button + : defaultBranchDropdownWidth + this.branchDropdownWidth = constrain( + this.branchDropdownWidth, + minimumBranchDropdownWidth, + branchDropdownMax + ) + + const pushPullButtonMaxWidth = available - this.branchDropdownWidth.value + const minimumPushPullToolBarWidth = + defaultPushPullButtonWidth > available / 2 + ? available / 2 + 30 // 30 to clip the fetch dropdown button in favor of seeing more of the words on the toolbar buttons + : defaultPushPullButtonWidth + this.pushPullButtonWidth = constrain( + this.pushPullButtonWidth, + minimumPushPullToolBarWidth, + pushPullButtonMaxWidth + ) + } + + /** + * Calculate the constraints of the resizable pane in the pull request dialog + * whenever the window dimensions change. + */ + private updatePullRequestResizableConstraints() { + // TODO: Get width of PR dialog -> determine if we will have default width + // for pr dialog. The goal is for it expand to fill some percent of + // available window so it will change on window resize. We may have some max + // value and min value of where to derive a default is we cannot obtain the + // width for some reason (like initialization nad no pr dialog is open) + // Thoughts -> ß + // 1. Use dialog id to grab dialog if exists, else use default + // 2. Pass dialog width up when and call this contrainst on dialog mounting + // to initialize and subscribe to window resize inside dialog to be able + // to pass up dialog width on window resize. + + // Get the width of the dialog + const available = 850 + const dialogPadding = 20 + + // This is a pretty silly width for a diff but it will fit ~9 chars per line + // in unified mode after subtracting the width of the unified gutter and ~4 + // chars per side in split diff mode. No one would want to use it this way + // but it doesn't break the layout and it allows users to temporarily + // maximize the width of the file list to see long path names. + const diffPaneMinWidth = 150 + const filesListMax = available - dialogPadding - diffPaneMinWidth + + this.pullRequestFileListWidth = constrain( + this.pullRequestFileListWidth, + 100, + filesListMax + ) } private updateSelectedExternalEditor( @@ -2035,15 +2475,18 @@ export class AppStore extends TypedBaseStore { */ private updateMenuItemLabels(state: IRepositoryState | null) { const { + useCustomShell, selectedShell, + selectedRepository, + useCustomEditor, selectedExternalEditor, askForConfirmationOnRepositoryRemoval, askForConfirmationOnForcePush, } = this const labels: MenuLabelsEvent = { - selectedShell, - selectedExternalEditor, + selectedShell: useCustomShell ? null : selectedShell, + selectedExternalEditor: useCustomEditor ? null : selectedExternalEditor, askForConfirmationOnRepositoryRemoval, askForConfirmationOnForcePush, } @@ -2054,17 +2497,21 @@ export class AppStore extends TypedBaseStore { } const { changesState, branchesState, aheadBehind } = state - const { defaultBranch, currentPullRequest } = branchesState + const { currentPullRequest } = branchesState - const defaultBranchName = - defaultBranch === null || defaultBranch.upstreamWithoutRemote === null - ? undefined - : defaultBranch.upstreamWithoutRemote + let contributionTargetDefaultBranch: string | undefined + if (selectedRepository instanceof Repository) { + contributionTargetDefaultBranch = + findContributionTargetDefaultBranch(selectedRepository, branchesState) + ?.name ?? undefined + } - const isForcePushForCurrentRepository = isCurrentBranchForcePush( - branchesState, - aheadBehind - ) + // From the menu, we'll offer to force-push whenever it's possible, regardless + // of whether or not the user performed any action we know would be followed + // by a force-push. + const isForcePushForCurrentRepository = + getCurrentBranchForcePushState(branchesState, aheadBehind) !== + ForcePushBranchState.NotAvailable const isStashedChangesVisible = changesState.selection.kind === ChangesSelectionKind.Stash @@ -2074,7 +2521,7 @@ export class AppStore extends TypedBaseStore { updatePreferredAppMenuItemLabels({ ...labels, - defaultBranchName, + contributionTargetDefaultBranch, isForcePushForCurrentRepository, isStashedChangesVisible, hasCurrentPullRequest: currentPullRequest !== null, @@ -2338,7 +2785,10 @@ export class AppStore extends TypedBaseStore { if ( displayingBanner || - isConflictsFlow(this.currentPopup, multiCommitOperationState) + isConflictsFlow( + this.popupManager.areTherePopupsOfType(PopupType.MultiCommitOperation), + multiCommitOperationState + ) ) { return } @@ -2371,6 +2821,8 @@ export class AppStore extends TypedBaseStore { assertNever(conflictState, `Unsupported conflict kind`) } + this.statsStore.increment('mergeConflictFromExplicitMergeCount') + this._setMultiCommitOperationStep(repository, { kind: MultiCommitOperationStepKind.ShowConflicts, conflictState: { @@ -2428,7 +2880,7 @@ export class AppStore extends TypedBaseStore { const { multiCommitOperationState } = state if ( userIsStartingMultiCommitOperation( - this.currentPopup, + this.popupManager.currentPopup, multiCommitOperationState ) ) { @@ -2443,24 +2895,33 @@ export class AppStore extends TypedBaseStore { /** This shouldn't be called directly. See `Dispatcher`. */ public async _changeRepositorySection( repository: Repository, - selectedSection: RepositorySectionTab + selectedSection: RepositorySectionTab, + forceButtonFocus: boolean = false ): Promise { this.repositoryStateCache.update(repository, state => { if (state.selectedSection !== selectedSection) { - this.statsStore.recordRepositoryViewChanged() + this.statsStore.increment('repositoryViewChangeCount') } return { selectedSection } }) this.emitUpdate() if (selectedSection === RepositorySectionTab.History) { - return this.refreshHistorySection(repository) + await this.refreshHistorySection(repository) } else if (selectedSection === RepositorySectionTab.Changes) { - return this.refreshChangesSection(repository, { + await this.refreshChangesSection(repository, { includingStatus: true, clearPartialState: false, }) } + + if (forceButtonFocus) { + const repoSideBar = document.getElementById('repository-sidebar') + const button = repoSideBar?.querySelector( + '.tab-bar-item.selected' + ) as HTMLButtonElement + button?.focus() + } } /** @@ -2534,7 +2995,7 @@ export class AppStore extends TypedBaseStore { const diff = await getWorkingDirectoryDiff( repository, selectedFileBeforeLoad, - enableHideWhitespaceInDiffOption() && this.hideWhitespaceInChangesDiff + this.hideWhitespaceInChangesDiff ) const stateAfterLoad = this.repositoryStateCache.get(repository) @@ -2701,7 +3162,7 @@ export class AppStore extends TypedBaseStore { // `hasUserViewedStash` is reset to false on every branch checkout // so we increment the metric before setting `hasUserViewedStash` to true // to make sure we only increment on the first view after checkout - this.statsStore.recordStashViewedAfterCheckout() + this.statsStore.increment('stashViewedAfterCheckoutCount') this.hasUserViewedStash = true } } @@ -2852,7 +3313,7 @@ export class AppStore extends TypedBaseStore { file => file.selection.getSelectionType() === DiffSelectionType.Partial ) if (includedPartialSelections) { - this.statsStore.recordPartialCommit() + this.statsStore.increment('partialCommits') } if (isAmend) { @@ -2861,22 +3322,22 @@ export class AppStore extends TypedBaseStore { const { trailers } = context if (trailers !== undefined && trailers.some(isCoAuthoredByTrailer)) { - this.statsStore.recordCoAuthoredCommit() + this.statsStore.increment('coAuthoredCommits') } const account = getAccountForRepository(this.accounts, repository) if (repository.gitHubRepository !== null) { if (account !== null) { if (account.endpoint === getDotComAPIEndpoint()) { - this.statsStore.recordCommitToDotcom() + this.statsStore.increment('dotcomCommits') } else { - this.statsStore.recordCommitToEnterprise() + this.statsStore.increment('enterpriseCommits') } const { commitAuthor } = repositoryState if (commitAuthor !== null) { if (!isAttributableEmailFor(account, commitAuthor.email)) { - this.statsStore.recordUnattributedCommit() + this.statsStore.increment('unattributedCommits') } } } @@ -2887,7 +3348,7 @@ export class AppStore extends TypedBaseStore { ) if (branchProtectionsFound) { - this.statsStore.recordCommitToRepositoryWithBranchProtections() + this.statsStore.increment('commitsToRepositoryWithBranchProtections') } const branchName = findRemoteBranchName( @@ -2899,7 +3360,7 @@ export class AppStore extends TypedBaseStore { if (branchName !== null) { const { changesState } = this.repositoryStateCache.get(repository) if (changesState.currentBranchProtected) { - this.statsStore.recordCommitToProtectedBranch() + this.statsStore.increment('commitsToProtectedBranch') } } @@ -2907,7 +3368,7 @@ export class AppStore extends TypedBaseStore { repository.gitHubRepository !== null && !hasWritePermission(repository.gitHubRepository) ) { - this.statsStore.recordCommitToRepositoryWithoutWriteAccess() + this.statsStore.increment('commitsToRepositoryWithoutWriteAccess') this.statsStore.recordRepositoryCommitedInWithoutWriteAccess( repository.gitHubRepository.dbID ) @@ -3038,6 +3499,8 @@ export class AppStore extends TypedBaseStore { return } + // loadBranches needs the default remote to determine the default branch + await gitStore.loadRemotes() await gitStore.loadBranches() const section = state.selectedSection @@ -3055,7 +3518,6 @@ export class AppStore extends TypedBaseStore { } await Promise.all([ - gitStore.loadRemotes(), gitStore.updateLastFetched(), gitStore.loadStashEntries(), this._refreshAuthor(repository), @@ -3194,12 +3656,12 @@ export class AppStore extends TypedBaseStore { * of the current branch to its upstream tracking branch. */ private fetchForRepositoryIndicator(repo: Repository) { - return this.withAuthenticatingUser(repo, async (repo, account) => { + return this.withRefreshedGitHubRepository(repo, async repo => { const isBackgroundTask = true const gitStore = this.gitStoreCache.get(repo) await this.withPushPullFetch(repo, () => - gitStore.fetch(account, isBackgroundTask, progress => + gitStore.fetch(isBackgroundTask, progress => this.updatePushPullFetchProgress(repo, progress) ) ) @@ -3243,6 +3705,12 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } + public _setShowCommitLengthWarning(showCommitLengthWarning: boolean) { + setBoolean(showCommitLengthWarningKey, showCommitLengthWarning) + this.showCommitLengthWarning = showCommitLengthWarning + this.emitUpdate() + } + public _setNotificationsEnabled(notificationsEnabled: boolean) { this.notificationsStore.setNotificationsEnabled(notificationsEnabled) this.emitUpdate() @@ -3310,32 +3778,45 @@ export class AppStore extends TypedBaseStore { /** This shouldn't be called directly. See `Dispatcher`. */ public async _showPopup(popup: Popup): Promise { - this._closePopup() - // Always close the app menu when showing a pop up. This is only // applicable on Windows where we draw a custom app menu. this._closeFoldout(FoldoutType.AppMenu) - this.currentPopup = popup + this.popupManager.addPopup(popup) this.emitUpdate() } /** This shouldn't be called directly. See `Dispatcher`. */ public _closePopup(popupType?: PopupType) { - const currentPopup = this.currentPopup - if (currentPopup == null) { + const currentPopup = this.popupManager.currentPopup + if (currentPopup === null) { return } - if (popupType !== undefined && currentPopup.type !== popupType) { - return + if (popupType === undefined) { + this.popupManager.removePopup(currentPopup) + } else { + if (currentPopup.type !== popupType) { + return + } + + if (currentPopup.type === PopupType.CloneRepository) { + this._completeOpenInDesktop(() => Promise.resolve(null)) + } + + this.popupManager.removePopupByType(popupType) } - if (currentPopup.type === PopupType.CloneRepository) { - this._completeOpenInDesktop(() => Promise.resolve(null)) + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _closePopupById(popupId: string) { + if (this.popupManager.currentPopup === null) { + return } - this.currentPopup = null + this.popupManager.removePopupById(popupId) this.emitUpdate() } @@ -3488,14 +3969,14 @@ export class AppStore extends TypedBaseStore { } } - return this.withAuthenticatingUser(repository, (repository, account) => { + return this.withRefreshedGitHubRepository(repository, repository => { // We always want to end with refreshing the repository regardless of // whether the checkout succeeded or not in order to present the most // up-to-date information to the user. - return this.checkoutImplementation(repository, branch, account, strategy) + return this.checkoutImplementation(repository, branch, strategy) .then(() => this.onSuccessfulCheckout(repository, branch)) .catch(e => this.emitError(new CheckoutError(e, repository, branch))) - .then(() => this.refreshAfterCheckout(repository, branch)) + .then(() => this.refreshAfterCheckout(repository, branch.name)) .finally(() => this.updateCheckoutProgress(repository, null)) }) } @@ -3504,15 +3985,16 @@ export class AppStore extends TypedBaseStore { private checkoutImplementation( repository: Repository, branch: Branch, - account: IGitAccount | null, strategy: UncommittedChangesStrategy ) { + const { currentRemote } = this.gitStoreCache.get(repository) + if (strategy === UncommittedChangesStrategy.StashOnCurrentBranch) { - return this.checkoutAndLeaveChanges(repository, branch, account) + return this.checkoutAndLeaveChanges(repository, branch, currentRemote) } else if (strategy === UncommittedChangesStrategy.MoveToNewBranch) { - return this.checkoutAndBringChanges(repository, branch, account) + return this.checkoutAndBringChanges(repository, branch, currentRemote) } else { - return this.checkoutIgnoringChanges(repository, branch, account) + return this.checkoutIgnoringChanges(repository, branch, currentRemote) } } @@ -3520,9 +4002,9 @@ export class AppStore extends TypedBaseStore { private async checkoutIgnoringChanges( repository: Repository, branch: Branch, - account: IGitAccount | null + currentRemote: IRemote | null ) { - await checkoutBranch(repository, account, branch, progress => { + await checkoutBranch(repository, branch, currentRemote, progress => { this.updateCheckoutProgress(repository, progress) }) } @@ -3535,7 +4017,7 @@ export class AppStore extends TypedBaseStore { private async checkoutAndLeaveChanges( repository: Repository, branch: Branch, - account: IGitAccount | null + currentRemote: IRemote | null ) { const repositoryState = this.repositoryStateCache.get(repository) const { workingDirectory } = repositoryState.changesState @@ -3543,10 +4025,10 @@ export class AppStore extends TypedBaseStore { if (tip.kind === TipState.Valid && workingDirectory.files.length > 0) { await this.createStashAndDropPreviousEntry(repository, tip.branch) - this.statsStore.recordStashCreatedOnCurrentBranch() + this.statsStore.increment('stashCreatedOnCurrentBranchCount') } - return this.checkoutIgnoringChanges(repository, branch, account) + return this.checkoutIgnoringChanges(repository, branch, currentRemote) } /** @@ -3562,10 +4044,10 @@ export class AppStore extends TypedBaseStore { private async checkoutAndBringChanges( repository: Repository, branch: Branch, - account: IGitAccount | null + currentRemote: IRemote | null ) { try { - await this.checkoutIgnoringChanges(repository, branch, account) + await this.checkoutIgnoringChanges(repository, branch, currentRemote) } catch (checkoutError) { if (!isLocalChangesOverwrittenError(checkoutError)) { throw checkoutError @@ -3583,10 +4065,10 @@ export class AppStore extends TypedBaseStore { throw checkoutError } - await this.checkoutIgnoringChanges(repository, branch, account) + await this.checkoutIgnoringChanges(repository, branch, currentRemote) await popStashEntry(repository, stash.stashSha) - this.statsStore.recordChangesTakenToNewBranch() + this.statsStore.increment('changesTakenToNewBranchCount') } } @@ -3607,24 +4089,80 @@ export class AppStore extends TypedBaseStore { } if (stashEntry !== null && !this.hasUserViewedStash) { - this.statsStore.recordStashNotViewedAfterCheckout() + this.statsStore.increment('stashNotViewedAfterCheckoutCount') } this.hasUserViewedStash = false } - private async refreshAfterCheckout(repository: Repository, branch: Branch) { + /** + * @param commitish A branch name or a commit hash + */ + private async refreshAfterCheckout( + repository: Repository, + commitish: string + ) { this.updateCheckoutProgress(repository, { kind: 'checkout', title: `Refreshing ${__DARWIN__ ? 'Repository' : 'repository'}`, + description: 'Checking out', value: 1, - targetBranch: branch.name, + target: commitish, }) await this._refreshRepository(repository) return repository } + /** + * Checkout the given commit, ignoring any local changes. + * + * Note: This shouldn't be called directly. See `Dispatcher`. + */ + public async _checkoutCommit( + repository: Repository, + commit: CommitOneLine + ): Promise { + const repositoryState = this.repositoryStateCache.get(repository) + const { branchesState } = repositoryState + const { tip } = branchesState + const { currentRemote } = this.gitStoreCache.get(repository) + + // No point in checking out the currently checked out commit. + if ( + (tip.kind === TipState.Valid && tip.branch.tip.sha === commit.sha) || + (tip.kind === TipState.Detached && tip.currentSha === commit.sha) + ) { + return repository + } + + return this.withRefreshedGitHubRepository(repository, repository => { + // We always want to end with refreshing the repository regardless of + // whether the checkout succeeded or not in order to present the most + // up-to-date information to the user. + return this.checkoutCommitDefaultBehaviour( + repository, + commit, + currentRemote + ) + .catch(e => this.emitError(new Error(e))) + .then(() => + this.refreshAfterCheckout(repository, shortenSHA(commit.sha)) + ) + .finally(() => this.updateCheckoutProgress(repository, null)) + }) + } + + private async checkoutCommitDefaultBehaviour( + repository: Repository, + commit: CommitOneLine, + currentRemote: IRemote | null + ) { + await checkoutCommit(repository, commit, currentRemote, progress => { + this.updateCheckoutProgress(repository, progress) + }) + } + /** * Creates a stash associated to the current checked out branch. * @@ -3655,7 +4193,7 @@ export class AppStore extends TypedBaseStore { } if (await this.createStashAndDropPreviousEntry(repository, currentBranch)) { - this.statsStore.recordStashCreatedOnCurrentBranch() + this.statsStore.increment('stashCreatedOnCurrentBranchCount') await this._refreshRepository(repository) return true } @@ -3757,17 +4295,7 @@ export class AppStore extends TypedBaseStore { /** This shouldn't be called directly. See `Dispatcher`. */ public _pushError(error: Error): Promise { - const newErrors = Array.from(this.errors) - newErrors.push(error) - this.errors = newErrors - this.emitUpdate() - - return Promise.resolve() - } - - /** This shouldn't be called directly. See `Dispatcher`. */ - public _clearError(error: Error): Promise { - this.errors = this.errors.filter(e => e !== error) + this.popupManager.addErrorPopup(error) this.emitUpdate() return Promise.resolve() @@ -3788,9 +4316,15 @@ export class AppStore extends TypedBaseStore { newName: string ): Promise { const gitStore = this.gitStoreCache.get(repository) - await gitStore.performFailableOperation(() => - renameBranch(repository, branch, newName) - ) + await gitStore.performFailableOperation(async () => { + await renameBranch(repository, branch, newName) + + const stashEntry = gitStore.desktopStashEntries.get(branch.name) + + if (stashEntry) { + await moveStashEntry(repository, stashEntry, newName) + } + }) return this._refreshRepository(repository) } @@ -3802,8 +4336,8 @@ export class AppStore extends TypedBaseStore { includeUpstream?: boolean, toCheckout?: Branch | null ): Promise { - return this.withAuthenticatingUser(repository, async (r, account) => { - const gitStore = this.gitStoreCache.get(r) + return this.withRefreshedGitHubRepository(repository, async repository => { + const gitStore = this.gitStoreCache.get(repository) // If solely a remote branch, there is no need to checkout a branch. if (branch.type === BranchType.Remote) { @@ -3816,8 +4350,18 @@ export class AppStore extends TypedBaseStore { ) } + const remote = + gitStore.remotes.find(r => r.name === remoteName) ?? + (await getRemoteURL(repository, remoteName) + .then(url => (url ? { name: remoteName, url } : undefined)) + .catch(e => log.debug(`Could not get remote URL`, e))) + + if (remote === undefined) { + throw new Error(`Could not determine remote url from: ${branch.ref}.`) + } + await gitStore.performFailableOperation(() => - deleteRemoteBranch(r, account, remoteName, nameWithoutRemote) + deleteRemoteBranch(repository, remote, nameWithoutRemote) ) // We log the remote branch's sha so that the user can recover it. @@ -3825,17 +4369,17 @@ export class AppStore extends TypedBaseStore { `Deleted branch ${branch.upstreamWithoutRemote} (was ${tip.sha})` ) - return this._refreshRepository(r) + return this._refreshRepository(repository) } // If a local branch, user may have the branch to delete checked out and // we need to switch to a different branch (default or recent). const branchToCheckout = - toCheckout ?? this.getBranchToCheckoutAfterDelete(branch, r) + toCheckout ?? this.getBranchToCheckoutAfterDelete(branch, repository) if (branchToCheckout !== null) { await gitStore.performFailableOperation(() => - checkoutBranch(r, account, branchToCheckout) + checkoutBranch(repository, branchToCheckout, gitStore.currentRemote) ) } @@ -3843,12 +4387,11 @@ export class AppStore extends TypedBaseStore { return this.deleteLocalBranchAndUpstreamBranch( repository, branch, - account, includeUpstream ) }) - return this._refreshRepository(r) + return this._refreshRepository(repository) }) } @@ -3859,7 +4402,6 @@ export class AppStore extends TypedBaseStore { private async deleteLocalBranchAndUpstreamBranch( repository: Repository, branch: Branch, - account: IGitAccount | null, includeUpstream?: boolean ): Promise { await deleteLocalBranch(repository, branch.name) @@ -3869,12 +4411,20 @@ export class AppStore extends TypedBaseStore { branch.upstreamRemoteName !== null && branch.upstreamWithoutRemote !== null ) { - await deleteRemoteBranch( - repository, - account, - branch.upstreamRemoteName, - branch.upstreamWithoutRemote - ) + const gitStore = this.gitStoreCache.get(repository) + const remoteName = branch.upstreamRemoteName + + const remote = + gitStore.remotes.find(r => r.name === remoteName) ?? + (await getRemoteURL(repository, remoteName) + .then(url => (url ? { name: remoteName, url } : undefined)) + .catch(e => log.debug(`Could not get remote URL`, e))) + + if (!remote) { + throw new Error(`Could not determine remote url from: ${branch.ref}.`) + } + + await deleteRemoteBranch(repository, remote, branch.upstreamWithoutRemote) } return } @@ -3923,14 +4473,40 @@ export class AppStore extends TypedBaseStore { repository: Repository, options?: PushOptions ): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performPush(repository, account, options) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performPush(repository, options) }) } + private getBranchToPush( + repository: Repository, + options?: PushOptions + ): Branch | undefined { + if (options?.branch !== undefined) { + return options?.branch + } + + const state = this.repositoryStateCache.get(repository) + + const { tip } = state.branchesState + + if (tip.kind === TipState.Unborn) { + throw new Error('The current branch is unborn.') + } + + if (tip.kind === TipState.Detached) { + throw new Error('The current repository is in a detached HEAD state.') + } + + if (tip.kind === TipState.Valid) { + return tip.branch + } + + return + } + private async performPush( repository: Repository, - account: IGitAccount | null, options?: PushOptions ): Promise { const state = this.repositoryStateCache.get(repository) @@ -3945,165 +4521,151 @@ export class AppStore extends TypedBaseStore { } return this.withPushPullFetch(repository, async () => { - const { tip } = state.branchesState - - if (tip.kind === TipState.Unborn) { - throw new Error('The current branch is unborn.') - } + const branch = this.getBranchToPush(repository, options) - if (tip.kind === TipState.Detached) { - throw new Error('The current repository is in a detached HEAD state.') + if (branch === undefined) { + return } - if (tip.kind === TipState.Valid) { - const { branch } = tip + const remoteName = branch.upstreamRemoteName || remote.name - const remoteName = branch.upstreamRemoteName || remote.name + const pushTitle = `Pushing to ${remoteName}` - const pushTitle = `Pushing to ${remoteName}` + // Emit an initial progress even before our push begins + // since we're doing some work to get remotes up front. + this.updatePushPullFetchProgress(repository, { + kind: 'push', + title: pushTitle, + value: 0, + remote: remoteName, + branch: branch.name, + }) - // Emit an initial progress even before our push begins - // since we're doing some work to get remotes up front. - this.updatePushPullFetchProgress(repository, { - kind: 'push', - title: pushTitle, - value: 0, - remote: remoteName, - branch: branch.name, - }) + // Let's say that a push takes roughly twice as long as a fetch, + // this is of course highly inaccurate. + let pushWeight = 2.5 + let fetchWeight = 1 - // Let's say that a push takes roughly twice as long as a fetch, - // this is of course highly inaccurate. - let pushWeight = 2.5 - let fetchWeight = 1 + // Let's leave 10% at the end for refreshing + const refreshWeight = 0.1 - // Let's leave 10% at the end for refreshing - const refreshWeight = 0.1 + // Scale pull and fetch weights to be between 0 and 0.9. + const scale = (1 / (pushWeight + fetchWeight)) * (1 - refreshWeight) - // Scale pull and fetch weights to be between 0 and 0.9. - const scale = (1 / (pushWeight + fetchWeight)) * (1 - refreshWeight) + pushWeight *= scale + fetchWeight *= scale - pushWeight *= scale - fetchWeight *= scale + const retryAction: RetryAction = { + type: RetryActionType.Push, + repository, + } - const retryAction: RetryAction = { - type: RetryActionType.Push, - repository, - } + // This is most likely not necessary and is only here out of + // an abundance of caution. We're introducing support for + // automatically configuring Git proxies based on system + // proxy settings and therefore need to pass along the remote + // url to functions such as push, pull, fetch etc. + // + // Prior to this we relied primarily on the `branch.remote` + // property and used the `remote.name` as a fallback in case the + // branch object didn't have a remote name (i.e. if it's not + // published yet). + // + // The remote.name is derived from the current tip first and falls + // back to using the defaultRemote if the current tip isn't valid + // or if the current branch isn't published. There's however no + // guarantee that they'll be refreshed at the exact same time so + // there's a theoretical possibility that `branch.remote` and + // `remote.name` could be out of sync. I have no reason to suspect + // that's the case and if it is then we already have problems as + // the `fetchRemotes` call after the push already relies on the + // `remote` and not the `branch.remote`. All that said this is + // a critical path in the app and somehow breaking pushing would + // be near unforgivable so I'm introducing this `safeRemote` + // temporarily to ensure that there's no risk of us using an + // out of sync remote name while still providing envForRemoteOperation + // with an url to use when resolving proxies. + // + // I'm also adding a non fatal exception if this ever happens + // so that we can confidently remove this safeguard in a future + // release. + const safeRemote: IRemote = { name: remoteName, url: remote.url } + + if (safeRemote.name !== remote.name) { + sendNonFatalException( + 'remoteNameMismatch', + new Error('The current remote name differs from the branch remote') + ) + } - // This is most likely not necessary and is only here out of - // an abundance of caution. We're introducing support for - // automatically configuring Git proxies based on system - // proxy settings and therefore need to pass along the remote - // url to functions such as push, pull, fetch etc. - // - // Prior to this we relied primarily on the `branch.remote` - // property and used the `remote.name` as a fallback in case the - // branch object didn't have a remote name (i.e. if it's not - // published yet). - // - // The remote.name is derived from the current tip first and falls - // back to using the defaultRemote if the current tip isn't valid - // or if the current branch isn't published. There's however no - // guarantee that they'll be refreshed at the exact same time so - // there's a theoretical possibility that `branch.remote` and - // `remote.name` could be out of sync. I have no reason to suspect - // that's the case and if it is then we already have problems as - // the `fetchRemotes` call after the push already relies on the - // `remote` and not the `branch.remote`. All that said this is - // a critical path in the app and somehow breaking pushing would - // be near unforgivable so I'm introducing this `safeRemote` - // temporarily to ensure that there's no risk of us using an - // out of sync remote name while still providing envForRemoteOperation - // with an url to use when resolving proxies. - // - // I'm also adding a non fatal exception if this ever happens - // so that we can confidently remove this safeguard in a future - // release. - const safeRemote: IRemote = { name: remoteName, url: remote.url } - - if (safeRemote.name !== remote.name) { - sendNonFatalException( - 'remoteNameMismatch', - new Error('The current remote name differs from the branch remote') + const gitStore = this.gitStoreCache.get(repository) + await gitStore.performFailableOperation( + async () => { + await pushRepo( + repository, + safeRemote, + branch.name, + branch.upstreamWithoutRemote, + gitStore.tagsToPush, + options, + progress => { + this.updatePushPullFetchProgress(repository, { + ...progress, + title: pushTitle, + value: pushWeight * progress.value, + }) + } ) - } - - const gitStore = this.gitStoreCache.get(repository) - await gitStore.performFailableOperation( - async () => { - await pushRepo( - repository, - account, - safeRemote, - branch.name, - branch.upstreamWithoutRemote, - gitStore.tagsToPush, - options, - progress => { - this.updatePushPullFetchProgress(repository, { - ...progress, - title: pushTitle, - value: pushWeight * progress.value, - }) - } - ) - gitStore.clearTagsToPush() - - await gitStore.fetchRemotes( - account, - [safeRemote], - false, - fetchProgress => { - this.updatePushPullFetchProgress(repository, { - ...fetchProgress, - value: pushWeight + fetchProgress.value * fetchWeight, - }) - } - ) - - const refreshTitle = __DARWIN__ - ? 'Refreshing Repository' - : 'Refreshing repository' - const refreshStartProgress = pushWeight + fetchWeight + gitStore.clearTagsToPush() + await gitStore.fetchRemotes([safeRemote], false, fetchProgress => { this.updatePushPullFetchProgress(repository, { - kind: 'generic', - title: refreshTitle, - description: 'Fast-forwarding branches', - value: refreshStartProgress, + ...fetchProgress, + value: pushWeight + fetchProgress.value * fetchWeight, }) + }) + + const refreshTitle = __DARWIN__ + ? 'Refreshing Repository' + : 'Refreshing repository' + const refreshStartProgress = pushWeight + fetchWeight + + this.updatePushPullFetchProgress(repository, { + kind: 'generic', + title: refreshTitle, + description: 'Fast-forwarding branches', + value: refreshStartProgress, + }) - await this.fastForwardBranches(repository) + await this.fastForwardBranches(repository) - this.updatePushPullFetchProgress(repository, { - kind: 'generic', - title: refreshTitle, - value: refreshStartProgress + refreshWeight * 0.5, - }) + this.updatePushPullFetchProgress(repository, { + kind: 'generic', + title: refreshTitle, + value: refreshStartProgress + refreshWeight * 0.5, + }) - // manually refresh branch protections after the push, to ensure - // any new branch will immediately report as protected - await this.refreshBranchProtectionState(repository) + // manually refresh branch protections after the push, to ensure + // any new branch will immediately report as protected + await this.refreshBranchProtectionState(repository) - await this._refreshRepository(repository) - }, - { retryAction } - ) + await this._refreshRepository(repository) + }, + { retryAction } + ) - this.updatePushPullFetchProgress(repository, null) + this.updatePushPullFetchProgress(repository, null) - this.updateMenuLabelsForSelectedRepository() + this.updateMenuLabelsForSelectedRepository() - // Note that we're using `getAccountForRepository` here instead - // of the `account` instance we've got and that's because recordPush - // needs to be able to differentiate between a GHES account and a - // generic account and it can't do that only based on the endpoint. - this.statsStore.recordPush( - getAccountForRepository(this.accounts, repository), - options - ) - } + // Note that we're using `getAccountForRepository` here instead + // of the `account` instance we've got and that's because recordPush + // needs to be able to differentiate between a GHES account and a + // generic account and it can't do that only based on the endpoint. + this.statsStore.recordPush( + getAccountForRepository(this.accounts, repository), + options + ) }) } @@ -4158,16 +4720,13 @@ export class AppStore extends TypedBaseStore { } public async _pull(repository: Repository): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performPull(repository, account) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performPull(repository) }) } /** This shouldn't be called directly. See `Dispatcher`. */ - private async performPull( - repository: Repository, - account: IGitAccount | null - ): Promise { + private async performPull(repository: Repository): Promise { return this.withPushPullFetch(repository, async () => { const gitStore = this.gitStoreCache.get(repository) const remote = gitStore.currentRemote @@ -4235,25 +4794,38 @@ export class AppStore extends TypedBaseStore { } if (gitStore.pullWithRebase) { - this.statsStore.recordPullWithRebaseEnabled() + this.statsStore.increment('pullWithRebaseCount') } else { - this.statsStore.recordPullWithDefaultSetting() + this.statsStore.increment('pullWithDefaultSettingCount') } - await gitStore.performFailableOperation( - () => - pullRepo(repository, account, remote, progress => { + const pullSucceeded = await gitStore.performFailableOperation( + async () => { + await pullRepo(repository, remote, progress => { this.updatePushPullFetchProgress(repository, { ...progress, value: progress.value * pullWeight, }) - }), - { - gitContext, - retryAction, - } + }) + return true + }, + { gitContext, retryAction } ) + // If the pull failed we shouldn't try to update the remote HEAD + // because there's a decent chance that it failed either because we + // didn't have the correct credentials (which we won't this time + // either) or because there's a network error which likely will + // persist for the next operation as well. + if (pullSucceeded) { + // Updating the local HEAD symref isn't critical so we don't want + // to show an error message to the user and have them retry the + // entire pull operation if it fails. + await updateRemoteHEAD(repository, remote, false).catch(e => + log.error('Failed updating remote HEAD', e) + ) + } + const refreshStartProgress = pullWeight + fetchWeight const refreshTitle = __DARWIN__ ? 'Refreshing Repository' @@ -4328,53 +4900,33 @@ export class AppStore extends TypedBaseStore { // skip pushing if the current branch is a detached HEAD or the repository // is unborn if (gitStore.tip.kind === TipState.Valid) { - await this.performPush(repository, account) - } - - return this.repositoryWithRefreshedGitHubRepository(repository) - } - - private getAccountForRemoteURL(remote: string): IGitAccount | null { - const account = matchGitHubRepository(this.accounts, remote)?.account - if (account !== undefined) { - const hasValidToken = - account.token.length > 0 ? 'has token' : 'empty token' - log.info( - `[AppStore.getAccountForRemoteURL] account found for remote: ${remote} - ${account.login} (${hasValidToken})` - ) - return account - } - - const hostname = getGenericHostname(remote) - const username = getGenericUsername(hostname) - if (username != null) { - log.info( - `[AppStore.getAccountForRemoteURL] found generic credentials for '${hostname}' and '${username}'` - ) - return { login: username, endpoint: hostname } + if ( + gitStore.defaultBranch !== null && + gitStore.tip.branch.name !== gitStore.defaultBranch.name + ) { + await this.performPush(repository, { + branch: gitStore.defaultBranch, + forceWithLease: false, + }) + } + await this.performPush(repository) } - log.info( - `[AppStore.getAccountForRemoteURL] no generic credentials found for '${remote}'` - ) + await gitStore.refreshDefaultBranch() - return null + return this.repositoryWithRefreshedGitHubRepository(repository) } /** This shouldn't be called directly. See `Dispatcher`. */ public _clone( url: string, path: string, - options?: { branch?: string; defaultBranch?: string } + options: { branch?: string; defaultBranch?: string } = {} ): { promise: Promise repository: CloningRepository } { - const account = this.getAccountForRemoteURL(url) - const promise = this.cloningRepositoriesStore.clone(url, path, { - ...options, - account, - }) + const promise = this.cloningRepositoriesStore.clone(url, path, options) const repository = this.cloningRepositoriesStore.repositories.find( r => r.url === url && r.path === path )! @@ -4431,7 +4983,49 @@ export class AppStore extends TypedBaseStore { return this._refreshRepository(repository) } - public _setRepositoryCommitToAmend( + public async _startAmendingRepository( + repository: Repository, + commit: Commit, + isLocalCommit: boolean, + continueWithForcePush: boolean = false + ) { + const repositoryState = this.repositoryStateCache.get(repository) + const { tip } = repositoryState.branchesState + const { askForConfirmationOnForcePush } = this.getState() + + if ( + askForConfirmationOnForcePush && + !continueWithForcePush && + !isLocalCommit && + tip.kind === TipState.Valid + ) { + return this._showPopup({ + type: PopupType.WarnForcePush, + operation: 'Amend', + onBegin: () => { + this._startAmendingRepository(repository, commit, isLocalCommit, true) + }, + }) + } + + await this._changeRepositorySection( + repository, + RepositorySectionTab.Changes + ) + + const gitStore = this.gitStoreCache.get(repository) + await gitStore.prepareToAmendCommit(commit) + + this.setRepositoryCommitToAmend(repository, commit) + + this.statsStore.increment('amendCommitStartedCount') + } + + public async _stopAmendingRepository(repository: Repository) { + this.setRepositoryCommitToAmend(repository, null) + } + + private setRepositoryCommitToAmend( repository: Repository, commit: Commit | null ) { @@ -4456,9 +5050,12 @@ export class AppStore extends TypedBaseStore { changesState.workingDirectory.files.length === 0 // Warn the user if there are changes in the working directory + // This warning can be disabled, except when the user tries to undo + // a merge commit. if ( showConfirmationDialog && - (!isWorkingDirectoryClean || commit.isMergeCommit) + ((this.confirmUndoCommit && !isWorkingDirectoryClean) || + commit.isMergeCommit) ) { return this._showPopup({ type: PopupType.WarnLocalChangesBeforeUndo, @@ -4471,7 +5068,8 @@ export class AppStore extends TypedBaseStore { // Make sure we show the changes after undoing the commit await this._changeRepositorySection( repository, - RepositorySectionTab.Changes + RepositorySectionTab.Changes, + true ) await gitStore.undoCommit(commit) @@ -4529,15 +5127,12 @@ export class AppStore extends TypedBaseStore { repository: Repository, refspec: string ): Promise { - return this.withAuthenticatingUser( - repository, - async (repository, account) => { - const gitStore = this.gitStoreCache.get(repository) - await gitStore.fetchRefspec(account, refspec) + return this.withRefreshedGitHubRepository(repository, async repository => { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.fetchRefspec(refspec) - return this._refreshRepository(repository) - } - ) + return this._refreshRepository(repository) + }) } /** @@ -4549,8 +5144,8 @@ export class AppStore extends TypedBaseStore { * if _any_ fetches or pulls are currently in-progress. */ public _fetch(repository: Repository, fetchType: FetchType): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performFetch(repository, account, fetchType) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performFetch(repository, fetchType) }) } @@ -4565,8 +5160,8 @@ export class AppStore extends TypedBaseStore { remote: IRemote, fetchType: FetchType ): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performFetch(repository, account, fetchType, [remote]) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performFetch(repository, fetchType, [remote]) }) } @@ -4579,7 +5174,6 @@ export class AppStore extends TypedBaseStore { */ private async performFetch( repository: Repository, - account: IGitAccount | null, fetchType: FetchType, remotes?: IRemote[] ): Promise { @@ -4599,10 +5193,9 @@ export class AppStore extends TypedBaseStore { } if (remotes === undefined) { - await gitStore.fetch(account, isBackgroundTask, progressCallback) + await gitStore.fetch(isBackgroundTask, progressCallback) } else { await gitStore.fetchRemotes( - account, remotes, isBackgroundTask, progressCallback @@ -4681,6 +5274,48 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + public _setBranchDropdownWidth(width: number): Promise { + this.branchDropdownWidth = { ...this.branchDropdownWidth, value: width } + setNumber(branchDropdownWidthConfigKey, width) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _resetBranchDropdownWidth(): Promise { + this.branchDropdownWidth = { + ...this.branchDropdownWidth, + value: defaultBranchDropdownWidth, + } + localStorage.removeItem(branchDropdownWidthConfigKey) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _setPushPullButtonWidth(width: number): Promise { + this.pushPullButtonWidth = { ...this.pushPullButtonWidth, value: width } + setNumber(pushPullButtonWidthConfigKey, width) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _resetPushPullButtonWidth(): Promise { + this.pushPullButtonWidth = { + ...this.pushPullButtonWidth, + value: defaultPushPullButtonWidth, + } + localStorage.removeItem(pushPullButtonWidthConfigKey) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + public _setCommitSummaryWidth(width: number): Promise { this.commitSummaryWidth = { ...this.commitSummaryWidth, value: width } setNumber(commitSummaryWidthConfigKey, width) @@ -4778,16 +5413,16 @@ export class AppStore extends TypedBaseStore { const gitStore = this.gitStoreCache.get(repository) if (isSquash) { - this.statsStore.recordSquashMergeInvokedCount() + this.statsStore.increment('squashMergeInvokedCount') } if (mergeStatus !== null) { if (mergeStatus.kind === ComputedAction.Clean) { - this.statsStore.recordMergeHintSuccessAndUserProceeded() + this.statsStore.increment('mergedWithCleanMergeHintCount') } else if (mergeStatus.kind === ComputedAction.Conflicts) { - this.statsStore.recordUserProceededAfterConflictWarning() + this.statsStore.increment('mergedWithConflictWarningHintCount') } else if (mergeStatus.kind === ComputedAction.Loading) { - this.statsStore.recordUserProceededWhileLoading() + this.statsStore.increment('mergedWithLoadingHintCount') } } @@ -4804,7 +5439,7 @@ export class AppStore extends TypedBaseStore { // This code will only run when there are no conflicts. // Thus recordSquashMergeSuccessful is done here and when merge finishes // successfully after conflicts in `dispatcher.finishConflictedMerge`. - this.statsStore.recordSquashMergeSuccessful() + this.statsStore.increment('squashMergeSuccessfulCount') } this._endMultiCommitOperation(repository) } else if ( @@ -4986,11 +5621,18 @@ export class AppStore extends TypedBaseStore { /** This shouldn't be called directly. See `Dispatcher`. */ public async _openShell(path: string) { - this.statsStore.recordOpenShell() + this.statsStore.increment('openShellCount') + const { useCustomShell, customShell } = this.getState() try { - const match = await findShellOrDefault(this.selectedShell) - await launchShell(match, path, error => this._pushError(error)) + if (useCustomShell && customShell) { + await launchCustomShell(customShell, path, error => + this._pushError(error) + ) + } else { + const match = await findShellOrDefault(this.selectedShell) + await launchShell(match, path, error => this._pushError(error)) + } } catch (error) { this.emitError(error) } @@ -5001,23 +5643,34 @@ export class AppStore extends TypedBaseStore { return shell.openExternal(url) } + public async _editGlobalGitConfig() { + await getGlobalConfigPath() + .then(p => this._openInExternalEditor(p)) + .catch(e => log.error('Could not open global Git config for editing', e)) + } + /** Open a path to a repository or file using the user's configured editor */ public async _openInExternalEditor(fullPath: string): Promise { - const { selectedExternalEditor } = this.getState() + const { selectedExternalEditor, useCustomEditor, customEditor } = + this.getState() try { - const match = await findEditorOrDefault(selectedExternalEditor) - if (match === null) { - this.emitError( - new ExternalEditorError( - `No suitable editors installed for GitHub Desktop to launch. Install ${suggestedExternalEditor.name} for your platform and restart GitHub Desktop to try again.`, - { suggestDefaultEditor: true } + if (useCustomEditor && customEditor) { + await launchCustomExternalEditor(fullPath, customEditor) + } else { + const match = await findEditorOrDefault(selectedExternalEditor) + if (match === null) { + this.emitError( + new ExternalEditorError( + `No suitable editors installed for GitHub Desktop to launch. Install ${suggestedExternalEditor.name} for your platform and restart GitHub Desktop to try again.`, + { suggestDefaultEditor: true } + ) ) - ) - return - } + return + } - await launchExternalEditor(fullPath, match) + await launchExternalEditor(fullPath, match) + } } catch (error) { this.emitError(error) } @@ -5042,6 +5695,12 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } + public _setUseExternalCredentialHelper(value: boolean) { + setUseExternalCredentialHelper(value) + this.useExternalCredentialHelper = value + this.emitUpdate() + } + public _setAskToMoveToApplicationsFolderSetting( value: boolean ): Promise { @@ -5086,6 +5745,24 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + public _setConfirmDiscardStashSetting(value: boolean): Promise { + this.confirmDiscardStash = value + + setBoolean(confirmDiscardStashKey, value) + this.emitUpdate() + + return Promise.resolve() + } + + public _setConfirmCheckoutCommitSetting(value: boolean): Promise { + this.confirmCheckoutCommit = value + + setBoolean(confirmCheckoutCommitKey, value) + this.emitUpdate() + + return Promise.resolve() + } + public _setConfirmForcePushSetting(value: boolean): Promise { this.askForConfirmationOnForcePush = value setBoolean(confirmForcePushKey, value) @@ -5097,6 +5774,24 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + public _setConfirmUndoCommitSetting(value: boolean): Promise { + this.confirmUndoCommit = value + setBoolean(confirmUndoCommitKey, value) + + this.emitUpdate() + + return Promise.resolve() + } + + public _setConfirmCommitFilteredChanges(value: boolean): Promise { + this.confirmCommitFilteredChanges = value + setBoolean(confirmCommitFilteredChangesKey, value) + + this.emitUpdate() + + return Promise.resolve() + } + public _setUncommittedChangesStrategySetting( value: UncommittedChangesStrategy ): Promise { @@ -5163,11 +5858,24 @@ export class AppStore extends TypedBaseStore { } } + public _setHideWhitespaceInPullRequestDiff( + hideWhitespaceInDiff: boolean, + repository: Repository, + file: CommittedFileChange | null + ) { + setBoolean(hideWhitespaceInPullRequestDiffKey, hideWhitespaceInDiff) + this.hideWhitespaceInPullRequestDiff = hideWhitespaceInDiff + + if (file !== null) { + this._changePullRequestFileSelection(repository, file) + } + } + public _setShowSideBySideDiff(showSideBySideDiff: boolean) { if (showSideBySideDiff !== this.showSideBySideDiff) { setShowSideBySideDiff(showSideBySideDiff) this.showSideBySideDiff = showSideBySideDiff - this.statsStore.recordDiffModeChanged() + this.statsStore.increment('diffModeChangeCount') this.emitUpdate() } } @@ -5228,38 +5936,31 @@ export class AppStore extends TypedBaseStore { return this._refreshRepository(repository) } + public _resolveOAuthRequest(action: IOAuthAction) { + return this.signInStore.resolveOAuthRequest(action) + } + public _resetSignInState(): Promise { this.signInStore.reset() return Promise.resolve() } - public _beginDotComSignIn(): Promise { - this.signInStore.beginDotComSignIn() - return Promise.resolve() + public _beginDotComSignIn(resultCallback?: (result: SignInResult) => void) { + return this.signInStore.beginDotComSignIn(resultCallback) } - public _beginEnterpriseSignIn(): Promise { - this.signInStore.beginEnterpriseSignIn() - return Promise.resolve() + public _beginEnterpriseSignIn( + resultCallback?: (result: SignInResult) => void + ) { + return this.signInStore.beginEnterpriseSignIn(resultCallback) } public _setSignInEndpoint(url: string): Promise { return this.signInStore.setEndpoint(url) } - public _setSignInCredentials( - username: string, - password: string - ): Promise { - return this.signInStore.authenticateWithBasicAuth(username, password) - } - - public _requestBrowserAuthentication(): Promise { - return this.signInStore.authenticateWithBrowser() - } - - public _setSignInOTP(otp: string): Promise { - return this.signInStore.setTwoFactorOTP(otp) + public _requestBrowserAuthentication() { + this.signInStore.authenticateWithBrowser() } public async _setAppFocusState(isFocused: boolean): Promise { @@ -5320,11 +6021,12 @@ export class AppStore extends TypedBaseStore { return this.repositoriesStore.updateRepositoryPath(repository, path) } - public _removeAccount(account: Account): Promise { + public async _removeAccount(account: Account) { log.info( `[AppStore] removing account ${account.login} (${account.name}) from store` ) - return this.accountsStore.removeAccount(account) + await this.accountsStore.removeAccount(account) + await deleteToken(account) } private async _addAccount(account: Account): Promise { @@ -5543,12 +6245,12 @@ export class AppStore extends TypedBaseStore { }` } - private async withAuthenticatingUser( + private async withRefreshedGitHubRepository( repository: Repository, - fn: (repository: Repository, account: IGitAccount | null) => Promise + fn: (repository: Repository) => Promise ): Promise { let updatedRepository = repository - let account: IGitAccount | null = getAccountForRepository( + const account: Account | null = getAccountForRepository( this.accounts, updatedRepository ) @@ -5561,30 +6263,9 @@ export class AppStore extends TypedBaseStore { updatedRepository = await this.repositoryWithRefreshedGitHubRepository( repository ) - account = getAccountForRepository(this.accounts, updatedRepository) - } - - if (!account) { - const gitStore = this.gitStoreCache.get(repository) - const remote = gitStore.currentRemote - if (remote) { - const hostname = getGenericHostname(remote.url) - const username = getGenericUsername(hostname) - if (username != null) { - account = { login: username, endpoint: hostname } - } - } - } - - if (account instanceof Account) { - const hasValidToken = - account.token.length > 0 ? 'has token' : 'empty token' - log.info( - `[AppStore.withAuthenticatingUser] account found for repository: ${repository.name} - ${account.login} (${hasValidToken})` - ) } - return fn(updatedRepository, account) + return fn(updatedRepository) } private updateRevertProgress( @@ -5605,43 +6286,18 @@ export class AppStore extends TypedBaseStore { repository: Repository, commit: Commit ): Promise { - return this.withAuthenticatingUser(repository, async (repo, account) => { - const gitStore = this.gitStoreCache.get(repo) + return this.withRefreshedGitHubRepository(repository, async repository => { + const gitStore = this.gitStoreCache.get(repository) - await gitStore.revertCommit(repo, commit, account, progress => { - this.updateRevertProgress(repo, progress) + await gitStore.revertCommit(repository, commit, progress => { + this.updateRevertProgress(repository, progress) }) - this.updateRevertProgress(repo, null) + this.updateRevertProgress(repository, null) await this._refreshRepository(repository) }) } - public async promptForGenericGitAuthentication( - repository: Repository | CloningRepository, - retryAction: RetryAction - ): Promise { - let url - if (repository instanceof Repository) { - const gitStore = this.gitStoreCache.get(repository) - const remote = gitStore.currentRemote - if (!remote) { - return - } - - url = remote.url - } else { - url = repository.url - } - - const hostname = getGenericHostname(url) - return this._showPopup({ - type: PopupType.GenericGitAuthentication, - hostname, - retryAction, - }) - } - public async _installGlobalLFSFilters(force: boolean): Promise { try { await installGlobalLFSFilters(force) @@ -5709,7 +6365,10 @@ export class AppStore extends TypedBaseStore { await this._openInBrowser(url.toString()) } - public async _createPullRequest(repository: Repository): Promise { + public async _createPullRequest( + repository: Repository, + baseBranch?: Branch + ): Promise { const gitHubRepository = repository.gitHubRepository if (!gitHubRepository) { return @@ -5722,24 +6381,28 @@ export class AppStore extends TypedBaseStore { return } - const branch = tip.branch + const compareBranch = tip.branch const aheadBehind = state.aheadBehind if (aheadBehind == null) { this._showPopup({ type: PopupType.PushBranchCommits, repository, - branch, + branch: compareBranch, }) } else if (aheadBehind.ahead > 0) { this._showPopup({ type: PopupType.PushBranchCommits, repository, - branch, + branch: compareBranch, unPushedCommits: aheadBehind.ahead, }) } else { - await this._openCreatePullRequestInBrowser(repository, branch) + await this._openCreatePullRequestInBrowser( + repository, + compareBranch, + baseBranch + ) } } @@ -5834,21 +6497,43 @@ export class AppStore extends TypedBaseStore { public async _openCreatePullRequestInBrowser( repository: Repository, - branch: Branch + compareBranch: Branch, + baseBranch?: Branch ): Promise { const gitHubRepository = repository.gitHubRepository if (!gitHubRepository) { return } - const urlEncodedBranchName = encodeURIComponent(branch.nameWithoutRemote) - const baseURL = `${gitHubRepository.htmlURL}/pull/new/${urlEncodedBranchName}` + const { parent, owner, name, htmlURL } = gitHubRepository + const isForkContributingToParent = + isForkedRepositoryContributingToParent(repository) - await this._openInBrowser(baseURL) + const baseForkPreface = + isForkContributingToParent && parent !== null + ? `${parent.owner.login}:${parent.name}:` + : '' + const encodedBaseBranch = + baseBranch !== undefined + ? baseForkPreface + + encodeURIComponent(baseBranch.nameWithoutRemote) + + '...' + : '' - if (this.currentOnboardingTutorialStep === TutorialStep.OpenPullRequest) { - this._markPullRequestTutorialStepAsComplete(repository) - } + const compareForkPreface = isForkContributingToParent + ? `${owner.login}:${name}:` + : '' + + const encodedCompareBranch = + compareForkPreface + + encodeURIComponent( + compareBranch.upstreamWithoutRemote ?? compareBranch.nameWithoutRemote + ) + + const compareString = `${encodedBaseBranch}${encodedCompareBranch}` + const baseURL = `${htmlURL}/pull/new/${compareString}` + + await this._openInBrowser(baseURL) } public async _updateExistingUpstreamRemote( @@ -5904,7 +6589,7 @@ export class AppStore extends TypedBaseStore { ) if (prBranch !== undefined) { await this._checkoutBranch(repository, prBranch) - this.statsStore.recordPRBranchCheckout() + this.statsStore.increment('prBranchCheckouts') } } @@ -6018,7 +6703,7 @@ export class AppStore extends TypedBaseStore { */ public _setCoAuthors( repository: Repository, - coAuthors: ReadonlyArray + coAuthors: ReadonlyArray ) { this.gitStoreCache.get(repository).setCoAuthors(coAuthors) return Promise.resolve() @@ -6030,21 +6715,20 @@ export class AppStore extends TypedBaseStore { public _setSelectedTheme(theme: ApplicationTheme) { setPersistedTheme(theme) this.selectedTheme = theme - if (theme === ApplicationTheme.HighContrast) { - this.currentTheme = theme - } this.emitUpdate() return Promise.resolve() } /** - * Set the custom application-wide theme + * Set the application-wide tab indentation */ - public _setCustomTheme(theme: ICustomTheme) { - setObject(customThemeKey, theme) - this.customTheme = theme - this.emitUpdate() + public _setSelectedTabSize(tabSize: number) { + if (!isNaN(tabSize)) { + this.selectedTabSize = tabSize + setNumber(tabSizeKey, tabSize) + this.emitUpdate() + } return Promise.resolve() } @@ -6169,15 +6853,12 @@ export class AppStore extends TypedBaseStore { /** This shouldn't be called directly. See `Dispatcher`. */ public async _popStashEntry(repository: Repository, stashEntry: IStashEntry) { - const gitStore = this.gitStoreCache.get(repository) - await gitStore.performFailableOperation(() => { - return popStashEntry(repository, stashEntry.stashSha) - }) + await popStashEntry(repository, stashEntry.stashSha) log.info( `[AppStore. _popStashEntry] popped stash with commit id ${stashEntry.stashSha}` ) - this.statsStore.recordStashRestore() + this.statsStore.increment('stashRestoreCount') await this._refreshRepository(repository) } @@ -6194,7 +6875,7 @@ export class AppStore extends TypedBaseStore { `[AppStore. _dropStashEntry] dropped stash with commit id ${stashEntry.stashSha}` ) - this.statsStore.recordStashDiscard() + this.statsStore.increment('stashDiscardCount') await gitStore.loadStashEntries() } @@ -6291,13 +6972,13 @@ export class AppStore extends TypedBaseStore { path, (title, value, description) => { if ( - this.currentPopup !== null && - this.currentPopup.type === PopupType.CreateTutorialRepository + this.popupManager.currentPopup?.type === + PopupType.CreateTutorialRepository ) { - this.currentPopup = { - ...this.currentPopup, + this.popupManager.updatePopup({ + ...this.popupManager.currentPopup, progress: { kind: 'generic', title, value, description }, - } + }) this.emitUpdate() } } @@ -6361,6 +7042,46 @@ export class AppStore extends TypedBaseStore { } } + /** + * Multi selection on the commit list can give an order of 1, 5, 3 if that is + * how the user selected them. However, we want to main chronological ordering + * of the commits to reduce the chance of conflicts during interact rebasing. + * Thus, assuming 1 is the first commit made by the user and 5 is the last. We + * want the order to be, 1, 3, 5. + */ + private orderCommitsByHistory( + repository: Repository, + commits: ReadonlyArray + ) { + const { compareState } = this.repositoryStateCache.get(repository) + const { commitSHAs } = compareState + const commitIndexBySha = new Map(commitSHAs.map((sha, i) => [sha, i])) + + return commits.toSorted((a, b) => + compare(commitIndexBySha.get(b.sha), commitIndexBySha.get(a.sha)) + ) + } + + /** + * Multi selection on the commit list can give an order of 1, 5, 3 if that is + * how the user selected them. However, sometimes we want them in + * chronological ordering of the commits such as when get a range files + * changed. Thus, assuming 1 is the first commit made by the user and 5 is the + * last. We want the order to be, 1, 3, 5. + */ + private orderShasByHistory( + repository: Repository, + commits: ReadonlyArray + ) { + const { compareState } = this.repositoryStateCache.get(repository) + const { commitSHAs } = compareState + const commitIndexBySha = new Map(commitSHAs.map((sha, i) => [sha, i])) + + return commits.toSorted((a, b) => + compare(commitIndexBySha.get(b), commitIndexBySha.get(a)) + ) + } + /** This shouldn't be called directly. See `Dispatcher`. */ public async _cherryPick( repository: Repository, @@ -6371,13 +7092,15 @@ export class AppStore extends TypedBaseStore { return CherryPickResult.UnableToStart } + const orderedCommits = this.orderCommitsByHistory(repository, commits) + await this._refreshRepository(repository) const progressCallback = this.getMultiCommitOperationProgressCallBack(repository) const gitStore = this.gitStoreCache.get(repository) const result = await gitStore.performFailableOperation(() => - cherryPick(repository, commits, progressCallback) + cherryPick(repository, orderedCommits, progressCallback) ) return result || CherryPickResult.Error @@ -6428,11 +7151,11 @@ export class AppStore extends TypedBaseStore { ): Promise { const gitStore = this.gitStoreCache.get(repository) - const checkoutSuccessful = await this.withAuthenticatingUser( + const checkoutSuccessful = await this.withRefreshedGitHubRepository( repository, - (r, account) => { + repository => { return gitStore.performFailableOperation(() => - checkoutBranch(repository, account, targetBranch) + checkoutBranch(repository, targetBranch, gitStore.currentRemote) ) } ) @@ -6543,9 +7266,9 @@ export class AppStore extends TypedBaseStore { } const gitStore = this.gitStoreCache.get(repository) - await this.withAuthenticatingUser(repository, async (r, account) => { + await this.withRefreshedGitHubRepository(repository, async repository => { await gitStore.performFailableOperation(() => - checkoutBranch(repository, account, sourceBranch) + checkoutBranch(repository, sourceBranch, gitStore.currentRemote) ) }) } @@ -6585,6 +7308,30 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } + public _setUseCustomEditor(useCustomEditor: boolean) { + setBoolean(useCustomEditorKey, useCustomEditor) + this.useCustomEditor = useCustomEditor + this.emitUpdate() + } + + public _setCustomEditor(customEditor: ICustomIntegration) { + setObject(customEditorKey, customEditor) + this.customEditor = customEditor + this.emitUpdate() + } + + public _setUseCustomShell(useCustomShell: boolean) { + setBoolean(useCustomShellKey, useCustomShell) + this.useCustomShell = useCustomShell + this.emitUpdate() + } + + public _setCustomShell(customShell: ICustomIntegration) { + setObject(customShellKey, customShell) + this.customShell = customShell + this.emitUpdate() + } + /** This shouldn't be called directly. See `Dispatcher`. */ public async _reorderCommits( repository: Repository, @@ -6942,11 +7689,9 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } - private onChecksFailedNotification = async ( + public onChecksFailedNotification = async ( repository: RepositoryWithGitHubRepository, pullRequest: PullRequest, - commitMessage: string, - commitSha: string, checks: ReadonlyArray ) => { const selectedRepository = @@ -6957,8 +7702,6 @@ export class AppStore extends TypedBaseStore { pullRequest, repository, shouldChangeRepository: true, - commitMessage, - commitSha, checks, } @@ -6968,7 +7711,7 @@ export class AppStore extends TypedBaseStore { selectedRepository === null || selectedRepository.hash !== repository.hash ) { - this.statsStore.recordChecksFailedDialogOpen() + this.statsStore.increment('checksFailedDialogOpenCount') return this._showPopup(popup) } @@ -6982,7 +7725,7 @@ export class AppStore extends TypedBaseStore { // If it's the same branch, just show the existing CI check run popover this._setShowCIStatusPopover(true) } else { - this.statsStore.recordChecksFailedDialogOpen() + this.statsStore.increment('checksFailedDialogOpenCount') // If there is no current branch or it's different than the PR branch, // show the checks failed dialog, but it won't offer to switch to the @@ -6997,8 +7740,7 @@ export class AppStore extends TypedBaseStore { private onPullRequestReviewSubmitNotification = async ( repository: RepositoryWithGitHubRepository, pullRequest: PullRequest, - review: ValidNotificationPullRequestReview, - numberOfComments: number + review: ValidNotificationPullRequestReview ) => { const selectedRepository = this.selectedRepository ?? (await this._selectRepository(repository)) @@ -7019,9 +7761,388 @@ export class AppStore extends TypedBaseStore { review, pullRequest, repository, - numberOfComments, }) } + + private onPullRequestCommentNotification = async ( + repository: RepositoryWithGitHubRepository, + pullRequest: PullRequest, + comment: IAPIComment + ) => { + const selectedRepository = + this.selectedRepository ?? (await this._selectRepository(repository)) + + const state = this.repositoryStateCache.get(repository) + + const { branchesState } = state + const { tip } = branchesState + const currentBranch = tip.kind === TipState.Valid ? tip.branch : null + + return this._showPopup({ + type: PopupType.PullRequestComment, + shouldCheckoutBranch: + currentBranch !== null && currentBranch.name !== pullRequest.head.ref, + shouldChangeRepository: + selectedRepository === null || + selectedRepository.hash !== repository.hash, + comment, + pullRequest, + repository, + }) + } + + public async _startPullRequest(repository: Repository) { + const { tip, defaultBranch } = + this.repositoryStateCache.get(repository).branchesState + + if (tip.kind !== TipState.Valid) { + // Shouldn't even be able to get here if so - just a type check + return + } + + const currentBranch = tip.branch + this._initializePullRequestPreview(repository, defaultBranch, currentBranch) + } + + private async _initializePullRequestPreview( + repository: Repository, + baseBranch: Branch | null, + currentBranch: Branch + ) { + if (baseBranch === null) { + this.showPullRequestPopupNoBaseBranch(repository, currentBranch) + return + } + + const gitStore = this.gitStoreCache.get(repository) + + const pullRequestCommits = await gitStore.getCommitsBetweenBranches( + baseBranch, + currentBranch + ) + + const commitsBetweenBranches = pullRequestCommits.map(c => c.sha) + + // A user may compare two branches with no changes between them. + const emptyChangeSet = { files: [], linesAdded: 0, linesDeleted: 0 } + const changesetData = + commitsBetweenBranches.length > 0 + ? await gitStore.performFailableOperation(() => + getBranchMergeBaseChangedFiles( + repository, + baseBranch.name, + currentBranch.name, + commitsBetweenBranches[0] + ) + ) + : emptyChangeSet + + if (changesetData === undefined) { + return + } + + const hasMergeBase = changesetData !== null + // We don't care how many commits exist on the unrelated history that + // can't be merged. + const commitSHAs = hasMergeBase ? commitsBetweenBranches : [] + + this.repositoryStateCache.initializePullRequestState(repository, { + baseBranch, + commitSHAs, + commitSelection: { + shas: commitSHAs, + shasInDiff: commitSHAs, + isContiguous: true, + changesetData: changesetData ?? emptyChangeSet, + file: null, + diff: null, + }, + mergeStatus: + commitSHAs.length > 0 || !hasMergeBase + ? { + kind: hasMergeBase + ? ComputedAction.Loading + : ComputedAction.Invalid, + } + : null, + }) + + this.emitUpdate() + + if (commitSHAs.length > 0) { + this.setupPRMergeTreePromise(repository, baseBranch, currentBranch) + } + + if (changesetData !== null && changesetData.files.length > 0) { + await this._changePullRequestFileSelection( + repository, + changesetData.files[0] + ) + } + + this.showPullRequestPopup(repository, currentBranch, commitSHAs) + } + + public showPullRequestPopupNoBaseBranch( + repository: Repository, + currentBranch: Branch + ) { + this.repositoryStateCache.initializePullRequestState(repository, { + baseBranch: null, + commitSHAs: null, + commitSelection: null, + mergeStatus: null, + }) + + this.emitUpdate() + + this.showPullRequestPopup(repository, currentBranch, []) + } + + public showPullRequestPopup( + repository: Repository, + currentBranch: Branch, + commitSHAs: ReadonlyArray + ) { + if (this.popupManager.areTherePopupsOfType(PopupType.StartPullRequest)) { + return + } + + this.statsStore.increment('previewedPullRequestCount') + + const { branchesState, localCommitSHAs } = + this.repositoryStateCache.get(repository) + const { allBranches, recentBranches, defaultBranch, currentPullRequest } = + branchesState + const gitStore = this.gitStoreCache.get(repository) + /* We only want branches that are also on dotcom such that, when we ask a + * user to create a pull request, the base branch also exists on dotcom. + */ + const remote = isForkedRepositoryContributingToParent(repository) + ? UpstreamRemoteName + : gitStore.defaultRemote?.name + const prBaseBranches = allBranches.filter( + b => b.upstreamRemoteName === remote || b.remoteName === remote + ) + const prRecentBaseBranches = recentBranches.filter( + b => b.upstreamRemoteName === remote || b.remoteName === remote + ) + const { imageDiffType, selectedExternalEditor, showSideBySideDiff } = + this.getState() + + const nonLocalCommitSHA = + commitSHAs.length > 0 && !localCommitSHAs.includes(commitSHAs[0]) + ? commitSHAs[0] + : null + + this._showPopup({ + type: PopupType.StartPullRequest, + prBaseBranches, + prRecentBaseBranches, + currentBranch, + defaultBranch, + imageDiffType, + repository, + externalEditorLabel: selectedExternalEditor ?? undefined, + nonLocalCommitSHA, + showSideBySideDiff, + currentBranchHasPullRequest: currentPullRequest !== null, + }) + } + + public async _changePullRequestFileSelection( + repository: Repository, + file: CommittedFileChange + ): Promise { + const { branchesState, pullRequestState } = + this.repositoryStateCache.get(repository) + + if ( + branchesState.tip.kind !== TipState.Valid || + pullRequestState === null + ) { + return + } + + const currentBranch = branchesState.tip.branch + const { baseBranch, commitSHAs } = pullRequestState + if (commitSHAs === null || baseBranch === null) { + return + } + + this.repositoryStateCache.updatePullRequestCommitSelection( + repository, + () => ({ + file, + diff: null, + }) + ) + + this.emitUpdate() + + if (commitSHAs.length === 0) { + // Shouldn't happen at this point, but if so moving forward doesn't + // make sense + return + } + + const diff = + (await this.gitStoreCache + .get(repository) + .performFailableOperation(() => + getBranchMergeBaseDiff( + repository, + file, + baseBranch.name, + currentBranch.name, + this.hideWhitespaceInPullRequestDiff, + commitSHAs[0] + ) + )) ?? null + + const { pullRequestState: stateAfterLoad } = + this.repositoryStateCache.get(repository) + const selectedFileAfterDiffLoad = stateAfterLoad?.commitSelection?.file + + if (selectedFileAfterDiffLoad?.id !== file.id) { + // this means user has clicked on another file since loading the diff + return + } + + this.repositoryStateCache.updatePullRequestCommitSelection( + repository, + () => ({ + diff, + }) + ) + + this.emitUpdate() + } + + public _setPullRequestFileListWidth(width: number): Promise { + this.pullRequestFileListWidth = { + ...this.pullRequestFileListWidth, + value: width, + } + setNumber(pullRequestFileListConfigKey, width) + this.updatePullRequestResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _resetPullRequestFileListWidth(): Promise { + this.pullRequestFileListWidth = { + ...this.pullRequestFileListWidth, + value: defaultPullRequestFileListWidth, + } + localStorage.removeItem(pullRequestFileListConfigKey) + this.updatePullRequestResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _updatePullRequestBaseBranch( + repository: Repository, + baseBranch: Branch + ) { + const { branchesState, pullRequestState } = + this.repositoryStateCache.get(repository) + const { tip } = branchesState + + if (tip.kind !== TipState.Valid) { + return + } + + if (pullRequestState === null) { + // This would mean the user submitted PR after requesting base branch + // update. + return + } + + this._initializePullRequestPreview(repository, baseBranch, tip.branch) + } + + private setupPRMergeTreePromise( + repository: Repository, + baseBranch: Branch, + compareBranch: Branch + ) { + this.setupMergabilityPromise(repository, baseBranch, compareBranch).then( + (mergeStatus: MergeTreeResult | null) => { + this.repositoryStateCache.updatePullRequestState(repository, () => ({ + mergeStatus, + })) + this.emitUpdate() + } + ) + } + + public _quitApp(evenIfUpdating: boolean) { + if (evenIfUpdating) { + sendWillQuitEvenIfUpdatingSync() + } + + quitApp() + } + + public _cancelQuittingApp() { + sendCancelQuittingSync() + } + + public _setPullRequestSuggestedNextAction( + value: PullRequestSuggestedNextAction + ) { + this.pullRequestSuggestedNextAction = value + + localStorage.setItem(pullRequestSuggestedNextActionKey, value) + + this.emitUpdate() + } + + private isResizePaneActive() { + if (document.activeElement === null) { + return false + } + + const appMenuBar = document.getElementById('app-menu-bar') + + // Don't track windows menu items as focused elements for keeping + // track of recently focused elements we want to act upon + if (appMenuBar?.contains(document.activeElement)) { + return this.resizablePaneActive + } + + return ( + document.activeElement.closest(`.${resizableComponentClass}`) !== null + ) + } + + public _appFocusedElementChanged() { + const resizablePaneActive = this.isResizePaneActive() + + if (resizablePaneActive !== this.resizablePaneActive) { + this.resizablePaneActive = resizablePaneActive + this.emitUpdate() + } + } + + public _updateUnderlineLinks(underlineLinks: boolean) { + if (underlineLinks !== this.underlineLinks) { + this.underlineLinks = underlineLinks + setBoolean(underlineLinksKey, underlineLinks) + this.emitUpdate() + } + } + + public _updateShowDiffCheckMarks(showDiffCheckMarks: boolean) { + if (showDiffCheckMarks !== this.showDiffCheckMarks) { + this.showDiffCheckMarks = showDiffCheckMarks + setBoolean(showDiffCheckMarksKey, showDiffCheckMarks) + this.emitUpdate() + } + } } /** @@ -7086,5 +8207,12 @@ function constrain( min = -Infinity, max = Infinity ): IConstrainedValue { - return { value: typeof value === 'number' ? value : value.value, min, max } + // Match CSS's behavior where min-width takes precedence over max-width + // See https://stackoverflow.com/a/16063871 + const constrainedMax = max < min ? min : max + return { + value: typeof value === 'number' ? value : value.value, + min, + max: constrainedMax, + } } diff --git a/app/src/lib/stores/commit-status-store.ts b/app/src/lib/stores/commit-status-store.ts index a43ab95bc1f..506e32f39cd 100644 --- a/app/src/lib/stores/commit-status-store.ts +++ b/app/src/lib/stores/commit-status-store.ts @@ -1,24 +1,24 @@ import pLimit from 'p-limit' import QuickLRU from 'quick-lru' +import { Disposable, DisposableLike } from 'event-kit' +import xor from 'lodash/xor' import { Account } from '../../models/account' -import { AccountsStore } from './accounts-store' import { GitHubRepository } from '../../models/github-repository' import { API, getAccountForEndpoint, IAPICheckSuite } from '../api' -import { DisposableLike, Disposable } from 'event-kit' import { - ICombinedRefCheck, - IRefCheck, - createCombinedCheckFromChecks, apiCheckRunToRefCheck, - getLatestCheckRunsByName, apiStatusToRefCheck, - getLatestPRWorkflowRunsLogsForCheckRun, + createCombinedCheckFromChecks, getCheckRunActionsWorkflowRuns, + getLatestCheckRunsById, + getLatestPRWorkflowRunsLogsForCheckRun, + ICombinedRefCheck, + IRefCheck, manuallySetChecksToPending, } from '../ci-checks/ci-checks' -import _ from 'lodash' import { offsetFromNow } from '../offset-from' +import { AccountsStore } from './accounts-store' interface ICommitStatusCacheEntry { /** @@ -310,9 +310,7 @@ export class CommitStatusStore { } if (checkRuns !== null) { - const latestCheckRunsByName = getLatestCheckRunsByName( - checkRuns.check_runs - ) + const latestCheckRunsByName = getLatestCheckRunsById(checkRuns.check_runs) checks.push(...latestCheckRunsByName.map(apiCheckRunToRefCheck)) } @@ -343,7 +341,7 @@ export class CommitStatusStore { existingChecks !== undefined && existingChecks.check !== null && existingChecks.check.checks.some(c => c.actionsWorkflow !== undefined) && - _.xor( + xor( existingChecks.check.checks.map(cr => cr.id), checks.map(cr => cr.id) ).length === 0 diff --git a/app/src/lib/stores/git-store.ts b/app/src/lib/stores/git-store.ts index caebad503de..2ad88655838 100644 --- a/app/src/lib/stores/git-store.ts +++ b/app/src/lib/stores/git-store.ts @@ -1,5 +1,9 @@ import * as Path from 'path' -import { Repository } from '../../models/repository' +import { + getNonForkGitHubRepository, + isRepositoryWithForkedGitHubRepository, + Repository, +} from '../../models/repository' import { WorkingDirectoryFileChange, AppFileStatusKind, @@ -26,7 +30,6 @@ import { ErrorWithMetadata, IErrorMetadata, } from '../error-with-metadata' -import { compare } from '../../lib/compare' import { queueWorkHigh } from '../../lib/queue-work' import { @@ -63,7 +66,6 @@ import { getAheadBehind, revRange, revSymmetricDifference, - getSymbolicRef, getConfigValue, removeRemote, createTag, @@ -71,6 +73,8 @@ import { deleteTag, MergeResult, createBranch, + updateRemoteHEAD, + getRemoteHEAD, } from '../git' import { GitError as DugiteError } from '../../lib/git' import { GitError } from 'dugite' @@ -82,10 +86,9 @@ import { UpstreamRemoteName, } from './helpers/find-upstream-remote' import { findDefaultRemote } from './helpers/find-default-remote' -import { IAuthor } from '../../models/author' +import { Author, isKnownAuthor } from '../../models/author' import { formatCommitMessage } from '../format-commit-message' import { GitAuthor } from '../../models/git-author' -import { IGitAccount } from '../../models/git-account' import { BaseStore } from './base-store' import { getStashes, getStashedFiles } from '../git/stash' import { IStashEntry, StashedChangesLoadStates } from '../../models/stash-entry' @@ -96,6 +99,7 @@ import { DiffSelection, ITextDiff } from '../../models/diff' import { getDefaultBranch } from '../helpers/default-branch' import { stat } from 'fs/promises' import { findForkedRemotesToPrune } from './helpers/find-forked-remotes-to-prune' +import { findDefaultBranch } from '../find-default-branch' /** The number of commits to load from history per batch. */ const CommitBatchSize = 100 @@ -120,6 +124,8 @@ export class GitStore extends BaseStore { private _defaultBranch: Branch | null = null + private _upstreamDefaultBranch: Branch | null = null + private _localTags: Map | null = null private _allBranches: ReadonlyArray = [] @@ -132,12 +138,14 @@ export class GitStore extends BaseStore { private _showCoAuthoredBy: boolean = false - private _coAuthors: ReadonlyArray = [] + private _coAuthors: ReadonlyArray = [] private _aheadBehind: IAheadBehind | null = null private _tagsToPush: ReadonlyArray = [] + private _remotes: ReadonlyArray = [] + private _defaultRemote: IRemote | null = null private _currentRemote: IRemote | null = null @@ -342,7 +350,7 @@ export class GitStore extends BaseStore { await this.refreshTags() this.addTagToPush(name) - this.statsStore.recordTagCreatedInDesktop() + this.statsStore.increment('tagsCreatedInDesktop') } public async deleteTag(name: string) { @@ -358,7 +366,7 @@ export class GitStore extends BaseStore { await this.refreshTags() this.removeTagToPush(name) - this.statsStore.recordTagDeleted() + this.statsStore.increment('tagsDeleted') } /** The list of ordered SHAs. */ @@ -460,21 +468,35 @@ export class GitStore extends BaseStore { } } - private async refreshDefaultBranch() { - const defaultBranchName = await this.resolveDefaultBranch() + public async refreshDefaultBranch() { + this._defaultBranch = await findDefaultBranch( + this.repository, + this.allBranches, + this.defaultRemote?.name + ) - // Find the default branch among all of our branches, giving - // priority to local branches by sorting them before remotes - this._defaultBranch = - this._allBranches - .filter( - b => - (b.name === defaultBranchName && - b.upstreamWithoutRemote === null) || - b.upstreamWithoutRemote === defaultBranchName - ) - .sort((x, y) => compare(x.type, y.type)) - .shift() || null + // The upstream default branch is only relevant for forked GitHub repos when + // the fork behavior is contributing to the parent. + if ( + !isRepositoryWithForkedGitHubRepository(this.repository) || + getNonForkGitHubRepository(this.repository) === + this.repository.gitHubRepository + ) { + this._upstreamDefaultBranch = null + return + } + + const upstreamDefaultBranch = + (await getRemoteHEAD(this.repository, UpstreamRemoteName)) ?? + getDefaultBranch() + + this._upstreamDefaultBranch = + this._allBranches.find( + b => + b.type === BranchType.Remote && + b.remoteName === UpstreamRemoteName && + b.nameWithoutRemote === upstreamDefaultBranch + ) ?? null } private addTagToPush(tagName: string) { @@ -500,41 +522,6 @@ export class GitStore extends BaseStore { this.emitUpdate() } - /** - * Resolve the default branch name for the current repository, - * using the available API data, remote information or branch - * name conventions. - */ - private async resolveDefaultBranch(): Promise { - const { gitHubRepository } = this.repository - if (gitHubRepository && gitHubRepository.defaultBranch != null) { - return gitHubRepository.defaultBranch - } - - if (this.currentRemote !== null) { - // the Git server should use [remote]/HEAD to advertise - // it's default branch, so see if it exists and matches - // a valid branch on the remote and attempt to use that - const remoteNamespace = `refs/remotes/${this.currentRemote.name}/` - const match = await getSymbolicRef( - this.repository, - `${remoteNamespace}HEAD` - ) - if ( - match != null && - match.length > remoteNamespace.length && - match.startsWith(remoteNamespace) - ) { - // strip out everything related to the remote because this - // is likely to be a tracked branch locally - // e.g. `main`, `develop`, etc - return match.substring(remoteNamespace.length) - } - } - - return getDefaultBranch() - } - private refreshRecentBranches( recentBranchNames: ReadonlyArray | undefined ) { @@ -587,6 +574,15 @@ export class GitStore extends BaseStore { return this._defaultBranch } + /** + * The default branch of the upstream remote in a forked GitHub repository + * with the ForkContributionTarget.Parent behavior, or null if it cannot be + * inferred or is another kind of repository. + */ + public get upstreamDefaultBranch(): Branch | null { + return this._upstreamDefaultBranch + } + /** All branches, including the current branch and the default branch. */ public get allBranches(): ReadonlyArray { return this._allBranches @@ -709,6 +705,32 @@ export class GitStore extends BaseStore { return } + const coAuthorsRestored = await this.restoreCoAuthorsFromCommit(commit) + if (coAuthorsRestored) { + return + } + + this._commitMessage = { + summary: commit.summary, + description: commit.body, + } + this.emitUpdate() + } + + public async prepareToAmendCommit(commit: Commit) { + const coAuthorsRestored = await this.restoreCoAuthorsFromCommit(commit) + if (coAuthorsRestored) { + return + } + + this._commitMessage = { + summary: commit.summary, + description: commit.body, + } + this.emitUpdate() + } + + private async restoreCoAuthorsFromCommit(commit: Commit) { // Let's be safe about this since it's untried waters. // If we can restore co-authors then that's fantastic // but if we can't we shouldn't be throwing an error, @@ -718,17 +740,14 @@ export class GitStore extends BaseStore { try { await this.loadCommitAndCoAuthors(commit) this.emitUpdate() - return + + return true } catch (e) { log.error('Failed to restore commit and co-authors, falling back', e) } } - this._commitMessage = { - summary: commit.summary, - description: commit.body, - } - this.emitUpdate() + return false } /** @@ -770,9 +789,8 @@ export class GitStore extends BaseStore { const lines = unfolded.split('\n') // We don't know (I mean, we're fairly sure) what the separator character - // used for the trailer is so we call out to git to get all possible - // characters. We'll need them in a bit - const separators = await getTrailerSeparatorCharacters(this.repository) + // used for the trailer is so we call out to git to get all possibilities + let separators: string | undefined = undefined // We know that what we've got now is well formed so we can capture the leading // token, followed by the separator char and a single space, followed by the @@ -787,7 +805,14 @@ export class GitStore extends BaseStore { const match = coAuthorRe.exec(line) // Not a trailer line, we're sure of that - if (!match || separators.indexOf(match[1]) === -1) { + if (!match) { + continue + } + + // Only shell out for separators if we really need them + separators ??= await getTrailerSeparatorCharacters(this.repository) + + if (separators.indexOf(match[1]) === -1) { continue } @@ -835,7 +860,7 @@ export class GitStore extends BaseStore { const extractedAuthors = extractedTrailers.map(t => GitAuthor.parse(t.value) ) - const newAuthors = new Array() + const newAuthors = new Array() // Last step, phew! The most likely scenario where we // get called is when someone has just made a commit and @@ -852,10 +877,12 @@ export class GitStore extends BaseStore { } const { name, email } = extractedAuthor - const existing = this.coAuthors.find( - a => a.name === name && a.email === email && a.username !== null + const existing = this.coAuthors + .filter(isKnownAuthor) + .find(a => a.name === name && a.email === email && a.username !== null) + newAuthors.push( + existing || { kind: 'known', name, email, username: null } ) - newAuthors.push(existing || { name, email, username: null }) } this._coAuthors = newAuthors @@ -908,7 +935,7 @@ export class GitStore extends BaseStore { * Gets a list of co-authors to use when crafting the next * commit. */ - public get coAuthors(): ReadonlyArray { + public get coAuthors(): ReadonlyArray { return this._coAuthors } @@ -922,7 +949,6 @@ export class GitStore extends BaseStore { * the overall fetch progress. */ public async fetch( - account: IGitAccount | null, backgroundTask: boolean, progressCallback?: (fetchProgress: IFetchProgress) => void ): Promise { @@ -948,7 +974,6 @@ export class GitStore extends BaseStore { if (remotes.size > 0) { await this.fetchRemotes( - account, [...remotes.values()], backgroundTask, progressCallback @@ -988,7 +1013,6 @@ export class GitStore extends BaseStore { * the overall fetch progress. */ public async fetchRemotes( - account: IGitAccount | null, remotes: ReadonlyArray, backgroundTask: boolean, progressCallback?: (fetchProgress: IFetchProgress) => void @@ -1003,7 +1027,7 @@ export class GitStore extends BaseStore { const remote = remotes[i] const startProgressValue = i * weight - await this.fetchRemote(account, remote, backgroundTask, progress => { + await this.fetchRemote(remote, backgroundTask, progress => { if (progress && progressCallback) { progressCallback({ ...progress, @@ -1024,19 +1048,36 @@ export class GitStore extends BaseStore { * the overall fetch progress. */ public async fetchRemote( - account: IGitAccount | null, remote: IRemote, backgroundTask: boolean, progressCallback?: (fetchProgress: IFetchProgress) => void ): Promise { + const repo = this.repository const retryAction: RetryAction = { type: RetryActionType.Fetch, - repository: this.repository, + repository: repo, } - await this.performFailableOperation( - () => fetchRepo(this.repository, account, remote, progressCallback), + const fetchSucceeded = await this.performFailableOperation( + async () => { + await fetchRepo(repo, remote, progressCallback, backgroundTask) + return true + }, { backgroundTask, retryAction } ) + + // If the pull failed we shouldn't try to update the remote HEAD + // because there's a decent chance that it failed either because we + // didn't have the correct credentials (which we won't this time + // either) or because there's a network error which likely will + // persist for the next operation as well. + if (fetchSucceeded) { + // Updating the local HEAD symref isn't critical so we don't want + // to show an error message to the user and have them retry the + // entire pull operation if it fails. + await updateRemoteHEAD(repo, remote, backgroundTask).catch(e => + log.error('Failed updating remote HEAD', e) + ) + } } /** @@ -1046,18 +1087,14 @@ export class GitStore extends BaseStore { * @param refspec - The association between a remote and local ref to use as * part of this action. Refer to git-scm for more * information on refspecs: https://www.git-scm.com/book/tr/v2/Git-Internals-The-Refspec - * */ - public async fetchRefspec( - account: IGitAccount | null, - refspec: string - ): Promise { + public async fetchRefspec(refspec: string): Promise { // TODO: we should favour origin here const remotes = await getRemotes(this.repository) for (const remote of remotes) { await this.performFailableOperation(() => - fetchRefspec(this.repository, account, remote, refspec) + fetchRefspec(this.repository, remote, refspec) ) } } @@ -1166,6 +1203,10 @@ export class GitStore extends BaseStore { : null } + public get desktopStashEntries(): ReadonlyMap { + return this._desktopStashEntries + } + /** The total number of stash entries */ public get stashEntryCount(): number { return this._stashEntryCount @@ -1220,6 +1261,7 @@ export class GitStore extends BaseStore { public async loadRemotes(): Promise { const remotes = await getRemotes(this.repository) + this._remotes = remotes this._defaultRemote = findDefaultRemote(remotes) const currentRemoteName = @@ -1321,6 +1363,11 @@ export class GitStore extends BaseStore { return this._aheadBehind } + /** The list of configured remotes for the repository */ + public get remotes() { + return this._remotes + } + /** * The remote considered to be the "default" remote in the repository. * @@ -1371,7 +1418,7 @@ export class GitStore extends BaseStore { * * @param coAuthors Zero or more authors */ - public setCoAuthors(coAuthors: ReadonlyArray) { + public setCoAuthors(coAuthors: ReadonlyArray) { this._coAuthors = coAuthors this.emitUpdate() } @@ -1563,11 +1610,10 @@ export class GitStore extends BaseStore { public async revertCommit( repository: Repository, commit: Commit, - account: IGitAccount | null, progressCallback?: (fetchProgress: IRevertProgress) => void ): Promise { await this.performFailableOperation(() => - revertCommit(repository, commit, account, progressCallback) + revertCommit(repository, commit, this.currentRemote, progressCallback) ) this.emitUpdate() @@ -1652,4 +1698,28 @@ export class GitStore extends BaseStore { await removeRemote(this.repository, remote.name) } } + + /** + * Returns the commits associated with merging the comparison branch into the + * base branch. + */ + public async getCommitsBetweenBranches( + baseBranch: Branch, + comparisonBranch: Branch + ): Promise> { + const revisionRange = revRange(baseBranch.name, comparisonBranch.name) + const commits = await this.performFailableOperation(() => + getCommits(this.repository, revisionRange) + ) + + if (commits == null) { + return [] + } + + if (commits.length > 0) { + this.storeCommits(commits) + } + + return commits + } } diff --git a/app/src/lib/stores/helpers/background-fetcher.ts b/app/src/lib/stores/helpers/background-fetcher.ts index 8dd6e468972..964b5a7868b 100644 --- a/app/src/lib/stores/helpers/background-fetcher.ts +++ b/app/src/lib/stores/helpers/background-fetcher.ts @@ -1,8 +1,8 @@ import { Repository } from '../../../models/repository' -import { Account } from '../../../models/account' import { GitHubRepository } from '../../../models/github-repository' -import { API } from '../../api' +import { API, getAccountForEndpoint } from '../../api' import { fatalError } from '../../fatal-error' +import { AccountsStore } from '../accounts-store' /** * A default interval at which to automatically fetch repositories, if the @@ -24,13 +24,6 @@ const SkewUpperBound = 30 * 1000 /** The class which handles doing background fetches of the repository. */ export class BackgroundFetcher { - private readonly repository: Repository - private readonly account: Account - private readonly fetch: (repository: Repository) => Promise - private readonly shouldPerformFetch: ( - repository: Repository - ) => Promise - /** The handle for our setTimeout invocation. */ private timeoutHandle: number | null = null @@ -38,16 +31,13 @@ export class BackgroundFetcher { private stopped = false public constructor( - repository: Repository, - account: Account, - fetch: (repository: Repository) => Promise, - shouldPerformFetch: (repository: Repository) => Promise - ) { - this.repository = repository - this.account = account - this.fetch = fetch - this.shouldPerformFetch = shouldPerformFetch - } + private readonly repository: Repository, + private readonly accountsStore: AccountsStore, + private readonly fetch: (repository: Repository) => Promise, + private readonly shouldPerformFetch: ( + repository: Repository + ) => Promise + ) {} /** Start background fetching. */ public start(withInitialSkew: boolean) { @@ -129,21 +119,29 @@ export class BackgroundFetcher { private async getFetchInterval( repository: GitHubRepository ): Promise { - const api = API.fromAccount(this.account) + const account = getAccountForEndpoint( + await this.accountsStore.getAll(), + repository.endpoint + ) let interval = DefaultFetchInterval - try { - const pollInterval = await api.getFetchPollInterval( - repository.owner.login, - repository.name - ) - if (pollInterval) { - interval = Math.max(pollInterval, MinimumInterval) - } else { - interval = DefaultFetchInterval + + if (account) { + const api = API.fromAccount(account) + + try { + const pollInterval = await api.getFetchPollInterval( + repository.owner.login, + repository.name + ) + if (pollInterval) { + interval = Math.max(pollInterval, MinimumInterval) + } else { + interval = DefaultFetchInterval + } + } catch (e) { + log.error('Error fetching poll interval', e) } - } catch (e) { - log.error('Error fetching poll interval', e) } return interval + skewInterval() diff --git a/app/src/lib/stores/helpers/branch-pruner.ts b/app/src/lib/stores/helpers/branch-pruner.ts index e9ab9b64fc5..33b4b945a79 100644 --- a/app/src/lib/stores/helpers/branch-pruner.ts +++ b/app/src/lib/stores/helpers/branch-pruner.ts @@ -241,7 +241,9 @@ export class BranchPruner { log.info(`[BranchPruner] Branch '${branchName}' marked for deletion`) } } - this.onPruneCompleted(this.repository) + this.onPruneCompleted(this.repository).catch(e => { + log.error(`[BranchPruner] Error calling onPruneCompleted`, e) + }) } } diff --git a/app/src/lib/stores/helpers/create-tutorial-repository.ts b/app/src/lib/stores/helpers/create-tutorial-repository.ts index dea55ed2208..ceacd9e94f5 100644 --- a/app/src/lib/stores/helpers/create-tutorial-repository.ts +++ b/app/src/lib/stores/helpers/create-tutorial-repository.ts @@ -73,7 +73,7 @@ async function pushRepo( const pushOpts = await executionOptionsWithProgress( { - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), }, new PushProgressParser(), progress => { diff --git a/app/src/lib/stores/helpers/repository-indicator-updater.ts b/app/src/lib/stores/helpers/repository-indicator-updater.ts index 85a231b888b..1a911f0ded4 100644 --- a/app/src/lib/stores/helpers/repository-indicator-updater.ts +++ b/app/src/lib/stores/helpers/repository-indicator-updater.ts @@ -125,7 +125,7 @@ export class RepositoryIndicatorUpdater { private clearRefreshTimeout() { if (this.refreshTimeoutId !== null) { - window.clearTimeout() + window.clearTimeout(this.refreshTimeoutId) this.refreshTimeoutId = null } } diff --git a/app/src/lib/stores/helpers/tutorial-assessor.ts b/app/src/lib/stores/helpers/tutorial-assessor.ts index ab52366eb3a..a80af641215 100644 --- a/app/src/lib/stores/helpers/tutorial-assessor.ts +++ b/app/src/lib/stores/helpers/tutorial-assessor.ts @@ -29,6 +29,8 @@ export class OnboardingTutorialAssessor { /** Is the tutorial currently paused? */ private tutorialPaused: boolean = getBoolean(tutorialPausedKey, false) + private tutorialAnnounced: boolean = false + public constructor( /** Method to call when we need to get the current editor */ private getResolvedExternalEditor: () => string | null @@ -61,8 +63,10 @@ export class OnboardingTutorialAssessor { return TutorialStep.PushBranch } else if (!this.pullRequestCreated(repositoryState)) { return TutorialStep.OpenPullRequest - } else { + } else if (!this.tutorialAnnounced) { return TutorialStep.AllDone + } else { + return TutorialStep.Announced } } @@ -145,6 +149,10 @@ export class OnboardingTutorialAssessor { setBoolean(pullRequestStepCompleteKey, this.prStepComplete) } + public markTutorialCompletionAsAnnounced = () => { + this.tutorialAnnounced = true + } + /** * Call when a new tutorial repository is created * diff --git a/app/src/lib/stores/notifications-debug-store.ts b/app/src/lib/stores/notifications-debug-store.ts new file mode 100644 index 00000000000..20355c69236 --- /dev/null +++ b/app/src/lib/stores/notifications-debug-store.ts @@ -0,0 +1,249 @@ +import { shortenSHA } from '../../models/commit' +import { GitHubRepository } from '../../models/github-repository' +import { PullRequest, getPullRequestCommitRef } from '../../models/pull-request' +import { RepositoryWithGitHubRepository } from '../../models/repository' +import { Dispatcher, defaultErrorHandler } from '../../ui/dispatcher' +import { API, APICheckConclusion, IAPIComment } from '../api' +import { showNotification } from '../notifications/show-notification' +import { + isValidNotificationPullRequestReview, + ValidNotificationPullRequestReview, +} from '../valid-notification-pull-request-review' +import { AccountsStore } from './accounts-store' +import { IDesktopChecksFailedAliveEvent } from './alive-store' +import { NotificationsStore } from './notifications-store' +import { PullRequestCoordinator } from './pull-request-coordinator' + +/** + * This class allows the TestNotifications dialog to fetch real data to simulate + * notifications. + */ +export class NotificationsDebugStore { + private cachedComments: Map> = new Map() + private cachedReviews: Map< + number, + ReadonlyArray + > = new Map() + + public constructor( + private readonly accountsStore: AccountsStore, + private readonly notificationsStore: NotificationsStore, + private readonly pullRequestCoordinator: PullRequestCoordinator + ) {} + + private async getAccountForRepository(repository: GitHubRepository) { + const { endpoint } = repository + + const accounts = await this.accountsStore.getAll() + return accounts.find(a => a.endpoint === endpoint) ?? null + } + + private async getAPIForRepository(repository: GitHubRepository) { + const account = await this.getAccountForRepository(repository) + + if (account === null) { + return null + } + + return API.fromAccount(account) + } + + /** Fetch all pull requests for the given repository. */ + public async getPullRequests( + repository: RepositoryWithGitHubRepository, + options: { filterByComments?: boolean; filterByReviews?: boolean } + ) { + const prs = await this.pullRequestCoordinator.getAllPullRequests(repository) + + if (!options.filterByComments && !options.filterByReviews) { + return prs + } + + const filteredPrs = [] + for (const pr of prs) { + if (options.filterByComments) { + const cachedComments = this.cachedComments.get(pr.pullRequestNumber) + + if (cachedComments && cachedComments.length > 0) { + filteredPrs.push(pr) + continue + } + + const comments = await this.getPullRequestComments( + repository, + pr.pullRequestNumber + ) + this.cachedComments.set(pr.pullRequestNumber, comments) + + if (comments.length > 0) { + filteredPrs.push(pr) + } + } + + if (options.filterByReviews) { + const cachedReviews = this.cachedReviews.get(pr.pullRequestNumber) + + if (cachedReviews && cachedReviews.length > 0) { + filteredPrs.push(pr) + continue + } + + const reviews = await this.getPullRequestReviews( + repository, + pr.pullRequestNumber + ) + + this.cachedReviews.set(pr.pullRequestNumber, reviews) + + if (reviews.length > 0) { + filteredPrs.push(pr) + } + } + } + + return filteredPrs + } + + /** Fetch all reviews for the given pull request. */ + public async getPullRequestReviews( + repository: RepositoryWithGitHubRepository, + pullRequestNumber: number + ) { + const cachedReviews = this.cachedReviews.get(pullRequestNumber) + if (cachedReviews) { + return cachedReviews + } + + const api = await this.getAPIForRepository(repository.gitHubRepository) + if (api === null) { + return [] + } + + const ghRepository = repository.gitHubRepository + + const reviews = await api.fetchPullRequestReviews( + ghRepository.owner.login, + ghRepository.name, + pullRequestNumber.toString() + ) + + return reviews.filter(isValidNotificationPullRequestReview) + } + + /** Fetch all comments (issue and review comments) for the given pull request. */ + public async getPullRequestComments( + repository: RepositoryWithGitHubRepository, + pullRequestNumber: number + ) { + const cachedComments = this.cachedComments.get(pullRequestNumber) + if (cachedComments) { + return cachedComments + } + + const api = await this.getAPIForRepository(repository.gitHubRepository) + if (api === null) { + return [] + } + + const ghRepository = repository.gitHubRepository + + const issueComments = await api.fetchIssueComments( + ghRepository.owner.login, + ghRepository.name, + pullRequestNumber.toString() + ) + + const reviewComments = await api.fetchPullRequestComments( + ghRepository.owner.login, + ghRepository.name, + pullRequestNumber.toString() + ) + + return [...issueComments, ...reviewComments] + } + + /** Simulate a notification for the given pull request review. */ + public simulatePullRequestReviewNotification( + repository: GitHubRepository, + pullRequest: PullRequest, + review: ValidNotificationPullRequestReview + ) { + this.notificationsStore.simulateAliveEvent({ + type: 'pr-review-submit', + timestamp: new Date(review.submitted_at).getTime(), + owner: repository.owner.login, + repo: repository.name, + pull_request_number: pullRequest.pullRequestNumber, + state: review.state, + review_id: review.id.toString(), + }) + } + + /** Simulate a notification for the given pull request comment. */ + public simulatePullRequestCommentNotification( + repository: GitHubRepository, + pullRequest: PullRequest, + comment: IAPIComment, + isIssueComment: boolean + ) { + this.notificationsStore.simulateAliveEvent({ + type: 'pr-comment', + subtype: isIssueComment ? 'issue-comment' : 'review-comment', + timestamp: new Date(comment.created_at).getTime(), + owner: repository.owner.login, + repo: repository.name, + pull_request_number: pullRequest.pullRequestNumber, + comment_id: comment.id.toString(), + }) + } + + /** Simulate a notification for pull request checks failure for the given PR. */ + public async simulatePullRequestChecksFailed( + repository: RepositoryWithGitHubRepository, + pullRequest: PullRequest, + dispatcher: Dispatcher + ) { + const commitSha = pullRequest.head.sha + const commitRef = getPullRequestCommitRef(pullRequest.pullRequestNumber) + const checks = await this.notificationsStore.getChecksForRef( + repository.gitHubRepository, + commitRef + ) + + if (!checks) { + defaultErrorHandler(new Error('Could not get checks for PR'), dispatcher) + return + } + + const event: IDesktopChecksFailedAliveEvent = { + type: 'pr-checks-failed', + timestamp: new Date(pullRequest.created).getTime(), + owner: repository.gitHubRepository.owner.login, + repo: repository.name, + pull_request_number: pullRequest.pullRequestNumber, + check_suite_id: checks[0].checkSuiteId ?? 0, + commit_sha: commitSha, + } + + const numberOfFailedChecks = checks.filter( + check => check.conclusion === APICheckConclusion.Failure + ).length + + const pluralChecks = + numberOfFailedChecks === 1 ? 'check was' : 'checks were' + + const shortSHA = shortenSHA(commitSha) + const title = 'Pull Request checks failed' + const body = `${pullRequest.title} #${pullRequest.pullRequestNumber} (${shortSHA})\n${numberOfFailedChecks} ${pluralChecks} not successful.` + const onClick = () => { + dispatcher.onChecksFailedNotification(repository, pullRequest, checks) + } + + showNotification({ + title, + body, + userInfo: event, + onClick, + }) + } +} diff --git a/app/src/lib/stores/notifications-store.ts b/app/src/lib/stores/notifications-store.ts index 5dd5b418da2..60453ae270b 100644 --- a/app/src/lib/stores/notifications-store.ts +++ b/app/src/lib/stores/notifications-store.ts @@ -1,53 +1,59 @@ +import { NotificationCallback } from 'desktop-notifications/dist/notification-callback' +import { Commit, shortenSHA } from '../../models/commit' +import { GitHubRepository } from '../../models/github-repository' +import { PullRequest, getPullRequestCommitRef } from '../../models/pull-request' import { Repository, - isRepositoryWithGitHubRepository, RepositoryWithGitHubRepository, + getForkContributionTarget, + isRepositoryWithForkedGitHubRepository, + isRepositoryWithGitHubRepository, } from '../../models/repository' -import { PullRequest } from '../../models/pull-request' -import { API, APICheckConclusion } from '../api' +import { ForkContributionTarget } from '../../models/workflow-preferences' +import { getVerbForPullRequestReview } from '../../ui/notifications/pull-request-review-helpers' +import { API, APICheckConclusion, IAPIComment } from '../api' import { - createCombinedCheckFromChecks, - getLatestCheckRunsByName, - apiStatusToRefCheck, - apiCheckRunToRefCheck, IRefCheck, + apiCheckRunToRefCheck, + apiStatusToRefCheck, + createCombinedCheckFromChecks, + getLatestCheckRunsById, } from '../ci-checks/ci-checks' -import { AccountsStore } from './accounts-store' import { getCommit } from '../git' -import { GitHubRepository } from '../../models/github-repository' -import { PullRequestCoordinator } from './pull-request-coordinator' -import { Commit } from '../../models/commit' -import { - AliveStore, - DesktopAliveEvent, - IDesktopChecksFailedAliveEvent, - IDesktopPullRequestReviewSubmitAliveEvent, -} from './alive-store' -import { setBoolean, getBoolean } from '../local-storage' +import { getBoolean, setBoolean } from '../local-storage' import { showNotification } from '../notifications/show-notification' import { StatsStore } from '../stats' import { truncateWithEllipsis } from '../truncate-with-ellipsis' -import { getVerbForPullRequestReview } from '../../ui/notifications/pull-request-review-helpers' -import { enablePullRequestReviewNotifications } from '../feature-flag' import { - isValidNotificationPullRequestReview, ValidNotificationPullRequestReview, + isValidNotificationPullRequestReview, } from '../valid-notification-pull-request-review' -import { NotificationCallback } from 'desktop-notifications/dist/notification-callback' +import { AccountsStore } from './accounts-store' +import { + AliveStore, + DesktopAliveEvent, + IDesktopChecksFailedAliveEvent, + IDesktopPullRequestCommentAliveEvent, + IDesktopPullRequestReviewSubmitAliveEvent, +} from './alive-store' +import { PullRequestCoordinator } from './pull-request-coordinator' -type OnChecksFailedCallback = ( +export type OnChecksFailedCallback = ( repository: RepositoryWithGitHubRepository, pullRequest: PullRequest, - commitMessage: string, - commitSha: string, checkRuns: ReadonlyArray ) => void type OnPullRequestReviewSubmitCallback = ( repository: RepositoryWithGitHubRepository, pullRequest: PullRequest, - review: ValidNotificationPullRequestReview, - numberOfComments: number + review: ValidNotificationPullRequestReview +) => void + +type OnPullRequestCommentCallback = ( + repository: RepositoryWithGitHubRepository, + pullRequest: PullRequest, + comment: IAPIComment ) => void /** @@ -67,11 +73,15 @@ export function getNotificationsEnabled() { */ export class NotificationsStore { private repository: RepositoryWithGitHubRepository | null = null + private recentRepositories: ReadonlyArray = [] private onChecksFailedCallback: OnChecksFailedCallback | null = null private onPullRequestReviewSubmitCallback: OnPullRequestReviewSubmitCallback | null = null + private onPullRequestCommentCallback: OnPullRequestCommentCallback | null = + null private cachedCommits: Map = new Map() private skipCommitShas: Set = new Set() + private skipCheckRuns: Set = new Set() public constructor( private readonly accountsStore: AccountsStore, @@ -101,6 +111,12 @@ export class NotificationsStore { public onNotificationEventReceived: NotificationCallback = async (event, id, userInfo) => this.handleAliveEvent(userInfo, true) + public simulateAliveEvent(event: DesktopAliveEvent) { + if (__DEV__ || __RELEASE_CHANNEL__ === 'test') { + this.handleAliveEvent(event, false) + } + } + private async handleAliveEvent( e: DesktopAliveEvent, skipNotification: boolean @@ -110,22 +126,112 @@ export class NotificationsStore { return this.handleChecksFailedEvent(e, skipNotification) case 'pr-review-submit': return this.handlePullRequestReviewSubmitEvent(e, skipNotification) + case 'pr-comment': + return this.handlePullRequestCommentEvent(e, skipNotification) } } - private async handlePullRequestReviewSubmitEvent( - event: IDesktopPullRequestReviewSubmitAliveEvent, + private async handlePullRequestCommentEvent( + event: IDesktopPullRequestCommentAliveEvent, skipNotification: boolean ) { - if (!enablePullRequestReviewNotifications()) { + const repository = this.repository + if (repository === null) { return } + if (!this.isValidRepositoryForEvent(repository, event)) { + if (this.isRecentRepositoryEvent(event)) { + this.statsStore.increment( + 'pullRequestCommentNotificationFromRecentRepoCount' + ) + } else { + this.statsStore.increment( + 'pullRequestCommentNotificationFromNonRecentRepoCount' + ) + } + return + } + + const pullRequests = await this.pullRequestCoordinator.getAllPullRequests( + repository + ) + const pullRequest = pullRequests.find( + pr => pr.pullRequestNumber === event.pull_request_number + ) + + // If the PR is not in cache, it probably means the user didn't work on it + // recently, so we don't want to show a notification. + if (pullRequest === undefined) { + return + } + + // Fetch comment from API depending on event subtype + const api = await this.getAPIForRepository(repository.gitHubRepository) + if (api === null) { + return + } + + const comment = + event.subtype === 'issue-comment' + ? await api.fetchIssueComment(event.owner, event.repo, event.comment_id) + : await api.fetchPullRequestReviewComment( + event.owner, + event.repo, + event.comment_id + ) + + if (comment === null) { + return + } + + const title = `@${comment.user.login} commented on your pull request` + const body = `${pullRequest.title} #${ + pullRequest.pullRequestNumber + }\n${truncateWithEllipsis(comment.body, 50)}` + const onClick = () => { + this.statsStore.increment('pullRequestCommentNotificationClicked') + + this.onPullRequestCommentCallback?.(repository, pullRequest, comment) + } + + if (skipNotification) { + onClick() + return + } + + showNotification({ + title, + body, + userInfo: event, + onClick, + }) + + this.statsStore.increment('pullRequestCommentNotificationCount') + } + + private async handlePullRequestReviewSubmitEvent( + event: IDesktopPullRequestReviewSubmitAliveEvent, + skipNotification: boolean + ) { const repository = this.repository if (repository === null) { return } + if (!this.isValidRepositoryForEvent(repository, event)) { + if (this.isRecentRepositoryEvent(event)) { + this.statsStore.increment( + 'pullRequestReviewNotificationFromRecentRepoCount' + ) + } else { + this.statsStore.increment( + 'pullRequestReviewNotificationFromNonRecentRepoCount' + ) + } + return + } + const pullRequests = await this.pullRequestCoordinator.getAllPullRequests( repository ) @@ -139,16 +245,17 @@ export class NotificationsStore { return } - const { gitHubRepository } = repository - const api = await this.getAPIForRepository(gitHubRepository) + // PR reviews must be retrieved from the repository the PR belongs to + const pullsRepository = this.getContributingRepository(repository) + const api = await this.getAPIForRepository(pullsRepository) if (api === null) { return } const review = await api.fetchPullRequestReview( - gitHubRepository.owner.login, - gitHubRepository.name, + pullsRepository.owner.login, + pullsRepository.name, pullRequest.pullRequestNumber.toString(), event.review_id ) @@ -165,12 +272,7 @@ export class NotificationsStore { const onClick = () => { this.statsStore.recordPullRequestReviewNotificationClicked(review.state) - this.onPullRequestReviewSubmitCallback?.( - repository, - pullRequest, - review, - event.number_of_comments - ) + this.onPullRequestReviewSubmitCallback?.(repository, pullRequest, review) } if (skipNotification) { @@ -197,6 +299,17 @@ export class NotificationsStore { return } + if (!this.isValidRepositoryForEvent(repository, event)) { + if (this.isRecentRepositoryEvent(event)) { + this.statsStore.increment('checksFailedNotificationFromRecentRepoCount') + } else { + this.statsStore.increment( + 'checksFailedNotificationFromNonRecentRepoCount' + ) + } + return + } + const pullRequests = await this.pullRequestCoordinator.getAllPullRequests( repository ) @@ -239,11 +352,29 @@ export class NotificationsStore { return } - const checks = await this.getChecksForRef(repository, pullRequest.head.ref) + // Checks must be retrieved from the repository the PR belongs to + const checksRepository = this.getContributingRepository(repository) + + const checks = await this.getChecksForRef( + checksRepository, + getPullRequestCommitRef(pullRequest.pullRequestNumber) + ) if (checks === null) { return } + // Make sure we haven't shown a notification for the check runs of this + // check suite already. + // If one of more jobs are re-run, the check suite will have the same ID + // but different check runs. + const checkSuiteCheckRunIds = checks.flatMap(check => + check.checkSuiteId === event.check_suite_id ? check.id : [] + ) + + if (checkSuiteCheckRunIds.every(id => this.skipCheckRuns.has(id))) { + return + } + const numberOfFailedChecks = checks.filter( check => check.conclusion === APICheckConclusion.Failure ).length @@ -255,22 +386,22 @@ export class NotificationsStore { return } + // Ignore any remaining notification for check runs that started along + // with this one. + for (const check of checks) { + this.skipCheckRuns.add(check.id) + } + const pluralChecks = numberOfFailedChecks === 1 ? 'check was' : 'checks were' - const shortSHA = commitSHA.slice(0, 9) + const shortSHA = shortenSHA(commitSHA) const title = 'Pull Request checks failed' const body = `${pullRequest.title} #${pullRequest.pullRequestNumber} (${shortSHA})\n${numberOfFailedChecks} ${pluralChecks} not successful.` const onClick = () => { - this.statsStore.recordChecksFailedNotificationClicked() - - this.onChecksFailedCallback?.( - repository, - pullRequest, - commit.summary, - commitSHA, - checks - ) + this.statsStore.increment('checksFailedNotificationClicked') + + this.onChecksFailedCallback?.(repository, pullRequest, checks) } if (skipNotification) { @@ -285,7 +416,51 @@ export class NotificationsStore { onClick, }) - this.statsStore.recordChecksFailedNotificationShown() + this.statsStore.increment('checksFailedNotificationCount') + } + + private getContributingRepository( + repository: RepositoryWithGitHubRepository + ) { + const isForkContributingToParent = + isRepositoryWithForkedGitHubRepository(repository) && + getForkContributionTarget(repository) === ForkContributionTarget.Parent + + return isForkContributingToParent + ? repository.gitHubRepository.parent + : repository.gitHubRepository + } + + private isValidRepositoryForEvent( + repository: RepositoryWithGitHubRepository, + event: DesktopAliveEvent + ) { + // If it's a fork and set to contribute to the parent repository, try to + // match the parent repository. + if ( + isRepositoryWithForkedGitHubRepository(repository) && + getForkContributionTarget(repository) === ForkContributionTarget.Parent + ) { + const parentRepository = repository.gitHubRepository.parent + return ( + parentRepository.owner.login === event.owner && + parentRepository.name === event.repo + ) + } + + const ghRepository = repository.gitHubRepository + return ( + ghRepository.owner.login === event.owner && + ghRepository.name === event.repo + ) + } + + private isRecentRepositoryEvent(event: DesktopAliveEvent) { + return this.recentRepositories.some( + r => + isRepositoryWithGitHubRepository(r) && + this.isValidRepositoryForEvent(r, event) + ) } /** @@ -293,9 +468,29 @@ export class NotificationsStore { * notifications for the currently selected repository will be shown. */ public selectRepository(repository: Repository) { + if (repository.hash === this.repository?.hash) { + return + } + this.repository = isRepositoryWithGitHubRepository(repository) ? repository : null + this.resetCache() + } + + private resetCache() { + this.cachedCommits.clear() + this.skipCommitShas.clear() + this.skipCheckRuns.clear() + } + + /** + * For stats purposes, we need to know which are the recent repositories. This + * will allow the notification store when a notification is related to one of + * these repositories. + */ + public setRecentRepositories(repositories: ReadonlyArray) { + this.recentRepositories = repositories } private async getAccountForRepository(repository: GitHubRepository) { @@ -315,22 +510,20 @@ export class NotificationsStore { return API.fromAccount(account) } - private async getChecksForRef( - repository: RepositoryWithGitHubRepository, - ref: string - ) { - const { gitHubRepository } = repository - const { owner, name } = gitHubRepository + public async getChecksForRef(repository: GitHubRepository, ref: string) { + const { owner, name } = repository - const api = await this.getAPIForRepository(gitHubRepository) + const api = await this.getAPIForRepository(repository) if (api === null) { return null } + // Hit these API endpoints reloading the cache to make sure we have the + // latest data at the time the notification is received. const [statuses, checkRuns] = await Promise.all([ - api.fetchCombinedRefStatus(owner.login, name, ref), - api.fetchRefCheckRuns(owner.login, name, ref), + api.fetchCombinedRefStatus(owner.login, name, ref, true), + api.fetchRefCheckRuns(owner.login, name, ref, true), ]) const checks = new Array() @@ -344,9 +537,7 @@ export class NotificationsStore { } if (checkRuns !== null) { - const latestCheckRunsByName = getLatestCheckRunsByName( - checkRuns.check_runs - ) + const latestCheckRunsByName = getLatestCheckRunsById(checkRuns.check_runs) checks.push(...latestCheckRunsByName.map(apiCheckRunToRefCheck)) } @@ -370,4 +561,11 @@ export class NotificationsStore { ) { this.onPullRequestReviewSubmitCallback = callback } + + /** Observe when the user reacted to a "PR comment" notification. */ + public onPullRequestCommentNotification( + callback: OnPullRequestCommentCallback + ) { + this.onPullRequestCommentCallback = callback + } } diff --git a/app/src/lib/stores/pull-request-coordinator.ts b/app/src/lib/stores/pull-request-coordinator.ts index 3898757abeb..6bd4c6c3f56 100644 --- a/app/src/lib/stores/pull-request-coordinator.ts +++ b/app/src/lib/stores/pull-request-coordinator.ts @@ -80,7 +80,6 @@ export class PullRequestCoordinator { * the `Repository`) * * the parent GitHub repo, if the `Repository` has one (the * `upstream` remote for the `Repository`) - * */ public onPullRequestsChanged( fn: ( @@ -119,7 +118,6 @@ export class PullRequestCoordinator { * the `Repository`) * * the parent GitHub repo, if the `Repository` has one (the * `upstream` remote for the `Repository`) - * */ public onIsLoadingPullRequests( fn: ( diff --git a/app/src/lib/stores/repositories-store.ts b/app/src/lib/stores/repositories-store.ts index 466d70c9203..8ecf70fa22e 100644 --- a/app/src/lib/stores/repositories-store.ts +++ b/app/src/lib/stores/repositories-store.ts @@ -28,7 +28,6 @@ import { WorkflowPreferences } from '../../models/workflow-preferences' import { clearTagsToPush } from './helpers/tags-to-push-storage' import { IMatchedGitHubRepository } from '../repository-matching' import { shallowEquals } from '../equality' -import { enableRepositoryAliases } from '../feature-flag' type AddRepositoryOptions = { missing?: boolean @@ -130,7 +129,6 @@ export class RepositoriesStore extends TypedBaseStore< repo.id, repo.private, repo.htmlURL, - repo.defaultBranch, repo.cloneURL, repo.issuesEnabled, repo.isArchived, @@ -152,7 +150,7 @@ export class RepositoriesStore extends TypedBaseStore< ? await this.findGitHubRepositoryByID(repo.gitHubRepositoryID) : await Promise.resolve(null), // Dexie gets confused if we return null repo.missing, - enableRepositoryAliases() ? repo.alias : null, + repo.alias, repo.workflowPreferences, repo.isTutorialRepository ) @@ -449,7 +447,6 @@ export class RepositoriesStore extends TypedBaseStore< const skeletonRepo: IDatabaseGitHubRepository = { cloneURL: null, - defaultBranch: null, htmlURL: null, lastPruneDate: null, name: match.name, @@ -557,7 +554,6 @@ export class RepositoriesStore extends TypedBaseStore< name: gitHubRepository.name, private: gitHubRepository.private, htmlURL: gitHubRepository.html_url, - defaultBranch: gitHubRepository.default_branch, cloneURL: gitHubRepository.clone_url, parentID, lastPruneDate: existingRepo?.lastPruneDate ?? null, diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts index d7e2586657b..04e37e0faaa 100644 --- a/app/src/lib/stores/repository-state-cache.ts +++ b/app/src/lib/stores/repository-state-cache.ts @@ -18,14 +18,19 @@ import { ChangesSelectionKind, IMultiCommitOperationUndoState, IMultiCommitOperationState, + IPullRequestState, } from '../app-state' import { merge } from '../merge' import { DefaultCommitMessage } from '../../models/commit-message' import { sendNonFatalException } from '../helpers/non-fatal-exception' +import { StatsStore } from '../stats' +import { RepoRulesInfo } from '../../models/repo-rules' export class RepositoryStateCache { private readonly repositoryState = new Map() + public constructor(private readonly statsStore: StatsStore) {} + /** Get the state for the repository. */ public get(repository: Repository): IRepositoryState { const existing = this.repositoryState.get(repository.hash) @@ -84,10 +89,47 @@ export class RepositoryStateCache { this.update(repository, state => { const changesState = state.changesState const newState = merge(changesState, fn(changesState)) + this.recordSubmoduleDiffViewedFromChangesListIfNeeded( + changesState, + newState + ) return { changesState: newState } }) } + private recordSubmoduleDiffViewedFromChangesListIfNeeded( + oldState: IChangesState, + newState: IChangesState + ) { + // Make sure only one file is selected from the current commit + if ( + newState.selection.kind !== ChangesSelectionKind.WorkingDirectory || + newState.selection.selectedFileIDs.length !== 1 + ) { + return + } + + const newFile = newState.workingDirectory.findFileWithID( + newState.selection.selectedFileIDs[0] + ) + + // Make sure that file is a submodule + if (newFile === null || newFile.status.submoduleStatus === undefined) { + return + } + + // If the old state was also a submodule, make sure it's a different one + if ( + oldState.selection.kind === ChangesSelectionKind.WorkingDirectory && + oldState.selection.selectedFileIDs.length === 1 && + oldState.selection.selectedFileIDs[0] === newFile.id + ) { + return + } + + this.statsStore.increment('submoduleDiffViewedFromChangesListCount') + } + public updateCommitSelection( repository: Repository, fn: (state: ICommitSelection) => Pick @@ -95,10 +137,28 @@ export class RepositoryStateCache { this.update(repository, state => { const { commitSelection } = state const newState = merge(commitSelection, fn(commitSelection)) + this.recordSubmoduleDiffViewedFromHistoryIfNeeded( + commitSelection, + newState + ) return { commitSelection: newState } }) } + private recordSubmoduleDiffViewedFromHistoryIfNeeded( + oldState: ICommitSelection, + newState: ICommitSelection + ) { + // Just detect when the app is gonna show the diff of a different submodule + // and record that in the stats. + if ( + oldState.file?.id !== newState.file?.id && + newState.file?.status.submoduleStatus !== undefined + ) { + this.statsStore.increment('submoduleDiffViewedFromHistoryCount') + } + } + public updateBranchesState( repository: Repository, fn: (branchesState: IBranchesState) => Pick @@ -172,12 +232,71 @@ export class RepositoryStateCache { return { multiCommitOperationState: null } }) } + + public initializePullRequestState( + repository: Repository, + pullRequestState: IPullRequestState | null + ) { + this.update(repository, () => { + return { pullRequestState } + }) + } + + private sendPullRequestStateNotExistsException() { + sendNonFatalException( + 'PullRequestState', + new Error(`Cannot update a null pull request state`) + ) + } + + public updatePullRequestState( + repository: Repository, + fn: (pullRequestState: IPullRequestState) => Pick + ) { + const { pullRequestState } = this.get(repository) + if (pullRequestState === null) { + this.sendPullRequestStateNotExistsException() + return + } + + this.update(repository, state => { + const oldState = state.pullRequestState + const pullRequestState = + oldState === null ? null : merge(oldState, fn(oldState)) + return { pullRequestState } + }) + } + + public updatePullRequestCommitSelection( + repository: Repository, + fn: (prCommitSelection: ICommitSelection) => Pick + ) { + const { pullRequestState } = this.get(repository) + if (pullRequestState === null) { + this.sendPullRequestStateNotExistsException() + return + } + + const oldState = pullRequestState.commitSelection + const commitSelection = + oldState === null ? null : merge(oldState, fn(oldState)) + this.updatePullRequestState(repository, () => ({ + commitSelection, + })) + } + + public clearPullRequestState(repository: Repository) { + this.update(repository, () => { + return { pullRequestState: null } + }) + } } function getInitialRepositoryState(): IRepositoryState { return { commitSelection: { shas: [], + shasInDiff: [], isContiguous: true, file: null, changesetData: { files: [], linesAdded: 0, linesDeleted: 0 }, @@ -198,11 +317,13 @@ function getInitialRepositoryState(): IRepositoryState { conflictState: null, stashEntry: null, currentBranchProtected: false, + currentRepoRulesInfo: new RepoRulesInfo(), }, selectedSection: RepositorySectionTab.Changes, branchesState: { tip: { kind: TipState.Unknown }, defaultBranch: null, + upstreamDefaultBranch: null, allBranches: new Array(), recentBranches: new Array(), openPullRequests: new Array(), @@ -219,10 +340,12 @@ function getInitialRepositoryState(): IRepositoryState { showBranchList: false, filterText: '', commitSHAs: [], + shasToHighlight: [], branches: new Array(), recentBranches: new Array(), defaultBranch: null, }, + pullRequestState: null, commitAuthor: null, commitLookup: new Map(), localCommitSHAs: [], diff --git a/app/src/lib/stores/sign-in-store.ts b/app/src/lib/stores/sign-in-store.ts index cd981ad7666..fa243b2145a 100644 --- a/app/src/lib/stores/sign-in-store.ts +++ b/app/src/lib/stores/sign-in-store.ts @@ -1,7 +1,6 @@ import { Disposable } from 'event-kit' import { Account } from '../../models/account' -import { assertNever, fatalError } from '../fatal-error' -import { askUserToOAuth } from '../../lib/oauth' +import { fatalError } from '../fatal-error' import { validateURL, InvalidURLErrorName, @@ -9,27 +8,19 @@ import { } from '../../ui/lib/enterprise-validate-url' import { - createAuthorization, - AuthorizationResponse, fetchUser, - AuthorizationResponseKind, - getHTMLURL, getDotComAPIEndpoint, getEnterpriseAPIURL, - fetchMetadata, + requestOAuthToken, + getOAuthAuthorizationURL, } from '../../lib/api' -import { AuthenticationMode } from '../../lib/2fa' - -import { minimumSupportedEnterpriseVersion } from '../../lib/enterprise' import { TypedBaseStore } from './base-store' -import { timeout } from '../promise' - -function getUnverifiedUserErrorMessage(login: string): string { - return `Unable to authenticate. The account ${login} is lacking a verified email address. Please sign in to GitHub.com, confirm your email address in the Emails section under Personal settings, and try again.` -} - -const EnterpriseTooOldMessage = `The GitHub Enterprise version does not support GitHub Desktop. Talk to your server's administrator about upgrading to the latest version of GitHub Enterprise.` +import uuid from 'uuid' +import { IOAuthAction } from '../parse-app-url' +import { shell } from '../app-shell' +import noop from 'lodash/noop' +import { AccountsStore } from './accounts-store' /** * An enumeration of the possible steps that the sign in @@ -37,6 +28,7 @@ const EnterpriseTooOldMessage = `The GitHub Enterprise version does not support */ export enum SignInStep { EndpointEntry = 'EndpointEntry', + ExistingAccountWarning = 'ExistingAccountWarning', Authentication = 'Authentication', TwoFactorAuthentication = 'TwoFactorAuthentication', Success = 'Success', @@ -48,8 +40,8 @@ export enum SignInStep { */ export type SignInState = | IEndpointEntryState + | IExistingAccountWarning | IAuthenticationState - | ITwoFactorAuthenticationState | ISuccessState /** @@ -76,6 +68,8 @@ export interface ISignInState { * sign in process is ongoing. */ readonly loading: boolean + + readonly resultCallback: (result: SignInResult) => void } /** @@ -83,20 +77,8 @@ export interface ISignInState { * This is the initial step in the Enterprise sign in * flow and is not present when signing in to GitHub.com */ -export interface IEndpointEntryState extends ISignInState { - readonly kind: SignInStep.EndpointEntry -} - -/** - * State interface representing the Authentication step where - * the user provides credentials and/or initiates a browser - * OAuth sign in process. This step occurs as the first step - * when signing in to GitHub.com and as the second step when - * signing in to a GitHub Enterprise instance. - */ -export interface IAuthenticationState extends ISignInState { - readonly kind: SignInStep.Authentication - +export interface IExistingAccountWarning extends ISignInState { + readonly kind: SignInStep.ExistingAccountWarning /** * The URL to the host which we're currently authenticating * against. This will be either https://api.github.com when @@ -104,31 +86,31 @@ export interface IAuthenticationState extends ISignInState { * URL when signing in against a GitHub Enterprise * instance. */ + readonly existingAccount: Account readonly endpoint: string - /** - * A value indicating whether or not the endpoint supports - * basic authentication (i.e. username and password). All - * GitHub Enterprise instances support OAuth (or web - * flow sign-in). - */ - readonly supportsBasicAuth: boolean + readonly resultCallback: (result: SignInResult) => void +} - /** - * The endpoint-specific URL for resetting credentials. - */ - readonly forgotPasswordUrl: string +/** + * State interface representing the endpoint entry step. + * This is the initial step in the Enterprise sign in + * flow and is not present when signing in to GitHub.com + */ +export interface IEndpointEntryState extends ISignInState { + readonly kind: SignInStep.EndpointEntry + readonly resultCallback: (result: SignInResult) => void } /** - * State interface representing the TwoFactorAuthentication - * step where the user provides an OTP token. This step - * occurs after the authentication step both for GitHub.com, - * and GitHub Enterprise when the user has enabled two - * factor authentication on the host. + * State interface representing the Authentication step where + * the user provides credentials and/or initiates a browser + * OAuth sign in process. This step occurs as the first step + * when signing in to GitHub.com and as the second step when + * signing in to a GitHub Enterprise instance. */ -export interface ITwoFactorAuthenticationState extends ISignInState { - readonly kind: SignInStep.TwoFactorAuthentication +export interface IAuthenticationState extends ISignInState { + readonly kind: SignInStep.Authentication /** * The URL to the host which we're currently authenticating @@ -139,22 +121,14 @@ export interface ITwoFactorAuthenticationState extends ISignInState { */ readonly endpoint: string - /** - * The username specified by the user in the preceding - * Authentication step - */ - readonly username: string - - /** - * The password specified by the user in the preceding - * Authentication step - */ - readonly password: string + readonly resultCallback: (result: SignInResult) => void - /** - * The 2FA type expected by the GitHub endpoint. - */ - readonly type: AuthenticationMode + readonly oauthState?: { + state: string + endpoint: string + onAuthCompleted: (account: Account) => void + onAuthError: (error: Error) => void + } } /** @@ -165,31 +139,16 @@ export interface ITwoFactorAuthenticationState extends ISignInState { */ export interface ISuccessState { readonly kind: SignInStep.Success -} - -/** - * The method used to authenticate a user. - */ -export enum SignInMethod { - /** - * In-app sign-in with username, password, and possibly a - * two-factor code. - */ - Basic = 'basic', - /** - * Sign-in through a web browser with a redirect back to - * the application. - */ - Web = 'web', + readonly resultCallback: (result: SignInResult) => void } interface IAuthenticationEvent { readonly account: Account - readonly method: SignInMethod } -/** The maximum time to wait for a `/meta` API call in milliseconds */ -const ServerMetaDataTimeout = 2000 +export type SignInResult = + | { kind: 'success'; account: Account } + | { kind: 'cancelled' } /** * A store encapsulating all logic related to signing in a user @@ -197,29 +156,35 @@ const ServerMetaDataTimeout = 2000 */ export class SignInStore extends TypedBaseStore { private state: SignInState | null = null - /** - * A map keyed on an endpoint url containing the last known - * value of the verifiable_password_authentication meta property - * for that endpoint. - */ - private endpointSupportBasicAuth = new Map() - private emitAuthenticate(account: Account, method: SignInMethod) { - const event: IAuthenticationEvent = { account, method } + private accounts: ReadonlyArray = [] + + public constructor(private readonly accountStore: AccountsStore) { + super() + + this.accountStore.getAll().then(accounts => { + this.accounts = accounts + }) + this.accountStore.onDidUpdate(accounts => { + this.accounts = accounts + }) + } + + private emitAuthenticate(account: Account) { + const event: IAuthenticationEvent = { account } this.emitter.emit('did-authenticate', event) + this.state?.resultCallback({ kind: 'success', account }) } /** * Registers an event handler which will be invoked whenever * a user has successfully completed a sign-in process. */ - public onDidAuthenticate( - fn: (account: Account, method: SignInMethod) => void - ): Disposable { + public onDidAuthenticate(fn: (account: Account) => void): Disposable { return this.emitter.on( 'did-authenticate', - ({ account, method }: IAuthenticationEvent) => { - fn(account, method) + ({ account }: IAuthenticationEvent) => { + fn(account) } ) } @@ -241,250 +206,159 @@ export class SignInStore extends TypedBaseStore { this.emitUpdate(this.getState()) } - private async endpointSupportsBasicAuth(endpoint: string): Promise { - if (endpoint === getDotComAPIEndpoint()) { - return false - } - - const cached = this.endpointSupportBasicAuth.get(endpoint) - const fallbackValue = - cached === undefined - ? null - : { verifiable_password_authentication: cached } - - const response = await timeout( - fetchMetadata(endpoint), - ServerMetaDataTimeout, - fallbackValue - ) - - if (response !== null) { - const supportsBasicAuth = - response.verifiable_password_authentication === true - this.endpointSupportBasicAuth.set(endpoint, supportsBasicAuth) - - return supportsBasicAuth - } - - throw new Error( - `Unable to authenticate with the GitHub Enterprise instance. Verify that the URL is correct, that your GitHub Enterprise instance is running version ${minimumSupportedEnterpriseVersion} or later, that you have an internet connection and try again.` - ) - } - - private getForgotPasswordURL(endpoint: string): string { - return `${getHTMLURL(endpoint)}/password_reset` - } - /** * Clear any in-flight sign in state and return to the * initial (no sign-in) state. */ public reset() { + const currentState = this.state + this.state?.resultCallback({ kind: 'cancelled' }) this.setState(null) + + if (currentState?.kind === SignInStep.Authentication) { + currentState.oauthState?.onAuthError(new Error('cancelled')) + } } /** * Initiate a sign in flow for github.com. This will put the store * in the Authentication step ready to receive user credentials. */ - public beginDotComSignIn() { + public beginDotComSignIn(resultCallback?: (result: SignInResult) => void) { const endpoint = getDotComAPIEndpoint() - this.setState({ - kind: SignInStep.Authentication, - endpoint, - supportsBasicAuth: false, - error: null, - loading: false, - forgotPasswordUrl: this.getForgotPasswordURL(endpoint), - }) + if (this.state !== null) { + this.reset() + } - // Asynchronously refresh our knowledge about whether GitHub.com - // support username and password authentication or not. - this.endpointSupportsBasicAuth(endpoint) - .then(supportsBasicAuth => { - if ( - this.state !== null && - this.state.kind === SignInStep.Authentication && - this.state.endpoint === endpoint - ) { - this.setState({ ...this.state, supportsBasicAuth }) - } + const existingAccount = this.accounts.find( + x => x.endpoint === getDotComAPIEndpoint() + ) + + if (existingAccount) { + this.setState({ + kind: SignInStep.ExistingAccountWarning, + endpoint, + existingAccount, + error: null, + loading: false, + resultCallback: resultCallback ?? noop, }) - .catch(err => - log.error( - 'Failed resolving whether GitHub.com supports password authentication', - err - ) - ) + } else { + this.setState({ + kind: SignInStep.Authentication, + endpoint, + error: null, + loading: false, + resultCallback: resultCallback ?? noop, + }) + } } /** - * Attempt to advance from the authentication step using a username - * and password. This method must only be called when the store is - * in the authentication step or an error will be thrown. If the - * provided credentials are valid the store will either advance to - * the Success step or to the TwoFactorAuthentication step if the - * user has enabled two factor authentication. - * - * If an error occurs during sign in (such as invalid credentials) - * the authentication state will be updated with that error so that - * the responsible component can present it to the user. + * Initiate an OAuth sign in using the system configured browser. + * This method must only be called when the store is in the authentication + * step or an error will be thrown. */ - public async authenticateWithBasicAuth( - username: string, - password: string - ): Promise { + public async authenticateWithBrowser() { const currentState = this.state - if (!currentState || currentState.kind !== SignInStep.Authentication) { + if ( + currentState?.kind !== SignInStep.Authentication && + currentState?.kind !== SignInStep.ExistingAccountWarning + ) { const stepText = currentState ? currentState.kind : 'null' return fatalError( - `Sign in step '${stepText}' not compatible with authentication` + `Sign in step '${stepText}' not compatible with browser authentication` ) } - const endpoint = currentState.endpoint - this.setState({ ...currentState, loading: true }) - let response: AuthorizationResponse - try { - response = await createAuthorization(endpoint, username, password, null) - } catch (e) { - this.emitError(e) - return - } - - if (!this.state || this.state.kind !== SignInStep.Authentication) { - // Looks like the sign in flow has been aborted - return + if (currentState.kind === SignInStep.ExistingAccountWarning) { + const { existingAccount } = currentState + // Try to avoid emitting an error out of AccountsStore if the account + // is already gone. + if (this.accounts.find(x => x.endpoint === existingAccount.endpoint)) { + await this.accountStore.removeAccount(existingAccount) + } } - if (response.kind === AuthorizationResponseKind.Authorized) { - const token = response.token - const user = await fetchUser(endpoint, token) + const csrfToken = uuid() - if (!this.state || this.state.kind !== SignInStep.Authentication) { - // Looks like the sign in flow has been aborted - return - } - - this.emitAuthenticate(user, SignInMethod.Basic) - this.setState({ kind: SignInStep.Success }) - } else if ( - response.kind === - AuthorizationResponseKind.TwoFactorAuthenticationRequired - ) { + new Promise((resolve, reject) => { + const { endpoint, resultCallback } = currentState + log.info('[SignInStore] initializing OAuth flow') this.setState({ - kind: SignInStep.TwoFactorAuthentication, + kind: SignInStep.Authentication, endpoint, - username, - password, - type: response.type, + resultCallback, error: null, - loading: false, + loading: true, + oauthState: { + state: csrfToken, + endpoint, + onAuthCompleted: resolve, + onAuthError: reject, + }, }) - } else { - if (response.kind === AuthorizationResponseKind.Error) { - this.emitError( - new Error( - `The server responded with an error while attempting to authenticate (${response.response.status})\n\n${response.response.statusText}` - ) - ) - this.setState({ ...currentState, loading: false }) - } else if (response.kind === AuthorizationResponseKind.Failed) { - if (username.includes('@')) { - this.setState({ - ...currentState, - loading: false, - error: new Error('Incorrect email or password.'), - }) - } else { - this.setState({ - ...currentState, - loading: false, - error: new Error('Incorrect username or password.'), - }) + shell.openExternal(getOAuthAuthorizationURL(endpoint, csrfToken)) + }) + .then(account => { + if (!this.state || this.state.kind !== SignInStep.Authentication) { + // Looks like the sign in flow has been aborted + log.warn('[SignInStore] account resolved but session has changed') + return } - } else if ( - response.kind === AuthorizationResponseKind.UserRequiresVerification - ) { - this.setState({ - ...currentState, - loading: false, - error: new Error(getUnverifiedUserErrorMessage(username)), - }) - } else if ( - response.kind === AuthorizationResponseKind.PersonalAccessTokenBlocked - ) { - this.setState({ - ...currentState, - loading: false, - error: new Error( - 'A personal access token cannot be used to login to GitHub Desktop.' - ), - }) - } else if (response.kind === AuthorizationResponseKind.EnterpriseTooOld) { - this.setState({ - ...currentState, - loading: false, - error: new Error(EnterpriseTooOldMessage), - }) - } else if (response.kind === AuthorizationResponseKind.WebFlowRequired) { + + log.info('[SignInStore] account resolved') + this.emitAuthenticate(account) this.setState({ - ...currentState, - loading: false, - supportsBasicAuth: false, - kind: SignInStep.Authentication, + kind: SignInStep.Success, + resultCallback: this.state.resultCallback, }) - } else { - return assertNever(response, `Unsupported response: ${response}`) - } - } + }) + .catch(e => { + // Make sure we're still in the same sign in session + if ( + this.state?.kind === SignInStep.Authentication && + this.state.oauthState?.state === csrfToken + ) { + log.info('[SignInStore] error with OAuth flow', e) + this.setState({ ...this.state, error: e, loading: false }) + } else { + log.info(`[SignInStore] OAuth error but session has changed: ${e}`) + } + }) } - /** - * Initiate an OAuth sign in using the system configured browser. - * This method must only be called when the store is in the authentication - * step or an error will be thrown. - * - * The promise returned will only resolve once the user has successfully - * authenticated. If the user terminates the sign-in process by closing - * their browser before the protocol handler is invoked, by denying the - * protocol handler to execute or by providing the wrong credentials - * this promise will never complete. - */ - public async authenticateWithBrowser(): Promise { - const currentState = this.state - - if (!currentState || currentState.kind !== SignInStep.Authentication) { - const stepText = currentState ? currentState.kind : 'null' - return fatalError( - `Sign in step '${stepText}' not compatible with browser authentication` - ) + public async resolveOAuthRequest(action: IOAuthAction) { + if (!this.state || this.state.kind !== SignInStep.Authentication) { + return } - this.setState({ ...currentState, loading: true }) - - let account: Account - try { - log.info('[SignInStore] initializing OAuth flow') - account = await askUserToOAuth(currentState.endpoint) - log.info('[SignInStore] account resolved') - } catch (e) { - log.info('[SignInStore] error with OAuth flow', e) - this.setState({ ...currentState, error: e, loading: false }) + if (!this.state.oauthState) { return } - if (!this.state || this.state.kind !== SignInStep.Authentication) { - // Looks like the sign in flow has been aborted + if (this.state.oauthState.state !== action.state) { + log.warn( + 'requestAuthenticatedUser was not called with valid OAuth state. This is likely due to a browser reloading the callback URL. Contact GitHub Support if you believe this is an error' + ) return } - this.emitAuthenticate(account, SignInMethod.Web) - this.setState({ kind: SignInStep.Success }) + const { endpoint } = this.state + const token = await requestOAuthToken(endpoint, action.code) + + if (token) { + const account = await fetchUser(endpoint, token) + this.state.oauthState.onAuthCompleted(account) + } else { + this.state.oauthState.onAuthError( + new Error('Failed retrieving authenticated user') + ) + } } /** @@ -492,11 +366,18 @@ export class SignInStore extends TypedBaseStore { * This will put the store in the EndpointEntry step ready to * receive the url to the enterprise instance. */ - public beginEnterpriseSignIn() { + public beginEnterpriseSignIn( + resultCallback?: (result: SignInResult) => void + ) { + if (this.state !== null) { + this.reset() + } + this.setState({ kind: SignInStep.EndpointEntry, error: null, loading: false, + resultCallback: resultCallback ?? noop, }) } @@ -516,13 +397,25 @@ export class SignInStore extends TypedBaseStore { public async setEndpoint(url: string): Promise { const currentState = this.state - if (!currentState || currentState.kind !== SignInStep.EndpointEntry) { + if ( + currentState?.kind !== SignInStep.EndpointEntry && + currentState?.kind !== SignInStep.ExistingAccountWarning + ) { const stepText = currentState ? currentState.kind : 'null' return fatalError( `Sign in step '${stepText}' not compatible with endpoint entry` ) } + /** + * If the user enters a github.com url in the GitHub Enterprise sign-in + * flow we'll redirect them to the GitHub.com sign-in flow. + */ + if (/^(?:https:\/\/)?(?:api\.)?github\.com($|\/)/.test(url)) { + this.beginDotComSignIn(currentState.resultCallback) + return + } + this.setState({ ...currentState, loading: true }) let validUrl: string @@ -536,7 +429,7 @@ export class SignInStore extends TypedBaseStore { ) } else if (e.name === InvalidProtocolErrorName) { error = new Error( - 'Unsupported protocol. Only http or https is supported when authenticating with GitHub Enterprise instances.' + 'Unsupported protocol. Only https is supported when authenticating with GitHub Enterprise instances.' ) } @@ -545,140 +438,26 @@ export class SignInStore extends TypedBaseStore { } const endpoint = getEnterpriseAPIURL(validUrl) - try { - const supportsBasicAuth = await this.endpointSupportsBasicAuth(endpoint) - if (!this.state || this.state.kind !== SignInStep.EndpointEntry) { - // Looks like the sign in flow has been aborted - return - } + const existingAccount = this.accounts.find(x => x.endpoint === endpoint) + if (existingAccount) { this.setState({ - kind: SignInStep.Authentication, + kind: SignInStep.ExistingAccountWarning, endpoint, - supportsBasicAuth, + existingAccount, error: null, loading: false, - forgotPasswordUrl: this.getForgotPasswordURL(endpoint), + resultCallback: currentState.resultCallback, }) - } catch (e) { - let error = e - // We'll get an ENOTFOUND if the address couldn't be resolved. - if (e.code === 'ENOTFOUND') { - error = new Error( - 'The server could not be found. Please verify that the URL is correct and that you have a stable internet connection.' - ) - } - - this.setState({ ...currentState, loading: false, error }) - } - } - - /** - * Attempt to complete the sign in flow with the given OTP token.\ - * This method must only be called when the store is in the - * TwoFactorAuthentication step or an error will be thrown. - * - * If the provided token is valid the store will advance to - * the Success step. - * - * If an error occurs during sign in (such as invalid credentials) - * the authentication state will be updated with that error so that - * the responsible component can present it to the user. - */ - public async setTwoFactorOTP(otp: string) { - const currentState = this.state - - if ( - !currentState || - currentState.kind !== SignInStep.TwoFactorAuthentication - ) { - const stepText = currentState ? currentState.kind : 'null' - fatalError( - `Sign in step '${stepText}' not compatible with two factor authentication` - ) - } - - this.setState({ ...currentState, loading: true }) - - let response: AuthorizationResponse - - try { - response = await createAuthorization( - currentState.endpoint, - currentState.username, - currentState.password, - otp - ) - } catch (e) { - this.emitError(e) - return - } - - if (!this.state || this.state.kind !== SignInStep.TwoFactorAuthentication) { - // Looks like the sign in flow has been aborted - return - } - - if (response.kind === AuthorizationResponseKind.Authorized) { - const token = response.token - const user = await fetchUser(currentState.endpoint, token) - - if ( - !this.state || - this.state.kind !== SignInStep.TwoFactorAuthentication - ) { - // Looks like the sign in flow has been aborted - return - } - - this.emitAuthenticate(user, SignInMethod.Basic) - this.setState({ kind: SignInStep.Success }) } else { - switch (response.kind) { - case AuthorizationResponseKind.Failed: - case AuthorizationResponseKind.TwoFactorAuthenticationRequired: - this.setState({ - ...currentState, - loading: false, - error: new Error('Two-factor authentication failed.'), - }) - break - case AuthorizationResponseKind.Error: - this.emitError( - new Error( - `The server responded with an error (${response.response.status})\n\n${response.response.statusText}` - ) - ) - break - case AuthorizationResponseKind.UserRequiresVerification: - this.emitError( - new Error(getUnverifiedUserErrorMessage(currentState.username)) - ) - break - case AuthorizationResponseKind.PersonalAccessTokenBlocked: - this.emitError( - new Error( - 'A personal access token cannot be used to login to GitHub Desktop.' - ) - ) - break - case AuthorizationResponseKind.EnterpriseTooOld: - this.emitError(new Error(EnterpriseTooOldMessage)) - break - case AuthorizationResponseKind.WebFlowRequired: - this.setState({ - ...currentState, - forgotPasswordUrl: this.getForgotPasswordURL(currentState.endpoint), - loading: false, - supportsBasicAuth: false, - kind: SignInStep.Authentication, - error: null, - }) - break - default: - assertNever(response, `Unknown response: ${response}`) - } + this.setState({ + kind: SignInStep.Authentication, + endpoint, + error: null, + loading: false, + resultCallback: currentState.resultCallback, + }) } } } diff --git a/app/src/lib/stores/updates/changes-state.ts b/app/src/lib/stores/updates/changes-state.ts index 8f274f82bcc..e15131d5aeb 100644 --- a/app/src/lib/stores/updates/changes-state.ts +++ b/app/src/lib/stores/updates/changes-state.ts @@ -193,7 +193,7 @@ function performEffectsForMergeStateChange( // The branch name has changed while remaining conflicted -> the merge must have been aborted if (branchNameChanged) { - statsStore.recordMergeAbortedAfterConflicts() + statsStore.increment('mergeAbortedAfterConflictsCount') return } @@ -208,9 +208,9 @@ function performEffectsForMergeStateChange( const previousTip = prevConflictState.currentTip if (previousTip !== currentTip) { - statsStore.recordMergeSuccessAfterConflicts() + statsStore.increment('mergeSuccessAfterConflictsCount') } else { - statsStore.recordMergeAbortedAfterConflicts() + statsStore.increment('mergeAbortedAfterConflictsCount') } } } @@ -233,7 +233,7 @@ function performEffectsForRebaseStateChange( // The branch name has changed while remaining conflicted -> the rebase must have been aborted if (branchNameChanged) { - statsStore.recordRebaseAbortedAfterConflicts() + statsStore.increment('rebaseAbortedAfterConflictsCount') return } @@ -253,7 +253,7 @@ function performEffectsForRebaseStateChange( currentBranch === prevConflictState.targetBranch if (!previousTipChanged) { - statsStore.recordRebaseAbortedAfterConflicts() + statsStore.increment('rebaseAbortedAfterConflictsCount') } } diff --git a/app/src/lib/suppress-certificate-error.ts b/app/src/lib/suppress-certificate-error.ts new file mode 100644 index 00000000000..3bc795f9b48 --- /dev/null +++ b/app/src/lib/suppress-certificate-error.ts @@ -0,0 +1,13 @@ +const suppressedUrls = new Set() + +export function suppressCertificateErrorFor(url: string) { + suppressedUrls.add(url) +} + +export function clearCertificateErrorSuppressionFor(url: string) { + suppressedUrls.delete(url) +} + +export function isCertificateErrorSuppressedFor(url: string) { + return suppressedUrls.has(url) +} diff --git a/app/src/lib/text-token-parser.ts b/app/src/lib/text-token-parser.ts index 3dc6fce6b13..bb2a802cea5 100644 --- a/app/src/lib/text-token-parser.ts +++ b/app/src/lib/text-token-parser.ts @@ -5,6 +5,7 @@ import { } from '../models/repository' import { GitHubRepository } from '../models/github-repository' import { getHTMLURL } from './api' +import { Emoji } from './emoji' export enum TokenType { /* @@ -28,6 +29,10 @@ export type EmojiMatch = { readonly text: string // The path on disk to the image. readonly path: string + // The unicode character of the emoji, if available + readonly emoji?: string + // The human description of the emoji, if available + readonly description?: string } export type HyperlinkMatch = { @@ -54,14 +59,14 @@ type LookupResult = { * A look-ahead tokenizer designed for scanning commit messages for emoji, issues, mentions and links. */ export class Tokenizer { - private readonly emoji: Map + private readonly allEmoji: Map private readonly repository: GitHubRepository | null = null private _results = new Array() private _currentString = '' - public constructor(emoji: Map, repository?: Repository) { - this.emoji = emoji + public constructor(emoji: Map, repository?: Repository) { + this.allEmoji = emoji if (repository && isRepositoryWithGitHubRepository(repository)) { this.repository = getNonForkGitHubRepository(repository) @@ -115,13 +120,19 @@ export class Tokenizer { return null } - const path = this.emoji.get(maybeEmoji) - if (!path) { + const emoji = this.allEmoji.get(maybeEmoji) + if (!emoji) { return null } this.flush() - this._results.push({ kind: TokenType.Emoji, text: maybeEmoji, path }) + this._results.push({ + kind: TokenType.Emoji, + text: maybeEmoji, + path: emoji.url, + emoji: emoji.emoji, + description: emoji.description, + }) return { nextIndex } } diff --git a/app/src/lib/to_sentence.ts b/app/src/lib/to_sentence.ts new file mode 100644 index 00000000000..6bb2cc44210 --- /dev/null +++ b/app/src/lib/to_sentence.ts @@ -0,0 +1,31 @@ +/** + * Converts the array to a comma-separated sentence where the last element is joined by the connector word + * + * Example output: + * [].to_sentence # => "" + * ['one'].to_sentence # => "one" + * ['one', 'two'].to_sentence # => "one and two" + * ['one', 'two', 'three'].to_sentence # => "one, two, and three" + * + * Based on https://gist.github.com/mudge/1076046 to emulate https://apidock.com/rails/Array/to_sentence + */ +export function toSentence(array: ReadonlyArray): string { + const wordsConnector = ', ', + twoWordsConnector = ' and ', + lastWordConnector = ', and ' + + switch (array.length) { + case 0: + return '' + case 1: + return array.at(0) ?? '' + case 2: + return array.at(0) + twoWordsConnector + array.at(1) + default: + return ( + array.slice(0, -1).join(wordsConnector) + + lastWordConnector + + array.at(-1) + ) + } +} diff --git a/app/src/lib/trampoline/find-account.ts b/app/src/lib/trampoline/find-account.ts new file mode 100644 index 00000000000..151f1637914 --- /dev/null +++ b/app/src/lib/trampoline/find-account.ts @@ -0,0 +1,60 @@ +import memoizeOne from 'memoize-one' +import { getHTMLURL } from '../api' +import { getGenericPassword, getGenericUsername } from '../generic-git-auth' +import { AccountsStore } from '../stores' +import { urlWithoutCredentials } from './url-without-credentials' +import { Account } from '../../models/account' + +/** + * When we're asked for credentials we're typically first asked for the username + * immediately followed by the password. We memoize the getGenericPassword call + * such that we only call it once per endpoint/login pair. Since we include the + * trampoline token in the invalidation key we'll only call it once per + * trampoline session. + */ +const memoizedGetGenericPassword = memoizeOne( + (_trampolineToken: string, endpoint: string, login: string) => + getGenericPassword(endpoint, login) +) + +export async function findGitHubTrampolineAccount( + accountsStore: AccountsStore, + remoteUrl: string +): Promise { + const accounts = await accountsStore.getAll() + const parsedUrl = new URL(remoteUrl) + return accounts.find( + a => new URL(getHTMLURL(a.endpoint)).origin === parsedUrl.origin + ) +} + +export async function findGenericTrampolineAccount( + trampolineToken: string, + remoteUrl: string +) { + const parsedUrl = new URL(remoteUrl) + const endpoint = urlWithoutCredentials(remoteUrl) + + const login = + parsedUrl.username === '' + ? getGenericUsername(endpoint) + : parsedUrl.username + + if (!login) { + return undefined + } + + const token = await memoizedGetGenericPassword( + trampolineToken, + endpoint, + login + ) + + if (!token) { + // We have a username but no password, that warrants a warning + log.warn(`credential: generic password for ${remoteUrl} missing`) + return undefined + } + + return { login, endpoint, token } +} diff --git a/app/src/lib/trampoline/trampoline-askpass-handler.ts b/app/src/lib/trampoline/trampoline-askpass-handler.ts index 68d6cc62a93..d2120c0e276 100644 --- a/app/src/lib/trampoline/trampoline-askpass-handler.ts +++ b/app/src/lib/trampoline/trampoline-askpass-handler.ts @@ -1,19 +1,22 @@ -import { getKeyForEndpoint } from '../auth' import { getSSHKeyPassphrase, - keepSSHKeyPassphraseToStore, + setMostRecentSSHKeyPassphrase, + setSSHKeyPassphrase, } from '../ssh/ssh-key-passphrase' -import { TokenStore } from '../stores' +import { AccountsStore } from '../stores/accounts-store' import { TrampolineCommandHandler } from './trampoline-command' import { trampolineUIHelper } from './trampoline-ui-helper' import { parseAddSSHHostPrompt } from '../ssh/ssh' import { getSSHUserPassword, - keepSSHUserPasswordToStore, + setMostRecentSSHUserPassword, + setSSHUserPassword, } from '../ssh/ssh-user-password' -import { removePendingSSHSecretToStore } from '../ssh/ssh-secret-storage' +import { removeMostRecentSSHCredential } from '../ssh/ssh-credential-storage' +import { getIsBackgroundTaskEnvironment } from './trampoline-environment' async function handleSSHHostAuthenticity( + operationGUID: string, prompt: string ): Promise<'yes' | 'no' | undefined> { const info = parseAddSSHHostPrompt(prompt) @@ -33,6 +36,13 @@ async function handleSSHHostAuthenticity( return 'yes' } + if (getIsBackgroundTaskEnvironment(operationGUID)) { + log.debug( + 'handleSSHHostAuthenticity: background task environment, skipping prompt' + ) + return undefined + } + const addHost = await trampolineUIHelper.promptAddingSSHHost( info.host, info.ip, @@ -66,9 +76,19 @@ async function handleSSHKeyPassphrase( const storedPassphrase = await getSSHKeyPassphrase(keyPath) if (storedPassphrase !== null) { + // Keep this stored passphrase around in case it's not valid and we need to + // delete it if the git operation fails to authenticate. + await setMostRecentSSHKeyPassphrase(operationGUID, keyPath) return storedPassphrase } + if (getIsBackgroundTaskEnvironment(operationGUID)) { + log.debug( + 'handleSSHKeyPassphrase: background task environment, skipping prompt' + ) + return undefined + } + const { secret: passphrase, storeSecret: storePassphrase } = await trampolineUIHelper.promptSSHKeyPassphrase(keyPath) @@ -80,9 +100,9 @@ async function handleSSHKeyPassphrase( // when, in one of those multiple attempts, the user chooses NOT to remember // the passphrase. if (passphrase !== undefined && storePassphrase) { - keepSSHKeyPassphraseToStore(operationGUID, keyPath, passphrase) + setSSHKeyPassphrase(operationGUID, keyPath, passphrase) } else { - removePendingSSHSecretToStore(operationGUID) + removeMostRecentSSHCredential(operationGUID) } return passphrase ?? '' @@ -100,23 +120,35 @@ async function handleSSHUserPassword(operationGUID: string, prompt: string) { const storedPassword = await getSSHUserPassword(username) if (storedPassword !== null) { + // Keep this stored password around in case it's not valid and we need to + // delete it if the git operation fails to authenticate. + setMostRecentSSHUserPassword(operationGUID, username) return storedPassword } + if (getIsBackgroundTaskEnvironment(operationGUID)) { + log.debug( + 'handleSSHUserPassword: background task environment, skipping prompt' + ) + return undefined + } + const { secret: password, storeSecret: storePassword } = await trampolineUIHelper.promptSSHUserPassword(username) if (password !== undefined && storePassword) { - keepSSHUserPasswordToStore(operationGUID, username, password) + setSSHUserPassword(operationGUID, username, password) } else { - removePendingSSHSecretToStore(operationGUID) + removeMostRecentSSHCredential(operationGUID) } return password ?? '' } -export const askpassTrampolineHandler: TrampolineCommandHandler = - async command => { +export const createAskpassTrampolineHandler: ( + accountsStore: AccountsStore +) => TrampolineCommandHandler = + (accountsStore: AccountsStore) => async command => { if (command.parameters.length !== 1) { return undefined } @@ -124,7 +156,7 @@ export const askpassTrampolineHandler: TrampolineCommandHandler = const firstParameter = command.parameters[0] if (firstParameter.startsWith('The authenticity of host ')) { - return handleSSHHostAuthenticity(firstParameter) + return handleSSHHostAuthenticity(command.trampolineToken, firstParameter) } if (firstParameter.startsWith('Enter passphrase for key ')) { @@ -135,23 +167,5 @@ export const askpassTrampolineHandler: TrampolineCommandHandler = return handleSSHUserPassword(command.trampolineToken, firstParameter) } - const username = command.environmentVariables.get('DESKTOP_USERNAME') - if (username === undefined || username.length === 0) { - return undefined - } - - if (firstParameter.startsWith('Username')) { - return username - } else if (firstParameter.startsWith('Password')) { - const endpoint = command.environmentVariables.get('DESKTOP_ENDPOINT') - if (endpoint === undefined || endpoint.length === 0) { - return undefined - } - - const key = getKeyForEndpoint(endpoint) - const token = await TokenStore.getItem(key, username) - return token ?? undefined - } - return undefined } diff --git a/app/src/lib/trampoline/trampoline-command-parser.ts b/app/src/lib/trampoline/trampoline-command-parser.ts index 9dee69d77c7..d760082f69d 100644 --- a/app/src/lib/trampoline/trampoline-command-parser.ts +++ b/app/src/lib/trampoline/trampoline-command-parser.ts @@ -1,4 +1,5 @@ import { parseEnumValue } from '../enum' +import { assertNever } from '../fatal-error' import { sendNonFatalException } from '../helpers/non-fatal-exception' import { ITrampolineCommand, @@ -10,6 +11,7 @@ enum TrampolineCommandParserState { Parameters, EnvironmentVariablesCount, EnvironmentVariables, + Stdin, Finished, } @@ -22,6 +24,7 @@ export class TrampolineCommandParser { private readonly parameters: string[] = [] private environmentVariablesCount: number = 0 private readonly environmentVariables = new Map() + private stdin = '' private state: TrampolineCommandParserState = TrampolineCommandParserState.ParameterCount @@ -63,7 +66,7 @@ export class TrampolineCommandParser { if (this.environmentVariablesCount > 0) { this.state = TrampolineCommandParserState.EnvironmentVariables } else { - this.state = TrampolineCommandParserState.Finished + this.state = TrampolineCommandParserState.Stdin } break @@ -86,12 +89,17 @@ export class TrampolineCommandParser { this.environmentVariables.set(variableKey, variableValue) if (this.environmentVariables.size === this.environmentVariablesCount) { - this.state = TrampolineCommandParserState.Finished + this.state = TrampolineCommandParserState.Stdin } break - + case TrampolineCommandParserState.Stdin: + this.stdin = value + this.state = TrampolineCommandParserState.Finished + break + case TrampolineCommandParserState.Finished: + throw new Error(`Received value when in Finished`) default: - throw new Error(`Received value during invalid state: ${this.state}`) + assertNever(this.state, `Invalid state: ${this.state}`) } } @@ -152,6 +160,7 @@ export class TrampolineCommandParser { trampolineToken, parameters: this.parameters, environmentVariables: this.environmentVariables, + stdin: this.stdin, } } diff --git a/app/src/lib/trampoline/trampoline-command.ts b/app/src/lib/trampoline/trampoline-command.ts index 18834e553c3..c0c2c6bc48a 100644 --- a/app/src/lib/trampoline/trampoline-command.ts +++ b/app/src/lib/trampoline/trampoline-command.ts @@ -1,5 +1,6 @@ export enum TrampolineCommandIdentifier { AskPass = 'ASKPASS', + CredentialHelper = 'CREDENTIALHELPER', } /** Represents a command in our trampoline mechanism. */ @@ -28,6 +29,12 @@ export interface ITrampolineCommand { /** Environment variables that were set when the command was invoked. */ readonly environmentVariables: ReadonlyMap + + /** + * The standard input received by the trampoline (note that when running as + * an askpass handler the trampoline won't read from stdin) + **/ + readonly stdin: string } /** diff --git a/app/src/lib/trampoline/trampoline-credential-helper.ts b/app/src/lib/trampoline/trampoline-credential-helper.ts new file mode 100644 index 00000000000..c8a88b22fc2 --- /dev/null +++ b/app/src/lib/trampoline/trampoline-credential-helper.ts @@ -0,0 +1,265 @@ +import { AccountsStore } from '../stores' +import { TrampolineCommandHandler } from './trampoline-command' +import { forceUnwrap } from '../fatal-error' +import { + approveCredential, + fillCredential, + formatCredential, + parseCredential, + rejectCredential, +} from '../git/credential' +import { + getCredentialUrl, + getIsBackgroundTaskEnvironment, + getTrampolineEnvironmentPath, + setHasRejectedCredentialsForEndpoint, +} from './trampoline-environment' +import { useExternalCredentialHelper } from './use-external-credential-helper' +import { + findGenericTrampolineAccount, + findGitHubTrampolineAccount, +} from './find-account' +import { IGitAccount } from '../../models/git-account' +import { + deleteGenericCredential, + setGenericCredential, +} from '../generic-git-auth' +import { urlWithoutCredentials } from './url-without-credentials' +import { trampolineUIHelper as ui } from './trampoline-ui-helper' +import { isGitHubHost } from '../api' +import { isDotCom, isGHE, isGist } from '../endpoint-capabilities' + +type Credential = Map +type Store = AccountsStore + +const info = (msg: string) => log.info(`credential-helper: ${msg}`) +const debug = (msg: string) => log.debug(`credential-helper: ${msg}`) +const error = (msg: string, e: any) => log.error(`credential-helper: ${msg}`, e) + +/** + * Merges credential info from account into credential + * + * When looking up a first-party account (GitHub.com et al) we can use the + * account's endpoint host in the credential since that's the API url so instead + * we take all the fields from the credential and set the username and password + * from the Account on top of those. + */ +const credWithAccount = (c: Credential, a: IGitAccount | undefined) => + a && new Map(c).set('username', a.login).set('password', a.token) + +async function getGitHubCredential(cred: Credential, store: AccountsStore) { + const endpoint = `${getCredentialUrl(cred)}` + const account = await findGitHubTrampolineAccount(store, endpoint) + if (account) { + info(`found GitHub credential for ${endpoint} in store`) + } + return credWithAccount(cred, account) +} + +async function promptForCredential(cred: Credential, endpoint: string) { + const parsedUrl = new URL(endpoint) + const username = parsedUrl.username === '' ? undefined : parsedUrl.username + const account = await ui.promptForGenericGitAuthentication(endpoint, username) + info(`prompt for ${endpoint}: ${account ? 'completed' : 'cancelled'}`) + return credWithAccount(cred, account) +} + +async function getGenericCredential(cred: Credential, token: string) { + const endpoint = `${getCredentialUrl(cred)}` + const account = await findGenericTrampolineAccount(token, endpoint) + + if (account) { + info(`found generic credential for ${endpoint}`) + return credWithAccount(cred, account) + } + + if (getIsBackgroundTaskEnvironment(token)) { + debug('background task environment, skipping prompt') + return undefined + } else { + return promptForCredential(cred, endpoint) + } +} + +async function getExternalCredential(input: Credential, token: string) { + const path = getTrampolineEnvironmentPath(token) + const cred = await fillCredential(input, path, getGcmEnv(token)) + if (cred) { + info(`found credential for ${getCredentialUrl(cred)} in external helper`) + } + return cred +} + +/** Implementation of the 'get' git credential helper command */ +async function getCredential(cred: Credential, store: Store, token: string) { + const ghCred = await getGitHubCredential(cred, store) + + if (ghCred) { + return ghCred + } + + const endpointKind = await getEndpointKind(cred, store) + const accounts = await store.getAll() + + const hasDotComAccount = accounts.some(a => isDotCom(a.endpoint)) + const hasEnterpriseAccount = accounts.some(a => !isDotCom(a.endpoint)) + + // If it appears as if the endpoint is a GitHub host and we don't have an + // account for it (since we currently only allow one GitHub.com account and + // one Enterprise account) we prompt the user to sign in. + if ( + (endpointKind === 'github.com' && !hasDotComAccount) || + (endpointKind === 'enterprise' && !hasEnterpriseAccount) + ) { + if (getIsBackgroundTaskEnvironment(token)) { + debug('background task environment, skipping prompt') + return undefined + } + + const endpoint = `${getCredentialUrl(cred)}` + const account = await ui.promptForGitHubSignIn(endpoint) + + if (!account) { + setHasRejectedCredentialsForEndpoint(token, endpoint) + } + + return credWithAccount(cred, account) + } + + // GitHub.com/GHE creds are only stored internally + if (endpointKind !== 'generic') { + return undefined + } + + return useExternalCredentialHelper() + ? getExternalCredential(cred, token) + : getGenericCredential(cred, token) +} + +const getEndpointKind = async (cred: Credential, store: Store) => { + const credentialUrl = getCredentialUrl(cred) + const endpoint = `${credentialUrl}` + + if (isGist(endpoint)) { + return 'generic' + } + + if (isDotCom(endpoint)) { + return 'github.com' + } + + if (isGHE(endpoint)) { + return 'ghe.com' + } + + // When Git attempts to authenticate with a host it captures any + // WWW-Authenticate headers and forwards them to the credential helper. We + // use them as a happy-path to determine if the host is a GitHub host without + // having to resort to making a request ourselves. + for (const [k, v] of cred.entries()) { + if (k.startsWith('wwwauth[')) { + if (v.includes('realm="GitHub"')) { + return 'enterprise' + } else if (/realm="(GitLab|Gitea|Atlassian Bitbucket)"/.test(v)) { + return 'generic' + } + } + } + + const existingAccount = await findGitHubTrampolineAccount(store, endpoint) + if (existingAccount) { + return isDotCom(existingAccount.endpoint) ? 'github.com' : 'enterprise' + } + + return (await isGitHubHost(endpoint)) ? 'enterprise' : 'generic' +} + +/** Implementation of the 'store' git credential helper command */ +async function storeCredential(cred: Credential, store: Store, token: string) { + if ((await getEndpointKind(cred, store)) !== 'generic') { + return + } + + return useExternalCredentialHelper() + ? storeExternalCredential(cred, token) + : setGenericCredential( + urlWithoutCredentials(getCredentialUrl(cred)), + forceUnwrap(`credential missing username`, cred.get('username')), + forceUnwrap(`credential missing password`, cred.get('password')) + ) +} + +const storeExternalCredential = (cred: Credential, token: string) => { + const path = getTrampolineEnvironmentPath(token) + return approveCredential(cred, path, getGcmEnv(token)) +} + +/** Implementation of the 'erase' git credential helper command */ +async function eraseCredential(cred: Credential, store: Store, token: string) { + if ((await getEndpointKind(cred, store)) !== 'generic') { + return + } + + return useExternalCredentialHelper() + ? eraseExternalCredential(cred, token) + : deleteGenericCredential( + urlWithoutCredentials(getCredentialUrl(cred)), + forceUnwrap(`credential missing username`, cred.get('username')) + ) +} + +const eraseExternalCredential = (cred: Credential, token: string) => { + const path = getTrampolineEnvironmentPath(token) + return rejectCredential(cred, path, getGcmEnv(token)) +} + +export const createCredentialHelperTrampolineHandler: ( + store: AccountsStore +) => TrampolineCommandHandler = (store: Store) => async command => { + const firstParameter = command.parameters.at(0) + if (!firstParameter) { + return undefined + } + + const { trampolineToken: token } = command + const input = parseCredential(command.stdin) + + if (__DEV__) { + debug( + `${firstParameter}\n${command.stdin + .replaceAll(/^password=.*$/gm, 'password=***') + .replaceAll(/^(.*)$/gm, ' $1') + .trimEnd()}` + ) + } + + try { + if (firstParameter === 'get') { + const cred = await getCredential(input, store, token) + if (!cred) { + const endpoint = `${getCredentialUrl(input)}` + info(`could not find credential for ${endpoint}`) + setHasRejectedCredentialsForEndpoint(token, endpoint) + } + return cred ? formatCredential(cred) : undefined + } else if (firstParameter === 'store') { + await storeCredential(input, store, token) + } else if (firstParameter === 'erase') { + await eraseCredential(input, store, token) + } + return undefined + } catch (e) { + error(`${firstParameter} failed`, e) + return undefined + } +} + +function getGcmEnv(token: string): Record { + const isBackgroundTask = getIsBackgroundTaskEnvironment(token) + return { + ...(process.env.GITHUB_DESKTOP_DISABLE_HARDWARE_ACCELERATION + ? { GCM_GUI_SOFTWARE_RENDERING: '1' } + : {}), + GCM_INTERACTIVE: isBackgroundTask ? '0' : '1', + } +} diff --git a/app/src/lib/trampoline/trampoline-environment.ts b/app/src/lib/trampoline/trampoline-environment.ts index e111a671962..059d5092d28 100644 --- a/app/src/lib/trampoline/trampoline-environment.ts +++ b/app/src/lib/trampoline/trampoline-environment.ts @@ -1,13 +1,84 @@ import { trampolineServer } from './trampoline-server' import { withTrampolineToken } from './trampoline-tokens' import * as Path from 'path' -import { getDesktopTrampolineFilename } from 'desktop-trampoline' -import { TrampolineCommandIdentifier } from '../trampoline/trampoline-command' import { getSSHEnvironment } from '../ssh/ssh' import { - removePendingSSHSecretToStore, - storePendingSSHSecret, -} from '../ssh/ssh-secret-storage' + deleteMostRecentSSHCredential, + removeMostRecentSSHCredential, +} from '../ssh/ssh-credential-storage' +import { GitError as DugiteError, exec } from 'dugite' +import memoizeOne from 'memoize-one' +import { enableGitConfigParameters } from '../feature-flag' +import { GitError, getDescriptionForError } from '../git/core' +import { getDesktopAskpassTrampolineFilename } from 'desktop-trampoline' + +const hasRejectedCredentialsForEndpoint = new Map>() + +export const setHasRejectedCredentialsForEndpoint = ( + trampolineToken: string, + endpoint: string +) => { + const set = hasRejectedCredentialsForEndpoint.get(trampolineToken) + if (set) { + set.add(endpoint) + } else { + hasRejectedCredentialsForEndpoint.set(trampolineToken, new Set([endpoint])) + } +} + +export const getHasRejectedCredentialsForEndpoint = ( + trampolineToken: string, + endpoint: string +) => { + return ( + hasRejectedCredentialsForEndpoint.get(trampolineToken)?.has(endpoint) ?? + false + ) +} +const isBackgroundTaskEnvironment = new Map() +const trampolineEnvironmentPath = new Map() + +export const getTrampolineEnvironmentPath = (trampolineToken: string) => + trampolineEnvironmentPath.get(trampolineToken) ?? process.cwd() + +export const getIsBackgroundTaskEnvironment = (trampolineToken: string) => + isBackgroundTaskEnvironment.get(trampolineToken) ?? false + +export const getCredentialUrl = (cred: Map) => { + const u = cred.get('url') + if (u) { + return new URL(u) + } + + const protocol = cred.get('protocol') ?? '' + const username = cred.get('username') + const user = username ? `${encodeURIComponent(username)}@` : '' + const host = cred.get('host') ?? '' + const path = cred.get('path') ?? '' + + return new URL(`${protocol}://${user}${host}/${path}`) +} + +export const GitUserAgent = memoizeOne(() => + // Can't use git() as that will call withTrampolineEnv which calls this method + exec(['--version'], process.cwd()) + // https://github.com/git/git/blob/a9e066fa63149291a55f383cfa113d8bdbdaa6b3/help.c#L733-L739 + .then(r => /git version (.*)/.exec(r.stdout)?.at(1) ?? 'unknown') + .catch(e => { + log.warn(`Could not get git version information`, e) + return 'unknown' + }) + .then(v => { + const suffix = __DEV__ ? `-${__SHA__.substring(0, 10)}` : '' + const ghdVersion = `GitHub Desktop/${__APP_VERSION__}${suffix}` + const { platform, arch } = process + + return `git/${v} (${ghdVersion}; ${platform} ${arch})` + }) +) + +const fatalPromptsDisabledRe = + /^fatal: could not read .*?: terminal prompts disabled\n$/ /** * Allows invoking a function with a set of environment variables to use when @@ -21,15 +92,28 @@ import { * variables. */ export async function withTrampolineEnv( - fn: (env: Object) => Promise + fn: (env: object) => Promise, + path: string, + isBackgroundTask = false, + customEnv?: Record ): Promise { const sshEnv = await getSSHEnvironment() return withTrampolineToken(async token => { + isBackgroundTaskEnvironment.set(token, isBackgroundTask) + trampolineEnvironmentPath.set(token, path) + + const existingGitEnvConfig = + customEnv?.['GIT_CONFIG_PARAMETERS'] ?? + process.env['GIT_CONFIG_PARAMETERS'] ?? + '' + + const gitEnvConfigPrefix = + existingGitEnvConfig.length > 0 ? `${existingGitEnvConfig} ` : '' + // The code below assumes a few things in order to manage SSH key passphrases // correctly: - // 1. `withTrampolineEnv` is only used in the functions `git` (core.ts) and - // `spawnAndComplete` (spawn.ts) + // 1. `withTrampolineEnv` is only used in the functions `git` (core.ts) // 2. Those two functions always thrown an error when something went wrong, // and just return a result when everything went fine. // @@ -37,30 +121,107 @@ export async function withTrampolineEnv( // `fn` has been invoked, we can store the SSH key passphrase for this git // operation if there was one pending to be stored. try { - const result = await fn({ + return await fn({ DESKTOP_PORT: await trampolineServer.getPort(), DESKTOP_TRAMPOLINE_TOKEN: token, - GIT_ASKPASS: getDesktopTrampolinePath(), - DESKTOP_TRAMPOLINE_IDENTIFIER: TrampolineCommandIdentifier.AskPass, - + GIT_ASKPASS: '', + // This warrants some explanation. We're configuring the + // credential helper using environment variables rather than + // arguments (i.e. -c credential.helper=) because we want commands + // invoked by filters (i.e. Git LFS) to be able to pick up our + // configuration. Arguments passed to git commands are not passed + // down to filters. + // + // We're using the undocumented GIT_CONFIG_PARAMETERS environment + // variable over the documented GIT_CONFIG_{COUNT,KEY,VALUE} due + // to an apparent bug either in a Windows Python runtime + // dependency or in a Python project commonly used to manage hooks + // which isn't able to handle the blank environment variables we + // need when using GIT_CONFIG_*. + // + // See https://github.com/desktop/desktop/issues/18945 + // See https://github.com/git/git/blob/ed155187b429a/config.c#L664 + ...(enableGitConfigParameters() + ? { + GIT_CONFIG_PARAMETERS: `${gitEnvConfigPrefix}'credential.helper=' 'credential.helper=desktop'`, + } + : { + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '', + GIT_CONFIG_KEY_1: 'credential.helper', + GIT_CONFIG_VALUE_1: 'desktop', + }), + GIT_USER_AGENT: await GitUserAgent(), ...sshEnv, }) + } catch (e) { + if (!getIsBackgroundTaskEnvironment(token)) { + // If the operation fails with an SSHAuthenticationFailed error, we + // assume that it's because the last credential we provided via the + // askpass handler was rejected. That's not necessarily the case but for + // practical purposes, it's as good as we can get with the information we + // have. We're limited by the ASKPASS flow here. + if (isSSHAuthFailure(e)) { + deleteMostRecentSSHCredential(token) + } + } + + // Prior to us introducing the credential helper trampoline, our askpass + // trampoline would return an empty string as the username and password if + // we were unable to find an account or acquire credentials from the user. + // Git would take that to mean that the literal username and password were + // an empty string and would attempt to authenticate with those. This + // would fail and Git would then exit with an authentication error which + // would bubble up to the user. Now that we're using the credential helper + // Git knows that we failed to provide credentials and instead of trying + // to authenticate with an empty string it will exit with an error saying + // that it couldn't read the username since terminal prompts were + // disabled. + // + // We catch that specific error here and throw the user-friendly + // authentication failed error that we've always done in the past. + if ( + hasRejectedCredentialsForEndpoint.has(token) && + e instanceof GitError && + fatalPromptsDisabledRe.test(e.message) + ) { + const msg = 'Authentication failed: user cancelled authentication' + const gitErrorDescription = + getDescriptionForError(DugiteError.HTTPSAuthenticationFailed, '') ?? + msg - await storePendingSSHSecret(token) + const fakeAuthError = new GitError( + { ...e.result, gitErrorDescription }, + e.args, + msg + ) - return result + fakeAuthError.cause = e + throw fakeAuthError + } + + throw e } finally { - removePendingSSHSecretToStore(token) + removeMostRecentSSHCredential(token) + isBackgroundTaskEnvironment.delete(token) + hasRejectedCredentialsForEndpoint.delete(token) + trampolineEnvironmentPath.delete(token) } }) } -/** Returns the path of the desktop-trampoline binary. */ -export function getDesktopTrampolinePath(): string { +const isSSHAuthFailure = (e: unknown): e is GitError => + e instanceof GitError && + (e.result.gitError === DugiteError.SSHAuthenticationFailed || + e.result.gitError === DugiteError.SSHPermissionDenied) + +/** Returns the path of the desktop-askpass-trampoline binary. */ +export function getDesktopAskpassTrampolinePath(): string { return Path.resolve( __dirname, 'desktop-trampoline', - getDesktopTrampolineFilename() + getDesktopAskpassTrampolineFilename() ) } diff --git a/app/src/lib/trampoline/trampoline-server.ts b/app/src/lib/trampoline/trampoline-server.ts index cb64fd48b59..a866049b9ec 100644 --- a/app/src/lib/trampoline/trampoline-server.ts +++ b/app/src/lib/trampoline/trampoline-server.ts @@ -1,7 +1,6 @@ import { createServer, AddressInfo, Server, Socket } from 'net' import split2 from 'split2' import { sendNonFatalException } from '../helpers/non-fatal-exception' -import { askpassTrampolineHandler } from './trampoline-askpass-handler' import { ITrampolineCommand, TrampolineCommandHandler, @@ -42,11 +41,6 @@ export class TrampolineServer { // suite runner would never finish, hitting a 45min timeout for the whole // GitHub Action. this.server.unref() - - this.registerCommandHandler( - TrampolineCommandIdentifier.AskPass, - askpassTrampolineHandler - ) } private async listen(): Promise { @@ -158,7 +152,7 @@ export class TrampolineServer { * @param identifier Identifier of the command. * @param handler Handler to register. */ - private registerCommandHandler( + public registerCommandHandler( identifier: TrampolineCommandIdentifier, handler: TrampolineCommandHandler ) { @@ -177,7 +171,9 @@ export class TrampolineServer { return } - const result = await handler(command) + const result = await handler(command).catch(e => + log.error('Error processing trampoline command', e) + ) if (result !== undefined) { socket.end(result) diff --git a/app/src/lib/trampoline/trampoline-ui-helper.ts b/app/src/lib/trampoline/trampoline-ui-helper.ts index 7e6fe66d5e0..51e66310fc8 100644 --- a/app/src/lib/trampoline/trampoline-ui-helper.ts +++ b/app/src/lib/trampoline/trampoline-ui-helper.ts @@ -1,5 +1,8 @@ +import { Account } from '../../models/account' +import { IGitAccount } from '../../models/git-account' import { PopupType } from '../../models/popup' import { Dispatcher } from '../../ui/dispatcher' +import { SignInResult } from '../stores' type PromptSSHSecretResponse = { readonly secret: string | undefined @@ -57,6 +60,48 @@ class TrampolineUIHelper { }) }) } + + public promptForGenericGitAuthentication( + endpoint: string, + username?: string + ): Promise { + return new Promise(resolve => { + this.dispatcher.showPopup({ + type: PopupType.GenericGitAuthentication, + remoteUrl: endpoint, + username, + onSubmit: (login: string, token: string) => + resolve({ login, token, endpoint }), + onDismiss: () => resolve(undefined), + }) + }) + } + + public promptForGitHubSignIn(endpoint: string): Promise { + return new Promise(async resolve => { + const cb = (result: SignInResult) => { + resolve(result.kind === 'success' ? result.account : undefined) + this.dispatcher.closePopup(PopupType.SignIn) + } + + const { hostname, origin } = new URL(endpoint) + if (hostname === 'github.com') { + this.dispatcher.beginDotComSignIn(cb) + } else { + this.dispatcher.beginEnterpriseSignIn(cb) + await this.dispatcher.setSignInEndpoint(origin) + } + + this.dispatcher.showPopup({ + type: PopupType.SignIn, + isCredentialHelperSignIn: true, + credentialHelperUrl: endpoint, + }) + }).catch(e => { + log.error(`Could not prompt for GitHub sign in`, e) + return undefined + }) + } } export const trampolineUIHelper = new TrampolineUIHelper() diff --git a/app/src/lib/trampoline/url-without-credentials.ts b/app/src/lib/trampoline/url-without-credentials.ts new file mode 100644 index 00000000000..cec2631ccf1 --- /dev/null +++ b/app/src/lib/trampoline/url-without-credentials.ts @@ -0,0 +1,6 @@ +export function urlWithoutCredentials(remoteUrl: string | URL): string { + const url = new URL(remoteUrl) + url.username = '' + url.password = '' + return url.toString() +} diff --git a/app/src/lib/trampoline/use-external-credential-helper.ts b/app/src/lib/trampoline/use-external-credential-helper.ts new file mode 100644 index 00000000000..12ff1556810 --- /dev/null +++ b/app/src/lib/trampoline/use-external-credential-helper.ts @@ -0,0 +1,11 @@ +import { getBoolean, setBoolean } from '../local-storage' + +export const useExternalCredentialHelperDefault = false +export const useExternalCredentialHelperKey: string = + 'useExternalCredentialHelper' + +export const useExternalCredentialHelper = () => + getBoolean(useExternalCredentialHelperKey, useExternalCredentialHelperDefault) + +export const setUseExternalCredentialHelper = (value: boolean) => + setBoolean(useExternalCredentialHelperKey, value) diff --git a/app/src/lib/unique-coauthors-as-authors.ts b/app/src/lib/unique-coauthors-as-authors.ts index 9e4ee710b88..4d45bf34833 100644 --- a/app/src/lib/unique-coauthors-as-authors.ts +++ b/app/src/lib/unique-coauthors-as-authors.ts @@ -1,21 +1,18 @@ -import _ from 'lodash' -import { IAuthor } from '../models/author' +import uniqWith from 'lodash/uniqWith' +import { KnownAuthor } from '../models/author' import { Commit } from '../models/commit' -import { GitAuthor } from '../models/git-author' export function getUniqueCoauthorsAsAuthors( commits: ReadonlyArray -): ReadonlyArray { - const allCommitsCoAuthors: GitAuthor[] = _.flatten( - commits.map(c => c.coAuthors) - ) +): ReadonlyArray { + const allCommitsCoAuthors = commits.flatMap(c => c.coAuthors) - const uniqueCoAuthors = _.uniqWith( + const uniqueCoAuthors = uniqWith( allCommitsCoAuthors, (a, b) => a.email === b.email && a.name === b.name ) return uniqueCoAuthors.map(ca => { - return { name: ca.name, email: ca.email, username: null } + return { kind: 'known', name: ca.name, email: ca.email, username: null } }) } diff --git a/app/src/main-process/alive-origin-filter.ts b/app/src/main-process/alive-origin-filter.ts index 5b6800da1cb..64b4d93b9bc 100644 --- a/app/src/main-process/alive-origin-filter.ts +++ b/app/src/main-process/alive-origin-filter.ts @@ -8,19 +8,26 @@ export function installAliveOriginFilter(orderedWebRequest: OrderedWebRequest) { orderedWebRequest.onBeforeSendHeaders.addEventListener(async details => { const { protocol, host } = new URL(details.url) - // If it's a WebSocket Secure request directed to a github.com subdomain, - // probably related to the Alive server, we need to override the `Origin` - // header with a valid value. - if (protocol === 'wss:' && /(^|\.)github\.com$/.test(host)) { - return { - requestHeaders: { - ...details.requestHeaders, - // TODO: discuss with Alive team a good Origin value to use here - Origin: 'https://desktop.github.com', - }, - } + // Here we're only interested in WebSockets + if (protocol !== 'wss:') { + return {} } - return {} + // Alive URLs are supposed to be prefixed by "alive" and then the hostname + if ( + !/^alive\.github\.com$/.test(host) && + !/^alive\.(.*)\.ghe\.com$/.test(host) + ) { + return {} + } + + // We will just replace the `alive` prefix (which indicates the service) + // with `desktop`. + return { + requestHeaders: { + ...details.requestHeaders, + Origin: `https://${host.replace('alive.', 'desktop.')}`, + }, + } }) } diff --git a/app/src/main-process/app-window.ts b/app/src/main-process/app-window.ts index 54fff826f05..0a2435f7f9e 100644 --- a/app/src/main-process/app-window.ts +++ b/app/src/main-process/app-window.ts @@ -6,6 +6,7 @@ import { autoUpdater, nativeTheme, } from 'electron' +import { shell } from '../lib/app-shell' import { Emitter, Disposable } from 'event-kit' import { encodePathAsUrl } from '../lib/path' import { @@ -25,6 +26,9 @@ import { installNotificationCallback, terminateDesktopNotifications, } from './notifications' +import { addTrustedIPCSender } from './trusted-ipc-sender' +import { getUpdaterGUID } from '../lib/get-updater-guid' +import { CLIAction } from '../lib/cli-action' export class AppWindow { private window: Electron.BrowserWindow @@ -32,6 +36,7 @@ export class AppWindow { private _loadTime: number | null = null private _rendererReadyTime: number | null = null + private isDownloadingUpdate: boolean = false private minWidth = 960 private minHeight = 660 @@ -77,6 +82,7 @@ export class AppWindow { } this.window = new BrowserWindow(windowOptions) + addTrustedIPCSender(this.window.webContents) installNotificationCallback(this.window) @@ -84,6 +90,7 @@ export class AppWindow { this.shouldMaximizeOnShow = savedWindowState.isMaximized let quitting = false + let quittingEvenIfUpdating = false app.on('before-quit', () => { quitting = true }) @@ -93,7 +100,39 @@ export class AppWindow { event.returnValue = true }) + ipcMain.on('will-quit-even-if-updating', event => { + quitting = true + quittingEvenIfUpdating = true + event.returnValue = true + }) + + ipcMain.on('cancel-quitting', event => { + quitting = false + quittingEvenIfUpdating = false + event.returnValue = true + }) + this.window.on('close', e => { + // On macOS, closing the window doesn't mean the app is quitting. If the + // app is updating, we will prevent the window from closing only when the + // app is also quitting. + if ( + (!__DARWIN__ || quitting) && + !quittingEvenIfUpdating && + this.isDownloadingUpdate + ) { + e.preventDefault() + ipcWebContents.send(this.window.webContents, 'show-installing-update') + + // Make sure the window is visible, so the user can see why we're + // preventing the app from quitting. This is important on macOS, where + // the window could be hidden/closed when the user tries to quit. + // It could also happen on Windows if the user quits the app from the + // task bar while it's in the background. + this.show() + return + } + // on macOS, when the user closes the window we really just hide it. This // lets us activate quickly and keep all our interesting logic in the // renderer. @@ -102,9 +141,9 @@ export class AppWindow { // https://github.com/desktop/desktop/issues/12838 if (this.window.isFullScreen()) { this.window.setFullScreen(false) - this.window.once('leave-full-screen', () => app.hide()) + this.window.once('leave-full-screen', () => this.window.hide()) } else { - app.hide() + this.window.hide() } return } @@ -211,7 +250,7 @@ export class AppWindow { return !!this.loadTime && !!this.rendererReadyTime } - public onClose(fn: () => void) { + public onClosed(fn: () => void) { this.window.on('closed', fn) } @@ -274,6 +313,13 @@ export class AppWindow { ipcWebContents.send(this.window.webContents, 'url-action', action) } + /** Send the URL action to the renderer. */ + public sendCLIAction(action: CLIAction) { + this.show() + + ipcWebContents.send(this.window.webContents, 'cli-action', action) + } + /** Send the app launch timing stats to the renderer. */ public sendLaunchTimingStats(stats: ILaunchStats) { ipcWebContents.send(this.window.webContents, 'launch-timing-stats', stats) @@ -288,6 +334,32 @@ export class AppWindow { } } + /** Handle when a modal dialog is opened. */ + public dialogDidOpen() { + if (this.window.isFocused()) { + // No additional notifications are needed. + return + } + // Care is taken to mimic OS dialog behaviors. + if (__DARWIN__) { + // macOS beeps when a modal dialog is opened. + shell.beep() + // See https://developer.apple.com/documentation/appkit/nsapplication/1428358-requestuserattention + // "If the inactive app presents a modal panel, this method will be invoked with NSCriticalRequest + // automatically. The modal panel is not brought to the front for an inactive app." + // NOTE: flashFrame() uses the 'informational' level, so we need to explicitly bounce the dock + // with the 'critical' level in order to that described behavior. + app.dock.bounce('critical') + } else { + // See https://learn.microsoft.com/en-us/windows/win32/uxguide/winenv-taskbar#taskbar-button-flashing + // "If an inactive program requires immediate attention, + // flash its taskbar button to draw attention and leave it highlighted." + // It advises not to beep. + this.window.once('focus', () => this.window.flashFrame(false)) + this.window.flashFrame(true) + } + } + /** Send a certificate error to the renderer. */ public sendCertificateError( certificate: Electron.Certificate, @@ -342,10 +414,12 @@ export class AppWindow { public setupAutoUpdater() { autoUpdater.on('error', (error: Error) => { + this.isDownloadingUpdate = false ipcWebContents.send(this.window.webContents, 'auto-updater-error', error) }) autoUpdater.on('checking-for-update', () => { + this.isDownloadingUpdate = false ipcWebContents.send( this.window.webContents, 'auto-updater-checking-for-update' @@ -353,6 +427,7 @@ export class AppWindow { }) autoUpdater.on('update-available', () => { + this.isDownloadingUpdate = true ipcWebContents.send( this.window.webContents, 'auto-updater-update-available' @@ -360,6 +435,7 @@ export class AppWindow { }) autoUpdater.on('update-not-available', () => { + this.isDownloadingUpdate = false ipcWebContents.send( this.window.webContents, 'auto-updater-update-not-available' @@ -367,6 +443,7 @@ export class AppWindow { }) autoUpdater.on('update-downloaded', () => { + this.isDownloadingUpdate = false ipcWebContents.send( this.window.webContents, 'auto-updater-update-downloaded' @@ -374,9 +451,9 @@ export class AppWindow { }) } - public checkForUpdates(url: string) { + public async checkForUpdates(url: string) { try { - autoUpdater.setFeedURL({ url }) + autoUpdater.setFeedURL({ url: await trySetUpdaterGuid(url) }) autoUpdater.checkForUpdates() } catch (e) { return e @@ -439,3 +516,18 @@ export class AppWindow { return filePaths.length > 0 ? filePaths[0] : null } } + +const trySetUpdaterGuid = async (url: string) => { + try { + const id = await getUpdaterGUID() + if (!id) { + return url + } + + const parsed = new URL(url) + parsed.searchParams.set('guid', id) + return parsed.toString() + } catch (e) { + return url + } +} diff --git a/app/src/main-process/authenticated-avatar-filter.ts b/app/src/main-process/authenticated-avatar-filter.ts deleted file mode 100644 index cb7b8ace5a6..00000000000 --- a/app/src/main-process/authenticated-avatar-filter.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { EndpointToken } from '../lib/endpoint-token' -import { OrderedWebRequest } from './ordered-webrequest' - -/** - * Installs a web request filter which adds the Authorization header for - * unauthenticated requests to the GHES/GHAE private avatars API. - * - * Returns a method that can be used to update the list of signed-in accounts - * which is used to resolve which token to use. - */ -export function installAuthenticatedAvatarFilter( - orderedWebRequest: OrderedWebRequest -) { - let originTokens = new Map() - - orderedWebRequest.onBeforeSendHeaders.addEventListener(async details => { - const { origin, pathname } = new URL(details.url) - const token = originTokens.get(origin) - - if (token && pathname.startsWith('/api/v3/enterprise/avatars/')) { - return { - requestHeaders: { - ...details.requestHeaders, - Authorization: `token ${token}`, - }, - } - } - - return {} - }) - - return (accounts: ReadonlyArray) => { - originTokens = new Map( - accounts.map(({ endpoint, token }) => [new URL(endpoint).origin, token]) - ) - } -} diff --git a/app/src/main-process/authenticated-image-filter.ts b/app/src/main-process/authenticated-image-filter.ts new file mode 100644 index 00000000000..8e970193719 --- /dev/null +++ b/app/src/main-process/authenticated-image-filter.ts @@ -0,0 +1,64 @@ +import { getDotComAPIEndpoint, getHTMLURL } from '../lib/api' +import { EndpointToken } from '../lib/endpoint-token' +import { OrderedWebRequest } from './ordered-webrequest' + +function isEnterpriseAvatarPath(pathname: string) { + return pathname.startsWith('/api/v3/enterprise/avatars/') +} + +function isGitHubRepoAssetPath(pathname: string) { + // Matches paths like: /repo/owner/assets/userID/guid + return ( + /^\/[^/]+\/[^/]+\/assets\/[^/]+\/[^/]+\/?$/.test(pathname) || + // or: /user-attachments/assets/guid + /^\/user-attachments\/assets\/[^/]+\/?$/.test(pathname) + ) +} + +/** + * Installs a web request filter which adds the Authorization header for + * unauthenticated requests to the GHES/GHAE private avatars API, and for private + * repo assets. + * + * Returns a method that can be used to update the list of signed-in accounts + * which is used to resolve which token to use. + */ +export function installAuthenticatedImageFilter( + orderedWebRequest: OrderedWebRequest +) { + let originTokens = new Map() + + orderedWebRequest.onBeforeSendHeaders.addEventListener(async details => { + const { origin, pathname } = new URL(details.url) + const token = originTokens.get(origin) + + if ( + token && + (isEnterpriseAvatarPath(pathname) || isGitHubRepoAssetPath(pathname)) + ) { + return { + requestHeaders: { + ...details.requestHeaders, + Authorization: `token ${token}`, + }, + } + } + + return {} + }) + + return (accounts: ReadonlyArray) => { + originTokens = new Map( + accounts.map(({ endpoint, token }) => [new URL(endpoint).origin, token]) + ) + + // If we have a token for api.github.com, add another entry in our + // tokens-by-origin map with the same token for github.com. This is + // necessary for private image URLs. + const dotComAPIEndpoint = getDotComAPIEndpoint() + const dotComAPIToken = originTokens.get(dotComAPIEndpoint) + if (dotComAPIToken) { + originTokens.set(getHTMLURL(dotComAPIEndpoint), dotComAPIToken) + } + } +} diff --git a/app/src/main-process/crash-window.ts b/app/src/main-process/crash-window.ts index 3a934d05896..d91fee11efc 100644 --- a/app/src/main-process/crash-window.ts +++ b/app/src/main-process/crash-window.ts @@ -4,6 +4,7 @@ import { ICrashDetails, ErrorType } from '../crash/shared' import { registerWindowStateChangedEvents } from '../lib/window-state' import * as ipcMain from './ipc-main' import * as ipcWebContents from './ipc-webcontents' +import { addTrustedIPCSender } from './trusted-ipc-sender' const minWidth = 600 const minHeight = 500 @@ -51,6 +52,7 @@ export class CrashWindow { } this.window = new BrowserWindow(windowOptions) + addTrustedIPCSender(this.window.webContents) this.error = error this.errorType = errorType diff --git a/app/src/main-process/desktop-file-transport.ts b/app/src/main-process/desktop-file-transport.ts index 0ec6e714ed3..7bda371d672 100644 --- a/app/src/main-process/desktop-file-transport.ts +++ b/app/src/main-process/desktop-file-transport.ts @@ -5,7 +5,7 @@ import TransportStream, { TransportStreamOptions } from 'winston-transport' import { EOL } from 'os' import { readdir, unlink } from 'fs/promises' import { promisify } from 'util' -import { escapeRegExp } from 'lodash' +import escapeRegExp from 'lodash/escapeRegExp' type DesktopFileTransportOptions = TransportStreamOptions & { readonly logDirectory: string diff --git a/app/src/main-process/ipc-main.ts b/app/src/main-process/ipc-main.ts index 1260ccfa25e..a68f4c99f5b 100644 --- a/app/src/main-process/ipc-main.ts +++ b/app/src/main-process/ipc-main.ts @@ -2,6 +2,17 @@ import { RequestChannels, RequestResponseChannels } from '../lib/ipc-shared' // eslint-disable-next-line no-restricted-imports import { ipcMain } from 'electron' import { IpcMainEvent, IpcMainInvokeEvent } from 'electron/main' +import { isTrustedIPCSender } from './trusted-ipc-sender' + +type RequestChannelListener = ( + event: IpcMainEvent, + ...args: Parameters +) => void + +type RequestResponseChannelListener = ( + event: IpcMainInvokeEvent, + ...args: Parameters +) => ReturnType /** * Subscribes to the specified IPC channel and provides strong typing of @@ -10,12 +21,9 @@ import { IpcMainEvent, IpcMainInvokeEvent } from 'electron/main' */ export function on( channel: T, - listener: ( - event: IpcMainEvent, - ...args: Parameters - ) => void + listener: RequestChannelListener ) { - ipcMain.on(channel, (event, ...args) => listener(event, ...(args as any))) + ipcMain.on(channel, safeListener(listener)) } /** @@ -25,12 +33,9 @@ export function on( */ export function once( channel: T, - listener: ( - event: IpcMainEvent, - ...args: Parameters - ) => void + listener: RequestChannelListener ) { - ipcMain.once(channel, (event, ...args) => listener(event, ...(args as any))) + ipcMain.once(channel, safeListener(listener)) } /** @@ -40,10 +45,22 @@ export function once( */ export function handle( channel: T, - listener: ( - event: IpcMainInvokeEvent, - ...args: Parameters - ) => ReturnType + listener: RequestResponseChannelListener +) { + ipcMain.handle(channel, safeListener(listener)) +} + +function safeListener( + listener: (event: E, ...a: any) => R ) { - ipcMain.handle(channel, (event, ...args) => listener(event, ...(args as any))) + return (event: E, ...args: any) => { + if (!isTrustedIPCSender(event.sender)) { + log.error( + `IPC message received from invalid sender: ${event.senderFrame.url}` + ) + return + } + + return listener(event, ...args) + } } diff --git a/app/src/main-process/log.ts b/app/src/main-process/log.ts index 213280ef63f..9186f9a002f 100644 --- a/app/src/main-process/log.ts +++ b/app/src/main-process/log.ts @@ -1,7 +1,7 @@ import * as winston from 'winston' import { getLogDirectoryPath } from '../lib/logging/get-log-path' import { LogLevel } from '../lib/logging/log-level' -import { noop } from 'lodash' +import noop from 'lodash/noop' import { DesktopConsoleTransport } from './desktop-console-transport' import memoizeOne from 'memoize-one' import { mkdir } from 'fs/promises' diff --git a/app/src/main-process/main.ts b/app/src/main-process/main.ts index 114480145f1..81cc75ffd68 100644 --- a/app/src/main-process/main.ts +++ b/app/src/main-process/main.ts @@ -10,13 +10,16 @@ import { nativeTheme, } from 'electron' import * as Fs from 'fs' -import * as URL from 'url' import { AppWindow } from './app-window' import { buildDefaultMenu, getAllMenuItems } from './menu' import { shellNeedsPatching, updateEnvironmentForProcess } from '../lib/shell' import { parseAppURL } from '../lib/parse-app-url' -import { handleSquirrelEvent } from './squirrel-updater' +import { + handleSquirrelEvent, + installWindowsCLI, + uninstallWindowsCLI, +} from './squirrel-updater' import { fatalError } from '../lib/fatal-error' import { log as writeLog } from './log' @@ -30,7 +33,7 @@ import { now } from './now' import { showUncaughtException } from './show-uncaught-exception' import { buildContextMenu } from './menu/build-context-menu' import { OrderedWebRequest } from './ordered-webrequest' -import { installAuthenticatedAvatarFilter } from './authenticated-avatar-filter' +import { installAuthenticatedImageFilter } from './authenticated-image-filter' import { installAliveOriginFilter } from './alive-origin-filter' import { installSameOriginFilter } from './same-origin-filter' import * as ipcMain from './ipc-main' @@ -46,6 +49,8 @@ import { showNotification, } from 'desktop-notifications' import { initializeDesktopNotifications } from './notifications' +import parseCommandLineArgs from 'minimist' +import { CLIAction } from '../lib/cli-action' app.setAppLogsPath() enableSourceMaps() @@ -135,22 +140,20 @@ process.on('uncaughtException', (error: Error) => { let handlingSquirrelEvent = false if (__WIN32__ && process.argv.length > 1) { const arg = process.argv[1] - const promise = handleSquirrelEvent(arg) + if (promise) { handlingSquirrelEvent = true promise - .catch(e => { - log.error(`Failed handling Squirrel event: ${arg}`, e) - }) - .then(() => { - app.quit() - }) - } else { - handlePossibleProtocolLauncherArgs(process.argv) + .catch(e => log.error(`Failed handling Squirrel event: ${arg}`, e)) + .then(() => app.quit()) } } +if (!handlingSquirrelEvent) { + handleCommandLineArguments(process.argv) +} + initializeDesktopNotifications() function handleAppURL(url: string) { @@ -186,7 +189,7 @@ if (!handlingSquirrelEvent) { mainWindow.focus() } - handlePossibleProtocolLauncherArgs(args) + handleCommandLineArguments(args) }) if (isDuplicateInstance) { @@ -225,53 +228,46 @@ if (__DARWIN__) { return } - handleAppURL( - `x-github-client://openLocalRepo/${encodeURIComponent(path)}` - ) + // Yeah this isn't technically a CLI action we use it here to indicate + // that it's more trusted than a URL action. + handleCLIAction({ kind: 'open-repository', path }) }) }) } -/** - * Attempt to detect and handle any protocol handler arguments passed - * either via the command line directly to the current process or through - * IPC from a duplicate instance (see makeSingleInstance) - * - * @param args Essentially process.argv, i.e. the first element is the exec - * path - */ -function handlePossibleProtocolLauncherArgs(args: ReadonlyArray) { - log.info(`Received possible protocol arguments: ${args.length}`) +async function handleCommandLineArguments(argv: string[]) { + const args = parseCommandLineArgs(argv) - if (__WIN32__) { - // Desktop registers it's protocol handler callback on Windows as - // `[executable path] --protocol-launcher "%1"`. Note that extra command - // line arguments might be added by Chromium - // (https://electronjs.org/docs/api/app#event-second-instance). - // At launch Desktop checks for that exact scenario here before doing any - // processing. If there's more than one matching url argument because of a - // malformed or untrusted url then we bail out. - - const matchingUrls = args.filter(arg => { - // sometimes `URL.parse` throws an error - try { - const url = URL.parse(arg) - // i think this `slice` is just removing a trailing `:` - return url.protocol && possibleProtocols.has(url.protocol.slice(0, -1)) - } catch (e) { - log.error(`Unable to parse argument as URL: ${arg}`) - return false - } - }) + // Desktop registers it's protocol handler callback on Windows as + // `[executable path] --protocol-launcher "%1"`. Note that extra command + // line arguments might be added by Chromium + // (https://electronjs.org/docs/api/app#event-second-instance). + if (__WIN32__ && typeof args['protocol-launcher'] === 'string') { + handleAppURL(args['protocol-launcher']) + return + } - if (args.includes(protocolLauncherArg) && matchingUrls.length === 1) { - handleAppURL(matchingUrls[0]) - } else { - log.error(`Malformed launch arguments received: ${args}`) - } - } else if (args.length > 1) { - handleAppURL(args[1]) + if (typeof args['cli-open'] === 'string') { + handleCLIAction({ kind: 'open-repository', path: args['cli-open'] }) + } else if (typeof args['cli-clone'] === 'string') { + handleCLIAction({ + kind: 'clone-url', + url: args['cli-clone'], + branch: + typeof args['cli-branch'] === 'string' ? args['cli-branch'] : undefined, + }) } + + return +} + +function handleCLIAction(action: CLIAction) { + onDidLoad(window => { + // This manual focus call _shouldn't_ be necessary, but is for Chrome on + // macOS. See https://github.com/desktop/desktop/issues/973. + window.focus() + window.sendCLIAction(action) + }) } /** @@ -317,8 +313,9 @@ app.on('ready', () => { // Ensures Alive websocket sessions are initiated with an acceptable Origin installAliveOriginFilter(orderedWebRequest) - // Adds an authorization header for requests of avatars on GHES - const updateAccounts = installAuthenticatedAvatarFilter(orderedWebRequest) + // Adds an authorization header for requests of avatars on GHES and private + // repo assets + const updateAccounts = installAuthenticatedImageFilter(orderedWebRequest) Menu.setApplicationMenu( buildDefaultMenu({ @@ -490,6 +487,8 @@ app.on('ready', () => { mainWindow?.quitAndInstallUpdate() ) + ipcMain.on('quit-app', () => app.quit()) + ipcMain.on('minimize-window', () => mainWindow?.minimizeWindow()) ipcMain.on('maximize-window', () => mainWindow?.maximizeWindow()) @@ -519,6 +518,11 @@ app.on('ready', () => { mainWindow?.setWindowZoomFactor(zoomFactor) ) + if (__WIN32__) { + ipcMain.on('install-windows-cli', installWindowsCLI) + ipcMain.on('uninstall-windows-cli', uninstallWindowsCLI) + } + /** * An event sent by the renderer asking for a copy of the current * application menu. @@ -605,6 +609,9 @@ app.on('ready', () => { mainWindow?.selectAllWindowContents() ) + /** An event sent by the renderer indicating a modal dialog is opened */ + ipcMain.on('dialog-did-open', () => mainWindow?.dialogDidOpen()) + /** * An event sent by the renderer asking whether the Desktop is in the * applications folder @@ -687,11 +694,11 @@ app.on('activate', () => { }) app.on('web-contents-created', (event, contents) => { - contents.on('new-window', (event, url) => { - // Prevent links or window.open from opening new windows - event.preventDefault() + contents.setWindowOpenHandler(({ url }) => { log.warn(`Prevented new window to: ${url}`) + return { action: 'deny' } }) + // prevent link navigation within our windows // see https://www.electronjs.org/docs/tutorial/security#12-disable-or-limit-navigation contents.on('will-navigate', (event, url) => { @@ -720,14 +727,18 @@ function createWindow() { REACT_DEVELOPER_TOOLS, } = require('electron-devtools-installer') - require('electron-debug')({ showDevTools: true }) - const ChromeLens = { id: 'idikgljglpfilbhaboonnpnnincjhjkd', electron: '>=1.2.1', } - const extensions = [REACT_DEVELOPER_TOOLS, ChromeLens] + const axeDevTools = { + id: 'lhdoppojpmngadmnindnejefpokejbdd', + electron: '>=1.2.1', + Permissions: ['tabs', 'debugger'], + } + + const extensions = [REACT_DEVELOPER_TOOLS, ChromeLens, axeDevTools] for (const extension of extensions) { try { @@ -738,7 +749,7 @@ function createWindow() { } } - window.onClose(() => { + window.onClosed(() => { mainWindow = null if (!__DARWIN__ && !preventQuit) { app.quit() diff --git a/app/src/main-process/menu/build-default-menu.ts b/app/src/main-process/menu/build-default-menu.ts index a79f1dba508..a67781c2921 100644 --- a/app/src/main-process/menu/build-default-menu.ts +++ b/app/src/main-process/menu/build-default-menu.ts @@ -5,11 +5,10 @@ import { truncateWithEllipsis } from '../../lib/truncate-with-ellipsis' import { getLogDirectoryPath } from '../../lib/logging/get-log-path' import { UNSAFE_openDirectory } from '../shell' import { MenuLabelsEvent } from '../../models/menu-labels' -import { enableSquashMerging } from '../../lib/feature-flag' import * as ipcWebContents from '../ipc-webcontents' import { mkdir } from 'fs/promises' +import { buildTestMenu } from './build-test-menu' -const platformDefaultShell = __WIN32__ ? 'Command Prompt' : 'Terminal' const createPullRequestLabel = __DARWIN__ ? 'Create Pull Request' : 'Create &pull request' @@ -32,18 +31,25 @@ enum ZoomDirection { Out, } +export const separator: Electron.MenuItemConstructorOptions = { + type: 'separator', +} + export function buildDefaultMenu({ selectedExternalEditor, selectedShell, askForConfirmationOnForcePush, askForConfirmationOnRepositoryRemoval, hasCurrentPullRequest = false, - defaultBranchName = defaultBranchNameValue, + contributionTargetDefaultBranch = defaultBranchNameValue, isForcePushForCurrentRepository = false, isStashedChangesVisible = false, askForConfirmationWhenStashingAllChanges = true, }: MenuLabelsEvent): Electron.Menu { - defaultBranchName = truncateWithEllipsis(defaultBranchName, 25) + contributionTargetDefaultBranch = truncateWithEllipsis( + contributionTargetDefaultBranch, + 25 + ) const removeRepoLabel = askForConfirmationOnRepositoryRemoval ? confirmRepositoryRemovalLabel @@ -54,7 +60,6 @@ export function buildDefaultMenu({ : createPullRequestLabel const template = new Array() - const separator: Electron.MenuItemConstructorOptions = { type: 'separator' } if (__DARWIN__) { template.push({ @@ -67,7 +72,7 @@ export function buildDefaultMenu({ }, separator, { - label: 'Preferences…', + label: 'Settings…', id: 'preferences', accelerator: 'CmdOrCtrl+,', click: emit('show-preferences'), @@ -76,7 +81,7 @@ export function buildDefaultMenu({ { label: 'Install Command Line Tool…', id: 'install-cli', - click: emit('install-cli'), + click: emit('install-darwin-cli'), }, separator, { @@ -120,6 +125,7 @@ export function buildDefaultMenu({ if (!__DARWIN__) { const fileItems = fileMenu.submenu as Electron.MenuItemConstructorOptions[] + const exitAccelerator = __WIN32__ ? 'Alt+F4' : 'CmdOrCtrl+Q' fileItems.push( separator, @@ -133,7 +139,7 @@ export function buildDefaultMenu({ { role: 'quit', label: 'E&xit', - accelerator: 'Alt+F4', + accelerator: exitAccelerator, } ) } @@ -226,6 +232,22 @@ export function buildDefaultMenu({ accelerator: 'CmdOrCtrl+-', click: zoom(ZoomDirection.Out), }, + { + label: __DARWIN__ + ? 'Expand Active Resizable' + : 'Expand active resizable', + id: 'increase-active-resizable-width', + accelerator: 'CmdOrCtrl+9', + click: emit('increase-active-resizable-width'), + }, + { + label: __DARWIN__ + ? 'Contract Active Resizable' + : 'Contract active resizable', + id: 'decrease-active-resizable-width', + accelerator: 'CmdOrCtrl+8', + click: emit('decrease-active-resizable-width'), + }, separator, { label: '&Reload', @@ -235,8 +257,8 @@ export function buildDefaultMenu({ // chorded shortcuts, but this menu item is not a user-facing feature // so we are going to keep this one around. accelerator: 'CmdOrCtrl+Alt+R', - click(item: any, focusedWindow: Electron.BrowserWindow | undefined) { - if (focusedWindow) { + click(item: any, focusedWindow: Electron.BaseWindow | undefined) { + if (focusedWindow instanceof BrowserWindow) { focusedWindow.reload() } }, @@ -250,8 +272,8 @@ export function buildDefaultMenu({ accelerator: (() => { return __DARWIN__ ? 'Alt+Command+I' : 'Ctrl+Shift+I' })(), - click(item: any, focusedWindow: Electron.BrowserWindow | undefined) { - if (focusedWindow) { + click(item: any, focusedWindow: Electron.BaseWindow | undefined) { + if (focusedWindow instanceof BrowserWindow) { focusedWindow.webContents.toggleDevTools() } }, @@ -282,6 +304,12 @@ export function buildDefaultMenu({ accelerator: 'CmdOrCtrl+Shift+P', click: emit('pull'), }, + { + id: 'fetch', + label: __DARWIN__ ? 'Fetch' : '&Fetch', + accelerator: 'CmdOrCtrl+Shift+T', + click: emit('fetch'), + }, { label: removeRepoLabel, id: 'remove-repository', @@ -297,8 +325,8 @@ export function buildDefaultMenu({ }, { label: __DARWIN__ - ? `Open in ${selectedShell ?? platformDefaultShell}` - : `O&pen in ${selectedShell ?? platformDefaultShell}`, + ? `Open in ${selectedShell ?? 'Shell'}` + : `O&pen in ${selectedShell ?? 'shell'}`, id: 'open-in-shell', accelerator: 'Ctrl+`', click: emit('open-in-shell'), @@ -376,11 +404,11 @@ export function buildDefaultMenu({ separator, { label: __DARWIN__ - ? `Update from ${defaultBranchName}` - : `&Update from ${defaultBranchName}`, - id: 'update-branch', + ? `Update from ${contributionTargetDefaultBranch}` + : `&Update from ${contributionTargetDefaultBranch}`, + id: 'update-branch-with-contribution-target-branch', accelerator: 'CmdOrCtrl+Shift+U', - click: emit('update-branch'), + click: emit('update-branch-with-contribution-target-branch'), }, { label: __DARWIN__ ? 'Compare to Branch' : '&Compare to branch', @@ -396,20 +424,14 @@ export function buildDefaultMenu({ accelerator: 'CmdOrCtrl+Shift+M', click: emit('merge-branch'), }, - ] - - if (enableSquashMerging()) { - branchSubmenu.push({ + { label: __DARWIN__ ? 'Squash and Merge into Current Branch…' : 'Squas&h and merge into current branch…', id: 'squash-and-merge-branch', accelerator: 'CmdOrCtrl+Shift+H', click: emit('squash-and-merge-branch'), - }) - } - - branchSubmenu.push( + }, { label: __DARWIN__ ? 'Rebase Current Branch…' : 'R&ebase current branch…', id: 'rebase-branch', @@ -429,13 +451,21 @@ export function buildDefaultMenu({ accelerator: 'CmdOrCtrl+Alt+B', click: emit('branch-on-github'), }, - { - label: pullRequestLabel, - id: 'create-pull-request', - accelerator: 'CmdOrCtrl+R', - click: emit('open-pull-request'), - } - ) + ] + + branchSubmenu.push({ + label: __DARWIN__ ? 'Preview Pull Request' : 'Preview pull request', + id: 'preview-pull-request', + accelerator: 'CmdOrCtrl+Alt+P', + click: emit('preview-pull-request'), + }) + + branchSubmenu.push({ + label: pullRequestLabel, + id: 'create-pull-request', + accelerator: 'CmdOrCtrl+R', + click: emit('open-pull-request'), + }) template.push({ label: __DARWIN__ ? 'Branch' : '&Branch', @@ -520,45 +550,7 @@ export function buildDefaultMenu({ showLogsItem, ] - if (__DEV__) { - helpItems.push( - separator, - { - label: 'Crash main process…', - click() { - throw new Error('Boomtown!') - }, - }, - { - label: 'Crash renderer process…', - click: emit('boomtown'), - }, - { - label: 'Show popup', - submenu: [ - { - label: 'Release notes', - click: emit('show-release-notes-popup'), - }, - { - label: 'Pull Request Check Run Failed', - click: emit('pull-request-check-run-failed'), - }, - ], - }, - { - label: 'Prune branches', - click: emit('test-prune-branches'), - } - ) - } - - if (__RELEASE_CHANNEL__ === 'development' || __RELEASE_CHANNEL__ === 'test') { - helpItems.push({ - label: 'Show notification', - click: emit('test-show-notification'), - }) - } + helpItems.push(...buildTestMenu()) if (__DARWIN__) { template.push({ @@ -610,7 +602,7 @@ function getStashedChangesLabel(isStashedChangesVisible: boolean): string { type ClickHandler = ( menuItem: Electron.MenuItem, - browserWindow: Electron.BrowserWindow | undefined, + browserWindow: Electron.BaseWindow | undefined, event: Electron.KeyboardEvent ) => void @@ -618,14 +610,17 @@ type ClickHandler = ( * Utility function returning a Click event handler which, when invoked, emits * the provided menu event over IPC. */ -function emit(name: MenuEvent): ClickHandler { +export function emit(name: MenuEvent): ClickHandler { return (_, focusedWindow) => { // focusedWindow can be null if the menu item was clicked without the window // being in focus. A simple way to reproduce this is to click on a menu item // while in DevTools. Since Desktop only supports one window at a time we // can be fairly certain that the first BrowserWindow we find is the one we // want. - const window = focusedWindow ?? BrowserWindow.getAllWindows()[0] + const window = + focusedWindow instanceof BrowserWindow + ? focusedWindow + : BrowserWindow.getAllWindows()[0] if (window !== undefined) { ipcWebContents.send(window.webContents, 'menu-event', name) } @@ -633,7 +628,7 @@ function emit(name: MenuEvent): ClickHandler { } /** The zoom steps that we support, these factors must sorted */ -const ZoomInFactors = [1, 1.1, 1.25, 1.5, 1.75, 2] +const ZoomInFactors = [0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2] const ZoomOutFactors = ZoomInFactors.slice().reverse() /** @@ -654,7 +649,7 @@ function findClosestValue(arr: Array, value: number) { */ function zoom(direction: ZoomDirection): ClickHandler { return (menuItem, window) => { - if (!window) { + if (!(window instanceof BrowserWindow)) { return } diff --git a/app/src/main-process/menu/build-spell-check-menu.ts b/app/src/main-process/menu/build-spell-check-menu.ts index 1a6d6f658c0..730ece72368 100644 --- a/app/src/main-process/menu/build-spell-check-menu.ts +++ b/app/src/main-process/menu/build-spell-check-menu.ts @@ -13,9 +13,21 @@ export async function buildSpellCheckMenu( dom. */ return new Promise(resolve => { - window.webContents.once('context-menu', (event, params) => + /** This is to make sure the context menu invocation doesn't just hang + * waiting to find out if it needs spell checker menu items if electron + * never emits it's context menu event. This is known to happen with the + * Shift + F10 key on macOS */ + const timer = setTimeout(() => { + resolve(undefined) + log.error( + `Unable to get spell check menu items - no electron context-menu event` + ) + }, 100) + + window.webContents.once('context-menu', (event, params) => { + clearTimeout(timer) resolve(getSpellCheckMenuItems(event, params, window.webContents)) - ) + }) }) } @@ -60,35 +72,67 @@ function getSpellCheckMenuItems( // NOTE: "On macOS as we use the native APIs there is no way to set the // language that the spellchecker uses" -- electron docs Therefore, we are // only allowing setting to English for non-mac machines. - const spellCheckLanguageItem = getSpellCheckLanguageMenuItem( - webContents.session + const { session } = webContents + const spellCheckLanguageItem = getSpellCheckLanguageMenuItemOptions( + app.getLocale(), + session.getSpellCheckerLanguages(), + session.availableSpellCheckerLanguages ) if (spellCheckLanguageItem !== null) { - items.push(spellCheckLanguageItem) + items.push( + new MenuItem({ + label: spellCheckLanguageItem.label, + click: () => + session.setSpellCheckerLanguages(spellCheckLanguageItem.languages), + }) + ) } } return items } +interface ISpellCheckMenuItemOption { + /** + * Dynamic label based on spellchecker's state + */ + readonly label: string + + /** + * An array with languages to set spellchecker + */ + readonly languages: string[] +} + +export const SpellcheckEnglishLabel = 'Set spellcheck to English' +export const SpellcheckSystemLabel = 'Set spellcheck to system language' + /** - * Method to get a menu item to give user the option to use English or their - * system language. + * Method to get a menu item options to give user the choice to use English or + * their system language. + * + * If system language is english or it's not part of the available languages, + * it returns null. If spellchecker is not set to english, it returns options + * that can set it to English. If spellchecker is set to english, it returns + * the options that can set it to their system language. * - * If system language is english, it returns null. If spellchecker is not set to - * english, it returns item that can set it to English. If spellchecker is set - * to english, it returns the item that can set it to their system language. + * @param userLanguageCode Language code based on user's locale. + * @param spellcheckLanguageCodes An array of language codes the spellchecker + * is enabled for. + * @param availableSpellcheckLanguages An array which consists of all available + * spellchecker languages. */ -function getSpellCheckLanguageMenuItem( - session: Electron.Session -): MenuItem | null { - const userLanguageCode = app.getLocale() +export function getSpellCheckLanguageMenuItemOptions( + userLanguageCode: string, + spellcheckLanguageCodes: string[], + availableSpellcheckLanguages: string[] +): ISpellCheckMenuItemOption | null { const englishLanguageCode = 'en-US' - const spellcheckLanguageCodes = session.getSpellCheckerLanguages() if ( - userLanguageCode === englishLanguageCode && - spellcheckLanguageCodes.includes(englishLanguageCode) + (userLanguageCode === englishLanguageCode && + spellcheckLanguageCodes.includes(englishLanguageCode)) || + !availableSpellcheckLanguages.includes(userLanguageCode) ) { return null } @@ -101,11 +145,11 @@ function getSpellCheckLanguageMenuItem( const label = languageCode === englishLanguageCode - ? 'Set spellcheck to English' - : 'Set spellcheck to system language' + ? SpellcheckEnglishLabel + : SpellcheckSystemLabel - return new MenuItem({ + return { label, - click: () => session.setSpellCheckerLanguages([languageCode]), - }) + languages: [languageCode], + } } diff --git a/app/src/main-process/menu/build-test-menu.ts b/app/src/main-process/menu/build-test-menu.ts new file mode 100644 index 00000000000..0026f449e83 --- /dev/null +++ b/app/src/main-process/menu/build-test-menu.ts @@ -0,0 +1,198 @@ +import { MenuItemConstructorOptions } from 'electron' +import { enableTestMenuItems } from '../../lib/feature-flag' +import { emit, separator } from './build-default-menu' + +export function buildTestMenu() { + if (!enableTestMenuItems()) { + return [] + } + + const testMenuItems: MenuItemConstructorOptions[] = [] + + if (__WIN32__) { + testMenuItems.push(separator, { + label: 'Command Line Tool', + submenu: [ + { + label: 'Install', + click: emit('install-windows-cli'), + }, + { + label: 'Uninstall', + click: emit('uninstall-windows-cli'), + }, + ], + }) + } + + const errorDialogsSubmenu: MenuItemConstructorOptions[] = [ + { + label: 'Confirm Committing Conflicted Files', + click: emit('test-confirm-committing-conflicted-files'), + }, + { + label: 'Discarded Changes Will Be Unrecoverable', + click: emit('test-discarded-changes-will-be-unrecoverable'), + }, + { + label: 'Do you want to fork this repository?', + click: emit('test-do-you-want-fork-this-repository'), + }, + { + label: 'Newer Commits On Remote', + click: emit('test-newer-commits-on-remote'), + }, + { + label: 'Files Too Large', + click: emit('test-files-too-large'), + }, + { + label: 'Generic Git Authentication', + click: emit('test-generic-git-authentication'), + }, + { + label: 'Invalidated Account Token', + click: emit('test-invalidated-account-token'), + }, + ] + + if (__DARWIN__) { + errorDialogsSubmenu.push({ + label: 'Move to Application Folder', + click: emit('test-move-to-application-folder'), + }) + } + + errorDialogsSubmenu.push( + { + label: 'Push Rejected', + click: emit('test-push-rejected'), + }, + { + label: 'Re-Authorization Required', + click: emit('test-re-authorization-required'), + }, + { + label: 'Unable to Locate Git', + click: emit('test-unable-to-locate-git'), + }, + { + label: 'Unable to Open External Editor', + click: emit('test-no-external-editor'), + }, + { + label: 'Unable to Open Shell', + click: emit('test-unable-to-open-shell'), + }, + { + label: 'Untrusted Server', + click: emit('test-untrusted-server'), + }, + { + label: 'Update Existing Git LFS Filters?', + click: emit('test-update-existing-git-lfs-filters'), + }, + { + label: 'Upstream Already Exists', + click: emit('test-upstream-already-exists'), + } + ) + + testMenuItems.push( + separator, + { + label: 'Crash main process…', + click() { + throw new Error('Boomtown!') + }, + }, + { + label: 'Crash renderer process…', + click: emit('boomtown'), + }, + { + label: 'Prune branches', + click: emit('test-prune-branches'), + }, + { + label: 'Show notification', + click: emit('test-notification'), + }, + { + label: 'Show popup', + submenu: [ + { + label: 'Release notes', + click: emit('test-release-notes-popup'), + }, + { + label: 'Thank you', + click: emit('test-thank-you-popup'), + }, + { + label: 'Show App Error', + click: emit('test-app-error'), + }, + { + label: 'Octicons', + click: emit('test-icons'), + }, + ], + }, + { + label: 'Show banner', + submenu: [ + { + label: 'Update banner', + click: emit('test-update-banner'), + }, + { + label: 'Update banner (priority)', + click: emit('test-prioritized-update-banner'), + }, + { + label: `Showcase Update banner`, + click: emit('test-showcase-update-banner'), + }, + { + label: `${__DARWIN__ ? 'Apple silicon' : 'Arm64'} banner`, + click: emit('test-arm64-banner'), + }, + { + label: 'Thank you', + click: emit('test-thank-you-banner'), + }, + { + label: 'Reorder Successful', + click: emit('test-reorder-banner'), + }, + { + label: 'Reorder Undone', + click: emit('test-undone-banner'), + }, + { + label: 'Cherry Pick Conflicts', + click: emit('test-cherry-pick-conflicts-banner'), + }, + { + label: 'Merge Successful', + click: emit('test-merge-successful-banner'), + }, + { + label: 'Accessibility', + click: emit('test-accessibility-banner'), + }, + { + label: 'OS Version No Longer Supported', + click: emit('test-os-version-no-longer-supported'), + }, + ], + }, + { + label: 'Show Error Dialogs', + submenu: errorDialogsSubmenu, + } + ) + + return testMenuItems +} diff --git a/app/src/main-process/menu/menu-event.ts b/app/src/main-process/menu/menu-event.ts index 561b7fb4386..93ca7880b07 100644 --- a/app/src/main-process/menu/menu-event.ts +++ b/app/src/main-process/menu/menu-event.ts @@ -2,6 +2,7 @@ export type MenuEvent = | 'push' | 'force-push' | 'pull' + | 'fetch' | 'show-changes' | 'show-history' | 'add-local-repository' @@ -16,7 +17,7 @@ export type MenuEvent = | 'show-preferences' | 'choose-repository' | 'open-working-directory' - | 'update-branch' + | 'update-branch-with-contribution-target-branch' | 'compare-to-branch' | 'merge-branch' | 'squash-and-merge-branch' @@ -29,16 +30,65 @@ export type MenuEvent = | 'clone-repository' | 'show-about' | 'go-to-commit-message' - | 'boomtown' | 'open-pull-request' - | 'install-cli' + | 'install-darwin-cli' + | 'install-windows-cli' + | 'uninstall-windows-cli' | 'open-external-editor' | 'select-all' - | 'show-release-notes-popup' | 'show-stashed-changes' | 'hide-stashed-changes' - | 'test-show-notification' - | 'test-prune-branches' | 'find-text' | 'create-issue-in-repository-on-github' - | 'pull-request-check-run-failed' + | 'preview-pull-request' + | 'test-app-error' + | 'decrease-active-resizable-width' + | 'increase-active-resizable-width' + | TestMenuEvent + +/** + * This is an alphabetized list of menu event's that are only used for testing + * UI. + */ +const TestMenuEvents = [ + 'boomtown', + 'test-accessibility-banner', + 'test-app-error', + 'test-arm64-banner', + 'test-confirm-committing-conflicted-files', + 'test-cherry-pick-conflicts-banner', + 'test-discarded-changes-will-be-unrecoverable', + 'test-do-you-want-fork-this-repository', + 'test-files-too-large', + 'test-generic-git-authentication', + 'test-icons', + 'test-invalidated-account-token', + 'test-merge-successful-banner', + 'test-move-to-application-folder', + 'test-newer-commits-on-remote', + 'test-no-external-editor', + 'test-notification', + 'test-os-version-no-longer-supported', + 'test-prune-branches', + 'test-push-rejected', + 'test-re-authorization-required', + 'test-release-notes-popup', + 'test-reorder-banner', + 'test-showcase-update-banner', + 'test-thank-you-banner', + 'test-thank-you-popup', + 'test-unable-to-locate-git', + 'test-unable-to-open-shell', + 'test-undone-banner', + 'test-untrusted-server', + 'test-update-banner', + 'test-prioritized-update-banner', + 'test-update-existing-git-lfs-filters', + 'test-upstream-already-exists', +] as const + +export type TestMenuEvent = typeof TestMenuEvents[number] + +export function isTestMenuEvent(value: any): value is TestMenuEvent { + return TestMenuEvents.includes(value) +} diff --git a/app/src/main-process/notifications.ts b/app/src/main-process/notifications.ts index 98717e04d04..0ae209ca463 100644 --- a/app/src/main-process/notifications.ts +++ b/app/src/main-process/notifications.ts @@ -11,6 +11,11 @@ import * as ipcWebContents from './ipc-webcontents' let windowsToastActivatorClsid: string | undefined = undefined export function initializeDesktopNotifications() { + if (__LINUX__) { + // notifications not currently supported + return + } + if (__DARWIN__) { initializeNotifications({}) return @@ -24,7 +29,7 @@ export function initializeDesktopNotifications() { if (windowsToastActivatorClsid === undefined) { log.error( - 'Toast activator CLSID not found in any of the shortucts. Falling back to known CLSIDs.' + 'Toast activator CLSID not found in any of the shortcuts. Falling back to known CLSIDs.' ) // This is generated by Squirrel.Windows here: diff --git a/app/src/main-process/ordered-webrequest.ts b/app/src/main-process/ordered-webrequest.ts index b200cdcb587..bcb4494264c 100644 --- a/app/src/main-process/ordered-webrequest.ts +++ b/app/src/main-process/ordered-webrequest.ts @@ -1,7 +1,7 @@ import { WebRequest, OnBeforeRequestListenerDetails, - Response, + CallbackResponse, OnBeforeSendHeadersListenerDetails, BeforeSendResponse, OnCompletedListenerDetails, @@ -112,7 +112,7 @@ export class OrderedWebRequest { public readonly onBeforeRequest: AsyncListenerSet< OnBeforeRequestListenerDetails, - Response + CallbackResponse > public readonly onBeforeSendHeaders: AsyncListenerSet< @@ -140,7 +140,7 @@ export class OrderedWebRequest { this.onBeforeRequest = new AsyncListenerSet( webRequest.onBeforeRequest.bind(webRequest), async (listeners, details) => { - let response: Response = {} + let response: CallbackResponse = {} for (const listener of listeners) { response = await listener(details) diff --git a/app/src/main-process/show-uncaught-exception.ts b/app/src/main-process/show-uncaught-exception.ts index e5b5c3151c2..5f04ee66fff 100644 --- a/app/src/main-process/show-uncaught-exception.ts +++ b/app/src/main-process/show-uncaught-exception.ts @@ -17,16 +17,13 @@ export function showUncaughtException(isLaunchError: boolean, error: Error) { setCrashMenu() - const crashWindow = new CrashWindow( - isLaunchError ? 'launch' : 'generic', - error - ) + const window = new CrashWindow(isLaunchError ? 'launch' : 'generic', error) - crashWindow.onDidLoad(() => { - crashWindow.show() + window.onDidLoad(() => { + window.show() }) - crashWindow.onFailedToLoad(async () => { + window.onFailedToLoad(async () => { await dialog.showMessageBox({ type: 'error', title: __DARWIN__ ? `Unrecoverable Error` : 'Unrecoverable error', @@ -44,12 +41,12 @@ export function showUncaughtException(isLaunchError: boolean, error: Error) { app.quit() }) - crashWindow.onClose(() => { + window.onClose(() => { if (!__DEV__) { app.relaunch() } app.quit() }) - crashWindow.load() + window.load() } diff --git a/app/src/main-process/squirrel-updater.ts b/app/src/main-process/squirrel-updater.ts index eb0ef380a42..d319880b0a8 100644 --- a/app/src/main-process/squirrel-updater.ts +++ b/app/src/main-process/squirrel-updater.ts @@ -38,15 +38,15 @@ export function handleSquirrelEvent(eventName: string): Promise | null { async function handleInstalled(): Promise { await createShortcut(['StartMenu', 'Desktop']) - await installCLI() + await installWindowsCLI() } async function handleUpdated(): Promise { await updateShortcut() - await installCLI() + await installWindowsCLI() } -async function installCLI(): Promise { +export async function installWindowsCLI(): Promise { const binPath = getBinPath() await mkdir(binPath, { recursive: true }) await writeBatchScriptCLITrampoline(binPath) @@ -61,6 +61,17 @@ async function installCLI(): Promise { } } +export async function uninstallWindowsCLI() { + try { + const paths = getPathSegments() + const binPath = getBinPath() + const pathsWithoutBinPath = paths.filter(p => p !== binPath) + return setPathSegments(pathsWithoutBinPath) + } catch (e) { + log.error('Failed removing bin path from PATH environment variable', e) + } +} + /** * Get the path for the `bin` directory which exists in our `AppData` but * outside path which includes the installed app version. @@ -135,15 +146,7 @@ function createShortcut(locations: ShortcutLocations): Promise { async function handleUninstall(): Promise { await removeShortcut() - - try { - const paths = getPathSegments() - const binPath = getBinPath() - const pathsWithoutBinPath = paths.filter(p => p !== binPath) - return setPathSegments(pathsWithoutBinPath) - } catch (e) { - log.error('Failed removing bin path from PATH environment variable', e) - } + return uninstallWindowsCLI() } function removeShortcut(): Promise { diff --git a/app/src/main-process/trusted-ipc-sender.ts b/app/src/main-process/trusted-ipc-sender.ts new file mode 100644 index 00000000000..ae36a90e78e --- /dev/null +++ b/app/src/main-process/trusted-ipc-sender.ts @@ -0,0 +1,16 @@ +import { WebContents } from 'electron' + +// WebContents id of trusted senders of IPC messages. This is used to verify +// that only IPC messages sent from trusted senders are handled, as recommended +// by the Electron security documentation: +// https://github.com/electron/electron/blob/main/docs/tutorial/security.md#17-validate-the-sender-of-all-ipc-messages +const trustedSenders = new Set() + +/** Adds a WebContents instance to the set of trusted IPC senders. */ +export const addTrustedIPCSender = (wc: WebContents) => { + trustedSenders.add(wc.id) + wc.on('destroyed', () => trustedSenders.delete(wc.id)) +} + +/** Returns true if the given WebContents is a trusted sender of IPC messages. */ +export const isTrustedIPCSender = (wc: WebContents) => trustedSenders.has(wc.id) diff --git a/app/src/models/accessible-message.ts b/app/src/models/accessible-message.ts new file mode 100644 index 00000000000..63323f9b0e1 --- /dev/null +++ b/app/src/models/accessible-message.ts @@ -0,0 +1,12 @@ +/** This is helper interface used when we have a message displayed that is a + * JSX.Element for visual styling and that message also needs to be given to + * screen reader users as well. Screen reader only messages should only be + * strings to prevent tab focusable element from being rendered but not visible + * as screen reader only messages are visually hidden */ +export interface IAccessibleMessage { + /** A message presented to screen reader users via an aria-live component. */ + screenReaderMessage: string + + /** A message visually displayed to the user. */ + displayedMessage: string | JSX.Element +} diff --git a/app/src/models/account.ts b/app/src/models/account.ts index 1c74a74bd61..95929859a27 100644 --- a/app/src/models/account.ts +++ b/app/src/models/account.ts @@ -20,7 +20,7 @@ export function accountEquals(x: Account, y: Account) { export class Account { /** Create an account which can be used to perform unauthenticated API actions */ public static anonymous(): Account { - return new Account('', getDotComAPIEndpoint(), '', [], '', -1, '') + return new Account('', getDotComAPIEndpoint(), '', [], '', -1, '', 'free') } /** @@ -41,7 +41,8 @@ export class Account { public readonly emails: ReadonlyArray, public readonly avatarURL: string, public readonly id: number, - public readonly name: string + public readonly name: string, + public readonly plan?: string ) {} public withToken(token: string): Account { @@ -52,7 +53,8 @@ export class Account { this.emails, this.avatarURL, this.id, - this.name + this.name, + this.plan ) } diff --git a/app/src/models/author.ts b/app/src/models/author.ts index 2f96af822ac..0ad4db2e3c8 100644 --- a/app/src/models/author.ts +++ b/app/src/models/author.ts @@ -1,3 +1,34 @@ +/** This represents known authors (authors for which there is a GitHub user) */ +export type KnownAuthor = { + readonly kind: 'known' + + /** The real name of the author */ + readonly name: string + + /** The email address of the author */ + readonly email: string + + /** + * The GitHub.com or GitHub Enterprise login for + * this author or null if that information is not + * available. + */ + readonly username: string | null +} + +/** This represents unknown authors (for which we still don't know a GitHub user) */ +export type UnknownAuthor = { + readonly kind: 'unknown' + + /** + * The GitHub.com or GitHub Enterprise login for this author. + */ + readonly username: string + + /** Whether we're currently looking for a GitHub user or if search failed */ + readonly state: 'searching' | 'error' +} + /** * A representation of an 'author'. In reality we're * talking about co-authors here but the representation @@ -11,17 +42,9 @@ * Additionally it includes an optional username which is * solely for presentation purposes inside AuthorInput */ -export interface IAuthor { - /** The real name of the author */ - readonly name: string - - /** The email address of the author */ - readonly email: string +export type Author = KnownAuthor | UnknownAuthor - /** - * The GitHub.com or GitHub Enterprise login for - * this author or null if that information is not - * available. - */ - readonly username: string | null +/** Checks whether or not a given author is a known user */ +export function isKnownAuthor(author: Author): author is KnownAuthor { + return author.kind === 'known' } diff --git a/app/src/models/avatar.ts b/app/src/models/avatar.ts index de4ef4479e9..3e6ac00050c 100644 --- a/app/src/models/avatar.ts +++ b/app/src/models/avatar.ts @@ -77,5 +77,9 @@ export function getAvatarUsersForCommit( ) } - return avatarUsers + const avatarUsersByIdentity = new Map( + avatarUsers.map(x => [x.name + x.email, x]) + ) + + return [...avatarUsersByIdentity.values()] } diff --git a/app/src/models/banner.ts b/app/src/models/banner.ts index b347eaaeb03..0d24b65ac06 100644 --- a/app/src/models/banner.ts +++ b/app/src/models/banner.ts @@ -1,3 +1,4 @@ +import { Emoji } from '../lib/emoji' import { Popup } from './popup' export enum BannerType { @@ -15,6 +16,8 @@ export enum BannerType { SuccessfulSquash = 'SuccessfulSquash', SuccessfulReorder = 'SuccessfulReorder', ConflictsFound = 'ConflictsFound', + OSVersionNoLongerSupported = 'OSVersionNoLongerSupported', + AccessibilitySettingsBanner = 'AccessibilitySettingsBanner', } export type Banner = @@ -78,7 +81,7 @@ export type Banner = } | { readonly type: BannerType.OpenThankYouCard - readonly emoji: Map + readonly emoji: Map readonly onOpenCard: () => void readonly onThrowCardAway: () => void } @@ -119,3 +122,8 @@ export type Banner = /** callback to run when user clicks on link in banner text */ readonly onOpenConflictsDialog: () => void } + | { readonly type: BannerType.OSVersionNoLongerSupported } + | { + readonly type: BannerType.AccessibilitySettingsBanner + readonly onOpenAccessibilitySettings: () => void + } diff --git a/app/src/models/branch.ts b/app/src/models/branch.ts index 63269333276..3d2f1e65058 100644 --- a/app/src/models/branch.ts +++ b/app/src/models/branch.ts @@ -1,6 +1,5 @@ import { Commit } from './commit' import { removeRemotePrefix } from '../lib/remove-remote-prefix' -import { CommitIdentity } from './commit-identity' import { ForkedRemotePrefix } from './remote' // NOTE: The values here matter as they are used to sort @@ -32,7 +31,6 @@ export interface ITrackingBranch { /** Basic data about the latest commit on the branch. */ export interface IBranchTip { readonly sha: string - readonly author: CommitIdentity } /** Default rules for where to create a branch from */ diff --git a/app/src/models/clone-options.ts b/app/src/models/clone-options.ts index b50cccf4e8c..2138191b35b 100644 --- a/app/src/models/clone-options.ts +++ b/app/src/models/clone-options.ts @@ -1,9 +1,5 @@ -import { IGitAccount } from './git-account' - /** Additional arguments to provide when cloning a repository */ export type CloneOptions = { - /** The optional identity to provide when cloning. */ - readonly account: IGitAccount | null /** The branch to checkout after the clone has completed. */ readonly branch?: string /** The default branch name in case we're cloning an empty repository. */ diff --git a/app/src/models/commit.ts b/app/src/models/commit.ts index 61e558a6061..44a0d8c206b 100644 --- a/app/src/models/commit.ts +++ b/app/src/models/commit.ts @@ -2,6 +2,11 @@ import { CommitIdentity } from './commit-identity' import { ITrailer, isCoAuthoredByTrailer } from '../lib/git/interpret-trailers' import { GitAuthor } from './git-author' +/** Shortens a given SHA. */ +export function shortenSHA(sha: string) { + return sha.slice(0, 9) +} + /** Grouping of information required to create a commit */ export interface ICommitContext { /** diff --git a/app/src/models/diff/diff-data.ts b/app/src/models/diff/diff-data.ts index 99cea420427..4ebeab139b7 100644 --- a/app/src/models/diff/diff-data.ts +++ b/app/src/models/diff/diff-data.ts @@ -1,5 +1,6 @@ import { DiffHunk } from './raw-diff' import { Image } from './image' +import { SubmoduleStatus } from '../status' /** * V8 has a limit on the size of string it can create, and unless we want to * trigger an unhandled exception we need to do the encoding conversion by hand @@ -87,6 +88,28 @@ export interface IBinaryDiff { readonly kind: DiffType.Binary } +export interface ISubmoduleDiff { + readonly kind: DiffType.Submodule + + /** Full path of the submodule */ + readonly fullPath: string + + /** Path of the repository within its container repository */ + readonly path: string + + /** URL of the submodule */ + readonly url: string | null + + /** Status of the submodule */ + readonly status: SubmoduleStatus + + /** Previous SHA of the submodule, or null if it hasn't changed */ + readonly oldSHA: string | null + + /** New SHA of the submodule, or null if it hasn't changed */ + readonly newSHA: string | null +} + export interface ILargeTextDiff extends ITextDiffData { readonly kind: DiffType.LargeText } @@ -100,5 +123,6 @@ export type IDiff = | ITextDiff | IImageDiff | IBinaryDiff + | ISubmoduleDiff | ILargeTextDiff | IUnrenderableDiff diff --git a/app/src/models/diff/diff-line.ts b/app/src/models/diff/diff-line.ts index 6c7c088778a..f562094c662 100644 --- a/app/src/models/diff/diff-line.ts +++ b/app/src/models/diff/diff-line.ts @@ -38,4 +38,15 @@ export class DiffLine { public get content(): string { return this.text.substring(1) } + + public equals(other: DiffLine) { + return ( + this.text === other.text && + this.type === other.type && + this.originalLineNumber === other.originalLineNumber && + this.oldLineNumber === other.oldLineNumber && + this.newLineNumber === other.newLineNumber && + this.noTrailingNewLine === other.noTrailingNewLine + ) + } } diff --git a/app/src/models/diff/diff-selection.ts b/app/src/models/diff/diff-selection.ts index 3adbf4c4b24..c7e45bfb378 100644 --- a/app/src/models/diff/diff-selection.ts +++ b/app/src/models/diff/diff-selection.ts @@ -135,6 +135,54 @@ export class DiffSelection { } } + /** + * Returns a value indicating whether the range is all selected, partially + * selected, or not selected. + * + * @param from The line index (inclusive) from where to checking the range. + * + * @param length The number of lines to check from the start point of + * 'from', Assumes positive number, returns None if length is <= 0. + */ + public isRangeSelected(from: number, length: number): DiffSelectionType { + if (length <= 0) { + // This shouldn't happen? But if it does we'll log it and return None. + return DiffSelectionType.None + } + + const computedSelectionType = this.getSelectionType() + if (computedSelectionType !== DiffSelectionType.Partial) { + // Nothing for us to do here. If all lines are selected or none, then any + // range of lines will be the same. + return computedSelectionType + } + + if (length === 1) { + return this.isSelected(from) + ? DiffSelectionType.All + : DiffSelectionType.None + } + + const to = from + length + let foundSelected = false + let foundDeselected = false + for (let i = from; i < to; i++) { + if (this.isSelected(i)) { + foundSelected = true + } + + if (!this.isSelected(i)) { + foundDeselected = true + } + + if (foundSelected && foundDeselected) { + return DiffSelectionType.Partial + } + } + + return foundSelected ? DiffSelectionType.All : DiffSelectionType.None + } + /** * Returns a value indicating wether the given line number is selectable. * A line not being selectable usually means it's a hunk header or a context diff --git a/app/src/models/diff/image.ts b/app/src/models/diff/image.ts index 9eb4702e375..f34992baef4 100644 --- a/app/src/models/diff/image.ts +++ b/app/src/models/diff/image.ts @@ -8,6 +8,7 @@ export class Image { * @param bytes Size of the file in bytes. */ public constructor( + public readonly rawContents: ArrayBufferLike, public readonly contents: string, public readonly mediaType: string, public readonly bytes: number diff --git a/app/src/models/diff/raw-diff.ts b/app/src/models/diff/raw-diff.ts index 50c4d57c599..e38e3b1ba67 100644 --- a/app/src/models/diff/raw-diff.ts +++ b/app/src/models/diff/raw-diff.ts @@ -41,6 +41,21 @@ export class DiffHunk { public readonly unifiedDiffEnd: number, public readonly expansionType: DiffHunkExpansionType ) {} + + public equals(other: DiffHunk) { + if (this === other) { + return true + } + + return ( + this.header.equals(other.header) && + this.unifiedDiffStart === other.unifiedDiffStart && + this.unifiedDiffEnd === other.unifiedDiffEnd && + this.expansionType === other.expansionType && + this.lines.length === other.lines.length && + this.lines.every((xLine, ix) => xLine.equals(other.lines[ix])) + ) + } } /** details about the start and end of a diff hunk */ @@ -61,6 +76,15 @@ export class DiffHunkHeader { public toDiffLineRepresentation() { return `@@ -${this.oldStartLine},${this.oldLineCount} +${this.newStartLine},${this.newLineCount} @@` } + + public equals(other: DiffHunkHeader) { + return ( + this.oldStartLine === other.oldStartLine && + this.oldLineCount === other.oldLineCount && + this.newStartLine === other.newStartLine && + this.oldStartLine === other.oldStartLine + ) + } } /** the contents of a diff generated by Git */ diff --git a/app/src/models/drag-drop.ts b/app/src/models/drag-drop.ts index 95003e6241b..18b75d75f4c 100644 --- a/app/src/models/drag-drop.ts +++ b/app/src/models/drag-drop.ts @@ -1,3 +1,4 @@ +import { RowIndexPath } from '../ui/lib/list/list-row-index-path' import { Commit } from './commit' import { GitHubRepository } from './github-repository' @@ -51,7 +52,7 @@ export type CommitTarget = { export type ListInsertionPointTarget = { type: DropTargetType.ListInsertionPoint data: DragData - index: number + index: RowIndexPath } /** diff --git a/app/src/models/git-account.ts b/app/src/models/git-account.ts index 0f5e04918c7..6d9bdcca8a8 100644 --- a/app/src/models/git-account.ts +++ b/app/src/models/git-account.ts @@ -7,4 +7,7 @@ export interface IGitAccount { /** The endpoint with which the user is authenticating. */ readonly endpoint: string + + /** The token/password to authenticate with */ + readonly token: string } diff --git a/app/src/models/github-repository.ts b/app/src/models/github-repository.ts index d950c6227d3..794985684c7 100644 --- a/app/src/models/github-repository.ts +++ b/app/src/models/github-repository.ts @@ -22,7 +22,6 @@ export class GitHubRepository { public readonly dbID: number, public readonly isPrivate: boolean | null = null, public readonly htmlURL: string | null = null, - public readonly defaultBranch: string | null = null, public readonly cloneURL: string | null = null, public readonly issuesEnabled: boolean | null = null, public readonly isArchived: boolean | null = null, @@ -36,7 +35,6 @@ export class GitHubRepository { this.dbID, this.isPrivate, this.htmlURL, - this.defaultBranch, this.cloneURL, this.issuesEnabled, this.isArchived, diff --git a/app/src/models/menu-ids.ts b/app/src/models/menu-ids.ts index 63b035e6e4d..bcabc009a31 100644 --- a/app/src/models/menu-ids.ts +++ b/app/src/models/menu-ids.ts @@ -5,7 +5,7 @@ export type MenuIDs = | 'discard-all-changes' | 'stash-all-changes' | 'preferences' - | 'update-branch' + | 'update-branch-with-contribution-target-branch' | 'merge-branch' | 'squash-and-merge-branch' | 'rebase-branch' @@ -35,3 +35,6 @@ export type MenuIDs = | 'compare-to-branch' | 'toggle-stashed-changes' | 'create-issue-in-repository-on-github' + | 'preview-pull-request' + | 'decrease-active-resizable-width' + | 'increase-active-resizable-width' diff --git a/app/src/models/menu-labels.ts b/app/src/models/menu-labels.ts index 974297d61dd..1093e0c474e 100644 --- a/app/src/models/menu-labels.ts +++ b/app/src/models/menu-labels.ts @@ -28,11 +28,16 @@ export type MenuLabelsEvent = { readonly askForConfirmationOnRepositoryRemoval: boolean /** - * Specify the default branch associated with the current repository. + * Specify the default branch of the user's contribution target. + * + * This value should be the fork's upstream default branch, if the user + * is contributing to the parent repository. + * + * Otherwise, this value should be the default branch of the repository. * * Omit this value to indicate that the default branch is unknown. */ - readonly defaultBranchName?: string + readonly contributionTargetDefaultBranch?: string /** * Is the current branch in a state where it can be force pushed to the remote? diff --git a/app/src/models/multi-commit-operation.ts b/app/src/models/multi-commit-operation.ts index d1c565fa4f9..69a79f7d837 100644 --- a/app/src/models/multi-commit-operation.ts +++ b/app/src/models/multi-commit-operation.ts @@ -18,6 +18,24 @@ export const enum MultiCommitOperationKind { Reorder = 'Reorder', } +/** Type guard which narrows a string to a MultiCommitOperationKind */ +export function isIdMultiCommitOperation( + id: string +): id is + | MultiCommitOperationKind.Rebase + | MultiCommitOperationKind.CherryPick + | MultiCommitOperationKind.Squash + | MultiCommitOperationKind.Merge + | MultiCommitOperationKind.Reorder { + return ( + id === MultiCommitOperationKind.Rebase || + id === MultiCommitOperationKind.CherryPick || + id === MultiCommitOperationKind.Squash || + id === MultiCommitOperationKind.Merge || + id === MultiCommitOperationKind.Reorder + ) +} + /** * Union type representing the possible states of an multi commit operation * such as rebase, interactive rebase, cherry-pick. diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index fdf1058b3a6..ea89afafc5e 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -14,79 +14,98 @@ import { Commit, CommitOneLine, ICommitContext } from './commit' import { IStashEntry } from './stash-entry' import { Account } from '../models/account' import { Progress } from './progress' -import { ITextDiff, DiffSelection } from './diff' +import { ITextDiff, DiffSelection, ImageDiffType } from './diff' import { RepositorySettingsTab } from '../ui/repository-settings/repository-settings' import { ICommitMessage } from './commit-message' -import { IAuthor } from './author' +import { Author, UnknownAuthor } from './author' import { IRefCheck } from '../lib/ci-checks/ci-checks' import { GitHubRepository } from './github-repository' import { ValidNotificationPullRequestReview } from '../lib/valid-notification-pull-request-review' +import { UnreachableCommitsTab } from '../ui/history/unreachable-commits-dialog' +import { IAPIComment } from '../lib/api' export enum PopupType { - RenameBranch = 1, - DeleteBranch, - DeleteRemoteBranch, - ConfirmDiscardChanges, - Preferences, - RepositorySettings, - AddRepository, - CreateRepository, - CloneRepository, - CreateBranch, - SignIn, - About, - InstallGit, - PublishRepository, - Acknowledgements, - UntrustedCertificate, - RemoveRepository, - TermsAndConditions, - PushBranchCommits, - CLIInstalled, - GenericGitAuthentication, - ExternalEditorFailed, - OpenShellFailed, - InitializeLFS, - LFSAttributeMismatch, - UpstreamAlreadyExists, - ReleaseNotes, - DeletePullRequest, - OversizedFiles, - CommitConflictsWarning, - PushNeedsPull, - ConfirmForcePush, - StashAndSwitchBranch, - ConfirmOverwriteStash, - ConfirmDiscardStash, - CreateTutorialRepository, - ConfirmExitTutorial, - PushRejectedDueToMissingWorkflowScope, - SAMLReauthRequired, - CreateFork, - CreateTag, - DeleteTag, - LocalChangesOverwritten, - ChooseForkSettings, - ConfirmDiscardSelection, - MoveToApplicationsFolder, - ChangeRepositoryAlias, - ThankYou, - CommitMessage, - MultiCommitOperation, - WarnLocalChangesBeforeUndo, - WarningBeforeReset, - InvalidatedToken, - AddSSHHost, - SSHKeyPassphrase, - SSHUserPassword, - PullRequestChecksFailed, - CICheckRunRerun, - WarnForcePush, - DiscardChangesRetry, - PullRequestReview, + RenameBranch = 'RenameBranch', + DeleteBranch = 'DeleteBranch', + DeleteRemoteBranch = 'DeleteRemoteBranch', + ConfirmDiscardChanges = 'ConfirmDiscardChanges', + Preferences = 'Preferences', + RepositorySettings = 'RepositorySettings', + AddRepository = 'AddRepository', + CreateRepository = 'CreateRepository', + CloneRepository = 'CloneRepository', + CreateBranch = 'CreateBranch', + SignIn = 'SignIn', + About = 'About', + InstallGit = 'InstallGit', + PublishRepository = 'PublishRepository', + Acknowledgements = 'Acknowledgements', + UntrustedCertificate = 'UntrustedCertificate', + RemoveRepository = 'RemoveRepository', + TermsAndConditions = 'TermsAndConditions', + PushBranchCommits = 'PushBranchCommits', + CLIInstalled = 'CLIInstalled', + GenericGitAuthentication = 'GenericGitAuthentication', + ExternalEditorFailed = 'ExternalEditorFailed', + OpenShellFailed = 'OpenShellFailed', + InitializeLFS = 'InitializeLFS', + LFSAttributeMismatch = 'LFSAttributeMismatch', + UpstreamAlreadyExists = 'UpstreamAlreadyExists', + ReleaseNotes = 'ReleaseNotes', + DeletePullRequest = 'DeletePullRequest', + OversizedFiles = 'OversizedFiles', + CommitConflictsWarning = 'CommitConflictsWarning', + PushNeedsPull = 'PushNeedsPull', + ConfirmForcePush = 'ConfirmForcePush', + StashAndSwitchBranch = 'StashAndSwitchBranch', + ConfirmOverwriteStash = 'ConfirmOverwriteStash', + ConfirmDiscardStash = 'ConfirmDiscardStash', + ConfirmCheckoutCommit = 'ConfirmCheckoutCommit', + CreateTutorialRepository = 'CreateTutorialRepository', + ConfirmExitTutorial = 'ConfirmExitTutorial', + PushRejectedDueToMissingWorkflowScope = 'PushRejectedDueToMissingWorkflowScope', + SAMLReauthRequired = 'SAMLReauthRequired', + CreateFork = 'CreateFork', + CreateTag = 'CreateTag', + DeleteTag = 'DeleteTag', + LocalChangesOverwritten = 'LocalChangesOverwritten', + ChooseForkSettings = 'ChooseForkSettings', + ConfirmDiscardSelection = 'ConfirmDiscardSelection', + MoveToApplicationsFolder = 'MoveToApplicationsFolder', + ChangeRepositoryAlias = 'ChangeRepositoryAlias', + ThankYou = 'ThankYou', + CommitMessage = 'CommitMessage', + MultiCommitOperation = 'MultiCommitOperation', + WarnLocalChangesBeforeUndo = 'WarnLocalChangesBeforeUndo', + WarningBeforeReset = 'WarningBeforeReset', + InvalidatedToken = 'InvalidatedToken', + AddSSHHost = 'AddSSHHost', + SSHKeyPassphrase = 'SSHKeyPassphrase', + SSHUserPassword = 'SSHUserPassword', + PullRequestChecksFailed = 'PullRequestChecksFailed', + CICheckRunRerun = 'CICheckRunRerun', + WarnForcePush = 'WarnForcePush', + DiscardChangesRetry = 'DiscardChangesRetry', + PullRequestReview = 'PullRequestReview', + UnreachableCommits = 'UnreachableCommits', + StartPullRequest = 'StartPullRequest', + Error = 'Error', + InstallingUpdate = 'InstallingUpdate', + TestNotifications = 'TestNotifications', + PullRequestComment = 'PullRequestComment', + UnknownAuthors = 'UnknownAuthors', + TestIcons = 'TestIcons', + ConfirmCommitFilteredChanges = 'ConfirmCommitFilteredChanges', } -export type Popup = +interface IBasePopup { + /** + * Unique id of the popup that it receives upon adding to the stack. + */ + readonly id?: string +} + +export type PopupDetail = | { type: PopupType.RenameBranch; repository: Repository; branch: Branch } | { type: PopupType.DeleteBranch @@ -131,7 +150,11 @@ export type Popup = initialName?: string targetCommit?: CommitOneLine } - | { type: PopupType.SignIn } + | { + type: PopupType.SignIn + isCredentialHelperSignIn?: boolean + credentialHelperUrl?: string + } | { type: PopupType.About } | { type: PopupType.InstallGit; path: string } | { type: PopupType.PublishRepository; repository: Repository } @@ -152,8 +175,10 @@ export type Popup = | { type: PopupType.CLIInstalled } | { type: PopupType.GenericGitAuthentication - hostname: string - retryAction: RetryAction + remoteUrl: string + username?: string + onSubmit: (username: string, password: string) => void + onDismiss: () => void } | { type: PopupType.ExternalEditorFailed @@ -218,6 +243,11 @@ export type Popup = repository: Repository stash: IStashEntry } + | { + type: PopupType.ConfirmCheckoutCommit + repository: Repository + commit: CommitOneLine + } | { type: PopupType.CreateTutorialRepository account: Account @@ -274,7 +304,7 @@ export type Popup = } | { type: PopupType.CommitMessage - coAuthors: ReadonlyArray + coAuthors: ReadonlyArray showCoAuthoredBy: boolean commitMessage: ICommitMessage | null dialogTitle: string @@ -328,8 +358,6 @@ export type Popup = repository: RepositoryWithGitHubRepository pullRequest: PullRequest shouldChangeRepository: boolean - commitMessage: string - commitSha: string checks: ReadonlyArray } | { @@ -349,7 +377,57 @@ export type Popup = repository: RepositoryWithGitHubRepository pullRequest: PullRequest review: ValidNotificationPullRequestReview - numberOfComments: number shouldCheckoutBranch: boolean shouldChangeRepository: boolean } + | { + type: PopupType.UnreachableCommits + selectedTab: UnreachableCommitsTab + } + | { + type: PopupType.StartPullRequest + prBaseBranches: ReadonlyArray + currentBranch: Branch + defaultBranch: Branch | null + externalEditorLabel?: string + imageDiffType: ImageDiffType + prRecentBaseBranches: ReadonlyArray + repository: Repository + nonLocalCommitSHA: string | null + showSideBySideDiff: boolean + currentBranchHasPullRequest: boolean + } + | { + type: PopupType.Error + error: Error + } + | { + type: PopupType.InstallingUpdate + } + | { + type: PopupType.TestNotifications + repository: RepositoryWithGitHubRepository + } + | { + type: PopupType.PullRequestComment + repository: RepositoryWithGitHubRepository + pullRequest: PullRequest + comment: IAPIComment + shouldCheckoutBranch: boolean + shouldChangeRepository: boolean + } + | { + type: PopupType.UnknownAuthors + authors: ReadonlyArray + onCommit: () => void + } + | { + type: PopupType.TestIcons + } + | { + type: PopupType.ConfirmCommitFilteredChanges + onCommitAnyway: () => void + onClearFilter: () => void + } + +export type Popup = IBasePopup & PopupDetail diff --git a/app/src/models/preferences.ts b/app/src/models/preferences.ts index f938f20e671..26e379aaa2b 100644 --- a/app/src/models/preferences.ts +++ b/app/src/models/preferences.ts @@ -1,8 +1,10 @@ export enum PreferencesTab { - Accounts = 0, - Integrations = 1, - Git = 2, - Appearance = 3, - Prompts = 4, - Advanced = 5, + Accounts, + Integrations, + Git, + Appearance, + Notifications, + Prompts, + Advanced, + Accessibility, } diff --git a/app/src/models/progress.ts b/app/src/models/progress.ts index ef9a06f5c6e..28802559d4e 100644 --- a/app/src/models/progress.ts +++ b/app/src/models/progress.ts @@ -42,8 +42,13 @@ export interface IGenericProgress extends IProgress { export interface ICheckoutProgress extends IProgress { kind: 'checkout' - /** The branch that's currently being checked out */ - readonly targetBranch: string + /** The branch or commit that's currently being checked out */ + readonly target: string + + /** + * Infotext for the user. + */ + readonly description: string } /** diff --git a/app/src/models/pull-request.ts b/app/src/models/pull-request.ts index 47dd05355ad..6176da439b7 100644 --- a/app/src/models/pull-request.ts +++ b/app/src/models/pull-request.ts @@ -41,3 +41,24 @@ export class PullRequest { public readonly body: string ) {} } + +/** The types of pull request suggested next actions */ +export enum PullRequestSuggestedNextAction { + PreviewPullRequest = 'PreviewPullRequest', + CreatePullRequest = 'CreatePullRequest', +} + +/** Type guard which narrows a string to a PullRequestSuggestedNextAction */ +export function isIdPullRequestSuggestedNextAction( + id: string +): id is + | PullRequestSuggestedNextAction.PreviewPullRequest + | PullRequestSuggestedNextAction.CreatePullRequest { + return ( + id === PullRequestSuggestedNextAction.PreviewPullRequest || + id === PullRequestSuggestedNextAction.CreatePullRequest + ) +} + +export const defaultPullRequestSuggestedNextAction = + PullRequestSuggestedNextAction.PreviewPullRequest diff --git a/app/src/models/repo-rules.ts b/app/src/models/repo-rules.ts new file mode 100644 index 00000000000..cd22dee03e2 --- /dev/null +++ b/app/src/models/repo-rules.ts @@ -0,0 +1,136 @@ +export type RepoRulesMetadataStatus = 'pass' | 'fail' | 'bypass' +export type RepoRulesMetadataFailure = { + description: string + rulesetId: number +} + +export class RepoRulesMetadataFailures { + public failed: RepoRulesMetadataFailure[] = [] + public bypassed: RepoRulesMetadataFailure[] = [] + + /** + * Returns the status of the rule based on its failures. + * 'pass' means all rules passed, 'bypass' means some rules failed + * but the user can bypass all of the failures, and 'fail' means + * at least one rule failed that the user cannot bypass. + */ + public get status(): RepoRulesMetadataStatus { + if (this.failed.length === 0) { + if (this.bypassed.length === 0) { + return 'pass' + } + + return 'bypass' + } + + return 'fail' + } +} + +/** + * Metadata restrictions for a specific type of rule, as multiple can + * be configured at once and all apply to the branch. + */ +export class RepoRulesMetadataRules { + private rules: IRepoRulesMetadataRule[] = [] + + public push(rule?: IRepoRulesMetadataRule): void { + if (rule === undefined) { + return + } + + this.rules.push(rule) + } + + /** + * Whether any rules are configured. + */ + public get hasRules(): boolean { + return this.rules.length > 0 + } + + /** + * Gets an object containing arrays of human-readable rules that + * fail to match the provided input string. If the returned object + * contains only empty arrays, then all rules pass. + */ + public getFailedRules(toMatch: string): RepoRulesMetadataFailures { + const failures = new RepoRulesMetadataFailures() + for (const rule of this.rules) { + if (!rule.matcher(toMatch)) { + if (rule.enforced === 'bypass') { + failures.bypassed.push({ + description: rule.humanDescription, + rulesetId: rule.rulesetId, + }) + } else { + failures.failed.push({ + description: rule.humanDescription, + rulesetId: rule.rulesetId, + }) + } + } + } + + return failures + } +} + +/** + * Parsed repo rule info + */ +export class RepoRulesInfo { + /** + * Many rules are not handled in a special way, they + * instead just display a warning to the user when they're + * about to commit. They're lumped together into this flag + * for simplicity. See the `parseRepoRules` function for + * the full list. + */ + public basicCommitWarning: RepoRuleEnforced = false + + /** + * If true, the branch's name conflicts with a rule and + * cannot be created. + */ + public creationRestricted: RepoRuleEnforced = false + + /** + * Whether signed commits are required. `parseRepoRules` will + * set this to `false` if the user has commit signing configured. + */ + public signedCommitsRequired: RepoRuleEnforced = false + + public pullRequestRequired: RepoRuleEnforced = false + public commitMessagePatterns = new RepoRulesMetadataRules() + public commitAuthorEmailPatterns = new RepoRulesMetadataRules() + public committerEmailPatterns = new RepoRulesMetadataRules() + public branchNamePatterns = new RepoRulesMetadataRules() +} + +export interface IRepoRulesMetadataRule { + /** + * Whether this rule is enforced for the current user. + */ + enforced: RepoRuleEnforced + + /** + * Function that determines whether the provided string matches the rule. + */ + matcher: RepoRulesMetadataMatcher + + /** + * Human-readable description of the rule. For example, a 'starts_with' + * rule with the pattern 'abc' that is negated would have a description + * of 'must not start with "abc"'. + */ + humanDescription: string + + /** + * ID of the ruleset this rule is configured in. + */ + rulesetId: number +} + +export type RepoRulesMetadataMatcher = (toMatch: string) => boolean +export type RepoRuleEnforced = boolean | 'bypass' diff --git a/app/src/models/repository.ts b/app/src/models/repository.ts index e299d051bdd..74b9a89b9e6 100644 --- a/app/src/models/repository.ts +++ b/app/src/models/repository.ts @@ -213,3 +213,15 @@ export function getForkContributionTarget( ? repository.workflowPreferences.forkContributionTarget : ForkContributionTarget.Parent } + +/** + * Returns whether the fork is contributing to the parent + */ +export function isForkedRepositoryContributingToParent( + repository: Repository +): boolean { + return ( + isRepositoryWithForkedGitHubRepository(repository) && + getForkContributionTarget(repository) === ForkContributionTarget.Parent + ) +} diff --git a/app/src/models/stash-entry.ts b/app/src/models/stash-entry.ts index da7f7667251..bf840018b05 100644 --- a/app/src/models/stash-entry.ts +++ b/app/src/models/stash-entry.ts @@ -12,6 +12,9 @@ export interface IStashEntry { /** The list of files this stash touches */ readonly files: StashedFileChanges + + readonly tree: string + readonly parents: ReadonlyArray } /** Whether file changes for a stash entry are loaded or not */ diff --git a/app/src/models/status.ts b/app/src/models/status.ts index ca7673f4c19..13aba603479 100644 --- a/app/src/models/status.ts +++ b/app/src/models/status.ts @@ -34,6 +34,7 @@ export type PlainFileStatus = { | AppFileStatusKind.New | AppFileStatusKind.Modified | AppFileStatusKind.Deleted + submoduleStatus?: SubmoduleStatus } /** @@ -46,6 +47,8 @@ export type PlainFileStatus = { export type CopiedOrRenamedFileStatus = { kind: AppFileStatusKind.Copied | AppFileStatusKind.Renamed oldPath: string + renameIncludesModifications: boolean + submoduleStatus?: SubmoduleStatus } /** @@ -56,6 +59,7 @@ export type ConflictsWithMarkers = { kind: AppFileStatusKind.Conflicted entry: TextConflictEntry conflictMarkerCount: number + submoduleStatus?: SubmoduleStatus } /** @@ -65,6 +69,7 @@ export type ConflictsWithMarkers = { export type ManualConflict = { kind: AppFileStatusKind.Conflicted entry: ManualConflictEntry + submoduleStatus?: SubmoduleStatus } /** Union of potential conflict scenarios the application should handle */ @@ -92,7 +97,10 @@ export function isManualConflict( } /** Denotes an untracked file in the working directory) */ -export type UntrackedFileStatus = { kind: AppFileStatusKind.Untracked } +export type UntrackedFileStatus = { + kind: AppFileStatusKind.Untracked + submoduleStatus?: SubmoduleStatus +} /** The union of potential states associated with a file change in Desktop */ export type AppFileStatus = @@ -101,6 +109,22 @@ export type AppFileStatus = | ConflictedFileStatus | UntrackedFileStatus +/** The status of a submodule */ +export type SubmoduleStatus = { + /** Whether or not the submodule is pointing to a different commit */ + readonly commitChanged: boolean + /** + * Whether or not the submodule contains modified changes that haven't been + * committed yet + */ + readonly modifiedChanges: boolean + /** + * Whether or not the submodule contains untracked changes that haven't been + * committed yet + */ + readonly untrackedChanges: boolean +} + /** The porcelain status for an ordinary changed entry */ type OrdinaryEntry = { readonly kind: 'ordinary' @@ -110,6 +134,8 @@ type OrdinaryEntry = { readonly index?: GitStatusEntry /** the status of the working tree for this entry (if known) */ readonly workingTree?: GitStatusEntry + /** the submodule status for this entry */ + readonly submoduleStatus?: SubmoduleStatus } /** The porcelain status for a renamed or copied entry */ @@ -119,6 +145,10 @@ type RenamedOrCopiedEntry = { readonly index?: GitStatusEntry /** the status of the working tree for this entry (if known) */ readonly workingTree?: GitStatusEntry + /** the submodule status for this entry */ + readonly submoduleStatus?: SubmoduleStatus + /** The rename or copy score in the case of a renamed file */ + readonly renameOrCopyScore?: number } export enum UnmergedEntrySummary { @@ -149,13 +179,18 @@ type TextConflictDetails = type TextConflictEntry = { readonly kind: 'conflicted' + /** the submodule status for this entry */ + readonly submoduleStatus?: SubmoduleStatus } & TextConflictDetails /** * Valid Git index states where the user needs to choose one of `us` or `them` * in the app. */ -type ManualConflictDetails = +type ManualConflictDetails = { + /** the submodule status for this entry */ + readonly submoduleStatus?: SubmoduleStatus +} & ( | { readonly action: UnmergedEntrySummary.BothAdded readonly us: GitStatusEntry.Added @@ -191,9 +226,12 @@ type ManualConflictDetails = readonly us: GitStatusEntry.Deleted readonly them: GitStatusEntry.Deleted } +) type ManualConflictEntry = { readonly kind: 'conflicted' + /** the submodule status for this entry */ + readonly submoduleStatus?: SubmoduleStatus } & ManualConflictDetails /** The porcelain status for an unmerged entry */ @@ -202,6 +240,8 @@ export type UnmergedEntry = TextConflictEntry | ManualConflictEntry /** The porcelain status for an unmerged entry */ type UntrackedEntry = { readonly kind: 'untracked' + /** the submodule status for this entry */ + readonly submoduleStatus?: SubmoduleStatus } /** The union of possible entries from the git status */ @@ -279,7 +319,8 @@ export class CommittedFileChange extends FileChange { public constructor( path: string, status: AppFileStatus, - public readonly commitish: string + public readonly commitish: string, + public readonly parentCommitish: string ) { super(path, status) diff --git a/app/src/models/tutorial-step.ts b/app/src/models/tutorial-step.ts index 6a7c47292cb..ae3e962da75 100644 --- a/app/src/models/tutorial-step.ts +++ b/app/src/models/tutorial-step.ts @@ -8,6 +8,7 @@ export enum TutorialStep { OpenPullRequest = 'OpenPullRequest', AllDone = 'AllDone', Paused = 'Paused', + Announced = 'Announced', } export type ValidTutorialStep = @@ -18,6 +19,7 @@ export type ValidTutorialStep = | TutorialStep.PushBranch | TutorialStep.OpenPullRequest | TutorialStep.AllDone + | TutorialStep.Announced export function isValidTutorialStep( step: TutorialStep @@ -33,4 +35,5 @@ export const orderedTutorialSteps: ReadonlyArray = [ TutorialStep.PushBranch, TutorialStep.OpenPullRequest, TutorialStep.AllDone, + TutorialStep.Announced, ] diff --git a/app/src/ui/about/about.tsx b/app/src/ui/about/about.tsx index a867e199156..ae18a158b7e 100644 --- a/app/src/ui/about/about.tsx +++ b/app/src/ui/about/about.tsx @@ -16,6 +16,7 @@ import { RelativeTime } from '../relative-time' import { assertNever } from '../../lib/fatal-error' import { ReleaseNotesUri } from '../lib/releases' import { encodePathAsUrl } from '../../lib/path' +import { isOSNoLongerSupportedByElectron } from '../../lib/get-os' const logoPath = __DARWIN__ ? 'static/logo-64x64@2x.png' @@ -25,7 +26,7 @@ const DesktopLogo = encodePathAsUrl(__dirname, logoPath) interface IAboutProps { /** * Event triggered when the dialog is dismissed by the user in the - * ways described in the Dialog component's dismissable prop. + * ways described in the Dialog component's dismissible prop. */ readonly onDismissed: () => void @@ -44,8 +45,8 @@ interface IAboutProps { */ readonly applicationArchitecture: string - /** A function to call to kick off an update check. */ - readonly onCheckForUpdates: () => void + /** A function to call to kick off a non-staggered update check. */ + readonly onCheckForNonStaggeredUpdates: () => void readonly onShowAcknowledgements: () => void @@ -114,15 +115,21 @@ export class About extends React.Component { case UpdateStatus.CheckingForUpdates: case UpdateStatus.UpdateAvailable: case UpdateStatus.UpdateNotChecked: - const disabled = ![ - UpdateStatus.UpdateNotChecked, - UpdateStatus.UpdateNotAvailable, - ].includes(updateStatus) + const disabled = + ![ + UpdateStatus.UpdateNotChecked, + UpdateStatus.UpdateNotAvailable, + ].includes(updateStatus) || isOSNoLongerSupportedByElectron() + + const buttonTitle = 'Check for Updates' return ( - ) @@ -220,6 +227,18 @@ export class About extends React.Component { return null } + if (isOSNoLongerSupportedByElectron()) { + return ( + + This operating system is no longer supported. Software updates have + been disabled.{' '} + + Supported operating systems + + + ) + } + if (!this.state.updateState.lastSuccessfulCheck) { return ( @@ -259,10 +278,12 @@ export class About extends React.Component { ) const versionText = __DEV__ ? `Build ${version}` : `Version ${version}` + const titleId = 'Dialog_about' return ( @@ -276,19 +297,19 @@ export class About extends React.Component { height="64" /> -

{name}

+

About {name}

{versionText} ({this.props.applicationArchitecture}) {' '} ({releaseNotesLink})

-

+

Terms and Conditions

-

+

License and Open Source Notices diff --git a/app/src/ui/accessibility/aria-live-container.tsx b/app/src/ui/accessibility/aria-live-container.tsx new file mode 100644 index 00000000000..af752f9ff88 --- /dev/null +++ b/app/src/ui/accessibility/aria-live-container.tsx @@ -0,0 +1,115 @@ +import debounce from 'lodash/debounce' +import React, { Component } from 'react' + +interface IAriaLiveContainerProps { + /** The content that will be read by the screen reader. + * + * Original solution used props.children, but we ran into invisible tab + * issues when the message has a link. Thus, we are using a prop instead to + * require the message to be a string. + */ + readonly message: string | null + + /** + * There is a common pattern that we may need to announce a message in + * response to user input. Unfortunately, aria-live announcements are + * interrupted by continued user input. We can force a rereading of a message + * by appending an invisible character when the user finishes their input. + * + * For example, we have a search filter for a list of branches and we need to + * announce how may results are found. Say a list of branches and the user + * types "ma", the message becomes "1 result", but if they continue to type + * "main" the message will have been interrupted. + * + * This prop allows us to pass in when the user input changes. This can either + * be directly passing in the user input on change or a boolean representing + * when we want the message re-read. We can append the invisible character to + * force the screen reader to read the message again after each input. To + * prevent the message from being read too much, we debounce the message. + */ + readonly trackedUserInput?: string | boolean + + /** Optional id that can be used to associate the message to a control */ + readonly id?: string +} + +interface IAriaLiveContainerState { + /** The generated message for the screen reader */ + readonly message: JSX.Element | null +} + +/** + * This component encapsulates aria-live containers, which are used to + * communicate changes to screen readers. The container is hidden from + * view, but the screen reader will read the contents of the container + * when it changes. + * + * It also allows to make an invisible change in the content in order to force + * the screen reader to read the content again. This is useful when the content + * is the same but the screen reader should read it again. + */ +export class AriaLiveContainer extends Component< + IAriaLiveContainerProps, + IAriaLiveContainerState +> { + private suffix: string = '' + private onTrackedInputChanged = debounce(() => { + this.setState({ message: this.buildMessage() }) + }, 1000) + + public constructor(props: IAriaLiveContainerProps) { + super(props) + + this.state = { + message: this.props.message !== null ? this.buildMessage() : null, + } + } + + public componentDidUpdate(prevProps: IAriaLiveContainerProps) { + if (prevProps.trackedUserInput === this.props.trackedUserInput) { + return + } + + this.onTrackedInputChanged() + } + + public componentWillUnmount() { + this.onTrackedInputChanged.cancel() + } + + private buildMessage() { + // We need to toggle from two non-breaking spaces to one non-breaking space + // because VoiceOver does not detect the empty string as a change. + this.suffix = this.suffix === '\u00A0\u00A0' ? '\u00A0' : '\u00A0\u00A0' + + return <>{this.props.message + this.suffix} + } + + private renderMessage() { + // We are just using this as a typical aria-live container where the message + // changes per usage - no need to force re-reading of the same message. + if (this.props.trackedUserInput === undefined) { + return this.props.message + } + + // We are using this as a container to force re-reading of the same message, + // so we are re-building message based on user input changes. + // If we get a null for the children, go ahead an empty out the + // message so we don't get an erroneous reading of a message after it is + // gone. + return this.props.message !== null ? this.state.message : '' + } + + public render() { + return ( +

+ {this.renderMessage()} +
+ ) + } +} diff --git a/app/src/ui/add-repository/add-existing-repository.tsx b/app/src/ui/add-repository/add-existing-repository.tsx index 1084a2d578d..6eeed3ee481 100644 --- a/app/src/ui/add-repository/add-existing-repository.tsx +++ b/app/src/ui/add-repository/add-existing-repository.tsx @@ -6,15 +6,16 @@ import { Button } from '../lib/button' import { TextBox } from '../lib/text-box' import { Row } from '../lib/row' import { Dialog, DialogContent, DialogFooter } from '../dialog' -import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' import { LinkButton } from '../lib/link-button' import { PopupType } from '../../models/popup' import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { FoldoutType } from '../../lib/app-state' import untildify from 'untildify' import { showOpenDialog } from '../main-process-proxy' import { Ref } from '../lib/ref' +import { InputError } from '../lib/input-description/input-error' +import { IAccessibleMessage } from '../../models/accessible-message' interface IAddExistingRepositoryProps { readonly dispatcher: Dispatcher @@ -29,17 +30,6 @@ interface IAddExistingRepositoryProps { interface IAddExistingRepositoryState { readonly path: string - /** - * Indicates whether or not the path provided in the path state field exists and - * is a valid Git repository. This value is immediately switched - * to false when the path changes and updated (if necessary) by the - * function, checkIfPathIsRepository. - * - * If set to false the user will be prevented from submitting this dialog - * and given the option to create a new repository instead. - */ - readonly isRepository: boolean - /** * Indicates whether or not to render a warning message about the entered path * not containing a valid Git repository. This value differs from `isGitRepository` in that it holds @@ -60,6 +50,8 @@ export class AddExistingRepository extends React.Component< IAddExistingRepositoryProps, IAddExistingRepositoryState > { + private pathTextBoxRef = React.createRef() + public constructor(props: IAddExistingRepositoryProps) { super(props) @@ -67,7 +59,6 @@ export class AddExistingRepository extends React.Component< this.state = { path, - isRepository: false, showNonGitRepositoryWarning: false, isRepositoryBare: false, isRepositoryUnsafe: false, @@ -75,14 +66,6 @@ export class AddExistingRepository extends React.Component< } } - public async componentDidMount() { - const { path } = this.state - - if (path.length !== 0) { - await this.validatePath(path) - } - } - private onTrustDirectory = async () => { this.setState({ isTrustingRepository: true }) const { repositoryUnsafePath, path } = this.state @@ -94,18 +77,16 @@ export class AddExistingRepository extends React.Component< } private async updatePath(path: string) { - this.setState({ path, isRepository: false }) - await this.validatePath(path) + this.setState({ path }) } - private async validatePath(path: string) { + private async validatePath(path: string): Promise { if (path.length === 0) { this.setState({ - isRepository: false, isRepositoryBare: false, showNonGitRepositoryWarning: false, }) - return + return false } const type = await getRepositoryType(path) @@ -119,7 +100,6 @@ export class AddExistingRepository extends React.Component< this.setState(state => path === state.path ? { - isRepository, isRepositoryBare, isRepositoryUnsafe, showNonGitRepositoryWarning, @@ -127,83 +107,119 @@ export class AddExistingRepository extends React.Component< } : null ) + + return path.length > 0 && isRepository && !isRepositoryBare } - private renderWarning() { - if (!this.state.path.length || !this.state.showNonGitRepositoryWarning) { + private buildBareRepositoryError() { + if ( + !this.state.path.length || + !this.state.showNonGitRepositoryWarning || + !this.state.isRepositoryBare + ) { return null } - if (this.state.isRepositoryBare) { - return ( - - -

- This directory appears to be a bare repository. Bare repositories - are not currently supported. -

-
- ) + const msg = + 'This directory appears to be a bare repository. Bare repositories are not currently supported.' + + return { screenReaderMessage: msg, displayedMessage: msg } + } + + private buildRepositoryUnsafeError() { + const { repositoryUnsafePath, path } = this.state + if ( + !this.state.path.length || + !this.state.showNonGitRepositoryWarning || + !this.state.isRepositoryUnsafe || + repositoryUnsafePath === undefined + ) { + return null } - const { isRepositoryUnsafe, repositoryUnsafePath, path } = this.state - - if (isRepositoryUnsafe && repositoryUnsafePath !== undefined) { - // Git for Windows will replace backslashes with slashes in the error - // message so we'll do the same to not show "the repo at path c:/repo" - // when the entered path is `c:\repo`. - const convertedPath = __WIN32__ ? path.replaceAll('\\', '/') : path - - return ( - - -
-

- The Git repository - {repositoryUnsafePath !== convertedPath && ( - <> - {' at '} - {repositoryUnsafePath} - - )}{' '} - appears to be owned by another user on your machine. Adding - untrusted repositories may automatically execute files in the - repository. -

-

- If you trust the owner of the directory you can - - add an exception for this directory - {' '} - in order to continue. -

-
-
- ) + // Git for Windows will replace backslashes with slashes in the error + // message so we'll do the same to not show "the repo at path c:/repo" + // when the entered path is `c:\repo`. + const convertedPath = __WIN32__ ? path.replaceAll('\\', '/') : path + + const displayedMessage = ( + <> +

+ The Git repository + {repositoryUnsafePath !== convertedPath && ( + <> + {' at '} + {repositoryUnsafePath} + + )}{' '} + appears to be owned by another user on your machine. Adding untrusted + repositories may automatically execute files in the repository. +

+

+ If you trust the owner of the directory you can + + {' '} + add an exception for this directory + {' '} + in order to continue. +

+ + ) + + const screenReaderMessage = `The Git repository appears to be owned by another user on your machine. + Adding untrusted repositories may automatically execute files in the repository. + If you trust the owner of the directory you can add an exception for this directory in order to continue.` + + return { screenReaderMessage, displayedMessage } + } + + private buildNotAGitRepositoryError(): IAccessibleMessage | null { + if (!this.state.path.length || !this.state.showNonGitRepositoryWarning) { + return null } - return ( - - + const displayedMessage = ( + <> +

This directory does not appear to be a Git repository.

- This directory does not appear to be a Git repository. -
Would you like to{' '} create a repository {' '} here instead?

+ + ) + + const screenReaderMessage = + 'This directory does not appear to be a Git repository. Would you like to create a repository here instead?' + + return { screenReaderMessage, displayedMessage } + } + + private renderErrors() { + const msg: IAccessibleMessage | null = + this.buildBareRepositoryError() ?? + this.buildRepositoryUnsafeError() ?? + this.buildNotAGitRepositoryError() + + if (msg === null) { + return null + } + + return ( + + + {msg.displayedMessage} + ) } public render() { - const disabled = - this.state.path.length === 0 || - !this.state.isRepository || - this.state.isRepositoryBare - return ( - {this.renderWarning()} + {this.renderErrors()} @@ -258,19 +275,30 @@ export class AddExistingRepository extends React.Component< } private addRepository = async () => { + const { path } = this.state + const isValidPath = await this.validatePath(path) + + if (!isValidPath) { + this.pathTextBoxRef.current?.focus() + return + } + this.props.onDismissed() const { dispatcher } = this.props - const resolvedPath = this.resolvedPath(this.state.path) + const resolvedPath = this.resolvedPath(path) const repositories = await dispatcher.addRepositories([resolvedPath]) if (repositories.length > 0) { + dispatcher.closeFoldout(FoldoutType.Repository) dispatcher.selectRepository(repositories[0]) dispatcher.recordAddExistingRepository() } } private onCreateRepositoryClicked = () => { + this.props.onDismissed() + const resolvedPath = this.resolvedPath(this.state.path) return this.props.dispatcher.showPopup({ diff --git a/app/src/ui/add-repository/create-repository.tsx b/app/src/ui/add-repository/create-repository.tsx index 08855c642a9..1544e20e52c 100644 --- a/app/src/ui/add-repository/create-repository.tsx +++ b/app/src/ui/add-repository/create-repository.tsx @@ -23,8 +23,6 @@ import { ILicense, getLicenses, writeLicense } from './licenses' import { writeGitAttributes } from './git-attributes' import { getDefaultDir, setDefaultDir } from '../lib/default-dir' import { Dialog, DialogContent, DialogFooter, DialogError } from '../dialog' -import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' import { LinkButton } from '../lib/link-button' import { PopupType } from '../../models/popup' import { Ref } from '../lib/ref' @@ -34,7 +32,14 @@ import { showOpenDialog } from '../main-process-proxy' import { pathExists } from '../lib/path-exists' import { mkdir } from 'fs/promises' import { directoryExists } from '../../lib/directory-exists' +import { FoldoutType } from '../../lib/app-state' import { join } from 'path' +import { isTopMostDialog } from '../dialog/is-top-most' +import { InputError } from '../lib/input-description/input-error' +import { InputWarning } from '../lib/input-description/input-warning' + +/** URL used to provide information about submodules to the user. */ +const submoduleDocsUrl = 'https://gh.io/git-submodules' /** The sentinel value used to indicate no gitignore should be used. */ const NoGitIgnoreValue = 'None' @@ -47,29 +52,15 @@ const NoLicenseValue: ILicense = { hidden: false, } -/** Is the path a git repository? */ -export const isGitRepository = async (path: string) => { - const type = await getRepositoryType(path).catch(e => { - log.error(`Unable to determine repository type`, e) - return { kind: 'missing' } as RepositoryType - }) - - if (type.kind === 'unsafe') { - // If the path is considered unsafe by Git we won't be able to - // verify that it's a repository (or worktree). So we'll fall back to this - // naive approximation. - return directoryExists(join(path, '.git')) - } - - return type.kind !== 'missing' -} - interface ICreateRepositoryProps { readonly dispatcher: Dispatcher readonly onDismissed: () => void /** Prefills path input so user doesn't have to. */ readonly initialPath?: string + + /** Whether the dialog is the top most in the dialog stack */ + readonly isTopMost: boolean } interface ICreateRepositoryState { @@ -83,6 +74,9 @@ interface ICreateRepositoryState { /** Is the given path already a repository? */ readonly isRepository: boolean + /** Is the given path already a subfolder of a repository? */ + readonly isSubFolderOfRepository: boolean + /** Should the repository be created with a default README? */ readonly createWithReadme: boolean @@ -114,6 +108,16 @@ export class CreateRepository extends React.Component< ICreateRepositoryProps, ICreateRepositoryState > { + private checkIsTopMostDialog = isTopMostDialog( + () => { + this.updateReadMeExists(this.state.path, this.state.name) + window.addEventListener('focus', this.onWindowFocus) + }, + () => { + window.removeEventListener('focus', this.onWindowFocus) + } + ) + public constructor(props: ICreateRepositoryProps) { super(props) @@ -136,6 +140,7 @@ export class CreateRepository extends React.Component< isValidPath: null, isRepository: false, readMeExists: false, + isSubFolderOfRepository: false, } if (path === null) { @@ -144,7 +149,7 @@ export class CreateRepository extends React.Component< } public async componentDidMount() { - window.addEventListener('focus', this.onWindowFocus) + this.checkIsTopMostDialog(this.props.isTopMost) const gitIgnoreNames = await getGitIgnoreNames() const licenses = await getLicenses() @@ -157,8 +162,12 @@ export class CreateRepository extends React.Component< this.updateReadMeExists(path, this.state.name) } - public componentWillUnmount() { - window.removeEventListener('focus', this.onWindowFocus) + public componentDidUpdate(): void { + this.checkIsTopMostDialog(this.props.isTopMost) + } + + public componentWillUnmount(): void { + this.checkIsTopMostDialog(false) } private initializePath = async () => { @@ -188,12 +197,35 @@ export class CreateRepository extends React.Component< private async updateIsRepository(path: string, name: string) { const fullPath = Path.join(path, sanitizedRepositoryName(name)) - const isRepository = await isGitRepository(fullPath) + + const type = await getRepositoryType(fullPath).catch(e => { + log.error(`Unable to determine repository type`, e) + return { kind: 'missing' } as RepositoryType + }) + + let isRepository: boolean = type.kind !== 'missing' + let isSubFolderOfRepository = false + if (type.kind === 'unsafe') { + // If the path is considered unsafe by Git we won't be able to + // verify that it's a repository (or worktree). So we'll fall back to this + // naive approximation. + isRepository = await directoryExists(join(path, '.git')) + } + + if (type.kind === 'regular') { + // If the path is a regular repository, we'll check if the top level. If it + // isn't than, the path is a subfolder of the repository and a user may want + // to make it into a repository. + isRepository = type.topLevelWorkingDirectory === fullPath + isSubFolderOfRepository = !isRepository + } // Only update isRepository if the path is still the same one we were using // to check whether it looked like a repository. this.setState(state => - state.path === path && state.name === name ? { isRepository } : null + state.path === path && state.name === name + ? { isRepository, isSubFolderOfRepository } + : null ) } @@ -391,6 +423,7 @@ export class CreateRepository extends React.Component< this.updateDefaultDirectory() + this.props.dispatcher.closeFoldout(FoldoutType.Repository) this.props.dispatcher.selectRepository(repository) this.props.dispatcher.recordCreateRepository() this.props.onDismissed() @@ -420,10 +453,16 @@ export class CreateRepository extends React.Component< } return ( - - - Will be created as {sanitizedName} - + +

Will be created as {sanitizedName}

+ + Spaces and invalid characters have been replaced by hyphens. + +
) } @@ -505,23 +544,55 @@ export class CreateRepository extends React.Component< ) } - private renderGitRepositoryWarning() { - const isRepo = this.state.isRepository + private renderGitRepositoryError() { + const { isRepository, path, name } = this.state - if (!this.state.path || this.state.path.length === 0 || !isRepo) { + if (!path || path.length === 0 || !isRepository) { return null } + const fullPath = Path.join(path, sanitizedRepositoryName(name)) + return ( - - -

- This directory appears to be a Git repository. Would you like to{' '} + + + The directory {fullPath}appears to be a Git repository. + Would you like to{' '} add this repository {' '} instead? -

+ +
+ ) + } + + private renderGitRepositorySubFolderMessage() { + const { isSubFolderOfRepository, path, name } = this.state + + if (!path || path.length === 0 || !isSubFolderOfRepository) { + return null + } + + const fullPath = Path.join(path, sanitizedRepositoryName(name)) + + return ( + + + The directory {fullPath}appears to be a subfolder of Git + repository. + + Learn about submodules. + + ) } @@ -539,17 +610,39 @@ export class CreateRepository extends React.Component< } return ( - - -

+ + This directory contains a README.md file already. Checking this box will result in the existing file being overwritten. -

+
) } + private renderPathMessage = () => { + const { path, name, isRepository } = this.state + + if (path === null || path === '' || name === '' || isRepository) { + return null + } + + const fullPath = Path.join(path, sanitizedRepositoryName(name)) + + return ( +
+ The repository will be created at {fullPath}. +
+ ) + } + private onAddRepositoryClicked = () => { + this.props.onDismissed() + const { path, name } = this.state // Shouldn't be able to even get here if path is null. @@ -591,6 +684,7 @@ export class CreateRepository extends React.Component< label="Name" placeholder="repository name" onValueChanged={this.onNameChanged} + ariaDescribedBy="existing-repository-path-error repo-sanitized-name-warning" />
@@ -611,6 +705,7 @@ export class CreateRepository extends React.Component< placeholder="repository path" onValueChanged={this.onPathChanged} disabled={readOnlyPath || loadingDefaultDir} + ariaDescribedBy="existing-repository-path-error path-is-subfolder-of-repository" />
diff --git a/app/src/ui/app-error.tsx b/app/src/ui/app-error.tsx index b5b91d068fc..f5d745f8892 100644 --- a/app/src/ui/app-error.tsx +++ b/app/src/ui/app-error.tsx @@ -7,25 +7,22 @@ import { DefaultDialogFooter, } from './dialog' import { dialogTransitionTimeout } from './app' -import { GitError, isAuthFailureError } from '../lib/git/core' +import { coerceToString, GitError, isAuthFailureError } from '../lib/git/core' import { Popup, PopupType } from '../models/popup' -import { TransitionGroup, CSSTransition } from 'react-transition-group' import { OkCancelButtonGroup } from './dialog/ok-cancel-button-group' import { ErrorWithMetadata } from '../lib/error-with-metadata' import { RetryActionType, RetryAction } from '../models/retry-actions' import { Ref } from './lib/ref' -import memoizeOne from 'memoize-one' -import { parseCarriageReturn } from '../lib/parse-carriage-return' +import { GitError as DugiteError } from 'dugite' +import { LinkButton } from './lib/link-button' +import { getFileFromExceedsError } from '../lib/helpers/regex' interface IAppErrorProps { - /** The list of queued, app-wide, errors */ - readonly errors: ReadonlyArray + /** The error to be displayed */ + readonly error: Error - /** - * A callback which is used whenever a particular error - * has been shown to, and been dismissed by, the user. - */ - readonly onClearError: (error: Error) => void + /** Called to dismiss the dialog */ + readonly onDismissed: () => void readonly onShowPopup: (popupType: Popup) => void | undefined readonly onRetryAction: (retryAction: RetryAction) => void } @@ -48,18 +45,17 @@ interface IAppErrorState { */ export class AppError extends React.Component { private dialogContent: HTMLDivElement | null = null - private formatGitErrorMessage = memoizeOne(parseCarriageReturn) public constructor(props: IAppErrorProps) { super(props) this.state = { - error: props.errors[0] || null, + error: props.error, disabled: false, } } public componentWillReceiveProps(nextProps: IAppErrorProps) { - const error = nextProps.errors[0] || null + const error = nextProps.error // We keep the currently shown error until it has disappeared // from the first spot in the application error queue. @@ -68,23 +64,8 @@ export class AppError extends React.Component { } } - private onDismissed = () => { - const currentError = this.state.error - - if (currentError !== null) { - this.setState({ error: null, disabled: true }) - - // Give some time for the dialog to nicely transition - // out before we clear the error and, potentially, deal - // with the next error in the queue. - window.setTimeout(() => { - this.props.onClearError(currentError) - }, dialogTransitionTimeout.exit) - } - } - private showPreferencesDialog = () => { - this.onDismissed() + this.props.onDismissed() //This is a hacky solution to resolve multiple dialog windows //being open at the same time. @@ -95,7 +76,7 @@ export class AppError extends React.Component { private onRetryAction = (event: React.MouseEvent) => { event.preventDefault() - this.onDismissed() + this.props.onDismissed() const { error } = this.state @@ -113,49 +94,53 @@ export class AppError extends React.Component { // If the error message is just the raw git output, display it in // fixed-width font if (isRawGitError(e)) { - const formattedMessage = this.formatGitErrorMessage(e.message) - return

{formattedMessage}

+ return

{e.message}

+ } + + if ( + isGitError(e) && + e.result.gitError === DugiteError.PushWithFileSizeExceedingLimit + ) { + const files = getFileFromExceedsError(coerceToString(e.result.stderr)) + return ( + <> +

{error.message}

+ {files.length > 0 && ( + <> +

Files that exceed the limit

+
    + {files.map(file => ( +
  • {file}
  • + ))} +
+ + )} +

+ See{' '} + https://gh.io/lfs{' '} + for more information on managing large files on GitHub +

+ + ) } return

{e.message}

} private getTitle(error: Error) { - if (isCloneError(error)) { - return 'Clone failed' + switch (getDugiteError(error)) { + case DugiteError.PushWithFileSizeExceedingLimit: + return 'File size limit exceeded' } - return 'Error' - } - - private renderDialog() { - const error = this.state.error - - if (!error) { - return null + switch (getRetryActionType(error)) { + case RetryActionType.Clone: + return 'Clone failed' + case RetryActionType.Push: + return 'Failed to push' } - return ( - - - {this.renderErrorMessage(error)} - {this.renderContentAfterErrorMessage(error)} - - {this.renderFooter(error)} - - ) + return 'Error' } private renderContentAfterErrorMessage(error: Error) { @@ -207,7 +192,7 @@ export class AppError extends React.Component { private onCloseButtonClick = (e: React.MouseEvent) => { e.preventDefault() - this.onDismissed() + this.props.onDismissed() } private renderFooter(error: Error) { @@ -257,16 +242,36 @@ export class AppError extends React.Component { } public render() { - const dialogContent = this.renderDialog() + const error = this.state.error + + if (!error) { + return null + } return ( - - {dialogContent && ( - - {dialogContent} - - )} - + + +
+ {this.renderErrorMessage(error)} + {this.renderContentAfterErrorMessage(error)} +
+
+ {this.renderFooter(error)} +
) } } @@ -298,3 +303,16 @@ function isCloneError(error: Error) { const { retryAction } = error.metadata return retryAction !== undefined && retryAction.type === RetryActionType.Clone } + +function getRetryActionType(error: Error) { + if (!isErrorWithMetaData(error)) { + return undefined + } + + return error.metadata.retryAction?.type +} + +function getDugiteError(error: Error) { + const e = getUnderlyingError(error) + return isGitError(e) ? e.result.gitError : undefined +} diff --git a/app/src/ui/app-menu/app-menu-bar-button.tsx b/app/src/ui/app-menu/app-menu-bar-button.tsx index 65e89f1ab02..91950bba86f 100644 --- a/app/src/ui/app-menu/app-menu-bar-button.tsx +++ b/app/src/ui/app-menu/app-menu-bar-button.tsx @@ -27,15 +27,6 @@ interface IAppMenuBarButtonProps { */ readonly enableAccessKeyNavigation: boolean - /** - * Whether the menu was opened by pressing Alt (or Alt+X where X is an - * access key for one of the top level menu items). This is used as a - * one-time signal to the AppMenu to use some special semantics for - * selection and focus. Specifically it will ensure that the last opened - * menu will receive focus. - */ - readonly openedWithAccessKey: boolean - /** * Whether or not to highlight the access key of a top-level menu * items (if they have one). This is normally true when the Alt-key @@ -202,13 +193,19 @@ export class AppMenuBarButton extends React.Component< onMouseEnter={this.onMouseEnter} onKeyDown={this.onKeyDown} tabIndex={-1} - role="menuitem" + buttonRole="menuitem" + buttonAriaHaspopup="menu" > ) @@ -254,7 +251,7 @@ export class AppMenuBarButton extends React.Component< if (this.isMenuOpen) { this.props.onClose(this.props.menuItem, source) } else { - this.props.onOpen(this.props.menuItem) + this.props.onOpen(this.props.menuItem, true) } } @@ -269,10 +266,9 @@ export class AppMenuBarButton extends React.Component< ) } diff --git a/app/src/ui/app-menu/app-menu-bar.tsx b/app/src/ui/app-menu/app-menu-bar.tsx index 20bc6c5312b..0e4940f66a9 100644 --- a/app/src/ui/app-menu/app-menu-bar.tsx +++ b/app/src/ui/app-menu/app-menu-bar.tsx @@ -9,6 +9,10 @@ import { AppMenuBarButton } from './app-menu-bar-button' import { Dispatcher } from '../dispatcher' import { AppMenuFoldout, FoldoutType } from '../../lib/app-state' +/** This is the id used for the windows app menu and used elsewhere + * to determine if the app menu is is focus */ +export const appMenuId = 'app-menu-bar' + interface IAppMenuBarProps { readonly appMenu: ReadonlyArray readonly dispatcher: Dispatcher @@ -445,10 +449,6 @@ export class AppMenuBar extends React.Component< ? this.props.appMenu.slice(1) : [] - const openedWithAccessKey = foldoutState - ? foldoutState.openedWithAccessKey || false - : false - const enableAccessKeyNavigation = foldoutState ? foldoutState.enableAccessKeyNavigation : false @@ -468,7 +468,6 @@ export class AppMenuBar extends React.Component< menuState={menuState} highlightMenuAccessKey={highlightMenuAccessKey} enableAccessKeyNavigation={enableAccessKeyNavigation} - openedWithAccessKey={openedWithAccessKey} onClose={this.onMenuClose} onOpen={this.onMenuOpen} onMouseEnter={this.onMenuButtonMouseEnter} diff --git a/app/src/ui/app-menu/app-menu.tsx b/app/src/ui/app-menu/app-menu.tsx index 36bf8c59cc2..21f1174c2a2 100644 --- a/app/src/ui/app-menu/app-menu.tsx +++ b/app/src/ui/app-menu/app-menu.tsx @@ -31,24 +31,8 @@ interface IAppMenuProps { */ readonly enableAccessKeyNavigation: boolean - /** - * Whether the menu was opened by pressing Alt (or Alt+X where X is an - * access key for one of the top level menu items). This is used as a - * one-time signal to the AppMenu to use some special semantics for - * selection and focus. Specifically it will ensure that the last opened - * menu will receive focus. - */ - readonly openedWithAccessKey: boolean - - /** - * If true the MenuPane only takes up as much vertical space needed to - * show all menu items. This does not affect maximum height, i.e. if the - * visible menu items takes up more space than what is available the menu - * will still overflow and be scrollable. - * - * @default false - */ - readonly autoHeight?: boolean + /** The id of the element that serves as the menu's accessibility label */ + readonly ariaLabelledby: string } export interface IKeyboardCloseSource { @@ -85,22 +69,6 @@ function menuPaneClassNameFromId(id: string) { } export class AppMenu extends React.Component { - /** - * The index of the menu pane that should receive focus after the - * next render. Default value is -1. This field is cleared after - * each successful focus operation. - */ - private focusPane: number = -1 - - /** - * A mapping between pane index (depth) and actual MenuPane instances. - * This is used in order to (indirectly) call the focus method on the - * underlying List instances. - * - * See focusPane and ensurePaneFocus - */ - private paneRefs: MenuPane[] = [] - /** * A numeric reference to a setTimeout timer id which is used for * opening and closing submenus after a delay. @@ -109,29 +77,6 @@ export class AppMenu extends React.Component { */ private expandCollapseTimer: number | null = null - public constructor(props: IAppMenuProps) { - super(props) - this.focusPane = props.state.length - 1 - this.receiveProps(null, props) - } - - private receiveProps( - currentProps: IAppMenuProps | null, - nextProps: IAppMenuProps - ) { - if (nextProps.openedWithAccessKey) { - // We only want to react to the openedWithAccessKey prop once, either - // when it goes from false to true or when we receive it as our first - // prop. By doing it this way we save ourselves having to go through - // the dispatcher and updating the value once we've received it. - if (!currentProps || !currentProps.openedWithAccessKey) { - // Since we were opened with an access key we auto set focus to the - // last pane opened. - this.focusPane = nextProps.state.length - 1 - } - } - } - private onItemClicked = ( depth: number, item: MenuItem, @@ -154,9 +99,6 @@ export class AppMenu extends React.Component { this.props.dispatcher.setAppMenuState(menu => menu.withOpenedMenu(item, sourceIsAccessKey) ) - if (source.kind === 'keyboard') { - this.focusPane = depth + 1 - } } else if (item.type !== 'separator') { // Send the close event before actually executing the item so that // the menu can restore focus to the previously selected element @@ -166,11 +108,12 @@ export class AppMenu extends React.Component { } } - private onItemKeyDown = ( + private onPaneKeyDown = ( depth: number, - item: MenuItem, - event: React.KeyboardEvent + event: React.KeyboardEvent ) => { + const { selectedItem } = this.props.state[depth] + if (event.key === 'ArrowLeft' || event.key === 'Escape') { this.clearExpandCollapseTimer() @@ -184,18 +127,16 @@ export class AppMenu extends React.Component { menu.withClosedMenu(this.props.state[depth]) ) - this.focusPane = depth - 1 event.preventDefault() } } else if (event.key === 'ArrowRight') { this.clearExpandCollapseTimer() // Open the submenu and select the first item - if (item.type === 'submenuItem') { + if (selectedItem?.type === 'submenuItem') { this.props.dispatcher.setAppMenuState(menu => - menu.withOpenedMenu(item, true) + menu.withOpenedMenu(selectedItem, true) ) - this.focusPane = depth + 1 event.preventDefault() } } @@ -222,6 +163,12 @@ export class AppMenu extends React.Component { }, expandCollapseTimeout) } + private onClearSelection = (depth: number) => { + this.props.dispatcher.setAppMenuState(appMenu => + appMenu.withDeselectedMenu(this.props.state[depth]) + ) + } + private onSelectionChanged = ( depth: number, item: MenuItem, @@ -248,12 +195,6 @@ export class AppMenu extends React.Component { } } - private onMenuPaneRef = (pane: MenuPane | null) => { - if (pane) { - this.paneRefs[pane.props.depth] = pane - } - } - private onPaneMouseEnter = (depth: number) => { this.clearExpandCollapseTimer() @@ -268,8 +209,6 @@ export class AppMenu extends React.Component { // This ensures that the selection to this menu is reset. this.props.dispatcher.setAppMenuState(m => m.withDeselectedMenu(paneMenu)) } - - this.focusPane = depth } private onKeyDown = (event: React.KeyboardEvent) => { @@ -280,12 +219,6 @@ export class AppMenu extends React.Component { } private renderMenuPane(depth: number, menu: IMenu): JSX.Element { - // NB: We use the menu id instead of depth as the key here to force - // a new MenuPane instance and List. This is because we used dynamic - // row heights and the react-virtualized Grid component isn't able to - // recompute row heights accurately. Without this row indices which - // previously held a separator item will retain that height and vice- - // versa. // If the menu doesn't have an id it's the root menu const key = menu.id || '@' const className = menu.id ? menuPaneClassNameFromId(menu.id) : undefined @@ -293,17 +226,17 @@ export class AppMenu extends React.Component { return ( ) } @@ -312,43 +245,14 @@ export class AppMenu extends React.Component { const menus = this.props.state const panes = menus.map((m, depth) => this.renderMenuPane(depth, m)) - // Clear out any old references we might have to panes that are - // no longer displayed. - this.paneRefs = this.paneRefs.slice(0, panes.length) - return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{panes}
) } - /** - * Called after mounting or re-rendering and ensures that the - * appropriate (if any) MenuPane list receives keyboard focus. - */ - private ensurePaneFocus() { - if (this.focusPane >= 0) { - const pane = this.paneRefs[this.focusPane] - if (pane) { - pane.focus() - this.focusPane = -1 - } - } - } - - public componentWillReceiveProps(nextProps: IAppMenuProps) { - this.receiveProps(this.props, nextProps) - } - - public componentDidMount() { - this.ensurePaneFocus() - } - - public componentDidUpdate() { - this.ensurePaneFocus() - } - public componentWillUnmount() { this.clearExpandCollapseTimer() } diff --git a/app/src/ui/app-menu/menu-list-item.tsx b/app/src/ui/app-menu/menu-list-item.tsx index aa7a7f37874..b18661d281e 100644 --- a/app/src/ui/app-menu/menu-list-item.tsx +++ b/app/src/ui/app-menu/menu-list-item.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import classNames from 'classnames' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { MenuItem } from '../../models/app-menu' import { AccessText } from '../lib/access-text' import { getPlatformSpecificNameOrSymbolForModifier } from '../../lib/menu-item' @@ -10,6 +10,12 @@ import { getPlatformSpecificNameOrSymbolForModifier } from '../../lib/menu-item' interface IMenuListItemProps { readonly item: MenuItem + /** + * A unique identifier for the menu item. On use is to link it to the menu + * for accessibility labelling. + */ + readonly menuItemId?: string + /** * Whether or not to highlight the access key of a menu item (if one exists). * @@ -34,6 +40,42 @@ interface IMenuListItemProps { * Defaults to true if not specified (i.e. undefined) */ readonly renderSubMenuArrow?: boolean + + /** + * Whether or not the menu item represented by this list item is the currently + * selected menu item. + */ + readonly selected: boolean + + /** + * Whether or not this menu item should have a role applied + */ + readonly hasNoRole?: boolean + + /** Called when the user's pointer device enter the list item */ + readonly onMouseEnter?: ( + item: MenuItem, + event: React.MouseEvent + ) => void + /** Called when the user's pointer device leaves the list item */ + readonly onMouseLeave?: ( + item: MenuItem, + event: React.MouseEvent + ) => void + + /** Called when the user's pointer device clicks on the list item */ + readonly onClick?: ( + item: MenuItem, + event: React.MouseEvent + ) => void + + /** + * Whether the list item should steal focus when selected. Defaults to + * false. + */ + readonly focusOnSelection?: boolean + + readonly renderLabel?: (item: MenuItem) => JSX.Element | undefined } /** @@ -49,16 +91,59 @@ export function friendlyAcceleratorText(accelerator: string): string { } export class MenuListItem extends React.Component { + private wrapperRef = React.createRef() + private getIcon(item: MenuItem): JSX.Element | null { if (item.type === 'checkbox' && item.checked) { - return + return } else if (item.type === 'radio' && item.checked) { - return + return } return null } + private onMouseEnter = (event: React.MouseEvent) => { + this.props.onMouseEnter?.(this.props.item, event) + } + + private onMouseLeave = (event: React.MouseEvent) => { + this.props.onMouseLeave?.(this.props.item, event) + } + + private onClick = (event: React.MouseEvent) => { + this.props.onClick?.(this.props.item, event) + } + + public componentDidMount() { + if (this.props.selected && this.props.focusOnSelection) { + this.wrapperRef.current?.focus() + } + } + + public componentDidUpdate(prevProps: IMenuListItemProps) { + const { focusOnSelection, selected } = this.props + if (focusOnSelection && selected && !prevProps.selected) { + this.wrapperRef.current?.focus() + } + } + + private renderLabel() { + const { item, renderLabel } = this.props + + if (renderLabel !== undefined) { + return renderLabel(item) + } + + if (item.type === 'separator') { + return + } + + return ( + + ) + } + public render() { const item = this.props.item @@ -68,10 +153,7 @@ export class MenuListItem extends React.Component { const arrow = item.type === 'submenuItem' && this.props.renderSubMenuArrow !== false ? ( - + ) : null const accelerator = @@ -83,26 +165,38 @@ export class MenuListItem extends React.Component { ) : null - const className = classNames( - 'menu-item', - { disabled: !item.enabled }, - { checkbox: item.type === 'checkbox' }, - { radio: item.type === 'radio' }, - { - checked: - (item.type === 'checkbox' || item.type === 'radio') && item.checked, - } - ) + const { type } = item + + const className = classNames('menu-item', { + disabled: !item.enabled, + checkbox: type === 'checkbox', + radio: type === 'radio', + checked: (type === 'checkbox' || type === 'radio') && item.checked, + selected: this.props.selected, + }) + + const role = this.props.hasNoRole + ? undefined + : type === 'checkbox' + ? 'menuitemradio' + : 'menuitem' + const ariaChecked = type === 'checkbox' ? item.checked : undefined return ( -
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
{this.getIcon(item)} -
- -
+
{this.renderLabel()}
{accelerator} {arrow}
diff --git a/app/src/ui/app-menu/menu-pane.tsx b/app/src/ui/app-menu/menu-pane.tsx index 0d8a544f771..c0dd8aab00b 100644 --- a/app/src/ui/app-menu/menu-pane.tsx +++ b/app/src/ui/app-menu/menu-pane.tsx @@ -1,13 +1,22 @@ import * as React from 'react' import classNames from 'classnames' -import { List, ClickSource, SelectionSource } from '../lib/list' +import { + ClickSource, + findLastSelectableRow, + findNextSelectableRow, + IHoverSource, + IKeyboardSource, + IMouseClickSource, + SelectionSource, +} from '../lib/list' import { MenuItem, itemIsSelectable, findItemByAccessKey, } from '../../models/app-menu' import { MenuListItem } from './menu-list-item' +import { assertNever } from '../../lib/fatal-error' interface IMenuPaneProps { /** @@ -47,14 +56,13 @@ interface IMenuPaneProps { ) => void /** - * A callback for when a keyboard key is pressed on a menu item. Note that - * this only picks up on keyboard events received by a MenuItem and does - * not cover keyboard events received on the MenuPane component itself. + * Called when the user presses down on a key while focused on, or within, the + * menu pane. Consumers should inspect isDefaultPrevented to determine whether + * the event was handled by the menu pane or not. */ - readonly onItemKeyDown?: ( + readonly onKeyDown?: ( depth: number, - item: MenuItem, - event: React.KeyboardEvent + event: React.KeyboardEvent ) => void /** @@ -74,118 +82,95 @@ interface IMenuPaneProps { * enables access key highlighting for applicable menu items as well as * keyboard navigation by pressing access keys. */ - readonly enableAccessKeyNavigation: boolean - - /** - * If true the MenuPane only takes up as much vertical space needed to - * show all menu items. This does not affect maximum height, i.e. if the - * visible menu items takes up more space than what is available the menu - * will still overflow and be scrollable. - * - * @default false - */ - readonly autoHeight?: boolean -} + readonly enableAccessKeyNavigation?: boolean -interface IMenuPaneState { /** - * A list of visible menu items that is to be rendered. This is a derivative - * of the props items with invisible items filtered out. + * Called to deselect the currently selected menu item (if any). This + * will be called when the user's pointer device leaves a menu item. */ - readonly items: ReadonlyArray + readonly onClearSelection: (depth: number) => void - /** The selected row index or -1 if no selection exists. */ - readonly selectedIndex: number -} + /** The id of the element that serves as the menu's accessibility label */ + readonly ariaLabelledby?: string -const RowHeight = 30 -const SeparatorRowHeight = 10 + /** Whether we move focus to the next menu item with a label that starts with + * the typed character if such an menu item exists. */ + readonly allowFirstCharacterNavigation?: boolean -function getSelectedIndex( - selectedItem: MenuItem | undefined, - items: ReadonlyArray -) { - return selectedItem ? items.findIndex(i => i.id === selectedItem.id) : -1 + readonly renderLabel?: (item: MenuItem) => JSX.Element | undefined } -export function getListHeight(menuItems: ReadonlyArray) { - return menuItems.reduce((acc, item) => acc + getRowHeight(item), 0) -} - -export function getRowHeight(item: MenuItem) { - if (!item.visible) { - return 0 +export class MenuPane extends React.Component { + private onRowClick = ( + item: MenuItem, + event: React.MouseEvent + ) => { + if (item.type !== 'separator' && item.enabled) { + const source: IMouseClickSource = { kind: 'mouseclick', event } + this.props.onItemClicked(this.props.depth, item, source) + } } - return item.type === 'separator' ? SeparatorRowHeight : RowHeight -} + private tryMoveSelection( + direction: 'up' | 'down' | 'first' | 'last', + source: ClickSource + ) { + const { items, selectedItem } = this.props + const row = selectedItem ? items.indexOf(selectedItem) : -1 + const count = items.length + const selectable = (ix: number) => items[ix] && itemIsSelectable(items[ix]) + + let ix: number | null = null + + if (direction === 'up' || direction === 'down') { + ix = findNextSelectableRow(count, { direction, row }, selectable) + } else if (direction === 'first' || direction === 'last') { + const d = direction === 'first' ? 'up' : 'down' + ix = findLastSelectableRow(d, count, selectable) + } -/** - * Creates a menu pane state given props. This is intentionally not - * an instance member in order to avoid mistakenly using any other - * input data or state than the received props. - */ -function createState(props: IMenuPaneProps): IMenuPaneState { - const items = new Array() - const selectedItem = props.selectedItem - - let selectedIndex = -1 - - // Filter out all invisible items and maintain the correct - // selected index (if possible) - for (let i = 0; i < props.items.length; i++) { - const item = props.items[i] - - if (item.visible) { - items.push(item) - if (item === selectedItem) { - selectedIndex = items.length - 1 - } + if (ix !== null && items[ix] !== undefined) { + this.props.onSelectionChanged(this.props.depth, items[ix], source) + return true } + + return false } - return { items, selectedIndex } -} + private tryMoveSelectionByFirstCharacter(key: string, source: ClickSource) { + if ( + key.length > 1 || + !isPrintableCharacterKey(key) || + !this.props.allowFirstCharacterNavigation + ) { + return + } + const { items, selectedItem } = this.props + const char = key.toLowerCase() + const currentRow = selectedItem ? items.indexOf(selectedItem) + 1 : 0 + const start = currentRow + 1 > items.length ? 0 : currentRow + 1 -export class MenuPane extends React.Component { - private list: List | null = null + const firstChars = items.map(v => + v.type === 'separator' ? '' : v.label.trim()[0].toLowerCase() + ) - public constructor(props: IMenuPaneProps) { - super(props) - this.state = createState(props) - } + // Check menu items after selected + let ix: number = firstChars.indexOf(char, start) - public componentWillReceiveProps(nextProps: IMenuPaneProps) { - // No need to recreate the filtered list if it hasn't changed, - // we only have to update the selected item - if (this.props.items === nextProps.items) { - // Has the selection changed? - if (this.props.selectedItem !== nextProps.selectedItem) { - const selectedIndex = getSelectedIndex( - nextProps.selectedItem, - this.state.items - ) - this.setState({ selectedIndex }) - } - } else { - this.setState(createState(nextProps)) + // check menu items before selected + if (ix === -1) { + ix = firstChars.indexOf(char, 0) } - } - private onRowClick = (row: number, source: ClickSource) => { - const item = this.state.items[row] - - if (item.type !== 'separator' && item.enabled) { - this.props.onItemClicked(this.props.depth, item, source) + if (ix >= 0 && items[ix] !== undefined) { + this.props.onSelectionChanged(this.props.depth, items[ix], source) + return true } - } - private onSelectedRowChanged = (row: number, source: SelectionSource) => { - const item = this.state.items[row] - this.props.onSelectionChanged(this.props.depth, item, source) + return false } - private onKeyDown = (event: React.KeyboardEvent) => { + private onKeyDown = (event: React.KeyboardEvent) => { if (event.defaultPrevented) { return } @@ -195,103 +180,117 @@ export class MenuPane extends React.Component { return } - // If we weren't opened with the Alt key we ignore key presses other than - // arrow keys and Enter/Space etc. - if (!this.props.enableAccessKeyNavigation) { - return - } + const source: IKeyboardSource = { kind: 'keyboard', event } + const { selectedItem } = this.props + const { key } = event - // At this point the list will already have intercepted any arrow keys - // and the list items themselves will have caught Enter/Space - const item = findItemByAccessKey(event.key, this.state.items) - if (item && itemIsSelectable(item)) { + if (isSupportedKey(key)) { event.preventDefault() - this.props.onSelectionChanged(this.props.depth, item, { - kind: 'keyboard', - event: event, - }) - this.props.onItemClicked(this.props.depth, item, { - kind: 'keyboard', - event: event, - }) - } - } - private onRowKeyDown = (row: number, event: React.KeyboardEvent) => { - if (this.props.onItemKeyDown) { - const item = this.state.items[row] - this.props.onItemKeyDown(this.props.depth, item, event) + if (key === 'ArrowUp' || key === 'ArrowDown') { + this.tryMoveSelection(key === 'ArrowUp' ? 'up' : 'down', source) + } else if (key === 'Home' || key === 'End') { + const direction = key === 'Home' ? 'first' : 'last' + this.tryMoveSelection(direction, source) + } else if (key === 'Enter' || key === ' ') { + if (selectedItem !== undefined) { + this.props.onItemClicked(this.props.depth, selectedItem, source) + } + } else { + assertNever(key, 'Unsupported key') + } } - } - private canSelectRow = (row: number) => { - const item = this.state.items[row] - return itemIsSelectable(item) - } + this.tryMoveSelectionByFirstCharacter(key, source) + + // If we weren't opened with the Alt key we ignore key presses other than + // arrow keys and Enter/Space etc. + if (this.props.enableAccessKeyNavigation) { + // At this point the list will already have intercepted any arrow keys + // and the list items themselves will have caught Enter/Space + const item = findItemByAccessKey(event.key, this.props.items) + if (item && itemIsSelectable(item)) { + event.preventDefault() + this.props.onSelectionChanged(this.props.depth, item, { + kind: 'keyboard', + event: event, + }) + this.props.onItemClicked(this.props.depth, item, { + kind: 'keyboard', + event: event, + }) + } + } - private onListRef = (list: List | null) => { - this.list = list + this.props.onKeyDown?.(this.props.depth, event) } private onMouseEnter = (event: React.MouseEvent) => { - if (this.props.onMouseEnter) { - this.props.onMouseEnter(this.props.depth) - } + this.props.onMouseEnter?.(this.props.depth) } - private renderMenuItem = (row: number) => { - const item = this.state.items[row] - - return ( - - ) + private onRowMouseEnter = ( + item: MenuItem, + event: React.MouseEvent + ) => { + if (itemIsSelectable(item)) { + const source: IHoverSource = { kind: 'hover', event } + this.props.onSelectionChanged(this.props.depth, item, source) + } } - private rowHeight = (info: { index: number }) => { - const item = this.state.items[info.index] - return item.type === 'separator' ? SeparatorRowHeight : RowHeight + private onRowMouseLeave = ( + item: MenuItem, + event: React.MouseEvent + ) => { + if (this.props.selectedItem === item) { + this.props.onClearSelection(this.props.depth) + } } public render(): JSX.Element { - const style: React.CSSProperties = - this.props.autoHeight === true - ? { height: getListHeight(this.props.items) + 5, maxHeight: '100%' } - : {} - const className = classNames('menu-pane', this.props.className) return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions
- + {this.props.items + .filter(x => x.visible) + .map((item, ix) => ( + + ))}
) } - - public focus() { - if (this.list) { - this.list.focus() - } - } } + +const supportedKeys = [ + 'ArrowUp', + 'ArrowDown', + 'Home', + 'End', + 'Enter', + ' ', +] as const +const isSupportedKey = (key: string): key is typeof supportedKeys[number] => + (supportedKeys as readonly string[]).includes(key) + +const isPrintableCharacterKey = (key: string) => + key.length === 1 && key.match(/\S/) diff --git a/app/src/ui/app-theme.tsx b/app/src/ui/app-theme.tsx index 9180cd6ab18..4563f1a6a5d 100644 --- a/app/src/ui/app-theme.tsx +++ b/app/src/ui/app-theme.tsx @@ -3,15 +3,10 @@ import { ApplicationTheme, getThemeName, getCurrentlyAppliedTheme, - ICustomTheme, } from './lib/application-theme' -import { isHexColorLight } from './lib/color-manipulation' -import { buildCustomThemeStyles } from './lib/custom-theme' interface IAppThemeProps { readonly theme: ApplicationTheme - readonly useCustomTheme: boolean - readonly customTheme?: ICustomTheme } /** @@ -40,13 +35,6 @@ export class AppTheme extends React.PureComponent { } private async ensureTheme() { - const { customTheme, useCustomTheme } = this.props - if (customTheme !== undefined && useCustomTheme) { - this.clearThemes() - this.setCustomTheme(customTheme) - return - } - let themeToDisplay = this.props.theme if (this.props.theme === ApplicationTheme.System) { @@ -54,15 +42,10 @@ export class AppTheme extends React.PureComponent { } const newThemeClassName = `theme-${getThemeName(themeToDisplay)}` - const body = document.body - if ( - !body.classList.contains(newThemeClassName) || - (body.classList.contains('theme-high-contrast') && - !this.props.useCustomTheme) - ) { + if (!document.body.classList.contains(newThemeClassName)) { this.clearThemes() - body.classList.add(newThemeClassName) + document.body.classList.add(newThemeClassName) this.updateColorScheme() } } @@ -74,45 +57,6 @@ export class AppTheme extends React.PureComponent { rootStyle.colorScheme = isDarkTheme ? 'dark' : 'light' } - /** - * This takes a custom theme object and applies it over top either our dark or - * light theme dynamically creating a new variables style sheet. - * - * It uses the background color of the custom theme to determine if the custom - * theme should be based on the light or dark theme. This is most important - * for the diff syntax highlighting. - * - * @param customTheme - */ - private setCustomTheme(customTheme: ICustomTheme) { - const { background } = customTheme - const body = document.body - - if (!body.classList.contains('theme-high-contrast')) { - // Currently our only custom theme is the high-contrast theme - // If we were to expand upon custom theming we would not - // want this so specific. - body.classList.add('theme-high-contrast') - // This is important so that code diff syntax colors are legible if the - // user customizes to a light vs dark background. Tho, the code diff does - // still use the customizable text color for some of the syntax text so - // user can still make things illegible by choosing poorly. - const themeBase = isHexColorLight(background) - ? 'theme-light' - : 'theme-dark' - body.classList.add(themeBase) - } - - const customThemeStyles = buildCustomThemeStyles(customTheme) - - const styles = document.createElement('style') - styles.setAttribute('type', 'text/css') - styles.appendChild(document.createTextNode(customThemeStyles)) - - body.appendChild(styles) - this.updateColorScheme() - } - private clearThemes() { const body = document.body diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 6140020c25e..65ba4e12a4c 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -1,5 +1,6 @@ import * as React from 'react' -import * as crypto from 'crypto' +import * as Path from 'path' + import { TransitionGroup, CSSTransition } from 'react-transition-group' import { IAppState, @@ -8,18 +9,24 @@ import { SelectionType, HistoryTabMode, } from '../lib/app-state' -import { defaultErrorHandler, Dispatcher } from './dispatcher' +import { Dispatcher } from './dispatcher' import { AppStore, GitHubUserStore, IssuesStore } from '../lib/stores' import { assertNever } from '../lib/fatal-error' import { shell } from '../lib/app-shell' import { updateStore, UpdateStatus } from './lib/update-store' import { RetryAction } from '../models/retry-actions' +import { FetchType } from '../models/fetch' import { shouldRenderApplicationMenu } from './lib/features' import { matchExistingRepository } from '../lib/repository-matching' import { getDotComAPIEndpoint } from '../lib/api' import { getVersion, getName } from './lib/app-proxy' -import { getOS } from '../lib/get-os' -import { MenuEvent } from '../main-process/menu' +import { + getOS, + isOSNoLongerSupportedByElectron, + isMacOSAndNoLongerSupportedByElectron, + isWindowsAndNoLongerSupportedByElectron, +} from '../lib/get-os' +import { MenuEvent, isTestMenuEvent } from '../main-process/menu' import { Repository, getGitHubHtmlUrl, @@ -49,13 +56,15 @@ import { BranchDropdown, RevertProgress, } from './toolbar' -import { iconForRepository, OcticonSymbolType } from './octicons' -import * as OcticonSymbol from './octicons/octicons.generated' +import { iconForRepository, OcticonSymbol } from './octicons' +import * as octicons from './octicons/octicons.generated' import { showCertificateTrustDialog, sendReady, isInApplicationFolder, selectAllWindowContents, + installWindowsCLI, + uninstallWindowsCLI, } from './main-process-proxy' import { DiscardChanges } from './discard-changes' import { Welcome } from './welcome' @@ -93,18 +102,19 @@ import { RepositoryStateCache } from '../lib/stores/repository-state-cache' import { PopupType, Popup } from '../models/popup' import { OversizedFiles } from './changes/oversized-files-warning' import { PushNeedsPullWarning } from './push-needs-pull' -import { isCurrentBranchForcePush } from '../lib/rebase' +import { getCurrentBranchForcePushState } from '../lib/rebase' import { Banner, BannerType } from '../models/banner' import { StashAndSwitchBranch } from './stash-changes/stash-and-switch-branch-dialog' import { OverwriteStash } from './stash-changes/overwrite-stashed-changes-dialog' import { ConfirmDiscardStashDialog } from './stashing/confirm-discard-stash' +import { ConfirmCheckoutCommitDialog } from './checkout/confirm-checkout-commit' import { CreateTutorialRepositoryDialog } from './no-repositories/create-tutorial-repository-dialog' import { ConfirmExitTutorial } from './tutorial' import { TutorialStep, isValidTutorialStep } from '../models/tutorial-step' import { WorkflowPushRejectedDialog } from './workflow-push-rejected/workflow-push-rejected' import { SAMLReauthRequiredDialog } from './saml-reauth-required/saml-reauth-required' import { CreateForkDialog } from './forks/create-fork-dialog' -import { findDefaultUpstreamBranch } from '../lib/branch' +import { findContributionTargetDefaultBranch } from '../lib/branch' import { GitHubRepository, hasWritePermission, @@ -132,7 +142,6 @@ import { ReleaseNote } from '../models/release-notes' import { CommitMessageDialog } from './commit-message/commit-message-dialog' import { buildAutocompletionProviders } from './autocompletion' import { DragType, DropTargetSelector } from '../models/drag-drop' -import { enableSquashing } from '../lib/feature-flag' import { dragAndDropManager } from '../lib/drag-and-drop-manager' import { MultiCommitOperation } from './multi-commit-operation/multi-commit-operation' import { WarnLocalChangesBeforeUndo } from './undo/warn-local-changes-before-undo' @@ -147,14 +156,32 @@ import { PullRequestChecksFailed } from './notifications/pull-request-checks-fai import { CICheckRunRerunDialog } from './check-runs/ci-check-run-rerun-dialog' import { WarnForcePushDialog } from './multi-commit-operation/dialog/warn-force-push-dialog' import { clamp } from '../lib/clamp' +import { generateRepositoryListContextMenu } from './repositories-list/repository-list-item-context-menu' import * as ipcRenderer from '../lib/ipc-renderer' -import { showNotification } from '../lib/notifications/show-notification' import { DiscardChangesRetryDialog } from './discard-changes/discard-changes-retry-dialog' -import { generateDevReleaseSummary } from '../lib/release-notes' import { PullRequestReview } from './notifications/pull-request-review' -import { getPullRequestCommitRef } from '../models/pull-request' import { getRepositoryType } from '../lib/git' import { SSHUserPassword } from './ssh/ssh-user-password' +import { showContextualMenu } from '../lib/menu-item' +import { UnreachableCommitsDialog } from './history/unreachable-commits-dialog' +import { OpenPullRequestDialog } from './open-pull-request/open-pull-request-dialog' +import { sendNonFatalException } from '../lib/helpers/non-fatal-exception' +import { createCommitURL } from '../lib/commit-url' +import { InstallingUpdate } from './installing-update/installing-update' +import { DialogStackContext } from './dialog' +import { TestNotifications } from './test-notifications/test-notifications' +import { NotificationsDebugStore } from '../lib/stores/notifications-debug-store' +import { PullRequestComment } from './notifications/pull-request-comment' +import { UnknownAuthors } from './unknown-authors/unknown-authors-dialog' +import { UnsupportedOSBannerDismissedAtKey } from './banners/os-version-no-longer-supported-banner' +import { offsetFromNow } from '../lib/offset-from' +import { getBoolean, getNumber } from '../lib/local-storage' +import { IconPreviewDialog } from './octicons/icon-preview-dialog' +import { accessibilityBannerDismissed } from './banners/accessibilty-settings-banner' +import { isCertificateErrorSuppressedFor } from '../lib/suppress-certificate-error' +import { webUtils } from 'electron' +import { showTestUI } from './lib/test-ui-components/test-ui-components' +import { ConfirmCommitFilteredChanges } from './changes/confirm-commit-filtered-changes-dialog' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -176,6 +203,7 @@ interface IAppProps { readonly issuesStore: IssuesStore readonly gitHubUserStore: GitHubUserStore readonly aheadBehindStore: AheadBehindStore + readonly notificationsDebugStore: NotificationsDebugStore readonly startTime: number } @@ -211,7 +239,7 @@ export class App extends React.Component { * modal dialog such as the preferences, or an error dialog. */ private get isShowingModal() { - return this.state.currentPopup !== null || this.state.errors.length > 0 + return this.state.currentPopup !== null } /** @@ -219,8 +247,8 @@ export class App extends React.Component { * passed popupType, so it can be used in render() without creating * multiple instances when the component gets re-rendered. */ - private getOnPopupDismissedFn = memoizeOne((popupType: PopupType) => { - return () => this.onPopupDismissed(popupType) + private getOnPopupDismissedFn = memoizeOne((popupId: string) => { + return () => this.onPopupDismissed(popupId) }) public constructor(props: IAppProps) { @@ -287,6 +315,10 @@ export class App extends React.Component { }) ipcRenderer.on('certificate-error', (_, certificate, error, url) => { + if (isCertificateErrorSuppressedFor(url)) { + return + } + this.props.dispatcher.showPopup({ type: PopupType.UntrustedCertificate, certificate, @@ -299,6 +331,10 @@ export class App extends React.Component { public componentWillUnmount() { window.clearInterval(this.updateIntervalHandle) + + if (__DARWIN__) { + window.removeEventListener('keydown', this.onMacOSWindowKeyDown) + } } private async performDeferredLaunchActions() { @@ -338,12 +374,52 @@ export class App extends React.Component { this.showPopup({ type: PopupType.MoveToApplicationsFolder }) } + this.setOnOpenBanner() + } + + private onOpenAccessibilitySettings = () => { + this.props.dispatcher.showPopup({ + type: PopupType.Preferences, + initialSelectedTab: PreferencesTab.Accessibility, + }) + } + + /** + * This method sets the app banner on opening the app. The last banner set in + * this method will be the one shown as only one banner is shown at a time. + * The only exception is the update available banner is always + * prioritized over other banners. + * + * Priority: + * 1. OS Not Supported by Electron + * 2. Accessibility Settings Banner + * 3. Thank you banner + */ + private setOnOpenBanner() { + if (isOSNoLongerSupportedByElectron()) { + const dismissedAt = getNumber(UnsupportedOSBannerDismissedAtKey, 0) + + // Remind the user that they're running an unsupported OS every 90 days + if (dismissedAt < offsetFromNow(-90, 'days')) { + this.setBanner({ type: BannerType.OSVersionNoLongerSupported }) + return + } + } + + if (getBoolean(accessibilityBannerDismissed) !== true) { + this.setBanner({ + type: BannerType.AccessibilitySettingsBanner, + onOpenAccessibilitySettings: this.onOpenAccessibilitySettings, + }) + return + } + this.checkIfThankYouIsInOrder() } private onMenuEvent(name: MenuEvent): any { // Don't react to menu events when an error dialog is shown. - if (this.state.errors.length) { + if (name !== 'test-app-error' && this.state.errorCount > 1) { return } @@ -354,10 +430,12 @@ export class App extends React.Component { return this.push({ forceWithLease: true }) case 'pull': return this.pull() + case 'fetch': + return this.fetch() case 'show-changes': - return this.showChanges() + return this.showChanges(true) case 'show-history': - return this.showHistory() + return this.showHistory(true) case 'choose-repository': return this.chooseRepository() case 'add-local-repository': @@ -382,11 +460,13 @@ export class App extends React.Component { return this.props.dispatcher.showPopup({ type: PopupType.Preferences }) case 'open-working-directory': return this.openCurrentRepositoryWorkingDirectory() - case 'update-branch': - this.props.dispatcher.recordMenuInitiatedUpdate() - return this.updateBranch() + case 'update-branch-with-contribution-target-branch': + this.props.dispatcher.incrementMetric( + 'updateFromDefaultBranchMenuCount' + ) + return this.updateBranchWithContributionTargetBranch() case 'compare-to-branch': - return this.showHistory(true) + return this.showHistory(false, true) case 'merge-branch': this.props.dispatcher.recordMenuInitiatedMerge() return this.mergeBranch() @@ -394,16 +474,16 @@ export class App extends React.Component { this.props.dispatcher.recordMenuInitiatedMerge(true) return this.mergeBranch(true) case 'rebase-branch': - this.props.dispatcher.recordMenuInitiatedRebase() + this.props.dispatcher.incrementMetric('rebaseCurrentBranchMenuCount') return this.showRebaseDialog() case 'show-repository-settings': return this.showRepositorySettings() case 'view-repository-on-github': return this.viewRepositoryOnGitHub() case 'compare-on-github': - return this.openBranchOnGitub('compare') + return this.openBranchOnGitHub('compare') case 'branch-on-github': - return this.openBranchOnGitub('tree') + return this.openBranchOnGitHub('tree') case 'create-issue-in-repository-on-github': return this.openIssueCreationOnGitHub() case 'open-in-shell': @@ -412,151 +492,62 @@ export class App extends React.Component { return this.showCloneRepo() case 'show-about': return this.showAbout() - case 'boomtown': - return this.boomtown() case 'go-to-commit-message': return this.goToCommitMessage() case 'open-pull-request': return this.openPullRequest() - case 'install-cli': - return this.props.dispatcher.installCLI() + case 'preview-pull-request': + return this.startPullRequest() + case 'install-darwin-cli': + return this.props.dispatcher.installDarwinCLI() + case 'install-windows-cli': + return installWindowsCLI() + case 'uninstall-windows-cli': + return uninstallWindowsCLI() case 'open-external-editor': return this.openCurrentRepositoryInExternalEditor() case 'select-all': return this.selectAll() - case 'show-release-notes-popup': - return this.showFakeReleaseNotesPopup() case 'show-stashed-changes': return this.showStashedChanges() case 'hide-stashed-changes': return this.hideStashedChanges() - case 'test-show-notification': - return this.testShowNotification() - case 'test-prune-branches': - return this.testPruneBranches() case 'find-text': return this.findText() - case 'pull-request-check-run-failed': - return this.testPullRequestCheckRunFailed() + case 'increase-active-resizable-width': + return this.resizeActiveResizable('increase-active-resizable-width') + case 'decrease-active-resizable-width': + return this.resizeActiveResizable('decrease-active-resizable-width') default: + if (isTestMenuEvent(name)) { + return showTestUI( + name, + this.getRepository(), + this.props.dispatcher, + this.state.emoji + ) + } return assertNever(name, `Unknown menu event name: ${name}`) } } /** - * Show a release notes popup for a fake release, intended only to - * make it easier to verify changes to the popup. Has no meaning - * about a new release being available. + * Handler for the 'increase-active-resizable-width' and + * 'decrease-active-resizable-width' menu event, dispatches a custom DOM event + * originating from the element which currently has keyboard focus. Components + * have a chance to intercept this event and implement their resize logic. */ - private async showFakeReleaseNotesPopup() { - if (__DEV__) { - this.props.dispatcher.showPopup({ - type: PopupType.ReleaseNotes, - newReleases: await generateDevReleaseSummary(), + private resizeActiveResizable( + menuId: + | 'increase-active-resizable-width' + | 'decrease-active-resizable-width' + ) { + document.activeElement?.dispatchEvent( + new CustomEvent(menuId, { + bubbles: true, + cancelable: true, }) - } - } - - private testShowNotification() { - if ( - __RELEASE_CHANNEL__ !== 'development' && - __RELEASE_CHANNEL__ !== 'test' - ) { - return - } - - showNotification({ - title: 'Test notification', - body: 'Click here! This is a test notification', - onClick: () => this.props.dispatcher.showPopup({ type: PopupType.About }), - }) - } - - private testPullRequestCheckRunFailed() { - if ( - __RELEASE_CHANNEL__ !== 'development' && - __RELEASE_CHANNEL__ !== 'test' - ) { - return - } - - const { selectedState } = this.state - if ( - selectedState == null || - selectedState.type !== SelectionType.Repository - ) { - defaultErrorHandler( - new Error( - 'You must be in a GitHub repo, on a pull request branch, and your branch tip must be in a valid state.' - ), - this.props.dispatcher - ) - return - } - - const { - repository, - state: { - branchesState: { currentPullRequest: pullRequest, tip }, - }, - } = selectedState - - const currentBranchName = - tip.kind === TipState.Valid - ? tip.branch.upstreamWithoutRemote ?? tip.branch.name - : '' - - if ( - !isRepositoryWithGitHubRepository(repository) || - pullRequest === null || - currentBranchName === '' - ) { - defaultErrorHandler( - new Error( - 'You must be in a GitHub repo, on a pull request branch, and your branch tip must be in a valid state.' - ), - this.props.dispatcher - ) - return - } - - const cachedStatus = this.props.dispatcher.tryGetCommitStatus( - repository.gitHubRepository, - getPullRequestCommitRef(pullRequest.pullRequestNumber) ) - - if (cachedStatus?.checks === undefined) { - // Probably be hard for this to happen as the checks start loading in the background for pr statuses - defaultErrorHandler( - new Error( - 'Your pull request must have cached checks. Try opening the checks popover and then try again.' - ), - this.props.dispatcher - ) - return - } - - const { checks } = cachedStatus - - const popup: Popup = { - type: PopupType.PullRequestChecksFailed, - pullRequest, - repository, - shouldChangeRepository: true, - commitMessage: 'Adding this feature', - commitSha: pullRequest.head.sha, - checks, - } - - this.showPopup(popup) - } - - private testPruneBranches() { - if (!__DEV__) { - return - } - - this.props.appStore._testPruneBranches() } /** @@ -602,23 +593,34 @@ export class App extends React.Component { } } - private boomtown() { - setImmediate(() => { - throw new Error('Boomtown!') - }) - } - private async goToCommitMessage() { - await this.showChanges() + await this.showChanges(false) this.props.dispatcher.setCommitMessageFocus(true) } - private checkForUpdates(inBackground: boolean) { + private checkForUpdates( + inBackground: boolean, + skipGuidCheck: boolean = false + ) { if (__LINUX__ || __RELEASE_CHANNEL__ === 'development') { return } - updateStore.checkForUpdates(inBackground) + if (isWindowsAndNoLongerSupportedByElectron()) { + log.error( + `Can't check for updates on Windows 8.1 or older. Next available update only supports Windows 10 and later` + ) + return + } + + if (isMacOSAndNoLongerSupportedByElectron()) { + log.error( + `Can't check for updates on macOS 10.14 or older. Next available update only supports macOS 10.15 and later` + ) + return + } + + updateStore.checkForUpdates(inBackground, skipGuidCheck) } private getDotComAccount(): Account | null { @@ -635,7 +637,7 @@ export class App extends React.Component { return enterpriseAccount || null } - private updateBranch() { + private updateBranchWithContributionTargetBranch() { const { selectedState } = this.state if ( selectedState == null || @@ -645,19 +647,27 @@ export class App extends React.Component { } const { state, repository } = selectedState - const defaultBranch = state.branchesState.defaultBranch - if (!defaultBranch) { + + const contributionTargetDefaultBranch = findContributionTargetDefaultBranch( + repository, + state.branchesState + ) + if (!contributionTargetDefaultBranch) { return } this.props.dispatcher.initializeMergeOperation( repository, false, - defaultBranch + contributionTargetDefaultBranch ) const { mergeStatus } = state.compareState - this.props.dispatcher.mergeBranch(repository, defaultBranch, mergeStatus) + this.props.dispatcher.mergeBranch( + repository, + contributionTargetDefaultBranch, + mergeStatus + ) } private mergeBranch(isSquash: boolean = false) { @@ -672,7 +682,7 @@ export class App extends React.Component { this.props.dispatcher.startMergeBranchOperation(repository, isSquash) } - private openBranchOnGitub(view: 'tree' | 'compare') { + private openBranchOnGitHub(view: 'tree' | 'compare') { const htmlURL = this.getCurrentRepositoryGitHubURL() if (!htmlURL) { return @@ -845,7 +855,10 @@ export class App extends React.Component { this.props.dispatcher.showPopup({ type: PopupType.About }) } - private async showHistory(showBranchList: boolean = false) { + private async showHistory( + shouldFocusHistory: boolean, + showBranchList: boolean = false + ) { const state = this.state.selectedState if (state == null || state.type !== SelectionType.Repository) { return @@ -866,19 +879,28 @@ export class App extends React.Component { filterText: '', showBranchList, }) + + if (shouldFocusHistory) { + this.repositoryViewRef.current?.setFocusHistoryNeeded() + } } - private showChanges() { + private async showChanges(shouldFocusChanges: boolean) { const state = this.state.selectedState if (state == null || state.type !== SelectionType.Repository) { return } this.props.dispatcher.closeCurrentFoldout() - return this.props.dispatcher.changeRepositorySection( + + await this.props.dispatcher.changeRepositorySection( state.repository, RepositorySectionTab.Changes ) + + if (shouldFocusChanges) { + this.repositoryViewRef.current?.setFocusChangesNeeded() + } } private chooseRepository() { @@ -932,6 +954,15 @@ export class App extends React.Component { this.props.dispatcher.pull(state.repository) } + private async fetch() { + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + this.props.dispatcher.fetch(state.repository, FetchType.UserInitiatedTask) + } + private showStashedChanges() { const state = this.state.selectedState if (state == null || state.type !== SelectionType.Repository) { @@ -982,6 +1013,43 @@ export class App extends React.Component { window.addEventListener('keydown', this.onWindowKeyDown) window.addEventListener('keyup', this.onWindowKeyUp) } + + if (__DARWIN__) { + window.addEventListener('keydown', this.onMacOSWindowKeyDown) + } + + document.addEventListener('focus', this.onDocumentFocus, { + capture: true, + }) + } + + private onDocumentFocus = (event: FocusEvent) => { + this.props.dispatcher.appFocusedElementChanged() + } + + /** + * Manages keyboard shortcuts specific to macOS. + * - adds Shift+F10 to open the context menus (like on Windows so macOS + * keyboard users are not required to use VoiceOver to trigger context + * menus) + */ + private onMacOSWindowKeyDown = (event: KeyboardEvent) => { + // We do not want to override Shift+F10 behavior for the context menu on Windows. + if (!__DARWIN__) { + return + } + + if (event.defaultPrevented) { + return + } + + if (event.shiftKey && event.key === 'F10') { + document.activeElement?.dispatchEvent( + new Event('contextmenu', { + bubbles: true, // Required for React's event system + }) + ) + } } /** @@ -1044,7 +1112,6 @@ export class App extends React.Component { this.props.dispatcher.showFoldout({ type: FoldoutType.AppMenu, enableAccessKeyNavigation: true, - openedWithAccessKey: true, }) } else { this.props.dispatcher.executeMenuItem(menuItemForAccessKey) @@ -1086,7 +1153,6 @@ export class App extends React.Component { this.props.dispatcher.showFoldout({ type: FoldoutType.AppMenu, enableAccessKeyNavigation: true, - openedWithAccessKey: false, }) } } @@ -1095,7 +1161,7 @@ export class App extends React.Component { } private async handleDragAndDrop(fileList: FileList) { - const paths = [...fileList].map(x => x.path) + const paths = Array.from(fileList, webUtils.getPathForFile) const { dispatcher } = this.props // If they're bulk adding repositories then just blindly try to add them. @@ -1301,6 +1367,12 @@ export class App extends React.Component { this.state.currentFoldout && this.state.currentFoldout.type === FoldoutType.AppMenu + // As Linux still uses the classic Electron menu, we are opting out of the + // custom menu that is shown as part of the title bar below + if (__LINUX__) { + return null + } + // When we're in full-screen mode on Windows we only need to render // the title bar when the menu bar is active. On other platforms we // never render the title bar while in full-screen mode. @@ -1336,8 +1408,8 @@ export class App extends React.Component { ) } - private onPopupDismissed = (popupType: PopupType) => { - return this.props.dispatcher.closePopup(popupType) + private onPopupDismissed = (popupId: string) => { + return this.props.dispatcher.closePopupById(popupId) } private onContinueWithUntrustedCertificate = ( @@ -1352,34 +1424,49 @@ export class App extends React.Component { private onUpdateAvailableDismissed = () => this.props.dispatcher.setUpdateBannerVisibility(false) - private currentPopupContent(): JSX.Element | null { - // Hide any dialogs while we're displaying an error - if (this.state.errors.length) { + private allPopupContent(): JSX.Element | null { + const { allPopups } = this.state + + if (allPopups.length === 0) { return null } - const popup = this.state.currentPopup + return ( + <> + {allPopups.map(popup => { + const isTopMost = this.state.currentPopup?.id === popup.id + return ( + + {this.popupContent(popup, isTopMost)} + + ) + })} + + ) + } - if (!popup) { + private popupContent(popup: Popup, isTopMost: boolean): JSX.Element | null { + if (popup.id === undefined) { + // Should not be possible... but if it does we want to know about it. + sendNonFatalException( + 'PopupNoId', + new Error( + `Attempted to open a popup of type '${popup.type}' without an Id` + ) + ) return null } - const onPopupDismissedFn = this.getOnPopupDismissedFn(popup.type) + const onPopupDismissedFn = this.getOnPopupDismissedFn(popup.id) switch (popup.type) { case PopupType.RenameBranch: - const stash = - this.state.selectedState !== null && - this.state.selectedState.type === SelectionType.Repository - ? this.state.selectedState.state.changesState.stashEntry - : null return ( ) @@ -1465,19 +1552,36 @@ export class App extends React.Component { confirmDiscardChangesPermanently={ this.state.askForConfirmationOnDiscardChangesPermanently } + confirmDiscardStash={this.state.askForConfirmationOnDiscardStash} + confirmCheckoutCommit={ + this.state.askForConfirmationOnCheckoutCommit + } confirmForcePush={this.state.askForConfirmationOnForcePush} + confirmUndoCommit={this.state.askForConfirmationOnUndoCommit} + askForConfirmationOnCommitFilteredChanges={ + this.state.askForConfirmationOnCommitFilteredChanges + } uncommittedChangesStrategy={this.state.uncommittedChangesStrategy} selectedExternalEditor={this.state.selectedExternalEditor} useWindowsOpenSSH={this.state.useWindowsOpenSSH} + showCommitLengthWarning={this.state.showCommitLengthWarning} notificationsEnabled={this.state.notificationsEnabled} optOutOfUsageTracking={this.state.optOutOfUsageTracking} + useExternalCredentialHelper={this.state.useExternalCredentialHelper} enterpriseAccount={this.getEnterpriseAccount()} repository={repository} onDismissed={onPopupDismissedFn} selectedShell={this.state.selectedShell} selectedTheme={this.state.selectedTheme} - customTheme={this.state.customTheme} + selectedTabSize={this.state.selectedTabSize} + useCustomEditor={this.state.useCustomEditor} + customEditor={this.state.customEditor} + useCustomShell={this.state.useCustomShell} + customShell={this.state.customShell} repositoryIndicatorsEnabled={this.state.repositoryIndicatorsEnabled} + onEditGlobalGitConfig={this.editGlobalGitConfig} + underlineLinks={this.state.underlineLinks} + showDiffCheckMarks={this.state.showDiffCheckMarks} /> ) case PopupType.RepositorySettings: { @@ -1507,6 +1611,8 @@ export class App extends React.Component { signInState={this.state.signInState} dispatcher={this.props.dispatcher} onDismissed={onPopupDismissedFn} + isCredentialHelperSignIn={popup.isCredentialHelperSignIn} + credentialHelperUrl={popup.credentialHelperUrl} /> ) case PopupType.AddRepository: @@ -1525,6 +1631,7 @@ export class App extends React.Component { onDismissed={onPopupDismissedFn} dispatcher={this.props.dispatcher} initialPath={popup.path} + isTopMost={isTopMost} /> ) case PopupType.CloneRepository: @@ -1540,6 +1647,7 @@ export class App extends React.Component { onTabSelected={this.onCloneRepositoriesTabSelected} apiRepositories={this.state.apiRepositories} onRefreshRepositories={this.onRefreshRepositories} + isTopMost={isTopMost} /> ) case PopupType.CreateBranch: { @@ -1557,10 +1665,7 @@ export class App extends React.Component { if (isRepositoryWithGitHubRepository(repository)) { upstreamGhRepo = getNonForkGitHubRepository(repository) - upstreamDefaultBranch = findDefaultUpstreamBranch( - repository, - branchesState.allBranches - ) + upstreamDefaultBranch = branchesState.upstreamDefaultBranch } return ( @@ -1573,6 +1678,8 @@ export class App extends React.Component { repository={repository} targetCommit={popup.targetCommit} upstreamGitHubRepository={upstreamGhRepo} + accounts={this.state.accounts} + cachedRepoRulesets={this.state.cachedRepoRulesets} onBranchCreatedFromCommit={this.onBranchCreatedFromCommit} onDismissed={onPopupDismissedFn} dispatcher={this.props.dispatcher} @@ -1599,7 +1706,7 @@ export class App extends React.Component { applicationName={getName()} applicationVersion={version} applicationArchitecture={process.arch} - onCheckForUpdates={this.onCheckForUpdates} + onCheckForNonStaggeredUpdates={this.onCheckForNonStaggeredUpdates} onShowAcknowledgements={this.showAcknowledgements} onShowTermsAndConditions={this.showTermsAndConditions} /> @@ -1665,13 +1772,19 @@ export class App extends React.Component { ) case PopupType.GenericGitAuthentication: + const onDismiss = () => { + popup.onDismiss?.() + onPopupDismissedFn() + } + return ( ) case PopupType.ExternalEditorFailed: @@ -1683,7 +1796,7 @@ export class App extends React.Component { key="editor-error" message={popup.message} onDismissed={onPopupDismissedFn} - showPreferencesDialog={this.onShowAdvancedPreferences} + showPreferencesDialog={this.onShowIntegrationsPreferences} viewPreferences={openPreferences} suggestDefaultEditor={suggestDefaultEditor} /> @@ -1694,7 +1807,7 @@ export class App extends React.Component { key="shell-error" message={popup.message} onDismissed={onPopupDismissedFn} - showPreferencesDialog={this.onShowAdvancedPreferences} + showPreferencesDialog={this.onShowIntegrationsPreferences} /> ) case PopupType.InitializeLFS: @@ -1712,6 +1825,7 @@ export class App extends React.Component { key="lsf-attribute-mismatch" onDismissed={onPopupDismissedFn} onUpdateExistingFilters={this.updateExistingLFSFilters} + onEditGlobalGitConfig={this.editGlobalGitConfig} /> ) case PopupType.UpstreamAlreadyExists: @@ -1732,6 +1846,7 @@ export class App extends React.Component { emoji={this.state.emoji} newReleases={popup.newReleases} onDismissed={onPopupDismissedFn} + underlineLinks={this.state.underlineLinks} /> ) case PopupType.DeletePullRequest: @@ -1834,12 +1949,31 @@ export class App extends React.Component { ) } + case PopupType.ConfirmCheckoutCommit: { + const { repository, commit } = popup + + return ( + + ) + } case PopupType.CreateTutorialRepository: { return ( { commitAuthor={repositoryState.commitAuthor} commitMessage={popup.commitMessage} commitSpellcheckEnabled={this.state.commitSpellcheckEnabled} + showCommitLengthWarning={this.state.showCommitLengthWarning} dialogButtonText={popup.dialogButtonText} dialogTitle={popup.dialogTitle} dispatcher={this.props.dispatcher} @@ -2012,11 +2147,14 @@ export class App extends React.Component { showBranchProtected={ repositoryState.changesState.currentBranchProtected } + repoRulesInfo={repositoryState.changesState.currentRepoRulesInfo} + aheadBehind={repositoryState.aheadBehind} showCoAuthoredBy={popup.showCoAuthoredBy} showNoWriteAccess={!hasWritePermissionForRepository} onDismissed={onPopupDismissedFn} onSubmitCommitMessage={popup.onSubmitCommitMessage} repositoryAccount={repositoryAccount} + accounts={this.state.accounts} /> ) case PopupType.MultiCommitOperation: { @@ -2050,6 +2188,8 @@ export class App extends React.Component { askForConfirmationOnForcePush={ this.state.askForConfirmationOnForcePush } + accounts={this.state.accounts} + cachedRepoRulesets={this.state.cachedRepoRulesets} openFileInExternalEditor={this.openFileInExternalEditor} resolvedExternalEditor={this.state.resolvedExternalEditor} openRepositoryInShell={this.openCurrentRepositoryInShell} @@ -2065,6 +2205,7 @@ export class App extends React.Component { repository={repository} commit={commit} isWorkingDirectoryClean={isWorkingDirectoryClean} + confirmUndoCommit={this.state.askForConfirmationOnUndoCommit} onDismissed={onPopupDismissedFn} /> ) @@ -2132,8 +2273,6 @@ export class App extends React.Component { shouldChangeRepository={popup.shouldChangeRepository} repository={popup.repository} pullRequest={popup.pullRequest} - commitMessage={popup.commitMessage} - commitSha={popup.commitSha} checks={popup.checks} accounts={this.state.accounts} onSubmit={onPopupDismissedFn} @@ -2186,18 +2325,176 @@ export class App extends React.Component { case PopupType.PullRequestReview: { return ( + ) + } + case PopupType.UnreachableCommits: { + const { selectedState, emoji } = this.state + if ( + selectedState == null || + selectedState.type !== SelectionType.Repository + ) { + return null + } + + const { + commitLookup, + commitSelection: { shas, shasInDiff }, + } = selectedState.state + + return ( + + ) + } + case PopupType.StartPullRequest: { + // Intentionally chose to get the current pull request state on + // rerender because state variables such as file selection change + // via the dispatcher. + const pullRequestState = this.getPullRequestState() + if (pullRequestState === null) { + // This shouldn't happen.. + sendNonFatalException( + 'FailedToStartPullRequest', + new Error( + 'Failed to start pull request because pull request state was null' + ) + ) + return null + } + + const { pullRequestFilesListWidth, hideWhitespaceInPullRequestDiff } = + this.state + + const { + prBaseBranches, + currentBranch, + defaultBranch, + imageDiffType, + externalEditorLabel, + nonLocalCommitSHA, + prRecentBaseBranches, + repository, + showSideBySideDiff, + currentBranchHasPullRequest, + } = popup + + return ( + + ) + } + case PopupType.Error: { + return ( + + ) + } + case PopupType.InstallingUpdate: { + return ( + + ) + } + case PopupType.TestNotifications: { + return ( + + ) + } + case PopupType.PullRequestComment: { + return ( + + ) + } + case PopupType.UnknownAuthors: { + return ( + + ) + } + case PopupType.TestIcons: { + return ( + + ) + } + case PopupType.ConfirmCommitFilteredChanges: { + return ( + ) } @@ -2206,6 +2503,22 @@ export class App extends React.Component { } } + private setConfirmCommitFilteredChanges = (value: boolean) => { + this.props.dispatcher.setConfirmCommitFilteredChanges(value) + } + + private getPullRequestState() { + const { selectedState } = this.state + if ( + selectedState == null || + selectedState.type !== SelectionType.Repository + ) { + return null + } + + return selectedState.state.pullRequestState + } + private getWarnForcePushDialogOnBegin( onBegin: () => void, onPopupDismissedFn: () => void @@ -2242,6 +2555,9 @@ export class App extends React.Component { this.props.dispatcher.installGlobalLFSFilters(true) } + private editGlobalGitConfig = () => + this.props.dispatcher.editGlobalGitConfig() + private initializeLFS = (repositories: ReadonlyArray) => { this.props.dispatcher.installLFSHooks(repositories) } @@ -2254,10 +2570,10 @@ export class App extends React.Component { this.props.dispatcher.refreshApiRepositories(account) } - private onShowAdvancedPreferences = () => { + private onShowIntegrationsPreferences = () => { this.props.dispatcher.showPopup({ type: PopupType.Preferences, - initialSelectedTab: PreferencesTab.Advanced, + initialSelectedTab: PreferencesTab.Integrations, }) } @@ -2272,22 +2588,8 @@ export class App extends React.Component { this.props.dispatcher.openShell(path, true) } - private onSaveCredentials = async ( - hostname: string, - username: string, - password: string, - retryAction: RetryAction - ) => { - await this.props.dispatcher.saveGenericGitCredentials( - hostname, - username, - password - ) - - this.props.dispatcher.performRetry(retryAction) - } - - private onCheckForUpdates = () => this.checkForUpdates(false) + private onCheckForNonStaggeredUpdates = () => + this.checkForUpdates(false, true) private showAcknowledgements = () => { this.props.dispatcher.showPopup({ type: PopupType.Acknowledgements }) @@ -2297,8 +2599,8 @@ export class App extends React.Component { this.props.dispatcher.showPopup({ type: PopupType.TermsAndConditions }) } - private renderPopup() { - const popupContent = this.currentPopupContent() + private renderPopups() { + const popupContent = this.allPopupContent() return ( @@ -2334,6 +2636,7 @@ export class App extends React.Component { commit={commit} selectedCommits={selectedCommits} emoji={emoji} + accounts={this.state.accounts} /> ) default: @@ -2352,8 +2655,6 @@ export class App extends React.Component { return } - private clearError = (error: Error) => this.props.dispatcher.clearError(error) - private onConfirmDiscardChangesChanged = (value: boolean) => { this.props.dispatcher.setConfirmDiscardChangesSetting(value) } @@ -2362,17 +2663,6 @@ export class App extends React.Component { this.props.dispatcher.setConfirmDiscardChangesPermanentlySetting(value) } - private renderAppError() { - return ( - - ) - } - private onRetryAction = (retryAction: RetryAction) => { this.props.dispatcher.performRetry(retryAction) } @@ -2381,15 +2671,15 @@ export class App extends React.Component { this.props.dispatcher.showPopup(popup) } + private setBanner = (banner: Banner) => + this.props.dispatcher.setBanner(banner) + private getDesktopAppContentsClassNames = (): string => { const { currentDragElement } = this.state const isCommitBeingDragged = currentDragElement !== null && currentDragElement.type === DragType.Commit return classNames({ 'commit-being-dragged': isCommitBeingDragged, - // 'squashing-enabled' is due to feature flagging. If feature flag is - // removed, we can just delete this line with adjustment to the css file - 'squashing-enabled': isCommitBeingDragged && enableSquashing(), }) } @@ -2402,8 +2692,7 @@ export class App extends React.Component { {this.renderToolbar()} {this.renderBanner()} {this.renderRepository()} - {this.renderPopup()} - {this.renderAppError()} + {this.renderPopups()} {this.renderDragElement()}
) @@ -2416,7 +2705,7 @@ export class App extends React.Component { const externalEditorLabel = this.state.selectedExternalEditor ? this.state.selectedExternalEditor : undefined - const shellLabel = this.state.selectedShell + const { useCustomShell, selectedShell } = this.state const filterText = this.state.repositoryFilterText return ( { onShowRepository={this.showRepository} onOpenInExternalEditor={this.openInExternalEditor} externalEditorLabel={externalEditorLabel} - shellLabel={shellLabel} + shellLabel={useCustomShell ? undefined : selectedShell} dispatcher={this.props.dispatcher} /> ) @@ -2478,6 +2767,16 @@ export class App extends React.Component { this.props.dispatcher.openInExternalEditor(repository.path) } + private onOpenInExternalEditor = (path: string) => { + const repository = this.state.selectedState?.repository + if (repository === undefined) { + return + } + + const fullPath = Path.join(repository.path, path) + this.props.dispatcher.openInExternalEditor(fullPath) + } + private showRepository = (repository: Repository | CloningRepository) => { if (!(repository instanceof Repository)) { return @@ -2515,17 +2814,17 @@ export class App extends React.Component { const repository = selection ? selection.repository : null - let icon: OcticonSymbolType + let icon: OcticonSymbol let title: string if (repository) { const alias = repository instanceof Repository ? repository.alias : null icon = iconForRepository(repository) title = alias ?? repository.name } else if (this.state.repositories.length > 0) { - icon = OcticonSymbol.repo + icon = octicons.repo title = __DARWIN__ ? 'Select a Repository' : 'Select a repository' } else { - icon = OcticonSymbol.repo + icon = octicons.repo title = __DARWIN__ ? 'No Repositories' : 'No repositories' } @@ -2548,6 +2847,11 @@ export class App extends React.Component { top: 0, } + /** The dropdown focus trap will stop focus event propagation we made need + * in some of our dialogs (noticed with Lists). Disabled this when dialogs + * are open */ + const enableFocusTrap = this.state.currentPopup === null + return ( { description={__DARWIN__ ? 'Current Repository' : 'Current repository'} tooltip={tooltip} foldoutStyle={foldoutStyle} + onContextMenu={this.onRepositoryToolbarButtonContextMenu} onDropdownStateChanged={this.onRepositoryDropdownStateChanged} dropdownContentRenderer={this.renderRepositoryList} dropdownState={currentState} + enableFocusTrap={enableFocusTrap} /> ) } + private onRepositoryToolbarButtonContextMenu = () => { + const repository = this.state.selectedState?.repository + if (repository === undefined) { + return + } + + const externalEditorLabel = this.state.selectedExternalEditor ?? undefined + + const onChangeRepositoryAlias = (repository: Repository) => { + this.props.dispatcher.showPopup({ + type: PopupType.ChangeRepositoryAlias, + repository, + }) + } + + const onRemoveRepositoryAlias = (repository: Repository) => { + this.props.dispatcher.changeRepositoryAlias(repository, null) + } + + const items = generateRepositoryListContextMenu({ + onRemoveRepository: this.removeRepository, + onShowRepository: this.showRepository, + onOpenInShell: this.openInShell, + onOpenInExternalEditor: this.openInExternalEditor, + askForConfirmationOnRemoveRepository: + this.state.askForConfirmationOnRepositoryRemoval, + externalEditorLabel: externalEditorLabel, + onChangeRepositoryAlias: onChangeRepositoryAlias, + onRemoveRepositoryAlias: onRemoveRepositoryAlias, + onViewOnGitHub: this.viewOnGitHub, + repository: repository, + shellLabel: this.state.useCustomShell + ? undefined + : this.state.selectedShell, + }) + + showContextualMenu(items) + } + private renderPushPullToolbarButton() { const selection = this.state.selectedState if (!selection || selection.type !== SelectionType.Repository) { @@ -2571,7 +2916,13 @@ export class App extends React.Component { const state = selection.state const revertProgress = state.revertProgress if (revertProgress) { - return + return ( + + ) } let remoteName = state.remote ? state.remote.name : null @@ -2587,9 +2938,26 @@ export class App extends React.Component { if (tip.kind === TipState.Valid && tip.branch.upstreamRemoteName !== null) { remoteName = tip.branch.upstreamRemoteName + + if (tip.branch.upstreamWithoutRemote !== tip.branch.name) { + remoteName = tip.branch.upstream + } } - const isForcePush = isCurrentBranchForcePush(branchesState, aheadBehind) + const currentFoldout = this.state.currentFoldout + + const isDropdownOpen = + currentFoldout !== null && currentFoldout.type === FoldoutType.PushPull + + const forcePushBranchState = getCurrentBranchForcePushState( + branchesState, + aheadBehind + ) + + /** The dropdown focus trap will stop focus event propagation we made need + * in some of our dialogs (noticed with Lists). Disabled this when dialogs + * are open */ + const enableFocusTrap = this.state.currentPopup === null return ( { tipState={tip.kind} pullWithRebase={pullWithRebase} rebaseInProgress={rebaseInProgress} - isForcePush={isForcePush} + forcePushBranchState={forcePushBranchState} shouldNudge={ this.state.currentOnboardingTutorialStep === TutorialStep.PushBranch } + isDropdownOpen={isDropdownOpen} + askForConfirmationOnForcePush={this.state.askForConfirmationOnForcePush} + onDropdownStateChanged={this.onPushPullDropdownStateChanged} + enableFocusTrap={enableFocusTrap} + pushPullButtonWidth={this.state.pushPullButtonWidth} /> ) } @@ -2648,12 +3021,22 @@ export class App extends React.Component { if (currentPullRequest == null) { dispatcher.createPullRequest(state.repository) - dispatcher.recordCreatePullRequest() + dispatcher.incrementMetric('createPullRequestCount') } else { dispatcher.showPullRequest(state.repository) } } + private startPullRequest = () => { + const state = this.state.selectedState + + if (state == null || state.type !== SelectionType.Repository) { + return + } + + this.props.dispatcher.startPullRequest(state.repository) + } + private openCreatePullRequestInBrowser = ( repository: Repository, branch: Branch @@ -2661,6 +3044,14 @@ export class App extends React.Component { this.props.dispatcher.openCreatePullRequestInBrowser(repository, branch) } + private onPushPullDropdownStateChanged = (newState: DropdownState) => { + if (newState === 'open') { + this.props.dispatcher.showFoldout({ type: FoldoutType.PushPull }) + } else { + this.props.dispatcher.closeFoldout(FoldoutType.PushPull) + } + } + private onBranchDropdownStateChanged = (newState: DropdownState) => { if (newState === 'open') { this.props.dispatcher.showFoldout({ type: FoldoutType.Branch }) @@ -2684,10 +3075,16 @@ export class App extends React.Component { const repository = selection.repository const { branchesState } = selection.state + /** The dropdown focus trap will stop focus event propagation we made need + * in some of our dialogs (noticed with Lists). Disabled this when dialogs + * are open */ + const enableFocusTrap = this.state.currentPopup === null + return ( { } showCIStatusPopover={this.state.showCIStatusPopover} emoji={this.state.emoji} + enableFocusTrap={enableFocusTrap} + underlineLinks={this.state.underlineLinks} /> ) } @@ -2728,13 +3127,18 @@ export class App extends React.Component { banner = this.renderUpdateBanner() } return ( - - {banner && ( - - {banner} - - )} - +
+ + {banner && ( + + {banner} + + )} + +
) } @@ -2743,6 +3147,11 @@ export class App extends React.Component { { } if (selectedState.type === SelectionType.Repository) { - const externalEditorLabel = state.selectedExternalEditor - ? state.selectedExternalEditor - : undefined + const externalEditorLabel = state.useCustomEditor + ? undefined + : state.selectedExternalEditor ?? undefined return ( { imageDiffType={state.imageDiffType} hideWhitespaceInChangesDiff={state.hideWhitespaceInChangesDiff} hideWhitespaceInHistoryDiff={state.hideWhitespaceInHistoryDiff} + showDiffCheckMarks={state.showDiffCheckMarks} showSideBySideDiff={state.showSideBySideDiff} focusCommitMessage={state.focusCommitMessage} askForConfirmationOnDiscardChanges={ state.askForConfirmationOnDiscardChanges } + askForConfirmationOnDiscardStash={ + state.askForConfirmationOnDiscardStash + } + askForConfirmationOnCheckoutCommit={ + state.askForConfirmationOnCheckoutCommit + } + askForConfirmationOnCommitFilteredChanges={ + state.askForConfirmationOnCommitFilteredChanges + } accounts={state.accounts} + isExternalEditorAvailable={ + state.useCustomEditor || state.selectedExternalEditor !== null + } externalEditorLabel={externalEditorLabel} resolvedExternalEditor={state.resolvedExternalEditor} - onOpenInExternalEditor={this.openFileInExternalEditor} + onOpenInExternalEditor={this.onOpenInExternalEditor} appMenu={state.appMenuState[0]} currentTutorialStep={state.currentOnboardingTutorialStep} onExitTutorial={this.onExitTutorial} @@ -2840,7 +3262,9 @@ export class App extends React.Component { isShowingFoldout={this.state.currentFoldout !== null} aheadBehindStore={this.props.aheadBehindStore} commitSpellcheckEnabled={this.state.commitSpellcheckEnabled} + showCommitLengthWarning={this.state.showCommitLengthWarning} onCherryPick={this.startCherryPickWithoutBranch} + pullRequestSuggestedNextAction={state.pullRequestSuggestedNextAction} /> ) } else if (selectedState.type === SelectionType.CloningRepository) { @@ -2866,7 +3290,6 @@ export class App extends React.Component { return ( @@ -2878,21 +3301,26 @@ export class App extends React.Component { return null } - const className = this.state.appIsFocused ? 'focused' : 'blurred' + const className = classNames( + this.state.appIsFocused ? 'focused' : 'blurred', + { + 'underline-links': this.state.underlineLinks, + } + ) const currentTheme = this.state.showWelcomeFlow ? ApplicationTheme.Light : this.state.currentTheme + const currentTabSize = this.state.selectedTabSize + return ( -
- +
+ {this.renderTitlebar()} {this.state.showWelcomeFlow ? this.renderWelcomeFlow() @@ -2923,22 +3351,17 @@ export class App extends React.Component { return } - const baseURL = repository.gitHubRepository.htmlURL + const commitURL = createCommitURL( + repository.gitHubRepository, + SHA, + filePath + ) - let fileSuffix = '' - if (filePath != null) { - const fileHash = crypto - .createHash('sha256') - .update(filePath) - .digest('hex') - fileSuffix = '#diff-' + fileHash + if (commitURL === null) { + return } - if (baseURL) { - this.props.dispatcher.openInBrowser( - `${baseURL}/commit/${SHA}${fileSuffix}` - ) - } + this.props.dispatcher.openInBrowser(commitURL) } private onBranchDeleted = (repository: Repository) => { @@ -3002,7 +3425,7 @@ export class App extends React.Component { const initialStep = getMultiCommitOperationChooseBranchStep(repositoryState) this.props.dispatcher.setMultiCommitOperationStep(repository, initialStep) - this.props.dispatcher.recordCherryPickViaContextMenu() + this.props.dispatcher.incrementMetric('cherryPickViaContextMenuCount') this.showPopup({ type: PopupType.MultiCommitOperation, @@ -3070,7 +3493,7 @@ export class App extends React.Component { ) }, } - this.props.dispatcher.setBanner(banner) + this.setBanner(banner) } private openThankYouCard = ( @@ -3096,7 +3519,7 @@ export class App extends React.Component { private onDragEnd = (dropTargetSelector: DropTargetSelector | undefined) => { this.props.dispatcher.closeFoldout(FoldoutType.Branch) if (dropTargetSelector === undefined) { - this.props.dispatcher.recordDragStartedAndCanceled() + this.props.dispatcher.incrementMetric('dragStartedAndCanceledCount') } } } diff --git a/app/src/ui/autocompletion/autocompleting-text-input.tsx b/app/src/ui/autocompletion/autocompleting-text-input.tsx index 21484f2d999..3586ccc2f13 100644 --- a/app/src/ui/autocompletion/autocompleting-text-input.tsx +++ b/app/src/ui/autocompletion/autocompleting-text-input.tsx @@ -8,37 +8,63 @@ import { import { IAutocompletionProvider } from './index' import { fatalError } from '../../lib/fatal-error' import classNames from 'classnames' +import getCaretCoordinates from 'textarea-caret' +import { showContextualMenu } from '../../lib/menu-item' +import { AriaLiveContainer } from '../accessibility/aria-live-container' +import { createUniqueId, releaseUniqueId } from '../lib/id-pool' +import { + Popover, + PopoverAnchorPosition, + PopoverDecoration, +} from '../lib/popover' interface IRange { readonly start: number readonly length: number } -import getCaretCoordinates from 'textarea-caret' -import { showContextualMenu } from '../../lib/menu-item' +interface IAutocompletingTextInputProps { + /** An optional specified id for the input */ + readonly inputId?: string -interface IAutocompletingTextInputProps { /** * An optional className to be applied to the rendered * top level element of the component. */ readonly className?: string + /** Element ID for the input field. */ + readonly elementId?: string + + /** Content of an optional invisible label element for screen readers. */ + readonly screenReaderLabel?: string + + /** + * The label of the text box. + */ + readonly label?: string | JSX.Element + /** The placeholder for the input field. */ readonly placeholder?: string /** The current value of the input field. */ readonly value?: string - /** Disabled state for input field. */ - readonly disabled?: boolean + /** Whether or not the input should be read-only and styled as disabled */ + readonly readOnly?: boolean /** Indicates if input field should be required */ - readonly isRequired?: boolean + readonly required?: boolean /** Indicates if input field applies spellcheck */ readonly spellcheck?: boolean + /** Indicates if it should always try to autocomplete. Optional (defaults to false) */ + readonly alwaysAutocomplete?: boolean + + /** Filter for autocomplete items */ + readonly autocompleteItemFilter?: (item: AutocompleteItemType) => boolean + /** * Called when the user changes the value in the input field. */ @@ -47,6 +73,9 @@ interface IAutocompletingTextInputProps { /** Called on key down. */ readonly onKeyDown?: (event: React.KeyboardEvent) => void + /** Called when an autocomplete item has been selected. */ + readonly onAutocompleteItemSelected?: (value: AutocompleteItemType) => void + /** * A list of autocompletion providers that should be enabled for this * input. @@ -64,6 +93,9 @@ interface IAutocompletingTextInputProps { * in the input field. */ readonly onContextMenu?: (event: React.MouseEvent) => void + + /** Called when the input field receives focus. */ + readonly onFocus?: (event: React.FocusEvent) => void } interface IAutocompletionState { @@ -72,6 +104,8 @@ interface IAutocompletionState { readonly range: IRange readonly rangeText: string readonly selectedItem: T | null + readonly selectedRowId: string | undefined + readonly itemListRowIdPrefix: string } /** @@ -97,16 +131,36 @@ interface IAutocompletingTextInputState { * matching autocompletion providers. */ readonly autocompletionState: IAutocompletionState | null + + /** Coordinates of the caret in the input/textarea element */ + readonly caretCoordinates: ReturnType | null + + /** + * An automatically generated id for the text element used to reference + * it from the label element. This is generated once via the id pool when the + * component is mounted and then released once the component unmounts. + */ + readonly uniqueInternalElementId?: string + + /** + * An automatically generated id for the autocomplete container element used + * to reference it from the ARIA autocomplete-related attributes. This is + * generated once via the id pool when the component is mounted and then + * released once the component unmounts. + */ + readonly autocompleteContainerId?: string } /** A text area which provides autocompletions as the user types. */ export abstract class AutocompletingTextInput< - ElementType extends HTMLInputElement | HTMLTextAreaElement + ElementType extends HTMLInputElement | HTMLTextAreaElement, + AutocompleteItemType extends object > extends React.Component< - IAutocompletingTextInputProps, - IAutocompletingTextInputState + IAutocompletingTextInputProps, + IAutocompletingTextInputState > { private element: ElementType | null = null + private invisibleCaretRef = React.createRef() /** The identifier for each autocompletion request. */ private autocompletionRequestID = 0 @@ -117,10 +171,61 @@ export abstract class AutocompletingTextInput< */ protected abstract getElementTagName(): 'textarea' | 'input' - public constructor(props: IAutocompletingTextInputProps) { + public constructor( + props: IAutocompletingTextInputProps + ) { super(props) - this.state = { autocompletionState: null } + this.state = { + autocompletionState: null, + caretCoordinates: null, + } + } + + public componentWillMount() { + const elementId = + this.props.inputId ?? createUniqueId('autocompleting-text-input') + const autocompleteContainerId = createUniqueId('autocomplete-container') + + this.setState({ + uniqueInternalElementId: elementId, + autocompleteContainerId, + }) + } + + public componentWillUnmount() { + if (this.state.uniqueInternalElementId) { + releaseUniqueId(this.state.uniqueInternalElementId) + } + + if (this.state.autocompleteContainerId) { + releaseUniqueId(this.state.autocompleteContainerId) + } + } + + public componentDidUpdate( + prevProps: IAutocompletingTextInputProps + ) { + if ( + this.props.autocompleteItemFilter !== prevProps.autocompleteItemFilter && + this.state.autocompletionState !== null + ) { + this.open(this.element?.value ?? '') + } + } + + private get elementId() { + return this.props.elementId ?? this.state.uniqueInternalElementId + } + + private getItemAriaLabel = (row: number): string | undefined => { + const state = this.state.autocompletionState + if (!state) { + return undefined + } + + const item = state.items[row] + return state.provider.getItemAriaLabel?.(item) } private renderItem = (row: number): JSX.Element | null => { @@ -149,35 +254,9 @@ export abstract class AutocompletingTextInput< return null } - const element = this.element! - let coordinates = getCaretCoordinates(element, state.range.start) - coordinates = { - ...coordinates, - top: coordinates.top - element.scrollTop, - left: coordinates.left - element.scrollLeft, - } - - const left = coordinates.left - const top = coordinates.top + YOffset - const selectedRow = state.selectedItem - ? items.indexOf(state.selectedItem) - : -1 - const rect = element.getBoundingClientRect() - const popupAbsoluteTop = rect.top + coordinates.top - - // The maximum height we can use for the popup without it extending beyond - // the Window bounds. - let maxHeight: number - if ( - element.ownerDocument !== null && - element.ownerDocument.defaultView !== null - ) { - const windowHeight = element.ownerDocument.defaultView.innerHeight - const spaceToBottomOfWindow = windowHeight - popupAbsoluteTop - YOffset - maxHeight = Math.min(DefaultPopupHeight, spaceToBottomOfWindow) - } else { - maxHeight = DefaultPopupHeight - } + const selectedRows = state.selectedItem + ? [items.indexOf(state.selectedItem)] + : [] // The height needed to accommodate all the matched items without overflowing // @@ -186,7 +265,7 @@ export abstract class AutocompletingTextInput< // without overflowing and triggering the scrollbar. const noOverflowItemHeight = RowHeight * items.length - const height = Math.min(noOverflowItemHeight, maxHeight) + const minHeight = RowHeight * Math.min(items.length, 3) // Use the completion text as invalidation props so that highlighting // will update as you type even though the number of items matched @@ -198,24 +277,60 @@ export abstract class AutocompletingTextInput< const className = classNames('autocompletion-popup', state.provider.kind) return ( -
+ -
+ ) } + private getRowId: (row: number) => string = row => { + const state = this.state.autocompletionState + if (!state) { + return '' + } + + return `autocomplete-item-row-${state.itemListRowIdPrefix}-${row}` + } + + private onAutocompletionListRef = (ref: List | null) => { + const { autocompletionState } = this.state + if (ref && autocompletionState && autocompletionState.selectedItem) { + const { items, selectedItem } = autocompletionState + this.setState({ + autocompletionState: { + ...autocompletionState, + selectedRowId: this.getRowId(items.indexOf(selectedItem)), + }, + }) + } + } + private onRowMouseDown = (row: number, event: React.MouseEvent) => { const currentAutoCompletionState = this.state.autocompletionState @@ -242,6 +357,7 @@ export abstract class AutocompletingTextInput< const newAutoCompletionState = { ...currentAutoCompletionState, selectedItem: newSelectedItem, + selectedRowId: newSelectedItem === null ? undefined : this.getRowId(row), } this.setState({ autocompletionState: newAutoCompletionState }) @@ -272,19 +388,57 @@ export abstract class AutocompletingTextInput< } } + private getActiveAutocompleteItemId(): string | undefined { + const { autocompletionState } = this.state + + if (autocompletionState === null) { + return undefined + } + + if (autocompletionState.selectedRowId) { + return autocompletionState.selectedRowId + } + + if (autocompletionState.selectedItem === null) { + return undefined + } + + const index = autocompletionState.items.indexOf( + autocompletionState.selectedItem + ) + + return this.getRowId(index) + } + private renderTextInput() { + const { autocompletionState } = this.state + + const autocompleteVisible = + autocompletionState !== null && autocompletionState.items.length > 0 + const props = { type: 'text', + id: this.elementId, + role: 'combobox', placeholder: this.props.placeholder, value: this.props.value, ref: this.onRef, onChange: this.onChange, + onScroll: this.onScroll, onKeyDown: this.onKeyDown, + onFocus: this.onFocus, onBlur: this.onBlur, onContextMenu: this.onContextMenu, - disabled: this.props.disabled, - 'aria-required': this.props.isRequired ? true : false, + readOnly: this.props.readOnly, + required: this.props.required ? true : false, spellCheck: this.props.spellcheck, + autoComplete: 'off', + 'aria-expanded': autocompleteVisible, + 'aria-autocomplete': 'list' as const, + 'aria-haspopup': 'listbox' as const, + 'aria-controls': this.state.autocompleteContainerId, + 'aria-owns': this.state.autocompleteContainerId, + 'aria-activedescendant': this.getActiveAutocompleteItemId(), } return React.createElement, ElementType>( @@ -293,12 +447,75 @@ export abstract class AutocompletingTextInput< ) } + // This will update the caret coordinates in the componen state, so that the + // "invisible caret" can be positioned correctly. + // Given the outcome of this function depends on both the caret coordinates + // and the scroll position, it should be called whenever the caret moves (on + // text changes) or the scroll position changes. + private updateCaretCoordinates = () => { + const element = this.element + if (!element) { + this.setState({ caretCoordinates: null }) + return + } + + const selectionEnd = element.selectionEnd + if (selectionEnd === null) { + this.setState({ caretCoordinates: null }) + return + } + + const caretCoordinates = getCaretCoordinates(element, selectionEnd) + + this.setState({ + caretCoordinates: { + top: caretCoordinates.top - element.scrollTop, + left: caretCoordinates.left - element.scrollLeft, + height: caretCoordinates.height, + }, + }) + } + + private renderInvisibleCaret = () => { + const { caretCoordinates } = this.state + if (!caretCoordinates) { + return null + } + + return ( +
+   +
+ ) + } + private onBlur = (e: React.FocusEvent) => { this.close() } + private onFocus = (e: React.FocusEvent) => { + if (!this.props.alwaysAutocomplete || this.element === null) { + return + } + + this.open(this.element.value) + + this.props.onFocus?.(e) + } + private onRef = (ref: ElementType | null) => { this.element = ref + this.updateCaretCoordinates() if (this.props.onElementRef) { this.props.onElementRef(ref) } @@ -314,16 +531,37 @@ export abstract class AutocompletingTextInput< const tagName = this.getElementTagName() const className = classNames( 'autocompletion-container', + 'no-invalid-state', this.props.className, { 'text-box-component': tagName === 'input', 'text-area-component': tagName === 'textarea', } ) + const { label, screenReaderLabel } = this.props + + const autoCompleteItems = this.state.autocompletionState?.items ?? [] + + const suggestionsMessage = + autoCompleteItems.length === 1 + ? '1 suggestion' + : `${autoCompleteItems.length} suggestions` + return (
{this.renderAutocompletions()} + {screenReaderLabel && label === undefined && ( + + )} + {label && } {this.renderTextInput()} + {this.renderInvisibleCaret()} + 0 ? suggestionsMessage : null} + trackedUserInput={this.state.autocompletionState?.rangeText} + />
) } @@ -338,7 +576,10 @@ export abstract class AutocompletingTextInput< this.element.selectionEnd = newCaretPosition } - private insertCompletion(item: Object, source: 'mouseclick' | 'keyboard') { + private insertCompletion( + item: AutocompleteItemType, + source: 'mouseclick' | 'keyboard' + ) { const element = this.element! const autocompletionState = this.state.autocompletionState! const originalText = element.value @@ -372,7 +613,12 @@ export abstract class AutocompletingTextInput< this.setCursorPosition(newCaretPosition) } + this.props.onAutocompleteItemSelected?.(item) + this.close() + if (this.props.alwaysAutocomplete) { + this.open('') + } } private getMovementDirection( @@ -428,11 +674,16 @@ export abstract class AutocompletingTextInput< const newAutoCompletionState = { ...currentAutoCompletionState, selectedItem: newSelectedItem, + selectedRowId: + newSelectedItem === null ? undefined : this.getRowId(nextRow), } this.setState({ autocompletionState: newAutoCompletionState }) } - } else if (event.key === 'Enter' || event.key === 'Tab') { + } else if ( + event.key === 'Enter' || + (event.key === 'Tab' && !event.shiftKey) + ) { const item = currentAutoCompletionState.selectedItem if (item) { event.preventDefault() @@ -451,7 +702,9 @@ export abstract class AutocompletingTextInput< private async attemptAutocompletion( str: string, caretPosition: number - ): Promise | null> { + ): Promise | null> { + const lowercaseStr = str.toLowerCase() + for (const provider of this.props.autocompletionProviders) { // NB: RegExps are stateful (AAAAAAAAAAAAAAAAAA) so defensively copy the // regex we're given. @@ -463,15 +716,26 @@ export abstract class AutocompletingTextInput< } let result: RegExpExecArray | null = null - while ((result = regex.exec(str))) { + while ((result = regex.exec(lowercaseStr))) { const index = regex.lastIndex const text = result[1] || '' - if (index === caretPosition) { + if (index === caretPosition || this.props.alwaysAutocomplete) { const range = { start: index - text.length, length: text.length } - const items = await provider.getAutocompletionItems(text) - - const selectedItem = items[0] - return { provider, items, range, selectedItem, rangeText: text } + let items = await provider.getAutocompletionItems(text) + + if (this.props.autocompleteItemFilter) { + items = items.filter(this.props.autocompleteItemFilter) + } + + return { + provider, + items, + range, + selectedItem: null, + selectedRowId: undefined, + rangeText: text, + itemListRowIdPrefix: this.buildAutocompleteListRowIdPrefix(), + } } } } @@ -479,6 +743,14 @@ export abstract class AutocompletingTextInput< return null } + private buildAutocompleteListRowIdPrefix() { + return new Date().getTime().toString() + } + + private onScroll = () => { + this.updateCaretCoordinates() + } + private onChange = async (event: React.FormEvent) => { const str = event.currentTarget.value @@ -486,6 +758,12 @@ export abstract class AutocompletingTextInput< this.props.onValueChanged(str) } + this.updateCaretCoordinates() + + return this.open(str) + } + + private async open(str: string) { const element = this.element if (element === null) { diff --git a/app/src/ui/autocompletion/autocompletion-provider.ts b/app/src/ui/autocompletion/autocompletion-provider.ts index 6b0115fc050..5882a5613a0 100644 --- a/app/src/ui/autocompletion/autocompletion-provider.ts +++ b/app/src/ui/autocompletion/autocompletion-provider.ts @@ -1,11 +1,15 @@ import { AutocompletingTextInput } from './autocompleting-text-input' -export class AutocompletingTextArea extends AutocompletingTextInput { +export class AutocompletingTextArea< + AutocompleteItemType extends object = object +> extends AutocompletingTextInput { protected getElementTagName(): 'textarea' | 'input' { return 'textarea' } } -export class AutocompletingInput extends AutocompletingTextInput { +export class AutocompletingInput< + AutocompleteItemType extends object = object +> extends AutocompletingTextInput { protected getElementTagName(): 'textarea' | 'input' { return 'input' } @@ -40,6 +44,9 @@ export interface IAutocompletionProvider { */ renderItem(item: T): JSX.Element + /** Returns the aria-label attribute for the rendered item. Optional. */ + getItemAriaLabel?(item: T): string + /** * Returns a text representation of a given autocompletion results. * This is the text that will end up going into the textbox if the diff --git a/app/src/ui/autocompletion/build-autocompletion-providers.ts b/app/src/ui/autocompletion/build-autocompletion-providers.ts index f762d66a74c..75f8bc38935 100644 --- a/app/src/ui/autocompletion/build-autocompletion-providers.ts +++ b/app/src/ui/autocompletion/build-autocompletion-providers.ts @@ -4,6 +4,7 @@ import { Repository, } from '../../models/repository' import { + CoAuthorAutocompletionProvider, EmojiAutocompletionProvider, IAutocompletionProvider, IssuesAutocompletionProvider, @@ -12,11 +13,12 @@ import { import { Dispatcher } from '../dispatcher' import { GitHubUserStore, IssuesStore } from '../../lib/stores' import { Account } from '../../models/account' +import { Emoji } from '../../lib/emoji' export function buildAutocompletionProviders( repository: Repository, dispatcher: Dispatcher, - emoji: Map, + emoji: Map, issuesStore: IssuesStore, gitHubUserStore: GitHubUserStore, accounts: ReadonlyArray @@ -42,7 +44,16 @@ export function buildAutocompletionProviders( const account = accounts.find(a => a.endpoint === gitHubRepository.endpoint) autocompletionProviders.push( - new UserAutocompletionProvider(gitHubUserStore, gitHubRepository, account) + new UserAutocompletionProvider( + gitHubUserStore, + gitHubRepository, + account + ), + new CoAuthorAutocompletionProvider( + gitHubUserStore, + gitHubRepository, + account + ) ) } diff --git a/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx b/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx index 64a0c436fdf..608393da232 100644 --- a/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx +++ b/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx @@ -2,6 +2,9 @@ import * as React from 'react' import { IAutocompletionProvider } from './index' import { compare } from '../../lib/compare' import { DefaultMaxHits } from './common' +import { Emoji } from '../../lib/emoji' + +const sanitizeEmoji = (emoji: string) => emoji.replaceAll(':', '') /** * Interface describing a autocomplete match for the given search @@ -9,7 +12,14 @@ import { DefaultMaxHits } from './common' */ export interface IEmojiHit { /** A human-readable markdown representation of the emoji, ex :heart: */ - readonly emoji: string + readonly title: string + + /** + * The unicode string of the emoji if emoji is part of + * the unicode specification. If missing this emoji is + * a GitHub custom emoji such as :shipit: + */ + readonly emoji?: string /** * The offset into the emoji string where the @@ -31,10 +41,10 @@ export class EmojiAutocompletionProvider { public readonly kind = 'emoji' - private readonly emoji: Map + private readonly allEmoji: Map - public constructor(emoji: Map) { - this.emoji = emoji + public constructor(emoji: Map) { + this.allEmoji = emoji } public getRegExp(): RegExp { @@ -49,18 +59,28 @@ export class EmojiAutocompletionProvider // when the user types a ':'. We want to open the popup // with suggestions as fast as possible. if (text.length === 0) { - return [...this.emoji.keys()] - .map(emoji => ({ emoji, matchStart: 0, matchLength: 0 })) + return [...this.allEmoji.entries()] + .map(([title, { emoji }]) => ({ + title, + emoji, + matchStart: 0, + matchLength: 0, + })) .slice(0, maxHits) } const results = new Array() const needle = text.toLowerCase() - for (const emoji of this.emoji.keys()) { - const index = emoji.indexOf(needle) + for (const [key, emoji] of this.allEmoji.entries()) { + const index = key.indexOf(needle) if (index !== -1) { - results.push({ emoji, matchStart: index, matchLength: needle.length }) + results.push({ + title: key, + emoji: emoji.emoji, + matchStart: index, + matchLength: needle.length, + }) } } @@ -79,42 +99,63 @@ export class EmojiAutocompletionProvider .sort( (x, y) => compare(x.matchStart, y.matchStart) || - compare(x.emoji.length, y.emoji.length) || - compare(x.emoji, y.emoji) + compare(x.title.length, y.title.length) || + compare(x.title, y.title) ) .slice(0, maxHits) } + public getItemAriaLabel(hit: IEmojiHit): string { + const emoji = this.allEmoji.get(hit.title) + const sanitizedEmoji = sanitizeEmoji(hit.title) + const emojiDescription = emoji?.emoji + ? emoji.emoji + : emoji?.description ?? sanitizedEmoji + return emojiDescription === sanitizedEmoji + ? emojiDescription + : `${emojiDescription}, ${sanitizedEmoji}` + } + public renderItem(hit: IEmojiHit) { - const emoji = hit.emoji + const emoji = this.allEmoji.get(hit.title) return ( -
- +
+ {emoji?.emoji ? ( +
{emoji?.emoji}
+ ) : ( + {emoji?.description + )} {this.renderHighlightedTitle(hit)}
) } private renderHighlightedTitle(hit: IEmojiHit) { - const emoji = hit.emoji + const emoji = sanitizeEmoji(hit.title) if (!hit.matchLength) { return
{emoji}
} + // Offset the match start by one to account for the leading ':' that was + // removed from the emoji string + const matchStart = hit.matchStart - 1 + return (
- {emoji.substring(0, hit.matchStart)} - - {emoji.substring(hit.matchStart, hit.matchStart + hit.matchLength)} - - {emoji.substring(hit.matchStart + hit.matchLength)} + {emoji.substring(0, matchStart)} + {emoji.substring(matchStart, matchStart + hit.matchLength)} + {emoji.substring(matchStart + hit.matchLength)}
) } public getCompletionText(item: IEmojiHit) { - return item.emoji + return item.emoji ?? item.title } } diff --git a/app/src/ui/autocompletion/issues-autocompletion-provider.tsx b/app/src/ui/autocompletion/issues-autocompletion-provider.tsx index 115c7f4df7e..1abeea1e923 100644 --- a/app/src/ui/autocompletion/issues-autocompletion-provider.tsx +++ b/app/src/ui/autocompletion/issues-autocompletion-provider.tsx @@ -53,7 +53,7 @@ export class IssuesAutocompletionProvider public renderItem(item: IIssueHit): JSX.Element { return (
- #{item.number} + #{item.number}  {item.title}
) diff --git a/app/src/ui/autocompletion/user-autocompletion-provider.tsx b/app/src/ui/autocompletion/user-autocompletion-provider.tsx index 52057abf8a9..c604eead17b 100644 --- a/app/src/ui/autocompletion/user-autocompletion-provider.tsx +++ b/app/src/ui/autocompletion/user-autocompletion-provider.tsx @@ -7,7 +7,9 @@ import { Account } from '../../models/account' import { IMentionableUser } from '../../lib/databases/index' /** An autocompletion hit for a user. */ -export interface IUserHit { +export type KnownUserHit = { + readonly kind: 'known-user' + /** The username. */ readonly username: string @@ -27,11 +29,21 @@ export interface IUserHit { readonly endpoint: string } +export type UnknownUserHit = { + readonly kind: 'unknown-user' + + /** The username. */ + readonly username: string +} + +export type UserHit = KnownUserHit | UnknownUserHit + function userToHit( repository: GitHubRepository, user: IMentionableUser -): IUserHit { +): UserHit { return { + kind: 'known-user', username: user.login, name: user.name, email: user.email, @@ -41,7 +53,7 @@ function userToHit( /** The autocompletion provider for user mentions in a GitHub repository. */ export class UserAutocompletionProvider - implements IAutocompletionProvider + implements IAutocompletionProvider { public readonly kind = 'user' @@ -63,9 +75,10 @@ export class UserAutocompletionProvider return /(?:^|\n| )(?:@)([a-z\d\\+-][a-z\d_-]*)?/g } - public async getAutocompletionItems( - text: string - ): Promise> { + protected async getUserAutocompletionItems( + text: string, + includeUnknownUser: boolean + ): Promise> { const users = await this.gitHubUserStore.getMentionableUsersMatching( this.repository, text @@ -77,19 +90,45 @@ export class UserAutocompletionProvider ? users.filter(x => x.login !== account.login) : users - return filtered.map(x => userToHit(this.repository, x)) + const hits = filtered.map(x => userToHit(this.repository, x)) + + if (includeUnknownUser && text.length > 0) { + const exactMatch = hits.some( + hit => hit.username.toLowerCase() === text.toLowerCase() + ) + + if (!exactMatch) { + hits.push({ + kind: 'unknown-user', + username: text, + }) + } + } + + return hits } - public renderItem(item: IUserHit): JSX.Element { - return ( + public async getAutocompletionItems( + text: string + ): Promise> { + return this.getUserAutocompletionItems(text, false) + } + + public renderItem(item: UserHit): JSX.Element { + return item.kind === 'known-user' ? (
{item.username} {item.name}
+ ) : ( +
+ {item.username} + Search for user +
) } - public getCompletionText(item: IUserHit): string { + public getCompletionText(item: UserHit): string { return `@${item.username}` } @@ -103,7 +142,7 @@ export class UserAutocompletionProvider * * @param login The login (i.e. handle) of the user */ - public async exactMatch(login: string): Promise { + public async exactMatch(login: string): Promise { if (this.account === null) { return null } @@ -117,3 +156,15 @@ export class UserAutocompletionProvider return userToHit(this.repository, user) } } + +export class CoAuthorAutocompletionProvider extends UserAutocompletionProvider { + public getRegExp(): RegExp { + return /(?:^|\n| )(?:@)?([a-z\d\\+-][a-z\d_-]*)?/g + } + + public async getAutocompletionItems( + text: string + ): Promise> { + return super.getUserAutocompletionItems(text, true) + } +} diff --git a/app/src/ui/banners/accessibilty-settings-banner.tsx b/app/src/ui/banners/accessibilty-settings-banner.tsx new file mode 100644 index 00000000000..2091509981f --- /dev/null +++ b/app/src/ui/banners/accessibilty-settings-banner.tsx @@ -0,0 +1,44 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import { Banner } from './banner' +import { LinkButton } from '../lib/link-button' +import { setBoolean } from '../../lib/local-storage' + +export const accessibilityBannerDismissed = 'accessibility-banner-dismissed' + +interface IAccessibilitySettingsBannerProps { + readonly onOpenAccessibilitySettings: () => void + readonly onDismissed: () => void +} + +export class AccessibilitySettingsBanner extends React.Component { + private onDismissed = () => { + setBoolean(accessibilityBannerDismissed, true) + this.props.onDismissed() + } + + private onOpenAccessibilitySettings = () => { + this.props.onOpenAccessibilitySettings() + this.onDismissed() + } + + public render() { + return ( + + +
+ Check out the new{' '} + + accessibility settings + {' '} + to control the visibility of the link underlines and diff check marks. +
+
+ ) + } +} diff --git a/app/src/ui/banners/banner.tsx b/app/src/ui/banners/banner.tsx index 016dbafb16f..9c3762ae986 100644 --- a/app/src/ui/banners/banner.tsx +++ b/app/src/ui/banners/banner.tsx @@ -1,20 +1,29 @@ import * as React from 'react' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' +import classNames from 'classnames' interface IBannerProps { readonly id?: string readonly timeout?: number readonly dismissable?: boolean + readonly className?: string readonly onDismissed: () => void } export class Banner extends React.Component { - private timeoutId: number | null = null + private banner = React.createRef() + + // Timeout ID for manual focus placement after mounting + private focusTimeoutId: number | null = null + + // Timeout ID for auto-dismissal of the banner after focus is lost + private dismissalTimeoutId: number | null = null public render() { + const cn = classNames('banner', this.props.className) return ( -
+
{this.props.children}
{this.renderCloseButton()}
@@ -22,31 +31,75 @@ export class Banner extends React.Component { } private renderCloseButton() { - const { dismissable } = this.props - if (dismissable === undefined || dismissable === false) { + const { dismissable, onDismissed } = this.props + + if (dismissable === false) { return null } return (
- - - +
) } - public componentDidMount = () => { - if (this.props.timeout !== undefined) { - this.timeoutId = window.setTimeout(() => { - this.props.onDismissed() - }, this.props.timeout) + public componentDidMount() { + this.focusTimeoutId = window.setTimeout(() => { + this.focusOnFirstSuitableElement() + }, 200) + this.addDismissalFocusListeners() + } + + public componentWillUnmount() { + if (this.focusTimeoutId !== null) { + window.clearTimeout(this.focusTimeoutId) + this.focusTimeoutId = null + } + + this.removeDismissalFocusListeners() + } + + private focusOnFirstSuitableElement = () => { + const target = + this.banner.current?.querySelector('a') || + this.banner.current?.querySelector('button') + target?.focus() + } + + private addDismissalFocusListeners() { + this.banner.current?.addEventListener('focusin', this.onFocusIn) + this.banner.current?.addEventListener('focusout', this.onFocusOut) + } + + private removeDismissalFocusListeners() { + this.banner.current?.removeEventListener('focusout', this.onFocusOut) + this.banner.current?.removeEventListener('focusin', this.onFocusIn) + } + + private onFocusIn = () => { + if (this.dismissalTimeoutId !== null) { + window.clearTimeout(this.dismissalTimeoutId) + this.dismissalTimeoutId = null } } - public componentWillUnmount = () => { - if (this.props.timeout !== undefined && this.timeoutId !== null) { - window.clearTimeout(this.timeoutId) + private onFocusOut = async (event: FocusEvent) => { + const { dismissable, onDismissed, timeout } = this.props + + if ( + event.relatedTarget && + this.banner.current?.contains(event.relatedTarget as Node) + ) { + return + } + + if (dismissable !== false && timeout !== undefined) { + this.dismissalTimeoutId = window.setTimeout(() => { + onDismissed() + }, timeout) } } } diff --git a/app/src/ui/banners/branch-already-up-to-date-banner.tsx b/app/src/ui/banners/branch-already-up-to-date-banner.tsx index 76efa4d0938..6ed3cdbe4fd 100644 --- a/app/src/ui/banners/branch-already-up-to-date-banner.tsx +++ b/app/src/ui/banners/branch-already-up-to-date-banner.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { Banner } from './banner' export function BranchAlreadyUpToDate({ @@ -29,7 +29,7 @@ export function BranchAlreadyUpToDate({ return (
- +
{message}
diff --git a/app/src/ui/banners/cherry-pick-conflicts-banner.tsx b/app/src/ui/banners/cherry-pick-conflicts-banner.tsx index 3b85f814892..67e871a0d4f 100644 --- a/app/src/ui/banners/cherry-pick-conflicts-banner.tsx +++ b/app/src/ui/banners/cherry-pick-conflicts-banner.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { Banner } from './banner' import { LinkButton } from '../lib/link-button' @@ -35,7 +35,7 @@ export class CherryPickConflictsBanner extends React.Component< dismissable={false} onDismissed={this.onDismissed} > - +
Resolve conflicts to continue cherry-picking onto{' '} diff --git a/app/src/ui/banners/conflicts-found-banner.tsx b/app/src/ui/banners/conflicts-found-banner.tsx index c6ed8044b93..0c76552246b 100644 --- a/app/src/ui/banners/conflicts-found-banner.tsx +++ b/app/src/ui/banners/conflicts-found-banner.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { Banner } from './banner' import { LinkButton } from '../lib/link-button' @@ -41,7 +41,7 @@ export class ConflictsFoundBanner extends React.Component< dismissable={false} onDismissed={this.onDismissed} > - +
Resolve conflicts to continue {this.props.operationDescription}. diff --git a/app/src/ui/banners/merge-conflicts-banner.tsx b/app/src/ui/banners/merge-conflicts-banner.tsx index e30358d9c8d..cbda3026fa8 100644 --- a/app/src/ui/banners/merge-conflicts-banner.tsx +++ b/app/src/ui/banners/merge-conflicts-banner.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { Banner } from './banner' import { Dispatcher } from '../dispatcher' import { Popup } from '../../models/popup' @@ -22,7 +22,7 @@ export class MergeConflictsBanner extends React.Component< private openDialog = () => { this.props.onDismissed() this.props.dispatcher.showPopup(this.props.popup) - this.props.dispatcher.recordMergeConflictsDialogReopened() + this.props.dispatcher.incrementMetric('mergeConflictsDialogReopenedCount') } public render() { return ( @@ -31,7 +31,7 @@ export class MergeConflictsBanner extends React.Component< dismissable={false} onDismissed={this.props.onDismissed} > - +
Resolve conflicts and commit to merge into{' '} diff --git a/app/src/ui/banners/open-thank-you-card.tsx b/app/src/ui/banners/open-thank-you-card.tsx index 4d81f239fd1..f2ac0e65b9c 100644 --- a/app/src/ui/banners/open-thank-you-card.tsx +++ b/app/src/ui/banners/open-thank-you-card.tsx @@ -2,9 +2,10 @@ import * as React from 'react' import { LinkButton } from '../lib/link-button' import { RichText } from '../lib/rich-text' import { Banner } from './banner' +import { Emoji } from '../../lib/emoji' interface IOpenThankYouCardProps { - readonly emoji: Map + readonly emoji: Map readonly onDismissed: () => void readonly onOpenCard: () => void readonly onThrowCardAway: () => void @@ -20,26 +21,21 @@ export class OpenThankYouCard extends React.Component< public render() { return ( - - The Desktop team would like to thank you for your contributions.{' '} - - Open Your Card - {' '} - - or{' '} - Throw It Away{' '} - - + The Desktop team would like to thank you for your contributions.{' '} + Open Your Card{' '} + + or Throw It Away{' '} + ) } diff --git a/app/src/ui/banners/os-version-no-longer-supported-banner.tsx b/app/src/ui/banners/os-version-no-longer-supported-banner.tsx new file mode 100644 index 00000000000..89ee9fb97bd --- /dev/null +++ b/app/src/ui/banners/os-version-no-longer-supported-banner.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import { Banner } from './banner' +import { LinkButton } from '../lib/link-button' +import { setNumber } from '../../lib/local-storage' + +export const UnsupportedOSBannerDismissedAtKey = + 'unsupported-os-banner-dismissed-at' + +export class OSVersionNoLongerSupportedBanner extends React.Component<{ + onDismissed: () => void +}> { + private onDismissed = () => { + setNumber(UnsupportedOSBannerDismissedAtKey, Date.now()) + this.props.onDismissed() + } + + public render() { + return ( + + +
+ + This operating system is no longer supported. Software updates have + been disabled. + + + Support details + +
+
+ ) + } +} diff --git a/app/src/ui/banners/rebase-conflicts-banner.tsx b/app/src/ui/banners/rebase-conflicts-banner.tsx index e177ef7f2ad..2b1dcc6760b 100644 --- a/app/src/ui/banners/rebase-conflicts-banner.tsx +++ b/app/src/ui/banners/rebase-conflicts-banner.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { Banner } from './banner' import { Dispatcher } from '../dispatcher' import { LinkButton } from '../lib/link-button' @@ -22,7 +22,7 @@ export class RebaseConflictsBanner extends React.Component< private openDialog = async () => { this.props.onDismissed() this.props.onOpenDialog() - this.props.dispatcher.recordRebaseConflictsDialogReopened() + this.props.dispatcher.incrementMetric('rebaseConflictsDialogReopenedCount') } private onDismissed = () => { @@ -38,7 +38,7 @@ export class RebaseConflictsBanner extends React.Component< dismissable={false} onDismissed={this.onDismissed} > - +
Resolve conflicts to continue rebasing{' '} diff --git a/app/src/ui/banners/render-banner.tsx b/app/src/ui/banners/render-banner.tsx index 939b8e26266..486a6e89543 100644 --- a/app/src/ui/banners/render-banner.tsx +++ b/app/src/ui/banners/render-banner.tsx @@ -18,6 +18,8 @@ import { OpenThankYouCard } from './open-thank-you-card' import { SuccessfulSquash } from './successful-squash' import { SuccessBanner } from './success-banner' import { ConflictsFoundBanner } from './conflicts-found-banner' +import { OSVersionNoLongerSupportedBanner } from './os-version-no-longer-supported-banner' +import { AccessibilitySettingsBanner } from './accessibilty-settings-banner' export function renderBanner( banner: Banner, @@ -122,7 +124,11 @@ export function renderBanner( case BannerType.SquashUndone: { const pluralized = banner.commitsCount === 1 ? 'commit' : 'commits' return ( - + Squash of {banner.commitsCount} {pluralized} undone. ) @@ -132,6 +138,7 @@ export function renderBanner( return ( + Reorder of {banner.commitsCount} {pluralized} undone. ) @@ -159,6 +170,15 @@ export function renderBanner( key={'conflicts-found'} > ) + case BannerType.OSVersionNoLongerSupported: + return + case BannerType.AccessibilitySettingsBanner: + return ( + + ) default: return assertNever(banner, `Unknown popup type: ${banner}`) } diff --git a/app/src/ui/banners/success-banner.tsx b/app/src/ui/banners/success-banner.tsx index 45a5c095c9a..d252d89389b 100644 --- a/app/src/ui/banners/success-banner.tsx +++ b/app/src/ui/banners/success-banner.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { LinkButton } from '../lib/link-button' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { Banner } from './banner' interface ISuccessBannerProps { @@ -36,7 +36,7 @@ export class SuccessBanner extends React.Component { onDismissed={this.props.onDismissed} >
- +
{this.props.children} diff --git a/app/src/ui/banners/update-available.tsx b/app/src/ui/banners/update-available.tsx index 37945f833d5..ea0bd6c14dd 100644 --- a/app/src/ui/banners/update-available.tsx +++ b/app/src/ui/banners/update-available.tsx @@ -1,9 +1,13 @@ import * as React from 'react' import { Dispatcher } from '../dispatcher/index' import { LinkButton } from '../lib/link-button' -import { lastShowCaseVersionSeen, updateStore } from '../lib/update-store' +import { + UpdateStatus, + lastShowCaseVersionSeen, + updateStore, +} from '../lib/update-store' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { PopupType } from '../../models/popup' import { shell } from '../../lib/app-shell' @@ -11,39 +15,67 @@ import { ReleaseSummary } from '../../models/release-notes' import { Banner } from './banner' import { ReleaseNotesUri } from '../lib/releases' import { RichText } from '../lib/rich-text' +import { Emoji } from '../../lib/emoji' interface IUpdateAvailableProps { readonly dispatcher: Dispatcher readonly newReleases: ReadonlyArray | null + readonly isX64ToARM64ImmediateAutoUpdate: boolean readonly isUpdateShowcaseVisible: boolean - readonly emoji: Map + readonly emoji: Map readonly onDismissed: () => void + readonly prioritizeUpdate: boolean + readonly prioritizeUpdateInfoUrl: string | undefined } /** * A component which tells the user an update is available and gives them the * option of moving into the future or being a luddite. */ -export class UpdateAvailable extends React.Component< - IUpdateAvailableProps, - {} -> { +export class UpdateAvailable extends React.Component { public render() { return ( - - {!this.props.isUpdateShowcaseVisible && ( - - )} - + + {this.renderIcon()} {this.renderMessage()} ) } + private renderIcon() { + if (this.props.isUpdateShowcaseVisible) { + return null + } + + if (this.props.prioritizeUpdate) { + return + } + + return ( + + ) + } + private renderMessage = () => { + if (this.props.isX64ToARM64ImmediateAutoUpdate) { + return ( + + An optimized version of GitHub Desktop is available for your{' '} + {__DARWIN__ ? 'Apple silicon' : 'Arm64'} machine and will be installed + at the next launch or{' '} + + restart GitHub Desktop + {' '} + now. + + ) + } + if (this.props.isUpdateShowcaseVisible) { const version = this.props.newReleases !== null @@ -67,6 +99,26 @@ export class UpdateAvailable extends React.Component< ) } + if (this.props.prioritizeUpdate) { + return ( + + This version of GitHub Desktop is missing{' '} + {this.props.prioritizeUpdateInfoUrl ? ( + + important updates + + ) : ( + 'important updates' + )} + . Please{' '} + + restart GitHub Desktop + {' '} + now to install pending updates. + + ) + } + return ( An updated version of GitHub Desktop is available and will be installed @@ -109,6 +161,15 @@ export class UpdateAvailable extends React.Component< } private updateNow = () => { + if ( + (__RELEASE_CHANNEL__ === 'development' || + __RELEASE_CHANNEL__ === 'test') && + updateStore.state.status !== UpdateStatus.UpdateReady + ) { + this.props.onDismissed() + return // causes a crash.. if no update is available + } + updateStore.quitAndInstallUpdate() } } diff --git a/app/src/ui/branches/branch-list-item-context-menu.tsx b/app/src/ui/branches/branch-list-item-context-menu.tsx new file mode 100644 index 00000000000..c937379adfa --- /dev/null +++ b/app/src/ui/branches/branch-list-item-context-menu.tsx @@ -0,0 +1,54 @@ +import { IMenuItem } from '../../lib/menu-item' +import { clipboard } from 'electron' + +interface IBranchContextMenuConfig { + name: string + isLocal: boolean + onRenameBranch?: (branchName: string) => void + onViewPullRequestOnGitHub?: () => void + onDeleteBranch?: (branchName: string) => void +} + +export function generateBranchContextMenuItems( + config: IBranchContextMenuConfig +): IMenuItem[] { + const { + name, + isLocal, + onRenameBranch, + onViewPullRequestOnGitHub, + onDeleteBranch, + } = config + const items = new Array() + + if (onRenameBranch !== undefined) { + items.push({ + label: 'Rename…', + action: () => onRenameBranch(name), + enabled: isLocal, + }) + } + + items.push({ + label: __DARWIN__ ? 'Copy Branch Name' : 'Copy branch name', + action: () => clipboard.writeText(name), + }) + + if (onViewPullRequestOnGitHub !== undefined) { + items.push({ + label: 'View Pull Request on GitHub', + action: () => onViewPullRequestOnGitHub(), + }) + } + + items.push({ type: 'separator' }) + + if (onDeleteBranch !== undefined) { + items.push({ + label: 'Delete…', + action: () => onDeleteBranch(name), + }) + } + + return items +} diff --git a/app/src/ui/branches/branch-list-item.tsx b/app/src/ui/branches/branch-list-item.tsx index 54cc1c3af0a..3900defcf2e 100644 --- a/app/src/ui/branches/branch-list-item.tsx +++ b/app/src/ui/branches/branch-list-item.tsx @@ -1,13 +1,10 @@ -import { clipboard } from 'electron' import * as React from 'react' import { IMatches } from '../../lib/fuzzy-find' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { HighlightText } from '../lib/highlight-text' -import { showContextualMenu } from '../../lib/menu-item' -import { IMenuItem } from '../../lib/menu-item' import { dragAndDropManager } from '../../lib/drag-and-drop-manager' import { DragType, DropTargetType } from '../../models/drag-drop' import { TooltippedContent } from '../lib/tooltipped-content' @@ -21,18 +18,10 @@ interface IBranchListItemProps { /** Specifies whether this item is currently selected */ readonly isCurrentBranch: boolean - /** The date may be null if we haven't loaded the tip commit yet. */ - readonly lastCommitDate: Date | null - /** The characters in the branch name to highlight */ readonly matches: IMatches - /** Specifies whether the branch is local */ - readonly isLocal: boolean - - readonly onRenameBranch?: (branchName: string) => void - - readonly onDeleteBranch?: (branchName: string) => void + readonly authorDate: Date | undefined /** When a drag element has landed on a branch that is not current */ readonly onDropOntoBranch?: (branchName: string) => void @@ -60,47 +49,6 @@ export class BranchListItem extends React.Component< this.state = { isDragInProgress: false } } - private onContextMenu = (event: React.MouseEvent) => { - event.preventDefault() - - /* - There are multiple instances in the application where a branch list item - is rendered. We only want to be able to rename or delete them on the - branch dropdown menu. Thus, other places simply will not provide these - methods, such as the merge and rebase logic. - */ - const { onRenameBranch, onDeleteBranch, name, isLocal } = this.props - if (onRenameBranch === undefined && onDeleteBranch === undefined) { - return - } - - const items: Array = [] - - if (onRenameBranch !== undefined) { - items.push({ - label: 'Rename…', - action: () => onRenameBranch(name), - enabled: isLocal, - }) - } - - items.push({ - label: __DARWIN__ ? 'Copy Branch Name' : 'Copy branch name', - action: () => clipboard.writeText(name), - }) - - items.push({ type: 'separator' }) - - if (onDeleteBranch !== undefined) { - items.push({ - label: 'Delete…', - action: () => onDeleteBranch(name), - }) - } - - showContextualMenu(items) - } - private onMouseEnter = () => { if (dragAndDropManager.isDragInProgress) { this.setState({ isDragInProgress: true }) @@ -142,15 +90,16 @@ export class BranchListItem extends React.Component< } public render() { - const { lastCommitDate, isCurrentBranch, name } = this.props - const icon = isCurrentBranch ? OcticonSymbol.check : OcticonSymbol.gitBranch + const { authorDate, isCurrentBranch, name } = this.props + + const icon = isCurrentBranch ? octicons.check : octicons.gitBranch const className = classNames('branches-list-item', { 'drop-target': this.state.isDragInProgress, }) return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions
- {lastCommitDate && ( + {authorDate && ( )} diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index a3db3b365c2..3b424bfba84 100644 --- a/app/src/ui/branches/branch-list.tsx +++ b/app/src/ui/branches/branch-list.tsx @@ -1,14 +1,10 @@ import * as React from 'react' -import { Branch } from '../../models/branch' +import { Branch, BranchType } from '../../models/branch' import { assertNever } from '../../lib/fatal-error' -import { - FilterList, - IFilterListGroup, - SelectionSource, -} from '../lib/filter-list' +import { SelectionSource } from '../lib/filter-list' import { IMatches } from '../../lib/fuzzy-find' import { Button } from '../lib/button' import { TextBox } from '../lib/text-box' @@ -20,10 +16,19 @@ import { } from './group-branches' import { NoBranches } from './no-branches' import { SelectionDirection, ClickSource } from '../lib/list' +import { generateBranchContextMenuItems } from './branch-list-item-context-menu' +import { showContextualMenu } from '../../lib/menu-item' +import { SectionFilterList } from '../lib/section-filter-list' +import memoizeOne from 'memoize-one' +import { getAuthors } from '../../lib/git/log' +import { Repository } from '../../models/repository' +import uuid from 'uuid' const RowHeight = 30 interface IBranchListProps { + readonly repository: Repository + /** * See IBranchesState.defaultBranch */ @@ -92,12 +97,19 @@ interface IBranchListProps { readonly textbox?: TextBox + /** Aria label for a specific row */ + readonly getBranchAriaLabel: ( + item: IBranchListItem, + authorDate: Date | undefined + ) => string | undefined + /** * Render function to apply to each branch in the list */ readonly renderBranch: ( item: IBranchListItem, - matches: IMatches + matches: IMatches, + authorDate: Date | undefined ) => JSX.Element /** @@ -110,62 +122,73 @@ interface IBranchListProps { /** Called to render content before/above the branches filter and list. */ readonly renderPreList?: () => JSX.Element | null -} -interface IBranchListState { - /** - * The grouped list of branches. - * - * Groups are currently defined as 'default branch', 'current branch', - * 'recent branches' and all branches. - */ - readonly groups: ReadonlyArray> + /** Optional: No branches message */ + readonly noBranchesMessage?: string | JSX.Element - /** The selected item in the filtered list */ - readonly selectedItem: IBranchListItem | null -} - -function createState(props: IBranchListProps): IBranchListState { - const groups = groupBranches( - props.defaultBranch, - props.currentBranch, - props.allBranches, - props.recentBranches - ) + /** Optional: Callback for if rename context menu should exist */ + readonly onRenameBranch?: (branchName: string) => void - let selectedItem: IBranchListItem | null = null - const selectedBranch = props.selectedBranch - if (selectedBranch) { - for (const group of groups) { - selectedItem = - group.items.find(i => { - const branch = i.branch - return branch.name === selectedBranch.name - }) || null - - if (selectedItem) { - break - } - } - } + /** Optional: Callback for if delete context menu should exist */ + readonly onDeleteBranch?: (branchName: string) => void +} - return { groups, selectedItem } +interface IBranchListState { + readonly commitAuthorDates: ReadonlyMap } +const commitDateCache = new Map() + /** The Branches list component. */ export class BranchList extends React.Component< IBranchListProps, IBranchListState > { - private branchFilterList: FilterList | null = null + private branchFilterList: SectionFilterList | null = null + + private getGroups = memoizeOne(groupBranches) + private getSelectedItem = memoizeOne( + (groups: ReturnType, selectedBranch: Branch | null) => + groups + .flatMap(g => g.items) + .find(i => i.branch.name === selectedBranch?.name) ?? null + ) - public constructor(props: IBranchListProps) { - super(props) - this.state = createState(props) + /** + * Generate an opaque value any time groups or commitAuthorDates changes + * in order to force the list to re-render. + * + * Note, change is determined by reference equality + */ + private getInvalidationProp = memoizeOne( + ( + _groups: ReturnType, + _commitAuthorDates: IBranchListState['commitAuthorDates'] + ) => uuid() + ) + + private get invalidationProp() { + return this.getInvalidationProp(this.groups, this.state.commitAuthorDates) + } + + private get groups() { + return this.getGroups( + this.props.defaultBranch, + this.props.currentBranch, + this.props.allBranches, + this.props.recentBranches + ) } - public componentWillReceiveProps(nextProps: IBranchListProps) { - this.setState(createState(nextProps)) + private get selectedItem() { + return this.getSelectedItem(this.groups, this.props.selectedBranch) + } + + public constructor(props: IBranchListProps) { + super(props) + this.state = { + commitAuthorDates: new Map(), + } } public selectNextItem(focus: boolean = false, direction: SelectionDirection) { @@ -174,41 +197,116 @@ export class BranchList extends React.Component< } } + public componentDidUpdate(prevProps: IBranchListProps) { + if (prevProps.allBranches !== this.props.allBranches) { + this.populateCommitDates() + } + } + + private populateCommitDates = () => { + const cached = new Map() + const missing = new Array() + const uniqShas = new Set(this.props.allBranches.map(b => b.tip.sha)) + + for (const sha of uniqShas) { + const date = commitDateCache.get(sha) + if (date) { + cached.set(sha, date) + } else { + missing.push(sha) + } + } + + // Clean up the cache + for (const sha of commitDateCache.keys()) { + if (!uniqShas.has(sha)) { + commitDateCache.delete(sha) + } + } + + this.setState({ commitAuthorDates: cached }) + + if (missing.length > 0) { + getAuthors(this.props.repository, missing) + .then(x => { + x.forEach(({ date }, i) => commitDateCache.set(missing[i], date)) + this.populateCommitDates() + }) + .catch(e => log.error(`Failed to populate commit dates`, e)) + } + } + + public componentDidMount() { + this.populateCommitDates() + } + public render() { return ( - + ref={this.onBranchesFilterListRef} className="branches-list" rowHeight={RowHeight} filterText={this.props.filterText} onFilterTextChanged={this.props.onFilterTextChanged} onFilterKeyDown={this.props.onFilterKeyDown} - selectedItem={this.state.selectedItem} + selectedItem={this.selectedItem} renderItem={this.renderItem} renderGroupHeader={this.renderGroupHeader} onItemClick={this.onItemClick} onSelectionChanged={this.onSelectionChanged} onEnterPressedWithoutFilteredItems={this.onCreateNewBranch} - groups={this.state.groups} - invalidationProps={this.props.allBranches} + groups={this.groups} + invalidationProps={this.invalidationProp} renderPostFilter={this.onRenderNewButton} renderNoItems={this.onRenderNoItems} filterTextBox={this.props.textbox} hideFilterRow={this.props.hideFilterRow} onFilterListResultsChanged={this.props.onFilterListResultsChanged} renderPreList={this.props.renderPreList} + onItemContextMenu={this.onBranchContextMenu} + getItemAriaLabel={this.getItemAriaLabel} + getGroupAriaLabel={this.getGroupAriaLabel} /> ) } + private onBranchContextMenu = ( + item: IBranchListItem, + event: React.MouseEvent + ) => { + event.preventDefault() + + const { onRenameBranch, onDeleteBranch } = this.props + + if (onRenameBranch === undefined && onDeleteBranch === undefined) { + return + } + + const { type, name } = item.branch + const isLocal = type === BranchType.Local + + const items = generateBranchContextMenuItems({ + name, + isLocal, + onRenameBranch, + onDeleteBranch, + }) + + showContextualMenu(items) + } + private onBranchesFilterListRef = ( - filterList: FilterList | null + filterList: SectionFilterList | null ) => { this.branchFilterList = filterList } private renderItem = (item: IBranchListItem, matches: IMatches) => { - return this.props.renderBranch(item, matches) + return this.props.renderBranch( + item, + matches, + this.state.commitAuthorDates.get(item.branch.tip.sha) + ) } private parseHeader(label: string): BranchGroupIdentifier | null { @@ -222,6 +320,18 @@ export class BranchList extends React.Component< } } + private getItemAriaLabel = (item: IBranchListItem) => { + return this.props.getBranchAriaLabel( + item, + this.state.commitAuthorDates.get(item.branch.tip.sha) + ) + } + + private getGroupAriaLabel = (group: number) => { + const identifier = this.groups[group].identifier as BranchGroupIdentifier + return this.getGroupLabel(identifier) + } + private renderGroupHeader = (label: string) => { const identifier = this.parseHeader(label) @@ -249,6 +359,7 @@ export class BranchList extends React.Component< ) } diff --git a/app/src/ui/branches/branch-renderer.tsx b/app/src/ui/branches/branch-renderer.tsx index aedcb1cebc7..410b242039c 100644 --- a/app/src/ui/branches/branch-renderer.tsx +++ b/app/src/ui/branches/branch-renderer.tsx @@ -1,34 +1,44 @@ import * as React from 'react' -import { Branch, BranchType } from '../../models/branch' +import { Branch } from '../../models/branch' import { IBranchListItem } from './group-branches' import { BranchListItem } from './branch-list-item' import { IMatches } from '../../lib/fuzzy-find' +import { getRelativeTimeInfoFromDate } from '../relative-time' export function renderDefaultBranch( item: IBranchListItem, matches: IMatches, currentBranch: Branch | null, - onRenameBranch?: (branchName: string) => void, - onDeleteBranch?: (branchName: string) => void, + authorDate: Date | undefined, onDropOntoBranch?: (branchName: string) => void, onDropOntoCurrentBranch?: () => void ): JSX.Element { const branch = item.branch - const commit = branch.tip const currentBranchName = currentBranch ? currentBranch.name : null return ( ) } + +export function getDefaultAriaLabelForBranch( + item: IBranchListItem, + authorDate: Date | undefined +): string { + const branch = item.branch + + if (!authorDate) { + return branch.name + } + + const { relativeText } = getRelativeTimeInfoFromDate(authorDate, true) + return `${item.branch.name} ${relativeText}` +} diff --git a/app/src/ui/branches/branch-select.tsx b/app/src/ui/branches/branch-select.tsx new file mode 100644 index 00000000000..909f7dd1eef --- /dev/null +++ b/app/src/ui/branches/branch-select.tsx @@ -0,0 +1,137 @@ +import * as React from 'react' +import { IMatches } from '../../lib/fuzzy-find' +import { Branch } from '../../models/branch' +import { ClickSource } from '../lib/list' +import { PopoverDropdown } from '../lib/popover-dropdown' +import { BranchList } from './branch-list' +import { + getDefaultAriaLabelForBranch, + renderDefaultBranch, +} from './branch-renderer' +import { IBranchListItem } from './group-branches' +import { Repository } from '../../models/repository' + +interface IBranchSelectProps { + readonly repository: Repository + + /** The initially selected branch. */ + readonly branch: Branch | null + + /** + * See IBranchesState.defaultBranch + */ + readonly defaultBranch: Branch | null + + /** + * The currently checked out branch + */ + readonly currentBranch: Branch + + /** + * See IBranchesState.allBranches + */ + readonly allBranches: ReadonlyArray + + /** + * See IBranchesState.recentBranches + */ + readonly recentBranches: ReadonlyArray + + /** Called when the user changes the selected branch. */ + readonly onChange?: (branch: Branch) => void + + /** Optional: No branches message */ + readonly noBranchesMessage?: string | JSX.Element +} + +interface IBranchSelectState { + readonly selectedBranch: Branch | null + readonly filterText: string +} + +/** + * A branch select element for filter and selecting a branch. + */ +export class BranchSelect extends React.Component< + IBranchSelectProps, + IBranchSelectState +> { + private popoverRef = React.createRef() + + public constructor(props: IBranchSelectProps) { + super(props) + + this.state = { + selectedBranch: props.branch, + filterText: '', + } + } + + private renderBranch = ( + item: IBranchListItem, + matches: IMatches, + authorDate: Date | undefined + ) => { + return renderDefaultBranch( + item, + matches, + this.props.currentBranch, + authorDate + ) + } + + private getBranchAriaLabel = ( + item: IBranchListItem, + authorDate: Date | undefined + ): string => { + return getDefaultAriaLabelForBranch(item, authorDate) + } + + private onItemClick = (branch: Branch, source: ClickSource) => { + source.event.preventDefault() + this.popoverRef.current?.closePopover() + this.setState({ selectedBranch: branch }) + this.props.onChange?.(branch) + } + + private onFilterTextChanged = (filterText: string) => { + this.setState({ filterText }) + } + + public render() { + const { + currentBranch, + defaultBranch, + recentBranches, + allBranches, + noBranchesMessage, + } = this.props + + const { filterText, selectedBranch } = this.state + + return ( + + + + ) + } +} diff --git a/app/src/ui/branches/branches-container.tsx b/app/src/ui/branches/branches-container.tsx index 0b6215c3eb2..fb88733b583 100644 --- a/app/src/ui/branches/branches-container.tsx +++ b/app/src/ui/branches/branches-container.tsx @@ -5,7 +5,7 @@ import { Repository, isRepositoryWithGitHubRepository, } from '../../models/repository' -import { Branch, BranchType } from '../../models/branch' +import { Branch } from '../../models/branch' import { BranchesTab } from '../../models/branches-tab' import { PopupType } from '../../models/popup' @@ -17,19 +17,27 @@ import { TabBar } from '../tab-bar' import { Row } from '../lib/row' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { Button } from '../lib/button' import { BranchList } from './branch-list' import { PullRequestList } from './pull-request-list' import { IBranchListItem } from './group-branches' -import { renderDefaultBranch } from './branch-renderer' +import { + getDefaultAriaLabelForBranch, + renderDefaultBranch, +} from './branch-renderer' import { IMatches } from '../../lib/fuzzy-find' import { startTimer } from '../lib/timing' import { dragAndDropManager } from '../../lib/drag-and-drop-manager' import { DragType, DropTargetType } from '../../models/drag-drop' -import { enablePullRequestQuickView } from '../../lib/feature-flag' +import { + enablePullRequestQuickView, + enableResizingToolbarButtons, +} from '../../lib/feature-flag' import { PullRequestQuickView } from '../pull-request-quick-view' +import { Emoji } from '../../lib/emoji' +import classNames from 'classnames' interface IBranchesContainerProps { readonly dispatcher: Dispatcher @@ -40,6 +48,8 @@ interface IBranchesContainerProps { readonly currentBranch: Branch | null readonly recentBranches: ReadonlyArray readonly pullRequests: ReadonlyArray + readonly onRenameBranch: (branchName: string) => void + readonly onDeleteBranch: (branchName: string) => void /** The pull request associated with the current branch. */ readonly currentPullRequest: PullRequest | null @@ -48,7 +58,9 @@ interface IBranchesContainerProps { readonly isLoadingPullRequests: boolean /** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */ - readonly emoji: Map + readonly emoji: Map + + readonly underlineLinks: boolean } interface IBranchesContainerState { @@ -105,8 +117,11 @@ export class BranchesContainer extends React.Component< } public render() { + const classes = classNames('branches-container', { + resizable: enableResizingToolbarButtons(), + }) return ( -
+
{this.renderTabBar()} {this.renderSelectedTab()} {this.renderMergeButtonRow()} @@ -133,6 +148,7 @@ export class BranchesContainer extends React.Component< pullRequestItemTop={prListItemTop} onMouseEnter={this.onMouseEnterPullRequestQuickView} onMouseLeave={this.onMouseLeavePullRequestQuickView} + underlineLinks={this.props.underlineLinks} /> ) } @@ -159,11 +175,13 @@ export class BranchesContainer extends React.Component< return ( - ) @@ -190,8 +208,8 @@ export class BranchesContainer extends React.Component< selectedIndex={this.props.selectedTab} allowDragOverSwitching={true} > - Branches - + Branches + {__DARWIN__ ? 'Pull Requests' : 'Pull requests'} {this.renderOpenPullRequestsBubble()} @@ -199,20 +217,50 @@ export class BranchesContainer extends React.Component< ) } - private renderBranch = (item: IBranchListItem, matches: IMatches) => { + private renderBranch = ( + item: IBranchListItem, + matches: IMatches, + authorDate: Date | undefined + ) => { return renderDefaultBranch( item, matches, this.props.currentBranch, - this.onRenameBranch, - this.onDeleteBranch, + authorDate, this.onDropOntoBranch, this.onDropOntoCurrentBranch ) } + private getBranchAriaLabel = ( + item: IBranchListItem, + authorDate: Date | undefined + ): string => { + return getDefaultAriaLabelForBranch(item, authorDate) + } + private renderSelectedTab() { + const { selectedTab, repository } = this.props + + const ariaLabelledBy = + selectedTab === BranchesTab.Branches || !repository.gitHubRepository + ? 'branches-tab' + : 'pull-requests-tab' + + return ( +
+ {this.renderSelectedTabContent()} +
+ ) + } + + private renderSelectedTabContent() { let tab = this.props.selectedTab + if (!this.props.repository.gitHubRepository) { tab = BranchesTab.Branches } @@ -221,6 +269,7 @@ export class BranchesContainer extends React.Component< case BranchesTab.Branches: return ( ) - case BranchesTab.PullRequests: { return this.renderPullRequests() } @@ -256,13 +307,14 @@ export class BranchesContainer extends React.Component< const label = __DARWIN__ ? 'New Branch' : 'New branch' return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions
- +
{label}
) @@ -321,7 +373,6 @@ export class BranchesContainer extends React.Component< isOnDefaultBranch={!!isOnDefaultBranch} onSelectionChanged={this.onPullRequestSelectionChanged} onCreateBranch={this.onCreateBranch} - onDismiss={this.onDismiss} dispatcher={this.props.dispatcher} repository={repository} isLoadingPullRequests={this.props.isLoadingPullRequests} @@ -355,10 +406,6 @@ export class BranchesContainer extends React.Component< this.props.dispatcher.changeBranchesTab(tab) } - private onDismiss = () => { - this.props.dispatcher.closeFoldout(FoldoutType.Branch) - } - private onMergeClick = () => { this.props.dispatcher.closeFoldout(FoldoutType.Branch) this.props.dispatcher.startMergeBranchOperation(this.props.repository) @@ -401,52 +448,6 @@ export class BranchesContainer extends React.Component< this.setState({ selectedPullRequest }) } - private getBranchWithName(branchName: string): Branch | undefined { - return this.props.allBranches.find(branch => branch.name === branchName) - } - - private onRenameBranch = (branchName: string) => { - const branch = this.getBranchWithName(branchName) - - if (branch === undefined) { - return - } - - this.props.dispatcher.showPopup({ - type: PopupType.RenameBranch, - repository: this.props.repository, - branch: branch, - }) - } - - private onDeleteBranch = async (branchName: string) => { - const branch = this.getBranchWithName(branchName) - - if (branch === undefined) { - return - } - - if (branch.type === BranchType.Remote) { - this.props.dispatcher.showPopup({ - type: PopupType.DeleteRemoteBranch, - repository: this.props.repository, - branch, - }) - return - } - - const aheadBehind = await this.props.dispatcher.getBranchAheadBehind( - this.props.repository, - branch - ) - this.props.dispatcher.showPopup({ - type: PopupType.DeleteBranch, - repository: this.props.repository, - branch, - existsOnRemote: aheadBehind !== null, - }) - } - /** * Method is to handle when something is dragged and dropped onto a branch * in the branch dropdown. @@ -477,7 +478,7 @@ export class BranchesContainer extends React.Component< private onDropOntoCurrentBranch = () => { if (dragAndDropManager.isDragOfType(DragType.Commit)) { - this.props.dispatcher.recordDragStartedAndCanceled() + this.props.dispatcher.incrementMetric('dragStartedAndCanceledCount') } } diff --git a/app/src/ui/branches/ci-status.tsx b/app/src/ui/branches/ci-status.tsx index 994062ce33b..3e101379a4b 100644 --- a/app/src/ui/branches/ci-status.tsx +++ b/app/src/ui/branches/ci-status.tsx @@ -1,15 +1,11 @@ import * as React from 'react' -import { Octicon, OcticonSymbolType } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import { Octicon, OcticonSymbol } from '../octicons' +import * as octicons from '../octicons/octicons.generated' import classNames from 'classnames' import { GitHubRepository } from '../../models/github-repository' import { DisposableLike } from 'event-kit' import { Dispatcher } from '../dispatcher' -import { - ICombinedRefCheck, - IRefCheck, - isSuccess, -} from '../../lib/ci-checks/ci-checks' +import { ICombinedRefCheck, IRefCheck } from '../../lib/ci-checks/ci-checks' import { IAPIWorkflowJobStep } from '../../lib/api' interface ICIStatusProps { @@ -115,7 +111,6 @@ export class CIStatus extends React.PureComponent< this.props.className )} symbol={getSymbolForCheck(check)} - title={getRefCheckSummary(check)} /> ) } @@ -123,28 +118,28 @@ export class CIStatus extends React.PureComponent< export function getSymbolForCheck( check: ICombinedRefCheck | IRefCheck | IAPIWorkflowJobStep -): OcticonSymbolType { +): OcticonSymbol { switch (check.conclusion) { case 'timed_out': - return OcticonSymbol.x + return octicons.x case 'failure': - return OcticonSymbol.x + return octicons.x case 'neutral': - return OcticonSymbol.squareFill + return octicons.squareFill case 'success': - return OcticonSymbol.check + return octicons.check case 'cancelled': - return OcticonSymbol.stop + return octicons.stop case 'action_required': - return OcticonSymbol.alert + return octicons.alert case 'skipped': - return OcticonSymbol.skip + return octicons.skip case 'stale': - return OcticonSymbol.issueReopened + return octicons.issueReopened } // Pending - return OcticonSymbol.dotFill + return octicons.dotFill } export function getClassNameForCheck( @@ -170,12 +165,12 @@ export function getClassNameForCheck( export function getSymbolForLogStep( logStep: IAPIWorkflowJobStep -): OcticonSymbolType { +): OcticonSymbol { switch (logStep.conclusion) { case 'success': - return OcticonSymbol.checkCircleFill + return octicons.checkCircleFill case 'failure': - return OcticonSymbol.xCircleFill + return octicons.xCircleFill } return getSymbolForCheck(logStep) @@ -190,20 +185,3 @@ export function getClassNameForLogStep(logStep: IAPIWorkflowJobStep): string { // Pending return '' } - -/** - * Convert the combined check to an app-friendly string. - */ -export function getRefCheckSummary(check: ICombinedRefCheck): string { - if (check.checks.length === 1) { - const { name, description } = check.checks[0] - return `${name}: ${description}` - } - - const successCount = check.checks.reduce( - (acc, cur) => acc + (isSuccess(cur) ? 1 : 0), - 0 - ) - - return `${successCount}/${check.checks.length} checks OK` -} diff --git a/app/src/ui/branches/no-branches.tsx b/app/src/ui/branches/no-branches.tsx index 65a3baac540..3e5b99f3afc 100644 --- a/app/src/ui/branches/no-branches.tsx +++ b/app/src/ui/branches/no-branches.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { encodePathAsUrl } from '../../lib/path' import { Button } from '../lib/button' +import { KeyboardShortcut } from '../keyboard-shortcut/keyboard-shortcut' const BlankSlateImage = encodePathAsUrl( __dirname, @@ -12,6 +13,8 @@ interface INoBranchesProps { readonly onCreateNewBranch: () => void /** True to display the UI elements for creating a new branch, false to hide them */ readonly canCreateNewBranch: boolean + /** Optional: No branches message */ + readonly noBranchesMessage?: string | JSX.Element } export class NoBranches extends React.Component { @@ -19,7 +22,7 @@ export class NoBranches extends React.Component { if (this.props.canCreateNewBranch) { return (
- +
Sorry, I can't find that branch
@@ -36,29 +39,21 @@ export class NoBranches extends React.Component {
- ProTip! Press {this.renderShortcut()} to quickly create a new branch - from anywhere within the app + ProTip! Press{' '} + {' '} + to quickly create a new branch from anywhere within the app
) } - return
Sorry, I can't find that branch
- } - - private renderShortcut() { - if (__DARWIN__) { - return ( - - + + N - - ) - } else { - return ( - - Ctrl + Shift + N - - ) - } + return ( +
+ {this.props.noBranchesMessage ?? "Sorry, I can't find that branch"} +
+ ) } } diff --git a/app/src/ui/branches/no-pull-requests.tsx b/app/src/ui/branches/no-pull-requests.tsx index 2ff762b621a..ab4d870d763 100644 --- a/app/src/ui/branches/no-pull-requests.tsx +++ b/app/src/ui/branches/no-pull-requests.tsx @@ -33,7 +33,7 @@ export class NoPullRequests extends React.Component { public render() { return (
- + {this.renderTitle()} {this.renderCallToAction()}
diff --git a/app/src/ui/branches/pull-request-badge.tsx b/app/src/ui/branches/pull-request-badge.tsx index dc34db63473..69b10ad937b 100644 --- a/app/src/ui/branches/pull-request-badge.tsx +++ b/app/src/ui/branches/pull-request-badge.tsx @@ -3,8 +3,8 @@ import { CIStatus } from './ci-status' import { GitHubRepository } from '../../models/github-repository' import { Dispatcher } from '../dispatcher' import { ICombinedRefCheck } from '../../lib/ci-checks/ci-checks' -import { enableCICheckRuns } from '../../lib/feature-flag' import { getPullRequestCommitRef } from '../../models/pull-request' +import { Button } from '../lib/button' interface IPullRequestBadgeProps { /** The pull request's number. */ @@ -15,6 +15,11 @@ interface IPullRequestBadgeProps { /** The GitHub repository to use when looking up commit status. */ readonly repository: GitHubRepository + /** Whether or not the check runs popover is open */ + readonly showCIStatusPopover?: boolean + + readonly onBadgeRef?: (ref: HTMLButtonElement | null) => void + /** The GitHub repository to use when looking up commit status. */ readonly onBadgeClick?: () => void @@ -33,7 +38,7 @@ export class PullRequestBadge extends React.Component< IPullRequestBadgeProps, IPullRequestBadgeState > { - private badgeRef: HTMLDivElement | null = null + private badgeRef: HTMLButtonElement | null = null private badgeBoundingBottom: number = 0 public constructor(props: IPullRequestBadgeProps) { @@ -56,14 +61,13 @@ export class PullRequestBadge extends React.Component< } } - private onRef = (badgeRef: HTMLDivElement) => { + private onRef = (badgeRef: HTMLButtonElement | null) => { this.badgeRef = badgeRef + this.props.onBadgeRef?.(badgeRef) } - private onBadgeClick = ( - event: React.MouseEvent - ) => { - if (!this.state.isStatusShowing || !enableCICheckRuns()) { + private onBadgeClick = (event: React.MouseEvent) => { + if (!this.state.isStatusShowing) { return } @@ -78,7 +82,14 @@ export class PullRequestBadge extends React.Component< public render() { const ref = getPullRequestCommitRef(this.props.number) return ( -
+
+ ) } } diff --git a/app/src/ui/branches/pull-request-list-item-context-menu.tsx b/app/src/ui/branches/pull-request-list-item-context-menu.tsx new file mode 100644 index 00000000000..86adbb4b557 --- /dev/null +++ b/app/src/ui/branches/pull-request-list-item-context-menu.tsx @@ -0,0 +1,21 @@ +import { IMenuItem } from '../../lib/menu-item' + +interface IPullRequestContextMenuConfig { + onViewPullRequestOnGitHub?: () => void +} + +export function generatePullRequestContextMenuItems( + config: IPullRequestContextMenuConfig +): IMenuItem[] { + const { onViewPullRequestOnGitHub } = config + const items = new Array() + + if (onViewPullRequestOnGitHub !== undefined) { + items.push({ + label: 'View Pull Request on GitHub', + action: () => onViewPullRequestOnGitHub(), + }) + } + + return items +} diff --git a/app/src/ui/branches/pull-request-list-item.tsx b/app/src/ui/branches/pull-request-list-item.tsx index 23cabc57154..c28d5cb7c32 100644 --- a/app/src/ui/branches/pull-request-list-item.tsx +++ b/app/src/ui/branches/pull-request-list-item.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import classNames from 'classnames' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { CIStatus } from './ci-status' import { HighlightText } from '../lib/highlight-text' import { IMatches } from '../../lib/fuzzy-find' @@ -11,6 +11,7 @@ import { dragAndDropManager } from '../../lib/drag-and-drop-manager' import { DropTargetType } from '../../models/drag-drop' import { getPullRequestCommitRef } from '../../models/pull-request' import { formatRelative } from '../../lib/format-relative' +import { TooltippedContent } from '../lib/tooltipped-content' export interface IPullRequestListItemProps { /** The title. */ @@ -126,6 +127,7 @@ export class PullRequestListItem extends React.Component< }) return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions
-
+ -
-
+ + -
+
{this.renderPullRequestStatus()}
diff --git a/app/src/ui/branches/pull-request-list.tsx b/app/src/ui/branches/pull-request-list.tsx index 28054d18786..8125d5ef393 100644 --- a/app/src/ui/branches/pull-request-list.tsx +++ b/app/src/ui/branches/pull-request-list.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import { - FilterList, IFilterListGroup, IFilterListItem, SelectionSource, @@ -21,6 +20,10 @@ import { startTimer } from '../lib/timing' import { DragType } from '../../models/drag-drop' import { dragAndDropManager } from '../../lib/drag-and-drop-manager' import { formatRelative } from '../../lib/format-relative' +import { AriaLiveContainer } from '../accessibility/aria-live-container' +import { SectionFilterList } from '../lib/section-filter-list' +import { generatePullRequestContextMenuItems } from './pull-request-list-item-context-menu' +import { showContextualMenu } from '../../lib/menu-item' interface IPullRequestListItem extends IFilterListItem { readonly id: string @@ -40,9 +43,6 @@ interface IPullRequestListProps { /** Is the default branch currently checked out? */ readonly isOnDefaultBranch: boolean - /** Called when the user wants to dismiss the foldout. */ - readonly onDismiss: () => void - /** Called when the user opts to create a branch */ readonly onCreateBranch: () => void @@ -52,14 +52,6 @@ interface IPullRequestListProps { source: SelectionSource ) => void - /** - * Called when a key down happens in the filter field. Users have a chance to - * respond or cancel the default behavior by calling `preventDefault`. - */ - readonly onFilterKeyDown?: ( - event: React.KeyboardEvent - ) => void - readonly dispatcher: Dispatcher readonly repository: RepositoryWithGitHubRepository @@ -82,6 +74,7 @@ interface IPullRequestListState { readonly filterText: string readonly groupedItems: ReadonlyArray> readonly selectedItem: IPullRequestListItem | null + readonly screenReaderStateMessage: string | null } function resolveSelectedItem( @@ -120,6 +113,7 @@ export class PullRequestList extends React.Component< filterText: '', groupedItems: [group], selectedItem, + screenReaderStateMessage: null, } } @@ -130,27 +124,47 @@ export class PullRequestList extends React.Component< nextProps, this.state.selectedItem ) - this.setState({ groupedItems: [group], selectedItem }) + + const loadingStarted = + !this.props.isLoadingPullRequests && nextProps.isLoadingPullRequests + const loadingComplete = + this.props.isLoadingPullRequests && !nextProps.isLoadingPullRequests + const numPullRequests = this.props.pullRequests.length + const plural = numPullRequests === 1 ? '' : 's' + const screenReaderStateMessage = loadingStarted + ? 'Hang Tight. Loading pull requests as fast as I can!' + : loadingComplete + ? `${numPullRequests} pull request${plural} found` + : null + + this.setState({ + groupedItems: [group], + selectedItem, + screenReaderStateMessage, + }) } public render() { return ( - - className="pull-request-list" - rowHeight={RowHeight} - groups={this.state.groupedItems} - selectedItem={this.state.selectedItem} - renderItem={this.renderPullRequest} - filterText={this.state.filterText} - onFilterTextChanged={this.onFilterTextChanged} - invalidationProps={this.props.pullRequests} - onItemClick={this.onItemClick} - onSelectionChanged={this.onSelectionChanged} - onFilterKeyDown={this.props.onFilterKeyDown} - renderGroupHeader={this.renderListHeader} - renderNoItems={this.renderNoItems} - renderPostFilter={this.renderPostFilter} - /> + <> + + className="pull-request-list" + rowHeight={RowHeight} + groups={this.state.groupedItems} + selectedItem={this.state.selectedItem} + renderItem={this.renderPullRequest} + filterText={this.state.filterText} + onFilterTextChanged={this.onFilterTextChanged} + invalidationProps={this.props.pullRequests} + onItemContextMenu={this.onPullRequestItemContextMenu} + onItemClick={this.onItemClick} + onSelectionChanged={this.onSelectionChanged} + renderGroupHeader={this.renderListHeader} + renderNoItems={this.renderNoItems} + renderPostFilter={this.renderPostFilter} + /> + + ) } @@ -190,6 +204,21 @@ export class PullRequestList extends React.Component< ) } + private onPullRequestItemContextMenu = ( + item: IPullRequestListItem, + event: React.MouseEvent + ): void => { + event.preventDefault() + + const items = generatePullRequestContextMenuItems({ + onViewPullRequestOnGitHub: () => { + this.props.dispatcher.showPullRequestByPR(item.pullRequest) + }, + }) + + showContextualMenu(items) + } + private onMouseEnterPullRequest = ( prNumber: number, prListItemTop: number @@ -228,7 +257,7 @@ export class PullRequestList extends React.Component< prNumber === selectedPullRequest.pullRequestNumber ) { dispatcher.endMultiCommitOperation(repository) - dispatcher.recordDragStartedAndCanceled() + dispatcher.incrementMetric('dragStartedAndCanceledCount') return } @@ -289,11 +318,14 @@ export class PullRequestList extends React.Component< } private renderPostFilter = () => { + const tooltip = 'Refresh the list of pull requests' + return ( ) } + private onRowDoubleClick = (row: number) => { + const file = this.props.workingDirectory.files[row] + + this.props.onOpenItemInExternalEditor(file.path) + } + private onRowKeyDown = ( _row: number, event: React.KeyboardEvent @@ -877,6 +968,10 @@ export class ChangesList extends React.Component< return } + public focus() { + this.includeAllCheckBoxRef.current?.focus() + } + public render() { const { workingDirectory, rebaseConflictState, isCommitting } = this.props const { files } = workingDirectory @@ -888,7 +983,7 @@ export class ChangesList extends React.Component< file => file.selection.getSelectionType() !== DiffSelectionType.None ).length const totalFilesPlural = files.length === 1 ? 'file' : 'files' - const selectedChangesDescription = `${selectedChangeCount}/${files.length} changed ${totalFilesPlural} selected` + const selectedChangesDescription = `${selectedChangeCount}/${files.length} changed ${totalFilesPlural} included` const includeAllValue = getIncludeAllValue( workingDirectory, @@ -899,42 +994,68 @@ export class ChangesList extends React.Component< files.length === 0 || isCommitting || rebaseConflictState !== null return ( -
-
- - {selectedChangesDescription} - - +
+
+ + + +
+ {selectedChangesDescription} +
+
+
- {this.renderStashedChanges()} {this.renderCommitMessageForm()} -
+ ) } + + private onRowFocus = (row: number) => { + this.setState({ focusedRow: row }) + } + + private onRowBlur = (row: number) => { + if (this.state.focusedRow === row) { + this.setState({ focusedRow: null }) + } + } } diff --git a/app/src/ui/changes/changes.tsx b/app/src/ui/changes/changes.tsx index dd7628b2f60..95b7be986f7 100644 --- a/app/src/ui/changes/changes.tsx +++ b/app/src/ui/changes/changes.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { ChangedFileDetails } from './changed-file-details' +import { DiffHeader } from '../diff/diff-header' import { DiffSelection, IDiff, @@ -9,7 +9,6 @@ import { import { WorkingDirectoryFileChange } from '../../models/status' import { Repository } from '../../models/repository' import { Dispatcher } from '../dispatcher' -import { enableHideWhitespaceInDiffOption } from '../../lib/feature-flag' import { SeamlessDiffSwitcher } from '../diff/seamless-diff-switcher' import { PopupType } from '../../models/popup' @@ -30,6 +29,9 @@ interface IChangesProps { */ readonly onOpenBinaryFile: (fullPath: string) => void + /** Called when the user requests to open a submodule. */ + readonly onOpenSubmodule: (fullPath: string) => void + /** * Called when the user is viewing an image diff and requests * to change the diff presentation mode. @@ -47,6 +49,9 @@ interface IChangesProps { */ readonly showSideBySideDiff: boolean + /** Whether or not to show the diff check marks indicating inclusion in a commit */ + readonly showDiffCheckMarks: boolean + /** Called when the user opens the diff options popover */ readonly onDiffOptionsOpened: () => void } @@ -58,10 +63,7 @@ export class Changes extends React.Component { * progress or if the user has opted to hide whitespace changes. */ private get lineSelectionDisabled() { - return ( - this.props.isCommitting || - (enableHideWhitespaceInDiffOption() && this.props.hideWhitespaceInDiff) - ) + return this.props.isCommitting || this.props.hideWhitespaceInDiff } private onDiffLineIncludeChanged = (selection: DiffSelection) => { @@ -99,8 +101,8 @@ export class Changes extends React.Component { public render() { return ( -
- + { diff={this.props.diff} hideWhitespaceInDiff={this.props.hideWhitespaceInDiff} showSideBySideDiff={this.props.showSideBySideDiff} + showDiffCheckMarks={this.props.showDiffCheckMarks} askForConfirmationOnDiscardChanges={ this.props.askForConfirmationOnDiscardChanges } onOpenBinaryFile={this.props.onOpenBinaryFile} + onOpenSubmodule={this.props.onOpenSubmodule} onChangeImageDiffType={this.props.onChangeImageDiffType} onHideWhitespaceInDiffChanged={this.onHideWhitespaceInDiffChanged} /> diff --git a/app/src/ui/changes/commit-message-avatar.tsx b/app/src/ui/changes/commit-message-avatar.tsx index 83f54e4f08c..d594a369586 100644 --- a/app/src/ui/changes/commit-message-avatar.tsx +++ b/app/src/ui/changes/commit-message-avatar.tsx @@ -2,36 +2,66 @@ import React from 'react' import { Select } from '../lib/select' import { Button } from '../lib/button' import { Row } from '../lib/row' -import { Popover, PopoverCaretPosition } from '../lib/popover' +import { + Popover, + PopoverAnchorPosition, + PopoverDecoration, +} from '../lib/popover' import { IAvatarUser } from '../../models/avatar' import { Avatar } from '../lib/avatar' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { LinkButton } from '../lib/link-button' +import { OkCancelButtonGroup } from '../dialog' +import { getConfigValue } from '../../lib/git/config' +import { Repository } from '../../models/repository' +import classNames from 'classnames' +import { RepoRulesMetadataFailures } from '../../models/repo-rules' +import { RepoRulesMetadataFailureList } from '../repository-rules/repo-rules-failure-list' +import { Account } from '../../models/account' + +export type CommitMessageAvatarWarningType = + | 'none' + | 'misattribution' + | 'disallowedEmail' interface ICommitMessageAvatarState { readonly isPopoverOpen: boolean /** Currently selected account email address. */ readonly accountEmail: string + + /** Whether the git configuration is local to the repository or global */ + readonly isGitConfigLocal: boolean } interface ICommitMessageAvatarProps { /** The user whose avatar should be displayed. */ readonly user?: IAvatarUser + /** Current email address configured by the user. */ + readonly email?: string + /** - * The title of the avatar. - * Defaults to the name and email if undefined and is - * skipped completely if title is null + * Controls whether a warning should be displayed. + * - 'none': No error is displayed, the field is valid. + * - 'misattribution': The user's Git config emails don't match and the + * commit may not be attributed to the user. + * - 'disallowedEmail': A repository rule may prevent the user from + * committing with the selected email address. */ - readonly title?: string | JSX.Element | null + readonly warningType: CommitMessageAvatarWarningType - /** Current email address configured by the user. */ - readonly email?: string + /** + * List of validations that failed for repo rules. Only used if + * {@link warningType} is 'disallowedEmail'. + */ + readonly emailRuleFailures?: RepoRulesMetadataFailures - /** Whether or not the warning badge on the avatar should be visible. */ - readonly warningBadgeVisible: boolean + /** + * Name of the current branch + */ + readonly branch: string | null /** Whether or not the user's account is a GHE account. */ readonly isEnterpriseAccount: boolean @@ -42,6 +72,11 @@ interface ICommitMessageAvatarProps { /** Preferred email address from the user's account. */ readonly preferredAccountEmail: string + /** + * The currently selected repository + */ + readonly repository: Repository + readonly onUpdateEmail: (email: string) => void /** @@ -49,6 +84,14 @@ interface ICommitMessageAvatarProps { * repository settings dialog */ readonly onOpenRepositorySettings: () => void + + /** + * Called when the user has requested to see the Git tab in the user settings + * dialog + */ + readonly onOpenGitSettings: () => void + + readonly accounts: ReadonlyArray } /** @@ -59,31 +102,99 @@ export class CommitMessageAvatar extends React.Component< ICommitMessageAvatarProps, ICommitMessageAvatarState > { + private avatarButtonRef: HTMLButtonElement | null = null + private warningBadgeRef = React.createRef() + public constructor(props: ICommitMessageAvatarProps) { super(props) this.state = { isPopoverOpen: false, accountEmail: this.props.preferredAccountEmail, + isGitConfigLocal: false, + } + this.determineGitConfigLocation() + } + + public componentDidUpdate(prevProps: ICommitMessageAvatarProps) { + if ( + this.props.user?.name !== prevProps.user?.name || + this.props.user?.email !== prevProps.user?.email + ) { + this.determineGitConfigLocation() } } + private async determineGitConfigLocation() { + const isGitConfigLocal = await this.isGitConfigLocal() + this.setState({ isGitConfigLocal }) + } + + private isGitConfigLocal = async () => { + const { repository } = this.props + const localName = await getConfigValue(repository, 'user.name', true) + const localEmail = await getConfigValue(repository, 'user.email', true) + return localName !== null || localEmail !== null + } + + private onButtonRef = (buttonRef: HTMLButtonElement | null) => { + this.avatarButtonRef = buttonRef + } + public render() { + const { warningType, user } = this.props + + let ariaLabel = '' + switch (warningType) { + case 'none': + ariaLabel = 'View commit author information' + break + + case 'misattribution': + ariaLabel = 'Commit may be misattributed. View warning.' + break + + case 'disallowedEmail': + ariaLabel = 'Email address is disallowed. View warning.' + break + } + + const classes = classNames('commit-message-avatar-component', { + misattributed: warningType !== 'none', + }) + return ( -
-
- {this.props.warningBadgeVisible && this.renderWarningBadge()} - -
+
+ {this.state.isPopoverOpen && this.renderPopover()}
) } private renderWarningBadge() { + const { warningType, emailRuleFailures } = this.props + + // the parent component only renders this one if an error/warning is present, so we + // only need to check which of the two it is here + const isError = + warningType === 'disallowedEmail' && emailRuleFailures?.status === 'fail' + const classes = classNames('warning-badge', { + error: isError, + warning: !isError, + }) + const symbol = isError ? octicons.stop : octicons.alert + return ( -
- +
+
) } @@ -106,11 +217,7 @@ export class CommitMessageAvatar extends React.Component< }) } - private onAvatarClick = (event: React.FormEvent) => { - if (this.props.warningBadgeVisible === false) { - return - } - + private onAvatarClick = (event: React.FormEvent) => { event.preventDefault() if (this.state.isPopoverOpen) { this.closePopover() @@ -119,31 +226,64 @@ export class CommitMessageAvatar extends React.Component< } } - private renderPopover() { - const accountTypeSuffix = this.props.isEnterpriseAccount - ? ' Enterprise' - : '' + private renderGitConfigPopover() { + const { user } = this.props + const { isGitConfigLocal } = this.state - const updateEmailTitle = __DARWIN__ ? 'Update Email' : 'Update email' + const location = isGitConfigLocal ? 'local' : 'global' + const locationDesc = isGitConfigLocal ? 'for your repository' : '' + const settingsName = __DARWIN__ ? 'settings' : 'options' + const settings = isGitConfigLocal + ? 'repository settings' + : `git ${settingsName}` + const buttonText = __DARWIN__ ? 'Open Git Settings' : 'Open git settings' return ( - -

This commit will be misattributed

- -
- The email in your global Git config ( - {this.props.email}) doesn't match - your GitHub{accountTypeSuffix} account.{' '} - - Learn more. + <> +

{user && user.name && `Email: ${user.email}`}

+ +

+ You can update your {location} git configuration {locationDesc} in + your {settings}. +

+ + {!isGitConfigLocal && ( +

+ You can also set an email local to this repository from the{' '} + + repository settings -

+ . +

+ )} + + + + ) + } + + private renderWarningPopover() { + const { warningType, emailRuleFailures } = this.props + + const updateEmailTitle = __DARWIN__ ? 'Update Email' : 'Update email' + + const sharedHeader = ( + <> + The email in your global Git config ( + {this.props.email}) + + ) + + const sharedFooter = ( + <> + ) + + return ( + <> + {!isOnlyOneCheckInRow && checkAllControl} + {hunkHandle} + {this.renderHunkHandlePlaceHolder(selectionState)} + + ) + } + + /** + * On scroll of the diff, the rendering of the hunk handle can be delayed so + * we make the placeholder mimic the selected state so visually it looks like + * the hunk handle is there and there isn't a flickter of grey background. + */ + private renderHunkHandlePlaceHolder = ( + selectionState?: DiffSelectionType + ) => { + return ( +
) } + private getCheckAllOcticon = ( + selectionState: DiffSelectionType, + isFirst: boolean + ) => { + if (!isFirst || !this.props.showDiffCheckMarks) { + return null + } + + if (selectionState === DiffSelectionType.All) { + return + } + if (selectionState === DiffSelectionType.Partial) { + return + } + + return null + } + + private getLineNumbersContainerID(column: DiffColumn) { + return `line-numbers-${this.props.numRow}-${column}` + } + /** * Renders the line number box. * * @param lineNumbers Array with line numbers to display. + * @param column Column to which the line number belongs. * @param isSelected Whether the line has been selected. * If undefined is passed, the line is treated * as non-selectable. */ private renderLineNumbers( lineNumbers: Array, + column: DiffColumn | undefined, isSelected?: boolean ) { - if (!this.props.isDiffSelectable || isSelected === undefined) { - return ( -
- {lineNumbers.map((lineNumber, index) => ( - {lineNumber} - ))} -
- ) + const wrapperID = + column === undefined ? undefined : this.getLineNumbersContainerID(column) + const isSelectable = this.props.isDiffSelectable && isSelected !== undefined + const classes = classNames('line-number', { + selectable: isSelectable, + hoverable: isSelectable, + 'line-selected': isSelected, + hover: this.props.rowSelectableGroup?.isHovered, + }) + + const firstDefinedLineNumber = lineNumbers + .filter(ln => ln !== undefined) + .at(0) + if (firstDefinedLineNumber === undefined) { + // This shouldn't be possible. If there are no line numbers, we shouldn't + // be rendering this component. + return null } + // Note: This id is used by the check all aria-controls attribute, + // modification of this should be reflected there. + const checkboxId: CheckBoxIdentifier = `${firstDefinedLineNumber}-${ + column === DiffColumn.After ? 'after' : 'before' + }` + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions
- {lineNumbers.map((lineNumber, index) => ( - {lineNumber} - ))} + {isSelectable && + this.renderLineNumberCheckbox(checkboxId, isSelected === true)} + +
+ ) + } + + private renderLineNumberCheck(isSelected?: boolean) { + if (!this.props.isDiffSelectable || !this.props.showDiffCheckMarks) { + return null + } + + return ( +
+ {isSelected ? : null}
) } + private renderLineNumberCheckbox( + checkboxId: string | undefined, + isSelected: boolean + ) { + return ( + + ) + } + private renderWhitespaceHintPopover(column: DiffColumn) { if (this.state.showWhitespaceHint !== column) { return } + const elementID = `line-numbers-${this.props.numRow}-${column}` + const anchor = document.getElementById(elementID) + if (anchor === null) { + return + } - const caretPosition = + const anchorPosition = column === DiffColumn.Before - ? PopoverCaretPosition.RightTop - : PopoverCaretPosition.LeftTop - - const style: React.CSSProperties = { - [column === DiffColumn.Before ? 'marginRight' : 'marginLeft']: - this.props.lineNumberWidth + 10, - marginTop: -10, - } + ? PopoverAnchorPosition.LeftTop + : PopoverAnchorPosition.RightTop return ( @@ -512,22 +856,21 @@ export class SideBySideDiffRow extends React.Component< * Renders the line number box. * * @param lineNumber Line number to display. + * @param column Column to which the line number belongs. * @param isSelected Whether the line has been selected. * If undefined is passed, the line is treated * as non-selectable. */ - private renderLineNumber(lineNumber?: number, isSelected?: boolean) { - return this.renderLineNumbers([lineNumber], isSelected) + private renderLineNumber( + lineNumber: number | undefined, + column: DiffColumn, + isSelected?: boolean + ) { + return this.renderLineNumbers([lineNumber], column, isSelected) } private getDiffColumn(targetElement?: Element): DiffColumn | null { - const { row, showSideBySideDiff } = this.props - - // On unified diffs we don't have columns so we always use "before" to not - // mess up with line selections. - if (!showSideBySideDiff) { - return DiffColumn.Before - } + const { row } = this.props switch (row.type) { case DiffRowType.Added: @@ -569,6 +912,29 @@ export class SideBySideDiffRow extends React.Component< return null } + private onLineNumberCheckboxChange = ({ + currentTarget: checkbox, + }: { + currentTarget: HTMLInputElement + }) => { + const column = this.getDiffColumn(checkbox) + + if (column === null) { + return + } + + if (this.props.hideWhitespaceInDiff) { + this.setState({ showWhitespaceHint: column }) + return + } + + this.props.onLineNumberCheckedChanged( + this.props.numRow, + column, + checkbox.checked + ) + } + private onMouseDownLineNumber = (evt: React.MouseEvent) => { if (evt.buttons === 2) { return @@ -577,27 +943,20 @@ export class SideBySideDiffRow extends React.Component< const column = this.getDiffColumn(evt.currentTarget) const data = this.getDiffData(evt.currentTarget) - if (data !== null && column !== null) { - if (this.props.hideWhitespaceInDiff) { - this.setState({ showWhitespaceHint: column }) - return - } - - this.props.onStartSelection(this.props.numRow, column, !data.isSelected) + if (column === null) { + return } - } - private onMouseEnterLineNumber = (evt: React.MouseEvent) => { if (this.props.hideWhitespaceInDiff) { + this.setState({ showWhitespaceHint: column }) return } - const data = this.getDiffData(evt.currentTarget) - const column = this.getDiffColumn(evt.currentTarget) - - if (data !== null && column !== null) { - this.props.onUpdateSelection(this.props.numRow, column) + if (data === null) { + return } + + this.props.onStartSelection(this.props.numRow, column, !data.isSelected) } private onMouseEnterHunk = () => { @@ -612,10 +971,23 @@ export class SideBySideDiffRow extends React.Component< } } - private onExpandHunk = (hunkIndex: number, kind: DiffExpansionKind) => () => { - this.props.onExpandHunk(hunkIndex, kind) + private onHunkFocus = () => { + if ('hunkStartLine' in this.props.row) { + this.props.onMouseEnterHunk(this.props.row.hunkStartLine) + } + } + + private onHunkBlur = () => { + if ('hunkStartLine' in this.props.row) { + this.props.onMouseLeaveHunk(this.props.row.hunkStartLine) + } } + private onExpandHunk = + (hunkIndex: number, kind: DiffHunkExpansionType) => () => { + this.props.onExpandHunk(hunkIndex, kind) + } + private onClickHunk = () => { if (this.props.hideWhitespaceInDiff) { const { row } = this.props diff --git a/app/src/ui/diff/side-by-side-diff.tsx b/app/src/ui/diff/side-by-side-diff.tsx index 459cb667845..0ac6b50f3ca 100644 --- a/app/src/ui/diff/side-by-side-diff.tsx +++ b/app/src/ui/diff/side-by-side-diff.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Repository } from '../../models/repository' + import { ITextDiff, DiffLineType, @@ -7,6 +7,7 @@ import { DiffLine, DiffSelection, DiffHunkExpansionType, + DiffSelectionType, } from '../../models/diff' import { getLineFilters, @@ -26,8 +27,15 @@ import { CellMeasurerCache, CellMeasurer, ListRowProps, + OverscanIndicesGetterParams, + defaultOverscanIndicesGetter, } from 'react-virtualized' -import { SideBySideDiffRow } from './side-by-side-diff-row' +import { + CheckBoxIdentifier, + IRowSelectableGroup, + IRowSelectableGroupStaticData, + SideBySideDiffRow, +} from './side-by-side-diff-row' import memoize from 'memoize-one' import { findInteractiveOriginalDiffRange, @@ -47,9 +55,11 @@ import { getNumberOfDigits, MaxIntraLineDiffStringLength, getFirstAndLastClassesSideBySide, + textDiffEquals, + isRowChanged, } from './diff-helpers' import { showContextualMenu } from '../../lib/menu-item' -import { getTokens } from './diff-syntax-mode' +import { getTokens } from './get-tokens' import { DiffSearchInput } from './diff-search-input' import { expandTextDiffHunk, @@ -57,27 +67,44 @@ import { expandWholeTextDiff, } from './text-diff-expansion' import { IMenuItem } from '../../lib/menu-item' -import { HiddenBidiCharsWarning } from './hidden-bidi-chars-warning' -import { escapeRegExp } from 'lodash' +import { DiffContentsWarning } from './diff-contents-warning' +import { findDOMNode } from 'react-dom' +import escapeRegExp from 'lodash/escapeRegExp' +import ReactDOM from 'react-dom' +import { AriaLiveContainer } from '../accessibility/aria-live-container' const DefaultRowHeight = 20 -export interface ISelectionPoint { - readonly column: DiffColumn - readonly row: number -} - export interface ISelection { - readonly from: ISelectionPoint - readonly to: ISelectionPoint + /// Initial diff line number in the selection + readonly from: number + + /// Last diff line number in the selection + readonly to: number + readonly isSelected: boolean } +type SearchDirection = 'next' | 'previous' + type ModifiedLine = { line: DiffLine; diffLineNumber: number } -interface ISideBySideDiffProps { - readonly repository: Repository +const isElement = (n: Node): n is Element => n.nodeType === Node.ELEMENT_NODE +const closestElement = (n: Node): Element | null => + isElement(n) ? n : n.parentElement + +const closestRow = (n: Node, container: Element) => { + const row = closestElement(n)?.closest('div[role=row]') + if (row && container.contains(row)) { + const rowIndex = + row.ariaRowIndex !== null ? parseInt(row.ariaRowIndex, 10) : NaN + return isNaN(rowIndex) ? undefined : rowIndex + } + return undefined +} + +interface ISideBySideDiffProps { /** The file whose diff should be displayed. */ readonly file: ChangedFile @@ -118,6 +145,9 @@ interface ISideBySideDiffProps { */ readonly showSideBySideDiff: boolean + /** Whether or not to show the diff check marks indicating inclusion in a commit */ + readonly showDiffCheckMarks: boolean + /** Called when the user changes the hide whitespace in diffs setting. */ readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void } @@ -142,7 +172,7 @@ interface ISideBySideDiffState { * column is doing it. This allows us to limit text selection to that * specific column via CSS. */ - readonly selectingTextInRow?: 'before' | 'after' + readonly selectingTextInRow: 'before' | 'after' /** * The current diff selection. This is used while @@ -182,6 +212,14 @@ interface ISideBySideDiffState { readonly searchResults?: SearchResults readonly selectedSearchResult: number | undefined + + readonly ariaLiveMessage: string + + /** This tracks the last expanded hunk index so that we can refocus the expander after rerender */ + readonly lastExpandedHunk: { + hunkIndex: number + expansionType: DiffHunkExpansionType + } | null } const listRowsHeightCache = new CellMeasurerCache({ @@ -194,10 +232,34 @@ export class SideBySideDiff extends React.Component< ISideBySideDiffState > { private virtualListRef = React.createRef() + private diffContainer: HTMLDivElement | null = null /** Diff to restore when "Collapse all expanded lines" option is used */ private diffToRestore: ITextDiff | null = null + private textSelectionStartRow: number | undefined = undefined + private textSelectionEndRow: number | undefined = undefined + + private renderedStartIndex: number = 0 + private renderedStopIndex: number | undefined = undefined + + private readonly hunkExpansionRefs = new Map() + + /** + * This is just a signal that will toggle whenever the aria live message + * changes, indicating it should be reannounced by screen readers. + */ + private ariaLiveChangeSignal: boolean = false + + /** + * Caches a group of selectable row's information that does not change on row + * rerender like line numbers using the row's hunkStartLline as the key. + */ + private readonly rowSelectableGroupStaticDataCache = new Map< + number, + IRowSelectableGroupStaticData + >() + public constructor(props: ISideBySideDiffProps) { super(props) @@ -205,6 +267,9 @@ export class SideBySideDiff extends React.Component< diff: props.diff, isSearching: false, selectedSearchResult: undefined, + selectingTextInRow: 'before', + lastExpandedHunk: null, + ariaLiveMessage: '', } } @@ -216,12 +281,147 @@ export class SideBySideDiff extends React.Component< // Listen for the custom event find-text (see app.tsx) // and trigger the search plugin if we see it. document.addEventListener('find-text', this.showSearch) + + document.addEventListener('cut', this.onCutOrCopy) + document.addEventListener('copy', this.onCutOrCopy) + + document.addEventListener('selectionchange', this.onDocumentSelectionChange) + + this.addContextMenuListenerToDiff() + } + + private addContextMenuListenerToDiff = () => { + const diffNode = findDOMNode(this.virtualListRef.current) + const diff = diffNode instanceof HTMLElement ? diffNode : null + diff?.addEventListener('contextmenu', this.onContextMenuText) + } + + private removeContextMenuListenerFromDiff = () => { + const diffNode = findDOMNode(this.virtualListRef.current) + const diff = diffNode instanceof HTMLElement ? diffNode : null + diff?.removeEventListener('contextmenu', this.onContextMenuText) + } + + private onCutOrCopy = (ev: ClipboardEvent) => { + if (ev.defaultPrevented || !this.isEntireDiffSelected()) { + return + } + + const exclude = this.props.showSideBySideDiff + ? this.state.selectingTextInRow === 'before' + ? DiffLineType.Add + : DiffLineType.Delete + : false + + const contents = this.state.diff.hunks + .flatMap(h => h.lines.filter(l => l.type !== exclude).map(l => l.content)) + .join('\n') + + ev.preventDefault() + ev.clipboardData?.setData('text/plain', contents) + } + + private onDocumentSelectionChange = (ev: Event) => { + if (!this.diffContainer) { + return + } + + const selection = document.getSelection() + + this.textSelectionStartRow = undefined + this.textSelectionEndRow = undefined + + if (!selection || selection.isCollapsed) { + return + } + + // Check to see if there's at least a partial selection within the + // diff container. If there isn't then we want to get out of here as + // quickly as possible. + if (!selection.containsNode(this.diffContainer, true)) { + return + } + + if (this.isEntireDiffSelected(selection)) { + return + } + + // Get the range to coerce uniform direction (i.e we don't want to have to + // care about whether the user is selecting right to left or left to right) + const range = selection.getRangeAt(0) + const { startContainer, endContainer } = range + + // The (relative) happy path is when the user is currently selecting within + // the diff. That means that the start container will very likely be a text + // node somewhere within a row. + let startRow = closestRow(startContainer, this.diffContainer) + + // If we couldn't find the row by walking upwards it's likely that the user + // has moved their selection to the container itself or beyond (i.e dragged + // their selection all the way up to the point where they're now selecting + // inside the commit details). + // + // If so we attempt to check if the first row we're currently rendering is + // encompassed in the selection + if (startRow === undefined) { + const firstRow = this.diffContainer.querySelector( + 'div[role=row]:first-child' + ) + + if (firstRow && range.intersectsNode(firstRow)) { + startRow = closestRow(firstRow, this.diffContainer) + } + } + + // If we don't have starting row there's no point in us trying to find + // the end row. + if (startRow === undefined) { + return + } + + let endRow = closestRow(endContainer, this.diffContainer) + + if (endRow === undefined) { + const lastRow = this.diffContainer.querySelector( + 'div[role=row]:last-child' + ) + + if (lastRow && range.intersectsNode(lastRow)) { + endRow = closestRow(lastRow, this.diffContainer) + } + } + + this.textSelectionStartRow = startRow + this.textSelectionEndRow = endRow + } + + private isEntireDiffSelected(selection = document.getSelection()) { + const { diffContainer } = this + + if (selection?.rangeCount === 0) { + return false + } + + const ancestor = selection?.getRangeAt(0).commonAncestorContainer + + // This is an artefact of the selectAllChildren call in the onSelectAll + // handler. We can get away with checking for this since we're handling + // the select-all event coupled with the fact that we have CSS rules which + // prevents text selection within the diff unless focus resides within the + // diff container. + return ancestor === diffContainer } public componentWillUnmount() { window.removeEventListener('keydown', this.onWindowKeyDown) document.removeEventListener('mouseup', this.onEndSelection) document.removeEventListener('find-text', this.showSearch) + document.removeEventListener( + 'selectionchange', + this.onDocumentSelectionChange + ) + document.removeEventListener('mousemove', this.onUpdateSelection) + this.removeContextMenuListenerFromDiff() } public componentDidUpdate( @@ -235,11 +435,10 @@ export class SideBySideDiff extends React.Component< this.clearListRowsHeightCache() } - if (this.props.diff.text !== prevProps.diff.text) { + if (!textDiffEquals(this.props.diff, prevProps.diff)) { this.diffToRestore = null - this.setState({ - diff: this.props.diff, - }) + this.setState({ diff: this.props.diff, lastExpandedHunk: null }) + this.rowSelectableGroupStaticDataCache.clear() } // Scroll to top if we switched to a new file @@ -248,7 +447,110 @@ export class SideBySideDiff extends React.Component< this.props.file.id !== prevProps.file.id ) { this.virtualListRef.current.scrollToPosition(0) + + // Reset selection + this.textSelectionStartRow = undefined + this.textSelectionEndRow = undefined + + if (this.diffContainer) { + const selection = document.getSelection() + if (selection?.containsNode(this.diffContainer, true)) { + selection.empty() + } + } + + this.rowSelectableGroupStaticDataCache.clear() + } + + if (prevProps.showSideBySideDiff !== this.props.showSideBySideDiff) { + this.rowSelectableGroupStaticDataCache.clear() + } + + if (this.state.lastExpandedHunk !== prevState.lastExpandedHunk) { + this.focusAfterLastExpandedHunkChange() + } + } + + private focusListElement = () => { + const diffNode = findDOMNode(this.virtualListRef.current) + const diff = diffNode instanceof HTMLElement ? diffNode : null + diff?.focus() + } + + /** + * This handles app focus after a user has clicked on an diff expansion + * button. With the exception of the top expand up button, the expansion + * buttons disappear after clicking and by default the focus moves to the app + * body. This is not ideal for accessibilty as a keyboard user must then tab + * all the way back to the diff to continut to interact with it. + * + * If an expansion button of the type clicked is available, we focus it. + * Otherwise, we try to find the next closest expansion button and focus that. + * If no expansion buttons available, we focus the diff container. This makes + * it so if a user expands down and can expand down further, they will + * automatically be focused on the next expand down. + * + * Other context: + * - When a user clicks on a diff expansion button, the + * lastExpandedHunk state is updated. In the componentDidUpdate, we detect + * that change in order to call this after the new expansion buttons have + * rendered. The rendered expansion buttons are stored in a map. + * - A hunk index may have multiple expansion buttons (up and down) so it does + * not uniquely identify a button. + */ + private focusAfterLastExpandedHunkChange() { + if (this.state.lastExpandedHunk === null) { + return + } + + // No expansion buttons? Focus the diff + if (this.hunkExpansionRefs.size === 0) { + this.focusListElement() + return + } + + const expansionHunkKeys = Array.from(this.hunkExpansionRefs.keys()).sort() + const { hunkIndex, expansionType } = this.state.lastExpandedHunk + const lastExpandedKey = `${hunkIndex}-${expansionType}` + + // If there is a new hunk expansion button of same type in same place as the + // last, focus it + const lastExpandedHunkButton = this.hunkExpansionRefs.get(lastExpandedKey) + if (lastExpandedHunkButton) { + lastExpandedHunkButton.focus() + return + } + + function getHunkKeyIndex(key: string) { + return parseInt(key.split('-').at(0) || '', 10) + } + + // No?, Then try to focus the next closest hunk in tab order + const closestInTabOrder = expansionHunkKeys.find( + key => getHunkKeyIndex(key) >= hunkIndex + ) + + if (closestInTabOrder) { + const closetHunkButton = this.hunkExpansionRefs.get(closestInTabOrder) + closetHunkButton?.focus() + return + } + + // No? Then try to focus the next closest hunk in reverse tab order + const closestInReverseTabOrder = expansionHunkKeys + .reverse() + .find(key => getHunkKeyIndex(key) <= hunkIndex) + + if (closestInReverseTabOrder) { + const closetHunkButton = this.hunkExpansionRefs.get( + closestInReverseTabOrder + ) + closetHunkButton?.focus() + return } + + // We should never get here, but just in case focus something! + this.focusListElement() } private canExpandDiff() { @@ -260,14 +562,37 @@ export class SideBySideDiff extends React.Component< ) } - public render() { + private onDiffContainerRef = (ref: HTMLDivElement | null) => { + if (ref === null) { + this.diffContainer?.removeEventListener('select-all', this.onSelectAll) + } else { + ref.addEventListener('select-all', this.onSelectAll) + } + this.diffContainer = ref + } + + private getCurrentDiffRows() { const { diff } = this.state - const rows = getDiffRows( + return getDiffRows( diff, this.props.showSideBySideDiff, this.canExpandDiff() ) + } + + private onRowsRendered = (info: { + startIndex: number + stopIndex: number + }) => { + this.renderedStartIndex = info.startIndex + this.renderedStopIndex = info.stopIndex + } + + public render() { + const { diff, ariaLiveMessage, isSearching } = this.state + + const rows = this.getCurrentDiffRows() const containerClassName = classNames('side-by-side-diff-container', { 'unified-diff': !this.props.showSideBySideDiff, [`selecting-${this.state.selectingTextInRow}`]: @@ -277,15 +602,27 @@ export class SideBySideDiff extends React.Component< }) return ( -
- {diff.hasHiddenBidiChars && } - {this.state.isSearching && ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ + {isSearching && ( )} -
+
+ {({ height, width }) => ( )} @@ -317,6 +660,197 @@ export class SideBySideDiff extends React.Component< ) } + private overscanIndicesGetter = (params: OverscanIndicesGetterParams) => { + const [start, end] = [this.textSelectionStartRow, this.textSelectionEndRow] + + if (start === undefined || end === undefined) { + return defaultOverscanIndicesGetter(params) + } + + const startIndex = Math.min(start, params.startIndex) + const stopIndex = Math.max( + params.stopIndex, + Math.min(params.cellCount - 1, end) + ) + + return defaultOverscanIndicesGetter({ ...params, startIndex, stopIndex }) + } + + /** + * Gathers information about if the row is in a selectable group. This + * information is used to facilitate the use of check all feature for the + * selectable group. + * + * This will return null if the row is not in a selectable group. A group is + * more than one row. + */ + private getRowSelectableGroupDetails( + rowIndex: number + ): IRowSelectableGroup | null { + const { diff, hoveredHunk } = this.state + + const rows = getDiffRows( + diff, + this.props.showSideBySideDiff, + this.canExpandDiff() + ) + const row = rows[rowIndex] + + if (row === undefined || !isRowChanged(row)) { + return null + } + + const { hunkStartLine } = row + const staticData = this.getRowSelectableGroupStaticData(hunkStartLine, rows) + const { diffRowStartIndex, diffRowStopIndex } = staticData + + const isFirst = diffRowStartIndex === rowIndex + const isCheckAllRenderedInRow = + isFirst || + (diffRowStartIndex < this.renderedStartIndex && + rowIndex === this.renderedStartIndex) + + return { + isFirst, + isCheckAllRenderedInRow, + isHovered: hoveredHunk === hunkStartLine, + selectionState: this.getSelectableGroupSelectionState( + diff.hunks, + hunkStartLine + ), + height: this.getRowSelectableGroupHeight( + diffRowStartIndex, + diffRowStopIndex + ), + staticData, + } + } + + private getSelectableGroupSelectionState( + hunks: ReadonlyArray, + hunkStartLine: number + ): DiffSelectionType { + const selection = this.getSelection() + if (selection === undefined) { + return DiffSelectionType.None + } + + const range = findInteractiveOriginalDiffRange(hunks, hunkStartLine) + if (range === null) { + //Shouldn't happen, but if it does, we can't do anything with it + return DiffSelectionType.None + } + + const { from, to } = range + + return selection.isRangeSelected(from, to - from + 1) + } + + private getRowSelectableGroupHeight = (from: number, to: number) => { + const start = + from > this.renderedStartIndex ? from : this.renderedStartIndex + + const stop = + this.renderedStopIndex !== undefined && to > this.renderedStopIndex + 10 + ? this.renderedStopIndex + 10 + : to + + let height = 0 + for (let i = start; i <= stop; i++) { + height += this.getRowHeight({ index: i }) + } + + return height + } + + private getSelectableGroupRowIndexRange( + hunkStartLine: number, + rows: ReadonlyArray + ) { + const diffRowStartIndex = rows.findIndex( + r => isRowChanged(r) && r.hunkStartLine === hunkStartLine + ) + + let diffRowStopIndex = diffRowStartIndex + + while ( + rows[diffRowStopIndex + 1] !== undefined && + isRowChanged(rows[diffRowStopIndex + 1]) + ) { + diffRowStopIndex++ + } + + return { + diffRowStartIndex, + diffRowStopIndex, + } + } + + private getRowSelectableGroupStaticData = ( + hunkStartLine: number, + rows: ReadonlyArray + ): IRowSelectableGroupStaticData => { + const cachedStaticData = + this.rowSelectableGroupStaticDataCache.get(hunkStartLine) + if (cachedStaticData !== undefined) { + return cachedStaticData + } + + const { diffRowStartIndex, diffRowStopIndex } = + this.getSelectableGroupRowIndexRange(hunkStartLine, rows) + + const lineNumbers = new Set() + let hasAfter = false + let hasBefore = false + + const groupRows = rows.slice(diffRowStartIndex, diffRowStopIndex + 1) + + const lineNumbersIdentifiers: Array = [] + + for (const r of groupRows) { + if (r.type === DiffRowType.Added) { + lineNumbers.add(r.data.lineNumber) + hasAfter = true + lineNumbersIdentifiers.push(`${r.data.lineNumber}-after`) + } + + if (r.type === DiffRowType.Deleted) { + lineNumbers.add(r.data.lineNumber) + hasBefore = true + lineNumbersIdentifiers.push(`${r.data.lineNumber}-before`) + } + + if (r.type === DiffRowType.Modified) { + hasAfter = true + hasBefore = true + lineNumbers.add(r.beforeData.lineNumber) + lineNumbers.add(r.afterData.lineNumber) + lineNumbersIdentifiers.push( + `${r.beforeData.lineNumber}-before`, + `${r.afterData.lineNumber}-after` + ) + } + } + + const diffType = + hasAfter && hasBefore + ? DiffRowType.Modified + : hasAfter + ? DiffRowType.Added + : DiffRowType.Deleted + + const data: IRowSelectableGroupStaticData = { + diffRowStartIndex, + diffRowStopIndex, + diffType, + lineNumbers: Array.from(lineNumbers).sort(), + lineNumbersIdentifiers, + } + + this.rowSelectableGroupStaticDataCache.set(hunkStartLine, data) + return data + } + private renderRow = ({ index, parent, style, key }: ListRowProps) => { const { diff } = this.state const rows = getDiffRows( @@ -352,8 +886,7 @@ export class SideBySideDiff extends React.Component< const rowWithTokens = this.createFullRow(row, index) - const isHunkHovered = - 'hunkStartLine' in row && this.state.hoveredHunk === row.hunkStartLine + const rowSelectableGroupDetails = this.getRowSelectableGroupDetails(index) return ( -
+
) } + private onLineNumberCheckedChanged = ( + row: number, + column: DiffColumn, + isSelected: boolean + ) => { + if (this.props.onIncludeChanged === undefined) { + return + } + + let selection = this.getSelection() + if (selection === undefined) { + return + } + + const lineBefore = this.getDiffLineNumber(row, column) + const lineAfter = this.getDiffLineNumber(row, column) + + if (lineBefore !== null) { + selection = selection.withLineSelection(lineBefore, isSelected) + } + + if (lineAfter !== null) { + selection = selection.withLineSelection(lineAfter, isSelected) + } + + this.props.onIncludeChanged(selection) + } + + private onHunkExpansionRef = ( + hunkIndex: number, + expansionType: DiffHunkExpansionType, + button: HTMLButtonElement | null + ) => { + const key = `${hunkIndex}-${expansionType}` + if (button === null) { + this.hunkExpansionRefs.delete(key) + } else { + this.hunkExpansionRefs.set(key, button) + } + } + private getRowHeight = (row: { index: number }) => { return listRowsHeightCache.rowHeight(row) ?? DefaultRowHeight } @@ -449,7 +1024,7 @@ export class SideBySideDiff extends React.Component< data: this.getRowDataPopulated( row.data, numRow, - this.props.showSideBySideDiff ? DiffColumn.After : DiffColumn.Before, + DiffColumn.After, this.state.afterTokens ), } @@ -538,8 +1113,6 @@ export class SideBySideDiff extends React.Component< data.diffLineNumber !== null && isInSelection( data.diffLineNumber, - row, - column, this.getSelection(), this.state.temporarySelection ), @@ -592,6 +1165,10 @@ export class SideBySideDiff extends React.Component< return null } + return this.getDiffRowLineNumber(row, column) + } + + private getDiffRowLineNumber(row: SimplifiedDiffRow, column: DiffColumn) { if (row.type === DiffRowType.Added || row.type === DiffRowType.Deleted) { return row.data.diffLineNumber } @@ -635,26 +1212,114 @@ export class SideBySideDiff extends React.Component< } } + private onKeyDown = (event: React.KeyboardEvent) => { + const modifiers = event.altKey || event.metaKey || event.shiftKey + + if (!__DARWIN__ && event.key === 'a' && event.ctrlKey && !modifiers) { + this.onSelectAll(event) + } + } + + /** + * Called when the user presses CtrlOrCmd+A while focused within the diff + * container or when the user triggers the select-all event. Note that this + * deals with text-selection whereas several other methods in this component + * named similarly deals with selection within the gutter. + */ + private onSelectAll = (ev?: Event | React.SyntheticEvent) => { + if (this.diffContainer) { + ev?.preventDefault() + document.getSelection()?.selectAllChildren(this.diffContainer) + } + } + private onStartSelection = ( row: number, column: DiffColumn, isSelected: boolean ) => { - const point: ISelectionPoint = { row, column } - const temporarySelection = { from: point, to: point, isSelected } + const line = this.getDiffLineNumber(row, column) + if (line === null) { + return + } + const temporarySelection = { from: line, to: line, isSelected } this.setState({ temporarySelection }) document.addEventListener('mouseup', this.onEndSelection, { once: true }) + document.addEventListener('mousemove', this.onUpdateSelection) } - private onUpdateSelection = (row: number, column: DiffColumn) => { + private onUpdateSelection = (ev: MouseEvent) => { const { temporarySelection } = this.state - if (temporarySelection === undefined) { + const list = this.virtualListRef.current + if (!temporarySelection || !list) { return } - const to = { row, column } - this.setState({ temporarySelection: { ...temporarySelection, to } }) + const listNode = ReactDOM.findDOMNode(list) + if (!(listNode instanceof Element)) { + return + } + + const rect = listNode.getBoundingClientRect() + const offsetInList = ev.clientY - rect.top + const offsetInListScroll = offsetInList + listNode.scrollTop + + const rows = this.getCurrentDiffRows() + const totalRows = rows.length + + let rowOffset = 0 + + // I haven't found an easy way to calculate which row the mouse is over, + // especially since react-virtualized's `getOffsetForRow` is buggy (see + // https://github.com/bvaughn/react-virtualized/issues/1422). + // Instead, the approach here is to iterate over all rows and sum their + // heights to calculate the offset of each row. Once we find the row that + // contains the mouse, we scroll to it and update the temporary selection. + for (let index = 0; index < totalRows; index++) { + // Use row height cache in order to do the math faster + let height = listRowsHeightCache.getHeight(index, 0) + if (height === undefined) { + list.recomputeRowHeights(index) + height = listRowsHeightCache.getHeight(index, 0) ?? DefaultRowHeight + } + + if ( + offsetInListScroll >= rowOffset && + offsetInListScroll < rowOffset + height + ) { + const row = rows[index] + let column = DiffColumn.Before + + if (this.props.showSideBySideDiff) { + column = + ev.clientX <= rect.left + rect.width / 2 + ? DiffColumn.Before + : DiffColumn.After + } else { + // `column` is irrelevant in unified diff because there aren't rows of + // type Modified (see `getModifiedRows`) + } + const diffLineNumber = this.getDiffRowLineNumber(row, column) + + // Always scroll to the row that contains the mouse, to ease range-based + // selection with it + list.scrollToRow(index) + + if (diffLineNumber !== null) { + this.setState({ + temporarySelection: { + ...temporarySelection, + to: diffLineNumber, + }, + }) + } + + return + } + + rowOffset += height + } } private onEndSelection = () => { @@ -667,20 +1332,11 @@ export class SideBySideDiff extends React.Component< const { from: tmpFrom, to: tmpTo, isSelected } = temporarySelection - const fromRow = Math.min(tmpFrom.row, tmpTo.row) - const toRow = Math.max(tmpFrom.row, tmpTo.row) - - for (let row = fromRow; row <= toRow; row++) { - const lineBefore = this.getDiffLineNumber(row, tmpFrom.column) - const lineAfter = this.getDiffLineNumber(row, tmpTo.column) - - if (lineBefore !== null) { - selection = selection.withLineSelection(lineBefore, isSelected) - } + const fromLine = Math.min(tmpFrom, tmpTo) + const toLine = Math.max(tmpFrom, tmpTo) - if (lineAfter !== null) { - selection = selection.withLineSelection(lineAfter, isSelected) - } + for (let line = fromLine; line <= toLine; line++) { + selection = selection.withLineSelection(line, isSelected) } this.props.onIncludeChanged?.(selection) @@ -697,14 +1353,24 @@ export class SideBySideDiff extends React.Component< this.setState({ hoveredHunk: undefined }) } - private onExpandHunk = (hunkIndex: number, kind: DiffExpansionKind) => { + private onExpandHunk = ( + hunkIndex: number, + expansionType: DiffHunkExpansionType + ) => { const { diff } = this.state if (hunkIndex === -1 || hunkIndex >= diff.hunks.length) { return } + this.setState({ lastExpandedHunk: { hunkIndex, expansionType } }) + + const kind = expansionType === DiffHunkExpansionType.Down ? 'down' : 'up' + this.expandHunk(diff.hunks[hunkIndex], kind) + + this.ariaLiveChangeSignal = !this.ariaLiveChangeSignal + this.setState({ ariaLiveMessage: 'Expanded' }) } private onClickHunk = (hunkStartLine: number, select: boolean) => { @@ -728,9 +1394,19 @@ export class SideBySideDiff extends React.Component< /** * Handler to show a context menu when the user right-clicks on the diff text. */ - private onContextMenuText = () => { + private onContextMenuText = (evt: React.MouseEvent | MouseEvent) => { const selectionLength = window.getSelection()?.toString().length ?? 0 + if ( + evt.target instanceof HTMLElement && + (evt.target.closest('.line-number') !== null || + evt.target.closest('.hunk-handle') !== null || // Windows uses the label element + evt.target.closest('.hunk-expansion-handle') !== null || + evt.target instanceof HTMLInputElement) // macOS users the input element which is adjacent to the .hunk-handle + ) { + return + } + const items: IMenuItem[] = [ { label: 'Copy', @@ -738,6 +1414,10 @@ export class SideBySideDiff extends React.Component< role: selectionLength > 0 ? 'copy' : undefined, enabled: selectionLength > 0, }, + { + label: __DARWIN__ ? 'Select All' : 'Select all', + action: () => this.onSelectAll(), + }, ] const expandMenuItem = this.buildExpandMenuItem() @@ -821,7 +1501,11 @@ export class SideBySideDiff extends React.Component< this.diffToRestore = diff - this.setState({ diff: updatedDiff }) + this.ariaLiveChangeSignal = !this.ariaLiveChangeSignal + this.setState({ + diff: updatedDiff, + ariaLiveMessage: 'Expanded', + }) } private onCollapseExpandedLines = () => { @@ -933,56 +1617,93 @@ export class SideBySideDiff extends React.Component< } } - private onSearch = (searchQuery: string, direction: 'next' | 'previous') => { - let { selectedSearchResult, searchResults: searchResults } = this.state - const { showSideBySideDiff } = this.props - const { diff } = this.state + private onSearch = (searchQuery: string, direction: SearchDirection) => { + const { searchResults } = this.state - // If the query is unchanged and we've got tokens we'll continue, else we'll restart - if (searchQuery === this.state.searchQuery && searchResults !== undefined) { - if (selectedSearchResult === undefined) { - selectedSearchResult = 0 - } else { - const delta = direction === 'next' ? 1 : -1 - - // http://javascript.about.com/od/problemsolving/a/modulobug.htm - selectedSearchResult = - (selectedSearchResult + delta + searchResults.length) % - searchResults.length - } + if (searchQuery?.trim() === '') { + this.resetSearch(true, 'No results') + } else if (searchQuery === this.state.searchQuery && searchResults) { + this.continueSearch(searchResults, direction) } else { - searchResults = calcSearchTokens( - diff, - showSideBySideDiff, - searchQuery, - this.canExpandDiff() - ) - selectedSearchResult = 0 - - if (searchResults === undefined || searchResults.length === 0) { - this.resetSearch(true) - return - } + this.startSearch(searchQuery, direction) } + } + + private startSearch = (searchQuery: string, direction: SearchDirection) => { + const searchResults = calcSearchTokens( + this.state.diff, + this.props.showSideBySideDiff, + searchQuery, + this.canExpandDiff() + ) - const scrollToRow = searchResults.get(selectedSearchResult)?.row + if (searchResults === undefined || searchResults.length === 0) { + this.resetSearch(true, `No results for "${searchQuery}"`) + } else { + const ariaLiveMessage = `Result 1 of ${searchResults.length} for "${searchQuery}"` - if (scrollToRow !== undefined) { - this.virtualListRef.current?.scrollToRow(scrollToRow) + this.scrollToSearchResult(0) + + this.ariaLiveChangeSignal = !this.ariaLiveChangeSignal + + this.setState({ + searchQuery, + searchResults, + selectedSearchResult: 0, + ariaLiveMessage, + }) } + } + + private continueSearch = ( + searchResults: SearchResults, + direction: SearchDirection + ) => { + const { searchQuery } = this.state + let { selectedSearchResult = 0 } = this.state + + const delta = direction === 'next' ? 1 : -1 - this.setState({ searchQuery, searchResults, selectedSearchResult }) + // https://web.archive.org/web/20090717035140if_/javascript.about.com/od/problemsolving/a/modulobug.htm + selectedSearchResult = + (selectedSearchResult + delta + searchResults.length) % + searchResults.length + + const ariaLiveMessage = `Result ${selectedSearchResult + 1} of ${ + searchResults.length + } for "${searchQuery}"` + + this.scrollToSearchResult(selectedSearchResult) + + this.ariaLiveChangeSignal = !this.ariaLiveChangeSignal + this.setState({ + searchResults, + selectedSearchResult, + ariaLiveMessage, + }) } private onSearchCancel = () => { this.resetSearch(false) } - private resetSearch(isSearching: boolean) { + private scrollToSearchResult = (index: number) => { + const { searchResults } = this.state + + const scrollToRow = searchResults?.get(index)?.row + + if (scrollToRow !== undefined) { + this.virtualListRef.current?.scrollToRow(scrollToRow) + } + } + + private resetSearch(isSearching: boolean, searchLiveMessage: string = '') { + this.ariaLiveChangeSignal = !this.ariaLiveChangeSignal this.setState({ selectedSearchResult: undefined, searchQuery: undefined, searchResults: undefined, + ariaLiveMessage: searchLiveMessage, isSearching, }) } @@ -1380,8 +2101,6 @@ function* enumerateColumnContents( function isInSelection( diffLineNumber: number, - row: number, - column: DiffColumn, selection: DiffSelection | undefined, temporarySelection: ISelection | undefined ) { @@ -1391,7 +2110,10 @@ function isInSelection( return isInStoredSelection } - const isInTemporary = isInTemporarySelection(row, column, temporarySelection) + const isInTemporary = isInTemporarySelection( + diffLineNumber, + temporarySelection + ) if (temporarySelection.isSelected) { return isInStoredSelection || isInTemporary @@ -1400,9 +2122,8 @@ function isInSelection( } } -export function isInTemporarySelection( - row: number, - column: DiffColumn, +function isInTemporarySelection( + diffLineNumber: number, selection: ISelection | undefined ): selection is ISelection { if (selection === undefined) { @@ -1410,9 +2131,8 @@ export function isInTemporarySelection( } if ( - row >= Math.min(selection.from.row, selection.to.row) && - row <= Math.max(selection.to.row, selection.from.row) && - (column === selection.from.column || column === selection.to.column) + diffLineNumber >= Math.min(selection.from, selection.to) && + diffLineNumber <= Math.max(selection.to, selection.from) ) { return true } diff --git a/app/src/ui/diff/submodule-diff.tsx b/app/src/ui/diff/submodule-diff.tsx new file mode 100644 index 00000000000..3e524dcaa9f --- /dev/null +++ b/app/src/ui/diff/submodule-diff.tsx @@ -0,0 +1,212 @@ +import React from 'react' +import { parseRepositoryIdentifier } from '../../lib/remote-parsing' +import { ISubmoduleDiff } from '../../models/diff' +import { LinkButton } from '../lib/link-button' +import { Octicon } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import { SuggestedAction } from '../suggested-actions' +import { Ref } from '../lib/ref' +import { CopyButton } from '../copy-button' +import { shortenSHA } from '../../models/commit' + +type SubmoduleItemIcon = + | { + readonly octicon: typeof octicons.info + readonly className: 'info-icon' + } + | { + readonly octicon: typeof octicons.diffModified + readonly className: 'modified-icon' + } + | { + readonly octicon: typeof octicons.diffAdded + readonly className: 'added-icon' + } + | { + readonly octicon: typeof octicons.diffRemoved + readonly className: 'removed-icon' + } + | { + readonly octicon: typeof octicons.fileDiff + readonly className: 'untracked-icon' + } + +interface ISubmoduleDiffProps { + readonly onOpenSubmodule?: (fullPath: string) => void + readonly diff: ISubmoduleDiff + + /** + * Whether the diff is readonly, e.g., displaying a historical diff, or the + * diff's content can be committed, e.g., displaying a change in the working + * directory. + */ + readonly readOnly: boolean +} + +export class SubmoduleDiff extends React.Component { + public constructor(props: ISubmoduleDiffProps) { + super(props) + } + + public render() { + return ( +
+
+
+
+

Submodule changes

+
+
+ {this.renderSubmoduleInfo()} + {this.renderCommitChangeInfo()} + {this.renderSubmodulesChangesInfo()} + {this.renderOpenSubmoduleAction()} +
+
+ ) + } + + private renderSubmoduleInfo() { + if (this.props.diff.url === null) { + return null + } + + const repoIdentifier = parseRepositoryIdentifier(this.props.diff.url) + if (repoIdentifier === null) { + return null + } + + const hostname = + repoIdentifier.hostname === 'github.com' + ? '' + : ` (${repoIdentifier.hostname})` + + return this.renderSubmoduleDiffItem( + { octicon: octicons.info, className: 'info-icon' }, + <> + This is a submodule based on the repository{' '} + + {repoIdentifier.owner}/{repoIdentifier.name} + {hostname} + + . + + ) + } + + private renderCommitChangeInfo() { + const { diff, readOnly } = this.props + const { oldSHA, newSHA } = diff + + const verb = readOnly ? 'was' : 'has been' + const suffix = readOnly + ? '' + : ' This change can be committed to the parent repository.' + + if (oldSHA !== null && newSHA !== null) { + return this.renderSubmoduleDiffItem( + { octicon: octicons.diffModified, className: 'modified-icon' }, + <> + This submodule changed its commit from{' '} + {this.renderCommitSHA(oldSHA, 'previous')} to{' '} + {this.renderCommitSHA(newSHA, 'new')}.{suffix} + + ) + } else if (oldSHA === null && newSHA !== null) { + return this.renderSubmoduleDiffItem( + { octicon: octicons.diffAdded, className: 'added-icon' }, + <> + This submodule {verb} added pointing at commit{' '} + {this.renderCommitSHA(newSHA)}.{suffix} + + ) + } else if (oldSHA !== null && newSHA === null) { + return this.renderSubmoduleDiffItem( + { octicon: octicons.diffRemoved, className: 'removed-icon' }, + <> + This submodule {verb} removed while it was pointing at commit{' '} + {this.renderCommitSHA(oldSHA)}.{suffix} + + ) + } + + return null + } + + private renderCommitSHA(sha: string, which?: 'previous' | 'new') { + const whichInfix = which === undefined ? '' : ` ${which}` + + return ( + <> + {shortenSHA(sha)} + + + ) + } + + private renderSubmodulesChangesInfo() { + const { diff } = this.props + + if (!diff.status.untrackedChanges && !diff.status.modifiedChanges) { + return null + } + + const changes = + diff.status.untrackedChanges && diff.status.modifiedChanges + ? 'modified and untracked' + : diff.status.untrackedChanges + ? 'untracked' + : 'modified' + + return this.renderSubmoduleDiffItem( + { octicon: octicons.fileDiff, className: 'untracked-icon' }, + <> + This submodule has {changes} changes. Those changes must be committed + inside of the submodule before they can be part of the parent + repository. + + ) + } + + private renderSubmoduleDiffItem( + icon: SubmoduleItemIcon, + content: React.ReactElement + ) { + return ( +
+ +
{content}
+
+ ) + } + + private renderOpenSubmoduleAction() { + // If no url is found for the submodule, it means it can't be opened + // This happens if the user is looking at an old commit which references + // a submodule that got later deleted. + if (this.props.diff.url === null) { + return null + } + + return ( + + + + ) + } + + private onOpenSubmoduleClick = () => { + this.props.onOpenSubmodule?.(this.props.diff.fullPath) + } +} diff --git a/app/src/ui/diff/syntax-highlighting/index.ts b/app/src/ui/diff/syntax-highlighting/index.ts index b598c631e8b..0f76c20a34d 100644 --- a/app/src/ui/diff/syntax-highlighting/index.ts +++ b/app/src/ui/diff/syntax-highlighting/index.ts @@ -15,7 +15,6 @@ import { import { Repository } from '../../../models/repository' import { DiffHunk, DiffLineType, DiffLine } from '../../../models/diff' import { getOldPathOrDefault } from '../../../lib/get-old-path' -import { enableTextDiffExpansion } from '../../../lib/feature-flag' /** The maximum number of bytes we'll process for highlighting. */ const MaxHighlightContentLength = 256 * 1024 @@ -66,7 +65,7 @@ async function getOldFileContent( // actually committed to get the appropriate content. commitish = 'HEAD' } else if (file instanceof CommittedFileChange) { - commitish = `${file.commitish}^` + commitish = file.parentCommitish } else { return assertNever(file, 'Unknown file change type') } @@ -107,27 +106,14 @@ async function getNewFileContent( export async function getFileContents( repo: Repository, - file: ChangedFile, - lineFilters: ILineFilters + file: ChangedFile ): Promise { - // If text-diff expansion is enabled, we'll always want to load both the old - // and the new contents, so that we can expand the diff as needed. - const oldContentsPromise = - enableTextDiffExpansion() || lineFilters.oldLineFilter.length - ? getOldFileContent(repo, file) - : Promise.resolve(null) - - const newContentsPromise = - enableTextDiffExpansion() || lineFilters.newLineFilter.length - ? getNewFileContent(repo, file) - : Promise.resolve(null) - const [oldContents, newContents] = await Promise.all([ - oldContentsPromise.catch(e => { + getOldFileContent(repo, file).catch(e => { log.error('Could not load old contents for syntax highlighting', e) return null }), - newContentsPromise.catch(e => { + getNewFileContent(repo, file).catch(e => { log.error('Could not load new contents for syntax highlighting', e) return null }), @@ -138,7 +124,6 @@ export async function getFileContents( oldContents: oldContents?.toString('utf8').split(/\r?\n/) ?? [], newContents: newContents?.toString('utf8').split(/\r?\n/) ?? [], canBeExpanded: - enableTextDiffExpansion() && newContents !== null && newContents.length <= MaxDiffExpansionNewContentLength, } diff --git a/app/src/ui/diff/text-diff-expansion.ts b/app/src/ui/diff/text-diff-expansion.ts index a9b2188f7cd..092426a1a9b 100644 --- a/app/src/ui/diff/text-diff-expansion.ts +++ b/app/src/ui/diff/text-diff-expansion.ts @@ -1,4 +1,3 @@ -import { enableTextDiffExpansion } from '../../lib/feature-flag' import { DiffHunk, DiffHunkExpansionType, @@ -91,10 +90,6 @@ export function getHunkHeaderExpansionType( hunkHeader: DiffHunkHeader, previousHunk: DiffHunk | null ): DiffHunkExpansionType { - if (!enableTextDiffExpansion()) { - return DiffHunkExpansionType.None - } - const distanceToPrevious = previousHunk === null ? Infinity diff --git a/app/src/ui/diff/text-diff.tsx b/app/src/ui/diff/text-diff.tsx deleted file mode 100644 index 0c8305f58ab..00000000000 --- a/app/src/ui/diff/text-diff.tsx +++ /dev/null @@ -1,1581 +0,0 @@ -import * as React from 'react' -import ReactDOM from 'react-dom' -import { clipboard } from 'electron' -import { Editor, Doc, EditorConfiguration } from 'codemirror' - -import { - DiffHunk, - DiffLineType, - DiffSelection, - DiffLine, - ITextDiff, - DiffHunkExpansionType, -} from '../../models/diff' -import { - WorkingDirectoryFileChange, - CommittedFileChange, -} from '../../models/status' - -import { DiffSyntaxMode, IDiffSyntaxModeSpec } from './diff-syntax-mode' -import { CodeMirrorHost } from './code-mirror-host' -import { - diffLineForIndex, - findInteractiveOriginalDiffRange, - lineNumberForDiffLine, - DiffRangeType, - diffLineInfoForIndex, - getLineInOriginalDiff, -} from './diff-explorer' - -import { - getLineFilters, - highlightContents, - IFileContents, -} from './syntax-highlighting' -import { relativeChanges } from './changed-range' -import { Repository } from '../../models/repository' -import memoizeOne from 'memoize-one' -import { structuralEquals } from '../../lib/equality' -import { assertNever } from '../../lib/fatal-error' -import { clamp } from '../../lib/clamp' -import { uuid } from '../../lib/uuid' -import { showContextualMenu } from '../../lib/menu-item' -import { IMenuItem } from '../../lib/menu-item' -import { - canSelect, - getLineWidthFromDigitCount, - getNumberOfDigits, - MaxIntraLineDiffStringLength, -} from './diff-helpers' -import { - expandTextDiffHunk, - DiffExpansionKind, - expandWholeTextDiff, -} from './text-diff-expansion' -import { createOcticonElement } from '../octicons/octicon' -import * as OcticonSymbol from '../octicons/octicons.generated' -import { WhitespaceHintPopover } from './whitespace-hint-popover' -import { PopoverCaretPosition } from '../lib/popover' -import { HiddenBidiCharsWarning } from './hidden-bidi-chars-warning' - -// This is a custom version of the no-newline octicon that's exactly as -// tall as it needs to be (8px) which helps with aligning it on the line. -export const narrowNoNewlineSymbol = { - w: 16, - h: 8, - d: 'm 16,1 0,3 c 0,0.55 -0.45,1 -1,1 l -3,0 0,2 -3,-3 3,-3 0,2 2,0 0,-2 2,0 z M 8,4 C 8,6.2 6.2,8 4,8 1.8,8 0,6.2 0,4 0,1.8 1.8,0 4,0 6.2,0 8,1.8 8,4 Z M 1.5,5.66 5.66,1.5 C 5.18,1.19 4.61,1 4,1 2.34,1 1,2.34 1,4 1,4.61 1.19,5.17 1.5,5.66 Z M 7,4 C 7,3.39 6.81,2.83 6.5,2.34 L 2.34,6.5 C 2.82,6.81 3.39,7 4,7 5.66,7 7,5.66 7,4 Z', -} - -type ChangedFile = WorkingDirectoryFileChange | CommittedFileChange - -/** - * Checks to see if any key parameters in the props object that are used - * when performing highlighting has changed. This is used to determine - * whether highlighting should abort in between asynchronous operations - * due to some factor (like which file is currently selected) have changed - * and thus rendering the in-flight highlighting data useless. - */ -function highlightParametersEqual( - newProps: ITextDiffProps, - prevProps: ITextDiffProps, - newState: ITextDiffState, - prevState: ITextDiffState -) { - return ( - (newProps === prevProps || newProps.file.id === prevProps.file.id) && - newState.diff.text === prevState.diff.text && - prevProps.fileContents?.file.id === newProps.fileContents?.file.id - ) -} - -type SelectionKind = 'hunk' | 'range' - -interface ISelection { - readonly from: number - readonly to: number - readonly kind: SelectionKind - readonly isSelected: boolean -} - -function createNoNewlineIndicatorWidget() { - const widget = document.createElement('span') - const titleId = uuid() - - const { w, h, d } = narrowNoNewlineSymbol - - const xmlns = 'http://www.w3.org/2000/svg' - const svgElem = document.createElementNS(xmlns, 'svg') - svgElem.setAttribute('version', '1.1') - svgElem.setAttribute('viewBox', `0 0 ${w} ${h}`) - svgElem.setAttribute('role', 'img') - svgElem.setAttribute('aria-labelledby', titleId) - svgElem.classList.add('no-newline') - - const titleElem = document.createElementNS(xmlns, 'title') - titleElem.setAttribute('id', titleId) - titleElem.setAttribute('lang', 'en') - titleElem.textContent = 'No newline at end of file' - svgElem.appendChild(titleElem) - - const pathElem = document.createElementNS(xmlns, 'path') - pathElem.setAttribute('role', 'presentation') - pathElem.setAttribute('d', d) - pathElem.textContent = 'No newline at end of file' - svgElem.appendChild(pathElem) - - widget.appendChild(svgElem) - return widget -} - -/** - * Utility function to check whether a selection exists, and whether - * the given index is contained within the selection. - */ -function inSelection(s: ISelection | null, ix: number): s is ISelection { - if (s === null) { - return false - } - return ix >= Math.min(s.from, s.to) && ix <= Math.max(s.to, s.from) -} - -/** Utility function for checking whether an event target has a given CSS class */ -function targetHasClass(target: EventTarget | null, token: string) { - return target instanceof HTMLElement && target.classList.contains(token) -} - -interface ITextDiffProps { - readonly repository: Repository - /** The file whose diff should be displayed. */ - readonly file: ChangedFile - /** The initial diff that should be rendered */ - readonly diff: ITextDiff - /** - * Contents of the old and new files related to the current text diff. - */ - readonly fileContents: IFileContents | null - /** If true, no selections or discards can be done against this diff. */ - readonly readOnly: boolean - /** - * Called when the includedness of lines or a range of lines has changed. - * Only applicable when readOnly is false. - */ - readonly onIncludeChanged?: (diffSelection: DiffSelection) => void - - readonly hideWhitespaceInDiff: boolean - - /** - * Called when the user wants to discard a selection of the diff. - * Only applicable when readOnly is false. - */ - readonly onDiscardChanges?: ( - diff: ITextDiff, - diffSelection: DiffSelection - ) => void - /** - * Whether we'll show a confirmation dialog when the user - * discards changes. - */ - readonly askForConfirmationOnDiscardChanges?: boolean - - /** Called when the user changes the hide whitespace in diffs setting. */ - readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void -} - -interface ITextDiffState { - /** The diff that should be rendered */ - readonly diff: ITextDiff -} - -const diffGutterName = 'diff-gutter' - -function showSearch(cm: Editor) { - const wrapper = cm.getWrapperElement() - - // Is there already a dialog open? If so we'll attempt to move - // focus there instead of opening another dialog since CodeMirror - // doesn't auto-close dialogs when opening a new one. - const existingSearchField = wrapper.querySelector( - ':scope > .CodeMirror-dialog .CodeMirror-search-field' - ) - - if (existingSearchField !== null) { - if (existingSearchField instanceof HTMLElement) { - existingSearchField.focus() - } - return - } - - cm.execCommand('findPersistent') - - const dialog = wrapper.querySelector('.CodeMirror-dialog') - - if (dialog === null) { - return - } - - dialog.classList.add('CodeMirror-search-dialog') - const searchField = dialog.querySelector('.CodeMirror-search-field') - - if (searchField instanceof HTMLInputElement) { - searchField.placeholder = 'Search' - searchField.style.removeProperty('width') - } -} - -/** - * Scroll the editor vertically by either line or page the number - * of times specified by the `step` parameter. - * - * This differs from the moveV function in CodeMirror in that it - * doesn't attempt to scroll by moving the cursor but rather by - * actually changing the scrollTop (if possible). - */ -function scrollEditorVertically(step: number, unit: 'line' | 'page') { - return (cm: Editor) => { - // The magic number 4 here is specific to Desktop and it's - // the extra padding we put around lines (2px below and 2px - // above) - const lineHeight = Math.round(cm.defaultTextHeight() + 4) - const scrollInfo = cm.getScrollInfo() - - if (unit === 'line') { - cm.scrollTo(undefined, scrollInfo.top + step * lineHeight) - } else { - // We subtract one line from the page height to keep som - // continuity when scrolling. Scrolling a full page leaves - // the user without any anchor point - const pageHeight = scrollInfo.clientHeight - lineHeight - cm.scrollTo(undefined, scrollInfo.top + step * pageHeight) - } - } -} - -const defaultEditorOptions: EditorConfiguration = { - lineNumbers: false, - readOnly: true, - showCursorWhenSelecting: false, - cursorBlinkRate: -1, - lineWrapping: true, - mode: { name: DiffSyntaxMode.ModeName }, - // Make sure CodeMirror doesn't capture Tab (and Shift-Tab) and thus destroy tab navigation - extraKeys: { - Tab: false, - Home: 'goDocStart', - End: 'goDocEnd', - 'Shift-Tab': false, - // Steal the default key binding so that we can launch our - // custom search UI. - [__DARWIN__ ? 'Cmd-F' : 'Ctrl-F']: showSearch, - // Disable all other search-related shortcuts so that they - // don't interfere with global app shortcuts. - [__DARWIN__ ? 'Cmd-G' : 'Ctrl-G']: false, // findNext - [__DARWIN__ ? 'Shift-Cmd-G' : 'Shift-Ctrl-G']: false, // findPrev - [__DARWIN__ ? 'Cmd-Alt-F' : 'Shift-Ctrl-F']: false, // replace - [__DARWIN__ ? 'Shift-Cmd-Alt-F' : 'Shift-Ctrl-R']: false, // replaceAll - Down: scrollEditorVertically(1, 'line'), - Up: scrollEditorVertically(-1, 'line'), - PageDown: scrollEditorVertically(1, 'page'), - PageUp: scrollEditorVertically(-1, 'page'), - }, - scrollbarStyle: __DARWIN__ ? 'simple' : 'native', - styleSelectedText: true, - lineSeparator: '\n', - specialChars: - /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200f\u2028\u2029\ufeff]/, - gutters: [diffGutterName], -} - -export class TextDiff extends React.Component { - private codeMirror: Editor | null = null - private whitespaceHintMountId: number | null = null - private whitespaceHintContainer: Element | null = null - - private getCodeMirrorDocument = memoizeOne( - (text: string, noNewlineIndicatorLines: ReadonlyArray) => { - const { mode, firstLineNumber, lineSeparator } = defaultEditorOptions - // If the text looks like it could have been formatted using Windows - // line endings (\r\n) we need to massage it a bit before we hand it - // off to CodeMirror. That's because CodeMirror has two ways of splitting - // lines, one is the built in which splits on \n, \r\n and \r. The last - // one is important because that will match carriage return characters - // inside a diff line. The other way is when consumers supply the - // lineSeparator option. That option only takes a string meaning we can - // either make it split on '\r\n', '\n' or '\r' but not what we would like - // to do, namely '\r?\n'. We want to keep CR characters inside of a diff - // line so that we can mark them using the specialChars attribute so - // we convert all \r\n to \n and remove any trailing \r character. - if (text.indexOf('\r') !== -1) { - // Capture the \r if followed by (positive lookahead) a \n or - // the end of the string. Note that this does not capture the \n. - text = text.replace(/\r(?=\n|$)/g, '') - } - - const doc = new Doc( - text, - mode, - firstLineNumber, - lineSeparator ?? undefined - ) - - for (const noNewlineLine of noNewlineIndicatorLines) { - doc.setBookmark( - { line: noNewlineLine, ch: Infinity }, - { widget: createNoNewlineIndicatorWidget() } - ) - } - - return doc - }, - // Only re-run the memoization function if the text differs or the array - // differs (by structural equality). Allows us to re-use the document as - // much as possible, recreating it only if a no-newline appears/disappears - structuralEquals - ) - - /** - * Returns an array of line numbers that should be marked as lacking a - * new line. Memoized such that even if `hunks` changes we don't have - * to re-run getCodeMirrorDocument needlessly. - */ - private getNoNewlineIndicatorLines = memoizeOne( - (hunks: ReadonlyArray) => { - const lines = new Array() - for (const hunk of hunks) { - for (const line of hunk.lines) { - if (line.noTrailingNewLine) { - lines.push(lineNumberForDiffLine(line, hunks)) - } - } - } - return lines - } - ) - - /** The current, active, diff gutter selection if any */ - private selection: ISelection | null = null - - /** Diff to restore when "Collapse all expanded lines" option is used */ - private diffToRestore: ITextDiff | null = null - - /** Whether a particular range should be highlighted due to hover */ - private hunkHighlightRange: ISelection | null = null - - /** - * When CodeMirror swaps documents it will usually lead to the - * viewportChange event being emitted but there are several scenarios - * where that doesn't happen (where the viewport happens to be the same - * after swapping). We set this field to false whenever we get notified - * that a document is about to get swapped out (`onSwapDoc`), and we set it - * to true on each call to `onViewportChanged` allowing us to check in - * the post-swap event (`onAfterSwapDoc`) whether the document swap - * triggered a viewport change event or not. - * - * This is important because we rely on the viewportChange event to - * know when to update our gutters and by leveraging this field we - * can ensure that we always repaint gutter on each document swap and - * that we only do so once per document swap. - */ - private swappedDocumentHasUpdatedViewport = true - - public constructor(props: ITextDiffProps) { - super(props) - - this.state = { - diff: this.props.diff, - } - } - - private async initDiffSyntaxMode() { - if (!this.codeMirror) { - return - } - - const contents = this.props.fileContents - - if (contents === null) { - return - } - - const { diff: currentDiff } = this.state - - // Store the current props and state to that we can see if anything - // changes from underneath us as we're making asynchronous - // operations that makes our data stale or useless. - const propsSnapshot = this.props - const stateSnapshot = this.state - - const lineFilters = getLineFilters(currentDiff.hunks) - const tsOpt = this.codeMirror.getOption('tabSize') - const tabSize = typeof tsOpt === 'number' ? tsOpt : 4 - - const tokens = await highlightContents(contents, tabSize, lineFilters) - - if ( - !highlightParametersEqual( - this.props, - propsSnapshot, - this.state, - stateSnapshot - ) - ) { - return - } - - const spec: IDiffSyntaxModeSpec = { - name: DiffSyntaxMode.ModeName, - hunks: currentDiff.hunks, - oldTokens: tokens.oldTokens, - newTokens: tokens.newTokens, - } - - if (this.codeMirror) { - this.codeMirror.setOption('mode', spec) - } - } - - /** - * start a selection gesture based on the current interaction - */ - private startSelection( - file: WorkingDirectoryFileChange, - hunks: ReadonlyArray, - index: number, - kind: SelectionKind - ) { - if (this.selection !== null) { - this.cancelSelection() - } - - const indexInOriginalDiff = getLineInOriginalDiff(hunks, index) - if (indexInOriginalDiff === null) { - return - } - - const isSelected = !file.selection.isSelected(indexInOriginalDiff) - - if (this.props.hideWhitespaceInDiff) { - if (file.selection.isSelectable(indexInOriginalDiff)) { - this.mountWhitespaceHint(index) - } - return - } - - if (kind === 'hunk') { - const range = findInteractiveOriginalDiffRange(hunks, index) - if (!range) { - console.error('unable to find range for given line in diff') - return - } - - const { from, to } = range - this.selection = { isSelected, from, to, kind } - } else if (kind === 'range') { - this.selection = { - isSelected, - from: indexInOriginalDiff, - to: indexInOriginalDiff, - kind, - } - document.addEventListener('mousemove', this.onDocumentMouseMove) - } else { - assertNever(kind, `Unknown selection kind ${kind}`) - } - - document.addEventListener('mouseup', this.onDocumentMouseUp, { once: true }) - } - - private cancelSelection() { - if (this.selection) { - document.removeEventListener('mouseup', this.onDocumentMouseUp) - document.removeEventListener('mousemove', this.onDocumentMouseMove) - this.selection = null - this.updateViewport() - } - } - - private onDocumentMouseMove = (ev: MouseEvent) => { - if ( - this.codeMirror === null || - this.selection === null || - this.selection.kind !== 'range' - ) { - return - } - - // CodeMirror can return a line that doesn't exist if the - // pointer is placed underneath the last line so we clamp it - // to the range of valid values. - const max = Math.max(0, this.codeMirror.getDoc().lineCount() - 1) - const index = clamp(this.codeMirror.lineAtHeight(ev.y), 0, max) - - this.codeMirror.scrollIntoView({ line: index, ch: 0 }) - - const to = getLineInOriginalDiff(this.state.diff.hunks, index) - - if (to === null) { - return - } - - if (to !== this.selection.to) { - this.selection = { ...this.selection, to } - this.updateViewport() - } - } - - private onDocumentMouseUp = (ev: MouseEvent) => { - ev.preventDefault() - - // We only care about the primary button here, secondary - // button clicks are handled by `onContextMenu` - if (ev.button !== 0) { - return - } - - if (this.selection === null || this.codeMirror === null) { - return this.cancelSelection() - } - - // A range selection is when the user clicks on the "hunk handle" - // which is a hit area spanning 10 or so pixels on either side of - // the gutter border, extending into the text area. We capture the - // mouse down event on that hunk handle and for the mouse up event - // we need to make sure the user is still within that hunk handle - // section and in the correct range. - if (this.selection.kind === 'hunk') { - const index = this.codeMirror.lineAtHeight(ev.y) - const indexInOriginalDiff = getLineInOriginalDiff( - this.state.diff.hunks, - index - ) - if (indexInOriginalDiff === null) { - return - } - - // Is the pointer over the same range (i.e hunk) that the - // selection was originally started from? - if ( - indexInOriginalDiff === null || - !targetHasClass(ev.target, 'hunk-handle') || - !inSelection(this.selection, indexInOriginalDiff) - ) { - return this.cancelSelection() - } - } else if (this.selection.kind === 'range') { - // Special case drag drop selections of 1 as single line 'click' - // events for which we require that the cursor is still on the - // original gutter element (i.e. if you mouse down on a gutter - // element and move the mouse out of the gutter it should not - // count as a click when you mouse up) - if (this.selection.from === this.selection.to) { - if ( - !targetHasClass(ev.target, 'diff-line-number') && - !targetHasClass(ev.target, 'diff-line-gutter') - ) { - return this.cancelSelection() - } - } - } else { - return assertNever( - this.selection.kind, - `Unknown selection kind ${this.selection.kind}` - ) - } - - this.endSelection() - } - - /** - * complete the selection gesture and apply the change to the diff - */ - private endSelection() { - const { onIncludeChanged, file } = this.props - if (onIncludeChanged && this.selection && canSelect(file)) { - const current = file.selection - const { isSelected } = this.selection - - const lower = Math.min(this.selection.from, this.selection.to) - const upper = Math.max(this.selection.from, this.selection.to) - const length = upper - lower + 1 - - onIncludeChanged(current.withRangeSelection(lower, length, isSelected)) - this.selection = null - } - } - - private isSelectionEnabled = () => { - return this.selection === null - } - - private canExpandDiff() { - const contents = this.props.fileContents - return ( - contents !== null && - contents.canBeExpanded && - contents.newContents.length > 0 - ) - } - - /** Expand a selected hunk. */ - private expandHunk(hunk: DiffHunk, kind: DiffExpansionKind) { - const contents = this.props.fileContents - - if (contents === null || !this.canExpandDiff()) { - return - } - - const updatedDiff = expandTextDiffHunk( - this.state.diff, - hunk, - kind, - contents.newContents - ) - - if (updatedDiff === undefined) { - return - } - - this.setState({ diff: updatedDiff }) - this.updateViewport() - } - - private getAndStoreCodeMirrorInstance = (cmh: CodeMirrorHost | null) => { - this.codeMirror = cmh === null ? null : cmh.getEditor() - this.initDiffSyntaxMode() - } - - private onContextMenu = (instance: CodeMirror.Editor, event: Event) => { - const selectionRanges = instance.getDoc().listSelections() - const isTextSelected = selectionRanges != null - - const action = () => { - this.onCopy(instance, event) - } - - const items: IMenuItem[] = [ - { - label: 'Copy', - action, - enabled: isTextSelected, - }, - ] - - const expandMenuItem = this.buildExpandMenuItem(event) - if (expandMenuItem !== null) { - items.push({ type: 'separator' }, expandMenuItem) - } - - const discardMenuItems = this.buildDiscardMenuItems(instance, event) - if (discardMenuItems !== null) { - items.push({ type: 'separator' }, ...discardMenuItems) - } - - showContextualMenu(items) - } - - private buildExpandMenuItem(event: Event): IMenuItem | null { - if (!this.canExpandDiff()) { - return null - } - - if (!(event instanceof MouseEvent)) { - // We can only infer which line was clicked when the context menu is opened - // via a mouse event. - return null - } - - const diff = this.state.diff - - return this.diffToRestore === null - ? { - label: __DARWIN__ ? 'Expand Whole File' : 'Expand whole file', - action: this.onExpandWholeFile, - // If there is only one hunk that can't be expanded, disable this item - enabled: - diff.hunks.length !== 1 || - diff.hunks[0].expansionType !== DiffHunkExpansionType.None, - } - : { - label: __DARWIN__ - ? 'Collapse Expanded Lines' - : 'Collapse expanded lines', - action: this.onCollapseExpandedLines, - } - } - - private buildDiscardMenuItems( - editor: CodeMirror.Editor, - event: Event - ): ReadonlyArray | null { - const file = this.props.file - - if (this.props.readOnly || !canSelect(file)) { - // Changes can't be discarded in readOnly mode. - return null - } - - if (!(event instanceof MouseEvent)) { - // We can only infer which line was clicked when the context menu is opened - // via a mouse event. - return null - } - - if (!this.props.onDiscardChanges) { - return null - } - - const diff = this.state.diff - const lineNumber = editor.lineAtHeight(event.y) - const diffLine = diffLineForIndex(diff.hunks, lineNumber) - if (diffLine === null || !diffLine.isIncludeableLine()) { - // Do not show the discard options for lines that are not additions/deletions. - return null - } - - const range = findInteractiveOriginalDiffRange(diff.hunks, lineNumber) - if (range === null) { - return null - } - - if (range.type === null) { - return null - } - - // When user opens the context menu from the hunk handle, we should - // discard the range of changes that from that hunk. - if (targetHasClass(event.target, 'hunk-handle')) { - return [ - { - label: this.getDiscardLabel(range.type, range.to - range.from + 1), - action: () => this.onDiscardChanges(file, range.from, range.to), - }, - ] - } - - // When user opens the context menu from a specific line, we should - // discard only that line. - if (targetHasClass(event.target, 'diff-line-number')) { - // We don't allow discarding individual lines on hunks that have both - // added and modified lines, since that can lead to unexpected results - // (e.g discarding the added line on a hunk that is a 1-line modification - // will leave the line deleted). - return [ - { - label: this.getDiscardLabel(range.type, 1), - action: () => this.onDiscardChanges(file, lineNumber), - enabled: range.type !== DiffRangeType.Mixed, - }, - ] - } - - return null - } - - private onDiscardChanges( - file: WorkingDirectoryFileChange, - startLine: number, - endLine: number = startLine - ) { - if (!this.props.onDiscardChanges) { - return - } - - const selection = file.selection - .withSelectNone() - .withRangeSelection(startLine, endLine - startLine + 1, true) - - // Pass the original diff (from props) instead of the (potentially) - // expanded one. - this.props.onDiscardChanges(this.props.diff, selection) - } - - private onExpandWholeFile = () => { - const contents = this.props.fileContents - - if (contents === null || !this.canExpandDiff()) { - return - } - - const updatedDiff = expandWholeTextDiff( - this.state.diff, - contents.newContents - ) - - if (updatedDiff === undefined) { - return - } - - this.diffToRestore = this.state.diff - - this.setState({ diff: updatedDiff }) - this.updateViewport() - } - - private onCollapseExpandedLines = () => { - if (this.diffToRestore === null) { - return - } - - this.setState({ diff: this.diffToRestore }) - this.updateViewport() - - this.diffToRestore = null - } - - private getDiscardLabel(rangeType: DiffRangeType, numLines: number): string { - const suffix = this.props.askForConfirmationOnDiscardChanges ? '…' : '' - let type = '' - - if (rangeType === DiffRangeType.Additions) { - type = __DARWIN__ ? 'Added' : 'added' - } else if (rangeType === DiffRangeType.Deletions) { - type = __DARWIN__ ? 'Removed' : 'removed' - } else if (rangeType === DiffRangeType.Mixed) { - type = __DARWIN__ ? 'Modified' : 'modified' - } else { - assertNever(rangeType, `Invalid range type: ${rangeType}`) - } - - const plural = numLines > 1 ? 's' : '' - return __DARWIN__ - ? `Discard ${type} Line${plural}${suffix}` - : `Discard ${type} line${plural}${suffix}` - } - - private onCopy = (editor: Editor, event: Event) => { - event.preventDefault() - - // Remove the diff line markers from the copied text. The beginning of the - // selection might start within a line, in which case we don't have to trim - // the diff type marker. But for selections that span multiple lines, we'll - // trim it. - const doc = editor.getDoc() - const lines = doc.getSelections() - const selectionRanges = doc.listSelections() - const lineContent: Array = [] - - for (let i = 0; i < lines.length; i++) { - const range = selectionRanges[i] - const content = lines[i] - const contentLines = content.split('\n') - for (const [i, line] of contentLines.entries()) { - if (i === 0 && range.head.ch > 0) { - lineContent.push(line) - } else { - lineContent.push(line.substring(1)) - } - } - - const textWithoutMarkers = lineContent.join('\n') - clipboard.writeText(textWithoutMarkers) - } - } - - private markIntraLineChanges(doc: Doc, hunks: ReadonlyArray) { - for (const hunk of hunks) { - const additions = hunk.lines.filter(l => l.type === DiffLineType.Add) - const deletions = hunk.lines.filter(l => l.type === DiffLineType.Delete) - if (additions.length !== deletions.length) { - continue - } - - for (let i = 0; i < additions.length; i++) { - const addLine = additions[i] - const deleteLine = deletions[i] - if ( - addLine.text.length > MaxIntraLineDiffStringLength || - deleteLine.text.length > MaxIntraLineDiffStringLength - ) { - continue - } - - const changeRanges = relativeChanges( - addLine.content, - deleteLine.content - ) - const addRange = changeRanges.stringARange - if (addRange.length > 0) { - const addLineNumber = lineNumberForDiffLine(addLine, hunks) - if (addLineNumber > -1) { - const addFrom = { - line: addLineNumber, - ch: addRange.location + 1, - } - const addTo = { - line: addLineNumber, - ch: addRange.location + addRange.length + 1, - } - doc.markText(addFrom, addTo, { className: 'cm-diff-add-inner' }) - } - } - - const deleteRange = changeRanges.stringBRange - if (deleteRange.length > 0) { - const deleteLineNumber = lineNumberForDiffLine(deleteLine, hunks) - if (deleteLineNumber > -1) { - const deleteFrom = { - line: deleteLineNumber, - ch: deleteRange.location + 1, - } - const deleteTo = { - line: deleteLineNumber, - ch: deleteRange.location + deleteRange.length + 1, - } - doc.markText(deleteFrom, deleteTo, { - className: 'cm-diff-delete-inner', - }) - } - } - } - } - } - - private onSwapDoc = (cm: Editor, oldDoc: Doc) => { - this.swappedDocumentHasUpdatedViewport = false - this.initDiffSyntaxMode() - this.markIntraLineChanges(cm.getDoc(), this.state.diff.hunks) - } - - /** - * When we swap in a new document that happens to have the exact same number - * of lines as the previous document and where neither of those document - * needs scrolling (i.e the document doesn't extend beyond the visible area - * of the editor) we technically never update the viewport as far as CodeMirror - * is concerned, meaning that we don't get a chance to update our gutters. - * - * By subscribing to the event that happens immediately after the document - * swap has been completed we can check for this condition and others that - * cause the onViewportChange event to not be emitted while swapping documents, - * (see `swappedDocumentHasUpdatedViewport`) and explicitly update the viewport - * (and thereby the gutters). - */ - private onAfterSwapDoc = (cm: Editor, oldDoc: Doc, newDoc: Doc) => { - if (!this.swappedDocumentHasUpdatedViewport) { - this.updateViewport() - } - } - - private onViewportChange = (cm: Editor, from: number, to: number) => { - const doc = cm.getDoc() - const batchedOps = new Array() - - this.swappedDocumentHasUpdatedViewport = true - - const hunks = this.state.diff.hunks - - doc.eachLine(from, to, line => { - const lineNumber = doc.getLineNumber(line) - - if (lineNumber !== null) { - const diffLineInfo = diffLineInfoForIndex(hunks, lineNumber) - - if (diffLineInfo !== null) { - const { hunk, line: diffLine } = diffLineInfo - const lineInfo = cm.lineInfo(line) - - if ( - lineInfo.gutterMarkers && - diffGutterName in lineInfo.gutterMarkers - ) { - const marker = lineInfo.gutterMarkers[diffGutterName] - if (marker instanceof HTMLElement) { - this.updateGutterMarker(marker, hunk, diffLine) - } - } else { - batchedOps.push(() => { - const marker = this.createGutterMarker( - lineNumber, - hunks, - hunk, - diffLine, - getNumberOfDigits(this.state.diff.maxLineNumber) - ) - cm.setGutterMarker(line, diffGutterName, marker) - }) - } - } - } - }) - - // Updating a gutter marker doesn't affect layout or rendering - // as far as CodeMirror is concerned so we only run an operation - // (which will trigger a CodeMirror refresh) when we have gutter - // markers to create. - if (batchedOps.length > 0) { - cm.operation(() => batchedOps.forEach(x => x())) - } - - const diffSize = getLineWidthFromDigitCount( - getNumberOfDigits(this.state.diff.maxLineNumber) - ) - - const gutterParentElement = cm.getGutterElement() - const gutterElement = - gutterParentElement.getElementsByClassName(diffGutterName)[0] - - const newStyle = `width: ${diffSize * 2}px;` - const currentStyle = gutterElement.getAttribute('style') - if (newStyle !== currentStyle) { - gutterElement.setAttribute('style', newStyle) - cm.refresh() - } - } - - /** - * Returns a value indicating whether the given line index is included - * in the current temporary or permanent (props) selection. Note that - * this function does not care about whether the line can be included, - * only whether it is indicated to be included by either selection. - */ - private isIncluded(index: number) { - const { file } = this.props - return inSelection(this.selection, index) - ? this.selection.isSelected - : canSelect(file) && file.selection.isSelected(index) - } - - private getGutterLineClassNameInfo( - hunk: DiffHunk, - diffLine: DiffLine - ): { [className: string]: boolean } { - const isIncludeable = diffLine.isIncludeableLine() - const isIncluded = - isIncludeable && - diffLine.originalLineNumber !== null && - this.isIncluded(diffLine.originalLineNumber) - const hover = - isIncludeable && - diffLine.originalLineNumber !== null && - inSelection(this.hunkHighlightRange, diffLine.originalLineNumber) - - const shouldEnableDiffExpansion = this.canExpandDiff() - - return { - 'diff-line-gutter': true, - 'diff-add': diffLine.type === DiffLineType.Add, - 'diff-delete': diffLine.type === DiffLineType.Delete, - 'diff-context': diffLine.type === DiffLineType.Context, - 'diff-hunk': diffLine.type === DiffLineType.Hunk, - 'read-only': this.props.readOnly, - 'diff-line-selected': isIncluded, - 'diff-line-hover': hover, - 'expandable-down': - shouldEnableDiffExpansion && - hunk.expansionType === DiffHunkExpansionType.Down, - 'expandable-up': - shouldEnableDiffExpansion && - hunk.expansionType === DiffHunkExpansionType.Up, - 'expandable-both': - shouldEnableDiffExpansion && - hunk.expansionType === DiffHunkExpansionType.Both, - 'expandable-short': - shouldEnableDiffExpansion && - hunk.expansionType === DiffHunkExpansionType.Short, - includeable: isIncludeable && !this.props.readOnly, - } - } - - private createGutterMarker( - index: number, - hunks: ReadonlyArray, - hunk: DiffHunk, - diffLine: DiffLine, - digitCount: number - ) { - const diffSize = getLineWidthFromDigitCount(digitCount) - - const marker = document.createElement('div') - marker.style.width = `${diffSize * 2}px` - marker.style.margin = '0px' - marker.className = 'diff-line-gutter' - - marker.addEventListener( - 'mousedown', - this.onDiffLineGutterMouseDown.bind(this, index) - ) - - const oldLineNumber = document.createElement('div') - oldLineNumber.classList.add('diff-line-number', 'before') - oldLineNumber.style.width = `${diffSize}px` - marker.appendChild(oldLineNumber) - - const newLineNumber = document.createElement('div') - newLineNumber.classList.add('diff-line-number', 'after') - newLineNumber.style.width = `${diffSize}px` - marker.appendChild(newLineNumber) - - const hunkHandle = document.createElement('div') - hunkHandle.addEventListener('mouseenter', this.onHunkHandleMouseEnter) - hunkHandle.addEventListener('mouseleave', this.onHunkHandleMouseLeave) - hunkHandle.addEventListener('mousedown', this.onHunkHandleMouseDown) - hunkHandle.classList.add('hunk-handle') - marker.appendChild(hunkHandle) - - if (this.canExpandDiff()) { - const hunkExpandUpHandle = document.createElement('div') - hunkExpandUpHandle.classList.add('hunk-expand-up-handle') - hunkExpandUpHandle.title = 'Expand Up' - hunkExpandUpHandle.addEventListener( - 'click', - this.onHunkExpandHalfHandleMouseDown.bind(this, hunks, hunk, 'up') - ) - marker.appendChild(hunkExpandUpHandle) - - hunkExpandUpHandle.appendChild( - createOcticonElement(OcticonSymbol.foldUp, 'hunk-expand-icon') - ) - - const hunkExpandDownHandle = document.createElement('div') - hunkExpandDownHandle.classList.add('hunk-expand-down-handle') - hunkExpandDownHandle.title = 'Expand Down' - hunkExpandDownHandle.addEventListener( - 'click', - this.onHunkExpandHalfHandleMouseDown.bind(this, hunks, hunk, 'down') - ) - marker.appendChild(hunkExpandDownHandle) - - hunkExpandDownHandle.appendChild( - createOcticonElement(OcticonSymbol.foldDown, 'hunk-expand-icon') - ) - - const hunkExpandWholeHandle = document.createElement('div') - hunkExpandWholeHandle.classList.add('hunk-expand-whole-handle') - hunkExpandWholeHandle.title = 'Expand whole' - hunkExpandWholeHandle.addEventListener( - 'click', - this.onHunkExpandWholeHandleMouseDown.bind(this, hunks, hunk) - ) - marker.appendChild(hunkExpandWholeHandle) - - hunkExpandWholeHandle.appendChild( - createOcticonElement( - OcticonSymbol.foldDown, - 'hunk-expand-icon', - 'hunk-expand-down-icon' - ) - ) - - hunkExpandWholeHandle.appendChild( - createOcticonElement( - OcticonSymbol.foldUp, - 'hunk-expand-icon', - 'hunk-expand-up-icon' - ) - ) - - hunkExpandWholeHandle.appendChild( - createOcticonElement( - OcticonSymbol.unfold, - 'hunk-expand-icon', - 'hunk-expand-short-icon' - ) - ) - } - - this.updateGutterMarker(marker, hunk, diffLine) - - return marker - } - - private onHunkExpandWholeHandleMouseDown = ( - hunks: ReadonlyArray, - hunk: DiffHunk, - ev: MouseEvent - ) => { - // If the event is prevented that means the hunk handle was - // clicked first and prevented the default action so we'll bail. - if (ev.defaultPrevented || this.codeMirror === null) { - return - } - - // We only care about the primary button here, secondary - // button clicks are handled by `onContextMenu` - if (ev.button !== 0) { - return - } - - ev.preventDefault() - - // This code is invoked when the user clicks a hunk line gutter that is - // not splitted in half, meaning it can only be expanded either up or down - // (or the distance between hunks is too short it doesn't matter). It - // won't be invoked when the user can choose to expand it up or down. - // - // With that in mind, in those situations, we'll ALWAYS expand the hunk - // up except when it's the last "dummy" hunk we placed to allow expanding - // the diff from the bottom. In that case, we'll expand the second-to-last - // hunk down. - if ( - hunk.lines.length === 1 && - hunks.length > 1 && - hunk === hunks[hunks.length - 1] - ) { - const previousHunk = hunks[hunks.length - 2] - this.expandHunk(previousHunk, 'down') - } else { - this.expandHunk(hunk, 'up') - } - } - - private onHunkExpandHalfHandleMouseDown = ( - hunks: ReadonlyArray, - hunk: DiffHunk, - kind: DiffExpansionKind, - ev: MouseEvent - ) => { - if (!this.codeMirror) { - return - } - - // We only care about the primary button here, secondary - // button clicks are handled by `onContextMenu` - if (ev.button !== 0) { - return - } - - ev.preventDefault() - - // This code is run when the user clicks on a hunk header line gutter that - // is split in two, meaning you can expand up or down the gap the line is - // located. - // Expanding it up will basically expand *up* the hunk to which that line - // belongs, as expected. - // Expanding that gap down, however, will expand *down* the hunk that is - // located right above this one. - if (kind === 'down') { - const hunkIndex = hunks.indexOf(hunk) - if (hunkIndex > 0) { - const previousHunk = hunks[hunkIndex - 1] - this.expandHunk(previousHunk, 'down') - } - } else { - this.expandHunk(hunk, 'up') - } - } - - private updateGutterMarker( - marker: HTMLElement, - hunk: DiffHunk, - diffLine: DiffLine - ) { - const classNameInfo = this.getGutterLineClassNameInfo(hunk, diffLine) - for (const [className, include] of Object.entries(classNameInfo)) { - if (include) { - marker.classList.add(className) - } else { - marker.classList.remove(className) - } - } - - const hunkExpandWholeHandle = marker.getElementsByClassName( - 'hunk-expand-whole-handle' - )[0] - if (hunkExpandWholeHandle !== undefined) { - if (classNameInfo['expandable-short'] === true) { - hunkExpandWholeHandle.setAttribute('title', 'Expand All') - } else if (classNameInfo['expandable-both'] !== true) { - if (classNameInfo['expandable-down']) { - hunkExpandWholeHandle.setAttribute('title', 'Expand Down') - } else { - hunkExpandWholeHandle.setAttribute('title', 'Expand Up') - } - } - } - - const isIncludeableLine = - !this.props.readOnly && diffLine.isIncludeableLine() - - if (diffLine.type === DiffLineType.Hunk || isIncludeableLine) { - marker.setAttribute('role', 'button') - } else { - marker.removeAttribute('role') - } - - const [oldLineNumber, newLineNumber] = marker.childNodes - oldLineNumber.textContent = `${diffLine.oldLineNumber ?? ''}` - newLineNumber.textContent = `${diffLine.newLineNumber ?? ''}` - } - - private onHunkHandleMouseEnter = (ev: MouseEvent) => { - if ( - this.codeMirror === null || - this.props.readOnly || - (this.selection !== null && this.selection.kind === 'range') - ) { - return - } - const lineNumber = this.codeMirror.lineAtHeight(ev.y) - const hunks = this.state.diff.hunks - const diffLine = diffLineForIndex(hunks, lineNumber) - - if (!diffLine || !diffLine.isIncludeableLine()) { - return - } - - const range = findInteractiveOriginalDiffRange(hunks, lineNumber) - - if (range === null) { - return - } - - const { from, to } = range - - this.hunkHighlightRange = { from, to, kind: 'hunk', isSelected: false } - this.updateViewport() - } - - private updateViewport() { - if (this.codeMirror) { - const { from, to } = this.codeMirror.getViewport() - this.onViewportChange(this.codeMirror, from, to) - } - } - - private onDiffLineGutterMouseDown = (index: number, ev: MouseEvent) => { - // If the event is prevented that means the hunk handle was - // clicked first and prevented the default action so we'll bail. - if (ev.defaultPrevented || this.codeMirror === null) { - return - } - - // We only care about the primary button here, secondary - // button clicks are handled by `onContextMenu` - if (ev.button !== 0) { - return - } - - const { file, readOnly } = this.props - const diff = this.state.diff - - if (!canSelect(file) || readOnly) { - return - } - - ev.preventDefault() - - this.startSelection(file, diff.hunks, index, 'range') - } - - private onHunkHandleMouseLeave = (ev: MouseEvent) => { - if (this.hunkHighlightRange !== null) { - this.hunkHighlightRange = null - this.updateViewport() - } - } - - private onHunkHandleMouseDown = (ev: MouseEvent) => { - if (!this.codeMirror) { - return - } - - // We only care about the primary button here, secondary - // button clicks are handled by `onContextMenu` - if (ev.button !== 0) { - return - } - - const { file, readOnly } = this.props - const diff = this.state.diff - - if (!canSelect(file) || readOnly) { - return - } - - ev.preventDefault() - - const lineNumber = this.codeMirror.lineAtHeight(ev.y) - this.startSelection(file, diff.hunks, lineNumber, 'hunk') - } - - public componentWillUnmount() { - this.cancelSelection() - this.unmountWhitespaceHint() - this.codeMirror = null - document.removeEventListener('find-text', this.onFindText) - } - - private mountWhitespaceHint(index: number) { - this.unmountWhitespaceHint() - - // Since we're in a bit of a weird state here where CodeMirror is mounted - // through React and we're in turn mounting a React component from a - // DOM event we want to make sure we're not mounting the Popover - // synchronously. Doing so will cause the popover to receiving the bubbling - // mousedown event (on document) which caused it to be mounted in the first - // place and it will then close itself thinking that it's seen a mousedown - // event outside of its container. - this.whitespaceHintMountId = requestAnimationFrame(() => { - this.whitespaceHintMountId = null - const cm = this.codeMirror - - if (cm === null) { - return - } - - const container = document.createElement('div') - container.style.position = 'absolute' - const scroller = cm.getScrollerElement() - - const diffSize = getLineWidthFromDigitCount( - getNumberOfDigits(this.state.diff.maxLineNumber) - ) - - const lineY = cm.heightAtLine(index, 'local') - // We're positioning relative to the scroll container, not the - // sizer or lines so we'll have to account for the gutter width and - // the hunk handle. - const style: React.CSSProperties = { left: diffSize * 2 + 10 } - let caretPosition = PopoverCaretPosition.LeftTop - - // Offset down by 10px to align the popover arrow. - container.style.top = `${lineY - 10}px` - - // If the line is further than 50% down the viewport we'll flip the - // popover to point upwards so as to not get hidden beneath (or above) - // the scroll boundary. - if (lineY - scroller.scrollTop > scroller.clientHeight / 2) { - caretPosition = PopoverCaretPosition.LeftBottom - style.bottom = -35 - } - - scroller.appendChild(container) - this.whitespaceHintContainer = container - - ReactDOM.render( - , - container - ) - }) - } - - private unmountWhitespaceHint = () => { - if (this.whitespaceHintMountId !== null) { - cancelAnimationFrame(this.whitespaceHintMountId) - this.whitespaceHintMountId = null - } - - // Note that unmountComponentAtNode may cause a reentrant call to this - // method by means of the Popover onDismissed callback. This is why we can't - // trust that whitespaceHintContainer remains non-null after this. - if (this.whitespaceHintContainer !== null) { - ReactDOM.unmountComponentAtNode(this.whitespaceHintContainer) - } - - if (this.whitespaceHintContainer !== null) { - this.whitespaceHintContainer.remove() - this.whitespaceHintContainer = null - } - } - - // eslint-disable-next-line react-proper-lifecycle-methods - public componentDidUpdate( - prevProps: ITextDiffProps, - prevState: ITextDiffState, - snapshot: CodeMirror.ScrollInfo | null - ) { - if (this.codeMirror === null) { - return - } - - if (!isSameFileContents(this.props.fileContents, prevProps.fileContents)) { - this.initDiffSyntaxMode() - } - - if (canSelect(this.props.file)) { - if ( - !canSelect(prevProps.file) || - this.props.file.selection !== prevProps.file.selection - ) { - // If the text has changed the gutters will be recreated - // regardless but if it hasn't then we'll need to update - // the viewport. - if (this.props.diff.text === prevProps.diff.text) { - this.updateViewport() - } - } - } - - if (this.props.diff.text !== prevProps.diff.text) { - this.diffToRestore = null - this.setState({ - diff: this.props.diff, - }) - } - - if (snapshot !== null) { - this.codeMirror.scrollTo(undefined, snapshot.top) - } - - // Scroll to top if we switched to a new file - if (this.props.file.id !== prevProps.file.id) { - this.codeMirror.scrollTo(undefined, 0) - } - } - - public getSnapshotBeforeUpdate( - prevProps: ITextDiffProps, - prevState: ITextDiffState - ) { - // Store the scroll position when the file stays the same - // but we probably swapped out the document - if ( - this.codeMirror !== null && - ((this.props.file !== prevProps.file && - this.props.file.id === prevProps.file.id && - this.props.diff.text !== prevProps.diff.text) || - this.state.diff.text !== prevState.diff.text) - ) { - return this.codeMirror.getScrollInfo() - } - return null - } - - public componentDidMount() { - // Listen for the custom event find-text (see app.tsx) - // and trigger the search plugin if we see it. - document.addEventListener('find-text', this.onFindText) - } - - private onFindText = (ev: Event) => { - if (!ev.defaultPrevented && this.codeMirror) { - ev.preventDefault() - showSearch(this.codeMirror) - } - } - - public render() { - const { diff } = this.state - const doc = this.getCodeMirrorDocument( - diff.text, - this.getNoNewlineIndicatorLines(this.state.diff.hunks) - ) - - return ( - <> - {diff.hasHiddenBidiChars && } - - - ) - } -} - -function isSameFileContents(x: IFileContents | null, y: IFileContents | null) { - return x?.newContents === y?.newContents && x?.oldContents === y?.oldContents -} diff --git a/app/src/ui/diff/whitespace-hint-popover.tsx b/app/src/ui/diff/whitespace-hint-popover.tsx index e07532941d2..b9d1d65c7ca 100644 --- a/app/src/ui/diff/whitespace-hint-popover.tsx +++ b/app/src/ui/diff/whitespace-hint-popover.tsx @@ -1,31 +1,35 @@ import * as React from 'react' import { Popover, - PopoverCaretPosition, + PopoverAnchorPosition, PopoverAppearEffect, + PopoverDecoration, } from '../lib/popover' import { OkCancelButtonGroup } from '../dialog' interface IWhitespaceHintPopoverProps { - readonly caretPosition: PopoverCaretPosition + readonly anchor: HTMLElement | null + readonly anchorPosition: PopoverAnchorPosition /** Called when the user changes the hide whitespace in diffs setting. */ readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void readonly onDismissed: () => void - readonly style: React.CSSProperties } export class WhitespaceHintPopover extends React.Component { public render() { return ( -

Show whitespace changes?

-

+

Show whitespace changes?

+

Selecting lines is disabled when hiding whitespace changes.