diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..996ecf5cc9 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +# These folders contain scripts to enhance the code coverage reports and are +# already ignored by .gitignore, but because they live under app and script they +# might be included when linting +node_modules +app/node_modules diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000000..615c247693 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,222 @@ +root: true +parser: '@typescript-eslint/parser' +plugins: + - '@typescript-eslint' + - react + - json + - jsdoc + +settings: + react: + version: '16.3' + +extends: + - prettier + - prettier/react + - plugin:@typescript-eslint/recommended + - prettier/@typescript-eslint + - plugin:github/react + +rules: + ########## + # CUSTOM # + ########## + insecure-random: error + react-no-unbound-dispatcher-props: error + react-readonly-props-and-state: error + react-proper-lifecycle-methods: error + no-loosely-typed-webcontents-ipc: error + + ########### + # PLUGINS # + ########### + + # TYPESCRIPT + '@typescript-eslint/naming-convention': + - error + - selector: interface + format: + - PascalCase + custom: + regex: '^I[A-Z]' + match: true + - selector: class + format: + - PascalCase + - selector: variableLike + format: null + custom: + # Based on https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar we + # should probably be using the following expression here (newlines added for readability) + # + # ^(break|case|catch|class|const|continue|debugger|default|delete|do|else|export| + # extends|finally|for|function|if|import|in|instanceof|new|return|super|switch|this| + # throw|try|typeof|var|void|while|with|yield|enum|implements|interface|let|package| + # private|protected|public|static|await|abstract|boolean|byte|char|double|final|float| + # goto|int|long|native|short|synchronized|throws|transient|volatile|null|true|false)$ + # + # But that'll cause a bunch of errors, for now we'll stick with replicating what the + # variable-name ban-keywords rule did for us in tslint + # see https://palantir.github.io/tslint/rules/variable-name/ + regex: '^(any|Number|number|String|string|Boolean|boolean|Undefined|undefined)$' + match: false + '@typescript-eslint/consistent-type-assertions': + - error + - assertionStyle: 'as' + '@typescript-eslint/no-unused-expressions': error + '@typescript-eslint/explicit-member-accessibility': error + '@typescript-eslint/no-unused-vars': + - error + - args: 'none' + '@typescript-eslint/no-use-before-define': + - error + - functions: false + variables: false + typedefs: false + '@typescript-eslint/member-ordering': + - error + - default: + - static-field + - static-method + - field + - abstract-method + - constructor + - method + '@typescript-eslint/no-extraneous-class': error + '@typescript-eslint/no-empty-interface': error + # Would love to be able to turn this on eventually + '@typescript-eslint/no-non-null-assertion': off + + # This rule does a lot of good but right now it catches way + # too many cases, we're gonna want to pay down this debt + # incrementally if we want to enable it. + '@typescript-eslint/ban-types': off + + # It'd be nice to be able to turn this on eventually + '@typescript-eslint/no-var-requires': off + + # Don't particularly care about these + '@typescript-eslint/triple-slash-reference': off + '@typescript-eslint/explicit-module-boundary-types': off + '@typescript-eslint/no-explicit-any': off + '@typescript-eslint/no-inferrable-types': off + '@typescript-eslint/no-empty-function': off + '@typescript-eslint/no-redeclare': error + + # React + react/jsx-boolean-value: + - error + - always + react/jsx-key: error + react/jsx-no-bind: error + react/no-string-refs: error + react/jsx-uses-vars: error + react/jsx-uses-react: error + react/no-unused-state: error + react/no-unused-prop-types: error + react/prop-types: + - error + - ignore: ['children'] + + # JSDoc + jsdoc/check-alignment: error + jsdoc/check-tag-names: error + jsdoc/check-types: error + jsdoc/implements-on-classes: error + jsdoc/tag-lines: + - error + - any + - startLines: 1 + jsdoc/no-undefined-types: error + jsdoc/valid-types: error + + # Would love to enable these at some point but + # they cause way to many issues now. + #jsdoc/check-param-names: error + #jsdoc/require-jsdoc: + # - error + # - publicOnly: true + + ########### + # BUILTIN # + ########### + curly: error + no-new-wrappers: error + # We'll use no-redeclare from @typescript/eslint-plugin instead as that + # supports overloads + no-redeclare: off + no-eval: error + no-sync: error + no-var: error + prefer-const: error + eqeqeq: + - error + - smart + strict: + - error + - global + no-buffer-constructor: error + no-restricted-imports: + - error + - paths: + - name: electron + importNames: ['ipcRenderer'] + message: + "Please use 'import * as ipcRenderer' from 'ipc-renderer' instead to + get strongly typed IPC methods." + - name: electron/renderer + importNames: ['ipcRenderer'] + message: + "Please use 'import * as ipcRenderer' from 'ipc-renderer' instead to + get strongly typed IPC methods." + - name: electron + importNames: ['ipcMain'] + message: + "Please use 'import * as ipcMain' from 'ipc-main' instead to get + strongly typed IPC methods." + - name: electron/main + importNames: ['ipcMain'] + message: + "Please use 'import * as ipcMain' from 'ipc-main' instead to get + strongly typed IPC methods." + + ########### + # SPECIAL # + ########### + no-restricted-syntax: + - error + # no-default-export + - 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: + strict: + - error + - never + - files: 'app/test/**/*' + rules: + '@typescript-eslint/no-non-null-assertion': off + - files: 'script/**/*' + rules: + '@typescript-eslint/no-non-null-assertion': off + +parserOptions: + sourceType: module + ecmaFeatures: + jsx: true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..94c18f9468 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/app/test/fixtures/** -text +/app/static/win32/github.sh eol=lf diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000000..d10ac1b2c5 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,148 @@ +# Contributing to GitHub Desktop + +:+1: :tada: :sparkling_heart: Thanks for your interest! :sparkling_heart: :tada: :+1: + +The following is a set of guidelines for contributing to GitHub Desktop and its +related projects, which are hosted in the [Desktop organization](https://github.com/desktop) +on GitHub. These are just guidelines, not rules. Use your best judgment, and +feel free to propose changes to this document in a pull request. + +Note that GitHub Desktop is an evolving project, so expect things to change over +time as the team learns, listens and refines how we work with the community. + +#### Table Of Contents + +- [What should I know before I get started?](#what-should-i-know-before-i-get-started) + * [Code of Conduct](#code-of-conduct) + * [The Roadmap](#the-roadmap) + +- [How Can I Contribute?](#how-can-i-contribute) + * [Reporting Bugs](#reporting-bugs) + * [Suggesting Enhancements](#suggesting-enhancements) + * [Help Wanted](#help-wanted) + +- [Process Documentation](#process-documentation) + +## What should I know before I get started? + +### Code of Conduct + +This project adheres to the Contributor Covenant [code of conduct](../CODE_OF_CONDUCT.md). +By participating, you are expected to uphold this code. +Please report unacceptable behavior to [opensource+desktop@github.com](mailto:opensource+desktop@github.com). + +### The Roadmap + +We are working on a roadmap you can read [here](https://github.com/desktop/desktop/blob/development/docs/process/roadmap.md). +The immediate milestones are more detailed, and the latter milestones are more +fuzzy and subject to change. + +If you have ideas or suggestions please read the +[Suggesting Enhancements](#suggesting-enhancements) section below to understand +how to contribute your feedback. + +## How Can I Contribute? + +### Reporting Bugs + +This section guides you through submitting a bug report for GitHub Desktop. +Following these guidelines helps maintainers and the community understand your +report :pencil:, reproduce the behavior :computer: :computer:, and find related +reports :mag_right:. + +Before creating bug reports, please check [this list](#before-submitting-a-bug-report) +as you might find out that you don't need to create one. When you are creating +a bug report, please [include as many details as possible](#how-do-i-submit-a-good-bug-report). +Fill out the required template, the information it asks for helps us resolve issues faster. + +#### Before Submitting A Bug Report + +**Perform a [cursory search](https://github.com/desktop/desktop/labels/bug)** +to see if the problem has already been reported. If it does exist, add a +:thumbsup: to the issue to indicate this is also an issue for you, and add a +comment to the existing issue if there is extra information you can contribute. + +#### How Do I Submit A Bug Report? + +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) +and fill out the provided issue template. + +The information we are interested in includes: + + - details about your environment - which build, which operating system + - details about reproducing the issue - what steps to take, what happens, how + often it happens + - other relevant information - log files, screenshots, etc + +### Suggesting Enhancements + +This section guides you through submitting an enhancement suggestion for +GitHub Desktop, including completely new features and minor improvements to +existing functionality. Following these guidelines helps maintainers and the +community understand your suggestion :pencil: and find related suggestions +:mag_right:. + +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 +that you imagine you would take if the feature you're requesting existed. + +#### Before Submitting An Enhancement Suggestion + +**Perform a [cursory search](https://github.com/desktop/desktop/labels/enhancement)** +to see if the enhancement has already been suggested. If it has, add a +:thumbsup: to indicate your interest in it, or comment if there is additional +information you would like to add. + +#### How Do I Submit An Enhancement Suggestion? + +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) +and fill out the provided issue template. + +Some additional advice: + +* **Use a clear and descriptive title** for the feature request +* **Provide a step-by-step description of the suggested enhancement** + This additional context helps the maintainers understand the enhancement from + your perspective +* **Explain why this enhancement would be useful** to GitHub Desktop users +* **Include screenshots and animated GIFs** if relevant to help you demonstrate + the steps or point out the part of GitHub Desktop which the suggestion is + related to. You can use [this tool](http://www.cockos.com/licecap/) to record + GIFs on macOS and Windows +* **List some other applications where this enhancement exists, if applicable** + +### Help Wanted + +As part of building GitHub Desktop, we'll identify tasks that are good for +external contributors to pick up. These tasks: + + - have low impact, or have a known workaround + - should be addressed + - have a narrow scope and/or easy reproduction steps + - can be worked on independent of other tasks + +These issues will be labelled as [`help wanted`](https://github.com/desktop/desktop/labels/help%20wanted) +in the repository. If you are interested in contributing to the project, please +comment on the issue to let the core team (and the community) know you are +interested in the issue. + +### Set Up Your Machine + +Start [here](https://github.com/desktop/desktop/blob/development/docs/contributing/setup.md). + + +## Process Documentation + +These documents are useful resources for contributors to learn more about the project and how it is run: + + - [Teams](https://github.com/desktop/desktop/blob/development/docs/process/teams.md) + - [Release Planning](https://github.com/desktop/desktop/blob/development/docs/process/release-planning.md) + - [Issue Triage](https://github.com/desktop/desktop/blob/development/docs/process/issue-triage.md) + - [Issue and Pull Request Labels](https://github.com/desktop/desktop/blob/development/docs/process/labels.md) + - [Pull Requests](https://github.com/desktop/desktop/blob/development/docs/process/pull-requests.md) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000000..f71cfe29e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,66 @@ +name: 🐛 Bug Report +description: File a bug report +body: + - type: markdown + attributes: + value: | + Thanks for filing a bug report! This issue tracker is for [GitHub Desktop](https://desktop.github.com). Please search the issue tracker to see if there is an existing issue for the problem you are experiencing. If you are experiencing issues with the Linux fork of GitHub Desktop please open an issue [in its repository](https://github.com/shiftkey/desktop). If you are experiencing issues with github.com please [contact GitHub Support](https://support.github.com/). + - type: textarea + id: the-problem + attributes: + label: The problem + description: + Describe the issue you are experiencing with GitHub Desktop. Provide a + clear and concise description of what you were trying to do and what + happened, along with any error messages you encountered. + validations: + required: true + - type: input + id: version + attributes: + label: Release version + description: + Open the 'About GitHub Desktop' menu item to see the GitHub Desktop + version. + validations: + required: true + - type: input + id: operating-system + attributes: + label: Operating system + description: + 'Enter the specific operating system version you are running (example: + Windows 10)' + validations: + required: true + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to reproduce the behavior + description: Provide steps to reproduce the problem you are experiencing. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + - type: textarea + id: logs + attributes: + label: Log files + description: + Please upload a log file from a day you experienced the issue. To access + the log files go to the menu and select `Help` > `Show Logs`. + placeholder: + You can attach log files by clicking this area to highlight it and then + dragging the files in. + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Add screenshots to help explain your problem, if applicable. + - type: textarea + id: additional-context + attributes: + label: Additional context + description: + Add any other context about the problem you are experiencing here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000000..8f9ab26a3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,29 @@ +name: ⭐ Feature Request +description: Submit a feature request +body: + - type: markdown + attributes: + value: | + Thanks for submitting a feature request! This issue tracker is for [GitHub Desktop](https://desktop.github.com). Please search the issue tracker to see if there is an existing issue for the feature you are requesting. If you have a feature request for github.com please visit the [GitHub public feedback discussions repository](https://github.com/github/feedback/discussions). + - type: textarea + id: the-feature-request + attributes: + label: The feature request + description: + Write a clear and concise description of what the feature or problem is. + validations: + required: true + - type: textarea + id: proposed-solution + attributes: + label: Proposed solution + description: Share how this will benefit GitHub Desktop and its users. + validations: + required: true + - type: textarea + id: additional-context + attributes: + label: Additional context + description: + Please include any other context, like screenshots or mockups, if + applicable. diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000..9365e64eff --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,4 @@ +name: 'GitHub Desktop CodeQL config' + +paths-ignore: + - 'vendor' diff --git a/.github/config.yml b/.github/config.yml new file mode 100644 index 0000000000..2d559a49f3 --- /dev/null +++ b/.github/config.yml @@ -0,0 +1,22 @@ +# Configuration for request-info - https://github.com/behaviorbot/request-info + +# *Required* Comment to reply with +requestInfoReplyComment: > + Thanks for reaching out! + + We require the + [template](https://github.com/desktop/desktop/blob/development/.github/CONTRIBUTING.md#how-do-i-submit-a-good-bug-report) + to be filled out with all new issues. We do this so that we can be certain we + have all the information we need to address your submission efficiently. This + allows the maintainers to spend more time fixing bugs, implementing + enhancements, and reviewing and merging pull requests. + + Thanks for understanding and meeting us halfway. 😀 + +requestInfoLabelToAdd: more-info-needed + +requestInfoOn: + pullRequest: false + issue: true + +checkIssueTemplate: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..ca79ca5b4d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..f99b109d58 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,26 @@ + + +Closes #[issue number] + +## Description + +- + +### Screenshots + + + +## Release notes + + + +Notes: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..5f4ba55da4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,141 @@ +name: CI + +on: + push: + branches: + - development + 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 + secrets: + DESKTOP_OAUTH_CLIENT_ID: + DESKTOP_OAUTH_CLIENT_SECRET: + APPLE_ID: + APPLE_ID_PASSWORD: + APPLE_APPLICATION_CERT: + APPLE_APPLICATION_CERT_PASSWORD: + WINDOWS_CERT_PFX: + WINDOWS_CERT_PASSWORD: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + repository: ${{ inputs.repository || github.repository }} + ref: ${{ inputs.ref }} + submodules: recursive + - uses: actions/setup-node@v3 + with: + node-version: 16.17.1 + 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: + contents: read + strategy: + fail-fast: false + matrix: + node: [18.14.0] + os: [macos-13-xl-arm64, windows-2019] + arch: [x64, arm64] + include: + - os: macos-13-xl-arm64 + friendlyName: macOS + - os: windows-2019 + friendlyName: Windows + timeout-minutes: 60 + env: + RELEASE_CHANNEL: ${{ inputs.environment }} + steps: + - uses: actions/checkout@v3 + with: + repository: ${{ inputs.repository || github.repository }} + ref: ${{ inputs.ref }} + submodules: recursive + - name: Use Node.js ${{ matrix.node }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + cache: 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 }} + + - name: Install and build dependencies + run: yarn + env: + npm_config_arch: ${{ matrix.arch }} + TARGET_ARCH: ${{ matrix.arch }} + - name: Build production app + run: yarn build:prod + env: + DESKTOP_OAUTH_CLIENT_ID: ${{ secrets.DESKTOP_OAUTH_CLIENT_ID }} + DESKTOP_OAUTH_CLIENT_SECRET: + ${{ secrets.DESKTOP_OAUTH_CLIENT_SECRET }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + 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: Install Windows code signing certificate + if: ${{ runner.os == 'Windows' }} + shell: bash + env: + CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }} + run: base64 -d <<<"$CERT_CONTENTS" > ./script/windows-certificate.pfx + - name: Package production app + run: yarn package + env: + npm_config_arch: ${{ matrix.arch }} + WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} + - name: Upload artifacts + uses: actions/upload-artifact@v3 + 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/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..0118ec3438 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,49 @@ +name: 'Code scanning - action' + +on: + push: + branches: ['development'] + pull_request: + branches: ['development'] + schedule: + - cron: '0 19 * * 0' + +jobs: + CodeQL-Build: + # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest + runs-on: ubuntu-latest + + permissions: + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + config-file: ./.github/codeql/codeql-config.yml + + # Override language selection by uncommenting this and choosing your languages + # with: + # languages: go, javascript, csharp, python, cpp, java + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below). + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following + # three lines and modify them (or add more) to build your code if your + # project uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml new file mode 100644 index 0000000000..44d543dc91 --- /dev/null +++ b/.github/workflows/no-response.yml @@ -0,0 +1,32 @@ +name: No Response + +# Both `issue_comment` and `scheduled` event types are required for this Action +# to work properly. +on: + issue_comment: + types: [created] + schedule: + # Schedule for five minutes after the hour, every hour + - cron: '5 * * * *' + +permissions: + issues: write + +jobs: + noResponse: + runs-on: ubuntu-latest + steps: + - uses: lee-dohm/no-response@v0.5.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + closeComment: > + Thank you for your issue! + + We haven’t gotten a response to our questions above. With only the + information that is currently in the issue, we don’t have enough + information to take action. We’re going to close this but don’t + hesitate to reach out if you have or find the answers we need. If + you answer our questions above, this issue will automatically + reopen. + daysUntilClose: 7 + responseRequiredLabel: more-info-needed diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 0000000000..e57200bf00 --- /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@v3 + 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@v1 + 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@v5.0.2 + 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/webpack.yml b/.github/workflows/webpack.yml new file mode 100644 index 0000000000..8b797d59db --- /dev/null +++ b/.github/workflows/webpack.yml @@ -0,0 +1,28 @@ +name: NodeJS with Webpack + +on: + push: + branches: [ "development" ] + pull_request: + branches: [ "development" ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x, 16.x, 18.x] + + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Build + run: github.dev/farahmandakbar26/farahmandakbar26 + npm install + npx webpack diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..30a0037ef0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +out/ +dist/ +node_modules/ +coverage/ +npm-debug.log +yarn-error.log +app/node_modules/ +.DS_Store +.awcache +.idea/ +.vs/ +.vscode/*.log +.eslintcache +*.iml +.envrc +junit*.xml +*.swp +tslint-rules/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..63e8c10b1f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "gemoji"] + path = gemoji + url = https://github.com/github/gemoji.git +[submodule "app/static/common/gitignore"] + path = app/static/common/gitignore + url = https://github.com/github/gitignore.git +[submodule "app/static/common/choosealicense.com"] + path = app/static/common/choosealicense.com + url = https://github.com/github/choosealicense.com.git diff --git a/.markdownlint.js b/.markdownlint.js new file mode 100644 index 0000000000..eb043f42c4 --- /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 new file mode 100644 index 0000000000..e6db45a907 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +18.14.0 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..49991d30ce --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.14.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..cdf20d6417 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,15 @@ +out/ +dist/ +vendor/ +tslint-rules/*.js +npm-debug.log +yarn-error.log +.DS_Store +.awcache +.idea/ +.eslintcache +app/coverage +app/static/common +app/test/fixtures +gemoji +*.md diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 0000000000..8e554f2796 --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,11 @@ +singleQuote: true +trailingComma: es5 +semi: false +proseWrap: always +endOfLine: auto +arrowParens: avoid + +overrides: + - files: '*.scss' + options: + printWidth: 200 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..bd28b9c5c2 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000000..64abef8922 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +python 3.9.5 +nodejs 18.14.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..c3360e1b89 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "stkb.rewrap" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..60e3be7c52 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,46 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Jest All", + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", + "program": "${workspaceFolder}/node_modules/jest/bin/jest", + "args": [ + "--silent", + "--config", + "${workspaceFolder}/app/jest.unit.config.js" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "env": { + "ELECTRON_RUN_AS_NODE": "1" + }, + "windows": { + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" + } + }, + { + "type": "node", + "request": "launch", + "name": "Jest Current", + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", + "program": "${workspaceFolder}/node_modules/jest/bin/jest", + "args": [ + "--silent", + "--config", + "${workspaceFolder}/app/jest.unit.config.js", + "${relativeFile}" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "env": { + "ELECTRON_RUN_AS_NODE": "1" + }, + "windows": { + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..d527f4e15c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,39 @@ +{ + "typescript.tsdk": "./node_modules/typescript/lib", + "search.exclude": { + ".awcache": true, + "**/dist": true, + "**/node_modules": true, + "**/out": true, + "app/test/fixtures": true, + "vendor": true + }, + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/.DS_Store": true, + "**/node_modules": true, + "**/dist": true, + "**/out": true, + ".awcache": true, + ".eslintcache": true + }, + "files.insertFinalNewline": true, + "editor.tabSize": 2, + "prettier.semi": false, + "prettier.singleQuote": true, + "prettier.trailingComma": "es5", + "editor.formatOnSave": true, + "prettier.ignorePath": ".prettierignore", + "eslint.options": { + "configFile": ".eslintrc.yml", + "rulePaths": ["eslint-rules"] + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact" + ] +} diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000000..50fee05fd7 --- /dev/null +++ b/.yarnrc @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +yarn-path "./vendor/yarn-1.21.1.js" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..5446cd464e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting +* Attempting to contact maintainers outside of GitHub.com without an explicit + invitation to do so. + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at opensource@github.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..68afe6febb --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) GitHub, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..006b2c5f32 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# [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](https://www.typescriptlang.org) and +uses [React](https://reactjs.org/). + + + + 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) + - [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) + +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. + +### 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) + - [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). + +### Community Releases + +There are several community-supported package managers that can be used to +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. + +## 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. + +## 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. + +First, please search the [open issues](https://github.com/desktop/desktop/issues?q=is%3Aopen) +and [closed issues](https://github.com/desktop/desktop/issues?q=is%3Aclosed) +to see if your issue hasn't already been reported (it may also be fixed). + +There is also a list of [known issues](https://github.com/desktop/desktop/blob/development/docs/known-issues.md) +that are being tracked against Desktop, and some of these issues have workarounds. + +If you can't find an issue that matches what you're seeing, open a [new issue](https://github.com/desktop/desktop/issues/new/choose), +choose the right template and provide us with enough information to investigate +further. + +## The issue I reported isn't fixed yet. What can I do? + +If nobody has responded to your issue in a few days, you're welcome to respond to it with a friendly ping in the issue. Please do not respond more than a second time if nobody has responded. The GitHub Desktop maintainers are constrained in time and resources, and diagnosing individual configurations can be difficult and time consuming. While we'll try to at least get you pointed in the right direction, we can't guarantee we'll be able to dig too deeply into any one person's issue. + +## How can I contribute to GitHub Desktop? + +The [CONTRIBUTING.md](./.github/CONTRIBUTING.md) document will help you get setup and +familiar with the source. The [documentation](docs/) folder also contains more +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. + +## License + +**[MIT](LICENSE)** + +The MIT license grant is not for GitHub's trademarks, which include the logo +designs. GitHub reserves all trademark and copyright rights in and to all +GitHub trademarks. GitHub's logos include, for instance, the stylized +Invertocat designs that include "logo" in the file title in the following +folder: [logos](app/static/logos). + +GitHub® and its stylized versions and the Invertocat mark are GitHub's +Trademarks or registered Trademarks. When using GitHub's logos, be sure to +follow the GitHub [logo guidelines](https://github.com/logos). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..d9ca9342c3 --- /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 new file mode 100644 index 0000000000..69d015300c --- /dev/null +++ b/app/.npmrc @@ -0,0 +1,3 @@ +runtime = electron +disturl = https://electronjs.org/headers +target = 24.4.0 diff --git a/app/.yarnrc b/app/.yarnrc new file mode 100644 index 0000000000..9792db1ce3 --- /dev/null +++ b/app/.yarnrc @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +yarn-path "../vendor/yarn-1.21.1.js" diff --git a/app/app-info.ts b/app/app-info.ts new file mode 100644 index 0000000000..8c8fd1acbb --- /dev/null +++ b/app/app-info.ts @@ -0,0 +1,49 @@ +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() { + const isDevBuild = channel === 'development' + + return { + __OAUTH_CLIENT_ID__: s(process.env.DESKTOP_OAUTH_CLIENT_ID || devClientId), + __OAUTH_SECRET__: s( + process.env.DESKTOP_OAUTH_CLIENT_SECRET || devClientSecret + ), + __DARWIN__: process.platform === 'darwin', + __WIN32__: process.platform === 'win32', + __LINUX__: process.platform === 'linux', + __APP_NAME__: s(productName), + __APP_VERSION__: s(version), + __DEV__: isDevBuild, + __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/git-info.ts b/app/git-info.ts new file mode 100644 index 0000000000..5a0b33f9ce --- /dev/null +++ b/app/git-info.ts @@ -0,0 +1,88 @@ +import * as Fs from 'fs' +import * as Path from 'path' + +/** + * Attempt to find a ref in the .git/packed-refs file, which is often + * created by Git as part of cleaning up loose refs in the repository. + * + * Will return null if the packed-refs file is missing. + * Will throw an error if the entry is not found in the packed-refs file + * + * @param gitDir The path to the Git repository's .git directory + * @param ref A qualified git ref such as 'refs/heads/main' + */ +function readPackedRefsFile(gitDir: string, ref: string) { + const packedRefsPath = Path.join(gitDir, 'packed-refs') + + try { + // eslint-disable-next-line no-sync + Fs.statSync(packedRefsPath) + } catch (err) { + // fail quietly if packed-refs not found + return null + } + + // eslint-disable-next-line no-sync + const packedRefsContents = Fs.readFileSync(packedRefsPath, 'utf8') + + // we need to build up the regex on the fly using the ref + const refRe = new RegExp('([a-f0-9]{40}) ' + ref) + const packedRefMatch = refRe.exec(packedRefsContents) + + if (!packedRefMatch) { + throw new Error(`Could not find ref entry in .git/packed-refs file: ${ref}`) + } + return packedRefMatch[1] +} + +/** + * Attempt to dereference the given ref without requiring a Git environment + * to be present. Note that this method will not be able to dereference packed + * refs but should suffice for simple refs like 'HEAD'. + * + * Will throw an error for unborn HEAD. + * + * @param gitDir The path to the Git repository's .git directory + * @param ref A qualified git ref such as 'HEAD' or 'refs/heads/main' + * @returns The ref SHA + */ +function revParse(gitDir: string, ref: string): string { + const refPath = Path.join(gitDir, ref) + + try { + // eslint-disable-next-line no-sync + Fs.statSync(refPath) + } catch (err) { + const packedRefMatch = readPackedRefsFile(gitDir, ref) + if (packedRefMatch) { + return packedRefMatch + } + + throw new Error( + `Could not de-reference HEAD to SHA, ref does not exist on disk: ${refPath}` + ) + } + // eslint-disable-next-line no-sync + const refContents = Fs.readFileSync(refPath, 'utf8') + const refRe = /^([a-f0-9]{40})|(?:ref: (refs\/.*))$/m + const refMatch = refRe.exec(refContents) + + if (!refMatch) { + throw new Error( + `Could not de-reference HEAD to SHA, invalid ref in ${refPath}: ${refContents}` + ) + } + + return refMatch[1] || revParse(gitDir, refMatch[2]) +} + +export function getSHA() { + // CircleCI does some funny stuff where HEAD points to an packed ref, but + // luckily it gives us the SHA we want in the environment. + const circleSHA = process.env.CIRCLE_SHA1 + if (circleSHA != null) { + return circleSHA + } + + return revParse(Path.resolve(__dirname, '../.git'), 'HEAD') +} diff --git a/app/jest.unit.config.js b/app/jest.unit.config.js new file mode 100644 index 0000000000..13d8d2482d --- /dev/null +++ b/app/jest.unit.config.js @@ -0,0 +1,14 @@ +module.exports = { + roots: ['/src/', '/test/'], + transform: { + '^.+\\.tsx?$': 'ts-jest', + '\\.m?jsx?$': 'jest-esm-transformer', + }, + testMatch: ['**/unit/**/*-test.ts{,x}'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + setupFiles: ['/test/globals.ts', '/test/unit-test-env.ts'], + setupFilesAfterEnv: ['/test/setup-test-framework.ts'], + reporters: ['default', '../script/jest-actions-reporter.js'], + // For now, @github Node modules required to be transformed by jest-esm-transformer + transformIgnorePatterns: ['node_modules/(?!(@github))'], +} diff --git a/app/package-info.ts b/app/package-info.ts new file mode 100644 index 0000000000..2b1b19bd5f --- /dev/null +++ b/app/package-info.ts @@ -0,0 +1,19 @@ +import { bundleID, companyName, productName, version } from './package.json' + +export function getProductName() { + return process.env.NODE_ENV === 'development' + ? `${productName}-dev` + : productName +} + +export function getCompanyName() { + return companyName +} + +export function getVersion() { + return version +} + +export function getBundleID() { + return process.env.NODE_ENV === 'development' ? `${bundleID}Dev` : bundleID +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000000..104602ba17 --- /dev/null +++ b/app/package.json @@ -0,0 +1,73 @@ +{ + "name": "desktop", + "productName": "GitHub Desktop", + "bundleID": "com.github.GitHubClient", + "companyName": "GitHub, Inc.", + "version": "3.3.0", + "main": "./main.js", + "repository": { + "type": "git", + "url": "https://github.com/desktop/desktop.git" + }, + "description": "Simple collaboration from your desktop", + "author": { + "name": "GitHub, Inc.", + "email": "opensource+desktop@github.com", + "url": "https://desktop.github.com/" + }, + "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-mode-elixir": "^1.1.2", + "compare-versions": "^3.6.0", + "deep-equal": "^1.0.1", + "desktop-notifications": "^0.2.4", + "desktop-trampoline": "desktop/desktop-trampoline#v0.9.8", + "dexie": "^3.2.2", + "dompurify": "^2.3.3", + "dugite": "^2.5.0", + "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", + "marked": "^4.0.10", + "mem": "^4.3.0", + "memoize-one": "^4.0.3", + "mri": "^1.1.0", + "p-limit": "^2.2.0", + "primer-support": "^4.0.0", + "prop-types": "^15.7.2", + "quick-lru": "^3.0.0", + "re2js": "^0.3.0", + "react": "^16.8.4", + "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", + "source-map-support": "^0.4.15", + "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", + "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 new file mode 100644 index 0000000000..012c0531b7 --- /dev/null +++ b/app/src/cli/commands/clone.ts @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000000..5396000901 --- /dev/null +++ b/app/src/cli/commands/help.ts @@ -0,0 +1,79 @@ +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 new file mode 100644 index 0000000000..b5c58d19f6 --- /dev/null +++ b/app/src/cli/commands/open.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000000..2199e5e0c4 --- /dev/null +++ b/app/src/cli/dev-commands-global.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000000..f988ef4d17 --- /dev/null +++ b/app/src/cli/load-commands.ts @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000000..c9525da9b0 --- /dev/null +++ b/app/src/cli/main.ts @@ -0,0 +1,107 @@ +import mri, { + DictionaryObject, + Options as MriOptions, + ArrayOrString, +} from 'mri' +import chalk from 'chalk' + +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') + } + } + } 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)) + } +} + +console.log() // nice blank line before the command prompt + +interface IMRIOpts extends MriOptions { + alias: DictionaryObject + boolean: Array + default: DictionaryObject + string: Array +} + +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 + } + + 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}` + ) + } + } + } + command.handler(parsedArgs, args) +} diff --git a/app/src/cli/open-desktop.ts b/app/src/cli/open-desktop.ts new file mode 100644 index 0000000000..49091d188c --- /dev/null +++ b/app/src/cli/open-desktop.ts @@ -0,0 +1,24 @@ +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 if (__LINUX__) { + return ChildProcess.spawn('xdg-open', [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 new file mode 100644 index 0000000000..5bfabe4289 --- /dev/null +++ b/app/src/cli/util.ts @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000000..1d2c861a92 --- /dev/null +++ b/app/src/crash/crash-app.tsx @@ -0,0 +1,227 @@ +import * as React from 'react' +import { ErrorType } from './shared' +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 { Button } from '../ui/lib/button' +import { LinkButton } from '../ui/lib/link-button' +import { getVersion } from '../ui/lib/app-proxy' +import { getOS } from '../lib/get-os' +import * as ipcRenderer from '../lib/ipc-renderer' +import { getCurrentWindowState } from '../ui/main-process-proxy' + +// This is a weird one, let's leave it as a placeholder +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface ICrashAppProps {} + +interface ICrashAppState { + /** + * Whether this error was thrown before we were able to launch + * the main renderer process or not. See the documentation for + * the ErrorType type for more details. + */ + readonly type?: ErrorType + + /** + * The error that caused us to spawn the crash process. + */ + readonly error?: Error + + /** + * The current state of the Window, ie maximized, minimized full-screen etc. + */ + readonly windowState: WindowState | null +} + +// Note that we're reusing the welcome illustration here, any changes to it +// will have to be reflected in the welcome flow as well. +const BottomImageUri = encodePathAsUrl( + __dirname, + 'static/welcome-illustration-left-bottom.svg' +) + +const issuesUri = 'https://github.com/desktop/desktop/issues' + +/** + * Formats an error by attempting to strip out user-identifiable information + * from paths and appends system metadata such and the running version and + * current operating system. + */ +function prepareErrorMessage(error: Error) { + let message + + if (error.stack) { + message = error.stack + .split('\n') + .map(line => { + // The stack trace lines come in two forms: + // + // `at Function.module.exports.Emitter.simpleDispatch (SOME_USER_SPECIFIC_PATH/app/node_modules/event-kit/lib/emitter.js:25:14)` + // `at file:///SOME_USER_SPECIFIC_PATH/app/renderer.js:6:4250` + // + // We want to try to strip the user-specific path part out. + const match = line.match(/(\s*)(.*)(\(|file:\/\/\/).*(app.*)/) + + return !match || match.length < 5 + ? line + : match[1] + match[2] + match[3] + match[4] + }) + .join('\n') + } else { + message = `${error.name}: ${error.message}` + } + + return `${message}\n\nVersion: ${getVersion()}\nOS: ${getOS()}\n` +} + +/** + * The root component for our crash process. + * + * The crash process is responsible for presenting the user with an + * error after the main process or any renderer process has crashed due + * to an uncaught exception or when the main renderer has failed to load. + * + * Exercise caution when working with the crash process. If the crash + * process itself crashes we've failed. + */ +export class CrashApp extends React.Component { + public constructor(props: ICrashAppProps) { + super(props) + + this.state = { + windowState: null, + } + + this.initializeWindowState() + } + + public componentDidMount() { + ipcRenderer.on('window-state-changed', this.onWindowStateChanged) + + ipcRenderer.on('error', (_, crashDetails) => this.setState(crashDetails)) + + ipcRenderer.send('crash-ready') + } + + public componentWillUnmount() { + ipcRenderer.removeListener( + 'window-state-changed', + this.onWindowStateChanged + ) + } + + private initializeWindowState = async () => { + const windowState = await getCurrentWindowState() + if (windowState === undefined) { + return + } + + this.setState({ windowState }) + } + + private onWindowStateChanged = ( + _: Electron.IpcRendererEvent, + windowState: WindowState + ) => { + this.setState({ windowState }) + } + + private onQuitButtonClicked = (e: React.MouseEvent) => { + e.preventDefault() + ipcRenderer.send('crash-quit') + } + + private renderTitle() { + const message = + this.state.type === 'launch' + ? 'GitHub Desktop failed to launch' + : 'GitHub Desktop encountered an error' + + return ( +
+ +

{message}

+
+ ) + } + + private renderDescription() { + if (this.state.type === 'launch') { + return ( +

+ GitHub Desktop encountered a catastrophic error that prevents it from + launching. This has been reported to the team, but if you encounter + this repeatedly please report this issue to the GitHub Desktop{' '} + issue tracker. +

+ ) + } else { + return ( +

+ GitHub Desktop has encountered an unrecoverable error and will need to + restart. This has been reported to the team, but if you encounter this + repeatedly please report this issue to the GitHub Desktop{' '} + issue tracker. +

+ ) + } + } + + private renderErrorDetails() { + const error = this.state.error + + if (!error) { + return + } + + return
{prepareErrorMessage(error)}
+ } + + private renderFooter() { + return
{this.renderQuitButton()}
+ } + + private renderQuitButton() { + let quitText + // We don't support restarting in dev mode since we can't + // control the life time of the dev server. + if (__DEV__) { + quitText = __DARWIN__ ? 'Quit' : 'Exit' + } else { + quitText = __DARWIN__ ? 'Quit and Restart' : 'Exit and restart' + } + + return ( + + ) + } + + private renderBackgroundGraphics() { + return ( + + ) + } + + public render() { + return ( +
+ +
+ {this.renderTitle()} + {this.renderDescription()} + {this.renderErrorDetails()} + {this.renderFooter()} + {this.renderBackgroundGraphics()} +
+
+ ) + } +} diff --git a/app/src/crash/index.tsx b/app/src/crash/index.tsx new file mode 100644 index 0000000000..24718ca084 --- /dev/null +++ b/app/src/crash/index.tsx @@ -0,0 +1,18 @@ +import * as React from 'react' +import * as ReactDOM from 'react-dom' + +import { CrashApp } from './crash-app' + +if (!process.env.TEST_ENV) { + /* This is the magic trigger for webpack to go compile + * our sass into css and inject it into the DOM. */ + require('./styles/crash.scss') +} + +document.body.classList.add(`platform-${process.platform}`) + +const container = document.createElement('div') +container.id = 'desktop-crash-container' +document.body.appendChild(container) + +ReactDOM.render(, container) diff --git a/app/src/crash/shared.ts b/app/src/crash/shared.ts new file mode 100644 index 0000000000..b62db5b1c0 --- /dev/null +++ b/app/src/crash/shared.ts @@ -0,0 +1,26 @@ +/** + * We differentiate between errors that happens before we're + * able to show the main renderer window and errors that happen + * after that. Launch errors are special in that users aren't + * even able to interact with the app. We use this information + * to customize the presentation of the crash process. + */ +export type ErrorType = 'launch' | 'generic' + +/** + * An interface describing the nature of the error that caused + * us to spawn the crash process. + */ +export interface ICrashDetails { + /** + * Whether this error was thrown before we were able to launch + * the main renderer process or not. See the documentation for + * the ErrorType type for more details. + */ + readonly type: ErrorType + + /** + * The error that caused us to spawn the crash process. + */ + readonly error: Error +} diff --git a/app/src/crash/styles/crash.scss b/app/src/crash/styles/crash.scss new file mode 100644 index 0000000000..0a27447160 --- /dev/null +++ b/app/src/crash/styles/crash.scss @@ -0,0 +1,105 @@ +@import '../../../styles/variables'; +@import '../../../styles/ui/window/title-bar'; +@import '../../../styles/ui/octicons'; +@import '../../../styles/globals'; +@import '../../../styles/ui/button'; +@import '../../../styles/ui/scroll'; + +#desktop-crash-container, +#crash-app { + height: 100%; +} + +#crash-app { + display: flex; + flex-direction: column; +} + +pre.error { + flex-shrink: 1; + flex-grow: 1; + + word-wrap: break-word; + white-space: pre-wrap; + font-family: var(--font-family-monospace); + background: rgba($blue-100, 0.3); + color: $gray-900; + padding: var(--spacing); + border-radius: var(--border-radius); + + user-select: text; + cursor: text; + + overflow: auto; + + margin: 0 0 var(--spacing-double) 0; +} + +header { + margin-bottom: var(--spacing-double); + display: flex; + flex: none; + align-items: center; + + h1 { + margin: 0; + font-weight: var(--font-weight-semibold); + } + + .octicon.error-icon { + flex: none; + color: $red-700; + height: 32px; + margin-right: var(--spacing); + // In case the title wraps we should render the icon up top + align-self: flex-start; + // Add a little margin so it looks like it's centered if + // the title doesn't wrap + margin-top: 3px; + } +} + +p { + margin: 0 0 var(--spacing-double) 0; + max-width: 500px; + flex-shrink: 0; +} + +main { + margin: var(--spacing-quad); + display: flex; + flex-direction: column; + flex-grow: 1; + min-height: 0; + + .footer { + flex: none; + display: flex; + flex-direction: row; + justify-content: flex-end; + + button { + min-width: 150px; + } + } +} + +.background-graphic-bottom { + position: absolute; + right: var(--spacing); + bottom: var(--spacing); + + height: 45%; + // I hate this but we'll have to live with it for beta. + z-index: -1; +} + +.background-graphic-top { + position: absolute; + right: 80px; + top: 40px; + + height: 20%; + // I hate this but we'll have to live with it for beta. + z-index: -1; +} diff --git a/app/src/highlighter/globals.d.ts b/app/src/highlighter/globals.d.ts new file mode 100644 index 0000000000..bdefb21dfb --- /dev/null +++ b/app/src/highlighter/globals.d.ts @@ -0,0 +1,378 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +declare namespace CodeMirror { + interface EditorConfiguration { + /** How many spaces a block (whatever that means in the edited language) should be indented. The default is 2. */ + indentUnit?: number + + /** The width of a tab character. Defaults to 4. */ + tabSize?: number + } + + /** + * A function that, given a CodeMirror configuration object and an optional mode configuration object, returns a mode object. + */ + interface ModeFactory { + (config: CodeMirror.EditorConfiguration, modeOptions?: any): Mode + } + + interface StringStreamContext { + lines: string[] + line: number + lookAhead: (n: number) => string + } + + /** + * A Mode is, in the simplest case, a lexer (tokenizer) for your language — a function that takes a character stream as input, + * advances it past a token, and returns a style for that token. More advanced modes can also handle indentation for the language. + */ + interface Mode { + /** + * A function that produces a state object to be used at the start of a document. + */ + startState?: () => T + /** + * For languages that have significant blank lines, you can define a blankLine(state) method on your mode that will get called + * whenever a blank line is passed over, so that it can update the parser state. + */ + blankLine?: (state: T) => void + /** + * Given a state returns a safe copy of that state. + */ + copyState?: (state: T) => T + + /** + * The indentation method should inspect the given state object, and optionally the textAfter string, which contains the text on + * the line that is being indented, and return an integer, the amount of spaces to indent. + */ + indent?: (state: T, textAfter: string) => number + + /** The four below strings are used for working with the commenting addon. */ + /** + * String that starts a line comment. + */ + lineComment?: string + /** + * String that starts a block comment. + */ + blockCommentStart?: string + /** + * String that ends a block comment. + */ + blockCommentEnd?: string + /** + * String to put at the start of continued lines in a block comment. + */ + blockCommentLead?: string + + /** + * Trigger a reindent whenever one of the characters in the string is typed. + */ + electricChars?: string + /** + * Trigger a reindent whenever the regex matches the part of the line before the cursor. + */ + electricinput?: RegExp + + /** + * This function should read one token from the stream it is given as an argument, optionally update its state, + * and return a style string, or null for tokens that do not have to be styled. Multiple styles can be returned, separated by spaces. + */ + token(stream: StringStream, state: T): string | null + } + + class StringStream { + public lastColumnPos: number + public lastColumnValue: number + public lineStart: number + + /** + * Current position in the string. + */ + public pos: number + + /** + * Where the stream's position was when it was first passed to the token function. + */ + public start: number + + /** + * The current line's content. + */ + public string: string + + /** + * Number of spaces per tab character. + */ + public tabSize: number + + public constructor( + string: string, + tabSize: number, + context: StringStreamContext + ) + + /** + * Returns true only if the stream is at the end of the line. + */ + public eol(): boolean + + /** + * Returns true only if the stream is at the start of the line. + */ + public sol(): boolean + + /** + * Returns the next character in the stream without advancing it. Will return an null at the end of the line. + */ + public peek(): string | null + + /** + * Returns the next character in the stream and advances it. Also returns null when no more characters are available. + */ + public next(): string | null + + /** + * match can be a character, a regular expression, or a function that takes a character and returns a boolean. + * If the next character in the stream 'matches' the given argument, it is consumed and returned. + * Otherwise, undefined is returned. + */ + public eat(match: string): string + public eat(match: RegExp): string + public eat(match: (char: string) => boolean): string + + /** + * Repeatedly calls eat with the given argument, until it fails. Returns true if any characters were eaten. + */ + public eatWhile(match: string): boolean + public eatWhile(match: RegExp): boolean + public eatWhile(match: (char: string) => boolean): boolean + + /** + * Shortcut for eatWhile when matching white-space. + */ + public eatSpace(): boolean + + /** + * Moves the position to the end of the line. + */ + public skipToEnd(): void + + /** + * Skips to the next occurrence of the given character, if found on the current line (doesn't advance the stream if + * the character does not occur on the line). + * + * Returns true if the character was found. + */ + public skipTo(ch: string): boolean + + /** + * Act like a multi-character eat - if consume is true or not given - or a look-ahead that doesn't update the stream + * position - if it is false. pattern can be either a string or a regular expression starting with ^. When it is a + * string, caseFold can be set to true to make the match case-insensitive. When successfully matching a regular + * expression, the returned value will be the array returned by match, in case you need to extract matched groups. + */ + public match( + pattern: string, + consume?: boolean, + caseFold?: boolean + ): boolean + public match(pattern: RegExp, consume?: boolean): string[] + + /** + * Backs up the stream n characters. Backing it up further than the start of the current token will cause things to + * break, so be careful. + */ + public backUp(n: number): void + + /** + * Returns the column (taking into account tabs) at which the current token starts. + */ + public column(): number + + /** + * Tells you how far the current line has been indented, in spaces. Corrects for tab characters. + */ + public indentation(): number + + /** + * Get the string between the start of the current token and the current stream position. + */ + public current(): string + } + + /** + * The first argument is a configuration object as passed to the mode constructor function, and the second argument + * is a mode specification as in the EditorConfiguration mode option. + */ + function getMode( + config: CodeMirror.EditorConfiguration, + mode: any + ): Mode + + /** + * id will be the id for the defined mode. Typically, you should use this second argument to defineMode as your module scope function + * (modes should not leak anything into the global scope!), i.e. write your whole mode inside this function. + */ + function defineMode(id: string, modefactory: ModeFactory): void + + function defineMIME(mime: string, spec: any): void + + function startState(mode: Mode<{}>, a1: any, a2: any): any + + function resolveMode(spec: any): any + + function extendMode(mode: any, properties: any): void + + /** + * Runs a CodeMirror mode over text without opening an editor instance. + * + * @param text The document to run through the highlighter. + * @param mode The mode to use (must be loaded as normal). + * @param output If this is a function, it will be called for each token with + * two arguments, the token's text and the token's style class + * (may be null for unstyled tokens). If it is a DOM node, the + * tokens will be converted to span elements as in an editor, + * and inserted into the node (through innerHTML). + */ + function runMode( + text: string, + modespec: any, + callback: (text: string, style: string | null) => void, + options?: { tabSize?: number; state?: any } + ): void + + function innerMode( + mode: CodeMirror.Mode<{}>, + state: any + ): { mode: CodeMirror.Mode<{}>; state: any } +} + +declare module 'codemirror/addon/runmode/runmode.node.js' { + export = CodeMirror +} + +declare module 'codemirror-mode-elixir' + +// find app/node_modules/codemirror/mode -iname *.js | cut -d '/' -f 3- | cut -d '.' -f 1 | sed -e "s/^/declare module '/" | sed -e "s/$/'/" +declare module 'codemirror/mode/scheme/scheme' +declare module 'codemirror/mode/modelica/modelica' +declare module 'codemirror/mode/idl/idl' +declare module 'codemirror/mode/pascal/pascal' +declare module 'codemirror/mode/nsis/nsis' +declare module 'codemirror/mode/haml/haml' +declare module 'codemirror/mode/toml/toml' +declare module 'codemirror/mode/pig/pig' +declare module 'codemirror/mode/gas/gas' +declare module 'codemirror/mode/go/go' +declare module 'codemirror/mode/apl/apl' +declare module 'codemirror/mode/textile/textile' +declare module 'codemirror/mode/turtle/turtle' +declare module 'codemirror/mode/sparql/sparql' +declare module 'codemirror/mode/troff/troff' +declare module 'codemirror/mode/cmake/cmake' +declare module 'codemirror/mode/htmlembedded/htmlembedded' +declare module 'codemirror/mode/xquery/xquery' +declare module 'codemirror/mode/python/python' +declare module 'codemirror/mode/css/css' +declare module 'codemirror/mode/clojure/clojure' +declare module 'codemirror/mode/spreadsheet/spreadsheet' +declare module 'codemirror/mode/asn.1/asn.1' +declare module 'codemirror/mode/z80/z80' +declare module 'codemirror/mode/jinja2/jinja2' +declare module 'codemirror/mode/gherkin/gherkin' +declare module 'codemirror/mode/asterisk/asterisk' +declare module 'codemirror/mode/dockerfile/dockerfile' +declare module 'codemirror/mode/dart/dart' +declare module 'codemirror/mode/shell/shell' +declare module 'codemirror/mode/yacas/yacas' +declare module 'codemirror/mode/markdown/markdown' +declare module 'codemirror/mode/haxe/haxe' +declare module 'codemirror/mode/soy/soy' +declare module 'codemirror/mode/perl/perl' +declare module 'codemirror/mode/smalltalk/smalltalk' +declare module 'codemirror/mode/dylan/dylan' +declare module 'codemirror/mode/stylus/stylus' +declare module 'codemirror/mode/vue/vue' +declare module 'codemirror/mode/rust/rust' +declare module 'codemirror/mode/rst/rst' +declare module 'codemirror/mode/tiddlywiki/tiddlywiki' +declare module 'codemirror/mode/pug/pug' +declare module 'codemirror/mode/erlang/erlang' +declare module 'codemirror/mode/r/r' +declare module 'codemirror/mode/mathematica/mathematica' +declare module 'codemirror/mode/yaml-frontmatter/yaml-frontmatter' +declare module 'codemirror/mode/diff/diff' +declare module 'codemirror/mode/elm/elm' +declare module 'codemirror/mode/crystal/crystal' +declare module 'codemirror/mode/cypher/cypher' +declare module 'codemirror/mode/htmlmixed/htmlmixed' +declare module 'codemirror/mode/ebnf/ebnf' +declare module 'codemirror/mode/webidl/webidl' +declare module 'codemirror/mode/smarty/smarty' +declare module 'codemirror/mode/stex/stex' +declare module 'codemirror/mode/haskell/haskell' +declare module 'codemirror/mode/factor/factor' +declare module 'codemirror/mode/php/php' +declare module 'codemirror/mode/pegjs/pegjs' +declare module 'codemirror/mode/lua/lua' +declare module 'codemirror/mode/velocity/velocity' +declare module 'codemirror/mode/xml/xml' +declare module 'codemirror/mode/solr/solr' +declare module 'codemirror/mode/mbox/mbox' +declare module 'codemirror/mode/mllike/mllike' +declare module 'codemirror/mode/vb/vb' +declare module 'codemirror/mode/powershell/powershell' +declare module 'codemirror/mode/tornado/tornado' +declare module 'codemirror/mode/vhdl/vhdl' +declare module 'codemirror/mode/tiki/tiki' +declare module 'codemirror/mode/clike/clike' +declare module 'codemirror/mode/tcl/tcl' +declare module 'codemirror/mode/brainfuck/brainfuck' +declare module 'codemirror/mode/ttcn/ttcn' +declare module 'codemirror/mode/dtd/dtd' +declare module 'codemirror/mode/octave/octave' +declare module 'codemirror/mode/properties/properties' +declare module 'codemirror/mode/verilog/verilog' +declare module 'codemirror/mode/handlebars/handlebars' +declare module 'codemirror/mode/nginx/nginx' +declare module 'codemirror/mode/http/http' +declare module 'codemirror/mode/asciiarmor/asciiarmor' +declare module 'codemirror/mode/swift/swift' +declare module 'codemirror/mode/meta' +declare module 'codemirror/mode/sas/sas' +declare module 'codemirror/mode/sieve/sieve' +declare module 'codemirror/mode/livescript/livescript' +declare module 'codemirror/mode/commonlisp/commonlisp' +declare module 'codemirror/mode/fcl/fcl' +declare module 'codemirror/mode/yaml/yaml' +declare module 'codemirror/mode/fortran/fortran' +declare module 'codemirror/mode/julia/julia' +declare module 'codemirror/mode/oz/oz' +declare module 'codemirror/mode/groovy/groovy' +declare module 'codemirror/mode/coffeescript/coffeescript' +declare module 'codemirror/mode/slim/slim' +declare module 'codemirror/mode/javascript/javascript' +declare module 'codemirror/mode/mscgen/mscgen' +declare module 'codemirror/mode/twig/twig' +declare module 'codemirror/mode/eiffel/eiffel' +declare module 'codemirror/mode/cobol/cobol' +declare module 'codemirror/mode/sass/sass' +declare module 'codemirror/mode/rpm/rpm' +declare module 'codemirror/mode/mumps/mumps' +declare module 'codemirror/mode/vbscript/vbscript' +declare module 'codemirror/mode/ttcn-cfg/ttcn-cfg' +declare module 'codemirror/mode/forth/forth' +declare module 'codemirror/mode/puppet/puppet' +declare module 'codemirror/mode/django/django' +declare module 'codemirror/mode/d/d' +declare module 'codemirror/mode/q/q' +declare module 'codemirror/mode/jsx/jsx' +declare module 'codemirror/mode/protobuf/protobuf' +declare module 'codemirror/mode/gfm/gfm' +declare module 'codemirror/mode/ecl/ecl' +declare module 'codemirror/mode/ruby/ruby' +declare module 'codemirror/mode/mirc/mirc' +declare module 'codemirror/mode/haskell-literate/haskell-literate' +declare module 'codemirror/mode/ntriples/ntriples' +declare module 'codemirror/mode/sql/sql' diff --git a/app/src/highlighter/index.ts b/app/src/highlighter/index.ts new file mode 100644 index 0000000000..5643535db4 --- /dev/null +++ b/app/src/highlighter/index.ts @@ -0,0 +1,662 @@ +/// + +// This doesn't import all of CodeMirror, instead it only imports +// a small subset. This hack is brought to you by webpack and you +// can read all about it in webpack.common.js. +import { + getMode, + innerMode, + StringStream, +} from 'codemirror/addon/runmode/runmode.node.js' + +import { ITokens, IHighlightRequest } from '../lib/highlighter/types' + +/** + * A mode definition object is used to map a certain file + * extension to a mode loader (see the documentation for + * the install property). + */ +interface IModeDefinition { + /** + * A function that, when called, will attempt to asynchronously + * load the required modules for a particular mode. This function + * is idempotent and can be called multiple times with no adverse + * effect. + */ + readonly install: () => Promise + + /** + * A map between file extensions (including the leading dot, i.e. + * ".jpeg") or basenames (i.e. "dockerfile") and the selected mime + * type to use when highlighting that extension as specified in + * the CodeMirror mode itself. + */ + readonly mappings: { + readonly [key: string]: string + } +} + +/** + * Array describing all currently supported extensionModes and the file extensions + * that they cover. + */ +const extensionModes: ReadonlyArray = [ + { + install: () => import('codemirror/mode/javascript/javascript'), + mappings: { + '.ts': 'text/typescript', + '.mts': 'text/typescript', + '.cts': 'text/typescript', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.cjs': 'text/javascript', + '.json': 'application/json', + }, + }, + { + install: () => import('codemirror/mode/coffeescript/coffeescript'), + mappings: { + '.coffee': 'text/x-coffeescript', + }, + }, + { + install: () => import('codemirror/mode/jsx/jsx'), + mappings: { + '.tsx': 'text/typescript-jsx', + '.mtsx': 'text/typescript-jsx', + '.ctsx': 'text/typescript-jsx', + '.jsx': 'text/jsx', + '.mjsx': 'text/jsx', + '.cjsx': 'text/jsx', + }, + }, + { + install: () => import('codemirror/mode/htmlmixed/htmlmixed'), + mappings: { + '.html': 'text/html', + '.htm': 'text/html', + }, + }, + { + install: () => import('codemirror/mode/htmlembedded/htmlembedded'), + mappings: { + '.aspx': 'application/x-aspx', + '.cshtml': 'application/x-aspx', + '.jsp': 'application/x-jsp', + }, + }, + { + install: () => import('codemirror/mode/css/css'), + mappings: { + '.css': 'text/css', + '.scss': 'text/x-scss', + '.less': 'text/x-less', + }, + }, + { + install: () => import('codemirror/mode/vue/vue'), + mappings: { + '.vue': 'text/x-vue', + }, + }, + { + install: () => import('codemirror/mode/markdown/markdown'), + mappings: { + '.markdown': 'text/x-markdown', + '.md': 'text/x-markdown', + }, + }, + { + install: () => import('codemirror/mode/yaml/yaml'), + mappings: { + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + }, + }, + { + install: () => import('codemirror/mode/xml/xml'), + mappings: { + '.xml': 'text/xml', + '.xaml': 'text/xml', + '.csproj': 'text/xml', + '.fsproj': 'text/xml', + '.vcxproj': 'text/xml', + '.vbproj': 'text/xml', + '.svg': 'text/xml', + '.resx': 'text/xml', + '.props': 'text/xml', + '.targets': 'text/xml', + }, + }, + { + install: () => import('codemirror/mode/diff/diff'), + mappings: { + '.diff': 'text/x-diff', + '.patch': 'text/x-diff', + }, + }, + { + install: () => import('codemirror/mode/clike/clike'), + mappings: { + '.m': 'text/x-objectivec', + '.scala': 'text/x-scala', + '.sc': 'text/x-scala', + '.cs': 'text/x-csharp', + '.cake': 'text/x-csharp', + '.java': 'text/x-java', + '.c': 'text/x-c', + '.h': 'text/x-c', + '.cpp': 'text/x-c++src', + '.hpp': 'text/x-c++src', + '.ino': 'text/x-c++src', + '.kt': 'text/x-kotlin', + }, + }, + { + install: () => import('codemirror/mode/mllike/mllike'), + mappings: { + '.ml': 'text/x-ocaml', + '.fs': 'text/x-fsharp', + '.fsx': 'text/x-fsharp', + '.fsi': 'text/x-fsharp', + }, + }, + { + install: () => import('codemirror/mode/swift/swift'), + mappings: { + '.swift': 'text/x-swift', + }, + }, + { + install: () => import('codemirror/mode/shell/shell'), + mappings: { + '.sh': 'text/x-sh', + }, + }, + { + install: () => import('codemirror/mode/sql/sql'), + mappings: { + '.sql': 'text/x-sql', + }, + }, + { + install: () => import('codemirror/mode/cypher/cypher'), + mappings: { + '.cql': 'application/x-cypher-query', + }, + }, + { + install: () => import('codemirror/mode/go/go'), + mappings: { + '.go': 'text/x-go', + }, + }, + { + install: () => import('codemirror/mode/perl/perl'), + mappings: { + '.pl': 'text/x-perl', + }, + }, + { + install: () => import('codemirror/mode/php/php'), + mappings: { + '.php': 'application/x-httpd-php', + }, + }, + { + install: () => import('codemirror/mode/python/python'), + mappings: { + '.py': 'text/x-python', + }, + }, + { + install: () => import('codemirror/mode/ruby/ruby'), + mappings: { + '.rb': 'text/x-ruby', + }, + }, + { + install: () => import('codemirror/mode/clojure/clojure'), + mappings: { + '.clj': 'text/x-clojure', + '.cljc': 'text/x-clojure', + '.cljs': 'text/x-clojure', + '.edn': 'text/x-clojure', + }, + }, + { + install: () => import('codemirror/mode/rust/rust'), + mappings: { + '.rs': 'text/x-rustsrc', + }, + }, + { + install: () => import('codemirror-mode-elixir'), + mappings: { + '.ex': 'text/x-elixir', + '.exs': 'text/x-elixir', + }, + }, + { + install: () => import('codemirror/mode/haxe/haxe'), + mappings: { + '.hx': 'text/x-haxe', + }, + }, + { + install: () => import('codemirror/mode/r/r'), + mappings: { + '.r': 'text/x-rsrc', + }, + }, + { + install: () => import('codemirror/mode/powershell/powershell'), + mappings: { + '.ps1': 'application/x-powershell', + }, + }, + { + install: () => import('codemirror/mode/vb/vb'), + mappings: { + '.vb': 'text/x-vb', + }, + }, + { + install: () => import('codemirror/mode/fortran/fortran'), + mappings: { + '.f': 'text/x-fortran', + '.f90': 'text/x-fortran', + }, + }, + { + install: () => import('codemirror/mode/lua/lua'), + mappings: { + '.lua': 'text/x-lua', + }, + }, + { + install: () => import('codemirror/mode/crystal/crystal'), + mappings: { + '.cr': 'text/x-crystal', + }, + }, + { + install: () => import('codemirror/mode/julia/julia'), + mappings: { + '.jl': 'text/x-julia', + }, + }, + { + install: () => import('codemirror/mode/stex/stex'), + mappings: { + '.tex': 'text/x-stex', + }, + }, + { + install: () => import('codemirror/mode/sparql/sparql'), + mappings: { + '.rq': 'application/sparql-query', + }, + }, + { + install: () => import('codemirror/mode/stylus/stylus'), + mappings: { + '.styl': 'text/x-styl', + }, + }, + { + install: () => import('codemirror/mode/soy/soy'), + mappings: { + '.soy': 'text/x-soy', + }, + }, + { + install: () => import('codemirror/mode/smalltalk/smalltalk'), + mappings: { + '.st': 'text/x-stsrc', + }, + }, + { + install: () => import('codemirror/mode/slim/slim'), + mappings: { + '.slim': 'application/x-slim', + }, + }, + { + install: () => import('codemirror/mode/haml/haml'), + mappings: { + '.haml': 'text/x-haml', + }, + }, + { + install: () => import('codemirror/mode/sieve/sieve'), + mappings: { + '.sieve': 'application/sieve', + }, + }, + { + install: () => import('codemirror/mode/scheme/scheme'), + mappings: { + '.ss': 'text/x-scheme', + '.sls': 'text/x-scheme', + '.scm': 'text/x-scheme', + }, + }, + { + install: () => import('codemirror/mode/rst/rst'), + mappings: { + '.rst': 'text/x-rst', + }, + }, + { + install: () => import('codemirror/mode/rpm/rpm'), + mappings: { + '.rpm': 'text/x-rpm-spec', + }, + }, + { + install: () => import('codemirror/mode/q/q'), + mappings: { + '.q': 'text/x-q', + }, + }, + { + install: () => import('codemirror/mode/puppet/puppet'), + mappings: { + '.pp': 'text/x-puppet', + }, + }, + { + install: () => import('codemirror/mode/pug/pug'), + mappings: { + '.pug': 'text/x-pug', + }, + }, + { + install: () => import('codemirror/mode/protobuf/protobuf'), + mappings: { + '.proto': 'text/x-protobuf', + }, + }, + { + install: () => import('codemirror/mode/properties/properties'), + mappings: { + '.properties': 'text/x-properties', + '.gitattributes': 'text/x-properties', + '.gitignore': 'text/x-properties', + '.editorconfig': 'text/x-properties', + '.ini': 'text/x-ini', + }, + }, + { + install: () => import('codemirror/mode/pig/pig'), + mappings: { + '.pig': 'text/x-pig', + }, + }, + { + install: () => import('codemirror/mode/asciiarmor/asciiarmor'), + mappings: { + '.pgp': 'application/pgp', + }, + }, + { + install: () => import('codemirror/mode/oz/oz'), + mappings: { + '.oz': 'text/x-oz', + }, + }, + { + install: () => import('codemirror/mode/pascal/pascal'), + mappings: { + '.pas': 'text/x-pascal', + }, + }, + { + install: () => import('codemirror/mode/toml/toml'), + mappings: { + '.toml': 'text/x-toml', + }, + }, + { + install: () => import('codemirror/mode/dart/dart'), + mappings: { + '.dart': 'application/dart', + }, + }, +] + +/** + * A map between file extensions and mime types, see + * the 'mappings' property on the IModeDefinition interface + * for more information + */ +const extensionMIMEMap = new Map() + +/** + * Array describing all currently supported basenameModes and the file names + * that they cover. + */ +const basenameModes: ReadonlyArray = [ + { + install: () => import('codemirror/mode/dockerfile/dockerfile'), + mappings: { + dockerfile: 'text/x-dockerfile', + }, + }, +] + +/** + * A map between file basenames and mime types, see + * the 'basenames' property on the IModeDefinition interface + * for more information + */ +const basenameMIMEMap = new Map() + +/** + * A map between mime types and mode definitions. See the + * documentation for the IModeDefinition interface + * for more information + */ +const mimeModeMap = new Map() + +for (const extensionMode of extensionModes) { + for (const [mapping, mimeType] of Object.entries(extensionMode.mappings)) { + extensionMIMEMap.set(mapping, mimeType) + mimeModeMap.set(mimeType, extensionMode) + } +} + +for (const basenameMode of basenameModes) { + for (const [mapping, mimeType] of Object.entries(basenameMode.mappings)) { + basenameMIMEMap.set(mapping, mimeType) + mimeModeMap.set(mimeType, basenameMode) + } +} + +function guessMimeType(contents: ReadonlyArray) { + const firstLine = contents[0] + + if (firstLine.startsWith(' | null> { + const mimeType = + extensionMIMEMap.get(request.extension.toLowerCase()) || + basenameMIMEMap.get(request.basename.toLowerCase()) || + guessMimeType(request.contentLines) + + if (!mimeType) { + return null + } + + const modeDefinition = mimeModeMap.get(mimeType) + + if (modeDefinition === undefined) { + return null + } + + await modeDefinition.install() + + return getMode({}, mimeType) || null +} + +function getModeName(mode: CodeMirror.Mode<{}>): string | null { + const name = (mode as any).name + return name && typeof name === 'string' ? name : null +} + +/** + * Helper method to determine the name of the innermost (i.e. current) + * mode. Think of this as an abstraction over CodeMirror's innerMode + * with added type guards. + */ +function getInnerModeName( + mode: CodeMirror.Mode<{}>, + state: any +): string | null { + const inner = innerMode(mode, state) + return inner && inner.mode ? getModeName(inner.mode) : null +} + +/** + * Extract the next token from the line stream or null if no token + * could be extracted from the current position in the line stream. + * + * This method is more or less equal to the readToken method in + * CodeMirror but since the readToken class in CodeMirror isn't included + * in the Node runmode we're not able to use it. + * + * Worth noting here is that we're also replicated the workaround for + * modes that aren't adhering to the rules of never returning without + * advancing the line stream by trying it again (up to ten times). See + * https://github.com/codemirror/CodeMirror/commit/2c60a2 for more + * details on that. + * + * @param mode The current (outer) mode + * @param stream The StringStream for the current line + * @param state The current mode state (if any) + * @param addModeClass Whether or not to append the current (inner) mode name + * as an extra CSS class to the token, indicating the mode + * that produced it, prefixed with "cm-m-". For example, + * tokens from the XML mode will get the cm-m-xml class. + */ +function readToken( + mode: CodeMirror.Mode<{}>, + stream: StringStream, + state: any, + addModeClass: boolean +): string | null { + for (let i = 0; i < 10; i++) { + const innerModeName = addModeClass ? getInnerModeName(mode, state) : null + const token = mode.token(stream, state) + + if (stream.pos > stream.start) { + return token && innerModeName ? `m-${innerModeName} ${token}` : token + } + } + + throw new Error(`Mode ${getModeName(mode)} failed to advance stream.`) +} + +onmessage = async (ev: MessageEvent) => { + const request = ev.data as IHighlightRequest + + const tabSize = request.tabSize || 4 + const addModeClass = request.addModeClass === true + + const mode = await detectMode(request) + + if (!mode) { + postMessage({}) + return + } + + const lineFilter = + request.lines && request.lines.length + ? new Set(request.lines) + : null + + // If we've got a set of requested lines we can keep track of the maximum + // line we need so that we can bail immediately when we've reached it. + const maxLine = lineFilter ? Math.max(...lineFilter) : null + + const lines = request.contentLines.concat() + const state: any = mode.startState ? mode.startState() : null + + const tokens: ITokens = {} + + for (const [ix, line] of lines.entries()) { + // No need to continue after the max line + if (maxLine !== null && ix > maxLine) { + break + } + + // For stateless modes we can optimize by only running + // the tokenizer over lines we care about. + if (lineFilter && !state) { + if (!lineFilter.has(ix)) { + continue + } + } + + if (!line.length) { + if (mode.blankLine) { + mode.blankLine(state) + } + + continue + } + + const lineCtx = { + lines, + line: ix, + lookAhead: (n: number) => lines[ix + n], + } + const lineStream = new StringStream(line, tabSize, lineCtx) + + while (!lineStream.eol()) { + const token = readToken(mode, lineStream, state, addModeClass) + + if (token && (!lineFilter || lineFilter.has(ix))) { + tokens[ix] = tokens[ix] || {} + tokens[ix][lineStream.start] = { + length: lineStream.pos - lineStream.start, + token, + } + } + + lineStream.start = lineStream.pos + } + } + + postMessage(tokens) +} diff --git a/app/src/highlighter/tsconfig.json b/app/src/highlighter/tsconfig.json new file mode 100644 index 0000000000..dc0e648134 --- /dev/null +++ b/app/src/highlighter/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "esnext", + "moduleResolution": "node", + "target": "ES2021", + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "sourceMap": true, + "strict": true, + "outDir": "../../../out", + "lib": ["scripthost", "webworker", "es2021"], + "types": [] + }, + "compileOnSave": false +} diff --git a/app/src/lib/2fa.ts b/app/src/lib/2fa.ts new file mode 100644 index 0000000000..4e289590e6 --- /dev/null +++ b/app/src/lib/2fa.ts @@ -0,0 +1,26 @@ +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/__mocks__/window-state.ts b/app/src/lib/__mocks__/window-state.ts new file mode 100644 index 0000000000..3c1ca1f8bb --- /dev/null +++ b/app/src/lib/__mocks__/window-state.ts @@ -0,0 +1 @@ +export const getWindowState = jest.fn() diff --git a/app/src/lib/actions-log-parser/action-log-parser.ts b/app/src/lib/actions-log-parser/action-log-parser.ts new file mode 100644 index 0000000000..cc50554094 --- /dev/null +++ b/app/src/lib/actions-log-parser/action-log-parser.ts @@ -0,0 +1,740 @@ +import { + base8BitColors, + bgColors, + colorIncrements216, + COMMAND, + commandToType, + END_GROUP, + ERROR, + fgColors, + getType, + GROUP, + ICON, + maxCommandLength, + NOTICE, + Resets, + SECTION, + specials, + supportedCommands, + WARNING, +} from './action-log-pipeline-commands' +import { + commandEnd, + commandStart, + hashChar, + IAnsiEscapeCodeState, + ILine, + ILogLine, + ILogLineTemplateData, + IParsedContent, + IParsedOutput, + IParseNode, + IRGBColor, + IStyle, + newLineChar, + NodeType, + unsetValue, +} from './actions-log-parser-objects' +import { + BrightClassPostfix, + ESC, + TimestampLength, + TimestampRegex, + UrlRegex, + _ansiEscapeCodeRegex, +} from './actions-logs-ansii' + +export function getText(text: string) { + return (text || '').toLocaleLowerCase() +} + +export class ActionsLogParser { + private logLines: ILogLine[] + private logContent: string + private rawLogData: string + private lineMetaData: ILine[] + private logLineNumbers: number[] + private timestamps: string[] + /** + * This is used a prefix to the line number if we would want the line numbers + * to be links to dotcom logs. + * + * Example: https://github.com/desktop/desktop/runs/3840220073#step:13 + */ + private permalinkPrefix: string + + public constructor(rawLogData: string, permalinkPrefix: string) { + this.rawLogData = rawLogData + this.permalinkPrefix = permalinkPrefix + this.logContent = '' + this.lineMetaData = [] + this.logLines = [] + this.logLineNumbers = [] + this.timestamps = [] + this.updateLogLines() + } + + /** + * Returns the parsed lines in the form of an object with the meta data needed + * to build a an html element for the line. + * + * @param node + * @param groupExpanded + * @returns + */ + public getParsedLogLinesTemplateData(): ReadonlyArray { + return this.logLines.map(ll => this.getParsedLineTemplateData(ll)) + } + + /** + * Returns a line object with the meta data needed to build a an html element + * for the line. + * + */ + private getParsedLineTemplateData(node: ILogLine): ILogLineTemplateData { + const { lineIndex } = node + const logLineNumber = this.logLineNumbers[lineIndex] + const lineNumber = logLineNumber != null ? logLineNumber : lineIndex + 1 + const text = this.getNodeText(node) + + // parse() does the ANSI parsing and converts to HTML + const lineContent = this.parse(text) + + // The parser assigns a type to each line. Such as "debug" or "command". + // These change the way the line looks. See checks.scss for the css. + const className = `log-line-${getType(node)}` + + return { + className, + lineNumber, + lineContent, + timeStamp: this.timestamps[lineIndex], + lineUrl: `${this.permalinkPrefix}:${lineNumber}`, + isGroup: node.type === NodeType.Group, + inGroup: node.group?.lineIndex != null, + isError: node.type === NodeType.Error, + isWarning: node.type === NodeType.Warning, + isNotice: node.type === NodeType.Notice, + groupExpanded: false, + } + } + + private updateLogLines() { + this.updateLineMetaData() + + this.logLines = [] + for (const line of this.lineMetaData) { + this.logLines.push(...line.nodes) + } + } + + private updateLineMetaData() { + const lines = this.rawLogData.split(/\r?\n/) + // Example log line with timestamp: + // 2019-07-11T16:56:45.9315998Z #[debug]Evaluating condition for step: 'fourth step' + const timestampRegex = /^(.{27}Z) / + + this.timestamps = [] + this.logContent = lines + .map(line => { + const match = line.match(timestampRegex) + const timestamp = match && new Date(match[1]) + + let ts = '' + if (match && timestamp && !isNaN(Number(timestamp))) { + ts = timestamp.toUTCString() + line = line.substring(match[0].length) + } + + if (!line.startsWith('##[endgroup]')) { + // endgroup lines are not rendered and do not increase the lineIndex, so we don't want to store a timestamp for them, + // otherwise we will get wrong timestamps in subsequent lines + this.timestamps.push(ts) + } + return line + }) + .join('\n') + + this.lineMetaData = this.parseLines(this.logContent) + } + + private getNodeText(node: ILogLine) { + if (node.text == null) { + node.text = this.logContent.substring(node.start, node.end + 1) + } + return node.text + } + + /** + * Converts the content to HTML with appropriate styles, escapes content to prevent XSS + * + * @param content + * @param lineNumber + */ + private parse(content: string): IParsedContent[] { + const result = [] + const states = this.getStates(content) + for (const x of states) { + const classNames: string[] = [] + const styles: string[] = [] + const currentText = x.output + if (x.style) { + const fg = x.style.fg + const bg = x.style.bg + const isFgRGB = x.style.isFgRGB + const isBgRGB = x.style.isBgRGB + if (fg && !isFgRGB) { + classNames.push(`ansifg-${fg}`) + } + if (bg && !isBgRGB) { + classNames.push(`ansibg-${bg}`) + classNames.push(`d-inline-flex`) + } + if (fg && isFgRGB) { + styles.push(`color:rgb(${fg})`) + } + if (bg && isBgRGB) { + styles.push(`background-color:rgb(${bg})`) + classNames.push(`d-inline-flex`) + } + if (x.style.bold) { + classNames.push('text-bold') + } + if (x.style.italic) { + classNames.push('text-italic') + } + if (x.style.underline) { + classNames.push('text-underline') + } + } + + const output: IParsedOutput[] = [] + + // Split the current text using the URL Regex pattern, if there are no URLs, there were will be single entry + const splitUrls = currentText.split(UrlRegex) + for (const entry of splitUrls) { + if (entry === '') { + continue + } + if (entry.match(UrlRegex)) { + const prefixMatch = entry.match(/^[{(<[]*/) + const entryPrefix = prefixMatch == null ? '' : prefixMatch[0] + let entrySuffix = '' + if (entryPrefix) { + const suffixRegex = new RegExp( + `[})>\\]]{${entryPrefix.length}}(?=$)` + ) + const suffixMatch = entry.match(suffixRegex) + entrySuffix = suffixMatch == null ? '' : suffixMatch[0] + } + const entryUrl = entry + .replace(entryPrefix, '') + .replace(entrySuffix, '') + + output.push({ + entry: entryPrefix, + entryUrl, + afterUrl: entrySuffix, + }) + } else { + output.push({ entry }) + } + } + result.push({ + classes: classNames, + styles, + output, + }) + } + + return result + } + + /** + * Parses the content into lines with nodes + * + * @param content content to parse + */ + private parseLines(content: string): ILine[] { + // lines we return + const lines: ILine[] = [] + // accumulated nodes for a particular line + let nodes: IParseNode[] = [] + + // start of a particular line + let lineStartIndex = 0 + // start of plain node content + let plainNodeStart = unsetValue + + // tells to consider the default logic where we check for plain text etc., + let considerDefaultLogic = true + + // stores the command, to match one of the 'supportedCommands' + let currentCommand = '' + // helps in finding commands in our format "##[command]" or "[command]" + let commandSeeker = '' + + // when line ends, this tells if there's any pending node + let pendingLastNode: number = unsetValue + + const resetCommandVar = () => { + commandSeeker = '' + currentCommand = '' + } + + const resetPlain = () => { + plainNodeStart = unsetValue + } + + const resetPending = () => { + pendingLastNode = unsetValue + } + + const parseCommandEnd = () => { + // possible continuation of our well-known commands + const commandIndex = supportedCommands.indexOf(currentCommand) + if (commandIndex !== -1) { + considerDefaultLogic = false + // we reached the end and found the command + resetPlain() + // command is for the whole line, so we are not pushing the node here but defering this to when we find the new line + pendingLastNode = commandToType[currentCommand] + + if ( + currentCommand === SECTION || + currentCommand === GROUP || + currentCommand === END_GROUP || + currentCommand === COMMAND || + currentCommand === ERROR || + currentCommand === WARNING || + currentCommand === NOTICE + ) { + // strip off ##[$(currentCommand)] if there are no timestamps at start + const possibleTimestamp = + content.substring( + lineStartIndex, + lineStartIndex + TimestampLength + ) || '' + if (!possibleTimestamp.match(TimestampRegex)) { + // ## is optional, so pick the right offfset + const offset = + content.substring(lineStartIndex, lineStartIndex + 2) === '##' + ? 4 + : 2 + lineStartIndex = lineStartIndex + offset + currentCommand.length + } + } + + if (currentCommand === GROUP) { + groupStarted = true + } + + // group logic- happyCase1: we found endGroup and there's already a group starting + if (currentCommand === END_GROUP && currentGroupNode) { + groupEnded = true + } + } + + resetCommandVar() + } + + let groupStarted = false + let groupEnded = false + let currentGroupNode: IParseNode | undefined + let nodeIndex = 0 + + for (let index = 0; index < content.length; index++) { + const char = content.charAt(index) + // start with considering default logic, individual conditions are responsible to set it false + considerDefaultLogic = true + if (char === newLineChar || index === content.length - 1) { + if (char === commandEnd) { + parseCommandEnd() + } + + const node = { + type: pendingLastNode, + start: lineStartIndex, + end: index, + lineIndex: lines.length, + } as IParseNode + + let pushNode = false + // end of the line/text, push any final nodes + if (pendingLastNode !== NodeType.Plain) { + // there's pending special node to be pushed + pushNode = true + // a new group has just started + if (groupStarted) { + currentGroupNode = node + groupStarted = false + } + // a group has ended + if (groupEnded && currentGroupNode) { + // this is a throw away node + pushNode = false + currentGroupNode.isGroup = true + // since group has ended, clear all of our pointers + groupEnded = false + currentGroupNode = undefined + } + } else if (pendingLastNode === NodeType.Plain) { + // there's pending plain node to be pushed + pushNode = true + } + + if (pushNode) { + node.index = nodeIndex++ + nodes.push(node) + } + + // A group is pending + if (currentGroupNode && node !== currentGroupNode) { + node.group = { + lineIndex: currentGroupNode.lineIndex, + nodeIndex: currentGroupNode.index, + } + } + + // end of the line, push all nodes that are accumulated till now + if (nodes.length > 0) { + lines.push({ nodes }) + } + + // clear node as we are done with the line + nodes = [] + // increment lineStart for the next line + lineStartIndex = index + 1 + // unset + resetPlain() + resetPending() + resetCommandVar() + + considerDefaultLogic = false + } else if (char === hashChar) { + // possible start of our well-known commands + if (commandSeeker === '' || commandSeeker === '#') { + considerDefaultLogic = false + commandSeeker += hashChar + } + } else if (char === commandStart) { + // possible continuation of our well-known commands + if (commandSeeker === '##') { + considerDefaultLogic = false + commandSeeker += commandStart + } else if (commandSeeker.length === 0) { + // covers - "", for live logs, commands will be of [section], with out "##" + considerDefaultLogic = false + commandSeeker += commandStart + } + } else if (char === commandEnd) { + if (currentCommand === ICON) { + const startIndex = index + 1 + let endIndex = startIndex + for (let i = startIndex; i < content.length; i++) { + const iconChar = content[i] + if (iconChar === ' ') { + endIndex = i + break + } + } + nodes.push({ + type: NodeType.Icon, + lineIndex: lines.length, + start: startIndex, + end: endIndex, + index: nodeIndex++, + }) + // jump to post Icon content + index = endIndex + 1 + lineStartIndex = index + continue + } else { + parseCommandEnd() + } + } + + if (considerDefaultLogic) { + if (commandSeeker === '##[' || commandSeeker === '[') { + // it's possible that we are parsing a command + currentCommand += char.toLowerCase() + } + + if (currentCommand.length > maxCommandLength) { + // to avoid accumulating command unncessarily, let's check max length, if it exceeds, it's not a command + resetCommandVar() + } + + // considering as plain text + if (plainNodeStart === unsetValue) { + // we didn't set this yet, set now + plainNodeStart = lineStartIndex + // set pending node if there isn't one pending + if (pendingLastNode === unsetValue) { + pendingLastNode = NodeType.Plain + } + } + } + } + + return lines + } + + /** + * Parses the content into ANSII states + * + * @param content content to parse + */ + private getStates(content: string): IAnsiEscapeCodeState[] { + const result: IAnsiEscapeCodeState[] = [] + // Eg: "ESC[0KESC[33;1mWorker informationESC[0m + if (!_ansiEscapeCodeRegex.test(content)) { + // Not of interest, don't touch content + return [ + { + output: content, + }, + ] + } + + let command = '' + let currentText = '' + let code = '' + let state = {} as IAnsiEscapeCodeState + let isCommandActive = false + let codes = [] + for (let index = 0; index < content.length; index++) { + const character = content[index] + if (isCommandActive) { + if (character === ';') { + if (code) { + codes.push(code) + code = '' + } + } else if (character === 'm') { + if (code) { + isCommandActive = false + // done + codes.push(code) + state.style = state.style || ({} as IStyle) + + let setForeground = false + let setBackground = false + let isSingleColorCode = false + let isRGBColorCode = false + const rgbColors: number[] = [] + + for (const currentCode of codes) { + const style = state.style as IStyle + const codeNumber = parseInt(currentCode) + if (setForeground && isSingleColorCode) { + // set foreground color using 8-bit (256 color) palette - Esc[ 38:5: m + if (codeNumber >= 0 && codeNumber < 16) { + style.fg = this._get8BitColorClasses(codeNumber) + } else if (codeNumber >= 16 && codeNumber < 256) { + style.fg = this._get8BitRGBColors(codeNumber) + style.isFgRGB = true + } + setForeground = false + isSingleColorCode = false + } else if (setForeground && isRGBColorCode) { + // set foreground color using 24-bit (true color) palette - Esc[ 38:2::: m + if (codeNumber >= 0 && codeNumber < 256) { + rgbColors.push(codeNumber) + if (rgbColors.length === 3) { + style.fg = `${rgbColors[0]},${rgbColors[1]},${rgbColors[2]}` + style.isFgRGB = true + rgbColors.length = 0 // clear array + setForeground = false + isRGBColorCode = false + } + } + } else if (setBackground && isSingleColorCode) { + // set background color using 8-bit (256 color) palette - Esc[ 48:5: m + if (codeNumber >= 0 && codeNumber < 16) { + style.bg = this._get8BitColorClasses(codeNumber) + } else if (codeNumber >= 16 && codeNumber < 256) { + style.bg = this._get8BitRGBColors(codeNumber) + style.isBgRGB = true + } + setBackground = false + isSingleColorCode = false + } else if (setBackground && isRGBColorCode) { + // set background color using 24-bit (true color) palette - Esc[ 48:2::: m + if (codeNumber >= 0 && codeNumber < 256) { + rgbColors.push(codeNumber) + if (rgbColors.length === 3) { + style.bg = `${rgbColors[0]},${rgbColors[1]},${rgbColors[2]}` + style.isBgRGB = true + rgbColors.length = 0 // clear array + setBackground = false + isRGBColorCode = false + } + } + } else if (setForeground || setBackground) { + if (codeNumber === 5) { + isSingleColorCode = true + } else if (codeNumber === 2) { + isRGBColorCode = true + } + } else if (fgColors[currentCode]) { + style.fg = fgColors[currentCode] + } else if (bgColors[currentCode]) { + style.bg = bgColors[currentCode] + } else if (currentCode === Resets.Reset) { + // reset + state.style = {} as IStyle + } else if (currentCode === Resets.Set_Bg) { + setBackground = true + } else if (currentCode === Resets.Set_Fg) { + setForeground = true + } else if (currentCode === Resets.Default_Fg) { + style.fg = '' + } else if (currentCode === Resets.Default_Bg) { + style.bg = '' + } else if (codeNumber >= 91 && codeNumber <= 97) { + style.fg = fgColors[codeNumber - 60] + BrightClassPostfix + } else if (codeNumber >= 101 && codeNumber <= 107) { + style.bg = bgColors[codeNumber - 60] + BrightClassPostfix + } else if (specials[currentCode]) { + style[specials[currentCode]] = true + } else if (currentCode === Resets.Bold) { + style.bold = false + } else if (currentCode === Resets.Italic) { + style.italic = false + } else if (currentCode === Resets.Underline) { + style.underline = false + } + } + + // clear + command = '' + currentText = '' + code = '' + } else { + // To handle ESC[m, we should just ignore them + isCommandActive = false + command = '' + state.style = {} as IStyle + } + + codes = [] + } else if (isNaN(parseInt(character))) { + // if this is not a number, eg: 0K, this isn't something we support + code = '' + isCommandActive = false + command = '' + } else if (code.length === 4) { + // we probably got code that we don't support, ignore + code = '' + isCommandActive = false + if (character !== ESC) { + // if this is not an ESC, let's not consider command from now on + // eg: ESC[0Ksometexthere, at this point, code would be 0K, character would be 's' + command = '' + currentText += character + } + } else { + code += character + } + + continue + } else if (command) { + if (command === ESC && character === '[') { + isCommandActive = true + // push state + if (currentText) { + state.output = currentText + result.push(state) + // deep copy exisiting style for the line to preserve different styles between commands + let previousStyle + if (state.style) { + previousStyle = Object.assign({}, state.style) + } + state = {} as IAnsiEscapeCodeState + if (previousStyle) { + state.style = previousStyle + } + currentText = '' + } + } + + continue + } + + if (character === ESC) { + command = character + } else { + currentText += character + } + } + + // still pending text + if (currentText) { + state.output = currentText + (command ? command : '') + result.push(state) + } + + return result + } + + /** + * With 8 bit colors, from 16-256, rgb color combinations are used + * 16-231 (216 colors) is a 6 x 6 x 6 color cube + * 232 - 256 are grayscale colors + * + * @param codeNumber 16-256 number + */ + private _get8BitRGBColors(codeNumber: number): string { + let rgbColor: IRGBColor + if (codeNumber < 232) { + rgbColor = this._get216Color(codeNumber - 16) + } else { + rgbColor = this._get8bitGrayscale(codeNumber - 232) + } + return `${rgbColor.r},${rgbColor.g},${rgbColor.b}` + } + + /** + * With 8 bit color, from 0-15, css classes are used to represent customer colors + * + * @param codeNumber 0-15 number that indicates the standard or high intensity color code that should be used + */ + private _get8BitColorClasses(codeNumber: number): string { + let colorClass = '' + if (codeNumber < 8) { + colorClass = `${base8BitColors[codeNumber]}` + } else { + colorClass = `${base8BitColors[codeNumber - 8] + BrightClassPostfix}` + } + return colorClass + } + + /** + * 6 x 6 x 6 (216 colors) rgb color generator + * https://en.wikipedia.org/wiki/Web_colors#Web-safe_colors + * + * @param increment 0-215 value + */ + private _get216Color(increment: number): IRGBColor { + return { + r: colorIncrements216[Math.floor(increment / 36)], + g: colorIncrements216[Math.floor(increment / 6) % 6], + b: colorIncrements216[increment % 6], + } + } + + /** + * Grayscale from black to white in 24 steps. The first value of 0 represents rgb(8,8,8) while the last value represents rgb(238,238,238) + * + * @param increment 0-23 value + */ + private _get8bitGrayscale(increment: number): IRGBColor { + const colorCode = increment * 10 + 8 + return { + r: colorCode, + g: colorCode, + b: colorCode, + } + } +} diff --git a/app/src/lib/actions-log-parser/action-log-pipeline-commands.ts b/app/src/lib/actions-log-parser/action-log-pipeline-commands.ts new file mode 100644 index 0000000000..b17518dd08 --- /dev/null +++ b/app/src/lib/actions-log-parser/action-log-pipeline-commands.ts @@ -0,0 +1,128 @@ +import { IParseNode, NodeType } from './actions-log-parser-objects' + +export enum Resets { + Reset = '0', + Bold = '22', + Italic = '23', + Underline = '24', + Set_Fg = '38', + Default_Fg = '39', + Set_Bg = '48', + Default_Bg = '49', +} + +export const specials = { + '1': 'bold', + '3': 'italic', + '4': 'underline', +} as { [key: string]: string } + +export const bgColors = { + // 40 (black), 41 (red), 42 (green), 43 (yellow), 44 (blue), 45 (magenta), 46 (cyan), 47 (white), 100 (grey) + '40': 'b', + '41': 'r', + '42': 'g', + '43': 'y', + '44': 'bl', + '45': 'm', + '46': 'c', + '47': 'w', + '100': 'gr', +} as { [key: string]: string } + +export const fgColors = { + // 30 (black), 31 (red), 32 (green), 33 (yellow), 34 (blue), 35 (magenta), 36 (cyan), 37 (white), 90 (grey) + '30': 'b', + '31': 'r', + '32': 'g', + '33': 'y', + '34': 'bl', + '35': 'm', + '36': 'c', + '37': 'w', + '90': 'gr', +} as { [key: string]: string } + +export const base8BitColors = { + // 0 (black), 1 (red), 2 (green), 3 (yellow), 4 (blue), 5 (magenta), 6 (cyan), 7 (white), 8 (grey) + '0': 'b', + '1': 'r', + '2': 'g', + '3': 'y', + '4': 'bl', + '5': 'm', + '6': 'c', + '7': 'w', +} as Record + +//0-255 in 6 increments, used to generate 216 equally incrementing colors +export const colorIncrements216 = { + 0: 0, + 1: 51, + 2: 102, + 3: 153, + 4: 204, + 5: 255, +} as Record + +export const PLAIN = 'plain' +export const COMMAND = 'command' +export const DEBUG = 'debug' +export const ERROR = 'error' +export const INFO = 'info' +export const SECTION = 'section' +export const VERBOSE = 'verbose' +export const WARNING = 'warning' +export const GROUP = 'group' +export const END_GROUP = 'endgroup' +export const ICON = 'icon' +export const NOTICE = 'notice' + +export const commandToType: { [command: string]: NodeType } = { + command: NodeType.Command, + debug: NodeType.Debug, + error: NodeType.Error, + info: NodeType.Info, + section: NodeType.Section, + verbose: NodeType.Verbose, + warning: NodeType.Warning, + notice: NodeType.Notice, + group: NodeType.Group, + endgroup: NodeType.EndGroup, + icon: NodeType.Icon, +} + +export const typeToCommand: { [type: string]: string } = { + '0': PLAIN, + '1': COMMAND, + '2': DEBUG, + '3': ERROR, + '4': INFO, + '5': SECTION, + '6': VERBOSE, + '7': WARNING, + '8': GROUP, + '9': END_GROUP, + '10': ICON, + '11': NOTICE, +} + +// Store the max command length we support, for example, we support "section", "command" which are of length 7, which highest of all others +export const maxCommandLength = 8 +export const supportedCommands = [ + COMMAND, + DEBUG, + ERROR, + INFO, + SECTION, + VERBOSE, + WARNING, + GROUP, + END_GROUP, + ICON, + NOTICE, +] + +export function getType(node: IParseNode) { + return typeToCommand[node.type] +} diff --git a/app/src/lib/actions-log-parser/actions-log-parser-objects.ts b/app/src/lib/actions-log-parser/actions-log-parser-objects.ts new file mode 100644 index 0000000000..a5bb82c9f3 --- /dev/null +++ b/app/src/lib/actions-log-parser/actions-log-parser-objects.ts @@ -0,0 +1,114 @@ +export const enum NodeType { + Plain = 0, + Command = 1, + Debug = 2, + Error = 3, + Info = 4, + Section = 5, + Verbose = 6, + Warning = 7, + Group = 8, + EndGroup = 9, + Icon = 10, + Notice = 11, +} + +// Set max to prevent any perf degradations +export const maxLineMatchesToParse = 100 +export const unsetValue = -1 +export const newLineChar = '\n' +export const hashChar = '#' +export const commandStart = '[' +export const commandEnd = ']' + +interface IGroupInfo { + lineIndex: number + nodeIndex: number +} + +export interface ISectionFind { + line: number +} + +export interface IParseNode { + type: NodeType + /** + * Index of the node inside ILine + */ + index: number + /** + * Index of the ILine this node belongs to + */ + lineIndex: number + /** + * Starting index of content + */ + start: number + /** + * Ending index of content + */ + end: number + /** + * If this is part of a group, this will refer to the node that's a group + */ + group?: IGroupInfo + /** + * If this is a group, this would be set + */ + isGroup?: boolean +} + +export interface ILine { + nodes: ReadonlyArray +} + +export interface IStyle { + fg: string + bg: string + isFgRGB: boolean + isBgRGB: boolean + bold: boolean + italic: boolean + underline: boolean + [key: string]: boolean | string +} + +export interface IRGBColor { + r: number + g: number + b: number +} + +export interface IAnsiEscapeCodeState { + output: string + style?: IStyle +} + +export interface IParsedOutput { + entry: string + entryUrl?: string + afterUrl?: string +} +export interface IParsedContent { + classes: ReadonlyArray + styles: ReadonlyArray + output: ReadonlyArray +} + +export interface ILogLine extends IParseNode { + text?: string +} + +export interface ILogLineTemplateData { + className: string + lineNumber: number + lineContent: ReadonlyArray + timeStamp?: string + lineUrl: string + isGroup: boolean + inGroup: boolean + isError: boolean + isWarning: boolean + isNotice: boolean + groupExpanded: boolean +} diff --git a/app/src/lib/actions-log-parser/actions-logs-ansii.ts b/app/src/lib/actions-log-parser/actions-logs-ansii.ts new file mode 100644 index 0000000000..7bae0a61d5 --- /dev/null +++ b/app/src/lib/actions-log-parser/actions-logs-ansii.ts @@ -0,0 +1,52 @@ +export const ESC = '\u001b' +export const TimestampLength = 29 +export const TimestampRegex = /^.{27}Z /gm +export const BrightClassPostfix = '-br' +// export for testing +// match characters that could be enclosing url to cleanly handle url formatting +export const UrlRegex = + /([{(<[]*https?:\/\/[a-z0-9][a-z0-9-]*[a-z0-9]\.[^\s<>|'"]{2,})/gi + +/** + * Regex for matching ANSII escape codes + * \u001b - ESC character + * ?: Non-capturing group + * (?:\u001b[) : Match ESC[ + * (?:[\?|#])??: Match also ? and # formats that we don't supports but want to eat our special characters to get rid of ESC character + * (?:[0-9]{1,3})?: Match one or more occurrences of the simple format we want with out semicolon + * (?:(?:;[0-9]{0,3})*)?: Match one or more occurrences of the format we want with semicolon + */ +// eslint-disable-next-line no-control-regex +export const _ansiEscapeCodeRegex = + /(?:\u001b\[)(?:[?|#])?(?:(?:[0-9]{1,3})?(?:(?:;[0-9]{0,3})*)?[A-Z|a-z])/ + +/** + * http://ascii-table.com/ansi-escape-sequences.php + * https://en.wikipedia.org/wiki/ANSI_escape_code#3/4_bit + * We support sequences of format: + * Esc[CONTENTHEREm + * Where CONTENTHERE can be of format: VALUE;VALUE;VALUE or VALUE + * Where VALUE is SGR parameter https://www.vt100.net/docs/vt510-rm/SGR + * We support: 0 (reset), 1 (bold), 3 (italic), 4 (underline), 22 (not bold), 23 (not italic), 24 (not underline), 38 (set fg), 39 (default fg), 48 (set bg), 49 (default bg), + * fg colors - 30 (black), 31 (red), 32 (green), 33 (yellow), 34 (blue), 35 (magenta), 36 (cyan), 37 (white), 90 (grey) + * with more brighness - 91 (red), 92 (green), 93 (yellow), 94 (blue), 95 (magenta), 96 (cyan), 97 (white) + * bg colors - 40 (black), 41 (red), 42 (green), 43 (yellow), 44 (blue), 45 (magenta), 46 (cyan), 47 (white), 100 (grey) + * with more brighness- 101 (red), 102 (green), 103 (yellow), 104 (blue), 105 (magenta), 106 (cyan), 107 (white) + * Where m refers to the "Graphics mode" + * + * 8-bit color is supported + * https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit + * Esc[38;5; To set the foreground color + * Esc[48;5; To set the background color + * n can be from 0-255 + * 0-7 are standard colors that match the 4_bit color palette + * 8-15 are high intensity colors that match the 4_bit high intensity color palette + * 16-231 are 216 colors that cover the entire spectrum + * 232-255 are grayscale colors that go from black to white in 24 steps + * + * 24-bit color is also supported + * https://en.wikipedia.org/wiki/ANSI_escape_code#24-bit + * Esc[38;2;;; To set the foreground color + * Esc[48;2;;; To set the background color + * Where r,g and b must be between 0-255 + */ diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts new file mode 100644 index 0000000000..4de3845ac2 --- /dev/null +++ b/app/src/lib/api.ts @@ -0,0 +1,2203 @@ +import * as OS from 'os' +import * as URL from 'url' +import { Account } from '../models/account' + +import { + request, + parsedResponse, + HTTPMethod, + APIError, + urlWithQueryString, +} 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' + +const envEndpoint = process.env['DESKTOP_GITHUB_DOTCOM_API_ENDPOINT'] +const envHTMLURL = process.env['DESKTOP_GITHUB_DOTCOM_HTML_URL'] +const envAdditionalCookies = + process.env['DESKTOP_GITHUB_DOTCOM_ADDITIONAL_COOKIES'] + +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 + */ +interface IFetchAllOptions { + /** + * The number of results to ask for on each page when making + * requests to paged API endpoints. + */ + perPage?: number + + /** + * An optional predicate which determines whether or not to + * continue loading results from the API. This can be used + * to put a limit on the number of results to return from + * a paged API resource. + * + * As an example, to stop loading results after 500 results: + * + * `(results) => results.length < 500` + * + * @param results All results retrieved thus far + */ + 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. + * + * Optional, see `getNextPagePathFromLink` for the default + * implementation. + */ + getNextPagePath?: (response: Response) => string | null + + /** + * Whether or not to silently suppress request errors and + * return the results retrieved thus far. If this field is + * `true` the fetchAll method will suppress errors (this is + * also the default behavior if no value is provided for + * this field). Setting this field to false will cause the + * fetchAll method to throw if it encounters an API error + * on any page. + */ + suppressErrors?: boolean +} + +const ClientID = process.env.TEST_ENV ? '' : __OAUTH_CLIENT_ID__ +const ClientSecret = process.env.TEST_ENV ? '' : __OAUTH_SECRET__ + +if (!ClientID || !ClientID.length || !ClientSecret || !ClientSecret.length) { + log.warn( + `DESKTOP_OAUTH_CLIENT_ID and/or DESKTOP_OAUTH_CLIENT_SECRET is undefined. You won't be able to authenticate new users.` + ) +} + +export type GitHubAccountType = 'User' | 'Organization' + +/** The OAuth scopes we want to request */ +const oauthScopes = ['repo', 'user', 'workflow'] + +enum HttpStatusCode { + NotModified = 304, + 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. + */ +export interface IAPIRepository { + readonly clone_url: string + readonly ssh_url: string + readonly html_url: string + readonly name: string + readonly owner: IAPIIdentity + readonly private: boolean + readonly fork: boolean + readonly default_branch: string + readonly pushed_at: string + readonly has_issues: boolean + readonly archived: boolean +} + +/** Information needed to clone a repository. */ +export interface IAPIRepositoryCloneInfo { + /** Canonical clone URL of the repository. */ + readonly url: string + + /** + * Default branch of the repository, if any. This is usually either retrieved + * from the API for GitHub repositories, or undefined for other repositories. + */ + readonly defaultBranch?: string +} + +export interface IAPIFullRepository extends IAPIRepository { + /** + * The parent repository of a fork. + * + * HACK: BEWARE: This is defined as `parent: IAPIRepository | undefined` + * rather than `parent?: ...` even though the parent property is actually + * optional in the API response. So we're lying a bit to the type system + * here saying that this will be present but the only time the difference + * between omission and explicit undefined matters is when using constructs + * like `x in y` or `y.hasOwnProperty('x')` which we do very rarely. + * + * Without at least one non-optional type in this interface TypeScript will + * happily let us pass an IAPIRepository in place of an IAPIFullRepository. + */ + readonly parent: IAPIRepository | undefined + + /** + * The high-level permissions that the currently authenticated + * user enjoys for the repository. Undefined if the API call + * was made without an authenticated user or if the repository + * isn't the primarily requested one (i.e. if this is the parent + * repository of the requested repository) + * + * The permissions hash will also be omitted when the repository + * information is embedded within another object such as a pull + * request (base.repo or head.repo). + * + * In other words, the only time when the permissions property + * will be present is when explicitly fetching the repository + * through the `/repos/user/name` endpoint or similar. + */ + readonly permissions?: IAPIRepositoryPermissions +} + +/* + * Information about how the user is permitted to interact with a repository. + */ +export interface IAPIRepositoryPermissions { + readonly admin: boolean + /* aka 'write' */ + readonly push: boolean + /* aka 'read' */ + readonly pull: boolean +} + +/** + * Information about a commit as returned by the GitHub API. + */ +export interface IAPICommit { + readonly sha: string + readonly author: IAPIIdentity | {} | null +} + +/** + * Entity returned by the `/user/orgs` endpoint. + * + * Because this is specific to one endpoint it omits the `type` member from + * `IAPIIdentity` that callers might expect. + */ +export interface IAPIOrganization { + readonly id: number + readonly url: string + readonly login: string + readonly avatar_url: string +} + +/** + * Minimum subset of an identity returned by the GitHub API + */ +export interface IAPIIdentity { + readonly id: number + readonly login: string + readonly avatar_url: string + readonly html_url: string + readonly type: GitHubAccountType +} + +/** + * Complete identity details returned in some situations by the GitHub API. + * + * If you are not sure what is returned as part of an API response, you should + * use `IAPIIdentity` as that contains the known subset of an identity and does + * not cover scenarios where privacy settings of a user control what information + * is returned. + */ +interface IAPIFullIdentity { + readonly id: number + readonly html_url: string + readonly login: string + readonly avatar_url: string + + /** + * The user's real name or null if the user hasn't provided + * a real name for their public profile. + */ + readonly name: string | null + + /** + * The email address for this user or null if the user has not + * specified a public email address in their profile. + */ + readonly email: string | null + readonly type: GitHubAccountType + readonly plan?: { + readonly name: string + } +} + +/** The users we get from the mentionables endpoint. */ +export interface IAPIMentionableUser { + /** + * A url to an avatar image chosen by the user + */ + readonly avatar_url: string + + /** + * The user's attributable email address or null if the + * user doesn't have an email address that they can be + * attributed by + */ + readonly email: string | null + + /** + * The username or "handle" of the user + */ + readonly login: string + + /** + * The user's real name (or at least the name that the user + * has configured to be shown) or null if the user hasn't provided + * a real name for their public profile. + */ + readonly name: string | null +} + +/** + * Error thrown by `fetchUpdatedPullRequests` when receiving more results than + * what the `maxResults` parameter allows for. + */ +export class MaxResultsError extends Error {} + +/** + * `null` can be returned by the API for legacy reasons. A non-null value is + * set for the primary email address currently, but in the future visibility + * may be defined for each email address. + */ +export type EmailVisibility = 'public' | 'private' | null + +/** + * Information about a user's email as returned by the GitHub API. + */ +export interface IAPIEmail { + readonly email: string + readonly verified: boolean + readonly primary: boolean + readonly visibility: EmailVisibility +} + +/** Information about an issue as returned by the GitHub API. */ +export interface IAPIIssue { + readonly number: number + readonly title: string + readonly state: 'open' | 'closed' + readonly updated_at: string +} + +/** The combined state of a ref. */ +export type APIRefState = 'failure' | 'pending' | 'success' | 'error' + +/** The overall status of a check run */ +export enum APICheckStatus { + Queued = 'queued', + InProgress = 'in_progress', + Completed = 'completed', +} + +/** The conclusion of a completed check run */ +export enum APICheckConclusion { + ActionRequired = 'action_required', + Canceled = 'cancelled', + TimedOut = 'timed_out', + Failure = 'failure', + Neutral = 'neutral', + Success = 'success', + Skipped = 'skipped', + Stale = 'stale', +} + +/** + * The API response for a combined view of a commit + * status for a given ref + */ +export interface IAPIRefStatusItem { + readonly state: APIRefState + readonly target_url: string | null + readonly description: string + readonly context: string + readonly id: number +} + +/** The API response to a ref status request. */ +export interface IAPIRefStatus { + readonly state: APIRefState + readonly total_count: number + readonly statuses: ReadonlyArray +} + +export interface IAPIRefCheckRun { + readonly id: number + readonly url: string + readonly status: APICheckStatus + readonly conclusion: APICheckConclusion | null + readonly name: string + readonly check_suite: IAPIRefCheckRunCheckSuite + readonly app: IAPIRefCheckRunApp + readonly completed_at: string + readonly started_at: string + readonly html_url: string + readonly pull_requests: ReadonlyArray +} + +// NB. Only partially mapped +export interface IAPIRefCheckRunApp { + readonly name: string +} + +// NB. Only partially mapped +export interface IAPIRefCheckRunOutput { + readonly title: string | null + readonly summary: string | null + readonly text: string | null +} + +export interface IAPIRefCheckRunCheckSuite { + readonly id: number +} + +export interface IAPICheckSuite { + readonly id: number + readonly rerequestable: boolean + readonly runs_rerequestable: boolean + readonly status: APICheckStatus + readonly created_at: string +} + +export interface IAPIRefCheckRuns { + readonly total_count: number + readonly check_runs: IAPIRefCheckRun[] +} + +interface IAPIWorkflowRuns { + readonly total_count: number + readonly workflow_runs: ReadonlyArray +} +// NB. Only partially mapped +export interface IAPIWorkflowRun { + readonly id: number + /** + * The workflow_id is the id of the workflow not the individual run. + **/ + readonly workflow_id: number + readonly cancel_url: string + readonly created_at: string + readonly logs_url: string + readonly name: string + readonly rerun_url: string + readonly check_suite_id: number + readonly event: string +} + +export interface IAPIWorkflowJobs { + readonly total_count: number + readonly jobs: IAPIWorkflowJob[] +} + +// NB. Only partially mapped +export interface IAPIWorkflowJob { + readonly id: number + readonly name: string + readonly status: APICheckStatus + readonly conclusion: APICheckConclusion | null + readonly completed_at: string + readonly started_at: string + readonly steps: ReadonlyArray + readonly html_url: string +} + +export interface IAPIWorkflowJobStep { + readonly name: string + readonly number: number + readonly status: APICheckStatus + readonly conclusion: APICheckConclusion | null + readonly completed_at: string + readonly started_at: string + readonly log: string +} + +/** Protected branch information returned by the GitHub API */ +export interface IAPIPushControl { + /** + * What status checks are required before merging? + * + * Empty array if user is admin and branch is not admin-enforced + */ + required_status_checks: Array + + /** + * How many reviews are required before merging? + * + * 0 if user is admin and branch is not admin-enforced + */ + required_approving_review_count: number + + /** + * Is user permitted? + * + * Always `true` for admins. + * `true` if `Restrict who can push` is not enabled. + * `true` if `Restrict who can push` is enabled and user is in list. + * `false` if `Restrict who can push` is enabled and user is not in list. + */ + allow_actor: boolean + + /** + * Currently unused properties + */ + pattern: string | null + required_signatures: boolean + required_linear_history: boolean + allow_deletions: boolean + allow_force_pushes: boolean +} + +/** Branch information returned by the GitHub API */ +export interface IAPIBranch { + /** + * The name of the branch stored on the remote. + * + * NOTE: this is NOT a fully-qualified ref (i.e. `refs/heads/main`) + */ + readonly name: string + /** + * Branch protection settings: + * + * - `true` indicates that the branch is protected in some way + * - `false` indicates no branch protection set + */ + 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 + + /** + * The repository in which this ref lives. It could be null if the repository + * has been deleted since the PR was opened. + */ + readonly repo: IAPIRepository | null +} + +/** Information about a pull request as returned by the GitHub API. */ +export interface IAPIPullRequest { + readonly number: number + readonly title: string + readonly created_at: string + readonly updated_at: string + readonly user: IAPIIdentity + readonly head: IAPIPullRequestRef + readonly base: IAPIPullRequestRef + readonly body: string + readonly state: 'open' | 'closed' + readonly draft?: boolean +} + +/** Information about a pull request review as returned by the GitHub API. */ +export interface IAPIPullRequestReview { + readonly id: number + readonly user: IAPIIdentity + readonly body: string + readonly html_url: string + readonly submitted_at: string + readonly state: + | 'APPROVED' + | 'DISMISSED' + | 'PENDING' + | 'COMMENTED' + | 'CHANGES_REQUESTED' +} + +/** 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 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 +} + +/** The server response when handling the OAuth callback (with code) to obtain an access token */ +interface IAPIAccessToken { + readonly access_token: string + readonly scope: string + 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 + readonly users: ReadonlyArray +} + +/** + * Parses the Link header from GitHub and returns the 'next' path + * if one is present. + * + * If no link rel next header is found this method returns null. + */ +function getNextPagePathFromLink(response: Response): string | null { + const linkHeader = response.headers.get('Link') + + if (!linkHeader) { + return null + } + + for (const part of linkHeader.split(',')) { + // https://github.com/philschatz/octokat.js/blob/5658abe442e8bf405cfda1c72629526a37554613/src/plugins/pagination.js#L17 + const match = part.match(/<([^>]+)>; rel="([^"]+)"/) + + if (match && match[2] === 'next') { + const nextURL = URL.parse(match[1]) + return nextURL.path || null + } + } + + return null +} + +/** + * Parses the 'next' Link header from GitHub using + * `getNextPagePathFromLink`. Unlike `getNextPagePathFromLink` + * this method will attempt to double the page size when + * the current page index and the page size allows for it + * leading to a ramp up in page size. + * + * This might sound confusing, and it is, but the primary use + * case for this is when retrieving updated PRs. By specifying + * an initial page size of, for example, 10 this method will + * increase the page size to 20 once the second page has been + * loaded. See the table below for an example. The ramp-up + * will stop at a page size of 100 since that's the maximum + * that the GitHub API supports. + * + * ``` + * |-----------|------|-----------|-----------------| + * | Request # | Page | Page size | Retrieved items | + * |-----------|------|-----------|-----------------| + * | 1 | 1 | 10 | 10 | + * | 2 | 2 | 10 | 20 | + * | 3 | 2 | 20 | 40 | + * | 4 | 2 | 40 | 80 | + * | 5 | 2 | 80 | 160 | + * | 6 | 3 | 80 | 240 | + * | 7 | 4 | 80 | 320 | + * | 8 | 5 | 80 | 400 | + * | 9 | 5 | 100 | 500 | + * |-----------|------|-----------|-----------------| + * ``` + * This algorithm means we can have the best of both worlds. + * If there's a small number of changed pull requests since + * our last update we'll do small requests that use minimal + * bandwidth but if we encounter a repository where a lot + * of PRs have changed since our last fetch (like a very + * active repository or one we haven't fetched in a long time) + * we'll spool up our page size in just a few requests and load + * in bulk. + * + * As an example I used a very active internal repository and + * asked for all PRs updated in the last 24 hours which was 320. + * With the previous regime of fetching with a page size of 10 + * that obviously took 32 requests. With this new regime it + * would take 7. + */ +export function getNextPagePathWithIncreasingPageSize(response: Response) { + const nextPath = getNextPagePathFromLink(response) + + if (!nextPath) { + return null + } + + const { pathname, query } = URL.parse(nextPath, true) + const { per_page, page } = query + + const pageSize = typeof per_page === 'string' ? parseInt(per_page, 10) : NaN + const pageNumber = typeof page === 'string' ? parseInt(page, 10) : NaN + + if (!pageSize || !pageNumber) { + return nextPath + } + + // Confusing, but we're looking at the _next_ page path here + // so the current is whatever came before it. + const currentPage = pageNumber - 1 + + // Number of received items thus far + const received = currentPage * pageSize + + // Can't go above 100, that's the max the API will allow. + const nextPageSize = Math.min(100, pageSize * 2) + + // Have we received exactly the amount of items + // such that doubling the page size and loading the + // second page would seamlessly fit? No sense going + // above 100 since that's the max the API supports + if (pageSize !== nextPageSize && received % nextPageSize === 0) { + query.per_page = `${nextPageSize}` + query.page = `${received / nextPageSize + 1}` + return URL.format({ pathname, query }) + } + + return nextPath +} + +/** + * Returns an ISO 8601 time string with second resolution instead of + * the standard javascript toISOString which returns millisecond + * resolution. The GitHub API doesn't return dates with milliseconds + * so we won't send any back either. + */ +function toGitHubIsoDateString(date: Date) { + return date.toISOString().replace(/\.\d{3}Z$/, 'Z') +} + +interface IAPIAliveSignedChannel { + readonly channel_name: string + readonly signed_channel: string +} + +interface IAPIAliveWebSocket { + readonly url: string +} + +/** + * An object for making authenticated requests to the GitHub API + */ +export class API { + private static readonly TOKEN_INVALIDATED_EVENT = 'token-invalidated' + + private static readonly emitter = new Emitter() + + public static onTokenInvalidated(callback: (endpoint: string) => void) { + API.emitter.on(API.TOKEN_INVALIDATED_EVENT, callback) + } + + private static emitTokenInvalidated(endpoint: string) { + API.emitter.emit(API.TOKEN_INVALIDATED_EVENT, endpoint) + } + + /** Create a new API client from the given account. */ + public static fromAccount(account: Account): API { + return new API(account.endpoint, account.token) + } + + private endpoint: string + private token: string + + /** Create a new API client for the endpoint, authenticated with the token. */ + public constructor(endpoint: string, token: string) { + this.endpoint = endpoint + this.token = token + } + + /** + * Retrieves the name of the Alive channel used by Desktop to receive + * high-signal notifications. + */ + public async getAliveDesktopChannel(): Promise { + try { + const res = await this.request('GET', '/desktop_internal/alive-channel') + const signedChannel = await parsedResponse(res) + return signedChannel + } catch (e) { + log.warn(`Alive channel request failed: ${e}`) + return null + } + } + + /** + * Retrieves the URL for the Alive websocket. + * + * @returns The websocket URL if the request succeeded, null if the request + * failed with 404, otherwise it will throw an error. + * + * This behavior is expected by the AliveSession class constructor, to prevent + * it from hitting the endpoint many times if it's disabled. + */ + public async getAliveWebSocketURL(): Promise { + try { + const res = await this.request('GET', '/alive_internal/websocket-url') + if (res.status === HttpStatusCode.NotFound) { + return null + } + const websocket = await parsedResponse(res) + return websocket.url + } catch (e) { + log.warn(`Alive web socket request failed: ${e}`) + throw e + } + } + + /** + * 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, + name: string + ): Promise { + try { + const response = await this.request('GET', `repos/${owner}/${name}`) + if (response.status === HttpStatusCode.NotFound) { + log.warn(`fetchRepository: '${owner}/${name}' returned a 404`) + return null + } + return await parsedResponse(response) + } catch (e) { + log.warn(`fetchRepository: an error occurred for '${owner}/${name}'`, e) + return null + } + } + + /** + * Fetch info needed to clone a repository. That includes: + * - The canonical clone URL for a repository, respecting the protocol + * preference if provided. + * - The default branch of the repository, in case the repository is empty. + * Only available for GitHub repositories. + * + * Returns null if the request returned a 404 (NotFound). NotFound doesn't + * necessarily mean that the repository doesn't exist, it could exist and + * the current user just doesn't have the permissions to see it. GitHub.com + * doesn't differentiate between not found and permission denied for private + * repositories as that would leak the existence of a private repository. + * + * Note that unlike `fetchRepository` this method will throw for all errors + * except 404 NotFound responses. + * + * @param owner The repository owner (nodejs in https://github.com/nodejs/node) + * @param name The repository name (node in https://github.com/nodejs/node) + * @param protocol The preferred Git protocol (https or ssh) + */ + public async fetchRepositoryCloneInfo( + owner: string, + name: string, + protocol: GitProtocol | undefined + ): Promise { + const response = await this.request('GET', `repos/${owner}/${name}`, { + // Make sure we don't run into cache issues when fetching the repositories, + // specially after repositories have been renamed. + reloadCache: true, + }) + + if (response.status === HttpStatusCode.NotFound) { + return null + } + + const repo = await parsedResponse(response) + return { + url: protocol === 'ssh' ? repo.ssh_url : repo.clone_url, + defaultBranch: repo.default_branch, + } + } + + /** + * 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 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( + `streamUserRepositories: failed with endpoint ${this.endpoint}`, + error + ) + } + } + + /** Fetch the logged in account. */ + public async fetchAccount(): Promise { + try { + const response = await this.request('GET', 'user') + const result = await parsedResponse(response) + return result + } catch (e) { + log.warn(`fetchAccount: failed with endpoint ${this.endpoint}`, e) + throw e + } + } + + /** Fetch the current user's emails. */ + public async fetchEmails(): Promise> { + try { + const response = await this.request('GET', 'user/emails') + const result = await parsedResponse>(response) + + return Array.isArray(result) ? result : [] + } catch (e) { + log.warn(`fetchEmails: failed with endpoint ${this.endpoint}`, e) + return [] + } + } + + /** Fetch all the orgs to which the user belongs. */ + public async fetchOrgs(): Promise> { + try { + return await this.fetchAll('user/orgs') + } catch (e) { + log.warn(`fetchOrgs: failed with endpoint ${this.endpoint}`, e) + return [] + } + } + + /** Create a new GitHub repository with the given properties. */ + public async createRepository( + org: IAPIOrganization | null, + name: string, + description: string, + private_: boolean + ): Promise { + try { + const apiPath = org ? `orgs/${org.login}/repos` : 'user/repos' + const response = await this.request('POST', apiPath, { + body: { + name, + description, + private: private_, + }, + }) + + return await parsedResponse(response) + } catch (e) { + if (e instanceof APIError) { + if (org !== null) { + throw new Error( + `Unable to create repository for organization '${org.login}'. Verify that the repository does not already exist and that you have permission to create a repository there.` + ) + } + throw e + } + + log.error(`createRepository: failed with endpoint ${this.endpoint}`, e) + throw new Error( + `Unable to publish repository. Please check if you have an internet connection and try again.` + ) + } + } + + /** Create a new GitHub fork of this repository (owner and name) */ + public async forkRepository( + owner: string, + name: string + ): Promise { + try { + const apiPath = `/repos/${owner}/${name}/forks` + const response = await this.request('POST', apiPath) + return await parsedResponse(response) + } catch (e) { + log.error( + `forkRepository: failed to fork ${owner}/${name} at endpoint: ${this.endpoint}`, + e + ) + throw e + } + } + + /** + * Fetch the issues with the given state that have been created or updated + * since the given date. + */ + public async fetchIssues( + owner: string, + name: string, + state: 'open' | 'closed' | 'all', + since: Date | null + ): Promise> { + const params: { [key: string]: string } = { + state, + } + if (since && !isNaN(since.getTime())) { + params.since = toGitHubIsoDateString(since) + } + + const url = urlWithQueryString(`repos/${owner}/${name}/issues`, params) + try { + const issues = await this.fetchAll(url) + + // PRs are issues! But we only want Really Seriously Issues. + return issues.filter((i: any) => !i.pullRequest) + } catch (e) { + log.warn(`fetchIssues: failed for repository ${owner}/${name}`, e) + throw e + } + } + + /** Fetch all open pull requests in the given repository. */ + public async fetchAllOpenPullRequests(owner: string, name: string) { + const url = urlWithQueryString(`repos/${owner}/${name}/pulls`, { + state: 'open', + }) + try { + return await this.fetchAll(url) + } catch (e) { + log.warn(`failed fetching open PRs for repository ${owner}/${name}`, e) + throw e + } + } + + /** + * Fetch all pull requests in the given repository that have been + * updated on or after the provided date. + * + * Note: The GitHub API doesn't support providing a last-updated + * limitation for PRs like it does for issues so we're emulating + * the issues API by sorting PRs descending by last updated and + * only grab as many pages as we need to until we no longer receive + * PRs that have been update more recently than the `since` + * parameter. + * + * If there's more than `maxResults` updated PRs since the last time + * we fetched this method will throw an error such that we can abort + * this strategy and commence loading all open PRs instead. + */ + public async fetchUpdatedPullRequests( + owner: string, + name: string, + since: Date, + // 320 is chosen because with a ramp-up page size starting with + // a page size of 10 we'll reach 320 in exactly 7 pages. See + // getNextPagePathWithIncreasingPageSize + maxResults = 320 + ) { + const sinceTime = since.getTime() + const url = urlWithQueryString(`repos/${owner}/${name}/pulls`, { + state: 'all', + sort: 'updated', + direction: 'desc', + }) + + try { + const prs = await this.fetchAll(url, { + // We use a page size smaller than our default 100 here because we + // expect that the majority use case will return much less than + // 100 results. Given that as long as _any_ PR has changed we'll + // get the full list back (PRs doesn't support ?since=) we want + // to keep this number fairly conservative in order to not use + // up bandwidth needlessly while balancing it such that we don't + // have to use a lot of requests to update our database. We then + // ramp up the page size (see getNextPagePathWithIncreasingPageSize) + // if it turns out there's a lot of updated PRs. + perPage: 10, + getNextPagePath: getNextPagePathWithIncreasingPageSize, + continue(results) { + if (results.length >= maxResults) { + throw new MaxResultsError('got max pull requests, aborting') + } + + // Given that we sort the results in descending order by their + // updated_at field we can safely say that if the last item + // is modified after our sinceTime then haven't reached the + // end of updated PRs. + const last = results.at(-1) + return last !== undefined && Date.parse(last.updated_at) > sinceTime + }, + // We can't ignore errors here as that might mean that we haven't + // retrieved enough pages to fully capture the changes since the + // last time we updated. Ignoring errors here would mean that we'd + // store an incorrect lastUpdated field in the database. + suppressErrors: false, + }) + return prs.filter(pr => Date.parse(pr.updated_at) >= sinceTime) + } catch (e) { + log.warn(`failed fetching updated PRs for repository ${owner}/${name}`, e) + throw e + } + } + + /** + * Fetch a single pull request in the given repository + */ + public async fetchPullRequest(owner: string, name: string, prNumber: string) { + try { + const path = `/repos/${owner}/${name}/pulls/${prNumber}` + const response = await this.request('GET', path) + return await parsedResponse(response) + } catch (e) { + log.warn(`failed fetching PR for ${owner}/${name}/pulls/${prNumber}`, e) + throw e + } + } + + /** + * Fetch a single pull request review in the given repository + */ + public async fetchPullRequestReview( + owner: string, + name: string, + prNumber: string, + reviewId: string + ) { + try { + const path = `/repos/${owner}/${name}/pulls/${prNumber}/reviews/${reviewId}` + const response = await this.request('GET', path) + return await parsedResponse(response) + } catch (e) { + log.debug( + `failed fetching PR review ${reviewId} for ${owner}/${name}/pulls/${prNumber}`, + e + ) + return null + } + } + + /** 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, + 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, { + reloadCache, + }) + + try { + return await parsedResponse(response) + } catch (err) { + log.debug( + `Failed fetching check runs for ref ${ref} (${owner}/${name})`, + err + ) + return null + } + } + + /** + * Get any check run results for the given ref. + */ + public async fetchRefCheckRuns( + owner: string, + name: string, + ref: string, + reloadCache: boolean = false + ): Promise { + const safeRef = encodeURIComponent(ref) + const path = `repos/${owner}/${name}/commits/${safeRef}/check-runs?per_page=100` + const headers = { + Accept: 'application/vnd.github.antiope-preview+json', + } + + const response = await this.request('GET', path, { + customHeaders: headers, + reloadCache, + }) + + try { + return await parsedResponse(response) + } catch (err) { + log.debug( + `Failed fetching check runs for ref ${ref} (${owner}/${name})`, + err + ) + return null + } + } + + /** + * List workflow runs for a repository filtered by branch and event type of + * pull_request + */ + public async fetchPRWorkflowRunsByBranchName( + owner: string, + name: string, + branchName: string + ): Promise { + const path = `repos/${owner}/${name}/actions/runs?event=pull_request&branch=${encodeURIComponent( + branchName + )}` + const customHeaders = { + Accept: 'application/vnd.github.antiope-preview+json', + } + const response = await this.request('GET', path, { customHeaders }) + try { + return await parsedResponse(response) + } catch (err) { + log.debug( + `Failed fetching workflow runs for ${branchName} (${owner}/${name})` + ) + } + return null + } + + /** + * Return the workflow run for a given check_suite_id. + * + * A check suite is a reference for a set check runs. + * A workflow run is a reference for set a of workflows for the GitHub Actions + * check runner. + * + * If a check suite is comprised of check runs ran by actions, there will be + * one workflow run that represents that check suite. Thus, if this api should + * either return an empty array indicating there are no actions runs for that + * check_suite_id (so check suite was not ran by actions) or an array with a + * single element. + */ + public async fetchPRActionWorkflowRunByCheckSuiteId( + owner: string, + name: string, + checkSuiteId: number + ): Promise { + const path = `repos/${owner}/${name}/actions/runs?event=pull_request&check_suite_id=${checkSuiteId}` + const customHeaders = { + Accept: 'application/vnd.github.antiope-preview+json', + } + const response = await this.request('GET', path, { customHeaders }) + try { + const apiWorkflowRuns = await parsedResponse(response) + + if (apiWorkflowRuns.workflow_runs.length > 0) { + return apiWorkflowRuns.workflow_runs[0] + } + } catch (err) { + log.debug( + `Failed fetching workflow runs for ${checkSuiteId} (${owner}/${name})` + ) + } + return null + } + + /** + * List workflow run jobs for a given workflow run + */ + public async fetchWorkflowRunJobs( + owner: string, + name: string, + workflowRunId: number + ): Promise { + const path = `repos/${owner}/${name}/actions/runs/${workflowRunId}/jobs` + const customHeaders = { + Accept: 'application/vnd.github.antiope-preview+json', + } + const response = await this.request('GET', path, { + customHeaders, + }) + try { + return await parsedResponse(response) + } catch (err) { + log.debug( + `Failed fetching workflow jobs (${owner}/${name}) workflow run: ${workflowRunId}` + ) + } + 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. + */ + public async rerequestCheckSuite( + owner: string, + name: string, + checkSuiteId: number + ): Promise { + const path = `/repos/${owner}/${name}/check-suites/${checkSuiteId}/rerequest` + + return this.request('POST', path) + .then(x => x.ok) + .catch(err => { + log.debug( + `Failed retry check suite id ${checkSuiteId} (${owner}/${name})`, + err + ) + return false + }) + } + + /** + * Re-run all of the failed jobs and their dependent jobs in a workflow run + * using the id of the workflow run. + */ + public async rerunFailedJobs( + owner: string, + name: string, + workflowRunId: number + ): Promise { + const path = `/repos/${owner}/${name}/actions/runs/${workflowRunId}/rerun-failed-jobs` + + return this.request('POST', path) + .then(x => x.ok) + .catch(err => { + log.debug( + `Failed to rerun failed workflow jobs for (${owner}/${name}): ${workflowRunId}`, + err + ) + return false + }) + } + + /** + * Re-run a job and its dependent jobs in a workflow run. + */ + public async rerunJob( + owner: string, + name: string, + jobId: number + ): Promise { + const path = `/repos/${owner}/${name}/actions/jobs/${jobId}/rerun` + + return this.request('POST', path) + .then(x => x.ok) + .catch(err => { + log.debug( + `Failed to rerun workflow job (${owner}/${name}): ${jobId}`, + err + ) + return false + }) + } + + /** + * Gets a single check suite using its id + */ + public async fetchCheckSuite( + owner: string, + name: string, + checkSuiteId: number + ): Promise { + const path = `/repos/${owner}/${name}/check-suites/${checkSuiteId}` + const response = await this.request('GET', path) + + try { + return await parsedResponse(response) + } catch (_) { + log.debug( + `[fetchCheckSuite] Failed fetch check suite id ${checkSuiteId} (${owner}/${name})` + ) + } + + return null + } + + /** + * Get branch protection info to determine if a user can push to a given branch. + * + * Note: if request fails, the default returned value assumes full access for the user + */ + public async fetchPushControl( + owner: string, + name: string, + branch: string + ): Promise { + const path = `repos/${owner}/${name}/branches/${encodeURIComponent( + branch + )}/push_control` + + const headers: any = { + Accept: 'application/vnd.github.phandalin-preview', + } + + try { + const response = await this.request('GET', path, { + customHeaders: headers, + }) + return await parsedResponse(response) + } catch (err) { + log.info( + `[fetchPushControl] unable to check if branch is potentially pushable`, + err + ) + return { + pattern: null, + required_signatures: false, + required_status_checks: [], + required_approving_review_count: 0, + required_linear_history: false, + allow_actor: true, + allow_deletions: true, + allow_force_pushes: true, + } + } + } + + public async fetchProtectedBranches( + owner: string, + name: string + ): Promise> { + const path = `repos/${owner}/${name}/branches?protected=true` + try { + const response = await this.request('GET', path) + return await parsedResponse(response) + } catch (err) { + log.info( + `[fetchProtectedBranches] unable to list protected branches`, + err + ) + return new Array() + } + } + + /** + * 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. + * + * Follows the GitHub API hypermedia links to get the subsequent + * pages when available, buffers all items and returns them in + * one array when done. + */ + private async fetchAll(path: string, options?: IFetchAllOptions) { + const buf = new Array() + const opts: IFetchAllOptions = { perPage: 100, ...options } + 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) { + log.warn(`fetchAll: '${path}' returned a ${response.status}`) + return buf + } + + page = await parsedResponse>(response) + if (page) { + buf.push(...page) + opts.onPage?.(page) + } + + nextPath = opts.getNextPagePath + ? opts.getNextPagePath(response) + : getNextPagePathFromLink(response) + } while (nextPath && (!opts.continue || (await opts.continue(buf)))) + + return buf + } + + /** Make an authenticated request to the client's endpoint with its token. */ + private async request( + method: HTTPMethod, + path: string, + options: { + body?: Object + customHeaders?: Object + reloadCache?: boolean + } = {} + ): Promise { + const response = await request( + this.endpoint, + this.token, + method, + path, + options.body, + options.customHeaders, + options.reloadCache + ) + + // Only consider invalid token when the status is 401 and the response has + // the X-GitHub-Request-Id header, meaning it comes from GH(E) and not from + // any kind of proxy/gateway. For more info see #12943 + // We're also not considering a token has been invalidated when the reason + // behind a 401 is the fact that any kind of 2 factor auth is required. + if ( + response.status === 401 && + response.headers.has('X-GitHub-Request-Id') && + !response.headers.has('X-GitHub-OTP') + ) { + API.emitTokenInvalidated(this.endpoint) + } + + tryUpdateEndpointVersionFromResponse(this.endpoint, response) + + return response + } + + /** + * Get the allowed poll interval for fetching. If an error occurs it will + * return null. + */ + public async getFetchPollInterval( + owner: string, + name: string + ): Promise { + const path = `repos/${owner}/${name}/git` + try { + const response = await this.request('HEAD', path) + const interval = response.headers.get('x-poll-interval') + if (interval) { + const parsed = parseInt(interval, 10) + return isNaN(parsed) ? null : parsed + } + return null + } catch (e) { + log.warn(`getFetchPollInterval: failed for ${owner}/${name}`, e) + return null + } + } + + /** Fetch the mentionable users for the repository. */ + public async fetchMentionables( + owner: string, + name: string, + etag: string | undefined + ): Promise { + // NB: this custom `Accept` is required for the `mentionables` endpoint. + const headers: any = { + Accept: 'application/vnd.github.jerry-maguire-preview', + } + + if (etag !== undefined) { + headers['If-None-Match'] = etag + } + + try { + const path = `repos/${owner}/${name}/mentionables/users` + const response = await this.request('GET', path, { + customHeaders: headers, + }) + + if (response.status === HttpStatusCode.NotFound) { + log.warn(`fetchMentionables: '${path}' returned a 404`) + return null + } + + if (response.status === HttpStatusCode.NotModified) { + return null + } + const users = await parsedResponse>( + response + ) + const etag = response.headers.get('etag') || undefined + return { users, etag } + } catch (e) { + log.warn(`fetchMentionables: failed for ${owner}/${name}`, e) + return null + } + } + + /** + * Retrieve the public profile information of a user with + * a given username. + */ + public async fetchUser(login: string): Promise { + try { + const response = await this.request( + 'GET', + `users/${encodeURIComponent(login)}` + ) + + if (response.status === 404) { + return null + } + + return await parsedResponse(response) + } catch (e) { + log.warn(`fetchUser: failed with endpoint ${this.endpoint}`, e) + throw e + } + } +} + +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) + + 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 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 { kind: AuthorizationResponseKind.Error, response } +} + +/** Fetch the user authenticated by the token. */ +export async function fetchUser( + endpoint: string, + token: string +): Promise { + const api = new API(endpoint, token) + try { + const user = await api.fetchAccount() + const emails = await api.fetchEmails() + + return new Account( + user.login, + endpoint, + token, + emails, + user.avatar_url, + user.id, + user.name || user.login, + user.plan?.name + ) + } catch (e) { + log.warn(`fetchUser: failed with endpoint ${endpoint}`, e) + throw e + } +} + +/** 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: + * + * https://github.com/desktop/desktop -> https://api.github.com + * http://github.mycompany.com/my-team/my-project -> http://github.mycompany.com/api + */ +export function getEndpointForRepository(url: string): string { + const parsed = URL.parse(url) + if (parsed.hostname === 'github.com') { + return getDotComAPIEndpoint() + } else { + return `${parsed.protocol}//${parsed.hostname}/api` + } +} + +/** + * Get the URL for the HTML site. For example: + * + * https://api.github.com -> https://github.com + * http://github.mycompany.com/api -> http://github.mycompany.com/ + */ +export function getHTMLURL(endpoint: string): string { + if (envHTMLURL !== undefined) { + return envHTMLURL + } + + // In the case of GitHub.com, the HTML site lives on the parent domain. + // E.g., https://api.github.com -> https://github.com + // + // Whereas with Enterprise, it lives on the same domain but without the + // API path: + // E.g., https://github.mycompany.com/api/v3 -> https://github.mycompany.com + // + // We need to normalize them. + if (endpoint === getDotComAPIEndpoint() && !envEndpoint) { + return 'https://github.com' + } else { + const parsed = URL.parse(endpoint) + return `${parsed.protocol}//${parsed.hostname}` + } +} + +/** + * Get the API URL for an HTML URL. For example: + * + * http://github.mycompany.com -> http://github.mycompany.com/api/v3 + */ +export function getEnterpriseAPIURL(endpoint: string): string { + const parsed = URL.parse(endpoint) + return `${parsed.protocol}//${parsed.hostname}/api/v3` +} + +/** Get github.com's API endpoint. */ +export function getDotComAPIEndpoint(): string { + // NOTE: + // `DESKTOP_GITHUB_DOTCOM_API_ENDPOINT` only needs to be set if you are + // developing against a local version of GitHub the Website, and need to debug + // the server-side interaction. For all other cases you should leave this + // unset. + if (envEndpoint && envEndpoint.length > 0) { + return envEndpoint + } + + return 'https://api.github.com' +} + +/** Get the account for the endpoint. */ +export function getAccountForEndpoint( + accounts: ReadonlyArray, + endpoint: string +): Account | null { + return accounts.find(a => a.endpoint === endpoint) || null +} + +export function getOAuthAuthorizationURL( + endpoint: string, + state: string +): string { + const urlBase = getHTMLURL(endpoint) + const scopes = oauthScopes + const scope = encodeURIComponent(scopes.join(' ')) + return `${urlBase}/login/oauth/authorize?client_id=${ClientID}&scope=${scope}&state=${state}` +} + +export async function requestOAuthToken( + endpoint: string, + code: string +): Promise { + try { + const urlBase = getHTMLURL(endpoint) + const response = await request( + urlBase, + null, + 'POST', + 'login/oauth/access_token', + { + client_id: ClientID, + client_secret: ClientSecret, + code: code, + } + ) + tryUpdateEndpointVersionFromResponse(endpoint, response) + + const result = await parsedResponse(response) + return result.access_token + } catch (e) { + log.warn(`requestOAuthToken: failed with endpoint ${endpoint}`, e) + return null + } +} + +function tryUpdateEndpointVersionFromResponse( + endpoint: string, + response: Response +) { + const gheVersion = response.headers.get('x-github-enterprise-version') + if (gheVersion !== null) { + updateEndpointVersion(endpoint, gheVersion) + } +} diff --git a/app/src/lib/app-shell.ts b/app/src/lib/app-shell.ts new file mode 100644 index 0000000000..2b437e7c57 --- /dev/null +++ b/app/src/lib/app-shell.ts @@ -0,0 +1,64 @@ +import { shell as electronShell } from 'electron' +import * as Path from 'path' + +import { Repository } from '../models/repository' +import { + showItemInFolder, + showFolderContents, + openExternal, + moveItemToTrash, +} from '../ui/main-process-proxy' + +export interface IAppShell { + readonly moveItemToTrash: (path: string) => Promise + readonly beep: () => void + readonly openExternal: (path: string) => Promise + /** + * Reveals the specified file using the operating + * system default application. + * Do not use this method with non-validated paths. + * + * @param path - The path of the file to open + */ + + readonly openPath: (path: string) => Promise + /** + * Reveals the specified file on the operating system + * default file explorer. If a folder is passed, it will + * open its parent folder and preselect the passed folder. + * + * @param path - The path of the file to show + */ + readonly showItemInFolder: (path: string) => void + /** + * Reveals the specified folder on the operating + * system default file explorer. + * Do not use this method with non-validated paths. + * + * @param path - The path of the folder to open + */ + readonly showFolderContents: (path: string) => void +} + +export const shell: IAppShell = { + // Since Electron 13, shell.trashItem doesn't work from the renderer process + // on Windows. Therefore, we must invoke it from the main process. See + // https://github.com/electron/electron/issues/29598 + moveItemToTrash, + beep: electronShell.beep, + openExternal, + showItemInFolder, + showFolderContents, + openPath: electronShell.openPath, +} + +/** + * Reveals a file from a repository in the native file manager. + * + * @param repository The currently active repository instance + * @param path The path of the file relative to the root of the repository + */ +export function revealInFileManager(repository: Repository, path: string) { + const fullyQualifiedFilePath = Path.join(repository.path, path) + return shell.showItemInFolder(fullyQualifiedFilePath) +} diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts new file mode 100644 index 0000000000..ba1523f42c --- /dev/null +++ b/app/src/lib/app-state.ts @@ -0,0 +1,1010 @@ +import { Account } from '../models/account' +import { CommitIdentity } from '../models/commit-identity' +import { IDiff, ImageDiffType } from '../models/diff' +import { Repository, ILocalRepositoryState } from '../models/repository' +import { Branch, IAheadBehind } from '../models/branch' +import { Tip } from '../models/tip' +import { Commit } from '../models/commit' +import { CommittedFileChange, WorkingDirectoryStatus } from '../models/status' +import { CloningRepository } from '../models/cloning-repository' +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, + PullRequestSuggestedNextAction, +} from '../models/pull-request' +import { Author } from '../models/author' +import { MergeTreeResult } from '../models/merge' +import { ICommitMessage } from '../models/commit-message' +import { + IRevertProgress, + Progress, + ICheckoutProgress, + ICloneProgress, + IMultiCommitOperationProgress, +} from '../models/progress' + +import { SignInState } from './stores/sign-in-store' + +import { WindowState } from './window-state' +import { Shell } from './shells' + +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' +import { IStashEntry } from '../models/stash-entry' +import { TutorialStep } from '../models/tutorial-step' +import { UncommittedChangesStrategy } from '../models/uncommitted-changes-strategy' +import { DragElement } from '../models/drag-drop' +import { ILastThankYou } from '../models/last-thank-you' +import { + MultiCommitOperationDetail, + 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' + +export enum SelectionType { + Repository, + CloningRepository, + MissingRepository, +} + +export type PossibleSelections = + | { + type: SelectionType.Repository + repository: Repository + state: IRepositoryState + } + | { + type: SelectionType.CloningRepository + repository: CloningRepository + progress: ICloneProgress + } + | { type: SelectionType.MissingRepository; repository: Repository } + +/** All of the shared app state. */ +export interface IAppState { + readonly accounts: ReadonlyArray + /** + * The current list of repositories tracked in the application + */ + readonly repositories: ReadonlyArray + + /** + * List of IDs of the most recently opened repositories (most recent first) + */ + readonly recentRepositories: ReadonlyArray + + /** + * A cache of the latest repository state values, keyed by the repository id + */ + readonly localRepositoryStateLookup: Map + + readonly selectedState: PossibleSelections | null + + /** + * The state of the ongoing (if any) sign in process. See SignInState + * and SignInStore for more details. Null if no current sign in flow + * is active. Sign in flows are initiated through the dispatcher methods + * beginDotComSignIn and beginEnterpriseSign in or via the + * showDotcomSignInDialog and showEnterpriseSignInDialog methods. + */ + readonly signInState: SignInState | null + + /** + * The current state of the window, ie maximized, minimized full-screen etc. + */ + readonly windowState: WindowState | null + + /** + * The current zoom factor of the window represented as a fractional number + * where 1 equals 100% (ie actual size) and 2 represents 200%. + */ + 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. + */ + readonly appIsFocused: boolean + + readonly showWelcomeFlow: boolean + readonly focusCommitMessage: boolean + readonly currentPopup: Popup | null + readonly allPopups: ReadonlyArray + readonly currentFoldout: Foldout | null + readonly currentBanner: Banner | null + + /** + * The shape of the drag element rendered in the `app.renderDragElement`. It + * is used in conjunction with the `Draggable` component. + */ + readonly currentDragElement: DragElement | null + + /** + * A list of currently open menus with their selected items + * in the application menu. + * + * The semantics around what constitutes an open menu and how + * selection works is defined by the AppMenu class and the + * individual components transforming that state. + * + * Note that as long as the renderer has received an application + * menu from the main process there will always be one menu + * "open", that is the root menu which can't be closed. In other + * words, a non-zero length appMenuState does not imply that the + * application menu should be visible. Currently thats defined by + * whether the app menu is open as a foldout (see currentFoldout). + * + * Not applicable on macOS unless the in-app application menu has + * been explicitly enabled for testing purposes. + */ + readonly appMenuState: ReadonlyArray + + readonly errorCount: number + + /** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */ + readonly emoji: Map + + /** + * The width of the repository sidebar. + * + * This affects the changes and history sidebar + * as well as the first toolbar section which contains + * repo selection on all platforms and repo selection and + * app menu on Windows. + * + * Lives on IAppState as opposed to IRepositoryState + * because it's used in the toolbar as well as the + * repository. + */ + readonly sidebarWidth: IConstrainedValue + + /** The width of the commit summary column in the history view */ + readonly commitSummaryWidth: IConstrainedValue + + /** 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 + + /** + * Used to highlight access keys throughout the app when the + * Alt key is pressed. Only applicable on non-macOS platforms. + */ + readonly highlightAccessKeys: boolean + + /** Whether we should show the update banner */ + readonly isUpdateAvailableBannerVisible: boolean + + /** Whether there is an update to showcase */ + readonly isUpdateShowcaseVisible: boolean + + /** Whether we should ask the user to move the app to /Applications */ + readonly askToMoveToApplicationsFolderSetting: boolean + + /** Whether we should show a confirmation dialog */ + readonly askForConfirmationOnRepositoryRemoval: boolean + + /** Whether we should show a confirmation dialog */ + readonly askForConfirmationOnDiscardChanges: boolean + + /** 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 + + /** How the app should handle uncommitted changes when switching branches */ + readonly uncommittedChangesStrategy: UncommittedChangesStrategy + + /** The external editor to use when opening repositories */ + readonly selectedExternalEditor: string | null + + /** Whether or not the app should use Windows' OpenSSH client */ + readonly useWindowsOpenSSH: boolean + + /** The current setting for whether the user has disable usage reports */ + readonly optOutOfUsageTracking: boolean + /** + * A cached entry representing an external editor found on the user's machine: + * + * - If the `selectedExternalEditor` can be found, choose that + * - Otherwise, if any editors found, this will be set to the first value + * based on the search order in `app/src/lib/editors/{platform}.ts` + * - If no editors found, this will remain `null` + */ + readonly resolvedExternalEditor: string | null + + /** What type of visual diff mode we should use to compare images */ + readonly imageDiffType: ImageDiffType + + /** Whether we should hide white space changes in changes diff */ + readonly hideWhitespaceInChangesDiff: boolean + + /** 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 + + /** The user's preferred shell. */ + readonly selectedShell: Shell + + /** The current repository filter text. */ + readonly repositoryFilterText: string + + /** The currently selected tab for Clone Repository. */ + readonly selectedCloneRepositoryTab: CloneRepositoryTab + + /** The currently selected tab for the Branches foldout. */ + readonly selectedBranchesTab: BranchesTab + + /** The selected appearance (aka theme) preference */ + readonly selectedTheme: ApplicationTheme + + /** The currently applied appearance (aka theme) */ + readonly currentTheme: ApplicableTheme + + /** + * A map keyed on a user account (GitHub.com or GitHub Enterprise) + * containing an object with repositories that the authenticated + * user has explicit permission (:read, :write, or :admin) to access + * as well as information about whether the list of repositories + * is currently being loaded or not. + * + * If a currently signed in account is missing from the map that + * means that the list of accessible repositories has not yet been + * loaded. An entry for an account with an empty list of repositories + * means that no accessible repositories was found for the account. + * + * See the ApiRepositoriesStore for more details on loading repositories + */ + readonly apiRepositories: ReadonlyMap + + /** Which step the user is on in the Onboarding Tutorial */ + readonly currentOnboardingTutorialStep: TutorialStep + + /** + * Whether or not the app should update the repository indicators (the + * blue dot and the ahead/behind arrows in the repository list used to + * indicate that the repository has uncommitted changes or is out of sync + * with its remote) in the background. See `RepositoryIndicatorUpdater` + * for more information + */ + readonly repositoryIndicatorsEnabled: boolean + + /** + * Whether or not the app should use spell check on commit summary and description + */ + readonly commitSpellcheckEnabled: boolean + + /** + * Record of what logged in users have been checked to see if thank you is in + * order for external contributions in latest release. + */ + readonly lastThankYou: ILastThankYou | undefined + + /** + * Whether or not the CI status popover is visible. + */ + readonly showCIStatusPopover: boolean + + /** + * 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 + + /** + * Cached repo rulesets. Used to prevent repeatedly querying the same + * rulesets to check their bypass status. + */ + readonly cachedRepoRulesets: ReadonlyMap +} + +export enum FoldoutType { + Repository, + Branch, + AppMenu, + AddMenu, + PushPull, +} + +export type AppMenuFoldout = { + type: FoldoutType.AppMenu + + /** + * Whether or not the application menu was opened with the Alt key, this + * enables access key highlighting for applicable menu items as well as + * 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 = { + type: FoldoutType.Branch +} + +export type Foldout = + | { type: FoldoutType.Repository } + | { type: FoldoutType.AddMenu } + | BranchFoldout + | AppMenuFoldout + | { type: FoldoutType.PushPull } + +export enum RepositorySectionTab { + Changes, + History, +} + +/** + * Stores information about a merge conflict when it occurs + */ +export type MergeConflictState = { + readonly kind: 'merge' + readonly currentBranch: string + readonly currentTip: string + readonly manualResolutions: Map +} + +/** Guard function for checking conflicts are from a merge */ +export function isMergeConflictState( + conflictStatus: ConflictState +): conflictStatus is MergeConflictState { + return conflictStatus.kind === 'merge' +} + +/** + * Stores information about a rebase conflict when it occurs + */ +export type RebaseConflictState = { + readonly kind: 'rebase' + /** + * This is the commit ID of the HEAD of the in-flight rebase + */ + readonly currentTip: string + /** + * The branch chosen by the user to be rebased + */ + readonly targetBranch: string + /** + * The branch chosen as the baseline for the rebase + */ + readonly baseBranch?: string + + /** + * The commit ID of the target branch before the rebase was initiated + */ + readonly originalBranchTip: string + /** + * The commit ID of the base branch onto which the history will be applied + */ + readonly baseBranchTip: string + /** + * Manual resolutions chosen by the user for conflicted files to be applied + * before continuing the rebase. + */ + readonly manualResolutions: Map +} + +/** Guard function for checking conflicts are from a rebase */ +export function isRebaseConflictState( + conflictStatus: ConflictState +): conflictStatus is RebaseConflictState { + return conflictStatus.kind === 'rebase' +} + +/** + * Conflicts can occur during a rebase, merge, or cherry pick. + * + * Callers should inspect the `kind` field to determine the kind of conflict + * that is occurring, as this will then provide additional information specific + * to the conflict, to help with resolving the issue. + */ +export type ConflictState = + | MergeConflictState + | RebaseConflictState + | CherryPickConflictState + +export interface IRepositoryState { + readonly commitSelection: ICommitSelection + readonly changesState: IChangesState + 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 + * updated between us reading it and a commit being made + * (ie we don't currently use this value explicitly when committing) + */ + readonly commitAuthor: CommitIdentity | null + + readonly branchesState: IBranchesState + + /** The commits loaded, keyed by their full SHA. */ + readonly commitLookup: Map + + /** + * The ordered local commit SHAs. The commits themselves can be looked up in + * `commitLookup.` + */ + readonly localCommitSHAs: ReadonlyArray + + /** The remote currently associated with the repository, if defined in the configuration */ + readonly remote: IRemote | null + + /** The state of the current branch in relation to its upstream. */ + readonly aheadBehind: IAheadBehind | null + + /** The tags that will get pushed if the user performs a push operation. */ + readonly tagsToPush: ReadonlyArray | null + + /** Is a push/pull/fetch in progress? */ + readonly isPushPullFetchInProgress: boolean + + /** Is a commit in progress? */ + readonly isCommitting: boolean + + /** Commit being amended, or null if none. */ + readonly commitToAmend: Commit | null + + /** The date the repository was last fetched. */ + readonly lastFetched: Date | null + + /** + * If we're currently working on switching to a new branch this + * provides insight into the progress of that operation. + * + * null if no current branch switch operation is in flight. + */ + readonly checkoutProgress: ICheckoutProgress | null + + /** + * If we're currently working on pushing a branch, fetching + * from a remote or pulling a branch this provides insight + * into the progress of that operation. + * + * null if no such operation is in flight. + */ + readonly pushPullFetchProgress: Progress | null + + /** + * If we're currently reverting a commit and it involves LFS progress, this + * will contain the LFS progress. + * + * null if no such operation is in flight. + */ + readonly revertProgress: IRevertProgress | null + + readonly localTags: Map | null + + /** Undo state associated with a multi commit operation operation */ + readonly multiCommitOperationUndoState: IMultiCommitOperationUndoState | null + + /** State associated with a multi commit operation such as rebase, + * cherry-pick, squash, reorder... */ + readonly multiCommitOperationState: IMultiCommitOperationState | null +} + +export interface IBranchesState { + /** + * The current tip of HEAD, either a branch, a commit (if HEAD is + * detached) or an unborn branch (a branch with no commits). + */ + readonly tip: Tip + + /** + * The default branch for a given repository. Historically it's been + * common to use 'master' as the default branch but as of September 2020 + * GitHub Desktop and GitHub.com default to using 'main' as the default branch. + * + * GitHub Desktop users are able to configure the `init.defaultBranch` Git + * setting in preferences. + * + * GitHub.com users are able to change their default branch in the web UI. + */ + 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. + */ + readonly allBranches: ReadonlyArray + + /** + * A list of zero to a few (at time of writing 5 but check loadRecentBranches + * in git-store for definitive answer) branches that have been checked out + * recently. This list is compiled by reading the reflog and tracking branch + * switches over the last couple of thousand reflog entries. + */ + readonly recentBranches: ReadonlyArray + + /** The open pull requests in the repository. */ + readonly openPullRequests: ReadonlyArray + + /** Are we currently loading pull requests? */ + readonly isLoadingPullRequests: boolean + + /** The pull request associated with the current branch. */ + readonly currentPullRequest: PullRequest | null + + /** + * Is the current branch configured to rebase on pull? + * + * This is the value returned from git config (local or global) for `git config pull.rebase` + * + * If this value is not found in config, this will be `undefined` to indicate + * that the default Git behaviour will occur. + */ + readonly pullWithRebase?: boolean + + /** Tracking branches that have been allowed to be force-pushed within Desktop */ + readonly forcePushBranches: ReadonlyMap +} + +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 + * opposed to 3, 5, 8. + * + * Technically order does not matter, but shas are stored in order. + * + * Contiguous selections can be diffed. Non-contiguous selections can be + * cherry-picked, reordered, or squashed. + * + * Assumed that a selections of zero or one commit are contiguous. + * */ + readonly isContiguous: boolean + + /** The changeset data associated with the selected commit */ + readonly changesetData: IChangesetData + + /** The selected file inside the selected commit */ + readonly file: CommittedFileChange | null + + /** The diff of the currently-selected file */ + readonly diff: IDiff | null +} + +export enum ChangesSelectionKind { + WorkingDirectory = 'WorkingDirectory', + Stash = 'Stash', +} + +export type ChangesWorkingDirectorySelection = { + readonly kind: ChangesSelectionKind.WorkingDirectory + + /** + * The ID of the selected files. The files themselves can be looked up in + * the `workingDirectory` property in `IChangesState`. + */ + readonly selectedFileIDs: ReadonlyArray + readonly diff: IDiff | null +} + +export type ChangesStashSelection = { + readonly kind: ChangesSelectionKind.Stash + + /** Currently selected file in the stash diff viewer UI (aka the file we want to show the diff for) */ + readonly selectedStashedFile: CommittedFileChange | null + + /** Currently selected file's diff */ + readonly selectedStashedFileDiff: IDiff | null +} + +export type ChangesSelection = + | ChangesWorkingDirectorySelection + | ChangesStashSelection + +export interface IChangesState { + readonly workingDirectory: WorkingDirectoryStatus + + /** The commit message for a work-in-progress commit in the changes view. */ + readonly commitMessage: ICommitMessage + + /** + * Whether or not to show a field for adding co-authors to + * a commit (currently only supported for GH/GHE repositories) + */ + readonly showCoAuthoredBy: boolean + + /** + * A list of authors (name, email pairs) which have been + * entered into the co-authors input box in the commit form + * and which _may_ be used in the subsequent commit to add + * Co-Authored-By commit message trailers depending on whether + * the user has chosen to do so. + */ + readonly coAuthors: ReadonlyArray + + /** + * Stores information about conflicts in the working directory + * + * The absence of a value means there is no merge or rebase conflict underway + */ + readonly conflictState: ConflictState | null + + /** + * The latest GitHub Desktop stash entry for the current branch, or `null` + * if no stash exists for the current branch. + */ + readonly stashEntry: IStashEntry | null + + /** + * The current selection state in the Changes view. Can be either + * working directory or a stash. In the case of a working directory + * selection multiple files may be selected. See `ChangesSelection` + * for more information about the differences between the two. + */ + readonly selection: ChangesSelection + + /** `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 +} + +/** + * This represents the various states the History tab can be in. + * + * By default, it should show the history of the current branch. + */ +export enum HistoryTabMode { + History = 'History', + Compare = 'Compare', +} + +/** + * This represents whether the compare tab is currently viewing the + * commits ahead or behind when merging some other branch into your + * current branch. + */ +export enum ComparisonMode { + Ahead = 'Ahead', + Behind = 'Behind', +} + +/** + * The default comparison state is to display the history for the current + * branch. + */ +export interface IDisplayHistory { + readonly kind: HistoryTabMode.History +} + +/** + * When the user has chosen another branch to compare, using their current + * branch as the base branch. + */ +export interface ICompareBranch { + readonly kind: HistoryTabMode.Compare + + /** The chosen comparison mode determines which commits to show */ + readonly comparisonMode: ComparisonMode.Ahead | ComparisonMode.Behind + + /** The branch to compare against the base branch */ + readonly comparisonBranch: Branch + + /** The number of commits the selected branch is ahead/behind the current branch */ + readonly aheadBehind: IAheadBehind +} + +export interface ICompareState { + /** The current state of the compare form, based on user input */ + readonly formState: IDisplayHistory | ICompareBranch + + /** The result of merging the compare branch into the current branch, if a branch selected */ + readonly mergeStatus: MergeTreeResult | null + + /** Whether the branch list should be expanded or hidden */ + readonly showBranchList: boolean + + /** The text entered into the compare branch filter text box */ + readonly filterText: string + + /** The SHA associated with the most recent history state */ + readonly tip: string | null + + /** 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`) + **/ + readonly branches: ReadonlyArray + + /** + * A list of zero to a few (at time of writing 5 but check loadRecentBranches + * in git-store for definitive answer) branches that have been checked out + * recently. This list is compiled by reading the reflog and tracking branch + * switches over the last couple of thousand reflog entries. + */ + readonly recentBranches: ReadonlyArray + + /** + * The default branch for a given repository. Historically it's been + * common to use 'master' as the default branch but as of September 2020 + * GitHub Desktop and GitHub.com default to using 'main' as the default branch. + * + * GitHub Desktop users are able to configure the `init.defaultBranch` Git + * setting in preferences. + * + * GitHub.com users are able to change their default branch in the web UI. + */ + readonly defaultBranch: Branch | null +} + +export interface ICompareFormUpdate { + /** The updated filter text to set */ + readonly filterText: string + + /** Thew new state of the branches list */ + readonly showBranchList: boolean +} + +export interface IViewHistory { + readonly kind: HistoryTabMode.History +} + +export interface ICompareToBranch { + readonly kind: HistoryTabMode.Compare + readonly branch: Branch + readonly comparisonMode: ComparisonMode.Ahead | ComparisonMode.Behind +} + +/** + * An action to send to the application store to update the compare state + */ +export type CompareAction = IViewHistory | ICompareToBranch + +/** + * Undo state associated with a multi commit operation being performed on a + * repository. + */ +export interface IMultiCommitOperationUndoState { + /** The sha of the tip before operation was initiated. */ + readonly undoSha: string + + /** The name of the branch the operation applied to */ + readonly branchName: string +} + +/** + * Stores information about a cherry pick conflict when it occurs + */ +export type CherryPickConflictState = { + readonly kind: 'cherryPick' + + /** + * Manual resolutions chosen by the user for conflicted files to be applied + * before continuing the cherry pick. + */ + readonly manualResolutions: Map + + /** + * The branch chosen by the user to copy the cherry picked commits to + */ + readonly targetBranchName: string +} + +/** Guard function for checking conflicts are from a rebase */ +export function isCherryPickConflictState( + conflictStatus: ConflictState +): conflictStatus is CherryPickConflictState { + return conflictStatus.kind === 'cherryPick' +} + +/** + * Tracks the state of the app during a multi commit operation such as rebase, + * cherry-picking, and interactive rebase (squashing, reordering). + */ +export interface IMultiCommitOperationState { + /** + * The current step of the operation the user is at. + * Examples: ChooseBranchStep, ChooseBranchStep, ShowConflictsStep, etc. + */ + readonly step: MultiCommitOperationStep + + /** + * This hold properties specific to the operation. + */ + readonly operationDetail: MultiCommitOperationDetail + /** + * The underlying parsed Git information associated with the progress of the + * current operation. + * + * Example: During cherry-picking, after each commit this progress will be + * updated to reflect the next commit in the list to cherry-pick. + */ + readonly progress: IMultiCommitOperationProgress + + /** + * Whether the user has done work to resolve any conflicts as part of this + * operation, and therefore, should be warned on aborting the operation. + */ + readonly userHasResolvedConflicts: boolean + + /** + * The commit id of the tip of the branch user is modifying in the operation. + * + * Uses: + * - Cherry-picking = tip of target branch before cherry-pick, used to undo cherry-pick + * - This maybe null if app opens mid cherry-pick + * - Rebasing = tip of current branch before rebase, used enable force pushing after rebase complete. + * - Interactive Rebasing (Squash, Reorder) = tip of current branch, used for force pushing and undoing + */ + readonly originalBranchTip: string | null + + /** + * The branch that is being modified during the operation. + * + * - Cherry-pick = the branch chosen to copy commits to; Maybe null when cherry-pick is in the choose branch step. + * - Rebase = the current branch the user is on. + * - Squash = the current branch the user is on. + */ + readonly targetBranch: Branch | null +} + +export type MultiCommitOperationConflictState = { + readonly kind: 'multiCommitOperation' + + /** + * Manual resolutions chosen by the user for conflicted files to be applied + * before continuing the operation + */ + readonly manualResolutions: Map + + /** + * Depending on the operation, this may be either source branch or the + * target branch. + * + * Also, we may not know what it is. This usually happens if Desktop is closed + * during an operation and the reopened and we lose some context that is + * stored in state. + */ + readonly ourBranch?: string + + /** + * Depending on the operation, this may be either source branch or the + * target branch + * + * Also, we may not know what it is. This usually happens if Desktop is closed + * during an operation and the reopened and we lose some context that is + * stored in state. + */ + readonly theirBranch?: string +} + +/** + * An interface for describing a desired value and a valid range + * + * Note that the value can be greater than `max` or less than `min`, it's + * an indication of the desired value. The real value needs to be validated + * or coerced using a function like `clamp`. + * + * Yeah this is a terrible name. + */ +export interface IConstrainedValue { + readonly value: number + 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/auth.ts b/app/src/lib/auth.ts new file mode 100644 index 0000000000..8da93338f8 --- /dev/null +++ b/app/src/lib/auth.ts @@ -0,0 +1,13 @@ +import { Account } from '../models/account' + +/** Get the auth key for the user. */ +export function getKeyForAccount(account: Account): string { + return getKeyForEndpoint(account.endpoint) +} + +/** Get the auth key for the endpoint. */ +export function getKeyForEndpoint(endpoint: string): string { + const appName = __DEV__ ? 'GitHub Desktop Dev' : 'GitHub' + + return `${appName} - ${endpoint}` +} diff --git a/app/src/lib/branch.ts b/app/src/lib/branch.ts new file mode 100644 index 0000000000..1f0342ba47 --- /dev/null +++ b/app/src/lib/branch.ts @@ -0,0 +1,26 @@ +import { Branch } from '../models/branch' +import { + isRepositoryWithGitHubRepository, + Repository, +} from '../models/repository' +import { IBranchesState } from './app-state' + +/** + * + * @param repository The repository to use. + * @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 findContributionTargetDefaultBranch( + repository: Repository, + { defaultBranch, upstreamDefaultBranch }: IBranchesState +): Branch | 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 new file mode 100644 index 0000000000..d86da76119 --- /dev/null +++ b/app/src/lib/ci-checks/ci-checks.ts @@ -0,0 +1,757 @@ +import { + APICheckStatus, + APICheckConclusion, + IAPIWorkflowJobStep, + IAPIRefCheckRun, + IAPIRefStatusItem, + IAPIWorkflowJob, + API, + IAPIWorkflowJobs, + IAPIWorkflowRun, +} from '../api' +import JSZip from 'jszip' +import { GitHubRepository } from '../../models/github-repository' +import { Account } from '../../models/account' +import { supportsRetrieveActionWorkflowByCheckSuiteId } from '../endpoint-capabilities' +import { formatPreciseDuration } from '../format-duration' + +/** + * A Desktop-specific model closely related to a GitHub API Check Run. + * + * The RefCheck object abstracts the difference between the legacy + * Commit Status objects and the modern Check Runs and unifies them + * under one common interface. Since all commit statuses can be + * represented as Check Runs but not all Check Runs can be represented + * as statuses the model closely aligns with Check Runs. + */ +export interface IRefCheck { + readonly id: number + readonly name: string + readonly description: string + readonly status: APICheckStatus + readonly conclusion: APICheckConclusion | null + readonly appName: string + readonly htmlUrl: string | null + + // Following are action check specific + readonly checkSuiteId: number | null + readonly actionJobSteps?: ReadonlyArray + readonly actionsWorkflow?: IAPIWorkflowRun +} + +/** + * A combined view of all legacy commit statuses as well as + * check runs for a particular Git reference. + */ +export interface ICombinedRefCheck { + readonly status: APICheckStatus + readonly conclusion: APICheckConclusion | null + 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 + */ +export function apiStatusToRefCheck(apiStatus: IAPIRefStatusItem): IRefCheck { + let state: APICheckStatus + let conclusion: APICheckConclusion | null = null + + if (apiStatus.state === 'success') { + state = APICheckStatus.Completed + conclusion = APICheckConclusion.Success + } else if (apiStatus.state === 'pending') { + state = APICheckStatus.InProgress + } else { + state = APICheckStatus.Completed + conclusion = APICheckConclusion.Failure + } + + return { + id: apiStatus.id, + name: apiStatus.context, + description: getCheckRunShortDescription(state, conclusion), + status: state, + conclusion, + appName: '', + checkSuiteId: null, + htmlUrl: apiStatus.target_url, + } +} + +/** + * Returns the user-facing adjective for a given check run conclusion. + */ +export function getCheckRunConclusionAdjective( + conclusion: APICheckConclusion | null +): string { + if (conclusion === null) { + return 'In progress' + } + + switch (conclusion) { + case APICheckConclusion.ActionRequired: + return 'Action required' + case APICheckConclusion.Canceled: + return 'Canceled' + case APICheckConclusion.TimedOut: + return 'Timed out' + case APICheckConclusion.Failure: + return 'Failed' + case APICheckConclusion.Neutral: + return 'Neutral' + case APICheckConclusion.Success: + return 'Successful' + case APICheckConclusion.Skipped: + return 'Skipped' + case APICheckConclusion.Stale: + return 'Marked as stale' + } +} + +/** + * Method to generate a user friendly short check run description such as + * "Successful in xs", "In Progress", "Failed after 1m" + * + * If the duration is not provided, it will omit the preposition and duration + * context. Also, conclusions such as `Skipped`, 'Action required`, `Marked as + * stale` don't make sense with duration context so it is ommited. + * + * @param status - The overall check status, something like completed, pending, + * or failing... + * @param conclusion - The conclusion of the check, something like success or + * skipped... + * @param durationMs - The time in milliseconds it took to complete. + */ +function getCheckRunShortDescription( + status: APICheckStatus, + conclusion: APICheckConclusion | null, + durationMs?: number +): string { + if (status !== APICheckStatus.Completed || conclusion === null) { + return 'In progress' + } + + const adjective = getCheckRunConclusionAdjective(conclusion) + + // Some conclusions such as 'Action required' or 'Skipped' don't make sense + // with time context so we just return them. + if ( + [ + APICheckConclusion.ActionRequired, + APICheckConclusion.Skipped, + APICheckConclusion.Stale, + ].includes(conclusion) + ) { + return adjective + } + + const preposition = conclusion === APICheckConclusion.Success ? 'in' : 'after' + + if (durationMs !== undefined && durationMs > 0) { + return `${adjective} ${preposition} ${formatPreciseDuration(durationMs)}` + } + + return adjective +} + +/** + * Attempts to get the duration of a check run in milliseconds. Returns NaN if + * parsing either completed_at or started_at fails + */ +export const getCheckDurationInMilliseconds = ( + checkRun: IAPIRefCheckRun | IAPIWorkflowJobStep +) => Date.parse(checkRun.completed_at) - Date.parse(checkRun.started_at) + +/** + * Convert an API check run object to a RefCheck model + */ +export function apiCheckRunToRefCheck(checkRun: IAPIRefCheckRun): IRefCheck { + return { + id: checkRun.id, + name: checkRun.name, + description: getCheckRunShortDescription( + checkRun.status, + checkRun.conclusion, + getCheckDurationInMilliseconds(checkRun) + ), + status: checkRun.status, + conclusion: checkRun.conclusion, + appName: checkRun.app.name, + checkSuiteId: checkRun.check_suite.id, + htmlUrl: checkRun.html_url, + } +} + +/** + * Combines a list of check runs into a single combined check with global status + * and conclusion. + */ +export function createCombinedCheckFromChecks( + checks: ReadonlyArray +): ICombinedRefCheck | null { + if (checks.length === 0) { + // This case is distinct from when we fail to call the API in + // that this means there are no checks or statuses so we should + // clear whatever info we've got for this ref. + return null + } + + if (checks.length === 1) { + // If we've got exactly one check then we can mirror its status + // and conclusion 1-1 without having to create an aggregate status + const { status, conclusion } = checks[0] + return { status, conclusion, checks } + } + + if (checks.some(isIncompleteOrFailure)) { + return { + status: APICheckStatus.Completed, + conclusion: APICheckConclusion.Failure, + checks, + } + } else if (checks.every(isSuccess)) { + return { + status: APICheckStatus.Completed, + conclusion: APICheckConclusion.Success, + checks, + } + } else { + return { status: APICheckStatus.InProgress, conclusion: null, checks } + } +} + +/** + * Whether the check is either incomplete or has failed + */ +export function isIncompleteOrFailure(check: IRefCheck) { + return isIncomplete(check) || isFailure(check) +} + +/** + * Whether the check is incomplete (timed out, stale or cancelled). + * + * The terminology here is confusing and deserves explanation. An + * incomplete check is a check run that has been started and who's + * state is 'completed' but it never got to produce a conclusion + * because it was either cancelled, it timed out, or GitHub marked + * it as stale. + */ +export function isIncomplete(check: IRefCheck) { + if (check.status === 'completed') { + switch (check.conclusion) { + case 'timed_out': + case 'stale': + case 'cancelled': + return true + } + } + + return false +} + +/** Whether the check has failed (failure or requires action) */ +export function isFailure(check: IRefCheck | IAPIWorkflowJobStep) { + if (check.status === 'completed') { + switch (check.conclusion) { + case 'failure': + case 'action_required': + return true + } + } + + return false +} + +/** Whether the check can be considered successful (success, neutral or skipped) */ +export function isSuccess(check: IRefCheck) { + if (check.status === 'completed') { + switch (check.conclusion) { + case 'success': + case 'neutral': + case 'skipped': + return true + } + } + + return false +} + +/** + * In some cases there may be multiple check runs reported for a + * reference. In that case GitHub.com will pick only the latest + * run for each check name to present in the PR merge footer and + * only the latest run counts towards the mergeability of a PR. + * + * 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( + checkRuns: ReadonlyArray +): ReadonlyArray { + const latestCheckRunsByName = new Map() + + for (const checkRun of checkRuns) { + // For release branches (maybe other scenarios?), there can be check runs + // with the same name, but are "push" events not "pull_request" events. For + // the push events, the pull_request array will be empty. For pull_request, + // the pull_request array should have the pull_request object in it. We want + // these runs treated separately even tho they have same name, they are not + // simply a repeat of the same run as they have a different origination. This + // feels hacky... but we don't have any other meta data on a check run that + // differieates these. + const nameAndHasPRs = + checkRun.name + + (checkRun.pull_requests.length > 0 + ? 'isPullRequestCheckRun' + : 'isPushCheckRun') + const current = latestCheckRunsByName.get(nameAndHasPRs) + if ( + current === undefined || + current.check_suite.id < checkRun.check_suite.id + ) { + latestCheckRunsByName.set(nameAndHasPRs, checkRun) + } + } + + return [...latestCheckRunsByName.values()] +} + +/** + * Retrieve GitHub Actions job and logs for the check runs. + */ +export async function getLatestPRWorkflowRunsLogsForCheckRun( + api: API, + owner: string, + repo: string, + checkRuns: ReadonlyArray +): Promise> { + const logCache = new Map() + const jobsCache = new Map() + const mappedCheckRuns = new Array() + for (const cr of checkRuns) { + if (cr.actionsWorkflow === undefined) { + mappedCheckRuns.push(cr) + continue + } + const { id: wfId, logs_url } = 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) + + const matchingJob = workFlowRunJobs?.jobs.find(j => j.id === cr.id) + if (matchingJob === undefined) { + mappedCheckRuns.push(cr) + 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), + }) + } + + return mappedCheckRuns +} + +/** + * Retrieves the action workflow run for the check runs and if exists updates + * the actionWorkflow property. + * + * @param api API instance used to retrieve the jobs and logs URLs + * @param owner Owner of the repository + * @param repo Name of the repository + * @param branchName Name of the branch to which the check runs belong + * @param checkRuns List of check runs to augment + */ +export async function getCheckRunActionsWorkflowRuns( + account: Account, + owner: string, + repo: string, + branchName: string, + checkRuns: ReadonlyArray +): Promise> { + const api = API.fromAccount(account) + return supportsRetrieveActionWorkflowByCheckSuiteId(account.endpoint) + ? getCheckRunActionsWorkflowRunsByCheckSuiteId(api, owner, repo, checkRuns) + : getCheckRunActionsWorkflowRunsByBranchName( + api, + owner, + repo, + branchName, + checkRuns + ) +} + +/** + * Retrieves the action workflow runs by using the branchName they are + * associated with. + * + * Note: This approach has pit falls because it is possible for a pull request + * to have check runs initiated from two separate branches and therefore we do + * not get action workflows that exist for some pr check runs. For example, + * desktop releases auto generate a release branch from the release pr branch. + * The actions teams added a way to retrieve Action workflows via the check + * suite id to avoid these pitfalls. However, this will not be immediately + * available for GitHub Enterprise; thus, we keep approach to maintain GitHub + * Enterprise Server usage. + */ +async function getCheckRunActionsWorkflowRunsByBranchName( + api: API, + owner: string, + repo: string, + branchName: string, + checkRuns: ReadonlyArray +): Promise> { + const latestWorkflowRuns = await getLatestPRWorkflowRunsByBranchName( + api, + owner, + repo, + branchName + ) + + if (latestWorkflowRuns.length === 0) { + return checkRuns + } + + return mapActionWorkflowsRunsToCheckRuns(checkRuns, latestWorkflowRuns) +} + +/** + * Retrieves the action workflow runs by using the check suite id. + * + * If the check run is run using GitHub Actions, then there will be an actions + * workflow run with a matching check suite id that has the corresponding + * actions workflow. + */ +async function getCheckRunActionsWorkflowRunsByCheckSuiteId( + api: API, + owner: string, + repo: string, + checkRuns: ReadonlyArray +): Promise { + if (checkRuns.length === 0) { + return checkRuns + } + + const mappedCheckRuns = new Array() + const actionsCache = new Map() + for (const cr of checkRuns) { + if (cr.checkSuiteId === null) { + mappedCheckRuns.push(cr) + continue + } + + // Multiple check runs share the same action workflow + const cachedActionWorkFlow = actionsCache.get(cr.checkSuiteId) + const actionsWorkflow = + cachedActionWorkFlow === undefined + ? await api.fetchPRActionWorkflowRunByCheckSuiteId( + owner, + repo, + cr.checkSuiteId + ) + : cachedActionWorkFlow + + actionsCache.set(cr.checkSuiteId, actionsWorkflow) + + if (actionsWorkflow === null) { + mappedCheckRuns.push(cr) + continue + } + + mappedCheckRuns.push({ + ...cr, + actionsWorkflow, + }) + } + + return mappedCheckRuns +} + +// Gets only the latest PR workflow runs hashed by name +async function getLatestPRWorkflowRunsByBranchName( + api: API, + owner: string, + name: string, + branchName: string +): Promise> { + const wrMap = new Map() + const allBranchWorkflowRuns = await api.fetchPRWorkflowRunsByBranchName( + owner, + name, + branchName + ) + + if (allBranchWorkflowRuns === null) { + return [] + } + + // When retrieving Actions Workflow runs it returns all present and past + // workflow runs for the given branch name. For each workflow name, we only + // care about showing the latest run. + for (const wr of allBranchWorkflowRuns.workflow_runs) { + const storedWR = wrMap.get(wr.workflow_id) + if (storedWR === undefined) { + wrMap.set(wr.workflow_id, wr) + continue + } + + const storedWRDate = new Date(storedWR.created_at) + const givenWRDate = new Date(wr.created_at) + if (storedWRDate.getTime() < givenWRDate.getTime()) { + wrMap.set(wr.workflow_id, wr) + } + } + + return Array.from(wrMap.values()) +} + +function mapActionWorkflowsRunsToCheckRuns( + checkRuns: ReadonlyArray, + actionWorkflowRuns: ReadonlyArray +): ReadonlyArray { + if (actionWorkflowRuns.length === 0 || checkRuns.length === 0) { + return checkRuns + } + + const mappedCheckRuns = new Array() + for (const cr of checkRuns) { + const matchingWR = actionWorkflowRuns.find( + wr => wr.check_suite_id === cr.checkSuiteId + ) + if (matchingWR === undefined) { + mappedCheckRuns.push(cr) + continue + } + + mappedCheckRuns.push({ + ...cr, + actionsWorkflow: matchingWR, + }) + } + + return mappedCheckRuns +} + +/** + * Gets the duration of a check run or job step formatted in minutes and + * seconds. + */ +export function getFormattedCheckRunDuration( + checkRun: IAPIRefCheckRun | IAPIWorkflowJobStep +) { + const duration = getCheckDurationInMilliseconds(checkRun) + return isNaN(duration) ? '' : formatPreciseDuration(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. + * + * @param checkRun Check run to generate the URL for + * @param step Check run step to generate the URL for + * @param repository Repository to which the check run belongs + * @param pullRequestNumber Number of PR associated with the check run + */ +export function getCheckRunStepURL( + checkRun: IRefCheck, + step: IAPIWorkflowJobStep, + repository: GitHubRepository, + pullRequestNumber: number +): string | null { + if (checkRun.htmlUrl === null && repository.htmlURL === null) { + // A check run may not have a url depending on how it is setup. + // However, the repository should have one; Thus, we shouldn't hit this + return null + } + + const url = + checkRun.htmlUrl !== null + ? `${checkRun.htmlUrl}/#step:${step.number}:1` + : `${repository.htmlURL}/pull/${pullRequestNumber}` + + return url +} + +/** + * Groups check runs by their actions workflow name and actions workflow event type. + * Event type only gets grouped if there are more than one event. + * Also sorts the check runs in the groups by their names. + * + * @param checkRuns + * @returns A map of grouped check runs. + */ +export function getCheckRunsGroupedByActionWorkflowNameAndEvent( + checkRuns: ReadonlyArray +): Map> { + const checkRunEvents = new Set( + checkRuns + .map(c => c.actionsWorkflow?.event) + .filter(c => c !== undefined && c.trim() !== '') + ) + const checkRunsHaveMultipleEventTypes = checkRunEvents.size > 1 + + const groups = new Map() + for (const checkRun of checkRuns) { + let group = checkRun.actionsWorkflow?.name || 'Other' + + if ( + checkRunsHaveMultipleEventTypes && + checkRun.actionsWorkflow !== undefined && + checkRun.actionsWorkflow.event.trim() !== '' + ) { + group = `${group} (${checkRun.actionsWorkflow.event})` + } + + if (group === 'Other' && checkRun.appName === 'GitHub Code Scanning') { + group = 'Code scanning results' + } + + const existingGroup = groups.get(group) + const newGroup = + existingGroup !== undefined ? [...existingGroup, checkRun] : [checkRun] + groups.set(group, newGroup) + } + + const sortedGroupNames = getCheckRunGroupNames(groups) + + sortedGroupNames.forEach(gn => { + const group = groups.get(gn) + if (group !== undefined) { + const sortedGroup = group.sort((a, b) => a.name.localeCompare(b.name)) + groups.set(gn, sortedGroup) + } + }) + + return groups +} + +/** + * Gets the check run group names from the map and sorts them alphebetically with Other being last. + */ +export function getCheckRunGroupNames( + checkRunGroups: Map> +): ReadonlyArray { + const groupNames = [...checkRunGroups.keys()] + + // Sort names with 'Other' always last. + groupNames.sort((a, b) => { + if (a === 'Other' && b !== 'Other') { + return 1 + } + + if (a !== 'Other' && b === 'Other') { + return -1 + } + + if (a === 'Other' && b === 'Other') { + return 0 + } + + return a.localeCompare(b) + }) + + return groupNames +} + +export function manuallySetChecksToPending( + cachedChecks: ReadonlyArray, + pendingChecks: ReadonlyArray +): ICombinedRefCheck | null { + const updatedChecks: IRefCheck[] = [] + for (const check of cachedChecks) { + const matchingCheck = pendingChecks.find(c => check.id === c.id) + if (matchingCheck === undefined) { + updatedChecks.push(check) + continue + } + + updatedChecks.push({ + ...check, + status: APICheckStatus.InProgress, + conclusion: null, + actionJobSteps: check.actionJobSteps?.map(js => ({ + ...js, + status: APICheckStatus.InProgress, + conclusion: null, + })), + }) + } + return createCombinedCheckFromChecks(updatedChecks) +} + +/** + * Groups and totals the checks by their conclusion if not null and otherwise by their status. + * + * @param checks + * @returns Returns a map with key of conclusions or status and values of count of that conclustion or status + */ +export function getCheckStatusCountMap(checks: ReadonlyArray) { + const countByStatus = new Map() + checks.forEach(check => { + const key = check.conclusion ?? check.status + const currentCount: number = countByStatus.get(key) ?? 0 + countByStatus.set(key, currentCount + 1) + }) + + return countByStatus +} + +/** + * An array of check conclusions that are considerd a failure. + */ +export const FailingCheckConclusions = [ + APICheckConclusion.Failure, + APICheckConclusion.Canceled, + APICheckConclusion.ActionRequired, + APICheckConclusion.TimedOut, +] diff --git a/app/src/lib/clamp.ts b/app/src/lib/clamp.ts new file mode 100644 index 0000000000..df446fe66b --- /dev/null +++ b/app/src/lib/clamp.ts @@ -0,0 +1,26 @@ +import { IConstrainedValue } from './app-state' + +/** + * Helper function to coerce a number into a valid range. + * + * Ensures that the returned value is at least min and at most (inclusive) max. + */ +export function clamp(value: number, min: number, max: number): number +export function clamp(value: IConstrainedValue): number +export function clamp( + value: IConstrainedValue | number, + min = -Infinity, + max = Infinity +): number { + if (typeof value !== 'number') { + return clamp(value.value, value.min, value.max) + } + + if (value < min) { + return min + } else if (value > max) { + return max + } else { + return value + } +} diff --git a/app/src/lib/commit-url.ts b/app/src/lib/commit-url.ts new file mode 100644 index 0000000000..8dd293c8be --- /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/compare.ts b/app/src/lib/compare.ts new file mode 100644 index 0000000000..a14e7cae1f --- /dev/null +++ b/app/src/lib/compare.ts @@ -0,0 +1,92 @@ +/** + * Compares the two values given and returns a value indicating whether + * one is greater than the other. When the return value is used in a sort + * operation the comparands will be sorted in ascending order + * + * Used for simplifying custom comparison logic when sorting arrays + * of complex types. + * + * The greater than/less than checks are implemented using standard + * javascript comparison operators and will only provide a meaningful + * sort value for types which javascript can compare natively. See + * + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comparison_Operators + * + * This method can be chained using `||` for more complex sorting + * logic. Example: + * + * ```ts + * arr.sort( + * (x, y) => + * compare(x.firstName, y.firstName) || compare(x.lastName, y.lastName) + * ) + * ``` + * + */ +export function compare(x: T, y: T): number { + if (x < y) { + return -1 + } + if (x > y) { + return 1 + } + + return 0 +} + +/** + * Compares the two values given and returns a value indicating whether + * one is greater than the other. When the return value is used in a sort + * operation the comparands will be sorted in descending order + * + * Used for simplifying custom comparison logic when sorting arrays + * of complex types. + * + * The greater than/less than checks are implemented using standard + * javascript comparison operators and will only provide a meaningful + * sort value for types which javascript can compare natively. See + * + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comparison_Operators + * + * This method can be chained using `||` for more complex sorting + * logic. Ex + * + * arr.sort((x, y) => compare(x.firstName, y.firstName) || compare(x.lastName, y.lastName)) + * + */ +export function compareDescending(x: T, y: T): number { + if (x < y) { + return 1 + } + if (x > y) { + return -1 + } + + return 0 +} + +/** + * Compares the two strings in a case-insensitive manner and returns a value + * indicating whether these are equal + */ +export function caseInsensitiveEquals(x: string, y: string): boolean { + return x.toLowerCase() === y.toLowerCase() +} + +/** + * Compares the two strings in a case-insensitive manner and returns a value + * indicating whether one is greater than the other. When the return value is + * used in a sort operation the comparands will be sorted in ascending order. + */ +export function caseInsensitiveCompare(x: string, y: string): number { + return compare(x.toLowerCase(), y.toLowerCase()) +} + +/** + * Compares the two strings in a case-insensitive manner and returns a value + * indicating whether one is greater than the other. When the return value is + * used in a sort operation the comparands will be sorted in descending order. + */ +export function caseInsensitiveCompareDescending(x: string, y: string): number { + return compareDescending(x.toLowerCase(), y.toLowerCase()) +} diff --git a/app/src/lib/create-branch.ts b/app/src/lib/create-branch.ts new file mode 100644 index 0000000000..56ea2c69b4 --- /dev/null +++ b/app/src/lib/create-branch.ts @@ -0,0 +1,49 @@ +import { TipState, Tip } from '../models/tip' +import { StartPoint, Branch } from '../models/branch' + +type BranchInfo = { + readonly tip: Tip + readonly defaultBranch: Branch | null + readonly upstreamDefaultBranch: Branch | null +} + +export function getStartPoint( + props: BranchInfo, + preferred: StartPoint +): StartPoint { + if (props.tip.kind === TipState.Detached) { + return StartPoint.Head + } + + if ( + preferred === StartPoint.UpstreamDefaultBranch && + props.upstreamDefaultBranch !== null + ) { + return preferred + } + + if (preferred === StartPoint.DefaultBranch && props.defaultBranch !== null) { + return preferred + } + + if ( + preferred === StartPoint.CurrentBranch && + props.tip.kind === TipState.Valid + ) { + return preferred + } + + if (preferred === StartPoint.Head) { + return preferred + } + + if (props.upstreamDefaultBranch) { + return StartPoint.UpstreamDefaultBranch + } else if (props.defaultBranch) { + return StartPoint.DefaultBranch + } else if (props.tip.kind === TipState.Valid) { + return StartPoint.CurrentBranch + } else { + return StartPoint.Head + } +} diff --git a/app/src/lib/databases/base-database.ts b/app/src/lib/databases/base-database.ts new file mode 100644 index 0000000000..c1ccfe2c08 --- /dev/null +++ b/app/src/lib/databases/base-database.ts @@ -0,0 +1,38 @@ +import Dexie, { Transaction } from 'dexie' + +export abstract class BaseDatabase extends Dexie { + private schemaVersion: number | undefined + + public constructor(name: string, schemaVersion: number | undefined) { + super(name) + + this.schemaVersion = schemaVersion + } + + /** + * Register the version of the schema only if `targetVersion` is less than + * `version` or is `undefined`. + * + * targetVersion - The version of the schema that is being targeted. If not + * provided, the given version will be registered. + * version - The version being registered. + * schema - The schema to register. + * upgrade - An upgrade function to call after upgrading to the given + * version. + */ + protected async conditionalVersion( + version: number, + schema: { [key: string]: string | null }, + upgrade?: (t: Transaction) => Promise + ) { + if (this.schemaVersion != null && this.schemaVersion < version) { + return + } + + const dexieVersion = this.version(version).stores(schema) + + if (upgrade != null) { + await dexieVersion.upgrade(upgrade) + } + } +} diff --git a/app/src/lib/databases/github-user-database.ts b/app/src/lib/databases/github-user-database.ts new file mode 100644 index 0000000000..9caffd17c4 --- /dev/null +++ b/app/src/lib/databases/github-user-database.ts @@ -0,0 +1,168 @@ +import Dexie from 'dexie' +import { BaseDatabase } from './base-database' + +export interface IMentionableUser { + /** + * The username or "handle" of the user. + */ + readonly login: string + + /** + * The real name (or at least the name that the user + * has configured to be shown) or null if the user hasn't + * specified a name. + */ + readonly name: string | null + + /** + * The user's attributable email address. If the + * user doesn't have a public profile email address + * this will instead contain an automatically generated + * stealth email address based on the account endpoint + * and login. + */ + readonly email: string + + /** + * A url to an avatar image chosen by the user + */ + readonly avatarURL: string +} + +interface IDBMentionableUser extends IMentionableUser { + /** + * The id corresponding to the dbID property of the + * `GitHubRepository` instance that this user is associated + * with + */ + readonly gitHubRepositoryID: number +} + +/** + * An object containing information about when a specific + * repository's mentionable users was last fetched and + * the ETag of that request. + */ +export interface IMentionableCacheEntry { + readonly gitHubRepositoryID: number + /** + * The time (in milliseconds since the epoch) that + * the mentionable users was last updated for this + * repository + */ + readonly lastUpdated: number + + /** + * The ETag returned by the server the last time + * we issued a request to get the mentionable users + */ + readonly eTag: string | undefined +} + +export class GitHubUserDatabase extends BaseDatabase { + public declare mentionables: Dexie.Table + public declare mentionableCache: Dexie.Table + + public constructor(name: string, schemaVersion?: number) { + super(name, schemaVersion) + + this.conditionalVersion(1, { + users: '++id, &[endpoint+email]', + }) + + this.conditionalVersion(2, { + users: '++id, [endpoint+email], [endpoint+login]', + mentionables: '++id, repositoryID, &[userID+repositoryID]', + }) + + // Remove the mentionables table in order to recreate it in + // version 4 using a new primary key. Also remove the obsolete + // users table + this.conditionalVersion(3, { + mentionables: null, + users: null, + }) + + this.conditionalVersion(4, { + mentionables: '&[gitHubRepositoryID+login], gitHubRepositoryID', + }) + + this.conditionalVersion(5, { + mentionableCache: 'gitHubRepositoryID', + }) + } + + /** + * Persist all the mentionable users provided for the given + * gitHubRepositoryID and update the lastUpdated property and + * ETag for the mentionable cache entry. + */ + public updateMentionablesForRepository( + gitHubRepositoryID: number, + mentionables: ReadonlyArray, + eTag: string | undefined + ) { + return this.transaction( + 'rw', + this.mentionables, + this.mentionableCache, + async () => { + await this.mentionables + .where('gitHubRepositoryID') + .equals(gitHubRepositoryID) + .delete() + + await this.touchMentionableCacheEntry(gitHubRepositoryID, eTag) + await this.mentionables.bulkAdd( + mentionables.map(x => ({ ...x, gitHubRepositoryID })) + ) + } + ) + } + + /** + * Retrieve all persisted mentionable users for the provided + * `gitHubRepositoryID` + */ + public async getAllMentionablesForRepository( + gitHubRepositoryID: number + ): Promise> { + const mentionables = await this.mentionables + .where('gitHubRepositoryID') + .equals(gitHubRepositoryID) + .toArray() + + return mentionables.map(toMentionableUser) + } + + /** + * Get the cache entry (or undefined if no cache entry has + * been written yet) for the `gitHubRepositoryID`. The + * cache entry contains information on when the repository + * mentionables was last refreshed as well as the ETag of + * the previous request. + */ + public getMentionableCacheEntry(gitHubRepositoryID: number) { + return this.mentionableCache.get(gitHubRepositoryID) + } + + /** + * Set the lastUpdated property for the cache entry to + * now and update the ETag + */ + public touchMentionableCacheEntry( + gitHubRepositoryID: number, + eTag: string | undefined + ) { + const lastUpdated = Date.now() + const entry = { gitHubRepositoryID, lastUpdated, eTag } + + return this.mentionableCache.put(entry) + } +} + +function toMentionableUser(mentionable: IDBMentionableUser): IMentionableUser { + // Exclude the githubRepositoryID prop + const { login, email, avatarURL, name } = mentionable + return { login, email, avatarURL, name } +} diff --git a/app/src/lib/databases/index.ts b/app/src/lib/databases/index.ts new file mode 100644 index 0000000000..cae860e136 --- /dev/null +++ b/app/src/lib/databases/index.ts @@ -0,0 +1,4 @@ +export * from './github-user-database' +export * from './issues-database' +export * from './repositories-database' +export * from './pull-request-database' diff --git a/app/src/lib/databases/issues-database.ts b/app/src/lib/databases/issues-database.ts new file mode 100644 index 0000000000..1bf8c95225 --- /dev/null +++ b/app/src/lib/databases/issues-database.ts @@ -0,0 +1,64 @@ +import Dexie, { Transaction } from 'dexie' +import { BaseDatabase } from './base-database' + +export interface IIssue { + readonly id?: number + readonly gitHubRepositoryID: number + readonly number: number + readonly title: string + readonly updated_at?: string +} + +export class IssuesDatabase extends BaseDatabase { + public declare issues: Dexie.Table + + public constructor(name: string, schemaVersion?: number) { + super(name, schemaVersion) + + this.conditionalVersion(1, { + issues: '++id, &[gitHubRepositoryID+number], gitHubRepositoryID, number', + }) + + this.conditionalVersion( + 2, + { + issues: + '++id, &[gitHubRepositoryID+number], gitHubRepositoryID, number, [gitHubRepositoryID+updated_at]', + }, + clearIssues + ) + } + + public getIssuesForRepository(gitHubRepositoryID: number) { + return this.issues + .where('gitHubRepositoryID') + .equals(gitHubRepositoryID) + .toArray() + } +} + +function clearIssues(transaction: Transaction) { + // Clear deprecated localStorage keys, we compute the since parameter + // using the database now. + clearDeprecatedKeys() + + // Unfortunately we have to clear the issues in order to maintain + // data consistency in the database. The issues table is only supposed + // to store 'open' issues and if we kept the existing issues (which) + // don't have an updated_at field around the initial query for + // max(updated_at) would return null, causing us to fetch all _open_ + // issues which in turn means we wouldn't be able to detect if we + // have any issues in the database that have been closed since the + // last time we fetched. Not only that, these closed issues wouldn't + // be updated to include the updated_at field unless they were actually + // modified at a later date. + // + // TL;DR; This is the safest approach + return transaction.table('issues').clear() +} + +function clearDeprecatedKeys() { + Object.keys(localStorage) + .filter(key => /^IssuesStore\/\d+\/lastFetch$/.test(key)) + .forEach(key => localStorage.removeItem(key)) +} diff --git a/app/src/lib/databases/pull-request-database.ts b/app/src/lib/databases/pull-request-database.ts new file mode 100644 index 0000000000..8052e9b50f --- /dev/null +++ b/app/src/lib/databases/pull-request-database.ts @@ -0,0 +1,262 @@ +import Dexie from 'dexie' +import { BaseDatabase } from './base-database' +import { GitHubRepository } from '../../models/github-repository' + +export interface IPullRequestRef { + /** + * The database ID of the GitHub repository in which this ref lives. It could + * be null if the repository was deleted on the site after the PR was opened. + */ + readonly repoId: number + + /** The name of the ref. */ + readonly ref: string + + /** The SHA of the ref. */ + readonly sha: string +} + +export interface IPullRequest { + /** The GitHub PR number. */ + readonly number: number + + /** The title. */ + readonly title: string + + /** The body of the PR - This is markdown. */ + readonly body: string + + /** The string formatted date on which the PR was created. */ + readonly createdAt: string + + /** The string formatted date on which the PR was created. */ + readonly updatedAt: string + + /** The ref from which the pull request's changes are coming. */ + readonly head: IPullRequestRef + + /** The ref which the pull request is targeting. */ + readonly base: IPullRequestRef + + /** The login of the author. */ + readonly author: string + + /** + * The draft state of the PR or undefined if state is unknown + */ + readonly draft: boolean +} + +/** + * Interface describing a record in the + * pullRequestsLastUpdated table. + */ +interface IPullRequestsLastUpdated { + /** + * The primary key. Corresponds to the + * dbId property for the associated `GitHubRepository` + * instance. + */ + readonly repoId: number + + /** + * The maximum value of the updated_at field on a + * pull request that we've seen in milliseconds since + * the epoch. + */ + readonly lastUpdated: number +} + +/** + * Pull Requests are keyed on the ID of the GitHubRepository + * that they belong to _and_ the PR number. + * + * Index 0 contains the GitHubRepository dbID and index 1 + * contains the PR number. + */ +export type PullRequestKey = [number, number] + +export class PullRequestDatabase extends BaseDatabase { + public declare pullRequests: Dexie.Table + public declare pullRequestsLastUpdated: Dexie.Table< + IPullRequestsLastUpdated, + number + > + + public constructor(name: string, schemaVersion?: number) { + super(name, schemaVersion) + + this.conditionalVersion(1, { + pullRequests: 'id++, base.repoId', + }) + + this.conditionalVersion(2, { + pullRequestStatus: 'id++, &[sha+pullRequestId]', + }) + + this.conditionalVersion(3, { + pullRequestStatus: 'id++, &[sha+pullRequestId], pullRequestId', + }) + + // Version 4 added status fields to the pullRequestStatus table + // which we've removed in version 5 so it makes no sense keeping + // that upgrade path available and that's why it appears as if + // we've got a no-change version. + this.conditionalVersion(4, {}) + + // Remove the pullRequestStatus table + this.conditionalVersion(5, { pullRequestStatus: null }) + + // Delete pullRequestsTable in order to recreate it again + // in version 7 with a new primary key + this.conditionalVersion(6, { pullRequests: null }) + + // new primary key and a new table dedicated to keeping track + // of the most recently updated PR we've seen. + this.conditionalVersion(7, { + pullRequests: '[base.repoId+number]', + pullRequestsLastUpdated: 'repoId', + }) + + this.conditionalVersion(8, {}, async tx => { + /** + * We're introducing the `draft` property on PRs in version 8 in order + * to be able to differentiate between draft and regular PRs. While + * we could just make the draft property optional and infer a missing + * value to be false that will mean all PRs will be treated as non-draft + * and unless the draft PRs get updated at some point in the future we'll + * never pick up on it so we'll clear the db to seed it with fresh data + * from the API. + */ + tx.table('pullRequests').clear() + tx.table('pullRequestsLastUpdated').clear() + }) + + this.conditionalVersion(9, {}, async tx => { + /** + * We're introducing the `body` property on PRs in version 8 in order + * to be able to display the body of the pr. + */ + tx.table('pullRequests').clear() + tx.table('pullRequestsLastUpdated').clear() + }) + } + + /** + * Removes all the pull requests associated with the given repository + * from the database. Also clears the last updated date for that repository + * if it exists. + */ + public async deleteAllPullRequestsInRepository(repository: GitHubRepository) { + await this.transaction( + 'rw', + this.pullRequests, + this.pullRequestsLastUpdated, + async () => { + await this.clearLastUpdated(repository) + await this.pullRequests + .where('[base.repoId+number]') + .between([repository.dbID], [repository.dbID + 1]) + .delete() + } + ) + } + + /** + * Removes all the given pull requests from the database. + */ + public async deletePullRequests(keys: PullRequestKey[]) { + // I believe this to be a bug in Dexie's type declarations. + // It definitely supports passing an array of keys but the + // type thinks that if it's an array it should be an array + // of void which I believe to be a mistake. Therefore we + // type it as any and hand it off to Dexie. + await this.pullRequests.bulkDelete(keys as any) + } + + /** + * Inserts the given pull requests, overwriting any existing records + * in the process. + */ + public async putPullRequests(prs: IPullRequest[]) { + await this.pullRequests.bulkPut(prs) + } + + /** + * Retrieve all PRs for the given repository. + * + * Note: This method will throw if the GitHubRepository hasn't + * yet been inserted into the database (i.e the dbID field is null). + */ + public getAllPullRequestsInRepository(repository: GitHubRepository) { + return this.pullRequests + .where('[base.repoId+number]') + .between([repository.dbID], [repository.dbID + 1]) + .toArray() + } + + /** + * Get a single pull requests for a particular repository + */ + public getPullRequest(repository: GitHubRepository, prNumber: number) { + return this.pullRequests.get([repository.dbID, prNumber]) + } + + /** + * Gets a value indicating the most recently updated PR + * that we've seen for a particular repository. + * + * Note: + * This value might differ from max(updated_at) in the pullRequests + * table since the most recently updated PR we saw might have + * been closed and we only store open PRs in the pullRequests + * table. + */ + public async getLastUpdated(repository: GitHubRepository) { + const row = await this.pullRequestsLastUpdated.get(repository.dbID) + + return row ? new Date(row.lastUpdated) : null + } + + /** + * Clears the stored date for the most recently updated PR seen for + * a given repository. + */ + public async clearLastUpdated(repository: GitHubRepository) { + await this.pullRequestsLastUpdated.delete(repository.dbID) + } + + /** + * Set a value indicating the most recently updated PR + * that we've seen for a particular repository. + * + * Note: + * This value might differ from max(updated_at) in the pullRequests + * table since the most recently updated PR we saw might have + * been closed and we only store open PRs in the pullRequests + * table. + */ + public async setLastUpdated(repository: GitHubRepository, lastUpdated: Date) { + await this.pullRequestsLastUpdated.put({ + repoId: repository.dbID, + lastUpdated: lastUpdated.getTime(), + }) + } +} + +/** + * Create a pull request key from a GitHub repository and a PR number. + * + * This method is mainly a helper function to ensure we don't + * accidentally swap the order of the repository id and the pr number + * if we were to create the key array manually. + * + * @param repository The GitHub repository to which this PR belongs + * @param prNumber The PR number as returned from the GitHub API + */ +export function getPullRequestKey( + repository: GitHubRepository, + prNumber: number +) { + return [repository.dbID, prNumber] as PullRequestKey +} diff --git a/app/src/lib/databases/repositories-database.ts b/app/src/lib/databases/repositories-database.ts new file mode 100644 index 0000000000..2f57ba1df0 --- /dev/null +++ b/app/src/lib/databases/repositories-database.ts @@ -0,0 +1,243 @@ +import Dexie, { Transaction } from 'dexie' +import { BaseDatabase } from './base-database' +import { WorkflowPreferences } from '../../models/workflow-preferences' +import { assertNonNullable } from '../fatal-error' +import { GitHubAccountType } from '../api' + +export interface IDatabaseOwner { + readonly id?: number + /** + * A case-insensitive lookup key which uniquely identifies a particular + * user on a particular endpoint. See getOwnerKey for more information. + */ + readonly key: string + readonly login: string + readonly endpoint: string + readonly type?: GitHubAccountType +} + +export interface IDatabaseGitHubRepository { + readonly id?: number + readonly ownerID: number + readonly name: string + readonly private: boolean | null + readonly htmlURL: string | null + readonly cloneURL: string | null + + /** The database ID of the parent repository if the repository is a fork. */ + readonly parentID: number | null + /** The last time a prune was attempted on the repository */ + readonly lastPruneDate: number | null + + readonly issuesEnabled?: boolean + readonly isArchived?: boolean + + readonly permissions?: 'read' | 'write' | 'admin' | null +} + +/** A record to track the protected branch information for a GitHub repository */ +export interface IDatabaseProtectedBranch { + readonly repoId: number + /** + * The branch name associated with the branch protection settings + * + * NOTE: this is NOT a fully-qualified ref (i.e. `refs/heads/main`) + */ + readonly name: string +} + +export interface IDatabaseRepository { + readonly id?: number + readonly gitHubRepositoryID: number | null + readonly path: string + readonly alias: string | null + readonly missing: boolean + + /** The last time the stash entries were checked for the repository */ + readonly lastStashCheckDate?: number | null + + readonly workflowPreferences?: WorkflowPreferences + + /** + * True if the repository is a tutorial repository created as part + * of the onboarding flow. Tutorial repositories trigger a tutorial + * user experience which introduces new users to some core concepts + * of Git and GitHub. + */ + readonly isTutorialRepository?: boolean +} + +/** + * Branches are keyed on the ID of the GitHubRepository that they belong to + * and the short name of the branch. + */ +type BranchKey = [number, string] + +/** The repositories database. */ +export class RepositoriesDatabase extends BaseDatabase { + /** The local repositories table. */ + public declare repositories: Dexie.Table + + /** The GitHub repositories table. */ + public declare gitHubRepositories: Dexie.Table< + IDatabaseGitHubRepository, + number + > + + /** A table containing the names of protected branches per repository. */ + public declare protectedBranches: Dexie.Table< + IDatabaseProtectedBranch, + BranchKey + > + + /** The GitHub repository owners table. */ + public declare owners: Dexie.Table + + /** + * Initialize a new repository database. + * + * name - The name of the database. + * schemaVersion - The version of the schema to use. If not provided, the + * database will be created with the latest version. + */ + public constructor(name: string, schemaVersion?: number) { + super(name, schemaVersion) + + this.conditionalVersion(1, { + repositories: '++id, &path', + gitHubRepositories: '++id, name', + owners: '++id, login', + }) + + this.conditionalVersion(2, { + owners: '++id, &[endpoint+login]', + }) + + // We're adding a new index with a uniqueness constraint in the *next* + // version and its upgrade callback only happens *after* the schema's been + // changed. So we need to prepare for it by removing any old data now + // which will violate it. + this.conditionalVersion(3, {}, removeDuplicateGitHubRepositories) + + this.conditionalVersion(4, { + gitHubRepositories: '++id, name, &[ownerID+name]', + }) + + this.conditionalVersion(5, { + gitHubRepositories: '++id, name, &[ownerID+name], cloneURL', + }) + + this.conditionalVersion(6, { + protectedBranches: '[repoId+name], repoId', + }) + + this.conditionalVersion(7, { + gitHubRepositories: '++id, &[ownerID+name]', + }) + + this.conditionalVersion(8, {}, ensureNoUndefinedParentID) + this.conditionalVersion(9, { owners: '++id, &key' }, createOwnerKey) + } +} + +/** + * Remove any duplicate GitHub repositories that have the same owner and name. + */ +function removeDuplicateGitHubRepositories(transaction: Transaction) { + const table = transaction.table( + 'gitHubRepositories' + ) + + const seenKeys = new Set() + return table.toCollection().each(repo => { + const key = `${repo.ownerID}+${repo.name}` + if (seenKeys.has(key)) { + // We can be sure `id` isn't null since we just got it from the + // database. + const id = repo.id! + + table.delete(id) + } else { + seenKeys.add(key) + } + }) +} + +async function ensureNoUndefinedParentID(tx: Transaction) { + return tx + .table('gitHubRepositories') + .toCollection() + .filter(ghRepo => ghRepo.parentID === undefined) + .modify({ parentID: null }) + .then(modified => log.info(`ensureNoUndefinedParentID: ${modified}`)) +} + +/** + * Replace the case-sensitive [endpoint+login] index with a case-insensitive + * lookup key in order to allow us to persist the proper case of a login. + * + * In addition to adding the key this transition will, out of an abundance of + * caution, guard against the possibility that the previous table (being + * case-sensitive) will contain two rows for the same user (only differing in + * case). This could happen if the Desktop installation as been constantly + * transitioned since before we started storing logins in lower case + * (https://github.com/desktop/desktop/pull/1242). This scenario ought to be + * incredibly unlikely. + */ +async function createOwnerKey(tx: Transaction) { + const ownersTable = tx.table('owners') + const ghReposTable = tx.table( + 'gitHubRepositories' + ) + const allOwners = await ownersTable.toArray() + + const ownerByKey = new Map() + const newOwnerIds = new Array<{ from: number; to: number }>() + const ownersToDelete = new Array() + + for (const owner of allOwners) { + assertNonNullable(owner.id, 'Missing owner id') + + const key = getOwnerKey(owner.endpoint, owner.login) + const existingOwner = ownerByKey.get(key) + + // If we've found a duplicate owner where that only differs by case we + // can't know which one of the two is accurate but that doesn't matter + // as it will eventually get corrected from fresh API data, we just need + // to pick one over the other and update any GitHubRepository still pointing + // to the owner to be deleted. + if (existingOwner !== undefined) { + assertNonNullable(existingOwner.id, 'Missing existing owner id') + log.warn( + `createOwnerKey: Conflicting owner data ${owner.id} (${owner.login}) and ${existingOwner.id} (${existingOwner.login})` + ) + newOwnerIds.push({ from: owner.id, to: existingOwner.id }) + ownersToDelete.push(owner.id) + } else { + ownerByKey.set(key, { ...owner, key }) + } + } + + log.info(`createOwnerKey: Updating ${ownerByKey.size} owners with keys`) + await ownersTable.bulkPut([...ownerByKey.values()]) + + for (const mapping of newOwnerIds) { + const modified = await ghReposTable + .where('[ownerID+name]') + .between([mapping.from], [mapping.from + 1]) + .modify({ ownerID: mapping.to }) + + log.info(`createOwnerKey: ${modified} repositories got new owner ids`) + } + + await ownersTable.bulkDelete(ownersToDelete) +} + +/* Creates a case-insensitive key used to uniquely identify an owner + * based on the endpoint and login. Note that the key happens to + * match the canonical API url for the user. This has no practical + * purpose but can make debugging a little bit easier. + */ +export function getOwnerKey(endpoint: string, login: string) { + return `${endpoint}/users/${login}`.toLowerCase() +} diff --git a/app/src/lib/desktop-fake-repository.ts b/app/src/lib/desktop-fake-repository.ts new file mode 100644 index 0000000000..ec24c905ea --- /dev/null +++ b/app/src/lib/desktop-fake-repository.ts @@ -0,0 +1,16 @@ +import { Repository } from '../models/repository' +import { getDotComAPIEndpoint } from './api' +import { GitHubRepository } from '../models/github-repository' +import { Owner } from '../models/owner' + +// HACK: This is needed because the `Rich`Text` component needs to know what +// repo to link issues against. Used when we can't rely on the repo info we keep +// in state because we it need Desktop specific, so we've stubbed out this repo +const desktopOwner = new Owner('desktop', getDotComAPIEndpoint(), -1) +const desktopUrl = 'https://github.com/desktop/desktop' +export const DesktopFakeRepository = new Repository( + '', + -1, + new GitHubRepository('desktop', desktopOwner, -1, false, desktopUrl), + true +) diff --git a/app/src/lib/diff-parser.ts b/app/src/lib/diff-parser.ts new file mode 100644 index 0000000000..7b82932c54 --- /dev/null +++ b/app/src/lib/diff-parser.ts @@ -0,0 +1,456 @@ +import { + IRawDiff, + DiffHunk, + DiffHunkHeader, + DiffLine, + DiffLineType, +} from '../models/diff' +import { assertNever } from '../lib/fatal-error' +import { getHunkHeaderExpansionType } from '../ui/diff/text-diff-expansion' +import { getLargestLineNumber } from '../ui/diff/diff-helpers' + +// https://en.wikipedia.org/wiki/Diff_utility +// +// @@ -l,s +l,s @@ optional section heading +// +// The hunk range information contains two hunk ranges. The range for the hunk of the original +// file is preceded by a minus symbol, and the range for the new file is preceded by a plus +// symbol. Each hunk range is of the format l,s where l is the starting line number and s is +// the number of lines the change hunk applies to for each respective file. +// +// In many versions of GNU diff, each range can omit the comma and trailing value s, +// in which case s defaults to 1 +const diffHeaderRe = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/ + +/** + * Regular expression matching invisible bidirectional Unicode characters that + * may be interpreted or compiled differently than what it appears. More info: + * https://github.co/hiddenchars + */ +export const HiddenBidiCharsRegex = /[\u202A-\u202E]|[\u2066-\u2069]/ + +const DiffPrefixAdd = '+' as const +const DiffPrefixDelete = '-' as const +const DiffPrefixContext = ' ' as const +const DiffPrefixNoNewline = '\\' as const + +type DiffLinePrefix = + | typeof DiffPrefixAdd + | typeof DiffPrefixDelete + | typeof DiffPrefixContext + | typeof DiffPrefixNoNewline +const DiffLinePrefixChars: Set = new Set([ + DiffPrefixAdd, + DiffPrefixDelete, + DiffPrefixContext, + DiffPrefixNoNewline, +]) + +interface IDiffHeaderInfo { + /** + * Whether or not the diff header contained a marker indicating + * that a diff couldn't be produced due to the contents of the + * new and/or old file was binary. + */ + readonly isBinary: boolean +} + +/** + * A parser for the GNU unified diff format + * + * See https://www.gnu.org/software/diffutils/manual/html_node/Detailed-Unified.html + */ +export class DiffParser { + /** + * Line start pointer. + * + * The offset into the text property where the current line starts (ie either zero + * or one character ahead of the last newline character). + */ + private ls!: number + + /** + * Line end pointer. + * + * The offset into the text property where the current line ends (ie it points to + * the newline character) or -1 if the line boundary hasn't been determined yet + */ + private le!: number + + /** + * The text buffer containing the raw, unified diff output to be parsed + */ + private text!: string + + public constructor() { + this.reset() + } + + /** + * Resets the internal parser state so that it can be reused. + * + * This is done automatically at the end of each parse run. + */ + private reset() { + this.ls = 0 + this.le = -1 + this.text = '' + } + + /** + * Aligns the internal character pointers at the boundaries of + * the next line. + * + * Returns true if successful or false if the end of the diff + * has been reached. + */ + private nextLine(): boolean { + this.ls = this.le + 1 + + // We've reached the end of the diff + if (this.ls >= this.text.length) { + return false + } + + this.le = this.text.indexOf('\n', this.ls) + + // If we can't find the next newline character we'll put our + // end pointer at the end of the diff string + if (this.le === -1) { + this.le = this.text.length + } + + // We've succeeded if there's anything to read in between the + // start and the end + return this.ls !== this.le + } + + /** + * Advances to the next line and returns it as a substring + * of the raw diff text. Returns null if end of diff was + * reached. + */ + private readLine(): string | null { + return this.nextLine() ? this.text.substring(this.ls, this.le) : null + } + + /** Tests if the current line starts with the given search text */ + private lineStartsWith(searchString: string): boolean { + return this.text.startsWith(searchString, this.ls) + } + + /** Tests if the current line ends with the given search text */ + private lineEndsWith(searchString: string): boolean { + return this.text.endsWith(searchString, this.le) + } + + /** + * Returns the starting character of the next line without + * advancing the internal state. Returns null if advancing + * would mean reaching the end of the diff. + */ + private peek(): string | null { + const p = this.le + 1 + return p < this.text.length ? this.text[p] : null + } + + /** + * Parse the diff header, meaning everything from the + * start of the diff output to the end of the line beginning + * with +++ + * + * Example diff header: + * + * diff --git a/app/src/lib/diff-parser.ts b/app/src/lib/diff-parser.ts + * index e1d4871..3bd3ee0 100644 + * --- a/app/src/lib/diff-parser.ts + * +++ b/app/src/lib/diff-parser.ts + * + * Returns an object with information extracted from the diff + * header (currently whether it's a binary patch) or null if + * the end of the diff was reached before the +++ line could be + * found (which is a valid state). + */ + private parseDiffHeader(): IDiffHeaderInfo | null { + // TODO: There's information in here that we might want to + // capture, such as mode changes + while (this.nextLine()) { + if (this.lineStartsWith('Binary files ') && this.lineEndsWith('differ')) { + return { isBinary: true } + } + + if (this.lineStartsWith('+++')) { + return { isBinary: false } + } + } + + // It's not an error to not find the +++ line, see the + // 'parses diff of empty file' test in diff-parser-tests.ts + return null + } + + /** + * Attempts to convert a RegExp capture group into a number. + * If the group doesn't exist or wasn't captured the function + * will return the value of the defaultValue parameter or throw + * an error if no default value was provided. If the captured + * string can't be converted to a number an error will be thrown. + */ + private numberFromGroup( + m: RegExpMatchArray, + group: number, + defaultValue: number | null = null + ): number { + const str = m[group] + if (!str) { + if (!defaultValue) { + throw new Error( + `Group ${group} missing from regexp match and no defaultValue was provided` + ) + } + + return defaultValue + } + + const num = parseInt(str, 10) + + if (isNaN(num)) { + throw new Error( + `Could not parse capture group ${group} into number: ${str}` + ) + } + + return num + } + + /** + * Parses a hunk header or throws an error if the given line isn't + * a well-formed hunk header. + * + * We currently only extract the line number information and + * ignore any hunk headings. + * + * Example hunk header (text within ``): + * + * `@@ -84,10 +82,8 @@ export function parseRawDiff(lines: ReadonlyArray): Diff {` + * + * Where everything after the last @@ is what's known as the hunk, or section, heading + */ + private parseHunkHeader(line: string): DiffHunkHeader { + const m = diffHeaderRe.exec(line) + if (!m) { + throw new Error(`Invalid hunk header format`) + } + + // If endLines are missing default to 1, see diffHeaderRe docs + const oldStartLine = this.numberFromGroup(m, 1) + const oldLineCount = this.numberFromGroup(m, 2, 1) + const newStartLine = this.numberFromGroup(m, 3) + const newLineCount = this.numberFromGroup(m, 4, 1) + + return new DiffHunkHeader( + oldStartLine, + oldLineCount, + newStartLine, + newLineCount + ) + } + + /** + * Convenience function which lets us leverage the type system to + * prove exhaustive checks in parseHunk. + * + * Takes an arbitrary string and checks to see if the first character + * of that string is one of the allowed prefix characters for diff + * lines (ie lines in between hunk headers). + */ + private parseLinePrefix(c: string | null): DiffLinePrefix | null { + // Since we know that DiffLinePrefixChars and the DiffLinePrefix type + // include the same characters we can tell the type system that we + // now know that c[0] is one of the characters in the DifflinePrefix set + if (c && c.length && (DiffLinePrefixChars as Set).has(c[0])) { + return c[0] as DiffLinePrefix + } + + return null + } + + /** + * Parses a hunk, including its header or throws an error if the diff doesn't + * contain a well-formed diff hunk at the current position. + * + * Expects that the position has been advanced to the beginning of a presumed + * diff hunk header. + * + * @param linesConsumed The number of unified diff lines consumed up until + * this point by the diff parser. Used to give the + * position and length (in lines) of the parsed hunk + * relative to the overall parsed diff. These numbers + * have no real meaning in the context of a diff and + * are only used to aid the app in line-selections. + */ + private parseHunk( + linesConsumed: number, + hunkIndex: number, + previousHunk: DiffHunk | null + ): DiffHunk { + const headerLine = this.readLine() + if (!headerLine) { + throw new Error('Expected hunk header but reached end of diff') + } + + const header = this.parseHunkHeader(headerLine) + const lines = new Array() + lines.push(new DiffLine(headerLine, DiffLineType.Hunk, 1, null, null)) + + let c: DiffLinePrefix | null + + let rollingDiffBeforeCounter = header.oldStartLine + let rollingDiffAfterCounter = header.newStartLine + + 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') + } + + // A marker indicating that the last line in the original or the new file + // is missing a trailing newline. In other words, the presence of this marker + // means that the new and/or original file lacks a trailing newline. + // + // When we find it we have to look up the previous line and set the + // noTrailingNewLine flag + if (c === DiffPrefixNoNewline) { + // See https://github.com/git/git/blob/21f862b498925194f8f1ebe8203b7a7df756555b/apply.c#L1725-L1732 + if (line.length < 12) { + throw new Error( + `Expected "no newline at end of file" marker to be at least 12 bytes long` + ) + } + + const previousLineIndex = lines.length - 1 + const previousLine = lines[previousLineIndex] + lines[previousLineIndex] = previousLine.withNoTrailingNewLine(true) + + continue + } + + let diffLine: DiffLine + + if (c === DiffPrefixAdd) { + diffLine = new DiffLine( + line, + DiffLineType.Add, + diffLineNumber, + null, + rollingDiffAfterCounter++ + ) + } else if (c === DiffPrefixDelete) { + diffLine = new DiffLine( + line, + DiffLineType.Delete, + diffLineNumber, + rollingDiffBeforeCounter++, + null + ) + } else if (c === DiffPrefixContext) { + diffLine = new DiffLine( + line, + DiffLineType.Context, + diffLineNumber, + rollingDiffBeforeCounter++, + rollingDiffAfterCounter++ + ) + } else { + return assertNever(c, `Unknown DiffLinePrefix: ${c}`) + } + + lines.push(diffLine) + } + + if (lines.length === 1) { + throw new Error('Malformed diff, empty hunk') + } + + return new DiffHunk( + header, + lines, + linesConsumed, + linesConsumed + lines.length - 1, + getHunkHeaderExpansionType(hunkIndex, header, previousHunk) + ) + } + + /** + * Parse a well-formed unified diff into hunks and lines. + * + * @param text A unified diff produced by git diff, git log --patch + * or any other git plumbing command that produces unified + * diffs. + */ + public parse(text: string): IRawDiff { + this.text = text + + try { + const headerInfo = this.parseDiffHeader() + + const headerEnd = this.le + const header = this.text.substring(0, headerEnd) + + // empty diff + if (!headerInfo) { + return { + header, + contents: '', + hunks: [], + isBinary: false, + maxLineNumber: 0, + hasHiddenBidiChars: false, + } + } + + if (headerInfo.isBinary) { + return { + header, + contents: '', + hunks: [], + isBinary: true, + maxLineNumber: 0, + hasHiddenBidiChars: false, + } + } + + const hunks = new Array() + let linesConsumed = 0 + let previousHunk: DiffHunk | null = null + + do { + const hunk = this.parseHunk(linesConsumed, hunks.length, previousHunk) + hunks.push(hunk) + previousHunk = hunk + linesConsumed += hunk.lines.length + } while (this.peek()) + + const contents = this.text + .substring(headerEnd + 1, this.le) + // Note that this simply returns a reference to the + // substring if no match is found, it does not create + // a new string instance. + .replace(/\n\\ No newline at end of file/g, '') + + return { + header, + contents, + hunks, + isBinary: headerInfo.isBinary, + maxLineNumber: getLargestLineNumber(hunks), + hasHiddenBidiChars: HiddenBidiCharsRegex.test(text), + } + } finally { + this.reset() + } + } +} diff --git a/app/src/lib/directory-exists.ts b/app/src/lib/directory-exists.ts new file mode 100644 index 0000000000..01339eb658 --- /dev/null +++ b/app/src/lib/directory-exists.ts @@ -0,0 +1,14 @@ +import { stat } from 'fs/promises' + +/** + * Helper method to stat a path and check both that it exists and that it's + * a directory. + */ +export const directoryExists = async (path: string) => { + try { + const s = await stat(path) + return s.isDirectory() + } catch (e) { + return false + } +} diff --git a/app/src/lib/drag-and-drop-manager.ts b/app/src/lib/drag-and-drop-manager.ts new file mode 100644 index 0000000000..62757957bf --- /dev/null +++ b/app/src/lib/drag-and-drop-manager.ts @@ -0,0 +1,90 @@ +import { Disposable, Emitter } from 'event-kit' +import { + DragData, + DragType, + DropTarget, + DropTargetSelector, +} from '../models/drag-drop' + +/** + * The drag and drop manager is implemented to manage drag and drop events + * that we want to track app wide without updating the enter app state. + * + * This was specifically implemented due to reduced performance during drag and + * drop when updating app state variables to track drag element changes during a + * drag event. + */ +export class DragAndDropManager { + private _isDragInProgress: boolean = false + private _dragData: DragData | null = null + + protected readonly emitter = new Emitter() + + public get isDragInProgress(): boolean { + return this._isDragInProgress + } + + public emitEnterDropTarget(target: DropTarget) { + this.emitter.emit('enter-drop-target', target) + } + + public emitLeaveDropTarget() { + this.emitter.emit('leave-drop-target', {}) + } + + public onEnterDropTarget(fn: (target: DropTarget) => void): Disposable { + return this.emitter.on('enter-drop-target', fn) + } + + public onLeaveDropTarget(fn: () => void): Disposable { + return this.emitter.on('leave-drop-target', fn) + } + + public onDragStarted(fn: () => void): Disposable { + return this.emitter.on('drag-started', fn) + } + + public onDragEnded( + fn: (dropTargetSelector: DropTargetSelector | undefined) => void + ): Disposable { + return this.emitter.on('drag-ended', fn) + } + + public dragStarted(): void { + this._isDragInProgress = true + this.emitter.emit('drag-started', {}) + } + + public dragEnded(dropTargetSelector: DropTargetSelector | undefined) { + this._isDragInProgress = false + this.emitter.emit('drag-ended', dropTargetSelector) + } + + public emitEnterDragZone(dropZoneDescription: string) { + this.emitter.emit('enter-drop-zone', dropZoneDescription) + } + + public onEnterDragZone( + fn: (dropZoneDescription: string) => void + ): Disposable { + return this.emitter.on('enter-drop-zone', fn) + } + + public setDragData(dragData: DragData | null): void { + this._dragData = dragData + } + + public get dragData(): DragData | null { + return this._dragData + } + + public isDragOfTypeInProgress(type: DragType) { + return this._isDragInProgress && this.isDragOfType(type) + } + + public isDragOfType(type: DragType) { + return this._dragData !== null && this._dragData.type === type + } +} + +export const dragAndDropManager = new DragAndDropManager() diff --git a/app/src/lib/editors/darwin.ts b/app/src/lib/editors/darwin.ts new file mode 100644 index 0000000000..5ef52e4766 --- /dev/null +++ b/app/src/lib/editors/darwin.ts @@ -0,0 +1,224 @@ +import { pathExists } from '../../ui/lib/path-exists' +import { IFoundEditor } from './found-editor' +import appPath from 'app-path' + +/** Represents an external editor on macOS */ +interface IDarwinExternalEditor { + /** Name of the editor. It will be used both as identifier and user-facing. */ + readonly name: string + + /** + * List of bundle identifiers that are used by the app in its multiple + * versions. + **/ + readonly bundleIdentifiers: string[] +} + +/** + * This list contains all the external editors supported on macOS. Add a new + * entry here to add support for your favorite editor. + **/ +const editors: IDarwinExternalEditor[] = [ + { + name: 'Atom', + bundleIdentifiers: ['com.github.atom'], + }, + { + name: 'Aptana Studio', + bundleIdentifiers: ['aptana.studio'], + }, + { + 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'], + }, + { + name: 'Visual Studio Code (Insiders)', + bundleIdentifiers: ['com.microsoft.VSCodeInsiders'], + }, + { + name: 'VSCodium', + bundleIdentifiers: ['com.visualstudio.code.oss', 'com.vscodium'], + }, + { + name: 'Sublime Text', + bundleIdentifiers: [ + 'com.sublimetext.4', + 'com.sublimetext.3', + 'com.sublimetext.2', + ], + }, + { + name: 'BBEdit', + bundleIdentifiers: ['com.barebones.bbedit'], + }, + { + name: 'PhpStorm', + bundleIdentifiers: ['com.jetbrains.PhpStorm'], + }, + { + 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: 'RStudio', + bundleIdentifiers: ['org.rstudio.RStudio', 'com.rstudio.desktop'], + }, + { + name: 'TextMate', + bundleIdentifiers: ['com.macromates.TextMate'], + }, + { + name: 'Brackets', + bundleIdentifiers: ['io.brackets.appshell'], + }, + { + name: 'WebStorm', + bundleIdentifiers: ['com.jetbrains.WebStorm'], + }, + { + name: 'CLion', + bundleIdentifiers: ['com.jetbrains.CLion'], + }, + { + name: 'Typora', + bundleIdentifiers: ['abnerworks.Typora'], + }, + { + name: 'CodeRunner', + bundleIdentifiers: ['com.krill.CodeRunner'], + }, + { + name: 'SlickEdit', + bundleIdentifiers: [ + 'com.slickedit.SlickEditPro2018', + 'com.slickedit.SlickEditPro2017', + 'com.slickedit.SlickEditPro2016', + 'com.slickedit.SlickEditPro2015', + ], + }, + { + name: 'IntelliJ', + bundleIdentifiers: ['com.jetbrains.intellij'], + }, + { + name: 'IntelliJ Community Edition', + bundleIdentifiers: ['com.jetbrains.intellij.ce'], + }, + { + name: 'Xcode', + bundleIdentifiers: ['com.apple.dt.Xcode'], + }, + { + name: 'GoLand', + bundleIdentifiers: ['com.jetbrains.goland'], + }, + { + name: 'Android Studio', + bundleIdentifiers: ['com.google.android.studio'], + }, + { + name: 'Rider', + bundleIdentifiers: ['com.jetbrains.rider'], + }, + { + 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'], + }, +] + +async function findApplication( + editor: IDarwinExternalEditor +): Promise { + for (const identifier of editor.bundleIdentifiers) { + try { + // app-path not finding the app isn't an error, it just means the + // bundle isn't registered on the machine. + // https://github.com/sindresorhus/app-path/blob/0e776d4e132676976b4a64e09b5e5a4c6e99fcba/index.js#L7-L13 + const installPath = await appPath(identifier).catch(e => + e.message === "Couldn't find the app" + ? Promise.resolve(null) + : Promise.reject(e) + ) + + if (installPath && (await pathExists(installPath))) { + return installPath + } + + log.debug( + `App installation for ${editor.name} not found at '${installPath}'` + ) + } catch (error) { + log.debug(`Unable to locate ${editor.name} installation`, error) + } + } + + return null +} + +/** + * Lookup known external editors using the bundle ID that each uses + * to register itself on a user's machine when installing. + */ +export async function getAvailableEditors(): Promise< + ReadonlyArray> +> { + const results: Array> = [] + + for (const editor of editors) { + const path = await findApplication(editor) + + if (path) { + results.push({ editor: editor.name, path }) + } + } + + return results +} diff --git a/app/src/lib/editors/found-editor.ts b/app/src/lib/editors/found-editor.ts new file mode 100644 index 0000000000..c6a54597cc --- /dev/null +++ b/app/src/lib/editors/found-editor.ts @@ -0,0 +1,11 @@ +export interface IFoundEditor { + readonly editor: T + readonly path: string + /** + * Indicate to Desktop to launch the editor with the `shell: true` option included. + * + * This is available to all platforms, but is only currently used by some Windows + * editors as their launch programs end in `.cmd` + */ + readonly usesShell?: boolean +} diff --git a/app/src/lib/editors/index.ts b/app/src/lib/editors/index.ts new file mode 100644 index 0000000000..a08254c05b --- /dev/null +++ b/app/src/lib/editors/index.ts @@ -0,0 +1,2 @@ +export * from './lookup' +export * from './launch' diff --git a/app/src/lib/editors/launch.ts b/app/src/lib/editors/launch.ts new file mode 100644 index 0000000000..6e1f5ee73f --- /dev/null +++ b/app/src/lib/editors/launch.ts @@ -0,0 +1,41 @@ +import { spawn, SpawnOptions } from 'child_process' +import { pathExists } from '../../ui/lib/path-exists' +import { ExternalEditorError, FoundEditor } from './shared' + +/** + * Open a given file or folder in the desired external editor. + * + * @param fullPath A folder or file path to pass as an argument when launching the editor. + * @param editor The external editor to launch. + */ +export async function launchExternalEditor( + fullPath: string, + editor: FoundEditor +): Promise { + const editorPath = editor.path + const exists = await pathExists(editorPath) + if (!exists) { + const label = __DARWIN__ ? 'Settings' : '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 } + ) + } + + 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, + } + + 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) + } +} diff --git a/app/src/lib/editors/linux.ts b/app/src/lib/editors/linux.ts new file mode 100644 index 0000000000..d7a0308b3c --- /dev/null +++ b/app/src/lib/editors/linux.ts @@ -0,0 +1,175 @@ +import { pathExists } from '../../ui/lib/path-exists' +import { IFoundEditor } from './found-editor' + +/** Represents an external editor on Linux */ +interface ILinuxExternalEditor { + /** Name of the editor. It will be used both as identifier and user-facing. */ + readonly name: string + + /** List of possible paths where the editor's executable might be located. */ + readonly paths: string[] +} + +/** + * This list contains all the external editors supported on Linux. Add a new + * entry here to add support for your favorite editor. + **/ +const editors: ILinuxExternalEditor[] = [ + { + name: 'Atom', + paths: ['/snap/bin/atom', '/usr/bin/atom'], + }, + { + 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', + '/mnt/c/Program Files/Microsoft VS Code/bin/code', + ], + }, + { + name: 'Visual Studio Code (Insiders)', + paths: ['/snap/bin/code-insiders', '/usr/bin/code-insiders'], + }, + { + name: 'VSCodium', + paths: [ + '/usr/bin/codium', + '/var/lib/flatpak/app/com.vscodium.codium', + '/usr/share/vscodium-bin/bin/codium', + ], + }, + { + name: 'Sublime Text', + paths: ['/usr/bin/subl'], + }, + { + name: 'Typora', + paths: ['/usr/bin/typora'], + }, + { + name: 'SlickEdit', + paths: [ + '/opt/slickedit-pro2018/bin/vs', + '/opt/slickedit-pro2017/bin/vs', + '/opt/slickedit-pro2016/bin/vs', + '/opt/slickedit-pro2015/bin/vs', + ], + }, + { + // Code editor for elementary OS + // https://github.com/elementary/code + 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: 'JetBrains PyCharm', + paths: [ + '/snap/bin/pycharm', + '.local/share/JetBrains/Toolbox/scripts/pycharm', + ], + }, + { + 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: 'Geany', + paths: ['/usr/bin/geany'], + }, + { + name: 'Mousepad', + paths: ['/usr/bin/mousepad'], + }, +] + +async function getAvailablePath(paths: string[]): Promise { + for (const path of paths) { + if (await pathExists(path)) { + return path + } + } + + return null +} + +export async function getAvailableEditors(): Promise< + ReadonlyArray> +> { + const results: Array> = [] + + for (const editor of editors) { + const path = await getAvailablePath(editor.paths) + if (path) { + results.push({ editor: editor.name, path }) + } + } + + return results +} diff --git a/app/src/lib/editors/lookup.ts b/app/src/lib/editors/lookup.ts new file mode 100644 index 0000000000..652e51f8b1 --- /dev/null +++ b/app/src/lib/editors/lookup.ts @@ -0,0 +1,70 @@ +import { ExternalEditorError } from './shared' +import { IFoundEditor } from './found-editor' +import { getAvailableEditors as getAvailableEditorsDarwin } from './darwin' +import { getAvailableEditors as getAvailableEditorsWindows } from './win32' +import { getAvailableEditors as getAvailableEditorsLinux } from './linux' + +let editorCache: ReadonlyArray> | null = null + +/** + * Resolve a list of installed editors on the user's machine, using the known + * install identifiers that each OS supports. + */ +export async function getAvailableEditors(): Promise< + ReadonlyArray> +> { + if (editorCache && editorCache.length > 0) { + return editorCache + } + + if (__DARWIN__) { + editorCache = await getAvailableEditorsDarwin() + return editorCache + } + + if (__WIN32__) { + editorCache = await getAvailableEditorsWindows() + return editorCache + } + + if (__LINUX__) { + editorCache = await getAvailableEditorsLinux() + return editorCache + } + + log.warn( + `Platform not currently supported for resolving editors: ${process.platform}` + ) + + return [] +} + +/** + * Find an editor installed on the machine using the friendly name, or the + * first valid editor if `null` is provided. + * + * Will throw an error if no editors are found, or if the editor name cannot + * be found (i.e. it has been removed). + */ +export async function findEditorOrDefault( + name: string | null +): Promise | null> { + const editors = await getAvailableEditors() + if (editors.length === 0) { + return null + } + + if (name) { + const match = editors.find(p => p.editor === name) || null + if (!match) { + 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 }) + } + + return match + } + + return editors[0] +} diff --git a/app/src/lib/editors/shared.ts b/app/src/lib/editors/shared.ts new file mode 100644 index 0000000000..3375efdddc --- /dev/null +++ b/app/src/lib/editors/shared.ts @@ -0,0 +1,41 @@ +/** + * A found external editor on the user's machine + */ +export type FoundEditor = { + /** + * The friendly name of the editor, to be used in labels + */ + editor: string + /** + * The executable associated with the editor to launch + */ + path: string + /** + * the editor requires a shell spawn to launch + */ + usesShell?: boolean +} + +interface IErrorMetadata { + /** The error dialog should link off to the default editor's website */ + suggestDefaultEditor?: boolean + + /** The error dialog should direct the user to open Preferences */ + openPreferences?: boolean +} + +export class ExternalEditorError extends Error { + /** The error's metadata. */ + public readonly metadata: IErrorMetadata + + public constructor(message: string, metadata: IErrorMetadata = {}) { + super(message) + + this.metadata = metadata + } +} + +export const suggestedExternalEditor = { + name: 'Visual Studio Code', + url: 'https://code.visualstudio.com', +} diff --git a/app/src/lib/editors/win32.ts b/app/src/lib/editors/win32.ts new file mode 100644 index 0000000000..9c58119d57 --- /dev/null +++ b/app/src/lib/editors/win32.ts @@ -0,0 +1,614 @@ +import * as Path from 'path' + +import { + enumerateValues, + HKEY, + RegistryValue, + RegistryValueType, +} from 'registry-js' +import { pathExists } from '../../ui/lib/path-exists' + +import { IFoundEditor } from './found-editor' + +interface IWindowsAppInformation { + displayName: string + publisher: string + installLocation: string +} + +type RegistryKey = { key: HKEY; subKey: string } + +type WindowsExternalEditorPathInfo = + | { + /** + * Registry key with the install location of the app. If not provided, + * 'InstallLocation' or 'UninstallString' will be assumed. + **/ + readonly installLocationRegistryKey?: + | 'InstallLocation' + | 'UninstallString' + + /** + * List of lists of path components from the editor's installation folder to + * the potential executable shims. Only needed when the install location + * registry key is `InstallLocation`. + **/ + readonly executableShimPaths: ReadonlyArray> + } + | { + /** + * Registry key with the install location of the app. + **/ + readonly installLocationRegistryKey: 'DisplayIcon' + } + +/** Represents an external editor on Windows */ +type WindowsExternalEditor = { + /** Name of the editor. It will be used both as identifier and user-facing. */ + readonly name: string + + /** + * Set of registry keys associated with the installed application. + * + * Some tools (like VSCode) may support a 64-bit or 32-bit version of the + * tool - we should use whichever they have installed. + */ + readonly registryKeys: ReadonlyArray + + /** Prefix of the DisplayName registry key that belongs to this editor. */ + readonly displayNamePrefixes: string[] + + /** Value of the Publisher registry key that belongs to this editor. */ + readonly publishers: string[] + + /** + * Default shell script name for JetBrains Product + * To get the script name go to: + * JetBrains Toolbox > Editor settings > Shell script name + * + * Go to `/docs/techical/editor-integration.md` for more information on + * how to use this field. + */ + readonly jetBrainsToolboxScriptName?: string +} & WindowsExternalEditorPathInfo + +const registryKey = (key: HKEY, ...subKeys: string[]): RegistryKey => ({ + key, + subKey: Path.win32.join(...subKeys), +}) + +const uninstallSubKey = + 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' + +const wow64UninstallSubKey = + 'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall' + +const CurrentUserUninstallKey = (subKey: string) => + registryKey(HKEY.HKEY_CURRENT_USER, uninstallSubKey, subKey) + +const LocalMachineUninstallKey = (subKey: string) => + registryKey(HKEY.HKEY_LOCAL_MACHINE, uninstallSubKey, subKey) + +const Wow64LocalMachineUninstallKey = (subKey: string) => + registryKey(HKEY.HKEY_LOCAL_MACHINE, wow64UninstallSubKey, subKey) + +// This function generates registry keys for a given JetBrains product for the +// last 2 years, assuming JetBrains makes no more than 5 major releases and +// no more than 5 minor releases per year +const registryKeysForJetBrainsIDE = ( + product: string +): ReadonlyArray => { + const maxMajorReleasesPerYear = 5 + const maxMinorReleasesPerYear = 5 + const lastYear = new Date().getFullYear() + const firstYear = lastYear - 2 + + const result = new Array() + + for (let year = firstYear; year <= lastYear; year++) { + for ( + let majorRelease = 1; + majorRelease <= maxMajorReleasesPerYear; + majorRelease++ + ) { + for ( + let minorRelease = 0; + minorRelease <= maxMinorReleasesPerYear; + minorRelease++ + ) { + let key = `${product} ${year}.${majorRelease}` + if (minorRelease > 0) { + key = `${key}.${minorRelease}` + } + result.push(Wow64LocalMachineUninstallKey(key)) + result.push(CurrentUserUninstallKey(key)) + } + } + } + + // Return in reverse order to prioritize newer versions + return result.reverse() +} + +// JetBrains IDEs might have 64 and/or 32 bit executables, so let's add both. +const executableShimPathsForJetBrainsIDE = ( + baseName: string +): ReadonlyArray> => { + return [ + ['bin', `${baseName}64.exe`], + ['bin', `${baseName}.exe`], + ] +} + +// 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)) +} + +/** + * 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']], + displayNamePrefixes: ['Atom'], + publishers: ['GitHub Inc.'], + }, + { + name: 'Atom Beta', + registryKeys: [CurrentUserUninstallKey('atom-beta')], + executableShimPaths: [['bin', 'atom-beta.cmd']], + displayNamePrefixes: ['Atom Beta'], + publishers: ['GitHub Inc.'], + }, + { + name: 'Atom Nightly', + registryKeys: [CurrentUserUninstallKey('atom-nightly')], + executableShimPaths: [['bin', 'atom-nightly.cmd']], + displayNamePrefixes: ['Atom Nightly'], + publishers: ['GitHub Inc.'], + }, + { + name: 'Visual Studio Code', + registryKeys: [ + // 64-bit version of VSCode (user) - provided by default in 64-bit Windows + CurrentUserUninstallKey('{771FD6B0-FA20-440A-A002-3B3BAC16DC50}_is1'), + // 32-bit version of VSCode (user) + CurrentUserUninstallKey('{D628A17A-9713-46BF-8D57-E671B46A741E}_is1'), + // ARM64 version of VSCode (user) + CurrentUserUninstallKey('{D9E514E7-1A56-452D-9337-2990C0DC4310}_is1'), + // 64-bit version of VSCode (system) - was default before user scope installation + LocalMachineUninstallKey('{EA457B21-F73E-494C-ACAB-524FDE069978}_is1'), + // 32-bit version of VSCode (system) + Wow64LocalMachineUninstallKey( + '{F8A2A208-72B3-4D61-95FC-8A65D340689B}_is1' + ), + // ARM64 version of VSCode (system) + LocalMachineUninstallKey('{A5270FC5-65AD-483E-AC30-2C276B63D0AC}_is1'), + ], + executableShimPaths: [['bin', 'code.cmd']], + displayNamePrefixes: ['Microsoft Visual Studio Code'], + publishers: ['Microsoft Corporation'], + }, + { + name: 'Visual Studio Code (Insiders)', + registryKeys: [ + // 64-bit version of VSCode (user) - provided by default in 64-bit Windows + CurrentUserUninstallKey('{217B4C08-948D-4276-BFBB-BEE930AE5A2C}_is1'), + // 32-bit version of VSCode (user) + CurrentUserUninstallKey('{26F4A15E-E392-4887-8C09-7BC55712FD5B}_is1'), + // ARM64 version of VSCode (user) + CurrentUserUninstallKey('{69BD8F7B-65EB-4C6F-A14E-44CFA83712C0}_is1'), + // 64-bit version of VSCode (system) - was default before user scope installation + LocalMachineUninstallKey('{1287CAD5-7C8D-410D-88B9-0D1EE4A83FF2}_is1'), + // 32-bit version of VSCode (system) + Wow64LocalMachineUninstallKey( + '{C26E74D1-022E-4238-8B9D-1E7564A36CC9}_is1' + ), + // ARM64 version of VSCode (system) + LocalMachineUninstallKey('{0AEDB616-9614-463B-97D7-119DD86CCA64}_is1'), + ], + executableShimPaths: [['bin', 'code-insiders.cmd']], + displayNamePrefixes: ['Microsoft Visual Studio Code Insiders'], + publishers: ['Microsoft Corporation'], + }, + { + name: 'VSCodium', + registryKeys: [ + // 64-bit version of VSCodium (user) + CurrentUserUninstallKey('{2E1F05D1-C245-4562-81EE-28188DB6FD17}_is1'), + // 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) - old key + CurrentUserUninstallKey('{3AEBF0C8-F733-4AD4-BADE-FDB816D53D7B}_is1'), + // 64-bit version of VSCodium (system) - old key + LocalMachineUninstallKey('{D77B7E06-80BA-4137-BCF4-654B95CCEBC5}_is1'), + // 32-bit version of VSCodium (system) - old key + Wow64LocalMachineUninstallKey( + '{E34003BB-9E10-4501-8C11-BE3FAA83F23F}_is1' + ), + // ARM64 version of VSCodium (system) - old key + LocalMachineUninstallKey('{D1ACE434-89C5-48D1-88D3-E2991DF85475}_is1'), + ], + executableShimPaths: [['bin', 'codium.cmd']], + 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: [['bin', 'codium-insiders.cmd']], + displayNamePrefixes: ['VSCodium Insiders', 'VSCodium (Insiders)'], + publishers: ['VSCodium'], + }, + { + name: 'Sublime Text', + registryKeys: [ + // Sublime Text 4 (and newer?) + LocalMachineUninstallKey('Sublime Text_is1'), + // Sublime Text 3 + LocalMachineUninstallKey('Sublime Text 3_is1'), + ], + executableShimPaths: [['subl.exe']], + displayNamePrefixes: ['Sublime Text'], + publishers: ['Sublime HQ Pty Ltd'], + }, + { + name: 'Brackets', + registryKeys: [ + Wow64LocalMachineUninstallKey('{4F3B6E8C-401B-4EDE-A423-6481C239D6FF}'), + ], + executableShimPaths: [['Brackets.exe']], + displayNamePrefixes: ['Brackets'], + publishers: ['brackets.io'], + }, + { + name: 'ColdFusion Builder', + registryKeys: [ + // 64-bit version of ColdFusionBuilder3 + LocalMachineUninstallKey('Adobe ColdFusion Builder 3_is1'), + // 64-bit version of ColdFusionBuilder2016 + LocalMachineUninstallKey('Adobe ColdFusion Builder 2016'), + ], + executableShimPaths: [['CFBuilder.exe']], + displayNamePrefixes: ['Adobe ColdFusion Builder'], + publishers: ['Adobe Systems Incorporated'], + }, + { + name: 'Typora', + registryKeys: [ + // 64-bit version of Typora + LocalMachineUninstallKey('{37771A20-7167-44C0-B322-FD3E54C56156}_is1'), + // 32-bit version of Typora + Wow64LocalMachineUninstallKey( + '{37771A20-7167-44C0-B322-FD3E54C56156}_is1' + ), + ], + executableShimPaths: [['typora.exe']], + displayNamePrefixes: ['Typora'], + publishers: ['typora.io'], + }, + { + name: 'SlickEdit', + registryKeys: [ + // 64-bit version of SlickEdit Pro 2018 + LocalMachineUninstallKey('{18406187-F49E-4822-CAF2-1D25C0C83BA2}'), + // 32-bit version of SlickEdit Pro 2018 + Wow64LocalMachineUninstallKey('{18006187-F49E-4822-CAF2-1D25C0C83BA2}'), + // 64-bit version of SlickEdit Standard 2018 + LocalMachineUninstallKey('{18606187-F49E-4822-CAF2-1D25C0C83BA2}'), + // 32-bit version of SlickEdit Standard 2018 + Wow64LocalMachineUninstallKey('{18206187-F49E-4822-CAF2-1D25C0C83BA2}'), + // 64-bit version of SlickEdit Pro 2017 + LocalMachineUninstallKey('{15406187-F49E-4822-CAF2-1D25C0C83BA2}'), + // 32-bit version of SlickEdit Pro 2017 + Wow64LocalMachineUninstallKey('{15006187-F49E-4822-CAF2-1D25C0C83BA2}'), + // 64-bit version of SlickEdit Pro 2016 (21.0.1) + LocalMachineUninstallKey('{10C06187-F49E-4822-CAF2-1D25C0C83BA2}'), + // 64-bit version of SlickEdit Pro 2016 (21.0.0) + LocalMachineUninstallKey('{10406187-F49E-4822-CAF2-1D25C0C83BA2}'), + // 64-bit version of SlickEdit Pro 2015 (20.0.3) + LocalMachineUninstallKey('{0DC06187-F49E-4822-CAF2-1D25C0C83BA2}'), + // 64-bit version of SlickEdit Pro 2015 (20.0.2) + LocalMachineUninstallKey('{0D406187-F49E-4822-CAF2-1D25C0C83BA2}'), + // 64-bit version of SlickEdit Pro 2014 (19.0.2) + LocalMachineUninstallKey('{7CC0E567-ACD6-41E8-95DA-154CEEDB0A18}'), + ], + executableShimPaths: [['win', 'vs.exe']], + displayNamePrefixes: ['SlickEdit'], + publishers: ['SlickEdit Inc.'], + }, + { + name: 'Aptana Studio 3', + registryKeys: [ + Wow64LocalMachineUninstallKey('{2D6C1116-78C6-469C-9923-3E549218773F}'), + ], + executableShimPaths: [['AptanaStudio3.exe']], + displayNamePrefixes: ['Aptana Studio'], + publishers: ['Appcelerator'], + }, + { + name: 'JetBrains Webstorm', + registryKeys: registryKeysForJetBrainsIDE('WebStorm'), + executableShimPaths: executableShimPathsForJetBrainsIDE('webstorm'), + jetBrainsToolboxScriptName: 'webstorm', + displayNamePrefixes: ['WebStorm'], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains Phpstorm', + registryKeys: registryKeysForJetBrainsIDE('PhpStorm'), + executableShimPaths: executableShimPathsForJetBrainsIDE('phpstorm'), + jetBrainsToolboxScriptName: 'phpstorm', + displayNamePrefixes: ['PhpStorm'], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'Android Studio', + registryKeys: [LocalMachineUninstallKey('Android Studio')], + installLocationRegistryKey: 'UninstallString', + jetBrainsToolboxScriptName: 'studio', + executableShimPaths: [ + ['..', 'bin', `studio64.exe`], + ['..', 'bin', `studio.exe`], + ], + displayNamePrefixes: ['Android Studio'], + publishers: ['Google LLC'], + }, + { + name: 'Notepad++', + registryKeys: [ + // 64-bit version of Notepad++ + LocalMachineUninstallKey('Notepad++'), + // 32-bit version of Notepad++ + Wow64LocalMachineUninstallKey('Notepad++'), + ], + installLocationRegistryKey: 'DisplayIcon', + displayNamePrefixes: ['Notepad++'], + publishers: ['Notepad++ Team'], + }, + { + name: 'JetBrains Rider', + registryKeys: registryKeysForJetBrainsIDE('JetBrains Rider'), + executableShimPaths: executableShimPathsForJetBrainsIDE('rider'), + jetBrainsToolboxScriptName: 'rider', + displayNamePrefixes: ['JetBrains Rider'], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'RStudio', + registryKeys: [Wow64LocalMachineUninstallKey('RStudio')], + installLocationRegistryKey: 'DisplayIcon', + displayNamePrefixes: ['RStudio'], + publishers: ['RStudio', 'Posit Software'], + }, + { + name: 'JetBrains IntelliJ Idea', + registryKeys: registryKeysForJetBrainsIDE('IntelliJ IDEA'), + executableShimPaths: executableShimPathsForJetBrainsIDE('idea'), + jetBrainsToolboxScriptName: 'idea', + displayNamePrefixes: ['IntelliJ IDEA '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains IntelliJ Idea Community Edition', + registryKeys: registryKeysForJetBrainsIDE( + 'IntelliJ IDEA Community Edition' + ), + executableShimPaths: executableShimPathsForJetBrainsIDE('idea'), + displayNamePrefixes: ['IntelliJ IDEA Community Edition '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains PyCharm', + registryKeys: registryKeysForJetBrainsIDE('PyCharm'), + executableShimPaths: executableShimPathsForJetBrainsIDE('pycharm'), + jetBrainsToolboxScriptName: 'pycharm', + displayNamePrefixes: ['PyCharm '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains PyCharm Community Edition', + registryKeys: registryKeysForJetBrainsIDE('PyCharm Community Edition'), + executableShimPaths: executableShimPathsForJetBrainsIDE('pycharm'), + displayNamePrefixes: ['PyCharm Community Edition'], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains CLion', + registryKeys: registryKeysForJetBrainsIDE('CLion'), + executableShimPaths: executableShimPathsForJetBrainsIDE('clion'), + jetBrainsToolboxScriptName: 'clion', + displayNamePrefixes: ['CLion '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains RubyMine', + registryKeys: registryKeysForJetBrainsIDE('RubyMine'), + executableShimPaths: executableShimPathsForJetBrainsIDE('rubymine'), + jetBrainsToolboxScriptName: 'rubymine', + displayNamePrefixes: ['RubyMine '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains GoLand', + registryKeys: registryKeysForJetBrainsIDE('GoLand'), + executableShimPaths: executableShimPathsForJetBrainsIDE('goland'), + jetBrainsToolboxScriptName: 'goland', + displayNamePrefixes: ['GoLand '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains Fleet', + registryKeys: [LocalMachineUninstallKey('Fleet')], + jetBrainsToolboxScriptName: 'fleet', + installLocationRegistryKey: 'DisplayIcon', + displayNamePrefixes: ['Fleet '], + publishers: ['JetBrains s.r.o.'], + }, + { + name: 'JetBrains DataSpell', + registryKeys: registryKeysForJetBrainsIDE('DataSpell'), + executableShimPaths: executableShimPathsForJetBrainsIDE('dataspell'), + jetBrainsToolboxScriptName: 'dataspell', + displayNamePrefixes: ['DataSpell '], + 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'], + }, +] + +function getKeyOrEmpty( + keys: ReadonlyArray, + key: string +): string { + const entry = keys.find(k => k.name === key) + return entry && entry.type === RegistryValueType.REG_SZ ? entry.data : '' +} + +function getAppInfo( + editor: WindowsExternalEditor, + keys: ReadonlyArray +): IWindowsAppInformation { + const displayName = getKeyOrEmpty(keys, 'DisplayName') + const publisher = getKeyOrEmpty(keys, 'Publisher') + const installLocation = getKeyOrEmpty( + keys, + editor.installLocationRegistryKey ?? 'InstallLocation' + ) + return { displayName, publisher, installLocation } +} + +async function findApplication(editor: WindowsExternalEditor) { + for (const { key, subKey } of editor.registryKeys) { + const keys = enumerateValues(key, subKey) + if (keys.length === 0) { + continue + } + + const { displayName, publisher, installLocation } = getAppInfo(editor, keys) + + if ( + !validateStartsWith(displayName, editor.displayNamePrefixes) || + !editor.publishers.includes(publisher) + ) { + log.debug(`Unexpected registry entries for ${editor.name}`) + continue + } + + const executableShimPaths = + editor.installLocationRegistryKey === 'DisplayIcon' + ? [installLocation] + : editor.executableShimPaths.map(p => Path.join(installLocation, ...p)) + + for (const path of executableShimPaths) { + const exists = await pathExists(path) + if (exists) { + return path + } + + log.debug(`Executable for ${editor.name} not found at '${path}'`) + } + } + + return findJetBrainsToolboxApplication(editor) +} + +/** + * Find JetBrain products installed through JetBrains Toolbox + */ +async function findJetBrainsToolboxApplication(editor: WindowsExternalEditor) { + if (!editor.jetBrainsToolboxScriptName) { + return null + } + + const toolboxRegistryReference = [ + CurrentUserUninstallKey('toolbox'), + Wow64LocalMachineUninstallKey('toolbox'), + ] + + for (const { key, subKey } of toolboxRegistryReference) { + const keys = enumerateValues(key, subKey) + if (keys.length > 0) { + const editorPathInToolbox = Path.join( + getKeyOrEmpty(keys, 'UninstallString'), + '..', + '..', + 'scripts', + `${editor.jetBrainsToolboxScriptName}.cmd` + ) + const exists = await pathExists(editorPathInToolbox) + if (exists) { + return editorPathInToolbox + } + } + } + + return null +} + +/** + * Lookup known external editors using the Windows registry to find installed + * applications and their location on disk for Desktop to launch. + */ +export async function getAvailableEditors(): Promise< + ReadonlyArray> +> { + const results: Array> = [] + + for (const editor of editors) { + const path = await findApplication(editor) + + if (path) { + results.push({ + editor: editor.name, + path, + usesShell: path.endsWith('.cmd'), + }) + } + } + + return results +} diff --git a/app/src/lib/email.ts b/app/src/lib/email.ts new file mode 100644 index 0000000000..2229a4709e --- /dev/null +++ b/app/src/lib/email.ts @@ -0,0 +1,124 @@ +import * as URL from 'url' + +import { IAPIEmail, getDotComAPIEndpoint } from './api' +import { Account } from '../models/account' + +/** + * Lookup a suitable email address to display in the application, based on the + * following rules: + * + * - the primary email if it's publicly visible + * - an anonymous (i.e. '@users.noreply.github.com') email address + * - the first email address returned from the API + * - an automatically generated stealth email based on the user's + * login, id, and endpoint. + * + * @param emails array of email addresses associated with an account + */ +export function lookupPreferredEmail(account: Account): string { + const emails = account.emails + + if (emails.length === 0) { + return getStealthEmailForUser(account.id, account.login, account.endpoint) + } + + const primary = emails.find(e => e.primary) + if (primary && isEmailPublic(primary)) { + return primary.email + } + + const stealthSuffix = `@${getStealthEmailHostForEndpoint(account.endpoint)}` + const noReply = emails.find(e => + e.email.toLowerCase().endsWith(stealthSuffix) + ) + + if (noReply) { + return noReply.email + } + + return emails[0].email +} + +/** + * Is the email public? + */ +function isEmailPublic(email: IAPIEmail): boolean { + // If an email doesn't have a visibility setting it means it's coming from an + // older Enterprise version which doesn't have the concept of visibility. + return email.visibility === 'public' || !email.visibility +} + +/** + * Returns the stealth email host name for a given endpoint. The stealth + * 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}` + : 'users.noreply.github.com' +} + +/** + * Generate a legacy stealth email address for the user + * on the given server. + * + * Ex: desktop@users.noreply.github.com + * + * @param login The user handle or "login" + * @param endpoint The API endpoint that this login belongs to, + * either GitHub.com or a GitHub Enterprise + * instance + */ +export function getLegacyStealthEmailForUser(login: string, endpoint: string) { + const stealthEmailHost = getStealthEmailHostForEndpoint(endpoint) + return `${login}@${stealthEmailHost}` +} + +/** + * Generate a stealth email address for the user on the given + * server. + * + * Ex: 123456+desktop@users.noreply.github.com + * + * @param id The numeric user id as returned by the endpoint + * API. See getLegacyStealthEmailFor if no user id + * is available. + * @param login The user handle or "login" + * @param endpoint The API endpoint that this login belongs to, + * either GitHub.com or a GitHub Enterprise + * instance + */ +export function getStealthEmailForUser( + id: number, + login: string, + endpoint: string +) { + const stealthEmailHost = getStealthEmailHostForEndpoint(endpoint) + return `${id}+${login}@${stealthEmailHost}` +} + +/** + * Gets a value indicating whether a commit email matching the given email would + * get attributed to the account (i.e. user) if pushed to the endpoint that said + * account belongs to. + * + * When determining if an email is attributable to an account we consider a list + * of email addresses consisting of all the email addresses we get from the API + * (since this is for the currently signed in user we get public as well as + * private email addresses here) as well as the legacy and modern format of the + * anonymous email addresses, for example: + * + * desktop@users.noreply.github.com + * 13171334+desktop@users.noreply.github.com + */ +export const isAttributableEmailFor = (account: Account, email: string) => { + const { id, login, endpoint, emails } = account + const needle = email.toLowerCase() + + return ( + emails.some(({ email }) => email.toLowerCase() === needle) || + getStealthEmailForUser(id, login, endpoint).toLowerCase() === needle || + getLegacyStealthEmailForUser(login, endpoint).toLowerCase() === needle + ) +} diff --git a/app/src/lib/endpoint-capabilities.ts b/app/src/lib/endpoint-capabilities.ts new file mode 100644 index 0000000000..ad39622de6 --- /dev/null +++ b/app/src/lib/endpoint-capabilities.ts @@ -0,0 +1,182 @@ +import * as semver from 'semver' +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) + */ + ae: boolean | string + /** + * Whether this constrain will be satisfied when using GitHub Enterprise + * Server. Supports specifying a version constraint as a SemVer Range (ex: >= + * 3.1.0) + */ + es: boolean | string +} + +/** + * If we're connected to a GHES instance but it doesn't report a version + * number (either because of corporate proxies that strip the version + * header or because GHES stops sending the version header in the future) + * we'll assume it's this version. + * + * This should correspond loosely with the oldest supported GHES series and + * needs to be updated manually. + */ +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() + +/** Stores parsed x-github-enterprise-version headers keyed on endpoint */ +const versionCache = new Map() + +/** Get the cache key for a given endpoint address */ +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 + */ +export const isDotCom = (ep: string) => ep === getDotComAPIEndpoint() + +/** + * 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) + +/** + * 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) + +function getEndpointVersion(endpoint: string) { + const key = endpointVersionKey(endpoint) + const cached = versionCache.get(key) + + if (cached !== undefined) { + return cached + } + + const raw = localStorage.getItem(key) + const parsed = raw === null ? null : semver.parse(raw) + + if (parsed !== null) { + versionCache.set(key, parsed) + } + + return parsed +} + +/** + * Update the known version number for a given endpoint + */ +export function updateEndpointVersion(endpoint: string, version: string) { + const key = endpointVersionKey(endpoint) + + if (rawVersionCache.get(key) !== version) { + const parsed = semver.parse(version) + localStorage.setItem(key, version) + rawVersionCache.set(key, version) + versionCache.set(key, parsed) + } +} + +function checkConstraint( + epConstraint: string | boolean, + epMatchesType: boolean, + epVersion?: semver.SemVer +) { + // Denial of endpoint type regardless of version + if (epConstraint === false) { + return false + } + + // Approval of endpoint type regardless of version + if (epConstraint === true) { + return epMatchesType + } + + // Version number constraint + assertNonNullable(epVersion, `Need to provide a version to compare against`) + return epMatchesType && semver.satisfies(epVersion, epConstraint) +} + +/** + * Returns a predicate which verifies whether a given endpoint matches the + * provided constraints. + * + * Note: NOT meant for direct consumption, only exported for testability reasons. + * Consumers should use the various `supports*` methods instead. + */ +export const endpointSatisfies = + ({ dotcom, ae, es }: VersionConstraint, getVersion = getEndpointVersion) => + (ep: string) => + checkConstraint(dotcom, isDotCom(ep)) || + checkConstraint(ae, isGHAE(ep), assumedGHAEVersion) || + 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 supportsRerunningChecks = endpointSatisfies({ + dotcom: true, + ae: '>= 3.4.0', + es: '>= 3.4.0', +}) + +export const supportsRerunningIndividualOrFailedChecks = endpointSatisfies({ + dotcom: true, + ae: false, + es: false, +}) + +/** + * Whether or not the endpoint supports the retrieval of action workflows by + * check suite id. + */ +export const supportsRetrieveActionWorkflowByCheckSuiteId = endpointSatisfies({ + dotcom: true, + ae: false, + es: false, +}) + +export const supportsAliveSessions = endpointSatisfies({ + dotcom: true, + ae: false, + es: false, +}) + +export const supportsRepoRules = endpointSatisfies({ + dotcom: true, + ae: false, + es: false, +}) diff --git a/app/src/lib/endpoint-token.ts b/app/src/lib/endpoint-token.ts new file mode 100644 index 0000000000..f1d453a53e --- /dev/null +++ b/app/src/lib/endpoint-token.ts @@ -0,0 +1 @@ +export type EndpointToken = { endpoint: string; token: string } diff --git a/app/src/lib/enterprise.ts b/app/src/lib/enterprise.ts new file mode 100644 index 0000000000..8390cd6e0c --- /dev/null +++ b/app/src/lib/enterprise.ts @@ -0,0 +1,8 @@ +/** + * 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/enum.ts b/app/src/lib/enum.ts new file mode 100644 index 0000000000..52d23ac094 --- /dev/null +++ b/app/src/lib/enum.ts @@ -0,0 +1,10 @@ +/** + * Parse a string into the given (string) enum type. Returns undefined if the + * enum type provided did not match any of the keys in the enum. + */ +export function parseEnumValue( + enumObj: Record, + value: string +): T | undefined { + return Object.values(enumObj).find(v => v === value) +} diff --git a/app/src/lib/equality.ts b/app/src/lib/equality.ts new file mode 100644 index 0000000000..e9e97b0917 --- /dev/null +++ b/app/src/lib/equality.ts @@ -0,0 +1,100 @@ +import deepEquals from 'deep-equal' + +export function structuralEquals( + actual: T, + expected: T +): boolean { + return deepEquals(actual, expected, { strict: true }) +} + +/** + * Performs a shallow equality comparison on the two objects, iterating over + * their keys (non-recursively) and compares their values. + * + * This method is functionally identical to that of React's shallowCompare + * function and is intended to be used where we need to test for the same + * kind of equality comparisons that a PureComponent performs. + * + * Note that for Arrays and primitive types this method will follow the same + * semantics as Object.is, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is + */ +export function shallowEquals(x: any, y: any) { + if (Object.is(x, y)) { + return true + } + + // After this we know that neither side is null or undefined + if ( + x === null || + y === null || + typeof x !== 'object' || + typeof y !== 'object' + ) { + return false + } + + const xKeys = Object.keys(x) + const yKeys = Object.keys(y) + + if (xKeys.length !== yKeys.length) { + return false + } + + for (let i = 0; i < xKeys.length; i++) { + const key = xKeys[i] + if (!Object.hasOwn(y, key) || !Object.is(x[key], y[key])) { + return false + } + } + + return true +} + +/** + * Compares two arrays for element reference equality. + * + * Two arrays are considered equal if they either contain the + * exact same elements in the same order (reference equality) + * if they're both empty, or if they are the exact same object + */ +export function arrayEquals(x: ReadonlyArray, y: ReadonlyArray) { + if (x === y) { + return true + } + + if (x.length !== y.length) { + return false + } + + for (let i = 0; i < x.length; i++) { + if (x[i] !== y[i]) { + return false + } + } + + return true +} + +/** + * Compares two maps for key reference equality. + * + * Two maps are considered equal if all their keys coincide, if they're + * both empty or if they're the same object. + */ +export function mapKeysEqual(x: Map, y: Map) { + if (x === y) { + return true + } + + if (x.size !== y.size) { + return false + } + + for (const key of x.keys()) { + if (!y.has(key)) { + return false + } + } + + return true +} diff --git a/app/src/lib/errno-exception.ts b/app/src/lib/errno-exception.ts new file mode 100644 index 0000000000..889c53e250 --- /dev/null +++ b/app/src/lib/errno-exception.ts @@ -0,0 +1,34 @@ +/** + * A type describing a specific type of errors thrown by Node.js + * when encountering errors in low-level operations (such as IO, network, + * processes) containing additional information related to the error + * itself. + */ +interface IErrnoException extends Error { + /** + * The string name for a numeric error code that comes from a Node.js API. + * See https://nodejs.org/api/util.html#util_util_getsystemerrorname_err + */ + readonly code: string + + /** + * The "system call" (i.e. the Node abstraction) such as 'spawn', 'open', etc + * which was responsible for triggering the exception. + * + * See https://github.com/nodejs/node/blob/v10.16.0/lib/internal/errors.js#L333-L351 + */ + readonly syscall: string +} + +/** + * Determine whether the given object conforms to the shape of an + * internal Node.js low-level exception, see IErrnoException for + * more details. + */ +export function isErrnoException(err: any): err is IErrnoException { + return ( + err instanceof Error && + typeof (err as any).code === 'string' && + typeof (err as any).syscall === 'string' + ) +} diff --git a/app/src/lib/error-with-metadata.ts b/app/src/lib/error-with-metadata.ts new file mode 100644 index 0000000000..b6417c8df3 --- /dev/null +++ b/app/src/lib/error-with-metadata.ts @@ -0,0 +1,68 @@ +import { Repository } from '../models/repository' +import { CloningRepository } from '../models/cloning-repository' +import { RetryAction, RetryActionType } from '../models/retry-actions' +import { GitErrorContext } from './git-error-context' +import { Branch } from '../models/branch' +import { WorkingDirectoryFileChange } from '../models/status' + +export interface IErrorMetadata { + /** Was the action which caused this error part of a background task? */ + readonly backgroundTask?: boolean + + /** The repository from which this error originated. */ + readonly repository?: Repository | CloningRepository + + /** The action to retry if applicable. */ + readonly retryAction?: RetryAction + + /** Additional context that specific actions can provide fields for */ + readonly gitContext?: GitErrorContext +} + +/** An error which contains additional metadata. */ +export class ErrorWithMetadata extends Error { + /** The error's metadata. */ + public readonly metadata: IErrorMetadata + + /** The underlying error to which the metadata is being attached. */ + public readonly underlyingError: Error + + public constructor(error: Error, metadata: IErrorMetadata) { + super(error.message) + + this.name = error.name + this.stack = error.stack + this.underlyingError = error + this.metadata = metadata + } +} + +/** + * An error thrown when a failure occurs while checking out a branch. + * Technically just a convience class on top of ErrorWithMetadata + */ +export class CheckoutError extends ErrorWithMetadata { + public constructor(error: Error, repository: Repository, branch: Branch) { + super(error, { + gitContext: { kind: 'checkout', branchToCheckout: branch }, + retryAction: { type: RetryActionType.Checkout, branch, repository }, + repository, + }) + } +} + +/** + * An error thrown when a failure occurs while discarding changes to trash. + * Technically just a convenience class on top of ErrorWithMetadata + */ +export class DiscardChangesError extends ErrorWithMetadata { + public constructor( + error: Error, + repository: Repository, + files: ReadonlyArray + ) { + super(error, { + retryAction: { type: RetryActionType.DiscardChanges, files, repository }, + }) + } +} diff --git a/app/src/lib/exec-file.ts b/app/src/lib/exec-file.ts new file mode 100644 index 0000000000..1455ac1839 --- /dev/null +++ b/app/src/lib/exec-file.ts @@ -0,0 +1,10 @@ +import { execFile as execFileOrig } from 'child_process' +import { promisify } from 'util' + +/** + * A version of execFile which returns a Promise rather than the traditional + * callback approach of `child_process.execFile`. + * + * See `child_process.execFile` for more information + */ +export const execFile = promisify(execFileOrig) diff --git a/app/src/lib/fatal-error.ts b/app/src/lib/fatal-error.ts new file mode 100644 index 0000000000..72f4975520 --- /dev/null +++ b/app/src/lib/fatal-error.ts @@ -0,0 +1,49 @@ +/** Throw an error. */ +export function fatalError(msg: string): never { + throw new Error(msg) +} + +/** + * Utility function used to achieve exhaustive type checks at compile time. + * + * If the type system is bypassed or this method will throw an exception + * using the second parameter as the message. + * + * @param x Placeholder parameter in order to leverage the type + * system. Pass the variable which has been type narrowed + * 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) +} + +/** + * Unwrap a value that, according to the type system, could be null or + * undefined, but which we know is not. If the value _is_ null or undefined, + * this will throw. The message should contain the rationale for knowing the + * value is defined. + */ +export function forceUnwrap(message: string, x: T | null | undefined): T { + if (x == null) { + return fatalError(message) + } else { + return x + } +} + +/** + * Unwrap a value that, according to the type system, could be null or + * undefined, but which we know is not. If the value _is_ null or undefined, + * this will throw. The message should contain the rationale for knowing the + * value is defined. + */ +export function assertNonNullable( + x: T, + message: string +): asserts x is NonNullable { + if (x == null) { + return fatalError(message) + } +} diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts new file mode 100644 index 0000000000..7255394f9e --- /dev/null +++ b/app/src/lib/feature-flag.ts @@ -0,0 +1,106 @@ +const Disable = false + +/** + * Enables the application to opt-in for preview features based on runtime + * checks. This is backed by the GITHUB_DESKTOP_PREVIEW_FEATURES environment + * variable, which is checked for non-development environments. + */ +function enableDevelopmentFeatures(): boolean { + if (Disable) { + return false + } + + if (__DEV__) { + return true + } + + if (process.env.GITHUB_DESKTOP_PREVIEW_FEATURES === '1') { + return true + } + + return false +} + +/** Should the app enable beta features? */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-ignore: this will be used again in the future +function enableBetaFeatures(): boolean { + return enableDevelopmentFeatures() || __RELEASE_CHANNEL__ === 'beta' +} + +/** Should git pass `--recurse-submodules` when performing operations? */ +export function enableRecurseSubmodulesFlag(): boolean { + return true +} + +export function enableReadmeOverwriteWarning(): boolean { + return enableBetaFeatures() +} + +/** Should the app detect Windows Subsystem for Linux as a valid shell? */ +export function enableWSLDetection(): boolean { + return enableBetaFeatures() +} + +/** + * Should we use the new diff viewer for unified diffs? + */ +export function enableExperimentalDiffViewer(): boolean { + return enableBetaFeatures() +} + +/** + * Should we allow reporting unhandled rejections as if they were crashes? + */ +export function enableUnhandledRejectionReporting(): boolean { + return enableBetaFeatures() +} + +/** + * Should we allow x64 apps running under ARM translation to auto-update to + * ARM64 builds? + */ +export function enableUpdateFromEmulatedX64ToARM64(): boolean { + if (__DARWIN__) { + return true + } + + return enableBetaFeatures() +} + +/** Should we allow resetting to a previous commit? */ +export function enableResetToCommit(): boolean { + return enableDevelopmentFeatures() +} + +/** 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() +} + +/** Should we show a pull-requests quick view? */ +export function enablePullRequestQuickView(): boolean { + return enableDevelopmentFeatures() +} + +export function enableMoveStash(): boolean { + return true +} + +export const enableCustomGitUserAgent = enableBetaFeatures + +export function enableSectionList(): boolean { + return enableBetaFeatures() +} + +export const enableRepoRulesBeta = () => true diff --git a/app/src/lib/file-system.ts b/app/src/lib/file-system.ts new file mode 100644 index 0000000000..360f20397c --- /dev/null +++ b/app/src/lib/file-system.ts @@ -0,0 +1,81 @@ +import * as Os from 'os' +import * as Path from 'path' +import { Disposable } from 'event-kit' +import { Tailer } from './tailer' +import byline from 'byline' +import { createReadStream } from 'fs' +import { mkdtemp } from 'fs/promises' + +/** + * Get a path to a temp file using the given name. Note that the file itself + * will not be created. + */ +export async function getTempFilePath(name: string): Promise { + const tempDir = Path.join(Os.tmpdir(), `${name}-`) + const directory = await mkdtemp(tempDir) + return Path.join(directory, name) +} + +/** + * Tail the file and call the callback on every line. + * + * Note that this will not stop tailing until the returned `Disposable` is + * disposed of. + */ +export function tailByLine( + path: string, + cb: (line: string) => void +): Disposable { + const tailer = new Tailer(path) + + const onErrorDisposable = tailer.onError(error => { + log.warn(`Unable to tail path: ${path}`, error) + }) + + const onDataDisposable = tailer.onDataAvailable(stream => { + byline(stream).on('data', (buffer: Buffer) => { + if (onDataDisposable.disposed) { + return + } + + const line = buffer.toString() + cb(line) + }) + }) + + tailer.start() + + return new Disposable(() => { + onDataDisposable.dispose() + onErrorDisposable.dispose() + tailer.stop() + }) +} + +/** + * Read a specific region from a file. + * + * @param path Path to the file + * @param start First index relative to the start of the file to + * read from + * @param end Last index (inclusive) relative to the start of the + * file to read to + */ +export async function readPartialFile( + path: string, + start: number, + end: number +): Promise { + return await new Promise((resolve, reject) => { + const chunks = new Array() + let total = 0 + + createReadStream(path, { start, end }) + .on('data', (chunk: Buffer) => { + chunks.push(chunk) + total += chunk.length + }) + .on('error', reject) + .on('end', () => resolve(Buffer.concat(chunks, total))) + }) +} diff --git a/app/src/lib/find-account.ts b/app/src/lib/find-account.ts new file mode 100644 index 0000000000..84c12257fd --- /dev/null +++ b/app/src/lib/find-account.ts @@ -0,0 +1,110 @@ +import * as URL from 'url' +import { getHTMLURL, API, getDotComAPIEndpoint } from './api' +import { parseRemote, parseRepositoryIdentifier } from './remote-parsing' +import { Account } from '../models/account' + +type RepositoryLookupFunc = ( + account: Account, + owner: string, + name: string +) => Promise + +/** + * Check if the repository designated by the owner and name exists and can be + * accessed by the given account. + */ +async function canAccessRepositoryUsingAPI( + account: Account, + owner: string, + name: string +): Promise { + const api = API.fromAccount(account) + const repository = await api.fetchRepository(owner, name) + if (repository) { + return true + } else { + return false + } +} + +/** + * Find the GitHub account associated with a given remote URL. + * + * @param urlOrRepositoryAlias - the URL or repository alias whose account + * should be found + * @param accounts - the list of active GitHub and GitHub Enterprise + * Server accounts + */ +export async function findAccountForRemoteURL( + urlOrRepositoryAlias: string, + accounts: ReadonlyArray, + canAccessRepository: RepositoryLookupFunc = canAccessRepositoryUsingAPI +): Promise { + const allAccounts = [...accounts, Account.anonymous()] + + // We have a couple of strategies to try to figure out what account we + // should use to authenticate the URL: + // + // 1. Try to parse a remote out of the URL. + // 1. If that works, try to find an account for that host. + // 2. If we don't find an account move on to our next strategy. + // 2. Try to parse an owner/name. + // 1. If that works, find the first account that can access it. + // 3. And if all that fails then throw our hands in the air because we + // truly don't care. + const parsedURL = parseRemote(urlOrRepositoryAlias) + if (parsedURL) { + const account = + allAccounts.find(a => { + const htmlURL = getHTMLURL(a.endpoint) + const parsedEndpoint = URL.parse(htmlURL) + return parsedURL.hostname === parsedEndpoint.hostname + }) || null + + // If we find an account whose hostname matches the URL to be cloned, it's + // always gonna be our best bet for success. We're not gonna do better. + if (account) { + return account + } + } + + const repositoryIdentifier = parseRepositoryIdentifier(urlOrRepositoryAlias) + if (repositoryIdentifier) { + const { owner, name, hostname } = repositoryIdentifier + + // This chunk of code is designed to sort the user's accounts in this order: + // - authenticated GitHub account + // - GitHub Enterprise accounts + // - unauthenticated GitHub account (access public repositories) + // + // 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) => { + if (a1.endpoint === getDotComAPIEndpoint()) { + return a1.token.length ? -1 : 1 + } else if (a2.endpoint === getDotComAPIEndpoint()) { + return a2.token.length ? 1 : -1 + } else { + return 0 + } + }) + + for (const account of sortedAccounts) { + if (hostname != null) { + const htmlURL = URL.parse(getHTMLURL(account.endpoint)) + const accountHost = htmlURL.hostname + if (accountHost !== hostname) { + continue + } + } + + const canAccess = await canAccessRepository(account, owner, name) + if (canAccess) { + return account + } + } + } + + return null +} diff --git a/app/src/lib/find-default-branch.ts b/app/src/lib/find-default-branch.ts new file mode 100644 index 0000000000..0c08347ef0 --- /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/find-toast-activator-clsid.ts b/app/src/lib/find-toast-activator-clsid.ts new file mode 100644 index 0000000000..1369321d75 --- /dev/null +++ b/app/src/lib/find-toast-activator-clsid.ts @@ -0,0 +1,55 @@ +import * as path from 'path' +import * as os from 'os' +import { shell } from 'electron' + +/** + * Checks all Windows shortcuts created by Squirrel looking for the toast + * activator CLSID needed to handle Windows notifications from the Action Center. + */ +export function findToastActivatorClsid() { + const shortcutPaths = [ + path.join( + os.homedir(), + 'AppData', + 'Roaming', + 'Microsoft', + 'Windows', + 'Start Menu', + 'Programs', + 'GitHub, Inc', + 'GitHub Desktop.lnk' + ), + path.join(os.homedir(), 'Desktop', 'GitHub Desktop.lnk'), + ] + + for (const shortcutPath of shortcutPaths) { + const toastActivatorClsid = findToastActivatorClsidInShorcut(shortcutPath) + + if (toastActivatorClsid !== undefined) { + return toastActivatorClsid + } + } + + return undefined +} + +function findToastActivatorClsidInShorcut(shortcutPath: string) { + try { + const shortcutDetails = shell.readShortcutLink(shortcutPath) + + if ( + shortcutDetails.toastActivatorClsid === undefined || + shortcutDetails.toastActivatorClsid === '' + ) { + return undefined + } + + return shortcutDetails.toastActivatorClsid + } catch (error) { + log.error( + `Error looking for toast activator CLSID in shortcut ${shortcutPath}`, + error + ) + return undefined + } +} diff --git a/app/src/lib/fix-emoji-spacing.ts b/app/src/lib/fix-emoji-spacing.ts new file mode 100644 index 0000000000..8f9ef6a5ee --- /dev/null +++ b/app/src/lib/fix-emoji-spacing.ts @@ -0,0 +1,37 @@ +// This module renders an element with an emoji using +// a non system-default font to workaround an Chrome +// issue that causes unexpected spacing on emojis. +// More info: +// https://bugs.chromium.org/p/chromium/issues/detail?id=1113293 + +const container = document.createElement('div') +container.style.setProperty('visibility', 'hidden') +container.style.setProperty('position', 'absolute') + +// Keep this array synced with the font size variables +// in _variables.scss +const fontSizes = [ + '--font-size', + '--font-size-sm', + '--font-size-md', + '--font-size-lg', + '--font-size-xl', + '--font-size-xxl', + '--font-size-xs', +] + +for (const fontSize of fontSizes) { + const span = document.createElement('span') + span.style.setProperty('font-size', `var(${fontSize}`) + span.style.setProperty('font-family', 'Arial', 'important') + span.textContent = '🤦🏿‍♀️' + container.appendChild(span) +} + +document.body.appendChild(container) + +// Read the dimensions of the element to force the browser to do a layout. +container.offsetHeight.toString() + +// Browser has rendered the emojis, now we can remove them. +document.body.removeChild(container) diff --git a/app/src/lib/format-commit-message.ts b/app/src/lib/format-commit-message.ts new file mode 100644 index 0000000000..ca02b0837f --- /dev/null +++ b/app/src/lib/format-commit-message.ts @@ -0,0 +1,36 @@ +import { mergeTrailers } from './git/interpret-trailers' +import { Repository } from '../models/repository' +import { ICommitContext } from '../models/commit' + +/** + * Formats a summary and a description into a git-friendly + * commit message where the summary and (optional) description + * is separated by a blank line. + * + * Also accepts an optional array of commit message trailers, + * see git-interpret-trailers which, if present, will be merged + * into the commit message. + * + * Always returns commit message with a trailing newline + * + * See https://git-scm.com/docs/git-commit#_discussion + */ +export async function formatCommitMessage( + repository: Repository, + context: ICommitContext +) { + const { summary, description, trailers } = context + + // Git always trim whitespace at the end of commit messages + // so we concatenate the summary with the description, ensuring + // that they're separated by two newlines. If we don't have a + // description or if it consists solely of whitespace that'll + // all get trimmed away and replaced with a single newline (since + // all commit messages needs to end with a newline for git + // interpret-trailers to work) + const message = `${summary}\n\n${description || ''}\n`.replace(/\s+$/, '\n') + + return trailers !== undefined && trailers.length > 0 + ? mergeTrailers(repository, message, trailers) + : message +} diff --git a/app/src/lib/format-date.ts b/app/src/lib/format-date.ts new file mode 100644 index 0000000000..60f561f747 --- /dev/null +++ b/app/src/lib/format-date.ts @@ -0,0 +1,21 @@ +import mem from 'mem' +import QuickLRU from 'quick-lru' + +// Initializing a date formatter is expensive but formatting is relatively cheap +// so we cache them based on the locale and their options. The maxSize of a 100 +// is only as an escape hatch, we don't expect to ever create more than a +// handful different formatters. +const getDateFormatter = mem(Intl.DateTimeFormat, { + cache: new QuickLRU({ maxSize: 100 }), + cacheKey: (...args) => JSON.stringify(args), +}) + +/** + * Format a date in en-US locale, customizable with Intl.DateTimeFormatOptions. + * + * See Intl.DateTimeFormat for more information + */ +export const formatDate = (date: Date, options: Intl.DateTimeFormatOptions) => + isNaN(date.valueOf()) + ? 'Invalid date' + : getDateFormatter('en-US', options).format(date) diff --git a/app/src/lib/format-duration.ts b/app/src/lib/format-duration.ts new file mode 100644 index 0000000000..e36efd8c8a --- /dev/null +++ b/app/src/lib/format-duration.ts @@ -0,0 +1,29 @@ +export const units: [string, number][] = [ + ['d', 86400000], + ['h', 3600000], + ['m', 60000], + ['s', 1000], +] + +/** + * Creates a narrow style precise duration format used for displaying things + * like check run durations that typically only last for a few minutes. + * + * Example: formatPreciseDuration(3670000) -> "1h 1m 10s" + * + * @param ms The duration in milliseconds + */ +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}`) + } + } + + return parts.join(' ') +} diff --git a/app/src/lib/format-relative.ts b/app/src/lib/format-relative.ts new file mode 100644 index 0000000000..affed1303e --- /dev/null +++ b/app/src/lib/format-relative.ts @@ -0,0 +1,44 @@ +import mem from 'mem' +import QuickLRU from 'quick-lru' + +// Initializing a date formatter is expensive but formatting is relatively cheap +// so we cache them based on the locale and their options. The maxSize of a 100 +// is only as an escape hatch, we don't expect to ever create more than a +// handful different formatters. +const getRelativeFormatter = mem( + (locale: string, options: Intl.RelativeTimeFormatOptions) => + new Intl.RelativeTimeFormat(locale, options), + { + cache: new QuickLRU({ maxSize: 100 }), + cacheKey: (...args) => JSON.stringify(args), + } +) + +export function formatRelative(ms: number) { + const formatter = getRelativeFormatter('en-US', { numeric: 'auto' }) + + const sign = ms < 0 ? -1 : 1 + + // Lifted and adopted from + // https://github.com/github/time-elements/blob/428b02c9/src/relative-time.ts#L57 + const sec = Math.round(Math.abs(ms) / 1000) + const min = Math.round(sec / 60) + const hr = Math.round(min / 60) + const day = Math.round(hr / 24) + const month = Math.round(day / 30) + const year = Math.round(month / 12) + + if (sec < 45) { + return formatter.format(sec * sign, 'second') + } else if (min < 45) { + return formatter.format(min * sign, 'minute') + } else if (hr < 24) { + return formatter.format(hr * sign, 'hour') + } else if (day < 30) { + return formatter.format(day * sign, 'day') + } else if (month < 18) { + return formatter.format(month * sign, 'month') + } else { + return formatter.format(year * sign, 'year') + } +} diff --git a/app/src/lib/friendly-endpoint-name.ts b/app/src/lib/friendly-endpoint-name.ts new file mode 100644 index 0000000000..b809d899a6 --- /dev/null +++ b/app/src/lib/friendly-endpoint-name.ts @@ -0,0 +1,16 @@ +import * as URL from 'url' +import { Account } from '../models/account' +import { getDotComAPIEndpoint } from './api' + +/** + * Generate a human-friendly description of the Account endpoint. + * + * Accounts on GitHub.com will return the string 'GitHub.com' + * whereas GitHub Enterprise accounts will return the + * hostname without the protocol and/or path. + */ +export function friendlyEndpointName(account: Account) { + return account.endpoint === getDotComAPIEndpoint() + ? 'GitHub.com' + : URL.parse(account.endpoint).hostname || account.endpoint +} diff --git a/app/src/lib/fuzzy-find.ts b/app/src/lib/fuzzy-find.ts new file mode 100644 index 0000000000..e08da6c482 --- /dev/null +++ b/app/src/lib/fuzzy-find.ts @@ -0,0 +1,53 @@ +import * as fuzzAldrin from 'fuzzaldrin-plus' + +import { compareDescending } from './compare' + +function score(str: string, query: string, maxScore: number) { + return fuzzAldrin.score(str, query) / maxScore +} + +export interface IMatches { + readonly title: ReadonlyArray + readonly subtitle: ReadonlyArray +} + +export interface IMatch { + /** `0 <= score <= 1` */ + score: number + item: T + matches: IMatches +} + +export type KeyFunction = (item: T) => ReadonlyArray + +export function match( + query: string, + items: ReadonlyArray, + getKey: KeyFunction +): ReadonlyArray> { + // matching `query` against itself is a perfect match. + const maxScore = score(query, query, 1) + const result = items + .map((item): IMatch => { + const matches: Array> = [] + const itemTextArray = getKey(item) + itemTextArray.forEach(text => { + matches.push(fuzzAldrin.match(text, query)) + }) + + return { + score: score(itemTextArray.join(''), query, maxScore), + item, + matches: { + title: matches[0], + subtitle: matches.length > 1 ? matches[1] : [], + }, + } + }) + .filter( + ({ matches }) => matches.title.length > 0 || matches.subtitle.length > 0 + ) + .sort(({ score: left }, { score: right }) => compareDescending(left, right)) + + return result +} diff --git a/app/src/lib/generic-git-auth.ts b/app/src/lib/generic-git-auth.ts new file mode 100644 index 0000000000..c234d70fb3 --- /dev/null +++ b/app/src/lib/generic-git-auth.ts @@ -0,0 +1,45 @@ +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 + } + + const urlHostname = URL.parse(remoteURL).hostname + if (urlHostname) { + return urlHostname + } + + return remoteURL +} + +function getKeyForUsername(hostname: string): string { + return `genericGitAuth/username/${hostname}` +} + +/** Get the username for the host. */ +export function getGenericUsername(hostname: string): string | null { + const key = getKeyForUsername(hostname) + return localStorage.getItem(key) +} + +/** Set the username for the host. */ +export function setGenericUsername(hostname: string, username: string) { + const key = getKeyForUsername(hostname) + return localStorage.setItem(key, username) +} + +/** Set the password for the username and host. */ +export function setGenericPassword( + hostname: string, + username: string, + password: string +): Promise { + const key = getKeyForEndpoint(hostname) + return TokenStore.setItem(key, username, password) +} diff --git a/app/src/lib/get-account-for-repository.ts b/app/src/lib/get-account-for-repository.ts new file mode 100644 index 0000000000..f761eefa9a --- /dev/null +++ b/app/src/lib/get-account-for-repository.ts @@ -0,0 +1,16 @@ +import { Repository } from '../models/repository' +import { Account } from '../models/account' +import { getAccountForEndpoint } from './api' + +/** Get the authenticated account for the repository. */ +export function getAccountForRepository( + accounts: ReadonlyArray, + repository: Repository +): Account | null { + const gitHubRepository = repository.gitHubRepository + if (!gitHubRepository) { + return null + } + + return getAccountForEndpoint(accounts, gitHubRepository.endpoint) +} diff --git a/app/src/lib/get-architecture.ts b/app/src/lib/get-architecture.ts new file mode 100644 index 0000000000..0d7f5e28c7 --- /dev/null +++ b/app/src/lib/get-architecture.ts @@ -0,0 +1,31 @@ +import { App } from 'electron' + +export type Architecture = 'x64' | 'arm64' | 'x64-emulated' + +/** + * Returns the architecture of the build currently running, which could be + * either x64 or arm64. Additionally, it could also be x64-emulated in those + * arm64 devices with the ability to emulate x64 binaries (like macOS using + * Rosetta). + */ +export function getArchitecture(app: App): Architecture { + if (isAppRunningUnderARM64Translation(app)) { + return 'x64-emulated' + } + + return process.arch === 'arm64' ? 'arm64' : 'x64' +} + +/** + * Returns true if the app is an x64 process running under arm64 translation. + */ +export function isAppRunningUnderARM64Translation(app: App): boolean { + // HACK: We cannot just use runningUnderARM64Translation because on Windows, + // it relies on IsWow64Process2 which, as of today, on Windows 11 (22000.469), + // always returns IMAGE_FILE_MACHINE_UNKNOWN as the process machine, which + // indicates that the process is NOT being emulated. This means, Electron's + // runningUnderARM64Translation will always return true for arm binaries on + // Windows, so we will use process.arch to check if node (and therefore the + // whole process) was compiled for x64. + return process.arch === 'x64' && app.runningUnderARM64Translation === true +} diff --git a/app/src/lib/get-file-hash.ts b/app/src/lib/get-file-hash.ts new file mode 100644 index 0000000000..f7da29909a --- /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-main-guid.ts b/app/src/lib/get-main-guid.ts new file mode 100644 index 0000000000..413664241c --- /dev/null +++ b/app/src/lib/get-main-guid.ts @@ -0,0 +1,52 @@ +import { app } from 'electron' +import { readFile, writeFile } from 'fs/promises' +import { join } from 'path' +import { uuid } from './uuid' + +let cachedGUID: string | null = null + +/** Get the GUID for the Main process. */ +export async function getMainGUID(): Promise { + if (!cachedGUID) { + let guid = await readGUIDFile() + + if (guid === undefined) { + guid = uuid() + await saveGUIDFile(guid).catch(e => { + log.error(e) + }) + } + + cachedGUID = guid + } + + return cachedGUID +} + +/** Reads the persisted GUID from the .guid file or generates a new one */ +async function readGUIDFile(): Promise { + let guid = undefined + + try { + guid = (await readFile(getGUIDPath(), 'utf8')).trim() + + // Validate (at least) the GUID by its length + if (guid.length !== 36) { + guid = undefined + } + } catch (e) {} + + return guid +} + +/** Saves the GUID to the .guid file */ +export async function saveGUIDFile(guid: string) { + // Cache the GUID even if it's not saved. This is handy for GUIDs that are + // migrated from the renderer process, in case the persistence fails. + // Otherwise, getMainGUID will cache the GUID anyway. This line can be removed + // once we remove the migration code. + cachedGUID = guid + await writeFile(getGUIDPath(), guid, 'utf8') +} + +const getGUIDPath = () => join(app.getPath('userData'), '.guid') diff --git a/app/src/lib/get-old-path.ts b/app/src/lib/get-old-path.ts new file mode 100644 index 0000000000..8d143fb0d4 --- /dev/null +++ b/app/src/lib/get-old-path.ts @@ -0,0 +1,16 @@ +import { FileChange, AppFileStatusKind } from '../models/status' + +/** + * Resolve the old path (for a rename or a copied change) or default to the + * current path of a file + */ +export function getOldPathOrDefault(file: FileChange) { + if ( + file.status.kind === AppFileStatusKind.Renamed || + file.status.kind === AppFileStatusKind.Copied + ) { + return file.status.oldPath + } else { + return file.path + } +} diff --git a/app/src/lib/get-os.ts b/app/src/lib/get-os.ts new file mode 100644 index 0000000000..d42cbfae7b --- /dev/null +++ b/app/src/lib/get-os.ts @@ -0,0 +1,73 @@ +import * as OS from 'os' +import { compare } from 'compare-versions' +import memoizeOne from 'memoize-one' + +function getSystemVersionSafe() { + if (__DARWIN__) { + // getSystemVersion only exists when running under Electron, and not when + // running unit tests which frequently end up calling this. There are no + // other known reasons why getSystemVersion() would return anything other + // than a string + return 'getSystemVersion' in process + ? process.getSystemVersion() + : undefined + } else { + return OS.release() + } +} + +function systemVersionGreaterThanOrEqualTo(version: string) { + const sysver = getSystemVersionSafe() + return sysver === undefined ? false : compare(sysver, version, '>=') +} + +function systemVersionLessThan(version: string) { + const sysver = getSystemVersionSafe() + return sysver === undefined ? false : compare(sysver, version, '<') +} + +/** Get the OS we're currently running on. */ +export function getOS() { + const version = getSystemVersionSafe() + if (__DARWIN__) { + return `Mac OS ${version}` + } else if (__WIN32__) { + return `Windows ${version}` + } else { + return `${OS.type()} ${version}` + } +} + +/** 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 Catalina or earlier. */ +export const isMacOSCatalinaOrEarlier = memoizeOne( + () => __DARWIN__ && systemVersionLessThan('10.16') +) + +/** We're currently running macOS and it is at least Mojave. */ +export const isMacOSMojaveOrLater = memoizeOne( + () => __DARWIN__ && systemVersionGreaterThanOrEqualTo('10.13.0') +) + +/** We're currently running macOS and it is at least Big Sur. */ +export const isMacOSBigSurOrLater = memoizeOne( + // We're using 10.16 rather than 11.0 here due to + // https://github.com/electron/electron/issues/26419 + () => __DARWIN__ && systemVersionGreaterThanOrEqualTo('10.16') +) + +/** We're currently running Windows 10 and it is at least 1809 Preview Build 17666. */ +export const isWindows10And1809Preview17666OrLater = memoizeOne( + () => __WIN32__ && systemVersionGreaterThanOrEqualTo('10.0.17666') +) + +export const isWindowsAndNoLongerSupportedByElectron = memoizeOne( + () => __WIN32__ && systemVersionLessThan('10') +) diff --git a/app/src/lib/get-renderer-guid.ts b/app/src/lib/get-renderer-guid.ts new file mode 100644 index 0000000000..a9dc1cd2a8 --- /dev/null +++ b/app/src/lib/get-renderer-guid.ts @@ -0,0 +1,35 @@ +import { getGUID, saveGUID } from '../ui/main-process-proxy' + +/** The localStorage key for the stats GUID. */ +const StatsGUIDKey = 'stats-guid' + +let cachedGUID: string | null = null + +/** + * Get the GUID for the Renderer process. + */ +export async function getRendererGUID(): Promise { + cachedGUID = cachedGUID ?? (await getGUID()) + return cachedGUID +} + +/** + * Grabs the existing GUID from the LocalStorage (if any), caches it, and sends + * it to the main process to be persisted. + */ +export async function migrateRendererGUID(): Promise { + const guid = localStorage.getItem(StatsGUIDKey) + + if (guid === null) { + return + } + + try { + await saveGUID(guid) + localStorage.removeItem(StatsGUIDKey) + } catch (e) { + log.error('Error migrating existing GUID', e) + } + + cachedGUID = guid +} diff --git a/app/src/lib/git-error-context.ts b/app/src/lib/git-error-context.ts new file mode 100644 index 0000000000..a5399bade8 --- /dev/null +++ b/app/src/lib/git-error-context.ts @@ -0,0 +1,24 @@ +import { Branch } from '../models/branch' + +type MergeOrPullConflictsErrorContext = { + /** The Git operation that triggered the conflicted state */ + readonly kind: 'merge' | 'pull' + /** The branch being merged into the current branch, "theirs" in Git terminology */ + readonly theirBranch: string + + /** The branch associated with the current tip of the repository, "ours" in Git terminology */ + readonly currentBranch: string +} + +type CheckoutBranchErrorContext = { + /** The Git operation that triggered the error */ + readonly kind: 'checkout' + + /** The branch associated with the current tip of the repository, "ours" in Git terminology */ + readonly branchToCheckout: Branch +} + +/** A custom shape of data for actions to provide to help with error handling */ +export type GitErrorContext = + | MergeOrPullConflictsErrorContext + | CheckoutBranchErrorContext diff --git a/app/src/lib/git/add.ts b/app/src/lib/git/add.ts new file mode 100644 index 0000000000..767556470c --- /dev/null +++ b/app/src/lib/git/add.ts @@ -0,0 +1,16 @@ +import { git } from './core' +import { Repository } from '../../models/repository' +import { WorkingDirectoryFileChange } from '../../models/status' + +/** + * Add a conflicted file to the index. + * + * Typically done after having resolved conflicts either manually + * or through checkout --theirs/--ours. + */ +export async function addConflictedFile( + repository: Repository, + file: WorkingDirectoryFileChange +) { + await git(['add', '--', file.path], repository.path, 'addConflictedFile') +} diff --git a/app/src/lib/git/apply.ts b/app/src/lib/git/apply.ts new file mode 100644 index 0000000000..bf3680cce1 --- /dev/null +++ b/app/src/lib/git/apply.ts @@ -0,0 +1,153 @@ +import { GitError as DugiteError } from 'dugite' +import { git } from './core' +import { + WorkingDirectoryFileChange, + AppFileStatusKind, +} from '../../models/status' +import { DiffType, ITextDiff, DiffSelection } from '../../models/diff' +import { Repository, WorkingTree } from '../../models/repository' +import { getWorkingDirectoryDiff } from './diff' +import { formatPatch, formatPatchToDiscardChanges } from '../patch-formatter' +import { assertNever } from '../fatal-error' + +export async function applyPatchToIndex( + repository: Repository, + file: WorkingDirectoryFileChange +): Promise { + // If the file was a rename we have to recreate that rename since we've + // just blown away the index. Think of this block of weird looking commands + // as running `git mv`. + if (file.status.kind === AppFileStatusKind.Renamed) { + // Make sure the index knows of the removed file. We could use + // update-index --force-remove here but we're not since it's + // possible that someone staged a rename and then recreated the + // original file and we don't have any guarantees for in which order + // partial stages vs full-file stages happen. By using git add the + // worst that could happen is that we re-stage a file already staged + // by updateIndex. + await git( + ['add', '--u', '--', file.status.oldPath], + repository.path, + 'applyPatchToIndex' + ) + + // Figure out the blob oid of the removed file + // SP SP TAB + const oldFile = await git( + ['ls-tree', 'HEAD', '--', file.status.oldPath], + repository.path, + 'applyPatchToIndex' + ) + + const [info] = oldFile.stdout.split('\t', 1) + const [mode, , oid] = info.split(' ', 3) + + // Add the old file blob to the index under the new name + await git( + ['update-index', '--add', '--cacheinfo', mode, oid, file.path], + repository.path, + 'applyPatchToIndex' + ) + } + + const applyArgs: string[] = [ + 'apply', + '--cached', + '--unidiff-zero', + '--whitespace=nowarn', + '-', + ] + + const diff = await getWorkingDirectoryDiff(repository, file) + + if (diff.kind !== DiffType.Text && diff.kind !== DiffType.LargeText) { + 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}` + ) + case DiffType.Unrenderable: + throw new Error( + `File diff is too large to generate a partial commit: ${file.path}` + ) + default: + assertNever(diff, `Unknown diff kind: ${kind}`) + } + } + + const patch = await formatPatch(file, diff) + await git(applyArgs, repository.path, 'applyPatchToIndex', { stdin: patch }) + + return Promise.resolve() +} + +/** + * Test a patch to see if it will apply cleanly. + * + * @param workTree work tree (which should be checked out to a specific commit) + * @param patch a Git patch (or patch series) to try applying + * @returns whether the patch applies cleanly + * + * See `formatPatch` to generate a patch series from existing Git commits + */ +export async function checkPatch( + workTree: WorkingTree, + patch: string +): Promise { + const result = await git( + ['apply', '--check', '-'], + workTree.path, + 'checkPatch', + { + stdin: patch, + stdinEncoding: 'utf8', + expectedErrors: new Set([DugiteError.PatchDoesNotApply]), + } + ) + + if (result.gitError === DugiteError.PatchDoesNotApply) { + // other errors will be thrown if encountered, so this is fine for now + return false + } + + return true +} + +/** + * Discards the local changes for the specified file based on the passed diff + * and a selection of lines from it. + * + * When passed an empty selection, this method won't do anything. When passed a + * full selection, all changes from the file will be discarded. + * + * @param repository The repository in which to update the working directory + * with information from the index + * + * @param filePath The relative path in the working directory of the file to use + * + * @param diff The diff containing the file local changes + * + * @param selection The selection of changes from the diff to discard + */ +export async function discardChangesFromSelection( + repository: Repository, + filePath: string, + diff: ITextDiff, + selection: DiffSelection +) { + const patch = formatPatchToDiscardChanges(filePath, diff, selection) + + if (patch === null) { + // When the patch is null we don't need to apply it since it will be a noop. + return + } + + const args = ['apply', '--unidiff-zero', '--whitespace=nowarn', '-'] + + await git(args, repository.path, 'discardChangesFromSelection', { + stdin: patch, + }) +} diff --git a/app/src/lib/git/authentication.ts b/app/src/lib/git/authentication.ts new file mode 100644 index 0000000000..59d2b0f71b --- /dev/null +++ b/app/src/lib/git/authentication.ts @@ -0,0 +1,30 @@ +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 = { + // 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. */ +export const AuthenticationErrors: ReadonlySet = new Set([ + DugiteError.HTTPSAuthenticationFailed, + DugiteError.SSHAuthenticationFailed, + DugiteError.HTTPSRepositoryNotFound, + DugiteError.SSHRepositoryNotFound, +]) diff --git a/app/src/lib/git/branch.ts b/app/src/lib/git/branch.ts new file mode 100644 index 0000000000..936c792191 --- /dev/null +++ b/app/src/lib/git/branch.ts @@ -0,0 +1,179 @@ +import { git, gitNetworkArguments } 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 { createForEachRefParser } from './git-delimiter-parser' + +/** + * Create a new branch from the given start point. + * + * @param repository - The repository in which to create the new branch + * @param name - The name of the new branch + * @param startPoint - A committish string that the new branch should be based + * on, or undefined if the branch should be created based + * off of the current state of HEAD + */ +export async function createBranch( + repository: Repository, + name: string, + startPoint: string | null, + noTrack?: boolean +): Promise { + const args = + startPoint !== null ? ['branch', name, startPoint] : ['branch', name] + + // if we're branching directly from a remote branch, we don't want to track it + // tracking it will make the rest of desktop think we want to push to that + // remote branch's upstream (which would likely be the upstream of the fork) + if (noTrack) { + args.push('--no-track') + } + + await git(args, repository.path, 'createBranch') +} + +/** Rename the given branch to a new name. */ +export async function renameBranch( + repository: Repository, + branch: Branch, + newName: string +): Promise { + await git( + ['branch', '-m', branch.nameWithoutRemote, newName], + repository.path, + 'renameBranch' + ) +} + +/** + * Delete the branch locally. + */ +export async function deleteLocalBranch( + repository: Repository, + branchName: string +): Promise { + await git(['branch', '-D', branchName], repository.path, 'deleteLocalBranch') + return true +} + +/** + * Deletes a remote branch + * + * @param remoteName - the name of the remote to delete the branch from + * @param remoteBranchName - the name of the branch on the remote + */ +export async function deleteRemoteBranch( + repository: Repository, + account: IGitAccount | null, + remoteName: string, + 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}`, + ] + + // 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), + expectedErrors: new Set([DugiteError.BranchDeletionFailed]), + }) + + // It's possible that the delete failed because the ref has already + // been deleted on the remote. If we identify that specific + // 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}` + await deleteRef(repository, ref) + } + + return true +} + +/** + * Finds branches that have a tip equal to the given committish + * + * @param repository within which to execute the command + * @param commitish a sha, HEAD, etc that the branch(es) tip should be + * @returns list branch names. null if an error is encountered + */ +export async function getBranchesPointedAt( + repository: Repository, + commitish: string +): Promise | null> { + const args = [ + 'branch', + `--points-at=${commitish}`, + '--format=%(refname:short)', + ] + // this command has an implicit \n delimiter + const { stdout, exitCode } = await git( + args, + repository.path, + 'branchPointedAt', + { + // - 1 is returned if a common ancestor cannot be resolved + // - 129 is returned if ref is malformed + // "warning: ignoring broken ref refs/remotes/origin/main." + successExitCodes: new Set([0, 1, 129]), + } + ) + if (exitCode === 1 || exitCode === 129) { + return null + } + // split (and remove trailing element cause its always an empty string) + return stdout.split('\n').slice(0, -1) +} + +/** + * Gets all branches that have been merged into the given branch + * + * @param repository The repository in which to search + * @param branchName The to be used as the base branch + * @returns map of branch canonical refs paired to its sha + */ +export async function getMergedBranches( + repository: Repository, + branchName: string +): Promise> { + const canonicalBranchRef = formatAsLocalRef(branchName) + const { formatArgs, parse } = createForEachRefParser({ + sha: '%(objectname)', + canonicalRef: '%(refname)', + }) + + const args = ['branch', ...formatArgs, '--merged', branchName] + const mergedBranches = new Map() + const { stdout } = await git(args, repository.path, 'mergedBranches') + + for (const branch of parse(stdout)) { + // Don't include the branch we're using to compare against + // in the list of branches merged into that branch. + if (branch.canonicalRef !== canonicalBranchRef) { + mergedBranches.set(branch.canonicalRef, branch.sha) + } + } + + return mergedBranches +} diff --git a/app/src/lib/git/checkout-index.ts b/app/src/lib/git/checkout-index.ts new file mode 100644 index 0000000000..731835c907 --- /dev/null +++ b/app/src/lib/git/checkout-index.ts @@ -0,0 +1,40 @@ +import { git } from './core' +import { Repository } from '../../models/repository' + +/** + * Forcefully updates the working directory with information from the index + * for a given set of files. + * + * This method is essentially the same as running `git checkout -- files` + * except by using `checkout-index` we can pass the files we want updated + * on stdin, avoiding all issues with too long arguments. + * + * Note that this function will not yield errors for paths that don't + * exist in the index (-q). + * + * @param repository The repository in which to update the working directory + * with information from the index + * + * @param paths The relative paths in the working directory to update + * with information from the index. + */ +export async function checkoutIndex( + repository: Repository, + paths: ReadonlyArray +) { + if (!paths.length) { + return + } + + const options = { + successExitCodes: new Set([0, 1]), + stdin: paths.join('\0'), + } + + await git( + ['checkout-index', '-f', '-u', '-q', '--stdin', '-z'], + repository.path, + 'checkoutIndex', + options + ) +} diff --git a/app/src/lib/git/checkout.ts b/app/src/lib/git/checkout.ts new file mode 100644 index 0000000000..ae25b04c81 --- /dev/null +++ b/app/src/lib/git/checkout.ts @@ -0,0 +1,203 @@ +import { git, IGitExecutionOptions, gitNetworkArguments } 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, +} from '../progress' +import { AuthenticationErrors } from './authentication' +import { enableRecurseSubmodulesFlag } from '../feature-flag' +import { + envForRemoteOperation, + getFallbackUrlForProxyResolve, +} from './environment' +import { WorkingDirectoryFileChange } from '../../models/status' +import { ManualConflictResolution } from '../../models/manual-conflict-resolution' +import { CommitOneLine, shortenSHA } from '../../models/commit' + +export type ProgressCallback = (progress: ICheckoutProgress) => void + +function getCheckoutArgs(progressCallback?: ProgressCallback) { + return progressCallback != null + ? [...gitNetworkArguments(), 'checkout', '--progress'] + : [...gitNetworkArguments(), 'checkout'] +} + +async function getBranchCheckoutArgs(branch: Branch) { + const baseArgs: ReadonlyArray = [] + if (enableRecurseSubmodulesFlag()) { + return branch.type === BranchType.Remote + ? baseArgs.concat( + branch.name, + '-b', + branch.nameWithoutRemote, + '--recurse-submodules', + '--' + ) + : baseArgs.concat(branch.name, '--recurse-submodules', '--') + } + + return branch.type === BranchType.Remote + ? baseArgs.concat(branch.name, '-b', branch.nameWithoutRemote, '--') + : baseArgs.concat(branch.name, '--') +} + +async function getCheckoutOpts( + repository: Repository, + account: IGitAccount | null, + title: string, + target: string, + progressCallback?: ProgressCallback, + initialDescription?: string +): Promise { + const opts: IGitExecutionOptions = { + env: await envForRemoteOperation( + account, + getFallbackUrlForProxyResolve(account, repository) + ), + 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, + }) + } + } + ) +} + +/** + * Check out the given branch. + * + * @param repository - The repository in which the branch checkout should + * take place + * + * @param branch - The branch name 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 checkoutBranch( + repository: Repository, + account: IGitAccount | null, + branch: Branch, + progressCallback?: ProgressCallback +): Promise { + const opts = await getCheckoutOpts( + repository, + account, + `Checking out branch ${branch.name}`, + branch.name, + progressCallback, + `Switching to ${__DARWIN__ ? 'Branch' : 'branch'}` + ) + + const baseArgs = getCheckoutArgs(progressCallback) + const args = [...baseArgs, ...(await getBranchCheckoutArgs(branch))] + + 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 +} + +/** + * 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, + account: IGitAccount | null, + commit: CommitOneLine, + progressCallback?: ProgressCallback +): Promise { + const title = `Checking out ${__DARWIN__ ? 'Commit' : 'commit'}` + const opts = await getCheckoutOpts( + repository, + account, + title, + shortenSHA(commit.sha), + progressCallback + ) + + 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 + return true +} + +/** Check out the paths at HEAD. */ +export async function checkoutPaths( + repository: Repository, + paths: ReadonlyArray +): Promise { + await git( + ['checkout', 'HEAD', '--', ...paths], + repository.path, + 'checkoutPaths' + ) +} + +/** + * Check out either stage #2 (ours) or #3 (theirs) for a conflicted + * file. + */ +export async function checkoutConflictedFile( + repository: Repository, + file: WorkingDirectoryFileChange, + resolution: ManualConflictResolution +) { + await git( + ['checkout', `--${resolution}`, '--', file.path], + repository.path, + 'checkoutConflictedFile' + ) +} diff --git a/app/src/lib/git/cherry-pick.ts b/app/src/lib/git/cherry-pick.ts new file mode 100644 index 0000000000..2ddacd6805 --- /dev/null +++ b/app/src/lib/git/cherry-pick.ts @@ -0,0 +1,508 @@ +import * as Path from 'path' +import { GitError } from 'dugite' +import { Repository } from '../../models/repository' +import { + AppFileStatusKind, + WorkingDirectoryFileChange, +} from '../../models/status' +import { git, IGitExecutionOptions, IGitResult } from './core' +import { getStatus } from './status' +import { stageFiles } from './update-index' +import { getCommitsInRange, revRange } from './rev-list' +import { CommitOneLine } from '../../models/commit' +import { merge } from '../merge' +import { ChildProcess } from 'child_process' +import { round } from '../../ui/lib/round' +import byline from 'byline' +import { ICherryPickSnapshot } from '../../models/cherry-pick' +import { ManualConflictResolution } from '../../models/manual-conflict-resolution' +import { stageManualConflictResolution } from './stage' +import { getCommit } from '.' +import { IMultiCommitOperationProgress } from '../../models/progress' +import { readFile } from 'fs/promises' +import { pathExists } from '../../ui/lib/path-exists' + +/** The app-specific results from attempting to cherry pick commits*/ +export enum CherryPickResult { + /** + * Git completed the cherry pick without reporting any errors, and the caller can + * signal success to the user. + */ + CompletedWithoutError = 'CompletedWithoutError', + /** + * The cherry pick encountered conflicts while attempting to cherry pick and + * need to be resolved before the user can continue. + */ + ConflictsEncountered = 'ConflictsEncountered', + /** + * The cherry pick was not able to continue as tracked files were not staged in + * the index. + */ + OutstandingFilesNotStaged = 'OutstandingFilesNotStaged', + /** + * The cherry pick was not attempted: + * - it could not check the status of the repository. + * - there was an invalid revision range provided. + * - there were uncommitted changes present. + * - there were errors in checkout the target branch + */ + UnableToStart = 'UnableToStart', + /** + * An unexpected error as part of the cherry pick flow was caught and handled. + * + * Check the logs to find the relevant Git details. + */ + Error = 'Error', +} + +/** + * A parser to read and emit cherry pick progress from Git `stdout`. + * + * Each successful cherry picked commit outputs a set of lines similar to the + * following example: + * [branchName commitSha] commitSummary + * Date: timestamp + * 1 file changed, 1 insertion(+) + * create mode 100644 filename + */ +class GitCherryPickParser { + public constructor( + private readonly commits: ReadonlyArray, + private count: number = 0 + ) {} + + public parse(line: string): IMultiCommitOperationProgress | null { + const cherryPickRe = /^\[(.*\s.*)\]/ + const match = cherryPickRe.exec(line) + if (match === null) { + // Skip lines that don't represent the first line of a successfully picked + // commit. -- i.e. timestamp, files changed, conflicts, etc.. + return null + } + this.count++ + + return { + kind: 'multiCommitOperation', + value: round(this.count / this.commits.length, 2), + position: this.count, + totalCommitCount: this.commits.length, + currentCommitSummary: this.commits[this.count - 1]?.summary ?? '', + } + } +} + +/** + * This method merges `baseOptions` with a call back method that obtains a + * `ICherryPickProgress` instance from `stdout` parsing. + * + * @param baseOptions - contains git execution options other than the + * progressCallBack such as expectedErrors + * @param commits - used by the parser to form `ICherryPickProgress` instance + * @param progressCallback - the callback method that accepts an + * `ICherryPickProgress` instance created by the parser + */ +function configureOptionsWithCallBack( + baseOptions: IGitExecutionOptions, + commits: readonly CommitOneLine[], + progressCallback: (progress: IMultiCommitOperationProgress) => void, + cherryPickedCount: number = 0 +) { + return merge(baseOptions, { + processCallback: (process: ChildProcess) => { + if (process.stdout === null) { + return + } + const parser = new GitCherryPickParser(commits, cherryPickedCount) + + byline(process.stdout).on('data', (line: string) => { + const progress = parser.parse(line) + + if (progress != null) { + progressCallback(progress) + } + }) + }, + }) +} + +/** + * A function to initiate cherry picking in the app. + * + * @param commits - array of commits to cherry-pick + * For a cherry-pick operation, it does not matter what order the commits + * appear. But, it is best practice to send them in ascending order to prevent + * conflicts. First one on the array is first to be cherry-picked. + */ +export async function cherryPick( + repository: Repository, + commits: ReadonlyArray, + progressCallback?: (progress: IMultiCommitOperationProgress) => void +): Promise { + if (commits.length === 0) { + return CherryPickResult.UnableToStart + } + + let baseOptions: IGitExecutionOptions = { + expectedErrors: new Set([ + GitError.MergeConflicts, + GitError.ConflictModifyDeletedInBranch, + ]), + } + + if (progressCallback !== undefined) { + baseOptions = await configureOptionsWithCallBack( + baseOptions, + commits, + progressCallback + ) + } + + // --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.) + // + // -m 1 makes it so a merge commit always takes the first parent's history + // (the branch you are cherry-picking from) for the commit. It also means + // 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', + ], + repository.path, + 'cherry-pick', + baseOptions + ) + + return parseCherryPickResult(result) +} + +function parseCherryPickResult(result: IGitResult): CherryPickResult { + if (result.exitCode === 0) { + return CherryPickResult.CompletedWithoutError + } + + switch (result.gitError) { + case GitError.ConflictModifyDeletedInBranch: + case GitError.MergeConflicts: + return CherryPickResult.ConflictsEncountered + case GitError.UnresolvedConflicts: + return CherryPickResult.OutstandingFilesNotStaged + default: + throw new Error(`Unhandled result found: '${JSON.stringify(result)}'`) + } +} + +/** + * Inspect the `.git/sequencer` folder and convert the current cherry pick + * state into am `ICherryPickProgress` instance as well as return an array of + * remaining commits queued for cherry picking. + * - Progress instance required to display progress to user. + * - Commits required to track progress after a conflict has been resolved. + * + * This is required when Desktop is not responsible for initiating the cherry + * pick and when continuing a cherry pick after conflicts are resolved: + * + * It returns null if it cannot parse an ongoing cherry pick. This happens when, + * - There isn't a cherry pick in progress (expected null outcome). + * - Runs into errors parsing cherry pick files. This is expected if cherry + * pick is aborted or finished during parsing. It could also occur if cherry + * pick sequencer files are corrupted. + */ +export async function getCherryPickSnapshot( + repository: Repository +): Promise { + if (!isCherryPickHeadFound(repository)) { + // If there no cherry pick head, there is no cherry pick in progress. + return null + } + + // Abort safety sha is stored in.git/sequencer/abort-safety. It is the sha of + // the last cherry-picked commit in the operation or the head of target branch + // if no commits have been cherry-picked yet. + let abortSafetySha: string = '' + + // The head sha is stored in .git/sequencer/head. It is the sha of target + // branch before the cherry-pick operation occurred. + let headSha: string = '' + + // Each line of .git/sequencer/todo holds a sha of a commit lined up to be + // cherry-picked. These shas are in historical order starting oldest commit as + // the first line and newest as the last line. + const remainingCommits: CommitOneLine[] = [] + + // Try block included as files may throw an error if it cannot locate + // the sequencer files. This is possible if cherry pick is continued + // or aborted at the same time. + try { + abortSafetySha = ( + await readFile( + Path.join(repository.path, '.git', 'sequencer', 'abort-safety'), + 'utf8' + ) + ).trim() + + if (abortSafetySha === '') { + // Technically possible if someone continued or aborted the cherry pick at + // the same time + return null + } + + headSha = ( + await readFile( + Path.join(repository.path, '.git', 'sequencer', 'head'), + 'utf8' + ) + ).trim() + + if (headSha === '') { + // Technically possible if someone continued or aborted the cherry pick at + // the same time + return null + } + + const remainingPicks = ( + await readFile( + Path.join(repository.path, '.git', 'sequencer', 'todo'), + 'utf8' + ) + ).trim() + + if (remainingPicks === '') { + // Technically possible if someone continued or aborted the cherry pick at + // the same time + return null + } + + // Each line is of the format: `pick shortSha commitSummary` + remainingPicks.split('\n').forEach(line => { + line = line.replace(/^pick /, '') + if (line.trim().includes(' ')) { + const sha = line.substr(0, line.indexOf(' ')) + const commit: CommitOneLine = { + sha, + summary: line.substr(sha.length + 1), + } + remainingCommits.push(commit) + } + }) + + if (remainingCommits.length === 0) { + // This should only be possible with corrupt sequencer files. + return null + } + } catch { + // could not parse sequencer files + + if (!isCherryPickHeadFound(repository)) { + // We redo this check just because a user technically could end the + // cherry-pick by the time we got here. + return null + } + + // If cherry-pick is in progress, then there was only one commit cherry-picked + // thus sequencer files were not used. + const cherryPickHeadSha = ( + await readFile( + Path.join(repository.path, '.git', 'CHERRY_PICK_HEAD'), + 'utf8' + ) + ).trim() + const commit = await getCommit(repository, cherryPickHeadSha) + if (commit === null) { + return null + } + + return { + progress: { + kind: 'multiCommitOperation', + value: 1, + position: 1, + totalCommitCount: 1, + currentCommitSummary: commit.summary, + }, + remainingCommits: [], + commits: [{ sha: commit.sha, summary: commit.summary }], + targetBranchUndoSha: headSha, + cherryPickedCount: 0, + } + } + + // To get all the commits for the cherry-pick operation, we need to get the + // ones already cherry-picked. If abortSafetySha is headSha; none have been + // cherry-picked yet. + const commitsCherryPicked = + abortSafetySha !== headSha + ? await getCommitsInRange(repository, revRange(headSha, abortSafetySha)) + : [] + + if (commitsCherryPicked === null) { + // This should only be possible with corrupt sequencer files resulting in a + // bad revision range. + return null + } + + const commits = [...commitsCherryPicked, ...remainingCommits] + const position = commitsCherryPicked.length + 1 + + return { + progress: { + kind: 'multiCommitOperation', + value: round(position / commits.length, 2), + position, + totalCommitCount: commits.length, + currentCommitSummary: remainingCommits[0].summary ?? '', + }, + remainingCommits, + commits, + targetBranchUndoSha: headSha, + cherryPickedCount: commitsCherryPicked.length, + } +} + +/** + * Proceed with the current cherry pick operation and report back on whether it completed + * + * It is expected that the index has staged files which are cleanly cherry + * picked onto the base branch, and the remaining unstaged files are those which + * need manual resolution or were changed by the user to address inline + * conflicts. + * + * @param files - The working directory of files. These are the files that are + * detected to have changes that we want to stage for the cherry pick. + */ +export async function continueCherryPick( + repository: Repository, + files: ReadonlyArray, + manualResolutions: ReadonlyMap = new Map(), + progressCallback?: (progress: IMultiCommitOperationProgress) => void +): Promise { + // only stage files related to cherry pick + const trackedFiles = files.filter(f => { + return f.status.kind !== AppFileStatusKind.Untracked + }) + + // apply conflict resolutions + for (const [path, resolution] of manualResolutions) { + const file = files.find(f => f.path === path) + if (file === undefined) { + log.error( + `[continueCherryPick] couldn't find file ${path} even though there's a manual resolution for it` + ) + continue + } + await stageManualConflictResolution(repository, file, resolution) + } + + const otherFiles = trackedFiles.filter(f => !manualResolutions.has(f.path)) + await stageFiles(repository, otherFiles) + + const status = await getStatus(repository) + if (status == null) { + log.warn( + `[continueCherryPick] unable to get status after staging changes, + skipping any other steps` + ) + return CherryPickResult.UnableToStart + } + + // make sure cherry pick is still in progress to continue + if (await !isCherryPickHeadFound(repository)) { + return CherryPickResult.UnableToStart + } + + let options: IGitExecutionOptions = { + expectedErrors: new Set([ + GitError.MergeConflicts, + GitError.ConflictModifyDeletedInBranch, + GitError.UnresolvedConflicts, + ]), + env: { + // if we don't provide editor, we can't detect git errors + GIT_EDITOR: ':', + }, + } + + if (progressCallback !== undefined) { + const snapshot = await getCherryPickSnapshot(repository) + if (snapshot === null) { + log.warn( + `[continueCherryPick] unable to get cherry-pick status, skipping other steps` + ) + return CherryPickResult.UnableToStart + } + + options = configureOptionsWithCallBack( + options, + snapshot.commits, + progressCallback, + snapshot.cherryPickedCount + ) + } + + const trackedFilesAfter = status.workingDirectory.files.filter( + f => f.status.kind !== AppFileStatusKind.Untracked + ) + + if (trackedFilesAfter.length === 0) { + log.warn( + `[cherryPick] no tracked changes to commit, continuing cherry-pick but skipping this commit` + ) + + // This commits the empty commit so that the cherry picked commit still + // shows up in the target branches history. + const result = await git( + ['commit', '--allow-empty'], + repository.path, + 'continueCherryPickSkipCurrentCommit', + options + ) + + 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'], + repository.path, + 'continueCherryPick', + options + ) + + return parseCherryPickResult(result) +} + +/** Abandon the current cherry pick operation */ +export async function abortCherryPick(repository: Repository) { + await git(['cherry-pick', '--abort'], repository.path, 'abortCherryPick') +} + +/** + * Check if the `.git/CHERRY_PICK_HEAD` file exists + */ +export async function isCherryPickHeadFound( + repository: Repository +): Promise { + try { + const cherryPickHeadPath = Path.join( + repository.path, + '.git', + 'CHERRY_PICK_HEAD' + ) + return pathExists(cherryPickHeadPath) + } catch (err) { + log.warn( + `[cherryPick] a problem was encountered reading .git/CHERRY_PICK_HEAD, + so it is unsafe to continue cherry-picking`, + err + ) + return false + } +} diff --git a/app/src/lib/git/clone.ts b/app/src/lib/git/clone.ts new file mode 100644 index 0000000000..12d3c5955d --- /dev/null +++ b/app/src/lib/git/clone.ts @@ -0,0 +1,76 @@ +import { git, IGitExecutionOptions, gitNetworkArguments } from './core' +import { ICloneProgress } from '../../models/progress' +import { CloneOptions } from '../../models/clone-options' +import { CloneProgressParser, executionOptionsWithProgress } from '../progress' +import { getDefaultBranch } from '../helpers/default-branch' +import { envForRemoteOperation } from './environment' + +/** + * Clones a repository from a given url into to the specified path. + * + * @param url - The remote repository URL to clone from + * + * @param path - The destination path for the cloned repository. If the + * path does not exist it will be created. Cloning into an + * existing directory is only allowed if the directory is + * empty. + * + * @param options - Options specific to the clone operation, see the + * documentation for CloneOptions for more details. + * + * @param progressCallback - An optional function which will be invoked + * with information about the current progress + * of the clone operation. When provided this enables + * the '--progress' command line flag for + * 'git clone'. + */ +export async function clone( + url: string, + path: string, + options: CloneOptions, + progressCallback?: (progress: ICloneProgress) => void +): Promise { + const env = await envForRemoteOperation(options.account, url) + + const defaultBranch = options.defaultBranch ?? (await getDefaultBranch()) + + const args = [ + ...gitNetworkArguments(), + '-c', + `init.defaultBranch=${defaultBranch}`, + 'clone', + '--recursive', + ] + + let opts: IGitExecutionOptions = { env } + + if (progressCallback) { + args.push('--progress') + + const title = `Cloning into ${path}` + const kind = 'clone' + + opts = await executionOptionsWithProgress( + { ...opts, trackLFSProgress: true }, + new CloneProgressParser(), + progress => { + const description = + progress.kind === 'progress' ? progress.details.text : progress.text + const value = progress.percent + + progressCallback({ kind, title, description, value }) + } + ) + + // Initial progress + progressCallback({ kind, title, value: 0 }) + } + + if (options.branch) { + args.push('-b', options.branch) + } + + args.push('--', url, path) + + await git(args, __dirname, 'clone', opts) +} diff --git a/app/src/lib/git/commit.ts b/app/src/lib/git/commit.ts new file mode 100644 index 0000000000..a54b701f87 --- /dev/null +++ b/app/src/lib/git/commit.ts @@ -0,0 +1,107 @@ +import { git, parseCommitSHA } from './core' +import { stageFiles } from './update-index' +import { Repository } from '../../models/repository' +import { WorkingDirectoryFileChange } from '../../models/status' +import { unstageAll } from './reset' +import { ManualConflictResolution } from '../../models/manual-conflict-resolution' +import { stageManualConflictResolution } from './stage' + +/** + * @param repository repository to execute merge in + * @param message commit message + * @param files files to commit + * @returns the commit SHA + */ +export async function createCommit( + repository: Repository, + message: string, + files: ReadonlyArray, + amend: boolean = false +): Promise { + // Clear the staging area, our diffs reflect the difference between the + // working directory and the last commit (if any) so our commits should + // do the same thing. + await unstageAll(repository) + + await stageFiles(repository, files) + + const args = ['-F', '-'] + + if (amend) { + args.push('--amend') + } + + const result = await git( + ['commit', ...args], + repository.path, + 'createCommit', + { + stdin: message, + } + ) + return parseCommitSHA(result) +} + +/** + * Creates a commit to finish an in-progress merge + * assumes that all conflicts have already been resolved + * *Warning:* Does _not_ clear staged files before it commits! + * + * @param repository repository to execute merge in + * @param files files to commit + */ +export async function createMergeCommit( + repository: Repository, + files: ReadonlyArray, + manualResolutions: ReadonlyMap = new Map() +): Promise { + // apply manual conflict resolutions + for (const [path, resolution] of manualResolutions) { + const file = files.find(f => f.path === path) + if (file !== undefined) { + await stageManualConflictResolution(repository, file, resolution) + } else { + log.error( + `couldn't find file ${path} even though there's a manual resolution for it` + ) + } + } + + const otherFiles = files.filter(f => !manualResolutions.has(f.path)) + + await stageFiles(repository, otherFiles) + const result = await git( + [ + 'commit', + // no-edit here ensures the app does not accidentally invoke the user's editor + '--no-edit', + // By default Git merge commits do not contain any commentary (which + // are lines prefixed with `#`). This works because the Git CLI will + // prompt the user to edit the file in `.git/COMMIT_MSG` before + // committing, and then it will run `--cleanup=strip`. + // + // This clashes with our use of `--no-edit` above as Git will now change + // it's behavior to invoke `--cleanup=whitespace` as it did not ask + // the user to edit the COMMIT_MSG as part of creating a commit. + // + // From the docs on git-commit (https://git-scm.com/docs/git-commit) I'll + // quote the relevant section: + // --cleanup= + // strip + // Strip leading and trailing empty lines, trailing whitespace, + // commentary and collapse consecutive empty lines. + // whitespace + // Same as `strip` except #commentary is not removed. + // default + // Same as `strip` if the message is to be edited. Otherwise `whitespace`. + // + // We should emulate the behavior in this situation because we don't + // let the user view or change the commit message before making the + // commit. + '--cleanup=strip', + ], + repository.path, + 'createMergeCommit' + ) + return parseCommitSHA(result) +} diff --git a/app/src/lib/git/config.ts b/app/src/lib/git/config.ts new file mode 100644 index 0000000000..2041dac7e6 --- /dev/null +++ b/app/src/lib/git/config.ts @@ -0,0 +1,272 @@ +import { git } from './core' +import { Repository } from '../../models/repository' +import { normalize } from 'path' + +/** + * Look up a config value by name in the repository. + * + * @param onlyLocal Whether or not the value to be retrieved should stick to + * the local repository settings. It is false by default. This + * is equivalent to using the `--local` argument in the + * `git config` invocation. + */ +export function getConfigValue( + repository: Repository, + name: string, + onlyLocal: boolean = false +): Promise { + return getConfigValueInPath(name, repository.path, onlyLocal) +} + +/** Look up a global config value by name. */ +export function getGlobalConfigValue( + name: string, + env?: { + HOME: string + } +): Promise { + return getConfigValueInPath(name, null, false, undefined, env) +} + +/** + * Look up a global 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 getGlobalBooleanConfigValue( + name: string, + env?: { + HOME: string + } +): Promise { + const value = await getConfigValueInPath(name, null, false, 'bool', env) + return value === null ? null : value !== 'false' +} + +/** + * Look up a config value by name + * + * @param path The path to execute the `git` command in. If null + * we'll use the global configuration (i.e. --global) + * and execute the Git call from the same location that + * GitHub Desktop is installed in. + * @param onlyLocal Whether or not the value to be retrieved should stick to + * the local repository settings (if a path is specified). It + * is false by default. It is equivalent to using the `--local` + * argument in the `git config` invocation. + * @param type Canonicalize configuration values according to the + * expected type (i.e. 0 -> false, "on" -> true etc). + * See `--type` documentation in `git config` + */ +async function getConfigValueInPath( + name: string, + path: string | null, + onlyLocal: boolean = false, + type?: 'bool' | 'int' | 'bool-or-int' | 'path' | 'expiry-date' | 'color', + env?: { + HOME: string + } +): Promise { + const flags = ['config', '-z'] + if (!path) { + flags.push('--global') + } else if (onlyLocal) { + flags.push('--local') + } + + if (type !== undefined) { + flags.push('--type', type) + } + + flags.push(name) + + const result = await git(flags, path || __dirname, 'getConfigValueInPath', { + successExitCodes: new Set([0, 1]), + env, + }) + + // Git exits with 1 if the value isn't found. That's OK. + if (result.exitCode === 1) { + return null + } + + const output = result.stdout + const pieces = output.split('\0') + 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]) +} + +/** Set the local config value by name. */ +export async function setConfigValue( + repository: Repository, + name: string, + value: string, + env?: { + HOME: string + } +): Promise { + return setConfigValueInPath(name, value, repository.path, env) +} + +/** Set the global config value by name. */ +export async function setGlobalConfigValue( + name: string, + value: string, + env?: { + HOME: string + } +): Promise { + return setConfigValueInPath(name, value, null, env) +} + +/** Set the global config value by name. */ +export async function addGlobalConfigValue( + name: string, + value: string +): Promise { + await git( + ['config', '--global', '--add', name, value], + __dirname, + 'addGlobalConfigValue' + ) +} + +/** + * Adds a path to the `safe.directories` configuration variable if it's not + * already present. Adding a path to `safe.directory` will cause Git to ignore + * if the path is owner by a different user than the current. + */ +export async function addSafeDirectory(path: string) { + // UNC-paths on Windows need to be prefixed with `%(prefix)/`, see + // https://github.com/git-for-windows/git/commit/e394a16023cbb62784e380f70ad8a833fb960d68 + if (__WIN32__ && path[0] === '/') { + path = `%(prefix)/${path}` + } + + await addGlobalConfigValueIfMissing('safe.directory', path) +} + +/** Set the global config value by name. */ +export async function addGlobalConfigValueIfMissing( + name: string, + value: string +): Promise { + const { stdout, exitCode } = await git( + ['config', '--global', '-z', '--get-all', name, value], + __dirname, + 'addGlobalConfigValue', + { successExitCodes: new Set([0, 1]) } + ) + + if (exitCode === 1 || !stdout.split('\0').includes(value)) { + await addGlobalConfigValue(name, value) + } +} + +/** + * Set config value by name + * + * @param path The path to execute the `git` command in. If null + * we'll use the global configuration (i.e. --global) + * and execute the Git call from the same location that + * GitHub Desktop is installed in. + */ +async function setConfigValueInPath( + name: string, + value: string, + path: string | null, + env?: { + HOME: string + } +): Promise { + const options = env ? { env } : undefined + + const flags = ['config'] + + if (!path) { + flags.push('--global') + } + + flags.push('--replace-all', name, value) + + await git(flags, path || __dirname, 'setConfigValueInPath', options) +} + +/** Remove the local config value by name. */ +export async function removeConfigValue( + repository: Repository, + name: string, + env?: { + HOME: string + } +): Promise { + return removeConfigValueInPath(name, repository.path, env) +} + +/** Remove the global config value by name. */ +export async function removeGlobalConfigValue( + name: string, + env?: { + HOME: string + } +): Promise { + return removeConfigValueInPath(name, null, env) +} + +/** + * Remove config value by name + * + * @param path The path to execute the `git` command in. If null + * we'll use the global configuration (i.e. --global) + * and execute the Git call from the same location that + * GitHub Desktop is installed in. + */ +async function removeConfigValueInPath( + name: string, + path: string | null, + env?: { + HOME: string + } +): Promise { + const options = env ? { env } : undefined + + const flags = ['config'] + + if (!path) { + flags.push('--global') + } + + flags.push('--unset-all', name) + + await git(flags, path || __dirname, 'removeConfigValueInPath', options) +} diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts new file mode 100644 index 0000000000..2f58683f20 --- /dev/null +++ b/app/src/lib/git/core.ts @@ -0,0 +1,475 @@ +import { + GitProcess, + IGitResult as DugiteResult, + GitError as DugiteError, + IGitExecutionOptions as DugiteExecutionOptions, +} 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' + +/** + * An extension of the execution options in dugite that + * allows us to piggy-back our own configuration options in the + * same object. + */ +export interface IGitExecutionOptions extends DugiteExecutionOptions { + /** + * The exit codes which indicate success to the + * caller. Unexpected exit codes will be logged and an + * error thrown. Defaults to 0 if undefined. + */ + readonly successExitCodes?: ReadonlySet + + /** + * The git errors which are expected by the caller. Unexpected errors will + * be logged and an error thrown. + */ + readonly expectedErrors?: ReadonlySet + + /** Should it track & report LFS progress? */ + readonly trackLFSProgress?: boolean +} + +/** + * The result of using `git`. This wraps dugite's results to provide + * the parsed error if one occurs. + */ +export interface IGitResult extends DugiteResult { + /** + * The parsed git error. This will be null when the exit code is included in + * the `successExitCodes`, or when dugite was unable to parse the + * error. + */ + readonly gitError: DugiteError | null + + /** 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 + * working directory which is... super confusing, I know) + */ + readonly path: string +} +export class GitError extends Error { + /** The result from the failed command. */ + public readonly result: IGitResult + + /** The args for the failed command. */ + public readonly args: ReadonlyArray + + /** + * Whether or not the error message is just the raw output of the git command. + */ + public readonly isRawMessage: boolean + + public constructor(result: IGitResult, args: ReadonlyArray) { + let rawMessage = true + let message + + if (result.gitErrorDescription) { + message = result.gitErrorDescription + rawMessage = false + } else if (result.combinedOutput.length > 0) { + message = result.combinedOutput + } else if (result.stderr.length) { + message = result.stderr + } else if (result.stdout.length) { + message = result.stdout + } else { + message = 'Unknown error' + rawMessage = false + } + + super(message) + + this.name = 'GitError' + this.result = result + this.args = args + this.isRawMessage = rawMessage + } +} + +/** + * Shell out to git with the given arguments, at the given path. + * + * @param args The arguments to pass to `git`. + * + * @param path The working directory path for the execution of the + * command. + * + * @param name The name for the command based on its caller's + * context. This will be used for performance + * measurements and debugging. + * + * @param options Configuration options for the execution of git, + * see IGitExecutionOptions for more information. + * + * Returns the result. If the command exits with a code not in + * `successExitCodes` or an error not in `expectedErrors`, a `GitError` will be + * thrown. + */ +export async function git( + args: string[], + path: string, + name: string, + options?: IGitExecutionOptions +): Promise { + const defaultOptions: IGitExecutionOptions = { + successExitCodes: new Set([0]), + expectedErrors: new Set(), + } + + let combinedOutput = '' + const opts = { + ...defaultOptions, + ...options, + } + + opts.processCallback = (process: ChildProcess) => { + 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}`) + } + + 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 + ? 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) + } + + if (result.stderr) { + errorMessage.push('stderr:') + errorMessage.push(result.stderr) + } + + if (gitError) { + errorMessage.push( + `(The error was parsed as ${gitError}: ${gitErrorDescription})` + ) + } + + log.error(errorMessage.join('\n')) + + if (gitError === DugiteError.PushWithFileSizeExceedingLimit) { + const result = getFileFromExceedsError(errorMessage.join()) + const files = result.join('\n') + + if (files !== '') { + gitResult.gitErrorDescription += '\n\nFile causing error:\n\n' + files + } + } + + throw new GitError(gitResult, args) + }) +} + +/** + * Determine whether the provided `error` is an authentication failure + * as per our definition. Note that this is not an exhaustive list of + * authentication failures, only a collection of errors that we treat + * equally in terms of error message and presentation to the user. + */ +export function isAuthFailureError( + error: DugiteError +): error is + | DugiteError.SSHAuthenticationFailed + | DugiteError.SSHPermissionDenied + | DugiteError.HTTPSAuthenticationFailed { + switch (error) { + case DugiteError.SSHAuthenticationFailed: + case DugiteError.SSHPermissionDenied: + case DugiteError.HTTPSAuthenticationFailed: + return true + } + return false +} + +/** + * Determine whether the provided `error` is an error from Git indicating + * that a configuration file write failed due to a lock file already + * existing for that config file. + */ +export function isConfigFileLockError(error: Error): error is GitError { + return ( + error instanceof GitError && + error.result.gitError === DugiteError.ConfigLockFileAlreadyExists + ) +} + +const lockFilePathRe = /^error: could not lock config file (.+?): File exists$/m + +/** + * If the `result` is associated with an config lock file error (as determined + * by `isConfigFileLockError`) this method will attempt to extract an absolute + * path (i.e. rooted) to the configuration lock file in question from the Git + * output. + */ +export function parseConfigLockFilePathFromError(result: IGitResult) { + const match = lockFilePathRe.exec(result.stderr) + + if (match === null) { + return null + } + + // Git on Windows may print the config file path using forward slashes. + // Luckily for us forward slashes are not allowed in Windows file or + // directory names so we can simply replace any instance of forward + // slashes with backslashes. + const normalized = __WIN32__ ? match[1].replace('/', '\\') : match[1] + + // https://github.com/git/git/blob/232378479/lockfile.h#L117-L119 + return Path.resolve(result.path, `${normalized}.lock`) +} + +function getDescriptionForError(error: DugiteError): string | null { + if (isAuthFailureError(error)) { + const menuHint = __DARWIN__ + ? 'GitHub Desktop > Settings.' + : 'File > Options.' + return `Authentication failed. Some common reasons include: + +- You are not logged in to your account: see ${menuHint} +- You may need to log out and log back in to refresh your token. +- You do not have permission to access this repository. +- The repository is archived on GitHub. Check the repository settings to confirm you are still permitted to push commits. +- If you use SSH authentication, check that your key is added to the ssh-agent and associated with your account. +- If you use SSH authentication, ensure the host key verification passes for your repository hosting service. +- If you used username / password authentication, you might need to use a Personal Access Token instead of your account password. Check the documentation of your repository hosting service.` + } + + switch (error) { + case DugiteError.SSHKeyAuditUnverified: + return 'The SSH key is unverified.' + case DugiteError.RemoteDisconnection: + return 'The remote disconnected. Check your Internet connection and try again.' + case DugiteError.HostDown: + return 'The host is down. Check your Internet connection and try again.' + case DugiteError.RebaseConflicts: + return 'We found some conflicts while trying to rebase. Please resolve the conflicts before continuing.' + case DugiteError.MergeConflicts: + return 'We found some conflicts while trying to merge. Please resolve the conflicts and commit the changes.' + case DugiteError.HTTPSRepositoryNotFound: + case DugiteError.SSHRepositoryNotFound: + return 'The repository does not seem to exist anymore. You may not have access, or it may have been deleted or renamed.' + case DugiteError.PushNotFastForward: + return 'The repository has been updated since you last pulled. Try pulling before pushing.' + case DugiteError.BranchDeletionFailed: + return 'Could not delete the branch. It was probably already deleted.' + case DugiteError.DefaultBranchDeletionFailed: + return `The branch is the repository's default branch and cannot be deleted.` + case DugiteError.RevertConflicts: + return 'To finish reverting, please merge and commit the changes.' + case DugiteError.EmptyRebasePatch: + return 'There aren’t any changes left to apply.' + case DugiteError.NoMatchingRemoteBranch: + return 'There aren’t any remote branches that match the current branch.' + case DugiteError.NothingToCommit: + return 'There are no changes to commit.' + case DugiteError.NoSubmoduleMapping: + return 'A submodule was removed from .gitmodules, but the folder still exists in the repository. Delete the folder, commit the change, then try again.' + case DugiteError.SubmoduleRepositoryDoesNotExist: + return 'A submodule points to a location which does not exist.' + case DugiteError.InvalidSubmoduleSHA: + return 'A submodule points to a commit which does not exist.' + case DugiteError.LocalPermissionDenied: + return 'Permission denied.' + case DugiteError.InvalidMerge: + return 'This is not something we can merge.' + case DugiteError.InvalidRebase: + return 'This is not something we can rebase.' + case DugiteError.NonFastForwardMergeIntoEmptyHead: + return 'The merge you attempted is not a fast-forward, so it cannot be performed on an empty branch.' + case DugiteError.PatchDoesNotApply: + return 'The requested changes conflict with one or more files in the repository.' + case DugiteError.BranchAlreadyExists: + return 'A branch with that name already exists.' + case DugiteError.BadRevision: + return 'Bad revision.' + case DugiteError.NotAGitRepository: + return 'This is not a git repository.' + case DugiteError.ProtectedBranchForcePush: + return 'This branch is protected from force-push operations.' + case DugiteError.ProtectedBranchRequiresReview: + return 'This branch is protected and any changes requires an approved review. Open a pull request with changes targeting this branch instead.' + case DugiteError.PushWithFileSizeExceedingLimit: + return "The push operation includes a file which exceeds GitHub's file size restriction of 100MB. Please remove the file from history and try again." + case DugiteError.HexBranchNameRejected: + return 'The branch name cannot be a 40-character string of hexadecimal characters, as this is the format that Git uses for representing objects.' + case DugiteError.ForcePushRejected: + return 'The force push has been rejected for the current branch.' + case DugiteError.InvalidRefLength: + return 'A ref cannot be longer than 255 characters.' + case DugiteError.CannotMergeUnrelatedHistories: + return 'Unable to merge unrelated histories in this repository.' + case DugiteError.PushWithPrivateEmail: + return 'Cannot push these commits as they contain an email address marked as private on GitHub. To push anyway, visit https://github.com/settings/emails, uncheck "Keep my email address private", then switch back to GitHub Desktop to push your commits. You can then enable the setting again.' + case DugiteError.LFSAttributeDoesNotMatch: + return 'Git LFS attribute found in global Git configuration does not match expected value.' + case DugiteError.ProtectedBranchDeleteRejected: + return 'This branch cannot be deleted from the remote repository because it is marked as protected.' + case DugiteError.ProtectedBranchRequiredStatus: + return 'The push was rejected by the remote server because a required status check has not been satisfied.' + case DugiteError.BranchRenameFailed: + return 'The branch could not be renamed.' + case DugiteError.PathDoesNotExist: + return 'The path does not exist on disk.' + case DugiteError.InvalidObjectName: + return 'The object was not found in the Git repository.' + case DugiteError.OutsideRepository: + return 'This path is not a valid path inside the repository.' + case DugiteError.LockFileAlreadyExists: + return 'A lock file already exists in the repository, which blocks this operation from completing.' + case DugiteError.NoMergeToAbort: + return 'There is no merge in progress, so there is nothing to abort.' + case DugiteError.NoExistingRemoteBranch: + return 'The remote branch does not exist.' + case DugiteError.LocalChangesOverwritten: + return 'Unable to switch branches as there are working directory changes which would be overwritten. Please commit or stash your changes.' + case DugiteError.UnresolvedConflicts: + return 'There are unresolved conflicts in the working directory.' + case DugiteError.ConfigLockFileAlreadyExists: + // Added in dugite 1.88.0 (https://github.com/desktop/dugite/pull/386) + // in support of https://github.com/desktop/desktop/issues/8675 but we're + // not using it yet. Returning a null message here means the stderr will + // be used as the error message (or stdout if stderr is empty), i.e. the + // same behavior as before the ConfigLockFileAlreadyExists was added + return null + case DugiteError.RemoteAlreadyExists: + return null + case DugiteError.TagAlreadyExists: + return 'A tag with that name already exists' + case DugiteError.MergeWithLocalChanges: + case DugiteError.RebaseWithLocalChanges: + case DugiteError.GPGFailedToSignData: + 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. + */ +export function gitRebaseArguments() { + return [ + // Explicitly set the rebase backend to merge. + // We need to force this option to be sure that Desktop + // 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', + ] +} + +/** + * Returns the SHA of the passed in IGitResult + */ +export function parseCommitSHA(result: IGitResult): string { + return result.stdout.split(']')[0].split(' ')[1] +} diff --git a/app/src/lib/git/description.ts b/app/src/lib/git/description.ts new file mode 100644 index 0000000000..2e72bb5b5d --- /dev/null +++ b/app/src/lib/git/description.ts @@ -0,0 +1,33 @@ +import * as Path from 'path' +import { readFile, writeFile } from 'fs/promises' + +const GitDescriptionPath = '.git/description' + +const DefaultGitDescription = + "Unnamed repository; edit this file 'description' to name the repository.\n" + +/** Get the repository's description from the .git/description file. */ +export async function getGitDescription( + repositoryPath: string +): Promise { + const path = Path.join(repositoryPath, GitDescriptionPath) + + try { + const data = await readFile(path, 'utf8') + if (data === DefaultGitDescription) { + return '' + } + return data + } catch (err) { + return '' + } +} + +/** Write a .git/description file to the repository. */ +export async function writeGitDescription( + repositoryPath: string, + description: string +): Promise { + const fullPath = Path.join(repositoryPath, GitDescriptionPath) + await writeFile(fullPath, description) +} diff --git a/app/src/lib/git/diff-check.ts b/app/src/lib/git/diff-check.ts new file mode 100644 index 0000000000..131773078d --- /dev/null +++ b/app/src/lib/git/diff-check.ts @@ -0,0 +1,42 @@ +import { spawnAndComplete } from './spawn' +import { getCaptures } from '../helpers/regex' + +/** + * Returns a list of files with conflict markers present + * + * @param repositoryPath filepath to repository + * @returns filepaths with their number of conflicted markers + */ +export async function getFilesWithConflictMarkers( + repositoryPath: string +): Promise> { + // git operation + const args = ['diff', '--check'] + const { output } = await spawnAndComplete( + args, + repositoryPath, + 'getFilesWithConflictMarkers', + new Set([0, 2]) + ) + + // result parsing + const outputStr = output.toString('utf8') + const captures = getCaptures(outputStr, fileNameCaptureRe) + if (captures.length === 0) { + return new Map() + } + // 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 diff --git a/app/src/lib/git/diff-index.ts b/app/src/lib/git/diff-index.ts new file mode 100644 index 0000000000..e8b6fd5351 --- /dev/null +++ b/app/src/lib/git/diff-index.ts @@ -0,0 +1,116 @@ +import { git } from './core' +import { Repository } from '../../models/repository' + +/** + * Possible statuses of an entry in Git, see the git diff-index + * man page for additional details. + */ +export enum IndexStatus { + Unknown = 0, + Added, + Copied, + Deleted, + Modified, + Renamed, + TypeChanged, + Unmerged, +} + +/** + * Index statuses excluding renames and copies. + * + * Used when invoking diff-index with rename detection explicitly turned + * off. + */ +export type NoRenameIndexStatus = + | IndexStatus.Added + | IndexStatus.Deleted + | IndexStatus.Modified + | IndexStatus.TypeChanged + | IndexStatus.Unmerged + | IndexStatus.Unknown + +function getIndexStatus(status: string) { + switch (status[0]) { + case 'A': + return IndexStatus.Added + case 'C': + return IndexStatus.Copied + case 'D': + return IndexStatus.Deleted + case 'M': + return IndexStatus.Modified + case 'R': + return IndexStatus.Renamed + case 'T': + return IndexStatus.TypeChanged + case 'U': + return IndexStatus.Unmerged + case 'X': + return IndexStatus.Unknown + default: + throw new Error(`Unknown index status: ${status}`) + } +} + +function getNoRenameIndexStatus(status: string): NoRenameIndexStatus { + const parsed = getIndexStatus(status) + + switch (parsed) { + case IndexStatus.Copied: + case IndexStatus.Renamed: + throw new Error( + `Invalid index status for no-rename index status: ${parsed}` + ) + } + + return parsed +} + +/** The SHA for the null tree. */ +export const NullTreeSHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + +/** + * Get a list of files which have recorded changes in the index as compared to + * HEAD along with the type of change. + * + * @param repository The repository for which to retrieve the index changes. + */ +export async function getIndexChanges( + repository: Repository +): Promise> { + const args = ['diff-index', '--cached', '--name-status', '--no-renames', '-z'] + + let result = await git( + [...args, 'HEAD', '--'], + repository.path, + 'getIndexChanges', + { + successExitCodes: new Set([0, 128]), + } + ) + + // 128 from diff-index either means that the path isn't a repository or (more + // likely) that the repository HEAD is unborn. If HEAD is unborn we'll diff + // the index against the null tree instead. + if (result.exitCode === 128) { + result = await git( + [...args, NullTreeSHA], + repository.path, + 'getIndexChanges' + ) + } + + const map = new Map() + + const pieces = result.stdout.split('\0') + + for (let i = 0; i < pieces.length - 1; i += 2) { + const status = getNoRenameIndexStatus(pieces[i]) + const path = pieces[i + 1] + + map.set(path, status) + } + + return map +} diff --git a/app/src/lib/git/diff.ts b/app/src/lib/git/diff.ts new file mode 100644 index 0000000000..3665ef4c10 --- /dev/null +++ b/app/src/lib/git/diff.ts @@ -0,0 +1,722 @@ +import * as Path from 'path' + +import { getBlobContents } from './show' + +import { Repository } from '../../models/repository' +import { + WorkingDirectoryFileChange, + FileChange, + AppFileStatusKind, + SubmoduleStatus, +} from '../../models/status' +import { + DiffType, + IRawDiff, + IDiff, + IImageDiff, + Image, + LineEndingsChange, + parseLineEndingText, + ILargeTextDiff, +} 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 { IChangesetData, parseRawLogWithNumstat } from './log' +import { getConfigValue } from './config' +import { getMergeBase } from './merge' + +/** + * 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. + * + * This is a hard limit on how big a buffer can be and still be converted into + * a string. + */ +const MaxDiffBufferSize = 70e6 // 70MB in decimal + +/** + * Where `MaxDiffBufferSize` is a hard limit, this is a suggested limit. Diffs + * bigger than this _could_ be displayed but it might cause some slowness. + */ +const MaxReasonableDiffSize = MaxDiffBufferSize / 16 // ~4.375MB in decimal + +/** + * The longest line length we should try to display. If a diff has a line longer + * than this, we probably shouldn't attempt it + */ +const MaxCharactersPerLine = 5000 + +/** + * Utility function to check whether parsing this buffer is going to cause + * issues at runtime. + * + * @param buffer A buffer of binary text from a spawned process + */ +function isValidBuffer(buffer: Buffer) { + return buffer.length <= MaxDiffBufferSize +} + +/** Is the buffer too large for us to reasonably represent? */ +function isBufferTooLarge(buffer: Buffer) { + return buffer.length >= MaxReasonableDiffSize +} + +/** Is the diff too large for us to reasonably represent? */ +function isDiffTooLarge(diff: IRawDiff) { + for (const hunk of diff.hunks) { + for (const line of hunk.lines) { + if (line.text.length > MaxCharactersPerLine) { + return true + } + } + } + + return false +} + +/** + * Defining the list of known extensions we can render inside the app + */ +const imageFileExtensions = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.ico', + '.webp', + '.bmp', + '.avif', +]) + +/** + * Render the difference between a file in the given commit and its parent + * + * @param commitish A commit SHA or some other identifier that ultimately dereferences + * to a commit. + */ +export async function getCommitDiff( + repository: Repository, + file: FileChange, + commitish: string, + hideWhitespaceInDiff: boolean = false +): Promise { + const args = [ + 'log', + commitish, + ...(hideWhitespaceInDiff ? ['-w'] : []), + '-m', + '-1', + '--first-parent', + '--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 { output } = await spawnAndComplete( + args, + repository.path, + 'getCommitDiff' + ) + + return buildDiff(output, 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', { + maxBuffer: Infinity, + }) + + return buildDiff(Buffer.from(result.stdout), repository, file, latestCommit) +} + +/** + * Render the difference between two commits for a file + * + */ +export async function getCommitRangeDiff( + repository: Repository, + file: FileChange, + commits: ReadonlyArray, + hideWhitespaceInDiff: boolean = false, + useNullTreeSHA: boolean = false +): Promise { + if (commits.length === 0) { + throw new Error('No commits to diff...') + } + + const oldestCommitRef = useNullTreeSHA ? NullTreeSHA : `${commits[0]}^` + const latestCommit = commits.at(-1) ?? '' // can't be undefined since commits.length > 0 + const args = [ + 'diff', + oldestCommitRef, + latestCommit, + ...(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, 'getCommitsDiff', { + maxBuffer: Infinity, + expectedErrors: new Set([GitError.BadRevision]), + }) + + // 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) { + return getCommitRangeDiff( + repository, + file, + commits, + hideWhitespaceInDiff, + true + ) + } + + return buildDiff(Buffer.from(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, + baseBranchName, + comparisonBranchName + ) + + if (mergeBaseCommit === null) { + return null + } + + const result = await git( + baseArgs, + repository.path, + 'getBranchMergeBaseChangedFiles' + ) + + return parseRawLogWithNumstat( + result.stdout, + `${latestComparisonBranchCommitRef}`, + mergeBaseCommit + ) +} + +export async function getCommitRangeChangedFiles( + repository: Repository, + shas: ReadonlyArray, + useNullTreeSHA: boolean = false +): Promise { + if (shas.length === 0) { + throw new Error('No commits to diff...') + } + + const oldestCommitRef = useNullTreeSHA ? NullTreeSHA : `${shas[0]}^` + const latestCommitRef = shas.at(-1) ?? '' // can't be undefined since shas.length > 0 + const baseArgs = [ + 'diff', + oldestCommitRef, + latestCommitRef, + '-C', + '-M', + '-z', + '--raw', + '--numstat', + '--', + ] + + const { stdout, gitError } = await git( + baseArgs, + repository.path, + 'getCommitRangeChangedFiles', + { + expectedErrors: new Set([GitError.BadRevision]), + } + ) + + // 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 (gitError === GitError.BadRevision && useNullTreeSHA === false) { + const useNullTreeSHA = true + return getCommitRangeChangedFiles(repository, shas, useNullTreeSHA) + } + + return parseRawLogWithNumstat(stdout, latestCommitRef, oldestCommitRef) +} + +/** + * Render the diff for a file within the repository working directory. The file will be + * compared against HEAD if it's tracked, if not it'll be compared to an empty file meaning + * that all content in the file will be treated as additions. + */ +export async function getWorkingDirectoryDiff( + repository: Repository, + file: WorkingDirectoryFileChange, + hideWhitespaceInDiff: boolean = false +): Promise { + // `--no-ext-diff` should be provided wherever we invoke `git diff` so that any + // diff.external program configured by the user is ignored + const args = [ + 'diff', + ...(hideWhitespaceInDiff ? ['-w'] : []), + '--no-ext-diff', + '--patch-with-raw', + '-z', + '--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 ( + !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 + // + // this is the behavior: + // - 0 if no changes found + // - 1 if changes found + // - and error otherwise + // + // citation in source: + // https://github.com/git/git/blob/1f66975deb8402131fbf7c14330d0c7cdebaeaa2/diff-no-index.c#L300 + successExitCodes.add(1) + args.push('--no-index', '--', '/dev/null', file.path) + } else if (file.status.kind === AppFileStatusKind.Renamed) { + // NB: Technically this is incorrect, the best kind of incorrect. + // In order to show exactly what will end up in the commit we should + // perform a diff between the new file and the old file as it appears + // in HEAD. By diffing against the index we won't show any changes + // already staged to the renamed file which differs from our other diffs. + // The closest I got to that was running hash-object and then using + // git diff but that seems a bit excessive. + args.push('--', file.path) + } else { + args.push('HEAD', '--', file.path) + } + + const { output, error } = await spawnAndComplete( + args, + repository.path, + 'getWorkingDirectoryDiff', + successExitCodes + ) + const lineEndingsChange = parseLineEndingsWarning(error) + + return buildDiff(output, repository, file, 'HEAD', lineEndingsChange) +} + +async function getImageDiff( + repository: Repository, + file: FileChange, + oldestCommitish: string +): Promise { + let current: Image | undefined = undefined + let previous: Image | undefined = undefined + + // Are we looking at a file in the working directory or a file in a commit? + if (file instanceof WorkingDirectoryFileChange) { + // No idea what to do about this, a conflicted binary (presumably) file. + // Ideally we'd show all three versions and let the user pick but that's + // a bit out of scope for now. + if (file.status.kind === AppFileStatusKind.Conflicted) { + return { kind: DiffType.Image } + } + + // Does it even exist in the working directory? + if (file.status.kind !== AppFileStatusKind.Deleted) { + current = await getWorkingDirectoryImage(repository, file) + } + + if ( + file.status.kind !== AppFileStatusKind.New && + file.status.kind !== AppFileStatusKind.Untracked + ) { + // If we have file.oldPath that means it's a rename so we'll + // look for that file. + previous = await getBlobImage( + repository, + getOldPathOrDefault(file), + 'HEAD' + ) + } + } else { + // File status can't be conflicted for a file in a commit + if (file.status.kind !== AppFileStatusKind.Deleted) { + current = await getBlobImage(repository, file.path, oldestCommitish) + } + + // File status can't be conflicted for a file in a commit + if ( + file.status.kind !== AppFileStatusKind.New && + file.status.kind !== AppFileStatusKind.Untracked + ) { + // TODO: commitish^ won't work for the first commit + // + // If we have file.oldPath that means it's a rename so we'll + // look for that file. + previous = await getBlobImage( + repository, + getOldPathOrDefault(file), + `${oldestCommitish}^` + ) + } + } + + return { + kind: DiffType.Image, + previous: previous, + current: current, + } +} + +export async function convertDiff( + repository: Repository, + file: FileChange, + diff: IRawDiff, + oldestCommitish: string, + lineEndingsChange?: LineEndingsChange +): Promise { + const extension = Path.extname(file.path).toLowerCase() + + if (diff.isBinary) { + // some extension we don't know how to parse, never mind + if (!imageFileExtensions.has(extension)) { + return { + kind: DiffType.Binary, + } + } else { + return getImageDiff(repository, file, oldestCommitish) + } + } + + return { + kind: DiffType.Text, + text: diff.contents, + hunks: diff.hunks, + lineEndingsChange, + maxLineNumber: diff.maxLineNumber, + hasHiddenBidiChars: diff.hasHiddenBidiChars, + } +} + +/** + * Map a given file extension to the related data URL media type + */ +function getMediaType(extension: string) { + if (extension === '.png') { + return 'image/png' + } + if (extension === '.jpg' || extension === '.jpeg') { + return 'image/jpg' + } + if (extension === '.gif') { + return 'image/gif' + } + if (extension === '.ico') { + return 'image/x-icon' + } + if (extension === '.webp') { + return 'image/webp' + } + if (extension === '.bmp') { + return 'image/bmp' + } + if (extension === '.avif') { + return 'image/avif' + } + + // fallback value as per the spec + return 'text/plain' +} + +/** + * `git diff` will write out messages about the line ending changes it knows + * about to `stderr` - this rule here will catch this and also the to/from + * changes based on what the user has configured. + */ +const lineEndingsChangeRegex = + /', (CRLF|CR|LF) will be replaced by (CRLF|CR|LF) the .*/ + +/** + * Utility function for inspecting the stderr output for the line endings + * warning that Git may report. + * + * @param error A buffer of binary text from a spawned process + */ +function parseLineEndingsWarning(error: Buffer): LineEndingsChange | undefined { + if (error.length === 0) { + return undefined + } + + const errorText = error.toString('utf-8') + const match = lineEndingsChangeRegex.exec(errorText) + if (match) { + const from = parseLineEndingText(match[1]) + const to = parseLineEndingText(match[2]) + if (from && to) { + return { from, to } + } + } + + return undefined +} + +/** + * Utility function used by get(Commit|WorkingDirectory)Diff. + * + * Parses the output from a diff-like command that uses `--path-with-raw` + */ +function diffFromRawDiffOutput(output: Buffer): IRawDiff { + // for now we just assume the diff is UTF-8, but given we have the raw buffer + // we can try and convert this into other encodings in the future + const result = output.toString('utf-8') + + const pieces = result.split('\0') + const parser = new DiffParser() + return parser.parse(forceUnwrap(`Invalid diff output`, pieces.at(-1))) +} + +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 { kind: DiffType.Unrenderable } + } + + const diff = diffFromRawDiffOutput(buffer) + + if (isBufferTooLarge(buffer) || isDiffTooLarge(diff)) { + // we don't want to render by default + // but we keep it as an option by + // passing in text and hunks + const largeTextDiff: ILargeTextDiff = { + kind: DiffType.LargeText, + text: diff.contents, + hunks: diff.hunks, + lineEndingsChange, + maxLineNumber: diff.maxLineNumber, + hasHiddenBidiChars: diff.hasHiddenBidiChars, + } + + return largeTextDiff + } + + return convertDiff(repository, file, diff, oldestCommitish, lineEndingsChange) +} + +/** + * Retrieve the binary contents of a blob from the object database + * + * Returns an image object containing the base64 encoded string, + * as tags support the data URI scheme instead of + * needing to reference a file:// URI + * + * https://en.wikipedia.org/wiki/Data_URI_scheme + */ +export async function getBlobImage( + repository: Repository, + path: string, + commitish: string +): Promise { + const extension = Path.extname(path) + const contents = await getBlobContents(repository, commitish, path) + return new Image( + contents.toString('base64'), + getMediaType(extension), + contents.length + ) +} +/** + * Retrieve the binary contents of a blob from the working directory + * + * Returns an image object containing the base64 encoded string, + * as tags support the data URI scheme instead of + * needing to reference a file:// URI + * + * https://en.wikipedia.org/wiki/Data_URI_scheme + */ +export async function getWorkingDirectoryImage( + repository: Repository, + file: FileChange +): Promise { + const contents = await readFile(Path.join(repository.path, file.path)) + return new Image( + contents.toString('base64'), + getMediaType(Path.extname(file.path)), + contents.length + ) +} + +/** + * List the modified binary files' paths in the given repository + * + * @param repository to run git operation in + * @param ref ref (sha, branch, etc) to compare the working index against + * + * if you're mid-merge pass `'MERGE_HEAD'` to ref to get a diff of `HEAD` vs `MERGE_HEAD`, + * otherwise you should probably pass `'HEAD'` to get a diff of the working tree vs `HEAD` + */ +export async function getBinaryPaths( + repository: Repository, + ref: string +): Promise> { + const { output } = await spawnAndComplete( + ['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 +} + +const binaryListRegex = /-\t-\t(?:\0.+\0)?([^\0]*)/gi diff --git a/app/src/lib/git/environment.ts b/app/src/lib/git/environment.ts new file mode 100644 index 0000000000..ad05be9c77 --- /dev/null +++ b/app/src/lib/git/environment.ts @@ -0,0 +1,130 @@ +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' + +/** + * For many remote operations it's well known what the primary remote + * url is (clone, push, fetch etc). But in some cases it's not as easy. + * + * Two examples are checkout, and revert where neither would need to + * hit the network in vanilla Git usage but do need to when LFS gets + * involved. + * + * What's the primary url when using LFS then? Most likely it's gonna + * be on the same as the default remote but it could theoretically + * 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. + */ +export function getFallbackUrlForProxyResolve( + account: IGitAccount | null, + repository: Repository +) { + // 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 + } + + if (repository.gitHubRepository !== null) { + if (repository.gitHubRepository.cloneURL !== null) { + return repository.gitHubRepository.cloneURL + } + } + + // If all else fails let's assume that whatever network resource + // Git is gonna hit it's gonna be using the same proxy as it would + // if it was a GitHub.com endpoint + return 'https://github.com' +} + +/** + * Create a set of environment variables to use when invoking a Git + * subcommand that needs to communicate with a remote (i.e. fetch, clone, + * push, pull, ls-remote, etc etc). + * + * The environment variables deal with setting up sane defaults, configuring + * authentication, and resolving proxy urls if necessary. + * + * @param account The authentication information (if available) to provide + * to Git for use when connecting to the remote + * @param remoteUrl The primary remote URL for this operation. Note that Git + * might connect to other remotes in order to fulfill the + * operation. As an example, a clone of + * https://github.com/desktop/desktop could contain a submodule + * 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 +) { + return { + ...envForAuthentication(account), + ...(await envForProxy(remoteUrl)), + } +} + +/** + * Not intended to be used directly. Exported only in order to + * allow for testing. + * + * @param remoteUrl The remote url to resolve a proxy for. + * @param env The current environment variables, defaults + * to `process.env` + * @param resolve The method to use when resolving the proxy url, + * defaults to `resolveGitProxy` + */ +export async function envForProxy( + remoteUrl: string, + env: NodeJS.ProcessEnv = process.env, + resolve: (url: string) => Promise = resolveGitProxy +): Promise { + const protocolMatch = /^(https?):\/\//i.exec(remoteUrl) + + // We can only resolve and use a proxy for the protocols where cURL + // would be involved (i.e http and https). git:// relies on ssh. + if (protocolMatch === null) { + return + } + + // Note that HTTPS here doesn't mean that the proxy is HTTPS, only + // that all requests to HTTPS protocols should be proxied. The + // proxy protocol is defined by the url returned by `this.resolve()` + const proto = protocolMatch[1].toLowerCase() // http or https + + // We'll play it safe and say that if the user has configured + // the ALL_PROXY environment variable they probably know what + // they're doing and wouldn't want us to override it with a + // protocol-specific proxy. cURL supports both lower and upper + // case, see: + // https://github.com/curl/curl/blob/14916a82e/lib/url.c#L2180-L2185 + if ('ALL_PROXY' in env || 'all_proxy' in env) { + log.info(`proxy url not resolved, ALL_PROXY already set`) + return + } + + // Lower case environment variables due to + // https://ec.haxx.se/usingcurl/usingcurl-proxies#http_proxy-in-lower-case-only + const envKey = `${proto}_proxy` // http_proxy or https_proxy + + // If the user has already configured a proxy in the environment + // for the protocol we're not gonna override it. + if (envKey in env || (proto === 'https' && 'HTTPS_PROXY' in env)) { + log.info(`proxy url not resolved, ${envKey} already set`) + return + } + + const proxyUrl = await resolve(remoteUrl).catch(err => { + log.error('Failed resolving Git proxy', err) + return undefined + }) + + return proxyUrl === undefined ? undefined : { [envKey]: proxyUrl } +} diff --git a/app/src/lib/git/fetch.ts b/app/src/lib/git/fetch.ts new file mode 100644 index 0000000000..0049df0296 --- /dev/null +++ b/app/src/lib/git/fetch.ts @@ -0,0 +1,171 @@ +import { git, IGitExecutionOptions, gitNetworkArguments } 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' +import { IRemote } from '../../models/remote' +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] + } +} + +/** + * Fetch from the given remote. + * + * @param repository - The repository to fetch into + * + * @param account - The account to use when authenticating with the remote + * + * @param remote - The remote to fetch from + * + * @param progressCallback - An optional function which will be invoked + * with information about the current progress + * of the fetch operation. When provided this enables + * the '--progress' command line flag for + * 'git fetch'. + */ +export async function fetch( + repository: Repository, + account: IGitAccount | null, + remote: IRemote, + progressCallback?: (progress: IFetchProgress) => void +): Promise { + let opts: IGitExecutionOptions = { + successExitCodes: new Set([0]), + env: await envForRemoteOperation(account, remote.url), + } + + if (progressCallback) { + const title = `Fetching ${remote.name}` + const kind = 'fetch' + + opts = await executionOptionsWithProgress( + { ...opts, trackLFSProgress: true }, + new FetchProgressParser(), + progress => { + // In addition to progress output from the remote end and from + // git itself, the stderr output from pull contains information + // about ref updates. We don't need to bring those into the progress + // stream so we'll just punt on anything we don't know about for now. + if (progress.kind === 'context') { + if (!progress.text.startsWith('remote: Counting objects')) { + return + } + } + + const description = + progress.kind === 'progress' ? progress.details.text : progress.text + const value = progress.percent + + progressCallback({ + kind, + title, + description, + value, + remote: remote.name, + }) + } + ) + + // Initial progress + progressCallback({ kind, title, value: 0, remote: remote.name }) + } + + const args = await getFetchArgs( + repository, + remote.name, + account, + progressCallback + ) + + await git(args, repository.path, 'fetch', opts) +} + +/** 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), + } + ) +} + +export async function fastForwardBranches( + repository: Repository, + branches: ReadonlyArray +): Promise { + if (branches.length === 0) { + return + } + + 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', + '.', + // Make sure we don't try to update branches that can't be fast-forwarded + // even if the user disabled this via the git config option + // `fetch.showForcedUpdates` + '--show-forced-updates', + // Prevent `git fetch` from touching the `FETCH_HEAD` + '--no-write-fetch-head', + // Take branch refs from stdin to circumvent shell max line length + // limitations (mainly on Windows) + '--stdin', + ], + repository.path, + 'fastForwardBranches', + opts + ) +} diff --git a/app/src/lib/git/for-each-ref.ts b/app/src/lib/git/for-each-ref.ts new file mode 100644 index 0000000000..5f477a0758 --- /dev/null +++ b/app/src/lib/git/for-each-ref.ts @@ -0,0 +1,147 @@ +import { git } from './core' +import { GitError } from 'dugite' +import { Repository } from '../../models/repository' +import { + Branch, + BranchType, + IBranchTip, + ITrackingBranch, +} from '../../models/branch' +import { CommitIdentity } from '../../models/commit-identity' +import { createForEachRefParser } from './git-delimiter-parser' + +/** Get all the branches. */ +export async function getBranches( + repository: Repository, + ...prefixes: string[] +): Promise> { + const { formatArgs, parse } = createForEachRefParser({ + fullName: '%(refname)', + shortName: '%(refname:short)', + upstreamShortName: '%(upstream:short)', + sha: '%(objectname)', + author: '%(author)', + symRef: '%(symref)', + }) + + if (!prefixes || !prefixes.length) { + prefixes = ['refs/heads', 'refs/remotes'] + } + + // TODO: use expectedErrors here to handle a specific error + // see https://github.com/desktop/desktop/pull/5299#discussion_r206603442 for + // discussion about what needs to change + const result = await git( + ['for-each-ref', ...formatArgs, ...prefixes], + repository.path, + 'getBranches', + { expectedErrors: new Set([GitError.NotAGitRepository]) } + ) + + if (result.gitError === GitError.NotAGitRepository) { + return [] + } + + const branches = [] + + for (const ref of parse(result.stdout)) { + // excude symbolic refs from the branch list + if (ref.symRef.length > 0) { + continue + } + + const author = CommitIdentity.parseIdentity(ref.author) + const tip: IBranchTip = { sha: ref.sha, author } + + const type = ref.fullName.startsWith('refs/heads') + ? BranchType.Local + : BranchType.Remote + + const upstream = + ref.upstreamShortName.length > 0 ? ref.upstreamShortName : null + + branches.push(new Branch(ref.shortName, upstream, tip, type, ref.fullName)) + } + + return branches +} + +/** + * Gets all branches that differ from their upstream (i.e. they're ahead, + * behind or both), excluding the current branch. + * Useful to narrow down a list of branches that could potentially be fast + * forwarded. + * + * @param repository Repository to get the branches from. + */ +export async function getBranchesDifferingFromUpstream( + repository: Repository +): Promise> { + const { formatArgs, parse } = createForEachRefParser({ + fullName: '%(refname)', + sha: '%(objectname)', // SHA + upstream: '%(upstream)', + symref: '%(symref)', + head: '%(HEAD)', + }) + + const prefixes = ['refs/heads', 'refs/remotes'] + + const result = await git( + ['for-each-ref', ...formatArgs, ...prefixes], + repository.path, + 'getBranchesDifferingFromUpstream', + { expectedErrors: new Set([GitError.NotAGitRepository]) } + ) + + if (result.gitError === GitError.NotAGitRepository) { + return [] + } + + const localBranches = [] + const remoteBranchShas = new Map() + + // First we need to collect the relevant info from the command output: + // - For local branches with upstream: name, ref, SHA and the upstream. + // - For remote branches we only need the sha (and the ref as key). + for (const ref of parse(result.stdout)) { + if (ref.symref.length > 0 || ref.head === '*') { + // Exclude symbolic refs and the current branch + continue + } + + if (ref.fullName.startsWith('refs/heads')) { + if (ref.upstream.length === 0) { + // Exclude local branches without upstream + continue + } + + localBranches.push({ + ref: ref.fullName, + sha: ref.sha, + upstream: ref.upstream, + }) + } else { + remoteBranchShas.set(ref.fullName, ref.sha) + } + } + + const eligibleBranches = new Array() + + // Compare the SHA of every local branch with the SHA of its upstream and + // collect the names of local branches that differ from their upstream. + for (const branch of localBranches) { + const remoteSha = remoteBranchShas.get(branch.upstream) + + if (remoteSha !== undefined && remoteSha !== branch.sha) { + eligibleBranches.push({ + ref: branch.ref, + sha: branch.sha, + upstreamRef: branch.upstream, + upstreamSha: remoteSha, + }) + } + } + + return eligibleBranches +} diff --git a/app/src/lib/git/format-patch.ts b/app/src/lib/git/format-patch.ts new file mode 100644 index 0000000000..28cd117335 --- /dev/null +++ b/app/src/lib/git/format-patch.ts @@ -0,0 +1,25 @@ +import { revRange } from './rev-list' +import { Repository } from '../../models/repository' +import { spawnAndComplete } from './spawn' + +/** + * Generate a patch representing the changes associated with a range of commits + * + * @param repository where to generate path from + * @param base starting commit in range + * @param head ending commit in rage + * @returns patch generated + */ +export async function formatPatch( + repository: Repository, + base: string, + head: string +): Promise { + const range = revRange(base, head) + const { output } = await spawnAndComplete( + ['format-patch', '--unified=1', '--minimal', '--stdout', range], + repository.path, + 'formatPatch' + ) + return output.toString('utf8') +} diff --git a/app/src/lib/git/git-delimiter-parser.ts b/app/src/lib/git/git-delimiter-parser.ts new file mode 100644 index 0000000000..715f8e0aae --- /dev/null +++ b/app/src/lib/git/git-delimiter-parser.ts @@ -0,0 +1,91 @@ +/** + * 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 + * `ref-filter`. + * + * Returns an object with the arguments that need to be appended to the git + * call and the parse function itself + * + * @param fields An object keyed on the friendly name of the value being + * parsed with the value being the format string of said value. + * + * 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 entries = [] + + for (let i = 0; i < records.length - keys.length; i += keys.length) { + const entry = {} as { [K in keyof T]: string } + keys.forEach((key, ix) => (entry[key] = records[i + ix])) + entries.push(entry) + } + + return entries + } + + return { formatArgs, parse } +} + +/** + * Create a new parser suitable for parsing --format output from commands such + * as `git for-each-ref`, `git branch`, and other commands that are not derived + * from `git log`. + * + * Returns an object with the arguments that need to be appended to the git + * call and the parse function itself + * + * @param fields An object keyed on the friendly name of the value being + * parsed with the value being the format string of said value. + * + * Example: + * + * `const { args, parse } = createForEachRefParser({ sha: '%(objectname)' })` + */ +export function createForEachRefParser>( + fields: T +) { + const keys: Array = Object.keys(fields) + const format = Object.values(fields).join('%00') + const formatArgs = [`--format=%00${format}%00`] + + const parse = (value: string) => { + const records = value.split('\0') + const entries = new Array<{ [K in keyof T]: string }>() + + let entry + let consumed = 0 + + // start at 1 to avoid 0 modulo X problem. The first record is guaranteed + // to be empty anyway (due to %00 at the start of --format) + for (let i = 1; i < records.length - 1; i++) { + if (i % (keys.length + 1) === 0) { + if (records[i] !== '\n') { + throw new Error('Expected newline') + } + continue + } + + entry = entry ?? ({} as { [K in keyof T]: string }) + const key = keys[consumed % keys.length] + entry[key] = records[i] + consumed++ + + if (consumed % keys.length === 0) { + entries.push(entry) + entry = undefined + } + } + + return entries + } + + return { formatArgs, parse } +} diff --git a/app/src/lib/git/gitignore.ts b/app/src/lib/git/gitignore.ts new file mode 100644 index 0000000000..540561f1e3 --- /dev/null +++ b/app/src/lib/git/gitignore.ts @@ -0,0 +1,157 @@ +import * as Path from 'path' +import * as FS from 'fs' +import { Repository } from '../../models/repository' +import { getConfigValue } from './config' +import { writeFile } from 'fs/promises' + +/** + * Read the contents of the repository .gitignore. + * + * Returns a promise which will either be rejected or resolved + * with the contents of the file. If there's no .gitignore file + * in the repository root the promise will resolve with null. + */ +export async function readGitIgnoreAtRoot( + repository: Repository +): Promise { + const ignorePath = Path.join(repository.path, '.gitignore') + + return new Promise((resolve, reject) => { + FS.readFile(ignorePath, 'utf8', (err, data) => { + if (err) { + if (err.code === 'ENOENT') { + resolve(null) + } else { + reject(err) + } + } else { + resolve(data) + } + }) + }) +} + +/** + * Persist the given content to the repository root .gitignore. + * + * If the repository root doesn't contain a .gitignore file one + * will be created, otherwise the current file will be overwritten. + */ +export async function saveGitIgnore( + repository: Repository, + text: string +): Promise { + const ignorePath = Path.join(repository.path, '.gitignore') + + if (text === '') { + return new Promise((resolve, reject) => { + FS.unlink(ignorePath, err => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } + + const fileContents = await formatGitIgnoreContents(text, repository) + await writeFile(ignorePath, fileContents) +} + +/** Add the given pattern or patterns to the root gitignore file */ +export async function appendIgnoreRule( + repository: Repository, + patterns: string | string[] +): Promise { + const text = (await readGitIgnoreAtRoot(repository)) || '' + + const currentContents = await formatGitIgnoreContents(text, repository) + + const newPatternText = + patterns instanceof Array ? patterns.join('\n') : patterns + const newText = await formatGitIgnoreContents( + `${currentContents}${newPatternText}`, + repository + ) + + await saveGitIgnore(repository, newText) +} + +/** + * Convenience method to add the given file path(s) to the repository's gitignore. + * + * The file path will be escaped before adding. + */ +export async function appendIgnoreFile( + repository: Repository, + filePath: string | string[] +): Promise { + if (filePath instanceof Array) { + const escapedFilePaths = filePath.map(path => + escapeGitSpecialCharacters(path) + ) + + return appendIgnoreRule(repository, escapedFilePaths) + } + + const escapedFilePath = escapeGitSpecialCharacters(filePath) + return appendIgnoreRule(repository, escapedFilePath) +} + +/** Escapes a string from special characters used in a gitignore file */ +export function escapeGitSpecialCharacters(pattern: string): string { + const specialCharacters = /[\[\]!\*\#\?]/g + + return pattern.replaceAll(specialCharacters, match => { + return '\\' + match + }) +} + +/** + * Format the gitignore text based on the current config settings. + * + * This setting looks at core.autocrlf to decide which line endings to use + * when updating the .gitignore file. + * + * If core.safecrlf is also set, adding this file to the index may cause + * Git to return a non-zero exit code, leaving the working directory in a + * confusing state for the user. So we should reformat the file in that + * case. + * + * @param text The text to format. + * @param repository The repository associated with the gitignore file. + */ +async function formatGitIgnoreContents( + text: string, + repository: Repository +): Promise { + const autocrlf = await getConfigValue(repository, 'core.autocrlf') + const safecrlf = await getConfigValue(repository, 'core.safecrlf') + + return new Promise((resolve, reject) => { + if (autocrlf === 'true' && safecrlf === 'true') { + // based off https://stackoverflow.com/a/141069/1363815 + const normalizedText = text.replace(/\r\n|\n\r|\n|\r/g, '\r\n') + resolve(normalizedText) + return + } + + if (text.endsWith('\n')) { + resolve(text) + return + } + + if (autocrlf == null) { + // fallback to Git default behaviour + resolve(`${text}\n`) + } else { + const linesEndInCRLF = autocrlf === 'true' + if (linesEndInCRLF) { + resolve(`${text}\n`) + } else { + resolve(`${text}\r\n`) + } + } + }) +} diff --git a/app/src/lib/git/index.ts b/app/src/lib/git/index.ts new file mode 100644 index 0000000000..e5a0cfcb58 --- /dev/null +++ b/app/src/lib/git/index.ts @@ -0,0 +1,35 @@ +export * from './apply' +export * from './branch' +export * from './checkout' +export * from './clone' +export * from './commit' +export * from './config' +export * from './core' +export * from './description' +export * from './diff' +export * from './fetch' +export * from './for-each-ref' +export * from './init' +export * from './log' +export * from './pull' +export * from './push' +export * from './reflog' +export * from './refs' +export * from './remote' +export * from './reset' +export * from './rev-list' +export * from './rev-parse' +export * from './status' +export * from './update-ref' +export * from './var' +export * from './merge' +export * from './diff-index' +export * from './checkout-index' +export * from './revert' +export * from './rm' +export * from './submodule' +export * from './interpret-trailers' +export * from './gitignore' +export * from './rebase' +export * from './format-patch' +export * from './tag' diff --git a/app/src/lib/git/init.ts b/app/src/lib/git/init.ts new file mode 100644 index 0000000000..266c5d342b --- /dev/null +++ b/app/src/lib/git/init.ts @@ -0,0 +1,11 @@ +import { getDefaultBranch } from '../helpers/default-branch' +import { git } from './core' + +/** Init a new git repository in the given path. */ +export async function initGitRepository(path: string): Promise { + await git( + ['-c', `init.defaultBranch=${await getDefaultBranch()}`, 'init'], + path, + 'initGitRepository' + ) +} diff --git a/app/src/lib/git/interpret-trailers.ts b/app/src/lib/git/interpret-trailers.ts new file mode 100644 index 0000000000..d3c1d09af0 --- /dev/null +++ b/app/src/lib/git/interpret-trailers.ts @@ -0,0 +1,176 @@ +import { git } from './core' +import { Repository } from '../../models/repository' +import { getConfigValue } from './config' + +/** + * A representation of a Git commit message trailer. + * + * See git-interpret-trailers for more information. + */ +export interface ITrailer { + readonly token: string + readonly value: string +} + +/** + * Gets a value indicating whether the trailer token is + * Co-Authored-By. Does not validate the token value. + */ +export function isCoAuthoredByTrailer(trailer: ITrailer) { + return trailer.token.toLowerCase() === 'co-authored-by' +} + +/** + * Parse a string containing only unfolded trailers produced by + * git-interpret-trailers --only-input --only-trailers --unfold or + * a derivative such as git log --format="%(trailers:only,unfold)" + * + * @param trailers A string containing one well formed trailer per + * line + * + * @param separators A string containing all characters to use when + * attempting to find the separator between token + * and value in a trailer. See the configuration + * option trailer.separators for more information + * + * Also see getTrailerSeparatorCharacters. + */ +export function parseRawUnfoldedTrailers(trailers: string, separators: string) { + const lines = trailers.split('\n') + const parsedTrailers = new Array() + + for (const line of lines) { + const trailer = parseSingleUnfoldedTrailer(line, separators) + + if (trailer) { + parsedTrailers.push(trailer) + } + } + + return parsedTrailers +} + +export function parseSingleUnfoldedTrailer( + line: string, + separators: string +): ITrailer | null { + for (const separator of separators) { + const ix = line.indexOf(separator) + if (ix > 0) { + return { + token: line.substring(0, ix).trim(), + value: line.substring(ix + 1).trim(), + } + } + } + + return null +} + +/** + * Get a string containing the characters that may be used in this repository + * separate tokens from values in commit message trailers. If no specific + * trailer separator is configured the default separator (:) will be returned. + */ +export async function getTrailerSeparatorCharacters( + repository: Repository +): Promise { + return (await getConfigValue(repository, 'trailer.separators')) || ':' +} + +/** + * Extract commit message trailers from a commit message. + * + * The trailers returned here are unfolded, i.e. they've had their + * whitespace continuation removed and are all on one line. See the + * documentation for --unfold in the help for `git interpret-trailers` + * + * @param repository The repository in which to run the interpret- + * trailers command. Although not intuitive this + * does matter as there are configuration options + * available for the format, position, etc of commit + * message trailers. See the manpage for + * git-interpret-trailers for more information. + * + * @param commitMessage A commit message from where to attempt to extract + * commit message trailers. + * + * @returns An array of zero or more parsed trailers + */ +export async function parseTrailers( + repository: Repository, + commitMessage: string +): Promise> { + const result = await git( + ['interpret-trailers', '--parse'], + repository.path, + 'parseTrailers', + { + stdin: commitMessage, + } + ) + + const trailers = result.stdout + + if (trailers.length === 0) { + return [] + } + + const separators = await getTrailerSeparatorCharacters(repository) + return parseRawUnfoldedTrailers(result.stdout, separators) +} + +/** + * Merge one or more commit message trailers into a commit message. + * + * If no trailers are given this method will simply try to ensure that + * any trailers that happen to be part of the raw message are formatted + * in accordance with the configuration options set for trailers in + * the given repository. + * + * Note that configuration may be set so that duplicate trailers are + * kept or discarded. + * + * @param repository The repository in which to run the interpret- + * trailers command. Although not intuitive this + * does matter as there are configuration options + * available for the format, position, etc of commit + * message trailers. See the manpage for + * git-interpret-trailers for more information. + * + * @param commitMessage A commit message with or without existing commit + * message trailers into which to merge the trailers + * given in the trailers parameter + * + * @param trailers Zero or more trailers to merge into the commit message + * + * @returns A commit message string where the provided trailers (if) + * any have been merged into the commit message using the + * configuration settings for trailers in the provided + * repository. + */ +export async function mergeTrailers( + repository: Repository, + commitMessage: string, + trailers: ReadonlyArray, + unfold: boolean = false +) { + 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') + } + + for (const trailer of trailers) { + args.push('--trailer', `${trailer.token}=${trailer.value}`) + } + + const result = await git(args, repository.path, 'mergeTrailers', { + stdin: commitMessage, + }) + + return result.stdout +} diff --git a/app/src/lib/git/lfs.ts b/app/src/lib/git/lfs.ts new file mode 100644 index 0000000000..455bdb24c9 --- /dev/null +++ b/app/src/lib/git/lfs.ts @@ -0,0 +1,100 @@ +import { git } from './core' +import { Repository } from '../../models/repository' + +/** Install the global LFS filters. */ +export async function installGlobalLFSFilters(force: boolean): Promise { + const args = ['lfs', 'install', '--skip-repo'] + if (force) { + args.push('--force') + } + + await git(args, __dirname, 'installGlobalLFSFilter') +} + +/** Install LFS hooks in the repository. */ +export async function installLFSHooks( + repository: Repository, + force: boolean +): Promise { + const args = ['lfs', 'install'] + if (force) { + args.push('--force') + } + + await git(args, repository.path, 'installLFSHooks') +} + +/** Is the repository configured to track any paths with LFS? */ +export async function isUsingLFS(repository: Repository): Promise { + const env = { + GIT_LFS_TRACK_NO_INSTALL_HOOKS: '1', + } + const result = await git(['lfs', 'track'], repository.path, 'isUsingLFS', { + env, + }) + return result.stdout.length > 0 +} + +/** + * Check if a provided file path is being tracked by Git LFS + * + * This uses the Git plumbing to read the .gitattributes file + * for any LFS-related rules that are set for the file + * + * @param repository repository with + * @param path relative file path in the repository + */ +export async function isTrackedByLFS( + repository: Repository, + path: string +): Promise { + const { stdout } = await git( + ['check-attr', 'filter', path], + repository.path, + 'checkAttrForLFS' + ) + + // "git check-attr -a" will output every filter it can find in .gitattributes + // and it looks like this: + // + // README.md: diff: lfs + // README.md: merge: lfs + // README.md: text: unset + // README.md: filter: lfs + // + // To verify git-lfs this test will just focus on that last row, "filter", + // and the value associated with it. If nothing is found in .gitattributes + // the output will look like this + // + // README.md: filter: unspecified + + const lfsFilterRegex = /: filter: lfs/ + + const match = lfsFilterRegex.exec(stdout) + + return match !== null +} + +/** + * Query a Git repository and filter the set of provided relative paths to see + * which are not covered by the current Git LFS configuration. + * + * @param repository + * @param filePaths List of relative paths in the repository + */ +export async function filesNotTrackedByLFS( + repository: Repository, + filePaths: ReadonlyArray +): Promise> { + const filesNotTrackedByGitLFS = new Array() + + for (const file of filePaths) { + const isTracked = await isTrackedByLFS(repository, file) + + if (!isTracked) { + filesNotTrackedByGitLFS.push(file) + } + } + + return filesNotTrackedByGitLFS +} diff --git a/app/src/lib/git/log.ts b/app/src/lib/git/log.ts new file mode 100644 index 0000000000..b908c18723 --- /dev/null +++ b/app/src/lib/git/log.ts @@ -0,0 +1,345 @@ +import { git } from './core' +import { + CommittedFileChange, + AppFileStatusKind, + 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 { parseRawUnfoldedTrailers } from './interpret-trailers' +import { getCaptures } from '../helpers/regex' +import { createLogParser } from './git-delimiter-parser' +import { revRange } from '.' +import { forceUnwrap } from '../fatal-error' + +// 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) + */ +function mapStatus( + rawStatus: 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, submoduleStatus } + } // modified + if (status === 'A') { + return { kind: AppFileStatusKind.New, submoduleStatus } + } // added + if (status === '?') { + return { kind: AppFileStatusKind.Untracked, submoduleStatus } + } // untracked + if (status === 'D') { + return { kind: AppFileStatusKind.Deleted, submoduleStatus } + } // deleted + if (status === 'R' && oldPath != null) { + return { kind: AppFileStatusKind.Renamed, oldPath, submoduleStatus } + } // renamed + if (status === 'C' && oldPath != null) { + return { kind: AppFileStatusKind.Copied, oldPath, submoduleStatus } + } // 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, submoduleStatus } + } + + // 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, submoduleStatus } + } + + 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` + */ +export async function getCommits( + repository: Repository, + revisionRange?: string, + limit?: number, + skip?: number, + additionalArgs: ReadonlyArray = [] +): Promise> { + const { formatArgs, parse } = createLogParser({ + sha: '%H', // SHA + shortSha: '%h', // short SHA + summary: '%s', // summary + body: '%b', // body + // author identity string, matching format of GIT_AUTHOR_IDENT. + // author name + // author date format dependent on --date arg, should be raw + author: '%an <%ae> %ad', + committer: '%cn <%ce> %cd', + parents: '%P', // parent SHAs, + trailers: '%(trailers:unfold,only)', + refs: '%D', + }) + + const args = ['log'] + + if (revisionRange !== undefined) { + args.push(revisionRange) + } + + args.push('--date=raw') + + if (limit !== undefined) { + args.push(`--max-count=${limit}`) + } + + if (skip !== undefined) { + args.push(`--skip=${skip}`) + } + + args.push( + ...formatArgs, + '--no-show-signature', + '--no-color', + ...additionalArgs, + '--' + ) + const result = await git(args, repository.path, 'getCommits', { + successExitCodes: new Set([0, 128]), + }) + + // if the repository has an unborn HEAD, return an empty history of commits + if (result.exitCode === 128) { + return new Array() + } + + 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]) + + 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(' ') : [], + // 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, ':'), + tags + ) + }) +} + +/** This interface contains information of a changeset. */ +export interface IChangesetData { + /** Files changed in the changeset. */ + readonly files: ReadonlyArray + + /** Number of lines added in the changeset. */ + readonly linesAdded: number + + /** Number of lines deleted in the changeset. */ + readonly linesDeleted: number +} + +/** Get the files that were changed in the given commit. */ +export async function getChangedFiles( + repository: Repository, + sha: string +): Promise { + // 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', + '--raw', + '--format=format:', + '--numstat', + '-z', + '--', + ] + + const { stdout } = await git(args, repository.path, 'getChangesFiles') + return parseRawLogWithNumstat(stdout, sha, `${sha}^`) +} + +/** + * 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 + */ + +export function parseRawLogWithNumstat( + stdout: string, + sha: string, + parentCommitish: string +) { + const files = new Array() + let linesAdded = 0 + let linesDeleted = 0 + let numStatCount = 0 + const lines = stdout.split('\0') + + 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, linesAdded, linesDeleted } +} + +/** Get the commit for the given ref. */ +export async function getCommit( + repository: Repository, + ref: string +): Promise { + const commits = await getCommits(repository, ref, 1) + if (commits.length < 1) { + return null + } + + 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'] + ) + + return mergeCommits.length > 0 +} diff --git a/app/src/lib/git/merge-tree.ts b/app/src/lib/git/merge-tree.ts new file mode 100644 index 0000000000..eb6b5a5877 --- /dev/null +++ b/app/src/lib/git/merge-tree.ts @@ -0,0 +1,117 @@ +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}$/ + +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], + repository.path, + 'mergeTree' + ) + + 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) + } + }) + }) +} + +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 } + ) + }) + }) +} diff --git a/app/src/lib/git/merge.ts b/app/src/lib/git/merge.ts new file mode 100644 index 0000000000..7216fc2153 --- /dev/null +++ b/app/src/lib/git/merge.ts @@ -0,0 +1,121 @@ +import * as Path from 'path' + +import { git } from './core' +import { GitError } from 'dugite' +import { Repository } from '../../models/repository' +import { pathExists } from '../../ui/lib/path-exists' + +export enum MergeResult { + /** The merge completed successfully */ + Success, + /** + * The merge was a noop since the current branch + * was already up to date with the target branch. + */ + AlreadyUpToDate, + /** + * The merge failed, likely due to conflicts. + */ + Failed, +} + +/** Merge the named branch into the current branch. */ +export async function merge( + repository: Repository, + branch: string, + isSquash: boolean = false +): Promise { + const args = ['merge'] + + if (isSquash) { + args.push('--squash') + } + + args.push(branch) + + const { exitCode, stdout } = await git(args, repository.path, 'merge', { + expectedErrors: new Set([GitError.MergeConflicts]), + }) + + if (exitCode !== 0) { + return MergeResult.Failed + } + + if (isSquash) { + const { exitCode } = await git( + ['commit', '--no-edit'], + repository.path, + 'createSquashMergeCommit' + ) + if (exitCode !== 0) { + return MergeResult.Failed + } + } + + return stdout === noopMergeMessage + ? MergeResult.AlreadyUpToDate + : MergeResult.Success +} + +const noopMergeMessage = 'Already up to date.\n' + +/** + * Find the base commit between two commit-ish identifiers + * + * @returns the commit id of the merge base, or null if the two commit-ish + * identifiers do not have a common base + */ +export async function getMergeBase( + repository: Repository, + firstCommitish: string, + secondCommitish: string +): Promise { + const process = await git( + ['merge-base', firstCommitish, secondCommitish], + repository.path, + 'merge-base', + { + // - 1 is returned if a common ancestor cannot be resolved + // - 128 is returned if a ref cannot be found + // "warning: ignoring broken ref refs/remotes/origin/main." + successExitCodes: new Set([0, 1, 128]), + } + ) + + if (process.exitCode === 1 || process.exitCode === 128) { + return null + } + + return process.stdout.trim() +} + +/** + * Abort a mid-flight (conflicted) merge + * + * @param repository where to abort the merge + */ +export async function abortMerge(repository: Repository): Promise { + await git(['merge', '--abort'], repository.path, 'abortMerge') +} + +/** + * Check the `.git/MERGE_HEAD` file exists in a repository to confirm + * that it is in a conflicted state. + */ +export async function isMergeHeadSet(repository: Repository): Promise { + const path = Path.join(repository.path, '.git', 'MERGE_HEAD') + return await pathExists(path) +} + +/** + * Check the `.git/SQUASH_MSG` file exists in a repository + * This would indicate we did a merge --squash and have not committed.. indicating + * we have detected a conflict. + * + * Note: If we abort the merge, this doesn't get cleared automatically which + * could lead to this being erroneously available in a non merge --squashing scenario. + */ +export async function isSquashMsgSet(repository: Repository): Promise { + const path = Path.join(repository.path, '.git', 'SQUASH_MSG') + return await pathExists(path) +} diff --git a/app/src/lib/git/pull.ts b/app/src/lib/git/pull.ts new file mode 100644 index 0000000000..fa95a8c910 --- /dev/null +++ b/app/src/lib/git/pull.ts @@ -0,0 +1,143 @@ +import { + git, + GitError, + IGitExecutionOptions, + gitNetworkArguments, + gitRebaseArguments, +} 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' +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(), + ...gitRebaseArguments(), + 'pull', + ...divergentPathArgs, + ] + + if (enableRecurseSubmodulesFlag()) { + args.push('--recurse-submodules') + } + + if (progressCallback != null) { + args.push('--progress') + } + + args.push(remote) + + return args +} + +/** + * Pull from the specified remote. + * + * @param repository - The repository in which the pull should take place + * + * @param remote - The name of the remote that should be pulled from + * + * @param progressCallback - An optional function which will be invoked + * with information about the current progress + * of the pull operation. When provided this enables + * the '--progress' command line flag for + * 'git pull'. + */ +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, + } + + if (progressCallback) { + const title = `Pulling ${remote.name}` + const kind = 'pull' + + opts = await executionOptionsWithProgress( + { ...opts, trackLFSProgress: true }, + new PullProgressParser(), + progress => { + // In addition to progress output from the remote end and from + // git itself, the stderr output from pull contains information + // about ref updates. We don't need to bring those into the progress + // stream so we'll just punt on anything we don't know about for now. + if (progress.kind === 'context') { + if (!progress.text.startsWith('remote: Counting objects')) { + return + } + } + + const description = + progress.kind === 'progress' ? progress.details.text : progress.text + + const value = progress.percent + + progressCallback({ + kind, + title, + description, + value, + remote: remote.name, + }) + } + ) + + // Initial progress + 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) + } +} + +/** + * Defaults the pull default for divergent paths to try to fast forward and if + * not perform a merge. Aka uses the flag --ff + * + * It checks whether the user has a config set for this already, if so, no need for + * default. + */ +async function getDefaultPullDivergentBranchArguments( + repository: Repository +): Promise> { + try { + const pullFF = await getConfigValue(repository, 'pull.ff') + return pullFF !== null ? [] : ['--ff'] + } catch (e) { + log.error("Couldn't read 'pull.ff' config", e) + } + + // If there is a failure in checking the config, we still want to use any + // config and not overwrite the user's set config behavior. This will show the + // git error if no config is set. + return [] +} diff --git a/app/src/lib/git/push.ts b/app/src/lib/git/push.ts new file mode 100644 index 0000000000..eb81701baf --- /dev/null +++ b/app/src/lib/git/push.ts @@ -0,0 +1,126 @@ +import { GitError as DugiteError } from 'dugite' + +import { + git, + IGitExecutionOptions, + gitNetworkArguments, + GitError, +} 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' + +export type PushOptions = { + /** + * Force-push the branch without losing changes in the remote that + * haven't been fetched. + * + * See https://git-scm.com/docs/git-push#Documentation/git-push.txt---no-force-with-lease + */ + readonly forceWithLease: boolean +} + +/** + * Push from the remote to the branch, optionally setting the upstream. + * + * @param repository - The repository from which to push + * + * @param account - The account to use when authenticating with the remote + * + * @param remote - The remote to push the specified branch to + * + * @param localBranch - The local branch to push + * + * @param remoteBranch - The remote branch to push to + * + * @param tagsToPush - The tags to push along with the branch. + * + * @param options - Optional customizations for the push execution. + * see PushOptions for more information. + * + * @param progressCallback - An optional function which will be invoked + * with information about the current progress + * of the push operation. When provided this enables + * the '--progress' command line flag for + * 'git push'. + */ +export async function push( + repository: Repository, + account: IGitAccount | null, + remote: IRemote, + localBranch: string, + remoteBranch: string | null, + tagsToPush: ReadonlyArray | null, + options: PushOptions = { + forceWithLease: false, + }, + progressCallback?: (progress: IPushProgress) => void +): Promise { + const args = [ + ...gitNetworkArguments(), + 'push', + remote.name, + remoteBranch ? `${localBranch}:${remoteBranch}` : localBranch, + ] + + if (tagsToPush !== null) { + args.push(...tagsToPush) + } + if (!remoteBranch) { + args.push('--set-upstream') + } else if (options.forceWithLease === true) { + args.push('--force-with-lease') + } + + const expectedErrors = new Set(AuthenticationErrors) + expectedErrors.add(DugiteError.ProtectedBranchForcePush) + + let opts: IGitExecutionOptions = { + env: await envForRemoteOperation(account, remote.url), + expectedErrors, + } + + if (progressCallback) { + args.push('--progress') + const title = `Pushing to ${remote.name}` + const kind = 'push' + + opts = await executionOptionsWithProgress( + { ...opts, trackLFSProgress: true }, + new PushProgressParser(), + progress => { + const description = + progress.kind === 'progress' ? progress.details.text : progress.text + const value = progress.percent + + progressCallback({ + kind, + title, + description, + value, + remote: remote.name, + branch: localBranch, + }) + } + ) + + // Initial progress + progressCallback({ + kind: 'push', + title, + value: 0, + remote: remote.name, + branch: localBranch, + }) + } + + const result = await git(args, repository.path, 'push', opts) + + if (result.gitErrorDescription) { + throw new GitError(result, args) + } +} diff --git a/app/src/lib/git/rebase.ts b/app/src/lib/git/rebase.ts new file mode 100644 index 0000000000..5e84cca5bf --- /dev/null +++ b/app/src/lib/git/rebase.ts @@ -0,0 +1,588 @@ +import * as Path from 'path' +import { ChildProcess } from 'child_process' +import { GitError } from 'dugite' +import byline from 'byline' + +import { Repository } from '../../models/repository' +import { RebaseInternalState, RebaseProgressOptions } from '../../models/rebase' +import { IMultiCommitOperationProgress } from '../../models/progress' +import { + WorkingDirectoryFileChange, + AppFileStatusKind, +} from '../../models/status' +import { ManualConflictResolution } from '../../models/manual-conflict-resolution' +import { Commit, CommitOneLine } from '../../models/commit' + +import { merge } from '../merge' +import { formatRebaseValue } from '../rebase' + +import { + git, + IGitResult, + IGitExecutionOptions, + gitRebaseArguments, +} from './core' +import { stageManualConflictResolution } from './stage' +import { stageFiles } from './update-index' +import { getStatus } from './status' +import { getCommitsBetweenCommits } from './rev-list' +import { Branch } from '../../models/branch' +import { readFile } from 'fs/promises' +import { pathExists } from '../../ui/lib/path-exists' + +/** The app-specific results from attempting to rebase a repository */ +export enum RebaseResult { + /** + * Git completed the rebase without reporting any errors, and the caller can + * signal success to the user. + */ + CompletedWithoutError = 'CompletedWithoutError', + /** + * The rebase encountered conflicts while attempting to rebase, and these + * need to be resolved by the user before the rebase can continue. + */ + ConflictsEncountered = 'ConflictsEncountered', + /** + * The rebase was not able to continue as tracked files were not staged in + * the index. + */ + OutstandingFilesNotStaged = 'OutstandingFilesNotStaged', + /** + * The rebase was not attempted because it could not check the status of the + * repository. The caller needs to confirm the repository is in a usable + * state. + */ + Aborted = 'Aborted', + /** + * An unexpected error as part of the rebase flow was caught and handled. + * + * Check the logs to find the relevant Git details. + */ + Error = 'Error', +} + +/** + * Check the `.git/REBASE_HEAD` file exists in a repository to confirm + * a rebase operation is underway. + */ +function isRebaseHeadSet(repository: Repository) { + const path = Path.join(repository.path, '.git', 'REBASE_HEAD') + return pathExists(path) +} + +/** + * Get the internal state about the rebase being performed on a repository. This + * information is required to help Desktop display information to the user + * about the current action as well as the options available. + * + * Returns `null` if no rebase is detected, or if the expected information + * cannot be found in the repository. + */ +export async function getRebaseInternalState( + repository: Repository +): Promise { + const isRebase = await isRebaseHeadSet(repository) + + if (!isRebase) { + return null + } + + let originalBranchTip: string | null = null + let targetBranch: string | null = null + let baseBranchTip: string | null = null + + try { + originalBranchTip = await readFile( + Path.join(repository.path, '.git', 'rebase-merge', 'orig-head'), + 'utf8' + ) + + originalBranchTip = originalBranchTip.trim() + + targetBranch = await readFile( + Path.join(repository.path, '.git', 'rebase-merge', 'head-name'), + 'utf8' + ) + + if (targetBranch.startsWith('refs/heads/')) { + targetBranch = targetBranch.substring(11).trim() + } + + baseBranchTip = await readFile( + Path.join(repository.path, '.git', 'rebase-merge', 'onto'), + 'utf8' + ) + + baseBranchTip = baseBranchTip.trim() + } catch {} + + if ( + originalBranchTip != null && + targetBranch != null && + baseBranchTip != null + ) { + return { originalBranchTip, targetBranch, baseBranchTip } + } + + // unable to resolve the rebase state of this repository + + return null +} + +/** + * Inspect the `.git/rebase-merge` folder and convert the current rebase state + * into data that can be provided to the rebase flow to update the application + * state. + * + * This is required when Desktop is not responsible for initiating the rebase: + * + * - when a rebase outside Desktop encounters conflicts + * - when a `git pull --rebase` was run and encounters conflicts + * + */ +export async function getRebaseSnapshot(repository: Repository): Promise<{ + progress: IMultiCommitOperationProgress + commits: ReadonlyArray +} | null> { + const rebaseHead = await isRebaseHeadSet(repository) + if (!rebaseHead) { + return null + } + + let next: number = -1 + let last: number = -1 + let originalBranchTip: string | null = null + let baseBranchTip: string | null = null + + // if the repository is in the middle of a rebase `.git/rebase-merge` will + // contain all the patches of commits that are being rebased into + // auto-incrementing files, e.g. `0001`, `0002`, `0003`, etc ... + + try { + // this contains the patch number that was recently applied to the repository + const nextText = await readFile( + Path.join(repository.path, '.git', 'rebase-merge', 'msgnum'), + 'utf8' + ) + + next = parseInt(nextText, 10) + + if (isNaN(next)) { + log.warn( + `[getCurrentProgress] found '${nextText}' in .git/rebase-merge/msgnum which could not be parsed to a valid number` + ) + next = -1 + } + + // this contains the total number of patches to be applied to the repository + const lastText = await readFile( + Path.join(repository.path, '.git', 'rebase-merge', 'end'), + 'utf8' + ) + + last = parseInt(lastText, 10) + + if (isNaN(last)) { + log.warn( + `[getCurrentProgress] found '${lastText}' in .git/rebase-merge/last which could not be parsed to a valid number` + ) + last = -1 + } + + originalBranchTip = await readFile( + Path.join(repository.path, '.git', 'rebase-merge', 'orig-head'), + 'utf8' + ) + + originalBranchTip = originalBranchTip.trim() + + baseBranchTip = await readFile( + Path.join(repository.path, '.git', 'rebase-merge', 'onto'), + 'utf8' + ) + + baseBranchTip = baseBranchTip.trim() + } catch {} + + if ( + next > 0 && + last > 0 && + originalBranchTip !== null && + baseBranchTip !== null + ) { + const percentage = next / last + const value = formatRebaseValue(percentage) + + const commits = await getCommitsBetweenCommits( + repository, + baseBranchTip, + originalBranchTip + ) + + if (commits === null || commits.length === 0) { + return null + } + + // this number starts from 1, but our array of commits starts from 0 + const nextCommitIndex = next - 1 + + const hasValidCommit = + commits.length > 0 && + nextCommitIndex >= 0 && + nextCommitIndex < commits.length + + const currentCommitSummary = hasValidCommit + ? commits[nextCommitIndex].summary + : '' + + return { + progress: { + kind: 'multiCommitOperation', + value, + position: next, + totalCommitCount: last, + currentCommitSummary, + }, + commits, + } + } + + return null +} + +/** + * Attempt to read the `.git/REBASE_HEAD` file inside a repository to confirm + * the rebase is still active. + */ +async function readRebaseHead(repository: Repository): Promise { + try { + const rebaseHead = Path.join(repository.path, '.git', 'REBASE_HEAD') + const rebaseCurrentCommitOutput = await readFile(rebaseHead, 'utf8') + return rebaseCurrentCommitOutput.trim() + } catch (err) { + log.warn( + '[rebase] a problem was encountered reading .git/REBASE_HEAD, so it is unsafe to continue rebasing', + err + ) + return null + } +} + +/** Regex for identifying when rebase applied each commit onto the base branch */ +const rebasingRe = /^Rebasing \((\d+)\/(\d+)\)$/ + +/** + * A parser to read and emit rebase progress from Git `stderr` + */ +class GitRebaseParser { + public constructor(private readonly commits: ReadonlyArray) {} + + public parse(line: string): IMultiCommitOperationProgress | null { + const match = rebasingRe.exec(line) + if (match === null || match.length !== 3) { + // Git will sometimes emit other output (for example, when it tries to + // resolve conflicts) and this does not match the expected output + return null + } + + const rebasedCommitCount = parseInt(match[1], 10) + const totalCommitCount = parseInt(match[2], 10) + + if (isNaN(rebasedCommitCount) || isNaN(totalCommitCount)) { + return null + } + + const currentCommitSummary = + this.commits[rebasedCommitCount - 1]?.summary ?? '' + + const progress = rebasedCommitCount / totalCommitCount + const value = formatRebaseValue(progress) + + return { + kind: 'multiCommitOperation', + value, + position: rebasedCommitCount, + totalCommitCount: totalCommitCount, + currentCommitSummary, + } + } +} + +function configureOptionsForRebase( + options: IGitExecutionOptions, + progress?: RebaseProgressOptions +) { + if (progress === undefined) { + return options + } + + const { commits, progressCallback } = progress + + return merge(options, { + processCallback: (process: ChildProcess) => { + // If Node.js encounters a synchronous runtime error while spawning + // `stderr` will be undefined and the error will be emitted asynchronously + if (process.stderr === null) { + return + } + const parser = new GitRebaseParser(commits) + + byline(process.stderr).on('data', (line: string) => { + const progress = parser.parse(line) + + if (progress != null) { + progressCallback(progress) + } + }) + }, + }) +} + +/** + * A stub function to use for initiating rebase in the app. + * + * If the rebase fails, the repository will be in an indeterminate state where + * the rebase is stuck. + * + * If the rebase completes without error, `featureBranch` will be checked out + * and it will probably have a different commit history. + * + * @param baseBranch the ref to start the rebase from + * @param targetBranch the ref to rebase onto `baseBranch` + */ +export async function rebase( + repository: Repository, + baseBranch: Branch, + targetBranch: Branch, + progressCallback?: (progress: IMultiCommitOperationProgress) => void +): Promise { + const baseOptions: IGitExecutionOptions = { + expectedErrors: new Set([GitError.RebaseConflicts]), + } + + let options = baseOptions + + if (progressCallback !== undefined) { + const commits = await getCommitsBetweenCommits( + repository, + baseBranch.tip.sha, + targetBranch.tip.sha + ) + + if (commits === null) { + // BadRevision can be raised here if git rev-list is unable to resolve a + // ref to a commit ID, so we need to signal to the caller that this rebase + // is not possible to perform + log.warn( + 'Unable to rebase these branches because one or both of the refs do not exist in the repository' + ) + return RebaseResult.Error + } + + options = configureOptionsForRebase(baseOptions, { + commits, + progressCallback, + }) + } + + const result = await git( + [...gitRebaseArguments(), 'rebase', baseBranch.name, targetBranch.name], + repository.path, + 'rebase', + options + ) + + return parseRebaseResult(result) +} + +/** Abandon the current rebase operation */ +export async function abortRebase(repository: Repository) { + await git(['rebase', '--abort'], repository.path, 'abortRebase') +} + +function parseRebaseResult(result: IGitResult): RebaseResult { + if (result.exitCode === 0) { + return RebaseResult.CompletedWithoutError + } + + if (result.gitError === GitError.RebaseConflicts) { + return RebaseResult.ConflictsEncountered + } + + if (result.gitError === GitError.UnresolvedConflicts) { + return RebaseResult.OutstandingFilesNotStaged + } + + throw new Error(`Unhandled result found: '${JSON.stringify(result)}'`) +} + +/** + * Proceed with the current rebase operation and report back on whether it completed + * + * It is expected that the index has staged files which are cleanly rebased onto + * the base branch, and the remaining unstaged files are those which need manual + * resolution or were changed by the user to address inline conflicts. + * + */ +export async function continueRebase( + repository: Repository, + files: ReadonlyArray, + manualResolutions: ReadonlyMap = new Map(), + progressCallback?: (progress: IMultiCommitOperationProgress) => void, + gitEditor: string = ':' +): Promise { + const trackedFiles = files.filter(f => { + return f.status.kind !== AppFileStatusKind.Untracked + }) + + // apply conflict resolutions + for (const [path, resolution] of manualResolutions) { + const file = files.find(f => f.path === path) + if (file !== undefined) { + await stageManualConflictResolution(repository, file, resolution) + } else { + log.error( + `[continueRebase] couldn't find file ${path} even though there's a manual resolution for it` + ) + } + } + + const otherFiles = trackedFiles.filter(f => !manualResolutions.has(f.path)) + + await stageFiles(repository, otherFiles) + + const status = await getStatus(repository) + if (status == null) { + log.warn( + `[continueRebase] unable to get status after staging changes, skipping any other steps` + ) + return RebaseResult.Aborted + } + + const rebaseCurrentCommit = await readRebaseHead(repository) + if (rebaseCurrentCommit === null) { + return RebaseResult.Aborted + } + + const trackedFilesAfter = status.workingDirectory.files.filter( + f => f.status.kind !== AppFileStatusKind.Untracked + ) + + const baseOptions: IGitExecutionOptions = { + expectedErrors: new Set([ + GitError.RebaseConflicts, + GitError.UnresolvedConflicts, + ]), + env: { + GIT_EDITOR: gitEditor, + }, + } + + let options = baseOptions + + if (progressCallback !== undefined) { + const snapshot = await getRebaseSnapshot(repository) + + if (snapshot === null) { + log.warn( + `[continueRebase] unable to get rebase status, skipping any other steps` + ) + return RebaseResult.Aborted + } + + options = configureOptionsForRebase(baseOptions, { + commits: snapshot.commits, + progressCallback, + }) + } + + if (trackedFilesAfter.length === 0) { + log.warn( + `[rebase] no tracked changes to commit for ${rebaseCurrentCommit}, continuing rebase but skipping this commit` + ) + + const result = await git( + ['rebase', '--skip'], + repository.path, + 'continueRebaseSkipCurrentCommit', + options + ) + + return parseRebaseResult(result) + } + + const result = await git( + ['rebase', '--continue'], + repository.path, + 'continueRebase', + options + ) + + return parseRebaseResult(result) +} + +/** + * Method for initiating interactive rebase in the app. + * + * In order to modify the interactive todo list during interactive rebase, we + * create a temporary todo list of our own. Pass that file's path into our + * interactive rebase and using the sequence.editor to cat replace the + * interactive todo list with the contents of our generated one. + * + * @param pathOfGeneratedTodo path to generated todo list for interactive rebase + * @param lastRetainedCommitRef the commit before the earliest commit to be + * changed during the interactive rebase or null if commit is root (first commit + * in history) of branch + * @param action a description of the action to be displayed in the progress + * dialog - i.e. Squash, Amend, etc.. + */ +export async function rebaseInteractive( + repository: Repository, + pathOfGeneratedTodo: string, + lastRetainedCommitRef: string | null, + action: string = 'Interactive rebase', + gitEditor: string = ':', + progressCallback?: (progress: IMultiCommitOperationProgress) => void, + commits?: ReadonlyArray +): Promise { + const baseOptions: IGitExecutionOptions = { + expectedErrors: new Set([GitError.RebaseConflicts]), + env: { + GIT_EDITOR: gitEditor, + }, + } + + let options = baseOptions + + if (progressCallback !== undefined) { + if (commits === undefined) { + log.warn(`Unable to interactively rebase if no commits`) + return RebaseResult.Error + } + + options = configureOptionsForRebase(baseOptions, { + commits, + progressCallback, + }) + } + + /* If the commit is the first commit in the branch, we cannot reference it + using the sha thus if lastRetainedCommitRef is null (we couldn't define it), + we must use the --root flag */ + const ref = lastRetainedCommitRef == null ? '--root' : lastRetainedCommitRef + const result = await git( + [ + '-c', + // This replaces interactive todo with contents of file at pathOfGeneratedTodo + `sequence.editor=cat "${pathOfGeneratedTodo}" >`, + 'rebase', + '-i', + ref, + ], + repository.path, + action, + options + ) + + return parseRebaseResult(result) +} diff --git a/app/src/lib/git/reflog.ts b/app/src/lib/git/reflog.ts new file mode 100644 index 0000000000..05bd1967a1 --- /dev/null +++ b/app/src/lib/git/reflog.ts @@ -0,0 +1,127 @@ +import { git } from './core' +import { Repository } from '../../models/repository' + +/** + * Get the `limit` most recently checked out branches. + */ +export async function getRecentBranches( + repository: Repository, + limit: number +): Promise> { + // "git reflog show" is just an alias for "git log -g --abbrev-commit --pretty=oneline" + // but by using log we can give it a max number which should prevent us from balling out + // of control when there's ginormous reflogs around (as in e.g. github/github). + const regex = new RegExp( + /.*? (renamed|checkout)(?:: moving from|\s*) (?:refs\/heads\/|\s*)(.*?) to (?:refs\/heads\/|\s*)(.*?)$/i + ) + + const result = await git( + [ + 'log', + '-g', + '--no-abbrev-commit', + '--pretty=oneline', + 'HEAD', + '-n', + '2500', + '--', + ], + repository.path, + 'getRecentBranches', + { successExitCodes: new Set([0, 128]) } + ) + + if (result.exitCode === 128) { + // error code 128 is returned if the branch is unborn + return [] + } + + const lines = result.stdout.split('\n') + const names = new Set() + const excludedNames = new Set() + + for (const line of lines) { + const result = regex.exec(line) + if (result && result.length === 4) { + const operationType = result[1] + const excludeBranchName = result[2] + const branchName = result[3] + + if (operationType === 'renamed') { + // exclude intermediate-state renaming branch from recent branches + excludedNames.add(excludeBranchName) + } + + if (!excludedNames.has(branchName)) { + names.add(branchName) + } + } + + if (names.size === limit) { + break + } + } + + return [...names] +} + +const noCommitsOnBranchRe = new RegExp( + "fatal: your current branch '.*' does not have any commits yet" +) + +/** + * Gets the distinct list of branches that have been checked out after a specific date + * Returns a map keyed on branch names + * + * @param repository the repository who's reflog you want to check + * @param afterDate filters checkouts so that only those occurring on or after this date are returned + * @returns map of branch name -> checkout date + */ +export async function getBranchCheckouts( + repository: Repository, + afterDate: Date +): Promise> { + //regexr.com/46n1v + const regex = new RegExp( + /^[a-z0-9]{40}\sHEAD@{(.*)}\scheckout: moving from\s.*\sto\s(.*)$/ + ) + const result = await git( + [ + 'reflog', + '--date=iso', + `--after="${afterDate.toISOString()}"`, + '--pretty=%H %gd %gs', + `--grep-reflog=checkout: moving from .* to .*$`, + '--', + ], + repository.path, + 'getCheckoutsAfterDate', + { successExitCodes: new Set([0, 128]) } + ) + + const checkouts = new Map() + + // edge case where orphaned branch is created but Git raises error when + // reading the reflog on this new branch as it has no commits + // + // see https://github.com/desktop/desktop/issues/7983 for more information + if (result.exitCode === 128 && noCommitsOnBranchRe.test(result.stderr)) { + return checkouts + } + + const lines = result.stdout.split('\n') + for (const line of lines) { + const parsedLine = regex.exec(line) + + if (parsedLine === null || parsedLine.length !== 3) { + continue + } + + const [, timestamp, branchName] = parsedLine + if (!checkouts.has(branchName)) { + checkouts.set(branchName, new Date(timestamp)) + } + } + + return checkouts +} diff --git a/app/src/lib/git/refs.ts b/app/src/lib/git/refs.ts new file mode 100644 index 0000000000..c676ed8796 --- /dev/null +++ b/app/src/lib/git/refs.ts @@ -0,0 +1,63 @@ +import { git } from './core' +import { Repository } from '../../models/repository' + +/** + * Format a local branch in the ref syntax, ensuring situations when the branch + * is ambiguous are handled. + * + * Examples: + * - main -> refs/heads/main + * - heads/Microsoft/main -> refs/heads/Microsoft/main + * + * @param branch The local branch name + */ +export function formatAsLocalRef(name: string): string { + if (name.startsWith('heads/')) { + // In some cases, Git will report this name explicitly to distinguish from + // a remote ref with the same name - this ensures we format it correctly. + return `refs/${name}` + } else if (!name.startsWith('refs/heads/')) { + // By default Git will drop the heads prefix unless absolutely necessary + // - include this to ensure the ref is fully qualified. + return `refs/heads/${name}` + } else { + return name + } +} + +/** + * Read a symbolic ref from the repository. + * + * Symbolic refs are used to point to other refs, similar to how symlinks work + * for files. Because refs can be removed easily from a Git repository, + * symbolic refs should only be used when absolutely necessary. + * + * @param repository The repository to lookup + * @param ref The symbolic ref to resolve + * + * @returns the canonical ref, if found, or `null` if `ref` cannot be found or + * is not a symbolic ref + */ +export async function getSymbolicRef( + repository: Repository, + ref: string +): Promise { + const result = await git( + ['symbolic-ref', '-q', ref], + repository.path, + 'getSymbolicRef', + { + // - 1 is the exit code that Git throws in quiet mode when the ref is not a + // symbolic ref + // - 128 is the generic error code that Git returns when it can't find + // something + successExitCodes: new Set([0, 1, 128]), + } + ) + + if (result.exitCode === 1 || result.exitCode === 128) { + return null + } + + return result.stdout.trim() +} diff --git a/app/src/lib/git/remote.ts b/app/src/lib/git/remote.ts new file mode 100644 index 0000000000..dd3d3a7625 --- /dev/null +++ b/app/src/lib/git/remote.ts @@ -0,0 +1,135 @@ +import { git } from './core' +import { GitError } from 'dugite' + +import { Repository } from '../../models/repository' +import { IRemote } from '../../models/remote' +import { envForRemoteOperation } from './environment' +import { IGitAccount } from '../../models/git-account' +import { getSymbolicRef } from './refs' +import { gitNetworkArguments } from '.' + +/** + * List the remotes, sorted alphabetically by `name`, for a repository. + */ +export async function getRemotes( + repository: Repository +): Promise> { + const result = await git(['remote', '-v'], repository.path, 'getRemotes', { + expectedErrors: new Set([GitError.NotAGitRepository]), + }) + + if (result.gitError === GitError.NotAGitRepository) { + return [] + } + + const output = result.stdout + const lines = output.split('\n') + const remotes = lines + .filter(x => /\(fetch\)( \[.+\])?$/.test(x)) + .map(x => x.split(/\s+/)) + .map(x => ({ name: x[0], url: x[1] })) + + return remotes +} + +/** Add a new remote with the given URL. */ +export async function addRemote( + repository: Repository, + name: string, + url: string +): Promise { + await git(['remote', 'add', name, url], repository.path, 'addRemote') + + return { url, name } +} + +/** Removes an existing remote, or silently errors if it doesn't exist */ +export async function removeRemote( + repository: Repository, + name: string +): Promise { + const options = { + successExitCodes: new Set([0, 2, 128]), + } + + await git( + ['remote', 'remove', name], + repository.path, + 'removeRemote', + options + ) +} + +/** Changes the URL for the remote that matches the given name */ +export async function setRemoteURL( + repository: Repository, + name: string, + url: string +): Promise { + await git(['remote', 'set-url', name, url], repository.path, 'setRemoteURL') + return true +} + +/** + * Get the URL for the remote that matches the given name. + * + * Returns null if the remote could not be found + */ +export async function getRemoteURL( + repository: Repository, + name: string +): Promise { + const result = await git( + ['remote', 'get-url', name], + repository.path, + 'getRemoteURL', + { successExitCodes: new Set([0, 2, 128]) } + ) + + if (result.exitCode !== 0) { + return null + } + + return result.stdout +} + +/** + * Update the HEAD ref of the remote, which is the default branch. + */ +export async function updateRemoteHEAD( + repository: Repository, + account: IGitAccount | null, + remote: IRemote +): Promise { + const options = { + successExitCodes: new Set([0, 1, 128]), + env: await envForRemoteOperation(account, remote.url), + } + + await git( + [...gitNetworkArguments(), '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/reorder.ts b/app/src/lib/git/reorder.ts new file mode 100644 index 0000000000..75b95c40f1 --- /dev/null +++ b/app/src/lib/git/reorder.ts @@ -0,0 +1,152 @@ +import { appendFile, rm } from 'fs/promises' +import { getCommits, revRange } from '.' +import { Commit } from '../../models/commit' +import { MultiCommitOperationKind } from '../../models/multi-commit-operation' +import { IMultiCommitOperationProgress } from '../../models/progress' +import { Repository } from '../../models/repository' +import { getTempFilePath } from '../file-system' +import { rebaseInteractive, RebaseResult } from './rebase' + +/** + * Reorders provided commits by calling interactive rebase. + * + * Goal is to replay the commits in order from oldest to newest to reduce + * conflicts with toMove commits placed in the log at the location of the + * prior to the base commit. + * + * Example: A user's history from oldest to newest is A, B, C, D, E and they + * want to move A and E (toMove) before C. Our goal: B, A, E, C, D. Thus, + * maintaining that A came before E, placed in history before the the base + * commit C. + * + * @param toMove - commits to move + * @param beforeCommit - commits will be moved right before this commit. If it's + * null, the commits will be moved to the end of the history. + * @param lastRetainedCommitRef - sha of commit before commits to reorder or null + * if base commit for reordering is the root (first in history) of the branch + */ +export async function reorder( + repository: Repository, + toMove: ReadonlyArray, + beforeCommit: Commit | null, + lastRetainedCommitRef: string | null, + progressCallback?: (progress: IMultiCommitOperationProgress) => void +): Promise { + let todoPath + let result: RebaseResult + + try { + if (toMove.length === 0) { + throw new Error('[reorder] No commits provided to reorder.') + } + + const toMoveShas = new Set(toMove.map(c => c.sha)) + + const commits = await getCommits( + repository, + lastRetainedCommitRef === null + ? undefined + : revRange(lastRetainedCommitRef, 'HEAD') + ) + + if (commits.length === 0) { + throw new Error( + '[reorder] Could not find commits in log for last retained commit ref.' + ) + } + + todoPath = await getTempFilePath('reorderTodo') + let foundBaseCommitInLog = false + const toReplayBeforeBaseCommit = [] + const toReplayAfterReorder = [] + + // Traversed in reverse so we do oldest to newest (replay commits) + for (let i = commits.length - 1; i >= 0; i--) { + const commit = commits[i] + if (toMoveShas.has(commit.sha)) { + // If it is toMove commit and we have found the base commit, we + // can go ahead and insert them (as we will hold any picks till after) + if (foundBaseCommitInLog) { + await appendFile(todoPath, `pick ${commit.sha} ${commit.summary}\n`) + } else { + // However, if we have not found the base commit yet we want to + // keep track of them in the order of the log. Thus, we use a new + // `toReplayBeforeBaseCommit` array and not trust that what was sent is in the + // order of the log. + toReplayBeforeBaseCommit.push(commit) + } + + continue + } + + // If it's the base commit, replay to the toMove in the order they + // appeared on the log to reduce potential conflicts. + if (beforeCommit !== null && commit.sha === beforeCommit.sha) { + foundBaseCommitInLog = true + toReplayAfterReorder.push(commit) + + for (let j = 0; j < toReplayBeforeBaseCommit.length; j++) { + await appendFile( + todoPath, + `pick ${toReplayBeforeBaseCommit[j].sha} ${toReplayBeforeBaseCommit[j].summary}\n` + ) + } + + continue + } + + // We can't just replay a pick in case there is a commit from the toMove + // commits further up in history that need to be moved. Thus, we will keep + // track of these and replay after traversing the remainder of the log. + if (foundBaseCommitInLog) { + toReplayAfterReorder.push(commit) + continue + } + + // If it is not one toMove nor the base commit and have not found the base + // commit, we simply record it is an unchanged pick (before the base commit) + await appendFile(todoPath, `pick ${commit.sha} ${commit.summary}\n`) + } + + if (toReplayAfterReorder.length > 0) { + for (let i = 0; i < toReplayAfterReorder.length; i++) { + await appendFile( + todoPath, + `pick ${toReplayAfterReorder[i].sha} ${toReplayAfterReorder[i].summary}\n` + ) + } + } + + if (beforeCommit === null) { + for (let i = 0; i < toReplayBeforeBaseCommit.length; i++) { + await appendFile( + todoPath, + `pick ${toReplayBeforeBaseCommit[i].sha} ${toReplayBeforeBaseCommit[i].summary}\n` + ) + } + } else if (!foundBaseCommitInLog) { + throw new Error( + '[reorder] The base commit onto was not in the log. Continuing would result in dropping the commits in the toMove array.' + ) + } + + result = await rebaseInteractive( + repository, + todoPath, + lastRetainedCommitRef, + MultiCommitOperationKind.Reorder, + undefined, + progressCallback, + commits + ) + } catch (e) { + log.error(e) + return RebaseResult.Error + } finally { + if (todoPath !== undefined) { + await rm(todoPath, { recursive: true, force: true }) + } + } + + return result +} diff --git a/app/src/lib/git/reset.ts b/app/src/lib/git/reset.ts new file mode 100644 index 0000000000..09dc1e6d5b --- /dev/null +++ b/app/src/lib/git/reset.ts @@ -0,0 +1,101 @@ +import { git } from './core' +import { Repository } from '../../models/repository' +import { assertNever } from '../fatal-error' + +/** The reset modes which are supported. */ +export const enum GitResetMode { + /** + * Resets the index and working tree. Any changes to tracked files in the + * working tree since are discarded. + */ + Hard = 0, + /** + * Does not touch the index file or the working tree at all (but resets the + * head to , just like all modes do). This leaves all your changed + * files "Changes to be committed", as git status would put it. + */ + Soft, + + /** + * Resets the index but not the working tree (i.e., the changed files are + * preserved but not marked for commit) and reports what has not been updated. + * This is the default action for git reset. + */ + Mixed, +} + +function resetModeToArgs(mode: GitResetMode, ref: string): string[] { + switch (mode) { + case GitResetMode.Hard: + return ['reset', '--hard', ref] + case GitResetMode.Mixed: + return ['reset', ref] + case GitResetMode.Soft: + return ['reset', '--soft', ref] + default: + return assertNever(mode, `Unknown reset mode: ${mode}`) + } +} + +/** Reset with the mode to the ref. */ +export async function reset( + repository: Repository, + mode: GitResetMode, + ref: string +): Promise { + const args = resetModeToArgs(mode, ref) + await git(args, repository.path, 'reset') + return true +} + +/** + * Updates the index with information from a particular tree for a given + * set of paths. + * + * @param repository The repository in which to reset the index. + * + * @param mode Which mode to use when resetting, see the GitResetMode + * enum for more information. + * + * @param ref A string which resolves to a tree, for example 'HEAD' or a + * commit sha. + * + * @param paths The paths that should be updated in the index with information + * from the given tree + */ +export async function resetPaths( + repository: Repository, + mode: GitResetMode, + ref: string, + paths: ReadonlyArray +): Promise { + if (!paths.length) { + return + } + + const baseArgs = resetModeToArgs(mode, ref) + + if (__WIN32__ && mode === GitResetMode.Mixed) { + // Git for Windows has experimental support for reading paths to reset + // from standard input. This is helpful in situations where your file + // paths are greater than 32KB in length, because of shell limitations. + // + // This hasn't made it to Git core, so we fallback to the default behaviour + // as macOS and Linux don't have this same shell limitation. See + // https://github.com/desktop/desktop/issues/2833#issuecomment-331352952 + // for more context. + const args = [...baseArgs, '--stdin', '-z', '--'] + await git(args, repository.path, 'resetPaths', { + stdin: paths.join('\0'), + }) + } else { + const args = [...baseArgs, '--', ...paths] + await git(args, repository.path, 'resetPaths') + } +} + +/** Unstage all paths. */ +export async function unstageAll(repository: Repository): Promise { + await git(['reset', '--', '.'], repository.path, 'unstageAll') + return true +} diff --git a/app/src/lib/git/rev-list.ts b/app/src/lib/git/rev-list.ts new file mode 100644 index 0000000000..6e87fe31c2 --- /dev/null +++ b/app/src/lib/git/rev-list.ts @@ -0,0 +1,184 @@ +import { GitError } from 'dugite' +import { git } from './core' +import { Repository } from '../../models/repository' +import { Branch, BranchType, IAheadBehind } from '../../models/branch' +import { CommitOneLine } from '../../models/commit' + +/** + * Convert two refs into the Git range syntax representing the set of commits + * that are reachable from `to` but excluding those that are reachable from + * `from`. This will not be inclusive to the `from` ref, see + * `revRangeInclusive`. + * + * Each parameter can be the commit SHA or a ref name, or specify an empty + * string to represent HEAD. + * + * @param from The start of the range + * @param to The end of the range + */ +export function revRange(from: string, to: string) { + return `${from}..${to}` +} + +/** + * Convert two refs into the Git range syntax representing the set of commits + * that are reachable from `to` but excluding those that are reachable from + * `from`. However as opposed to `revRange`, this will also include `from` ref. + * + * Each parameter can be the commit SHA or a ref name, or specify an empty + * string to represent HEAD. + * + * @param from The start of the range + * @param to The end of the range + */ +export function revRangeInclusive(from: string, to: string) { + return `${from}^..${to}` +} + +/** + * Convert two refs into the Git symmetric difference syntax, which represents + * the set of commits that are reachable from either `from` or `to` but not + * from both. + * + * Each parameter can be the commit SHA or a ref name, or you can use an empty + * string to represent HEAD. + * + * @param from The start of the range + * @param to The end of the range + */ +export function revSymmetricDifference(from: string, to: string) { + return `${from}...${to}` +} + +/** Calculate the number of commits the range is ahead and behind. */ +export async function getAheadBehind( + repository: Repository, + range: string +): Promise { + // `--left-right` annotates the list of commits in the range with which side + // they're coming from. When used with `--count`, it tells us how many + // commits we have from the two different sides of the range. + const args = ['rev-list', '--left-right', '--count', range, '--'] + const result = await git(args, repository.path, 'getAheadBehind', { + expectedErrors: new Set([GitError.BadRevision]), + }) + + // This means one of the refs (most likely the upstream branch) no longer + // exists. In that case we can't be ahead/behind at all. + if (result.gitError === GitError.BadRevision) { + return null + } + + const stdout = result.stdout + const pieces = stdout.split('\t') + if (pieces.length !== 2) { + return null + } + + const ahead = parseInt(pieces[0], 10) + if (isNaN(ahead)) { + return null + } + + const behind = parseInt(pieces[1], 10) + if (isNaN(behind)) { + return null + } + + return { ahead, behind } +} + +/** Calculate the number of commits `branch` is ahead/behind its upstream. */ +export async function getBranchAheadBehind( + repository: Repository, + branch: Branch +): Promise { + if (branch.type === BranchType.Remote) { + return null + } + + const upstream = branch.upstream + if (!upstream) { + return null + } + + // NB: The three dot form means we'll go all the way back to the merge base + // of the branch and its upstream. Practically this is important for seeing + // "through" merges. + const range = revSymmetricDifference(branch.name, upstream) + return getAheadBehind(repository, range) +} + +/** + * Get a list of commits from the target branch that do not exist on the base + * branch, ordered how they will be applied to the base branch. + * Therefore, this will not include the baseBranchSha commit. + * + * This emulates how `git rebase` initially determines what will be applied to + * the repository. + * + * Returns `null` when the rebase is not possible to perform, because of a + * missing commit ID + */ +export async function getCommitsBetweenCommits( + repository: Repository, + baseBranchSha: string, + targetBranchSha: string +): Promise | null> { + const range = revRange(baseBranchSha, targetBranchSha) + + return getCommitsInRange(repository, range) +} + +/** + * Get a list of commits inside the provided range. + * + * Returns `null` when it is not possible to perform because of a bad range. + */ +export async function getCommitsInRange( + repository: Repository, + range: string +): Promise | null> { + const args = [ + 'rev-list', + range, + '--reverse', + // the combination of these two arguments means each line of the stdout + // will contain the full commit sha and a commit summary + `--oneline`, + `--no-abbrev-commit`, + '--', + ] + + const options = { + expectedErrors: new Set([GitError.BadRevision]), + } + + const result = await git(args, repository.path, 'getCommitsInRange', options) + + if (result.gitError === GitError.BadRevision) { + return null + } + + const lines = result.stdout.split('\n') + + const commits = new Array() + + const commitSummaryRe = /^([a-z0-9]{40}) (.*)$/ + + for (const line of lines) { + const match = commitSummaryRe.exec(line) + + if (match !== null && match.length === 3) { + const sha = match[1] + const summary = match[2] + + commits.push({ + sha, + summary, + }) + } + } + + return commits +} diff --git a/app/src/lib/git/rev-parse.ts b/app/src/lib/git/rev-parse.ts new file mode 100644 index 0000000000..171c27fa0c --- /dev/null +++ b/app/src/lib/git/rev-parse.ts @@ -0,0 +1,58 @@ +import { git } from './core' +import { directoryExists } from '../directory-exists' +import { resolve } from 'path' + +export type RepositoryType = + | { kind: 'bare' } + | { kind: 'regular'; topLevelWorkingDirectory: string } + | { kind: 'missing' } + | { kind: 'unsafe'; path: string } + +/** + * Attempts to fulfill the work of isGitRepository and isBareRepository while + * requiring only one Git process to be spawned. + * + * Returns 'bare', 'regular', or 'missing' if the repository couldn't be + * found. + */ +export async function getRepositoryType(path: string): Promise { + if (!(await directoryExists(path))) { + return { kind: 'missing' } + } + + try { + const result = await git( + ['rev-parse', '--is-bare-repository', '--show-cdup'], + path, + 'getRepositoryType', + { successExitCodes: new Set([0, 128]) } + ) + + if (result.exitCode === 0) { + const [isBare, cdup] = result.stdout.split('\n', 2) + + return isBare === 'true' + ? { kind: 'bare' } + : { kind: 'regular', topLevelWorkingDirectory: resolve(path, cdup) } + } + + const unsafeMatch = + /fatal: detected dubious ownership in repository at '(.+)'/.exec( + result.stderr + ) + if (unsafeMatch) { + return { kind: 'unsafe', path: unsafeMatch[1] } + } + + return { kind: 'missing' } + } catch (err) { + // This could theoretically mean that the Git executable didn't exist but + // in reality it's almost always going to be that the process couldn't be + // launched inside of `path` meaning it didn't exist. This would constitute + // a race condition given that we stat the path before executing Git. + if (err.code === 'ENOENT') { + return { kind: 'missing' } + } + throw err + } +} diff --git a/app/src/lib/git/revert.ts b/app/src/lib/git/revert.ts new file mode 100644 index 0000000000..cee54c28d9 --- /dev/null +++ b/app/src/lib/git/revert.ts @@ -0,0 +1,56 @@ +import { git, gitNetworkArguments, IGitExecutionOptions } 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' +import { + envForRemoteOperation, + getFallbackUrlForProxyResolve, +} from './environment' + +/** + * Creates a new commit that reverts the changes of a previous commit + * + * @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, + progressCallback?: (progress: IRevertProgress) => void +) { + const args = [...gitNetworkArguments(), 'revert'] + if (commit.parentSHAs.length > 1) { + args.push('-m', '1') + } + + args.push(commit.sha) + + let opts: IGitExecutionOptions = {} + if (progressCallback) { + const env = await envForRemoteOperation( + account, + getFallbackUrlForProxyResolve(account, repository) + ) + opts = await executionOptionsWithProgress( + { env, trackLFSProgress: true }, + new RevertProgressParser(), + progress => { + const description = + progress.kind === 'progress' ? progress.details.text : progress.text + const title = progress.kind === 'progress' ? progress.details.title : '' + const value = progress.percent + + progressCallback({ kind: 'revert', description, value, title }) + } + ) + } + + await git(args, repository.path, 'revert', opts) +} diff --git a/app/src/lib/git/rm.ts b/app/src/lib/git/rm.ts new file mode 100644 index 0000000000..0fb5821abd --- /dev/null +++ b/app/src/lib/git/rm.ts @@ -0,0 +1,31 @@ +import { git } from './core' +import { Repository } from '../../models/repository' +import { WorkingDirectoryFileChange } from '../../models/status' + +/** + * Remove all files from the index + * + * @param repository the repository to update + */ +export async function unstageAllFiles(repository: Repository): Promise { + await git( + // these flags are important: + // --cached to only remove files from the index + // -r to recursively remove files, in case files are in folders + // -f to ignore differences between working directory and index + // which will block this + ['rm', '--cached', '-r', '-f', '.'], + repository.path, + 'unstageAllFiles' + ) +} + +/** + * Remove conflicted file from working tree and index + */ +export async function removeConflictedFile( + repository: Repository, + file: WorkingDirectoryFileChange +) { + await git(['rm', '--', file.path], repository.path, 'removeConflictedFile') +} diff --git a/app/src/lib/git/show.ts b/app/src/lib/git/show.ts new file mode 100644 index 0000000000..ab9cc51a44 --- /dev/null +++ b/app/src/lib/git/show.ts @@ -0,0 +1,108 @@ +import { ChildProcess } from 'child_process' + +import { git } from './core' + +import { Repository } from '../../models/repository' +import { GitError } from 'dugite' + +/** + * Retrieve the binary contents of a blob from the repository at a given + * reference, commit, or tree. + * + * Returns a promise that will produce a Buffer instance containing + * the binary contents of the blob or an error if the file doesn't + * exists in the given revision. + * + * @param repository - The repository from where to read the blob + * + * @param commitish - A commit SHA or some other identifier that + * ultimately dereferences to a commit/tree. + * + * @param path - The file path, relative to the repository + * root from where to read the blob contents + */ +export async function 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') +} + +/** + * Retrieve some or all binary contents of a blob from the repository + * at a given reference, commit, or tree. This is almost identical + * to the getBlobContents method except that it supports only reading + * a maximum number of bytes. + * + * Returns a promise that will produce a Buffer instance containing + * the binary contents of the blob or an error if the file doesn't + * exists in the given revision. + * + * @param repository - The repository from where to read the blob + * + * @param commitish - A commit SHA or some other identifier that + * ultimately dereferences to a commit/tree. + * + * @param path - The file path, relative to the repository + * root from where to read the blob contents + * + * @param length - The maximum number of bytes to read from + * the blob. Note that the number of bytes + * returned may always be less than this number. + */ +export async function getPartialBlobContents( + repository: Repository, + commitish: string, + path: string, + length: number +): 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}`] + + const result = await git( + args, + repository.path, + 'getPartialBlobContentsCatchPathNotInRef', + { + maxBuffer: length, + expectedErrors: new Set([GitError.PathExistsButNotInRef]), + } + ) + + if (result.gitError === GitError.PathExistsButNotInRef) { + return null + } + + return Buffer.from(result.combinedOutput) +} diff --git a/app/src/lib/git/spawn.ts b/app/src/lib/git/spawn.ts new file mode 100644 index 0000000000..5eb2831274 --- /dev/null +++ b/app/src/lib/git/spawn.ts @@ -0,0 +1,143 @@ +import { GitProcess } from 'dugite' +import { IGitSpawnExecutionOptions } from 'dugite/build/lib/git-process' +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 +} + +/** + * Spawn a Git process, 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 const spawnGit = ( + args: string[], + path: string, + name: string, + options?: IGitSpawnExecutionOptions +) => + 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, + }) + return + } else { + reject( + new Error( + `Git returned an unexpected exit code '${code}' which should be handled by the caller (${name}).'` + ) + ) + } + }) + }) +} diff --git a/app/src/lib/git/squash.ts b/app/src/lib/git/squash.ts new file mode 100644 index 0000000000..499db082a4 --- /dev/null +++ b/app/src/lib/git/squash.ts @@ -0,0 +1,171 @@ +import { appendFile, rm, writeFile } from 'fs/promises' +import { getCommits, revRange } from '.' +import { Commit } from '../../models/commit' +import { MultiCommitOperationKind } from '../../models/multi-commit-operation' +import { IMultiCommitOperationProgress } from '../../models/progress' +import { Repository } from '../../models/repository' +import { getTempFilePath } from '../file-system' +import { rebaseInteractive, RebaseResult } from './rebase' + +/** + * Squashes provided commits by calling interactive rebase. + * + * Goal is to replay the commits in order from oldest to newest to reduce + * conflicts with toSquash commits placed in the log at the location of the + * squashOnto commit. + * + * Example: A user's history from oldest to newest is A, B, C, D, E and they + * want to squash A and E (toSquash) onto C. Our goal: B, A-C-E, D. Thus, + * maintaining that A came before C and E came after C, placed in history at the + * the squashOnto of C. + * + * Also means if the last 2 commits in history are A, B, whether user squashes A + * onto B or B onto A. It will always perform based on log history, thus, B onto + * A. + * + * @param toSquash - commits to squash onto another commit and does not contain the squashOnto commit + * @param squashOnto - commit to squash the `toSquash` commits onto + * @param lastRetainedCommitRef - sha of commit before commits in squash or null + * if commit to be squash is the root (first in history) of the branch + * @param commitMessage - the first line of the string provided will be the + * summary and rest the body (similar to commit implementation) + */ +export async function squash( + repository: Repository, + toSquash: ReadonlyArray, + squashOnto: Commit, + lastRetainedCommitRef: string | null, + commitMessage: string, + progressCallback?: (progress: IMultiCommitOperationProgress) => void +): Promise { + let messagePath, todoPath + let result: RebaseResult + + try { + if (toSquash.length === 0) { + throw new Error('[squash] No commits provided to squash.') + } + + const toSquashShas = new Set(toSquash.map(c => c.sha)) + if (toSquashShas.has(squashOnto.sha)) { + throw new Error( + '[squash] The commits to squash cannot contain the commit to squash onto.' + ) + } + + const commits = await getCommits( + repository, + lastRetainedCommitRef === null + ? undefined + : revRange(lastRetainedCommitRef, 'HEAD') + ) + + if (commits.length === 0) { + throw new Error( + '[squash] Could not find commits in log for last retained commit ref.' + ) + } + + todoPath = await getTempFilePath('squashTodo') + let foundSquashOntoCommitInLog = false + const toReplayAtSquash = [] + const toReplayAfterSquash = [] + // Traversed in reverse so we do oldest to newest (replay commits) + for (let i = commits.length - 1; i >= 0; i--) { + const commit = commits[i] + if (toSquashShas.has(commit.sha)) { + // If it is toSquash commit and we have found the squashOnto commit, we + // can go ahead and squash them (as we will hold any picks till after) + if (foundSquashOntoCommitInLog) { + await appendFile(todoPath, `squash ${commit.sha} ${commit.summary}\n`) + } else { + // However, if we have not found the squashOnto commit yet we want to + // keep track of them in the order of the log. Thus, we use a new + // `toReplayAtSquash` array and not trust that what was sent is in the + // order of the log. + toReplayAtSquash.push(commit) + } + + continue + } + + // If it's the squashOnto commit, replay to the toSquash in the order they + // appeared on the log to reduce potential conflicts. + if (commit.sha === squashOnto.sha) { + foundSquashOntoCommitInLog = true + toReplayAtSquash.push(commit) + + for (let j = 0; j < toReplayAtSquash.length; j++) { + const action = j === 0 ? 'pick' : 'squash' + await appendFile( + todoPath, + `${action} ${toReplayAtSquash[j].sha} ${toReplayAtSquash[j].summary}\n` + ) + } + + continue + } + + // We can't just replay a pick in case there is a commit from the toSquash + // commits further up in history that need to be replayed with the + // squashes. Thus, we will keep track of these and replay after traversing + // the remainder of the log. + if (foundSquashOntoCommitInLog) { + toReplayAfterSquash.push(commit) + continue + } + + // If it is not one toSquash nor the squashOnto and have not found the + // squashOnto commit, we simply record it is an unchanged pick (before the + // squash) + await appendFile(todoPath, `pick ${commit.sha} ${commit.summary}\n`) + } + + if (toReplayAfterSquash.length > 0) { + for (let i = 0; i < toReplayAfterSquash.length; i++) { + await appendFile( + todoPath, + `pick ${toReplayAfterSquash[i].sha} ${toReplayAfterSquash[i].summary}\n` + ) + } + } + + if (!foundSquashOntoCommitInLog) { + throw new Error( + '[squash] The commit to squash onto was not in the log. Continuing would result in dropping the commits in the toSquash array.' + ) + } + + if (commitMessage.trim() !== '') { + messagePath = await getTempFilePath('squashCommitMessage') + await writeFile(messagePath, commitMessage) + } + + // if no commit message provided, accept default editor + const gitEditor = + messagePath !== undefined ? `cat "${messagePath}" >` : undefined + + result = await rebaseInteractive( + repository, + todoPath, + lastRetainedCommitRef, + MultiCommitOperationKind.Squash, + gitEditor, + progressCallback, + [...toSquash, squashOnto] + ) + } catch (e) { + log.error(e) + return RebaseResult.Error + } finally { + if (todoPath !== undefined) { + await rm(todoPath, { recursive: true, force: true }) + } + + if (messagePath !== undefined) { + await rm(messagePath, { recursive: true, force: true }) + } + } + + return result +} diff --git a/app/src/lib/git/stage.ts b/app/src/lib/git/stage.ts new file mode 100644 index 0000000000..9c915635fb --- /dev/null +++ b/app/src/lib/git/stage.ts @@ -0,0 +1,63 @@ +import { Repository } from '../../models/repository' +import { + WorkingDirectoryFileChange, + isConflictedFileStatus, + GitStatusEntry, + isConflictWithMarkers, +} from '../../models/status' +import { ManualConflictResolution } from '../../models/manual-conflict-resolution' +import { assertNever } from '../fatal-error' +import { removeConflictedFile } from './rm' +import { checkoutConflictedFile } from './checkout' +import { addConflictedFile } from './add' + +/** + * Stages a file with the given manual resolution method. Useful for resolving binary conflicts at commit-time. + * + * @param repository + * @param file conflicted file to stage + * @param manualResolution method to resolve the conflict of file + * @returns true if successful, false if something went wrong + */ +export async function stageManualConflictResolution( + repository: Repository, + file: WorkingDirectoryFileChange, + manualResolution: ManualConflictResolution +): Promise { + const { status } = file + // if somehow the file isn't in a conflicted state + if (!isConflictedFileStatus(status)) { + log.error(`tried to manually resolve unconflicted file (${file.path})`) + return + } + + if (isConflictWithMarkers(status) && status.conflictMarkerCount === 0) { + // If somehow the user used the Desktop UI to solve the conflict via ours/theirs + // but afterwards resolved manually the conflicts via an editor, used the manually + // resolved file. + return + } + + const chosen = + manualResolution === ManualConflictResolution.theirs + ? status.entry.them + : status.entry.us + + const addedInBoth = + status.entry.us === GitStatusEntry.Added && + status.entry.them === GitStatusEntry.Added + + if (chosen === GitStatusEntry.UpdatedButUnmerged || addedInBoth) { + await checkoutConflictedFile(repository, file, manualResolution) + } + + switch (chosen) { + case GitStatusEntry.Deleted: + return removeConflictedFile(repository, file) + case GitStatusEntry.Added: + case GitStatusEntry.UpdatedButUnmerged: + return addConflictedFile(repository, file) + default: + assertNever(chosen, 'unaccounted for git status entry possibility') + } +} diff --git a/app/src/lib/git/stash.ts b/app/src/lib/git/stash.ts new file mode 100644 index 0000000000..696e7a16a0 --- /dev/null +++ b/app/src/lib/git/stash.ts @@ -0,0 +1,280 @@ +import { GitError as DugiteError } from 'dugite' +import { git, GitError } from './core' +import { Repository } from '../../models/repository' +import { + IStashEntry, + StashedChangesLoadStates, + StashedFileChanges, +} from '../../models/stash-entry' +import { + WorkingDirectoryFileChange, + CommittedFileChange, +} from '../../models/status' +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' + +/** + * RegEx for determining if a stash entry is created by Desktop + * + * This is done by looking for a magic string with the following + * format: `!!GitHub_Desktop` + */ +const desktopStashEntryMessageRe = /!!GitHub_Desktop<(.+)>$/ + +type StashResult = { + /** The stash entries created by Desktop */ + readonly desktopEntries: ReadonlyArray + + /** + * The total amount of stash entries, + * i.e. stash entries created both by Desktop and outside of Desktop + */ + readonly stashEntryCount: number +} + +/** + * Get the list of stash entries created by Desktop in the current repository + * using the default ordering of refs (which is LIFO ordering), + * as well as the total amount of stash entries. + */ +export async function getStashes(repository: Repository): Promise { + const { formatArgs, parse } = createLogParser({ + name: '%gD', + stashSha: '%H', + message: '%gs', + tree: '%T', + parents: '%P', + }) + + const result = await git( + ['log', '-g', ...formatArgs, 'refs/stash'], + repository.path, + 'getStashEntries', + { successExitCodes: new Set([0, 128]) } + ) + + // There's no refs/stashes reflog in the repository or it's not + // even a repository. In either case we don't care + if (result.exitCode === 128) { + return { desktopEntries: [], stashEntryCount: 0 } + } + + const desktopEntries: Array = [] + const files: StashedFileChanges = { kind: StashedChangesLoadStates.NotLoaded } + + 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, 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) +} + +/** + * Returns the last Desktop created stash entry for the given branch + */ +export async function getLastDesktopStashEntryForBranch( + repository: Repository, + branch: Branch | string +) { + const stash = await getStashes(repository) + const branchName = typeof branch === 'string' ? branch : branch.name + + // Since stash objects are returned in a LIFO manner, the first + // entry found is guaranteed to be the last entry created + return ( + stash.desktopEntries.find(stash => stash.branchName === branchName) || null + ) +} + +/** Creates a stash entry message that indicates the entry was created by Desktop */ +export function createDesktopStashMessage(branchName: string) { + return `${DesktopStashEntryMarker}<${branchName}>` +} + +/** + * Stash the working directory changes for the current branch + */ +export async function createDesktopStashEntry( + repository: Repository, + branch: Branch | string, + untrackedFilesToStage: ReadonlyArray +): Promise { + // We must ensure that no untracked files are present before stashing + // See https://github.com/desktop/desktop/pull/8085 + // First ensure that all changes in file are selected + // (in case the user has not explicitly checked the checkboxes for the untracked files) + const fullySelectedUntrackedFiles = untrackedFilesToStage.map(x => + x.withIncludeAll(true) + ) + await stageFiles(repository, fullySelectedUntrackedFiles) + + const branchName = typeof branch === 'string' ? branch : branch.name + 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) + } + + // 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') { + return false + } + + return true +} + +async function getStashEntryMatchingSha(repository: Repository, sha: string) { + const stash = await getStashes(repository) + return stash.desktopEntries.find(e => e.stashSha === sha) || null +} + +/** + * Removes the given stash entry if it exists + * + * @param stashSha the SHA that identifies the stash entry + */ +export async function dropDesktopStashEntry( + repository: Repository, + stashSha: string +) { + const entryToDelete = await getStashEntryMatchingSha(repository, stashSha) + + if (entryToDelete !== null) { + const args = ['stash', 'drop', entryToDelete.name] + await git(args, repository.path, 'dropStashEntry') + } +} + +/** + * Pops the stash entry identified by matching `stashSha` to its commit hash. + * + * To see the commit hash of stash entry, run + * `git log -g refs/stash --pretty="%nentry: %gd%nsubject: %gs%nhash: %H%n"` + * in a repo with some stash entries. + */ +export async function popStashEntry( + repository: Repository, + stashSha: string +): Promise { + // 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', { + 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) + } + + log.info( + `[popStashEntry] a stash was popped successfully but exit code ${result.exitCode} reported.` + ) + // bye bye + await dropDesktopStashEntry(repository, stashSha) + } + } +} + +function extractBranchFromMessage(message: string): string | null { + const match = desktopStashEntryMessageRe.exec(message) + return match === null || match[1].length === 0 ? null : match[1] +} + +/** Get the files that were changed in the given stash commit */ +export async function getStashedFiles( + repository: Repository, + stashSha: string +): Promise> { + const args = [ + 'stash', + 'show', + stashSha, + '--raw', + '--numstat', + '-z', + '--format=format:', + '--no-show-signature', + '--', + ] + + 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 new file mode 100644 index 0000000000..4038ece0f9 --- /dev/null +++ b/app/src/lib/git/status.ts @@ -0,0 +1,461 @@ +import { spawnAndComplete } from './spawn' +import { getFilesWithConflictMarkers } from './diff-check' +import { + WorkingDirectoryStatus, + WorkingDirectoryFileChange, + AppFileStatus, + FileEntry, + GitStatusEntry, + AppFileStatusKind, + UnmergedEntry, + ConflictedFileStatus, + UnmergedEntrySummary, +} from '../../models/status' +import { + parsePorcelainStatus, + mapStatus, + IStatusEntry, + IStatusHeader, + isStatusHeader, + isStatusEntry, +} from '../status-parser' +import { DiffSelectionType, DiffSelection } from '../../models/diff' +import { Repository } from '../../models/repository' +import { IAheadBehind } from '../../models/branch' +import { fatalError } from '../../lib/fatal-error' +import { isMergeHeadSet, isSquashMsgSet } from './merge' +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 + +/** The encapsulation of the result from 'git status' */ +export interface IStatusResult { + /** The name of the current branch */ + readonly currentBranch?: string + + /** The name of the current upstream branch */ + readonly currentUpstreamBranch?: string + + /** The SHA of the tip commit of the current branch */ + readonly currentTip?: string + + /** How many commits ahead and behind + * the `currentBranch` is compared to the `currentUpstreamBranch` + */ + readonly branchAheadBehind?: IAheadBehind + + /** true if the repository exists at the given location */ + readonly exists: boolean + + /** true if repository is in a conflicted state */ + readonly mergeHeadFound: boolean + + /** true merge --squash operation started */ + readonly squashMsgFound: boolean + + /** details about the rebase operation, if found */ + readonly rebaseInternalState: RebaseInternalState | null + + /** true if repository is in cherry pick state */ + readonly isCherryPickingHeadFound: boolean + + /** the absolute path to the repository's working directory */ + readonly workingDirectory: WorkingDirectoryStatus + + /** whether conflicting files present on repository */ + readonly doConflictedFilesExist: boolean +} + +interface IStatusHeadersData { + currentBranch?: string + currentUpstreamBranch?: string + currentTip?: string + branchAheadBehind?: IAheadBehind + match: RegExpMatchArray | null +} + +type ConflictFilesDetails = { + conflictCountsByPath: ReadonlyMap + binaryFilePaths: ReadonlyArray +} + +function parseConflictedState( + entry: UnmergedEntry, + path: string, + conflictDetails: ConflictFilesDetails +): ConflictedFileStatus { + switch (entry.action) { + case UnmergedEntrySummary.BothAdded: { + const isBinary = conflictDetails.binaryFilePaths.includes(path) + if (!isBinary) { + return { + kind: AppFileStatusKind.Conflicted, + entry, + conflictMarkerCount: + conflictDetails.conflictCountsByPath.get(path) || 0, + } + } else { + return { + kind: AppFileStatusKind.Conflicted, + entry, + } + } + } + case UnmergedEntrySummary.BothModified: { + const isBinary = conflictDetails.binaryFilePaths.includes(path) + if (!isBinary) { + return { + kind: AppFileStatusKind.Conflicted, + entry, + conflictMarkerCount: + conflictDetails.conflictCountsByPath.get(path) || 0, + } + } else { + return { + kind: AppFileStatusKind.Conflicted, + entry, + } + } + } + default: + return { + kind: AppFileStatusKind.Conflicted, + entry, + } + } +} + +function convertToAppStatus( + path: string, + entry: FileEntry, + conflictDetails: ConflictFilesDetails, + oldPath?: string +): AppFileStatus { + if (entry.kind === 'ordinary') { + switch (entry.type) { + case 'added': + return { + kind: AppFileStatusKind.New, + submoduleStatus: entry.submoduleStatus, + } + case 'modified': + return { + kind: AppFileStatusKind.Modified, + submoduleStatus: entry.submoduleStatus, + } + case 'deleted': + return { + kind: AppFileStatusKind.Deleted, + submoduleStatus: entry.submoduleStatus, + } + } + } else if (entry.kind === 'copied' && oldPath != null) { + return { + kind: AppFileStatusKind.Copied, + oldPath, + submoduleStatus: entry.submoduleStatus, + } + } else if (entry.kind === 'renamed' && oldPath != null) { + return { + kind: AppFileStatusKind.Renamed, + oldPath, + submoduleStatus: entry.submoduleStatus, + } + } else if (entry.kind === 'untracked') { + return { + kind: AppFileStatusKind.Untracked, + submoduleStatus: entry.submoduleStatus, + } + } else if (entry.kind === 'conflicted') { + return parseConflictedState(entry, path, conflictDetails) + } + + return fatalError(`Unknown file status ${status}`) +} + +// List of known conflicted index entries for a file, extracted from mapStatus +// inside `app/src/lib/status-parser.ts` for convenience +const conflictStatusCodes = ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'] + +/** + * Retrieve the status for a given repository, + * and fail gracefully if the location is not a Git repository + */ +export async function getStatus( + repository: Repository +): Promise { + const args = [ + '--no-optional-locks', + 'status', + '--untracked-files=all', + '--branch', + '--porcelain=2', + '-z', + ] + + const result = await spawnAndComplete( + args, + repository.path, + 'getStatus', + new Set([0, 128]) + ) + + if (result.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 rebaseInternalState = await getRebaseInternalState(repository) + + const conflictDetails = await getConflictDetails( + repository, + mergeHeadFound, + conflictedFilesInIndex, + rebaseInternalState + ) + + // Map of files keyed on their paths. + const files = entries.reduce( + (files, entry) => buildStatusMap(files, entry, conflictDetails), + new Map() + ) + + const { + currentBranch, + currentUpstreamBranch, + currentTip, + branchAheadBehind, + } = headers.reduce(parseStatusHeader, { + currentBranch: undefined, + currentUpstreamBranch: undefined, + currentTip: undefined, + branchAheadBehind: undefined, + match: null, + }) + + const workingDirectory = WorkingDirectoryStatus.fromFiles([...files.values()]) + + const isCherryPickingHeadFound = await isCherryPickHeadFound(repository) + + const squashMsgFound = await isSquashMsgSet(repository) + + return { + currentBranch, + currentTip, + currentUpstreamBranch, + branchAheadBehind, + exists: true, + mergeHeadFound, + rebaseInternalState, + workingDirectory, + isCherryPickingHeadFound, + squashMsgFound, + doConflictedFilesExist: conflictedFilesInIndex, + } +} + +/** + * + * Update map of working directory changes with a file status entry. + * Reducer(ish). + * + * (Map is used here to maintain insertion order.) + */ +function buildStatusMap( + files: Map, + entry: IStatusEntry, + conflictDetails: ConflictFilesDetails +): Map { + const status = mapStatus(entry.statusCode, entry.submoduleStatusCode) + + if (status.kind === 'ordinary') { + // when a file is added in the index but then removed in the working + // directory, the file won't be part of the commit, so we can skip + // displaying this entry in the changes list + if ( + status.index === GitStatusEntry.Added && + status.workingTree === GitStatusEntry.Deleted + ) { + return files + } + } + + if (status.kind === 'untracked') { + // when a delete has been staged, but an untracked file exists with the + // same path, we should ensure that we only draw one entry in the + // changes list - see if an entry already exists for this path and + // remove it if found + files.delete(entry.path) + } + + // for now we just poke at the existing summary + const appStatus = convertToAppStatus( + entry.path, + status, + conflictDetails, + entry.oldPath + ) + + 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, + new WorkingDirectoryFileChange(entry.path, appStatus, selection) + ) + return files +} + +/** + * Update status header based on the current header entry. + * Reducer. + */ +function parseStatusHeader(results: IStatusHeadersData, header: IStatusHeader) { + let { + currentBranch, + currentUpstreamBranch, + currentTip, + branchAheadBehind, + match, + } = results + const value = header.value + + // This intentionally does not match branch.oid initial + if ((match = value.match(/^branch\.oid ([a-f0-9]+)$/))) { + currentTip = match[1] + } else if ((match = value.match(/^branch.head (.*)/))) { + if (match[1] !== '(detached)') { + currentBranch = match[1] + } + } else if ((match = value.match(/^branch.upstream (.*)/))) { + currentUpstreamBranch = match[1] + } else if ((match = value.match(/^branch.ab \+(\d+) -(\d+)$/))) { + const ahead = parseInt(match[1], 10) + const behind = parseInt(match[2], 10) + + if (!isNaN(ahead) && !isNaN(behind)) { + branchAheadBehind = { ahead, behind } + } + } + return { + currentBranch, + currentUpstreamBranch, + currentTip, + branchAheadBehind, + match, + } +} + +async function getMergeConflictDetails(repository: Repository) { + const conflictCountsByPath = await getFilesWithConflictMarkers( + repository.path + ) + const binaryFilePaths = await getBinaryPaths(repository, 'MERGE_HEAD') + return { + conflictCountsByPath, + binaryFilePaths, + } +} + +async function getRebaseConflictDetails(repository: Repository) { + const conflictCountsByPath = await getFilesWithConflictMarkers( + repository.path + ) + const binaryFilePaths = await getBinaryPaths(repository, 'REBASE_HEAD') + return { + conflictCountsByPath, + binaryFilePaths, + } +} + +/** + * 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) { + 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') + } catch (error) {} + + return { + conflictCountsByPath, + binaryFilePaths, + } +} + +/** + * gets the conflicted files count and binary file paths in a given repository. + * for computing an `IStatusResult`. + * + * @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) + */ +async function getConflictDetails( + repository: Repository, + mergeHeadFound: boolean, + lookForStashConflicts: boolean, + rebaseInternalState: RebaseInternalState | null +): Promise { + try { + if (mergeHeadFound) { + return await getMergeConflictDetails(repository) + } + + if (rebaseInternalState !== null) { + return await getRebaseConflictDetails(repository) + } + + if (lookForStashConflicts) { + return await getWorkingDirectoryConflictDetails(repository) + } + } catch (error) { + log.error( + 'Unexpected error from git operations in getConflictDetails', + error + ) + } + return { + conflictCountsByPath: new Map(), + binaryFilePaths: new Array(), + } +} diff --git a/app/src/lib/git/submodule.ts b/app/src/lib/git/submodule.ts new file mode 100644 index 0000000000..7cdaef26d9 --- /dev/null +++ b/app/src/lib/git/submodule.ts @@ -0,0 +1,78 @@ +import * as Path from 'path' + +import { git } from './core' +import { Repository } from '../../models/repository' +import { SubmoduleEntry } from '../../models/submodule' +import { pathExists } from '../../ui/lib/path-exists' + +export async function listSubmodules( + repository: Repository +): Promise> { + const [submodulesFile, submodulesDir] = await Promise.all([ + pathExists(Path.join(repository.path, '.gitmodules')), + pathExists(Path.join(repository.path, '.git', 'modules')), + ]) + + if (!submodulesFile && !submodulesDir) { + log.info('No submodules found. Skipping "git submodule status"') + return [] + } + + // We don't recurse when listing submodules here because we don't have a good + // story about managing these currently. So for now we're only listing + // changes to the top-level submodules to be consistent with `git status` + const { stdout, exitCode } = await git( + ['submodule', 'status', '--'], + repository.path, + 'listSubmodules', + { successExitCodes: new Set([0, 128]) } + ) + + if (exitCode === 128) { + // unable to parse submodules in repository, giving up + return [] + } + + const submodules = new Array() + + // entries are of the format: + // 1eaabe34fc6f486367a176207420378f587d3b48 git (v2.16.0-rc0) + // + // first character: + // - " " if no change + // - "-" if the submodule is not initialized + // - "+" if the currently checked out submodule commit does not match the SHA-1 found in the index of the containing repository + // - "U" if the submodule has merge conflicts + // + // then the 40-character SHA represents the current commit + // + // then the path to the submodule + // + // then the output of `git describe` for the submodule in braces + // we're not leveraging this in the app, so go and read the docs + // about it if you want to learn more: + // + // https://git-scm.com/docs/git-describe + const statusRe = /^.([^ ]+) (.+) \((.+?)\)$/gm + + for (const [, sha, path, describe] of stdout.matchAll(statusRe)) { + submodules.push(new SubmoduleEntry(sha, path, describe)) + } + + return submodules +} + +export async function resetSubmodulePaths( + repository: Repository, + paths: ReadonlyArray +): Promise { + if (paths.length === 0) { + return + } + + await git( + ['submodule', 'update', '--recursive', '--force', '--', ...paths], + repository.path, + 'updateSubmodule' + ) +} diff --git a/app/src/lib/git/tag.ts b/app/src/lib/git/tag.ts new file mode 100644 index 0000000000..50f8d38c79 --- /dev/null +++ b/app/src/lib/git/tag.ts @@ -0,0 +1,137 @@ +import { git, gitNetworkArguments } from './core' +import { Repository } from '../../models/repository' +import { IGitAccount } from '../../models/git-account' +import { IRemote } from '../../models/remote' +import { envForRemoteOperation } from './environment' + +/** + * Create a new tag on the given target commit. + * + * @param repository - The repository in which to create the new tag. + * @param name - The name of the new tag. + * @param targetCommitSha - The SHA of the commit where the new tag will live on. + */ +export async function createTag( + repository: Repository, + name: string, + targetCommitSha: string +): Promise { + const args = ['tag', '-a', '-m', '', name, targetCommitSha] + + await git(args, repository.path, 'createTag') +} + +/** + * Delete a tag. + * + * @param repository - The repository in which to create the new tag. + * @param name - The name of the tag to delete. + */ +export async function deleteTag( + repository: Repository, + name: string +): Promise { + const args = ['tag', '-d', name] + + await git(args, repository.path, 'deleteTag') +} + +/** + * Gets all the local tags. Returns a Map with the tag name and the commit it points to. + * + * @param repository The repository in which to get all the tags from. + */ +export async function getAllTags( + repository: Repository +): Promise> { + const args = ['show-ref', '--tags', '-d'] + + const tags = await git(args, repository.path, 'getAllTags', { + successExitCodes: new Set([0, 1]), // when there are no tags, git exits with 1. + }) + + const tagsArray: Array<[string, string]> = tags.stdout + .split('\n') + .filter(line => line !== '') + .map(line => { + const [commitSha, rawTagName] = line.split(' ') + + // Normalize tag names by removing the leading ref/tags/ and the trailing ^{}. + // + // git show-ref returns two entries for annotated tags: + // deadbeef refs/tags/annotated-tag + // de510b99 refs/tags/annotated-tag^{} + // + // The first entry sha correspond to the blob object of the annotation, while the second + // entry corresponds to the actual commit where the tag was created. + // By normalizing the tag name we can make sure that the commit sha gets stored in the returned + // Map of commits (since git will always print the entry with the commit sha at the end). + const tagName = rawTagName + .replace(/^refs\/tags\//, '') + .replace(/\^\{\}$/, '') + + return [tagName, commitSha] + }) + + return new Map(tagsArray) +} + +/** + * Fetches the tags that will get pushed to the remote repository (it does a network request). + * + * @param repository - The repository in which to check for unpushed tags + * @param account - The account to use when authenticating with the remote + * @param remote - The remote to check for unpushed tags + * @param branchName - The branch that will be used on the push command + */ +export async function fetchTagsToPush( + repository: Repository, + account: IGitAccount | null, + remote: IRemote, + branchName: string +): Promise> { + const args = [ + ...gitNetworkArguments(), + 'push', + remote.name, + branchName, + '--follow-tags', + '--dry-run', + '--no-verify', + '--porcelain', + ] + + const result = await git(args, repository.path, 'fetchTagsToPush', { + env: await envForRemoteOperation(account, remote.url), + successExitCodes: new Set([0, 1, 128]), + }) + + if (result.exitCode !== 0 && result.exitCode !== 1) { + // Only when the exit code of git is 0 or 1, its stdout is parseable. + // In other cases, we just rethrow the error so our memoization layer + // doesn't cache it indefinitely. + throw result.gitError + } + + const lines = result.stdout.split('\n') + let currentLine = 1 + const unpushedTags = [] + + // the last line of this porcelain command is always 'Done' + while (currentLine < lines.length && lines[currentLine] !== 'Done') { + const line = lines[currentLine] + const parts = line.split('\t') + + if (parts[0] === '*' && parts[2] === '[new tag]') { + const [tagName] = parts[1].split(':') + + if (tagName !== undefined) { + unpushedTags.push(tagName.replace(/^refs\/tags\//, '')) + } + } + + currentLine++ + } + + return unpushedTags +} diff --git a/app/src/lib/git/update-index.ts b/app/src/lib/git/update-index.ts new file mode 100644 index 0000000000..fa6cfecb25 --- /dev/null +++ b/app/src/lib/git/update-index.ts @@ -0,0 +1,169 @@ +import { git } from './core' +import { Repository } from '../../models/repository' +import { DiffSelectionType } from '../../models/diff' +import { applyPatchToIndex } from './apply' +import { + WorkingDirectoryFileChange, + AppFileStatusKind, +} from '../../models/status' + +interface IUpdateIndexOptions { + /** + * Whether or not to add a file when it exists in the working directory + * but not in the index. Defaults to true (note that this differs from the + * default behavior of Git which is to ignore new files). + * + * @default true + */ + add?: boolean + + /** + * Whether or not to remove a file when it exists in the index but not + * in the working directory. Defaults to true (note that this differs from + * the default behavior of Git which is to ignore removed files). + * + * @default true + */ + remove?: boolean + + /** + * Whether or not to forcefully remove a file from the index even though it + * exists in the working directory. This implies remove. + * + * @default false + */ + forceRemove?: boolean + + /** + * Whether or not to replace conflicting entries in the index with that of + * the working directory. Imagine the following scenario + * + * $ touch foo && git update-index --add foo && git commit -m 'foo' + * $ rm foo && mkdir foo && echo "bar" > foo/bar + * $ git update-index --add foo/bar + * error: 'foo/bar' appears as both a file and as a directory + * error: foo/bar: cannot add to the index - missing --add option? + * fatal: Unable to process path foo/bar + * + * Replace ignores this conflict and overwrites the index with the + * newly created directory, causing the original foo file to be deleted + * in the index. This behavior matches what `git add` would do in a similar + * scenario. + * + * @default true + */ + replace?: boolean +} + +/** + * Updates the index with file contents from the working tree. This method + * is a noop when no paths are provided. + * + * @param paths A list of paths which are to be updated with file contents and + * status from the working directory. + * + * @param options See the IUpdateIndexOptions interface for more details. + */ +async function updateIndex( + repository: Repository, + paths: ReadonlyArray, + options: IUpdateIndexOptions = {} +) { + if (paths.length === 0) { + return + } + + const args = ['update-index'] + + if (options.add !== false) { + args.push('--add') + } + + if (options.remove !== false || options.forceRemove === true) { + args.push('--remove') + } + + if (options.forceRemove) { + args.push('--force-remove') + } + + if (options.replace !== false) { + args.push('--replace') + } + + args.push('-z', '--stdin') + + await git(args, repository.path, 'updateIndex', { + stdin: paths.join('\0'), + }) +} + +/** + * Stage all the given files by either staging the entire path or by applying + * a patch. + * + * Note that prior to stageFiles the index has been completely reset, + * the job of this function is to set up the index in such a way that it + * reflects what the user has selected in the app. + */ +export async function stageFiles( + repository: Repository, + files: ReadonlyArray +): Promise { + const normal = [] + const oldRenamed = [] + const partial = [] + const deletedFiles = [] + + for (const file of files) { + if (file.selection.getSelectionType() === DiffSelectionType.All) { + normal.push(file.path) + if (file.status.kind === AppFileStatusKind.Renamed) { + oldRenamed.push(file.status.oldPath) + } else if (file.status.kind === AppFileStatusKind.Deleted) { + deletedFiles.push(file.path) + } + } else { + partial.push(file) + } + } + + // Staging files happens in three steps. + // + // In the first step we run through all of the renamed files, or + // more specifically the source files (old) that were renamed and + // forcefully remove them from the index. We do this in order to handle + // the scenario where a file has been renamed and a new file has been + // created in its original position. Think of it like this + // + // $ touch foo && git add foo && git commit -m 'foo' + // $ git mv foo bar + // $ echo "I'm a new foo" > foo + // + // Now we have a file which is of type Renamed that has its path set + // to 'bar' and its oldPath set to 'foo'. But there's a new file called + // foo in the repository. So if the user selects the 'foo -> bar' change + // but not the new 'foo' file for inclusion in this commit we don't + // want to add the new 'foo', we just want to recreate the move in the + // index. We do this by forcefully removing the old path from the index + // and then later (in step 2) stage the new file. + await updateIndex(repository, oldRenamed, { forceRemove: true }) + + // In the second step we update the index to match + // the working directory in the case of new, modified, deleted, + // and copied files as well as the destination paths for renamed + // paths. + await updateIndex(repository, normal) + + // This third step will only happen if we have files that have been marked + // for deletion. This covers us for files that were blown away in the last + // updateIndex call + await updateIndex(repository, deletedFiles, { forceRemove: true }) + + // Finally we run through all files that have partial selections. + // We don't care about renamed or not here since applyPatchToIndex + // has logic to support that scenario. + for (const file of partial) { + await applyPatchToIndex(repository, file) + } +} diff --git a/app/src/lib/git/update-ref.ts b/app/src/lib/git/update-ref.ts new file mode 100644 index 0000000000..ff1870a591 --- /dev/null +++ b/app/src/lib/git/update-ref.ts @@ -0,0 +1,50 @@ +import { git } from './core' +import { Repository } from '../../models/repository' + +/** + * Update the ref to a new value. + * + * @param repository - The repository in which the ref exists. + * @param ref - The ref to update. Must be fully qualified + * (e.g., `refs/heads/NAME`). + * @param oldValue - The value we expect the ref to have currently. If it + * doesn't match, the update will be aborted. + * @param newValue - The new value for the ref. + * @param reason - The reflog entry. + */ +export async function updateRef( + repository: Repository, + ref: string, + oldValue: string, + newValue: string, + reason: string +): Promise { + await git( + ['update-ref', ref, newValue, oldValue, '-m', reason], + repository.path, + 'updateRef' + ) +} + +/** + * Remove a ref. + * + * @param repository - The repository in which the ref exists. + * @param ref - The ref to remove. Should be fully qualified, but may also be 'HEAD'. + * @param reason - The reflog entry (optional). Note that this is only useful when + * deleting the HEAD reference as deleting any other reference will + * implicitly delete the reflog file for that reference as well. + */ +export async function deleteRef( + repository: Repository, + ref: string, + reason?: string +) { + const args = ['update-ref', '-d', ref] + + if (reason !== undefined) { + args.push('-m', reason) + } + + await git(args, repository.path, 'deleteRef') +} diff --git a/app/src/lib/git/var.ts b/app/src/lib/git/var.ts new file mode 100644 index 0000000000..80bbaa8817 --- /dev/null +++ b/app/src/lib/git/var.ts @@ -0,0 +1,42 @@ +import { git } from './core' +import { Repository } from '../../models/repository' +import { CommitIdentity } from '../../models/commit-identity' + +/** + * Gets the author identity, ie the name and email which would + * have been used should a commit have been performed in this + * instance. This differs from what's stored in the user.name + * and user.email config variables in that it will match what + * Git itself will use in a commit even if there's no name or + * email configured. If no email or name is configured Git will + * attempt to come up with a suitable replacement using the + * signed-in system user and hostname. + * + * A null return value means that no name/and or email was set + * and the user.useconfigonly setting prevented Git from making + * up a user ident string. If this returns null any subsequent + * commits can be expected to fail as well. + */ +export async function getAuthorIdentity( + repository: Repository +): Promise { + const result = await git( + ['var', 'GIT_AUTHOR_IDENT'], + repository.path, + 'getAuthorIdentity', + { + successExitCodes: new Set([0, 128]), + } + ) + + // If user.user.useconfigonly is set and no user.name or user.email + if (result.exitCode === 128) { + return null + } + + try { + return CommitIdentity.parseIdentity(result.stdout) + } catch (err) { + return null + } +} diff --git a/app/src/lib/globals.d.ts b/app/src/lib/globals.d.ts new file mode 100644 index 0000000000..e1d4aa4a04 --- /dev/null +++ b/app/src/lib/globals.d.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** Is the app running in dev mode? */ +declare const __DEV__: boolean + +/** The OAuth client id the app should use */ +declare const __OAUTH_CLIENT_ID__: string | undefined + +/** The OAuth secret the app should use. */ +declare const __OAUTH_SECRET__: string | undefined + +/** Is the app being built to run on Darwin? */ +declare const __DARWIN__: boolean + +/** Is the app being built to run on Win32? */ +declare const __WIN32__: boolean + +/** Is the app being built to run on Linux? */ +declare const __LINUX__: boolean + +/** + * The product name of the app, this is intended to be a compile-time + * replacement for app.getName + * (https://www.electronjs.org/docs/latest/api/app#appgetname) + */ +declare const __APP_NAME__: string + +/** + * The current version of the app, this is intended to be a compile-time + * replacement for app.getVersion + * (https://www.electronjs.org/docs/latest/api/app#appgetname) + */ +declare const __APP_VERSION__: string + +/** + * The commit id of the repository HEAD at build time. + * Represented as a 40 character SHA-1 hexadecimal digest string. + */ +declare const __SHA__: string + +/** The channel for which the release was created. */ +declare const __RELEASE_CHANNEL__: + | 'production' + | 'beta' + | 'test' + | 'development' + +declare const __CLI_COMMANDS__: ReadonlyArray + +/** The URL for Squirrel's updates. */ +declare const __UPDATES_URL__: string + +/** + * The currently executing process kind, this is specific to desktop + * and identifies the processes that we have. + */ +declare const __PROCESS_KIND__: 'main' | 'ui' | 'crash' | 'highlighter' + +interface IDesktopLogger { + /** + * Writes a log message at the 'error' level. + * + * The error will be persisted to disk as long as the disk transport is + * configured to pass along log messages at this level. For more details + * about the on-disk transport, see log.ts in the main process. + * + * If used from a renderer the log message will also be appended to the + * devtools console. + * + * @param message The text to write to the log file + * @param error An optional error instance that will be formatted to + * include the stack trace (if one is available) and + * then appended to the log message. + */ + error(message: string, error?: Error): void + + /** + * Writes a log message at the 'warn' level. + * + * The error will be persisted to disk as long as the disk transport is + * configured to pass along log messages at this level. For more details + * about the on-disk transport, see log.ts in the main process. + * + * If used from a renderer the log message will also be appended to the + * devtools console. + * + * @param message The text to write to the log file + * @param error An optional error instance that will be formatted to + * include the stack trace (if one is available) and + * then appended to the log message. + */ + warn(message: string, error?: Error): void + + /** + * Writes a log message at the 'info' level. + * + * The error will be persisted to disk as long as the disk transport is + * configured to pass along log messages at this level. For more details + * about the on-disk transport, see log.ts in the main process. + * + * If used from a renderer the log message will also be appended to the + * devtools console. + * + * @param message The text to write to the log file + * @param error An optional error instance that will be formatted to + * include the stack trace (if one is available) and + * then appended to the log message. + */ + info(message: string, error?: Error): void + + /** + * Writes a log message at the 'debug' level. + * + * The error will be persisted to disk as long as the disk transport is + * configured to pass along log messages at this level. For more details + * about the on-disk transport, see log.ts in the main process. + * + * If used from a renderer the log message will also be appended to the + * devtools console. + * + * @param message The text to write to the log file + * @param error An optional error instance that will be formatted to + * include the stack trace (if one is available) and + * then appended to the log message. + */ + debug(message: string, error?: Error): void +} + +declare const log: IDesktopLogger +// these changes should be pushed into the Electron declarations + +declare namespace NodeJS { + interface Process extends EventEmitter { + on( + event: 'send-non-fatal-exception', + listener: (error: Error, context?: { [key: string]: string }) => void + ): this + emit( + event: 'send-non-fatal-exception', + error: Error, + context?: { [key: string]: string } + ): this + removeListener(event: 'exit', listener: Function): this + } +} + +declare namespace Electron { + type AppleActionOnDoubleClickPref = 'Maximize' | 'Minimize' | 'None' + + interface SystemPreferences { + getUserDefault( + key: 'AppleActionOnDoubleClick', + type: 'string' + ): AppleActionOnDoubleClickPref + } +} + +// https://github.com/microsoft/TypeScript/issues/21568#issuecomment-362473070 +interface Window { + Element: typeof Element + HTMLElement: typeof HTMLElement +} + +interface HTMLDialogElement { + showModal: () => void + close: (returnValue?: string | undefined) => void + open: boolean +} +/** + * Obtain the number of elements of a tuple type + * + * See https://itnext.io/implementing-arithmetic-within-typescripts-type-system-a1ef140a6f6f + */ +type Length = T extends { length: infer L } ? L : never + +/** Obtain the the number of parameters of a function type */ +type ParameterCount any> = Length> diff --git a/app/src/lib/gravatar.ts b/app/src/lib/gravatar.ts new file mode 100644 index 0000000000..8b6338000b --- /dev/null +++ b/app/src/lib/gravatar.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000000..a21328e20f --- /dev/null +++ b/app/src/lib/helpers/default-branch.ts @@ -0,0 +1,42 @@ +import { getGlobalConfigValue, setGlobalConfigValue } from '../git' + +/** + * The default branch name that GitHub Desktop will use when + * initializing a new repository. + */ +const DefaultBranchInDesktop = 'main' + +/** + * The name of the Git configuration variable which holds what + * branch name Git will use when initializing a new repository. + */ +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 + */ +async function getConfiguredDefaultBranch(): Promise { + return getGlobalConfigValue(DefaultBranchSettingName) +} + +/** + * Returns the configured default branch when creating new repositories + */ +export async function getDefaultBranch(): Promise { + return (await getConfiguredDefaultBranch()) ?? DefaultBranchInDesktop +} + +/** + * Sets the configured default branch when creating new repositories. + * + * @param branchName The default branch name to use. + */ +export async function setDefaultBranch(branchName: string) { + return setGlobalConfigValue(DefaultBranchSettingName, branchName) +} diff --git a/app/src/lib/helpers/non-fatal-exception.ts b/app/src/lib/helpers/non-fatal-exception.ts new file mode 100644 index 0000000000..cdc2d3ca85 --- /dev/null +++ b/app/src/lib/helpers/non-fatal-exception.ts @@ -0,0 +1,45 @@ +/** + * Send a caught (ie. non-fatal) exception to the non-fatal error bucket + * + * The intended use of this message is for getting insight into areas of the + * code where we suspect alternate failure modes other than those accounted for. + * + * Example: In the Desktop tutorial creation logic we handle all errors and our + * initial belief was that the only two failure modes we would have to account + * for were either the repo existing on disk or on the user's account. We now + * suspect that there might be other reasons why the creation logic is failing + * and therefore want to send all errors encountered during creation to central + * where we can determine if there are additional failure modes for us to + * consider. + * + * @param kind - a grouping key that allows us to group all errors originating + * in the same area of the code base or relating to the same kind of failure + * (recommend a single non-hyphenated word) Example: tutorialRepoCreation + * + * @param error - the caught error + */ + +import { getHasOptedOutOfStats } from '../stats/stats-store' + +let lastNonFatalException: number | undefined = undefined + +/** Max one non fatal exeception per minute */ +const minIntervalBetweenNonFatalExceptions = 60 * 1000 + +export function sendNonFatalException(kind: string, error: Error) { + if (getHasOptedOutOfStats()) { + return + } + + const now = Date.now() + + if ( + lastNonFatalException !== undefined && + now - lastNonFatalException < minIntervalBetweenNonFatalExceptions + ) { + return + } + + lastNonFatalException = now + process.emit('send-non-fatal-exception', error, { kind }) +} diff --git a/app/src/lib/helpers/pull-request-matching.ts b/app/src/lib/helpers/pull-request-matching.ts new file mode 100644 index 0000000000..22bdbae1b4 --- /dev/null +++ b/app/src/lib/helpers/pull-request-matching.ts @@ -0,0 +1,38 @@ +import { IRemote } from '../../models/remote' +import { repositoryMatchesRemote } from '../repository-matching' +import { Branch } from '../../models/branch' +import { PullRequest } from '../../models/pull-request' + +/** + * Find the pull request for this branch. + * + * @param branch branch in question + * @param pullRequests list to search + * @param remote remote for the pull request's head + */ +export function findAssociatedPullRequest( + branch: Branch, + pullRequests: ReadonlyArray, + remote: IRemote +): PullRequest | null { + if (branch.upstreamWithoutRemote == null) { + return null + } + + return ( + pullRequests.find(pr => + isPullRequestAssociatedWithBranch(branch, pr, remote) + ) || null + ) +} + +export function isPullRequestAssociatedWithBranch( + branch: Branch, + pr: PullRequest, + remote: IRemote +): boolean { + return ( + pr.head.ref === branch.upstreamWithoutRemote && + repositoryMatchesRemote(pr.head.gitHubRepository, remote) + ) +} diff --git a/app/src/lib/helpers/push-control.ts b/app/src/lib/helpers/push-control.ts new file mode 100644 index 0000000000..59ed118d78 --- /dev/null +++ b/app/src/lib/helpers/push-control.ts @@ -0,0 +1,50 @@ +import { IAPIPushControl } from '../api' + +/** + * Determine if branch can be pushed to by user based on results from + * push_control API. + * + * Note: "admin-enforced" means that admins are restricted according to branch + * protection rules. By default admins are not restricted. + * + * `allow_actor` indicates if user is permitted + * Always `true` for admins. + * `true` if `Restrict who can push` is not enabled. + * `true` if `Restrict who can push` is enabled and user is in list. + * `false` if `Restrict who can push` is enabled and user is not in list. + * + * `required_status_checks` indicates if checks are required for merging + * Empty array if user is admin and branch is not admin-enforced. + * + * `required_approving_review_count` indicates if reviews are required before merging + * 0 if user is admin and branch is not admin-enforced + */ + +export function isBranchPushable(pushControl: IAPIPushControl) { + const { + allow_actor, + required_status_checks, + required_approving_review_count, + } = pushControl + + // See https://github.com/desktop/desktop/issues/9054#issuecomment-582768322 + // We'll guard against this being undefined until we can determine the + // root cause and fix that. + const requiredStatusCheckCount = Array.isArray(required_status_checks) + ? required_status_checks.length + : 0 + + // If user is admin and branch is not admin-enforced, + // required status checks and reviews get zeroed out in API response (no merge requirements). + // If user is admin and branch is admin-enforced, + // required status checks and reviews do NOT get zeroed out in API response. + // If user is allowed to push based on `Restrict who can push` setting, they must still + // respect the merge requirements, and can't push if checks or reviews are required for merging + const noMergeRequirements = + requiredStatusCheckCount === 0 && required_approving_review_count === 0 + + // We check for !== false so that if a future version of the API decides to + // remove or rename that property we'll revert to assuming that the user + // _does_ have access rather than assuming that they _don't_. + return allow_actor !== false && noMergeRequirements +} diff --git a/app/src/lib/helpers/regex.ts b/app/src/lib/helpers/regex.ts new file mode 100644 index 0000000000..9e479c45ed --- /dev/null +++ b/app/src/lib/helpers/regex.ts @@ -0,0 +1,83 @@ +/** + * 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. + * + * example return [ "LargeFile.exe (150.00 MB)", "AlsoTooLargeOfAFile.txt (1.00 GB)" ] + */ +export function getFileFromExceedsError(error: string): string[] { + const endRegex = + /(;\sthis\sexceeds\sGitHub's\sfile\ssize\slimit\sof\s100.00\sMB)/gm + const beginRegex = /(^remote:\serror:\sFile\s)/gm + const beginMatches = Array.from(error.matchAll(beginRegex)) + const endMatches = Array.from(error.matchAll(endRegex)) + + // Something went wrong and we didn't find the same amount of endings as we did beginnings + // Just return an empty array as the output we'd give would look weird anyway + if (beginMatches.length !== endMatches.length) { + return [] + } + + const files: string[] = [] + + for (let index = 0; index < beginMatches.length; index++) { + const beginMatch = beginMatches[index] + const endMatch = endMatches[index] + + if (beginMatch.index === undefined || endMatch.index === undefined) { + continue + } + + const from = beginMatch.index + beginMatch[0].length + const to = endMatch.index + let file = error.slice(from, to) + file = file.replace('is ', '(') + file += ')' + files.push(file) + } + + return files +} diff --git a/app/src/lib/helpers/repo-rules.ts b/app/src/lib/helpers/repo-rules.ts new file mode 100644 index 0000000000..0f37055fc0 --- /dev/null +++ b/app/src/lib/helpers/repo-rules.ts @@ -0,0 +1,210 @@ +import { RE2JS } from 're2js' +import { + RepoRulesInfo, + IRepoRulesMetadataRule, + RepoRulesMetadataMatcher, + RepoRuleEnforced, +} from '../../models/repo-rules' +import { + APIRepoRuleMetadataOperator, + APIRepoRuleType, + IAPIRepoRule, + IAPIRepoRuleMetadataParameters, + IAPIRepoRuleset, +} from '../api' +import { enableRepoRulesBeta } from '../feature-flag' +import { supportsRepoRules } from '../endpoint-capabilities' +import { Account } from '../../models/account' +import { + Repository, + isRepositoryWithGitHubRepository, +} from '../../models/repository' + +/** + * 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 || + !enableRepoRulesBeta() || + !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 function parseRepoRules( + rules: ReadonlyArray, + rulesets: ReadonlyMap +): RepoRulesInfo { + const info = new RepoRulesInfo() + + 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.RequiredSignatures: + case APIRepoRuleType.RequiredStatusChecks: + info.basicCommitWarning = + info.basicCommitWarning !== true ? enforced : true + break + + case APIRepoRuleType.Creation: + info.creationRestricted = + info.creationRestricted !== 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/highlighter/types.ts b/app/src/lib/highlighter/types.ts new file mode 100644 index 0000000000..7b58e87d5f --- /dev/null +++ b/app/src/lib/highlighter/types.ts @@ -0,0 +1,81 @@ +/** + * Represents a single token inside of a line. + * This object is useless without the startIndex + * information contained within the ILineTokens interface. + */ +export interface IToken { + readonly length: number + readonly token: string +} + +/** + * A lookup object keyed on the line index (relative to the + * start of the line) containing the tokens parsed from + * that line. + */ +export interface ILineTokens { + [startIndex: number]: IToken +} + +/** + * A lookup object keyed on lines containing another + * lookup from startIndex (relative to line start position) + * and token. + * + * This structure is returned by the highlighter worker and + * is used by the Diff Syntax Mode to provide syntax + * highlighting in diffs. See the diff syntax mode for more + * details on how this object is to be interpreted. + */ +export interface ITokens { + [line: number]: ILineTokens +} + +/** + * Represents a request to detect the language and highlight + * the contents provided. + */ +export interface IHighlightRequest { + /** + * The width of a tab character. Defaults to 4. Used by the + * stream to count columns. See CodeMirror's StringStream + * class for more details. + */ + readonly tabSize: number + + /** + * The file basename of the path in question as returned + * by node's basename() function. + */ + readonly basename: string + + /** + * The file extension of the path in question as returned + * by node's extname() function (i.e. with a leading dot). + */ + readonly extension: string + + /** + * The actual content lines which is to be used for highlighting. + */ + readonly contentLines: ReadonlyArray + + /** + * An optional filter of lines which needs to be tokenized. + * + * If undefined or empty array all lines will be tokenized + * and returned. By passing an explicit set of lines we can + * both minimize the size of the response object (which needs + * to be serialized over the IPC boundary) and, for stateless + * modes we can significantly speed up the highlight process. + */ + readonly lines?: Array + + /** + * When enabled (off by default), an extra CSS class will be + * added to each token, indicating the (inner) mode that + * produced it, prefixed with "cm-m-". For example, tokens from + * the XML mode will get the cm-m-xml class. + */ + readonly addModeClass?: boolean +} diff --git a/app/src/lib/highlighter/worker.ts b/app/src/lib/highlighter/worker.ts new file mode 100644 index 0000000000..4495f98dd0 --- /dev/null +++ b/app/src/lib/highlighter/worker.ts @@ -0,0 +1,88 @@ +import { ITokens, IHighlightRequest } from './types' +import { encodePathAsUrl } from '../../lib/path' + +const highlightWorkers = new Array() +const maxIdlingWorkers = 2 +const workerMaxRunDuration = 5 * 1000 +const workerUri = encodePathAsUrl(__dirname, 'highlighter.js') + +/** + * Request an automatic detection of the language and highlight + * the contents provided. + * + * @param contents The actual contents which is to be used for + * highlighting. + * @param basename The file basename of the path in question as returned + * by node's basename() function (i.e. without a leading dot). + * @param extension The file extension of the path in question as returned + * by node's extname() function (i.e. with a leading dot). + * @param tabSize The width of a tab character. Defaults to 4. Used by the + * stream to count columns. See CodeMirror's StringStream + * class for more details. + * @param lines An optional filter of lines which needs to be tokenized. + * + * If undefined or empty all lines will be tokenized + * and returned. By passing an explicit set of lines we can + * both minimize the size of the response object (which needs + * to be serialized over the IPC boundary) and, for stateless + * modes we can significantly speed up the highlight process. + */ +export function highlight( + contentLines: ReadonlyArray, + basename: string, + extension: string, + tabSize: number, + lines: Array +): Promise { + // Bail early if there's no content to highlight or if we don't + // need any lines from this file. + if (!contentLines.length || !lines.length) { + return Promise.resolve({}) + } + + // Get an idle worker or create a new one if none exist. + const worker = highlightWorkers.shift() || new Worker(workerUri) + + return new Promise((resolve, reject) => { + let timeout: null | number = null + + const clearTimeout = () => { + if (timeout) { + window.clearTimeout(timeout) + timeout = null + } + } + + worker.onerror = ev => { + clearTimeout() + worker.terminate() + reject(ev.error || new Error(ev.message)) + } + + worker.onmessage = ev => { + clearTimeout() + if (highlightWorkers.length < maxIdlingWorkers) { + highlightWorkers.push(worker) + } else { + worker.terminate() + } + resolve(ev.data as ITokens) + } + + const request: IHighlightRequest = { + contentLines, + basename, + extension, + tabSize, + lines, + addModeClass: true, + } + + worker.postMessage(request) + + timeout = window.setTimeout(() => { + worker.terminate() + reject(new Error('timed out')) + }, workerMaxRunDuration) + }) +} diff --git a/app/src/lib/http.ts b/app/src/lib/http.ts new file mode 100644 index 0000000000..af011b326b --- /dev/null +++ b/app/src/lib/http.ts @@ -0,0 +1,204 @@ +import * as appProxy from '../ui/lib/app-proxy' +import { URL } from 'url' + +/** The HTTP methods available. */ +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'HEAD' + +/** + * The structure of error messages returned from the GitHub API. + * + * Details: https://developer.github.com/v3/#client-errors + */ +export interface IError { + readonly message: string + readonly resource: string + readonly field: string +} + +/** + * The partial server response when an error has been returned. + * + * Details: https://developer.github.com/v3/#client-errors + */ +export interface IAPIError { + readonly errors?: IError[] + readonly message?: string +} + +/** An error from getting an unexpected response to an API call. */ +export class APIError extends Error { + /** The error as sent from the API, if one could be parsed. */ + public readonly apiError: IAPIError | null + + /** The HTTP response code that the error was delivered with */ + public readonly responseStatus: number + + public constructor(response: Response, apiError: IAPIError | null) { + let message + if (apiError && apiError.message) { + message = apiError.message + + const errors = apiError.errors + const additionalMessages = errors && errors.map(e => e.message).join(', ') + if (additionalMessages) { + message = `${message} (${additionalMessages})` + } + } else { + message = `API error ${response.url}: ${response.statusText} (${response.status})` + } + + super(message) + + this.responseStatus = response.status + this.apiError = apiError + } +} + +/** + * Deserialize the HTTP response body into an expected object shape + * + * Note: this doesn't validate the expected shape, and will only fail if it + * encounters invalid JSON. + */ +async function deserialize(response: Response): Promise { + try { + const json = await response.json() + return json as T + } catch (e) { + const contentLength = response.headers.get('Content-Length') || '(missing)' + const requestId = response.headers.get('X-GitHub-Request-Id') || '(missing)' + log.warn( + `deserialize: invalid JSON found at '${response.url}' - status: ${response.status}, length: '${contentLength}' id: '${requestId}'`, + e + ) + throw e + } +} + +/** + * Convert the endpoint and resource path into an absolute URL. As the app bakes + * the `/api/v3/` path into the endpoint, we need to prevent duplicating this when + * the API returns pagination headers that also include the `/api/v3/` fragment. + * + * @param endpoint The API endpoint + * @param path The resource path (should be relative to the root of the server) + */ +export function getAbsoluteUrl(endpoint: string, path: string): string { + let relativePath = path[0] === '/' ? path.substring(1) : path + if (relativePath.startsWith('api/v3/')) { + relativePath = relativePath.substring(7) + } + + // Our API endpoints are a bit sloppy in that they don't typically + // include the trailing slash (i.e. we use https://api.github.com for + // dotcom and https://ghe.enterprise.local/api/v3 for Enterprise when + // both of those should really include the trailing slash since that's + // the qualified base). We'll work around our past since here by ensuring + // that the endpoint ends with a trailing slash. + const base = endpoint.endsWith('/') ? endpoint : `${endpoint}/` + + return new URL(relativePath, base).toString() +} + +/** + * Make an API request. + * + * @param endpoint - The API endpoint. + * @param token - The token to use for authentication. + * @param method - The HTTP method. + * @param path - The path, including any query string parameters. + * @param jsonBody - The JSON body to send. + * @param customHeaders - Any optional additional headers to send. + * @param reloadCache - sets cache option to reload — The browser fetches + * the resource from the remote server without first looking in the cache, but + * then will update the cache with the downloaded resource. + */ +export function request( + endpoint: string, + token: string | null, + method: HTTPMethod, + path: string, + jsonBody?: Object, + customHeaders?: Object, + reloadCache: boolean = false +): Promise { + const url = getAbsoluteUrl(endpoint, path) + + let headers: any = { + Accept: 'application/vnd.github.v3+json, application/json', + 'Content-Type': 'application/json', + 'User-Agent': getUserAgent(), + } + + if (token) { + headers['Authorization'] = `token ${token}` + } + + headers = { + ...headers, + ...customHeaders, + } + + const options: RequestInit = { + headers, + method, + body: JSON.stringify(jsonBody), + } + + if (reloadCache) { + options.cache = 'reload' as RequestCache + } + + return fetch(url, options) +} + +/** Get the user agent to use for all requests. */ +function getUserAgent() { + const platform = __DARWIN__ ? 'Macintosh' : 'Windows' + return `GitHubDesktop/${appProxy.getVersion()} (${platform})` +} + +/** + * If the response was OK, parse it as JSON and return the result. If not, parse + * the API error and throw it. + */ +export async function parsedResponse(response: Response): Promise { + if (response.ok) { + return deserialize(response) + } else { + let apiError: IAPIError | null + // Deserializing the API error could throw. If it does, we'll throw a more + // general API error. + try { + apiError = await deserialize(response) + } catch (e) { + throw new APIError(response, null) + } + + throw new APIError(response, apiError) + } +} + +/** + * Appends the parameters provided to the url as query string parameters. + * + * If the url already has a query the new parameters will be appended. + */ +export function urlWithQueryString( + url: string, + params: { [key: string]: string } +): string { + const qs = Object.keys(params) + .map(key => `${key}=${encodeURIComponent(params[key])}`) + .join('&') + + if (!qs.length) { + return url + } + + if (url.indexOf('?') === -1) { + return `${url}?${qs}` + } else { + return `${url}&${qs}` + } +} diff --git a/app/src/lib/infer-last-push-for-repository.ts b/app/src/lib/infer-last-push-for-repository.ts new file mode 100644 index 0000000000..a21b344e47 --- /dev/null +++ b/app/src/lib/infer-last-push-for-repository.ts @@ -0,0 +1,56 @@ +import { GitStore } from './stores' +import { Repository } from '../models/repository' +import { Account } from '../models/account' +import { getAccountForRepository } from './get-account-for-repository' +import { API } from './api' +import { matchGitHubRepository } from './repository-matching' + +/** + * Use the GitHub API to find the last push date for a repository, favouring + * the current remote (if defined) or falling back to the detected GitHub remote + * if no tracking information set for the current branch. + * + * Returns null if no date can be detected. + * + * @param accounts available accounts in the app + * @param gitStore Git information about the repository + * @param repository the local repository tracked by Desktop + */ +export async function inferLastPushForRepository( + accounts: ReadonlyArray, + gitStore: GitStore, + repository: Repository +): Promise { + const account = getAccountForRepository(accounts, repository) + if (account == null) { + return null + } + + await gitStore.loadRemotes() + const { currentRemote } = gitStore + + const api = API.fromAccount(account) + if (currentRemote !== null) { + const matchedRepository = matchGitHubRepository(accounts, currentRemote.url) + + if (matchedRepository !== null) { + const { owner, name } = matchedRepository + const repo = await api.fetchRepository(owner, name) + + if (repo !== null) { + return new Date(repo.pushed_at) + } + } + } + + if (repository.gitHubRepository !== null) { + const { owner, name } = repository.gitHubRepository + const repo = await api.fetchRepository(owner.login, name) + + if (repo !== null) { + return new Date(repo.pushed_at) + } + } + + return null +} diff --git a/app/src/lib/ipc-renderer.ts b/app/src/lib/ipc-renderer.ts new file mode 100644 index 0000000000..a75c7427b9 --- /dev/null +++ b/app/src/lib/ipc-renderer.ts @@ -0,0 +1,83 @@ +import { RequestResponseChannels, RequestChannels } from './ipc-shared' +// eslint-disable-next-line no-restricted-imports +import { ipcRenderer, IpcRendererEvent } from 'electron' + +/** + * Send a message to the main process via channel and expect a result + * asynchronously. This is the equivalent of ipcRenderer.invoke except with + * strong typing guarantees. + */ +export function invoke( + channel: T, + ...args: Parameters +): ReturnType { + return ipcRenderer.invoke(channel, ...args) as any +} + +/** + * Send a message to the main process via channel asynchronously. This is the + * equivalent of ipcRenderer.send except with strong typing guarantees. + */ +export function send( + channel: T, + ...args: Parameters +): void { + return ipcRenderer.send(channel, ...args) as any +} + +/** + * Send a message to the main process via channel synchronously. This is the + * equivalent of ipcRenderer.sendSync except with strong typing guarantees. + */ +export function sendSync( + channel: T, + ...args: Parameters +): void { + // eslint-disable-next-line no-sync + return ipcRenderer.sendSync(channel, ...args) as any +} + +/** + * Subscribes to the specified IPC channel and provides strong typing of + * the channel name, and request parameters. This is the equivalent of + * using ipcRenderer.on. + */ +export function on( + channel: T, + listener: ( + event: IpcRendererEvent, + ...args: Parameters + ) => void +) { + ipcRenderer.on(channel, listener as any) +} + +/** + * Subscribes to the specified IPC channel and provides strong typing of + * the channel name, and request parameters. This is the equivalent of + * using ipcRenderer.once + */ +export function once( + channel: T, + listener: ( + event: IpcRendererEvent, + ...args: Parameters + ) => void +) { + ipcRenderer.once(channel, listener as any) +} + +/** + * Unsubscribes from the specified IPC channel and provides strong typing of + * the channel name, and request parameters. This is the equivalent of + * using ipcRenderer.removeListener + */ +export function removeListener( + channel: T, + listener: ( + event: IpcRendererEvent, + ...args: Parameters + ) => void +) { + ipcRenderer.removeListener(channel, listener as any) +} diff --git a/app/src/lib/ipc-shared.ts b/app/src/lib/ipc-shared.ts new file mode 100644 index 0000000000..a8d4b3328a --- /dev/null +++ b/app/src/lib/ipc-shared.ts @@ -0,0 +1,131 @@ +import { IMenuItemState } from './menu-update' +import { MenuIDs } from '../models/menu-ids' +import { ISerializableMenuItem } from './menu-item' +import { MenuLabelsEvent } from '../models/menu-labels' +import { MenuEvent } from '../main-process/menu' +import { LogLevel } from './logging/log-level' +import { ICrashDetails } from '../crash/shared' +import { WindowState } from './window-state' +import { IMenu } from '../models/app-menu' +import { ILaunchStats } from './stats' +import { URLActionType } from './parse-app-url' +import { Architecture } from './get-architecture' +import { EndpointToken } from './endpoint-token' +import { PathType } from '../ui/lib/app-proxy' +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' + +/** + * Defines the simplex IPC channel names we use from the renderer + * process along with their signatures. This type is used from both + * the renderer and the main process to ensure a common contract between + * the two over the untyped IPC framework. + */ +export type RequestChannels = { + 'select-all-window-contents': () => void + 'update-menu-state': ( + state: Array<{ id: MenuIDs; state: IMenuItemState }> + ) => void + 'renderer-ready': (time: number) => void + 'execute-menu-item-by-id': (id: string) => void + 'show-certificate-trust-dialog': ( + certificate: Electron.Certificate, + message: string + ) => void + 'get-app-menu': () => void + 'update-preferred-app-menu-item-labels': (labels: MenuLabelsEvent) => void + 'uncaught-exception': (error: Error) => void + 'send-error-report': ( + error: Error, + extra: Record, + nonFatal: boolean + ) => void + 'unsafe-open-directory': (path: string) => void + '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 + error: (crashDetails: ICrashDetails) => void + 'zoom-factor-changed': (zoomFactor: number) => void + 'app-menu': (menu: IMenu) => void + 'launch-timing-stats': (stats: ILaunchStats) => void + 'url-action': (action: URLActionType) => void + 'certificate-error': ( + certificate: Electron.Certificate, + error: string, + url: string + ) => void + focus: () => void + blur: () => void + 'update-accounts': (accounts: ReadonlyArray) => void + 'quit-and-install-updates': () => void + 'quit-app': () => void + 'minimize-window': () => void + 'maximize-window': () => void + 'unmaximize-window': () => void + 'close-window': () => void + 'auto-updater-error': (error: Error) => void + 'auto-updater-checking-for-update': () => void + 'auto-updater-update-available': () => void + 'auto-updater-update-not-available': () => void + 'auto-updater-update-downloaded': () => void + 'native-theme-updated': () => void + 'set-native-theme-source': (themeName: ThemeSource) => void + 'focus-window': () => void + 'notification-event': NotificationCallback + 'set-window-zoom-factor': (zoomFactor: number) => void + 'show-installing-update': () => void +} + +/** + * Defines the duplex IPC channel names we use from the renderer + * process along with their signatures. This type is used from both + * the renderer and the main process to ensure a common contract between + * the two over the untyped IPC framework. + * + * Return signatures must be promises + */ +export type RequestResponseChannels = { + 'get-path': (path: PathType) => Promise + 'get-app-architecture': () => Promise + 'get-app-path': () => Promise + 'is-running-under-arm64-translation': () => Promise + 'move-to-trash': (path: string) => Promise + 'show-item-in-folder': (path: string) => Promise + 'show-contextual-menu': ( + items: ReadonlyArray, + addSpellCheckMenu: boolean + ) => Promise | null> + 'is-window-focused': () => Promise + 'open-external': (path: string) => Promise + 'is-in-application-folder': () => Promise + 'move-to-applications-folder': () => Promise + 'check-for-updates': (url: string) => Promise + 'get-current-window-state': () => Promise + 'get-current-window-zoom-factor': () => Promise + 'resolve-proxy': (url: string) => Promise + 'show-save-dialog': ( + options: Electron.SaveDialogOptions + ) => Promise + 'show-open-dialog': ( + options: Electron.OpenDialogOptions + ) => Promise + 'is-window-maximized': () => Promise + 'get-apple-action-on-double-click': () => Promise + 'should-use-dark-colors': () => Promise + 'save-guid': (guid: string) => Promise + 'get-guid': () => Promise + 'show-notification': ( + title: string, + body: string, + userInfo?: DesktopAliveEvent + ) => Promise + 'get-notifications-permission': () => Promise + 'request-notifications-permission': () => Promise +} diff --git a/app/src/lib/is-application-bundle.ts b/app/src/lib/is-application-bundle.ts new file mode 100644 index 0000000000..779a7e179d --- /dev/null +++ b/app/src/lib/is-application-bundle.ts @@ -0,0 +1,47 @@ +import { execFile } from './exec-file' + +/** + * Attempts to determine if the provided path is an application bundle or not. + * + * macOS differs from the other platforms we support in that a directory can + * also be an application and therefore executable making it unsafe to open + * directories on macOS as we could conceivably end up launching an application. + * + * This application uses file metadata (the `mdls` tool to be exact) to + * determine whether a path is actually an application bundle or otherwise + * executable. + * + * NOTE: This method will always return false when not running on macOS. + */ +export async function isApplicationBundle(path: string): Promise { + if (process.platform !== 'darwin') { + return false + } + + // Expected output for an application bundle: + // $ mdls -name kMDItemContentType -name kMDItemContentTypeTree /Applications/GitHub\ Desktop.app + // kMDItemContentType = "com.apple.application-bundle" + // kMDItemContentTypeTree = ( + // "com.apple.application-bundle", + // "com.apple.application", + // "public.executable", + // "com.apple.localizable-name-bundle", + // "com.apple.bundle", + // "public.directory", + // "public.item", + // "com.apple.package" + // ) + const { stdout } = await execFile('mdls', [ + ...['-name', 'kMDItemContentType'], + ...['-name', 'kMDItemContentTypeTree'], + path, + ]) + + const probableBundleIdentifiers = [ + 'com.apple.application-bundle', + 'com.apple.application', + 'public.executable', + ] + + return probableBundleIdentifiers.some(id => stdout.includes(`"${id}"`)) +} diff --git a/app/src/lib/is-empty-or-whitespace.ts b/app/src/lib/is-empty-or-whitespace.ts new file mode 100644 index 0000000000..0afec12a86 --- /dev/null +++ b/app/src/lib/is-empty-or-whitespace.ts @@ -0,0 +1,6 @@ +/** + * Indicates whether the given string is empty or consists only of white-space + * characters. + */ +export const isEmptyOrWhitespace = (s: string) => + s.length === 0 || !/\S/.test(s) diff --git a/app/src/lib/is-git-on-path.ts b/app/src/lib/is-git-on-path.ts new file mode 100644 index 0000000000..c9ba9abfa9 --- /dev/null +++ b/app/src/lib/is-git-on-path.ts @@ -0,0 +1,33 @@ +import * as Path from 'path' +import { execFile } from './exec-file' + +const findOnPath = (program: string) => { + if (__WIN32__) { + const cwd = process.env.SystemRoot || 'C:\\Windows' + const cmd = Path.join(cwd, 'System32', 'where.exe') + return execFile(cmd, [program], { cwd }) + } + return execFile('which', [program]) +} + +/** Attempts to locate the path to the system version of Git */ +export const findGitOnPath = () => + // `where` (i.e on Windows) will list _all_ PATH components where the + // executable is found, one per line, and return 0, or print an error and + // return 1 if it cannot be found. + // + // `which` (i.e. on macOS and Linux) will print the path and return 0 + // when the executable is found under PATH, or return 1 if it cannot be found + findOnPath('git') + .then(({ stdout }) => stdout.split(/\r?\n/, 1)[0]) + .catch(err => { + log.warn(`Failed trying to find Git on PATH`, err) + return undefined + }) + +/** Returns a value indicating whether Git was found in the system's PATH */ +export const isGitOnPath = async () => + // Modern versions of macOS ship with a Git shim that guides you through + // the process of setting everything up. We trust this is available, so + // don't worry about looking for it here. + __DARWIN__ || (await findGitOnPath()) !== undefined diff --git a/app/src/lib/large-files.ts b/app/src/lib/large-files.ts new file mode 100644 index 0000000000..1e58869596 --- /dev/null +++ b/app/src/lib/large-files.ts @@ -0,0 +1,42 @@ +import { WorkingDirectoryStatus } from '../models/status' +import { DiffSelectionType } from '../models/diff' +import { Repository } from '../models/repository' +import { stat } from 'fs/promises' +import { join } from 'path' + +const ReceiveLimit = 100 * 1024 * 1024 // 100 MiB + +/** + * Retrieve paths of working directory files that are larger than a given Megabyte size. + * + * @param repository - The repository from which the base file directory will be retrieved. + * @param workingDirectory - The collection of changed files, from which the selected files will + * be determined. + * @param maximumSizeMB - The size limit (in Megabytes) at which an exceeding file size will + * result in it's path being retrieved. + */ +export async function getLargeFilePaths( + repository: Repository, + workingDirectory: WorkingDirectoryStatus +) { + const fileNames = new Array() + const workingDirectoryFiles = workingDirectory.files + const includedFiles = workingDirectoryFiles.filter( + file => file.selection.getSelectionType() !== DiffSelectionType.None + ) + + for (const file of includedFiles) { + const filePath = join(repository.path, file.path) + try { + const fileStatus = await stat(filePath) + const fileSizeBytes = fileStatus.size + if (fileSizeBytes > ReceiveLimit) { + fileNames.push(file.path) + } + } catch (error) { + log.debug(`Unable to get the file size for ${filePath}`, error) + } + } + + return fileNames +} diff --git a/app/src/lib/local-storage.ts b/app/src/lib/local-storage.ts new file mode 100644 index 0000000000..efc6c40bba --- /dev/null +++ b/app/src/lib/local-storage.ts @@ -0,0 +1,240 @@ +import { parseEnumValue } from './enum' + +/** + * Returns the value for the provided key from local storage interpreted as a + * boolean or the provided `defaultValue` if the key doesn't exist. + * + * @param key local storage entry to find + * @param defaultValue fallback value if key not found + */ +export function getBoolean(key: string): boolean | undefined +export function getBoolean(key: string, defaultValue: boolean): boolean +export function getBoolean( + key: string, + defaultValue?: boolean +): boolean | undefined { + const value = localStorage.getItem(key) + if (value === null) { + return defaultValue + } + + // NOTE: + // 'true' and 'false' were acceptable values for controlling feature flags + // but it required users to set them manually, and were not documented well in + // the codebase + // For now we can check these values for compatibility, but we could drop + // these at some point in the future + + if (value === '1' || value === 'true') { + return true + } + + if (value === '0' || value === 'false') { + return false + } + + return defaultValue +} + +/** + * Set the provided key in local storage to a boolean value, or update the + * existing value if a key is already defined. + * + * `true` and `false` will be encoded as the string '1' or '0' respectively. + * + * @param key local storage entry to update + * @param value the boolean to set + */ +export function setBoolean(key: string, value: boolean) { + localStorage.setItem(key, value ? '1' : '0') +} + +/** + * Retrieve a integer number value from a given local storage entry if found, or the + * provided `defaultValue` if the key doesn't exist or if the value cannot be + * converted into a number + * + * @param key local storage entry to read + * @param defaultValue fallback value if unable to find key or valid value + */ +export function getNumber(key: string): number | undefined +export function getNumber(key: string, defaultValue: number): number +export function getNumber( + key: string, + defaultValue?: number +): number | undefined { + const numberAsText = localStorage.getItem(key) + + if (numberAsText === null || numberAsText.length === 0) { + return defaultValue + } + + const value = parseInt(numberAsText, 10) + if (isNaN(value)) { + return defaultValue + } + + return value +} + +/** + * Retrieve a floating point number value from a given local storage entry if + * found, or the provided `defaultValue` if the key doesn't exist or if the + * value cannot be converted into a number + * + * @param key local storage entry to read + * @param defaultValue fallback value if unable to find key or valid value + */ +export function getFloatNumber(key: string): number | undefined +export function getFloatNumber(key: string, defaultValue: number): number +export function getFloatNumber( + key: string, + defaultValue?: number +): number | undefined { + const numberAsText = localStorage.getItem(key) + + if (numberAsText === null || numberAsText.length === 0) { + return defaultValue + } + + const value = parseFloat(numberAsText) + if (isNaN(value)) { + return defaultValue + } + + return value +} + +/** + * Set the provided key in local storage to a numeric value, or update the + * existing value if a key is already defined. + * + * Stores the string representation of the number. + * + * @param key local storage entry to update + * @param value the number to set + */ +export function setNumber(key: string, value: number) { + localStorage.setItem(key, value.toString()) +} + +/** + * Retrieve an array of `number` values from a given local + * storage entry, if found. The array will be empty if the + * key doesn't exist or if the values cannot be converted + * into numbers + * + * @param key local storage entry to read + */ +export function getNumberArray(key: string): ReadonlyArray { + return (localStorage.getItem(key) || '') + .split(NumberArrayDelimiter) + .map(parseFloat) + .filter(n => !isNaN(n)) +} + +/** + * Set the provided key in local storage to a list of numeric values, or update the + * existing value if a key is already defined. + * + * Stores the string representation of the number, delimited. + * + * @param key local storage entry to update + * @param values the numbers to set + */ +export function setNumberArray(key: string, values: ReadonlyArray) { + localStorage.setItem(key, values.join(NumberArrayDelimiter)) +} + +/** + * Retrieve an array of `string` values from a given local + * storage entry, if found. The array will be empty if the + * key doesn't exist or if the values cannot be converted + * into strings. + * + * @param key local storage entry to read + */ +export function getStringArray(key: string): ReadonlyArray { + const rawData = localStorage.getItem(key) || '[]' + + try { + const outputArray = JSON.parse(rawData) + + if (!(outputArray instanceof Array)) { + return [] + } + + if (outputArray.some(element => typeof element !== 'string')) { + return [] + } + + return outputArray + } catch (e) { + return [] + } +} + +/** + * Set the provided key in local storage to a list of string values, or update the + * existing value if a key is already defined. + * + * @param key local storage entry to update + * @param values the strings to set + */ +export function setStringArray(key: string, values: ReadonlyArray) { + const rawData = JSON.stringify(values) + + localStorage.setItem(key, rawData) +} + +/** Default delimiter for stringifying and parsing arrays of numbers */ +const NumberArrayDelimiter = ',' + +/** + * Load a (string) enum based on its stored value. See `parseEnumValue` for more + * details on the conversion. Note that there's no `setEnum` companion method + * here since callers can just use `localStorage.setItem(key, enumValue)` + * + * @param key The localStorage key to read from + * @param enumObj The Enum type definition + */ +export function getEnum( + key: string, + enumObj: Record +): T | undefined { + const storedValue = localStorage.getItem(key) + return storedValue === null ? undefined : parseEnumValue(enumObj, storedValue) +} + +/** + * Retrieve an object of type T's value from a given local + * storage entry, if found. If not found, return undefined. + * + * @param key local storage entry to read + */ +export function getObject(key: string): T | undefined { + const rawData = localStorage.getItem(key) + + if (rawData === null) { + return + } + + try { + return JSON.parse(rawData) + } catch (e) { + // If corrupted and can't be parsed, we return undefined. + return + } +} + +/** + * Set the provided key in local storage to an object, or update the + * existing value if a key is already defined. + * + * @param key local storage entry to update + * @param value the object to set + */ +export function setObject(key: string, value: object) { + const rawData = JSON.stringify(value) + localStorage.setItem(key, rawData) +} diff --git a/app/src/lib/logging/format-error.ts b/app/src/lib/logging/format-error.ts new file mode 100644 index 0000000000..61e2dc6f67 --- /dev/null +++ b/app/src/lib/logging/format-error.ts @@ -0,0 +1,17 @@ +import { withSourceMappedStack } from '../source-map-support' + +/** + * Formats an error for log file output. Use this instead of + * multiple calls to log.error. + */ +export function formatError(error: Error, title?: string) { + error = withSourceMappedStack(error) + + if (error.stack) { + return title ? `${title}\n${error.stack}` : error.stack.trim() + } else { + return title + ? `${title}\n${error.name}: ${error.message}` + : `${error.name}: ${error.message}` + } +} diff --git a/app/src/lib/logging/format-log-message.ts b/app/src/lib/logging/format-log-message.ts new file mode 100644 index 0000000000..1f14e954f4 --- /dev/null +++ b/app/src/lib/logging/format-log-message.ts @@ -0,0 +1,5 @@ +import { formatError } from './format-error' + +export function formatLogMessage(message: string, error?: Error) { + return error ? formatError(error, message) : message +} diff --git a/app/src/lib/logging/get-log-path.ts b/app/src/lib/logging/get-log-path.ts new file mode 100644 index 0000000000..47ff9b2a4d --- /dev/null +++ b/app/src/lib/logging/get-log-path.ts @@ -0,0 +1,13 @@ +import * as Path from 'path' +import { app } from 'electron' + +let logDirectoryPath: string | null = null + +export function getLogDirectoryPath() { + if (!logDirectoryPath) { + const userData = app.getPath('userData') + logDirectoryPath = Path.join(userData, 'logs') + } + + return logDirectoryPath +} diff --git a/app/src/lib/logging/log-level.ts b/app/src/lib/logging/log-level.ts new file mode 100644 index 0000000000..7428d24b32 --- /dev/null +++ b/app/src/lib/logging/log-level.ts @@ -0,0 +1 @@ +export type LogLevel = 'error' | 'warn' | 'info' | 'debug' diff --git a/app/src/lib/logging/main/install.ts b/app/src/lib/logging/main/install.ts new file mode 100644 index 0000000000..b2a2141977 --- /dev/null +++ b/app/src/lib/logging/main/install.ts @@ -0,0 +1,19 @@ +import { log } from '../../../main-process/log' +import { formatLogMessage } from '../format-log-message' + +const g = global as any + +g.log = { + error(message: string, error?: Error) { + log('error', '[main] ' + formatLogMessage(message, error)) + }, + warn(message: string, error?: Error) { + log('warn', '[main] ' + formatLogMessage(message, error)) + }, + info(message: string, error?: Error) { + log('info', '[main] ' + formatLogMessage(message, error)) + }, + debug(message: string, error?: Error) { + log('debug', '[main] ' + formatLogMessage(message, error)) + }, +} as IDesktopLogger diff --git a/app/src/lib/logging/renderer/install.ts b/app/src/lib/logging/renderer/install.ts new file mode 100644 index 0000000000..7806ae0fac --- /dev/null +++ b/app/src/lib/logging/renderer/install.ts @@ -0,0 +1,34 @@ +import { LogLevel } from '../log-level' +import { formatLogMessage } from '../format-log-message' +import { sendProxy } from '../../../ui/main-process-proxy' + +const g = global as any +const ipcLog = sendProxy('log', 2) + +/** + * Dispatches the given log entry to the main process where it will be picked + * written to all log transports. See initializeWinston in logger.ts for more + * details about what transports we set up. + */ +function log(level: LogLevel, message: string, error?: Error) { + ipcLog(level, formatLogMessage(`[${__PROCESS_KIND__}] ${message}`, error)) +} + +g.log = { + error(message: string, error?: Error) { + log('error', message, error) + console.error(formatLogMessage(message, error)) + }, + warn(message: string, error?: Error) { + log('warn', message, error) + console.warn(formatLogMessage(message, error)) + }, + info(message: string, error?: Error) { + log('info', message, error) + console.info(formatLogMessage(message, error)) + }, + debug(message: string, error?: Error) { + log('debug', message, error) + console.debug(formatLogMessage(message, error)) + }, +} as IDesktopLogger diff --git a/app/src/lib/markdown-filters/close-keyword-filter.ts b/app/src/lib/markdown-filters/close-keyword-filter.ts new file mode 100644 index 0000000000..6cb711b8e8 --- /dev/null +++ b/app/src/lib/markdown-filters/close-keyword-filter.ts @@ -0,0 +1,190 @@ +import { GitHubRepository } from '../../models/github-repository' +import { issueUrl } from './issue-link-filter' +import { IssueReference } from './issue-mention-filter' +import { INodeFilter, MarkdownContext } from './node-filter' + +/** Markdown locations that can have closing keywords */ +const IssueClosingContext: ReadonlyArray = [ + 'Commit', + 'PullRequest', +] + +/** Determines if markdown context could have issue closing mention */ +export function isIssueClosingContext(markdownContext: MarkdownContext) { + return IssueClosingContext.includes(markdownContext) +} + +/** + * The Closes keyword filter matches text nodes for a set of key words that + * indicate the markdown closes an issue (Closes, fixes) followed by a issue + * reference. It replaces the closes keywords it with a span to be styled and + * provide a tooltip indicating the markdown will close the referenced issue. + * + * Markdown that can have these keywords are pull request bodies and commit + * messages. + * + * Closes keywords are close, closes, closed, fix, fixes, fixed, resolve, + * resolves, and resolved. + * + * Issue reference can be plain test like #1234 or can be a pasted issue link + * like https://github.com/owner/repo/issues/1234. + * + * Example: + * 'Closes #1234' becomes + * 'Closes #1234' + */ +export class CloseKeywordFilter implements INodeFilter { + private closesWithTextReference = new RegExp( + this.closeText('closeTextWIssue').source + + '(?' + + IssueReference.source + + ')' + ) + + private closesAtEndOfText = new RegExp( + this.closeText('closeTextAtEnd').source + /$/.source + ) + + private closesKeywordUnion = new RegExp( + '(' + + this.closesWithTextReference.source + + ')|(' + + this.closesAtEndOfText.source + + ')', + 'ig' + ) + + public constructor( + /** The context from which the markdown content originated from - such as a PullRequest or PullRequest Comment */ + private readonly markdownContext: MarkdownContext, + /** The repository which the markdown content originated from */ + private readonly repository: GitHubRepository + ) {} + + /** + * Searches for the words: close, closes, closed, fix, fixes, fixed, resolve, + * resolves, resolved + * + * Expects one or more spaces at the end to avoid false matches like + * owner/fixops#1 + */ + private closeText(groupName: string) { + return new RegExp( + /\b/.source + + `(?<${groupName}>` + + /close[sd]?|fix(e[sd])?|resolve[sd]?/.source + + ')' + + /(\s*:?\s+)/.source + ) + } + + /** + * Close keyword filter iterates on all text nodes that are not inside a pre, + * code, or anchor tag. + */ + public createFilterTreeWalker(doc: Document): TreeWalker { + return doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT, { + acceptNode: node => { + return (node.parentNode !== null && + ['CODE', 'PRE', 'A'].includes(node.parentNode.nodeName)) || + node.textContent === null + ? NodeFilter.FILTER_SKIP + : NodeFilter.FILTER_ACCEPT + }, + }) + } + + /** + * Takes a text node that matches a close keyword pattern and returns an array + * of nodes to replace the text node where the matching keyword becomes a span + * element. + * + * Example: Closes #1 becomes [Closes, ' #1'] + */ + public async filter(node: Node): Promise | null> { + const text = node.textContent + if (node.nodeType !== node.TEXT_NODE || text === null) { + return null + } + + const matches = [...text.matchAll(this.closesKeywordUnion)] + if (matches.length === 0) { + return null + } + + let lastMatchEndingPosition = 0 + const nodes: Array = [] + for (const match of matches) { + if (match.groups === undefined || match.index === undefined) { + continue + } + const { closeTextWIssue, closeTextAtEnd, issueReference } = match.groups + const closeText = closeTextWIssue ?? closeTextAtEnd + const issueDesc = + issueReference ?? this.getIssueReferenceFromSibling(node.nextSibling) + + if (issueDesc === undefined || closeText === undefined) { + return null + } + + const span = this.createTooltipContent(closeText, issueDesc) + + const textBefore = text.slice(lastMatchEndingPosition, match.index) + nodes.push(document.createTextNode(textBefore)) + nodes.push(span) + + lastMatchEndingPosition = match.index + closeText.length + } + + const trailingText = text.slice(lastMatchEndingPosition) + if (trailingText !== '') { + nodes.push(document.createTextNode(trailingText)) + } + + return nodes + } + + /** + * A match of something like 'Closes ' a text issue reference can happen if + * someone pastes a link to an issue reference such as + * https://github.com/owner/repo/issues/1234. + * This having been processed by a markdown parser will make that be `Closes https://github.com/owner/repo/issues/1234`. + * In this case, we still want to format the closes keyword. + * + * This method takes the current text nodes next sibling and inspects it to + * see if it is a pasted issue url. If so, returns a text issue reference, + * otherwise returns undefined. + */ + private getIssueReferenceFromSibling(siblingNode: ChildNode | null) { + if ( + siblingNode === null || + !(siblingNode instanceof HTMLAnchorElement) || + siblingNode.href !== siblingNode.innerText + ) { + return + } + + const issueLinkMatches = siblingNode.href.match(issueUrl(this.repository)) + if ( + issueLinkMatches === null || + issueLinkMatches.groups === undefined || + issueLinkMatches.groups.refNumber === undefined + ) { + return + } + + return `#${issueLinkMatches.groups.refNumber}` + } + + private createTooltipContent(closesText: string, issueNumber: string) { + const tooltipSpan = document.createElement('span') + tooltipSpan.textContent = closesText + tooltipSpan.classList.add('issue-keyword') + tooltipSpan.ariaLabel = `This ${ + this.markdownContext === 'Commit' ? 'commit' : 'pull request' + } closes ${issueNumber}.` + return tooltipSpan + } +} diff --git a/app/src/lib/markdown-filters/commit-mention-filter.ts b/app/src/lib/markdown-filters/commit-mention-filter.ts new file mode 100644 index 0000000000..c84283b635 --- /dev/null +++ b/app/src/lib/markdown-filters/commit-mention-filter.ts @@ -0,0 +1,334 @@ +import { GitHubRepository } from '../../models/github-repository' +import { getHTMLURL } from '../api' +import { INodeFilter } from './node-filter' +import { resolveOwnerRepo } from './resolve-owner-repo' + +/** + * The Commit Mention Filter matches for sha patterns and replaces them with + * links to sha and concatenates long ones. + * + * There are three sha patterns: + * 1) SHA (7-40 hex characters) + * 2) SHA...SHA + * 3) user/repo@SHA + * + * Notes: + * 1) When no user/repo is provided, the link defaults to the provided repo + * owner and/or repo name. + * 2) Notable difference from dotcom approach is that, it does not verify + * commit exists in the given repo context. (To note, dotcom doesn't verify + * for repo's outside of the markdown context.) This improves performance at + * the cost of false-positives. Additionally, all commit shas are trimmed to + * 7 characters, if >= 30 characters, unlike dotcom that obtains the git + * short sha for shas in the markdown context. + * + * Example: A text node of "Check out desktop/desktop@123456781012134543265 for + * an idea of how to do it..." Becomes three nodes: + * 1) "Check out " + * 2) desktop/desktop@1234567 + * 3) " for an idea of how to do it..." + */ +export class CommitMentionFilter implements INodeFilter { + /** A commit reference can start at beginning of a string or be prefaced by a whitespace character, (, {, or [ */ + private readonly sharedLeader = /^|[\s({\[]/ + + /** Some references also can be prefaced with an @ */ + private readonly sharedLeaderWithAt = new RegExp( + this.sharedLeader.source + '|@' + ) + + /** + * Some references can be prefaced with .. or ... + * Notes: not using quantify pattern so it can be used in a positive look behind + */ + private readonly sharedLeaderWithAtAndDots = new RegExp( + this.sharedLeaderWithAt.source + '|' + /\.\.|\.\.\./.source + ) + + /** Sha */ + private readonly sha = /[0-9a-f]{7,40}/ + + /** Sha followed by a non-word character **/ + private readonly endBoundedSha = new RegExp(this.sha.source + /\b/.source) + + /** + * Looking for SHA...SHA + * At start of string or prefaced with space like character, (, {, @, or [ + * Suffixed by a word boundary character such as a space or a period. + * + * Examples: + * 1234567...1234567 + * 1234567...1234567. + * 1234567...1234567 + * [1234567...1234567, + * {1234567...1234567, + * (1234567...1234567 + * */ + private readonly shaRange = new RegExp( + '(?' + + // Positive look behind for start of string, (, {, @, or [ + '(?<=' + + this.sharedLeaderWithAt.source + + ')' + + // first sha + '(?' + + this.sha.source + + ')' + + // The joiner ... + /\.\.\./.source + + // last sha + '(?' + + this.sha.source + + ')' + + ')' + + // must be followed by boundary character (but not part of shaRange) + /\b/.source + ) + + /** + * Looking for a commit + * + * Examples: + * 1234567 + * ..1234567 + * ...1234567 + * (1234567 + * {1234567 + * 1234567 + * [1234567 + * 1234567. + * (But not 1234567L (l is not 0-9a-f)) + */ + private boundedSha = new RegExp( + '(?' + + // Positive look behind for start of string, (, {, @, [, .., or ... + '(?<=' + + this.sharedLeaderWithAtAndDots.source + + ')' + + '(?' + + this.sha.source + + ')' + + ')' + + // must be followed by boundary character (but not part of shaRange) + /\b/.source + ) + + /** Matches for 'user' or 'user/repo' */ + private readonly ownerOrOwnerRepo = /(?[\w-]+\/?[\w.-]*)/ + + /** + * Loosely looking for user@sha or user/repo@sha + * + * tidy-dev@1234567 + * tidy-dev/test@1234567 + */ + private readonly ownerSpecifiedSha = new RegExp( + '(?' + + // Positive look behind for start of string, (, {, , or [ + '(?<=' + + this.sharedLeader.source + + ')' + + this.ownerOrOwnerRepo.source + + '@' + + '(?' + + this.sha.source + + ')' + + /\b/.source + + ')' + ) + + private readonly commitShaRegexUnion = new RegExp( + this.shaRange.source + + '|' + + this.ownerSpecifiedSha.source + + '|' + + this.boundedSha.source, + 'g' + ) + + public constructor( + /** The repository which the markdown content originated from */ + private readonly repository: GitHubRepository + ) {} + + /** + * Commit mention filters iterates on all text nodes that are not inside a pre, + * code, or anchor tag. The text node would also at a minimum not be null and + * end in a commit sha. + */ + public createFilterTreeWalker(doc: Document): TreeWalker { + return doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT, { + acceptNode: node => { + return (node.parentNode !== null && + ['CODE', 'PRE', 'A'].includes(node.parentNode.nodeName)) || + node.textContent === null || + !this.endBoundedSha.test(node.textContent) + ? NodeFilter.FILTER_SKIP + : NodeFilter.FILTER_ACCEPT + }, + }) + } + + /** + * Takes a text node and creates multiple text and link nodes by inserting + * commit sha link nodes where commit mentions are. + * + * Warning: This filter can create false positives. It assumes the commits exist. + */ + public async filter(node: Node): Promise | null> { + const { textContent: text } = node + if (node.nodeType !== node.TEXT_NODE || text === null) { + return null + } + + const matches = [...text.matchAll(this.commitShaRegexUnion)] + if (matches.length === 0) { + return null + } + + let lastMatchEndingPosition = 0 + const nodes: Array = [] + for (const match of matches) { + if (match.groups === undefined || match.index === undefined) { + continue + } + + const link = this.createLink(match.groups) + // This is possible because owner specified regex could match on invalid + // owner/repo based on the repo this markdown is from. + if (link === undefined) { + continue + } + + const textBefore = text.slice(lastMatchEndingPosition, match.index) + nodes.push(document.createTextNode(textBefore)) + nodes.push(link) + + const { shaRange, ownerSpecifiedSha, boundedSha } = match.groups + lastMatchEndingPosition = + match.index + (shaRange ?? ownerSpecifiedSha ?? boundedSha ?? '').length + } + + const trailingText = text.slice(lastMatchEndingPosition) + if (trailingText !== '') { + nodes.push(document.createTextNode(trailingText)) + } + + return nodes + } + + /** + * Method to determine what kind of link to make depending on which commit + * mention regex was matched. + * + * The names group names names are defined in regex's: this.shaRange, + * this.ownerSpecifiedSha, this.boundedSha + */ + private createLink(matchGroups: { [key: string]: string }) { + const { + shaRange, + firstSha, + lastSha, + ownerSpecifiedSha, + ownerOrOwnerRepo, + ownerSha, + boundedSha, + rawBoundedSha, + } = matchGroups + + if (shaRange !== undefined) { + return this.createCommitShaRangeLinkElement(firstSha, lastSha) + } + + if (ownerSpecifiedSha !== undefined) { + return this.createOwnerSpecifiedCommitLinkElement( + ownerOrOwnerRepo, + ownerSha + ) + } + + if (boundedSha !== undefined) { + return this.createCommitMentionLinkElement( + this.trimCommitSha(rawBoundedSha), + 'commit' + ) + } + + return + } + + /** + * Method to create a commit sha mention link element for a sha range + **/ + private createCommitShaRangeLinkElement(firstSha: string, lastSha: string) { + return this.createCommitMentionLinkElement( + `${this.trimCommitSha(firstSha)}...${this.trimCommitSha(lastSha)}`, + 'compare' + ) + } + + /** + * Method to create a commit sha mention link element for an owner specified + * commit sha + **/ + private createOwnerSpecifiedCommitLinkElement( + ownerOrOwnerRepo: string, + sha: string + ) { + const ownerAndRepo = resolveOwnerRepo(ownerOrOwnerRepo, this.repository) + + // It was an owner only and it wasn't the owner of this repo (or otherwise + // bad data), so we don't have repo name to link it to. + if (ownerAndRepo === null) { + return + } + + const trimmedSha = this.trimCommitSha(sha) + // It was the owner of this repo or empty array because it matched this + // repository, either we just want to ignore it. + if (ownerAndRepo.length < 2) { + return this.createCommitMentionLinkElement(trimmedSha) + } + + // Otherwise, a owner and repo name outside of the context of this markdown + // was given and needs to be specified + const [repoOwner, repoName] = ownerAndRepo + return this.createCommitMentionLinkElement( + trimmedSha, + 'commit', + repoOwner, + repoName, + `${repoOwner}/${repoName}@` + ) + } + + /** + * Method to create a commit mention link element. + * + * If for a range, it links to a 'compare' view + * If for a commit, it links to a 'commit' view (default) + **/ + private createCommitMentionLinkElement( + ref: string, + view: 'commit' | 'compare' = 'commit', + repoOwner: string = this.repository.owner.login, + repoName: string = this.repository.name, + refPreface?: string + ) { + const baseHref = getHTMLURL(this.repository.endpoint) + const href = `${baseHref}/${repoOwner}/${repoName}/${view}/${ref}` + const anchor = document.createElement('a') + anchor.innerHTML = `${refPreface ?? ''}${ref}` + anchor.href = href + return anchor + } + + /** + * Method to trim the shas + * + * If sha >= 30, trimmed to first 7 + */ + private trimCommitSha(sha: string) { + return sha.length >= 30 ? sha.slice(0, 7) : sha + } +} diff --git a/app/src/lib/markdown-filters/commit-mention-link-filter.ts b/app/src/lib/markdown-filters/commit-mention-link-filter.ts new file mode 100644 index 0000000000..f32669df2e --- /dev/null +++ b/app/src/lib/markdown-filters/commit-mention-link-filter.ts @@ -0,0 +1,293 @@ +import { escapeRegExp } from 'lodash' +import { GitHubRepository } from '../../models/github-repository' +import { getHTMLURL } from '../api' +import { INodeFilter } from './node-filter' + +/** + * The Commit mention Link filter matches the target and text of an anchor element that + * is an commit mention link and changes the text to a uniform + * reference. + * + * Types of commit mention links: + * - Plain Single Commit: https://github.com/desktop/desktop/commit/6fd794543af171c35cc9c325f570f9553128ffc9 + * - Compare a range of Commits: https://github.com/desktop/desktop/compare/6fd794543...6fd794543 + * - Pull Request Commit: https://github.com/desktop/desktop/pull/14239/commits/6fd794543af171c35cc9c325f570f9553128ffc9 + * + * Example: + * https://github.com/desktop/desktop/commit/6fd794543af171c35cc9c325f570f9553128ffc9 + * + * Becomes + * 6fd7945 + * + * or this, if not owned by current repository, + * desktop/desktop@6fd7945 + * + * + * The intention behind this node filter is for use after the markdown parser + * that has taken raw urls and auto tagged them them as anchor elements. + * + * Trailing filepath and query parameters: Plain and compare links may be + * followed by further filepaths and query params. Pull request commits links + * cannot. Additionally, plain link paths have some that may not follow that + * indicate reserved actions paths -- see method isReservedCommitActionPath. Thus, + * https://github.com/desktop/desktop/commit/6fd7945/test/test/test will become + * 6fd7945/test/test/test. + * + */ +export class CommitMentionLinkFilter implements INodeFilter { + /** A regexp that searches for the owner/name pattern in issue href */ + private readonly nameWithOwner = + /(?-?[a-z0-9][a-z0-9\-\_]*)\/(?(?:\w|\.|\-)+)/ + + /** + * A regexp that searches for a url path pattern for a commit + * + * Example: /desktop/desktop/commit/6fd7945 + */ + private readonly commitPath = /^commit\/(?.+)$/ + + /** + * A regexp that searches for a url path pattern for a compare + * + * Example: /desktop/desktop/commit/6fd7945...6fd7945 + */ + private readonly comparePath = /^compare\/(?.+)$/ + + /** + * A regexp that searches for a url path pattern for a compare + * + * Example: /desktop/desktop/commit/6fd7945...6fd7945 + */ + private readonly pullCommitPath = + /^pull\/(\d+)\/commits\/(?[0-9a-f]{7,40})$/ + + /** A regexp that matches a full issue, pull request, or discussion url + * including the anchor */ + private get commitMentionUrl(): RegExp { + const gitHubURL = getHTMLURL(this.repository.endpoint) + return new RegExp( + escapeRegExp(gitHubURL) + + '/' + + this.nameWithOwner.source + + '/' + + /(commit|pull|compare)/.source + + '/' + + /(\d+\/commits\/)?/.source + + /([0-9a-f]{7,40})/.source + + /\b/.source + ) + } + + /** The parent github repository of which the content the filter is being + * applied to belongs */ + private readonly repository: GitHubRepository + + public constructor(repository: GitHubRepository) { + this.repository = repository + } + + /** + * Commit mention link filter iterates on all anchor elements that are not + * inside a pre, code, or anchor tag and resemble a commit mention link and + * their href matches their inner text. + * + * Looking for something like: + * https://github.com/desktop/desktop/commit/6fd7945 + * Where the href could be like: + * - Plain Single Commit: https://github.com/desktop/desktop/commit/6fd7945 + * - Compare a range of Commits: https://github.com/desktop/desktop/compare/6fd7945...6fd7945 + * - Pull Request Commit: https://github.com/desktop/desktop/pull/14239/commits/6fd7945 + */ + public createFilterTreeWalker(doc: Document): TreeWalker { + return doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT, { + acceptNode: (el: Element) => { + return (el.parentNode !== null && + ['CODE', 'PRE', 'A'].includes(el.parentNode.nodeName)) || + !(el instanceof HTMLAnchorElement) || + el.href !== el.innerText || + !this.commitMentionUrl.test(el.href) + ? NodeFilter.FILTER_SKIP + : NodeFilter.FILTER_ACCEPT + }, + }) + } + + /** + * Takes an anchor element that's href and inner text looks like a github + * references and prepares an anchor element with a consistent issue reference + * as the inner text to replace it with. + * + * Example: + * Anchor tag of = https://github.com/owner/repo/issues/1234 + * Output = [#1234] + */ + public async filter(node: Node): Promise | null> { + const newNode = node.cloneNode(true) + const { textContent: text } = newNode + if (!(newNode instanceof HTMLAnchorElement) || text === null) { + return null + } + + const url = new URL(text) + const [, owner, name] = url.pathname.split('/', 3) + if (owner === undefined || name === undefined) { + return null + } + const slashes = 3 + const path = url.pathname.substring(owner.length + name.length + slashes) + + let ref, filepathToAppend + + const commitComparePathMatch = + this.getRefFromCommitPath(path) ?? this.getRefFromComparePath(path) + if (commitComparePathMatch !== null) { + ;({ ref, filepathToAppend } = commitComparePathMatch) + + filepathToAppend = + filepathToAppend !== undefined + ? filepathToAppend + url.search + : url.search + } else { + ref = this.getRefFromPullPath(path) + } + + if (ref === null || ref === undefined) { + return null + } + + newNode.innerHTML = this.getCommitMentionRef( + owner, + name, + ref, + filepathToAppend + ) + return [newNode] + } + + private getRefFromCommitPath(path: string) { + const match = path.match(this.commitPath) + if (match === null || match.groups === undefined) { + return null + } + + const { pathFragment } = match.groups + const slashIndex = pathFragment.indexOf('/') + const possibleSha = + slashIndex >= 0 ? pathFragment.slice(0, slashIndex) : pathFragment + const filepathToAppend = + slashIndex >= 0 ? pathFragment.slice(slashIndex) : undefined + + if (possibleSha === undefined) { + return null + } + const [sha, format] = possibleSha.split('.') + + if ( + sha === undefined || + this.isReservedCommitActionPath(filepathToAppend) || + format !== undefined + ) { + return null + } + + return { + ref: this.trimCommitSha(sha), + filepathToAppend, + } + } + + private getRefFromComparePath(path: string) { + const match = path.match(this.comparePath) + if (match === null || match.groups === undefined) { + return null + } + + const { range } = match.groups + + if (/\.(diff|path)$/.test(range)) { + return null + } + + const shas = range.split('...') + if (shas.length > 2) { + return null + } + + const slashIndex = shas[1].indexOf('/') + const secondSha = slashIndex >= 0 ? shas[1].slice(0, slashIndex) : shas[1] + + return { + ref: `${this.trimCommitSha(shas[0])}...${this.trimCommitSha(secondSha)}`, + filepathToAppend: slashIndex >= 0 ? shas[1].slice(slashIndex) : undefined, + } + } + + private getRefFromPullPath(path: string) { + const match = path.match(this.pullCommitPath) + if (match === null || match.groups === undefined) { + return null + } + + return this.trimCommitSha(match.groups.sha) + } + + /** + * Commit action path's are not formatted nor shortened. + * + * Commit links could be action paths + * ${github.url}/owner/repo/commit/1234567/${actionPathPossibility} + * + * where actionPathPossibility could look like: + * "_render_node/partialpath" + * "checks" + * "checks/123" + * "checks/123/logs" + * "checks_state_summary" + * "hovercard" + * "rollup" + * "show_partial" + */ + private isReservedCommitActionPath(filePath: string | undefined) { + const commitActions = [ + 'checks_state_summary', + 'hovercard', + 'rollup', + 'show_partial', + ] + if (filePath === undefined) { + return false + } + + const commitActionsWithParams = ['_render_node', 'checks'] + return ( + commitActions.includes(filePath) || + commitActionsWithParams.includes(filePath.split('/')[0]) + ) + } + + /** + * Creates commit sha references + */ + private getCommitMentionRef( + owner: string, + name: string, + shaRef: string, + filePath?: string + ) { + const ownerRepo = + owner !== this.repository.owner.login || name !== this.repository.name + ? `${owner}/${name}@` + : '' + const trimmedSha = this.trimCommitSha(shaRef) + return `${ownerRepo}${trimmedSha}${filePath ?? ''}` + } + + /** + * Method to trim the shas + * + * If sha >= 30, trimmed to first 7 + */ + private trimCommitSha(sha: string) { + return sha.length >= 30 ? sha.slice(0, 7) : sha + } +} diff --git a/app/src/lib/markdown-filters/emoji-filter.ts b/app/src/lib/markdown-filters/emoji-filter.ts new file mode 100644 index 0000000000..82d00dd4aa --- /dev/null +++ b/app/src/lib/markdown-filters/emoji-filter.ts @@ -0,0 +1,145 @@ +import { INodeFilter } from './node-filter' +import { fileURLToPath } from 'url' +import { readFile } from 'fs/promises' +import { escapeRegExp } from 'lodash' + +/** + * The Emoji Markdown filter will take a text node and create multiple text and + * image nodes by inserting emoji images using base64 data uri where emoji + * references are in the text node. + * + * Example: A text node of "That is great! :+1: Good Job!" + * Becomes three nodes: "That is great! ", + 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) + } + + /** + * Emoji filter iterates on all text nodes that are not inside a pre or code tag. + */ + public createFilterTreeWalker(doc: Document): TreeWalker { + return doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT, { + acceptNode: function (node) { + return node.parentNode !== null && + ['CODE', 'PRE'].includes(node.parentNode.nodeName) + ? NodeFilter.FILTER_SKIP + : NodeFilter.FILTER_ACCEPT + }, + }) + } + + /** + * Takes a text node and creates multiple text and image nodes by inserting + * emoji image nodes using base64 data uri where emoji references are. + * + * Example: A text node of "That is great! :+1: Good Job!" Becomes three + * nodes: ["That is great! ", | null> { + let text = node.textContent + if ( + node.nodeType !== node.TEXT_NODE || + text === null || + !text.includes(':') + ) { + return null + } + + const emojiMatches = text.match(this.emojiRegex) + if (emojiMatches === null) { + return null + } + + 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) { + continue + } + + const emojiImg = await this.createEmojiNode(emojiPath) + if (emojiImg === null) { + continue + } + + const emojiPosition = text.indexOf(emojiKey) + const textBeforeEmoji = text.slice(0, emojiPosition) + const textNodeBeforeEmoji = document.createTextNode(textBeforeEmoji) + nodes.push(textNodeBeforeEmoji) + nodes.push(emojiImg) + + text = text.slice(emojiPosition + emojiKey.length) + } + + if (text !== '') { + const trailingTextNode = document.createTextNode(text) + nodes.push(trailingTextNode) + } + + return nodes + } + + /** + * 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. + */ + private async createEmojiNode( + emojiPath: string + ): Promise { + try { + const dataURI = await this.getBase64FromImageUrl(emojiPath) + const emojiImg = new Image() + emojiImg.classList.add('emoji') + emojiImg.src = dataURI + return emojiImg + } catch (e) {} + return null + } + + /** + * Method to obtain an images base 64 data uri from it's file path. + * - It checks cache, if not, reads from file, then stores in cache. + */ + private async getBase64FromImageUrl(filePath: string): Promise { + const cached = this.emojiBase64URICache.get(filePath) + if (cached !== undefined) { + return cached + } + const imageBuffer = await readFile(fileURLToPath(filePath)) + const b64src = imageBuffer.toString('base64') + const uri = `data:image/png;base64,${b64src}` + this.emojiBase64URICache.set(filePath, uri) + + return uri + } + + /** + * Builds a regular expression that is looking for all group of characters + * that represents any emoji ref (or map key) in the provided map. + * + * @param emoji Map from the emoji ref (e.g., :+1:) to the image's local path. + */ + private buildEmojiRegExp(emoji: Map): RegExp { + const emojiGroups = [...emoji.keys()] + .map(emoji => escapeRegExp(emoji)) + .join('|') + return new RegExp('(' + emojiGroups + ')', 'g') + } +} diff --git a/app/src/lib/markdown-filters/issue-link-filter.ts b/app/src/lib/markdown-filters/issue-link-filter.ts new file mode 100644 index 0000000000..99599296f7 --- /dev/null +++ b/app/src/lib/markdown-filters/issue-link-filter.ts @@ -0,0 +1,169 @@ +import { escapeRegExp } from 'lodash' +import { GitHubRepository } from '../../models/github-repository' +import { getHTMLURL } from '../api' +import { INodeFilter } from './node-filter' + +/** Return a regexp that matches a full issue, pull request, or discussion url + * including the anchor */ +export function issueUrl(repository: GitHubRepository): RegExp { + const gitHubURL = getHTMLURL(repository.endpoint) + return new RegExp( + escapeRegExp(gitHubURL) + + '/' + + /** A regexp that searches for the owner/name pattern in issue href */ + /(?\w+(?:-\w+)*\/[.\w-]+)/.source + + '/' + + /(?:issues|pull|discussions)/.source + + '/' + + /** A regexp that searches for the number and #anchor of an issue reference */ + /(?\d+)(?#[\w-]+)?\b/.source + ) +} + +/** + * The Issue Link filter matches the target and text of an anchor element that + * is an issue, pull request, or discussion and changes the text to a uniform + * reference. + * + * Example: + * https://github.com/github/github/issues/99872 + * Becomes + * #99872 + * + * Additionally if a link has an anchor tag such as #discussioncomment-1858985. + * We will append a relevant description. + * + * The intention behind this node filter is for use after the markdown parser + * that has taken raw urls and auto tagged them them as anchor elements. + */ +export class IssueLinkFilter implements INodeFilter { + public constructor( + /** The repository which the markdown content originated from */ + private readonly repository: GitHubRepository + ) {} + + /** + * Issue link mention filter iterates on all anchor elements that are not + * inside a pre, code, or anchor tag and resemble an issue, pull request, or + * discussion link and their href matches their inner text. + * + * Looking for something like: + * https://github.com/github/github/issues/99872 + * Where the href could be like: + * - https://github.com/github/github/issues/99872 + * - https://github.com/github/github/pulls/99872 + * - https://github.com/github/github/discussions/99872 + * - https://github.com/github/github/discussions/99872#discussioncomment-1858985 + */ + public createFilterTreeWalker(doc: Document): TreeWalker { + return doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT, { + acceptNode: (el: Element) => { + return (el.parentNode !== null && + ['CODE', 'PRE', 'A'].includes(el.parentNode.nodeName)) || + !(el instanceof HTMLAnchorElement) || + el.href !== el.innerText || + !this.isGitHubIssuePullDiscussionLink(el) + ? NodeFilter.FILTER_SKIP + : NodeFilter.FILTER_ACCEPT + }, + }) + } + + /** + * Returns true if the given anchor element is a link to a GitHub issue, + * discussion, or the default tab of a pull request. + */ + private isGitHubIssuePullDiscussionLink(anchor: HTMLAnchorElement) { + const isIssuePullOrDiscussion = /(issue|pull|discussion)/.test(anchor.href) + if (!isIssuePullOrDiscussion) { + return false + } + + const isPullRequestTab = /\d+\/(files|commits|conflicts|checks)/.test( + anchor.href + ) + if (isPullRequestTab) { + return false + } + + const isURlCustomFormat = /\.[a-z]+\z/.test(anchor.href) + if (isURlCustomFormat) { + return false + } + + return issueUrl(this.repository).test(anchor.href) + } + + /** + * Takes an anchor element that's href and inner text looks like a github + * references and prepares an anchor element with a consistent issue reference + * as the inner text to replace it with. + * + * Example: + * Anchor tag of = https://github.com/owner/repo/issues/1234 + * Output = [#1234] + */ + public async filter(node: Node): Promise | null> { + const { textContent: text } = node + if (!(node instanceof HTMLAnchorElement) || text === null) { + return null + } + + const match = text.match(issueUrl(this.repository)) + if (match === null || match.groups === undefined) { + return null + } + + const { refNumber, anchor } = match.groups + const newNode = node.cloneNode(true) + newNode.textContent = this.getConsistentIssueReferenceText( + refNumber, + anchor + ) + + return [newNode] + } + + /** + * Creates a standard issue references and description. + * + * Examples: + * Issue 1 => #1 + * Issue 1#discussion-comment-1234 => #1 (comment) + */ + private getConsistentIssueReferenceText(refNumber: string, anchor?: string) { + const text = `#${refNumber}` + const anchorDescription = this.getAnchorDescription(anchor) + return `${text} ${anchorDescription}` + } + + /** + * Provides generic description for a provided href anchor. + * + * Example: An anchor "#commits-pushed-1234" returns "(commits)". + * + * If the anchor does not fit a common anchor type , it defaults `(comment)` + */ + private getAnchorDescription(anchor: string | undefined) { + if (anchor === undefined) { + return '' + } + + switch (true) { + case /discussion-diff-/.test(anchor): + return '(diff)' + case /commits-pushed-/.test(anchor): + return '(commits)' + case /ref-/.test(anchor): + return '(reference)' + case /pullrequestreview/.test(anchor): + return '(review)' + // Note: On dotcom, there is an additional case + // /discussioncomment-/.test(anchor): and a check for threaded that + // returns '(reply in thread)' as opposed to '(comment)', but this would + // require an api call to determine. + } + + return '(comment)' + } +} diff --git a/app/src/lib/markdown-filters/issue-mention-filter.ts b/app/src/lib/markdown-filters/issue-mention-filter.ts new file mode 100644 index 0000000000..33248a055b --- /dev/null +++ b/app/src/lib/markdown-filters/issue-mention-filter.ts @@ -0,0 +1,212 @@ +import { GitHubRepository } from '../../models/github-repository' +import { getHTMLURL } from '../api' +import { INodeFilter } from './node-filter' +import { resolveOwnerRepo } from './resolve-owner-repo' + +/** A regular expression to match a group of any digit follow by a word + * bounding character. + * Example: 123 or 123. + */ +const IssueRefNumber = /(?\d+)\b/ + +/** A regular expression to match a group of an repo name or name with owner + * Example: desktop/dugite or desktop + */ +const IssueOwnerOrOwnerRepo = /(?\w+(?:-\w+)*(?:\/[.\w-]+)?)/ + +/** A regular expression to match a group possible of preceding markers are + * gh-, #, /issues/, /pull/, or /discussions/ followed by a digit + */ +const IssueMentionMarker = + /(?#|gh-|\/(?:issues|pull|discussions)\/)(?=\d)/i + +/** + * A regular expression string of a lookbehind is used so that valid matches + * for the issue reference have the leader precede them but the leader is not + * considered part of the match. An issue reference much have a whitespace, + * beginning of line, or some other non-word character must precede it. + * */ +const IssueMentionLeader = /(?<=^|\W)/ + +/** + * A regular expression matching an issue reference. Issue reference must: + * 1) Start with an issue marker: gh-, #, /issues/, /pull/, or /discussions/ + * 2) The issue marker must be followed by a number + * 3) The number must end in a word bounding character. Additionally, the + * issue reference match may be such that the marker may be preceded by a + * repo references of owner/repo or owner + * */ +export const IssueReference = new RegExp( + IssueOwnerOrOwnerRepo.source + + '?' + + IssueMentionMarker.source + + IssueRefNumber.source, + 'i' +) + +/** + * The Issue Mention filter matches for text issue references. For this purpose, + * issues, pull requests, and discussions all share reference patterns and + * therefore are all filtered. + * + * Examples: #1234, gh-1234, /issues/1234, /pull/1234, or /discussions/1234, + * desktop/dugite#1, desktop/dugite/issues/1234 + * + * Each references is made up of {ownerOrOwnerRepo}{marker}{number} and must be + * preceded by a non-word character. + * - ownerOrOwnerRepo: Optional. If both owner/repo is provided, it can be + * used to specify an issue outside of the parent repository. + * Redundant references will be trimmed. Single owners can be + * redundant, but single repo names are treated as non-matches. + * + * Example: When viewing from the tidy-dev/foo repo, + * a. tidy-dev/foo#1 becomes linked as #1. + * b. tidy-dev#1 becomes linked as #1, + * c. foo#1 is not linked and is a non-match. + * d. desktop/desktop#1 is linked and stays desktop/desktop#1 + * + * - marker: Required #, gh-, /issues/, /pull/, or /discussions/ + * - number: Required and must be digits followed by a word bounding + * character like a whitespace or period. + * + */ +export class IssueMentionFilter implements INodeFilter { + /** + * A regular expression matching an issue reference. + * Issue reference must: + * 1) Be preceded by a beginning of a line or some some other non-word + * character. + * 2) Start with an issue marker: gh-, #, /issues/, /pull/, or /discussions/ + * 3) The issue marker must be followed by a number + * 4) The number must end in a word bounding character. Additionally, the + * issue reference match may be such that the marker may be preceded by a + * repo references of owner/repo or owner + * */ + private readonly issueReferenceWithLeader = new RegExp( + IssueMentionLeader.source + IssueReference.source, + 'ig' + ) + + /** The parent github repository of which the content the filter is being + * applied to belongs */ + private readonly repository: GitHubRepository + + public constructor(repository: GitHubRepository) { + this.repository = repository + } + + /** + * Returns tree walker that iterates on all text nodes that are not inside a + * pre, code, or anchor tag. + */ + public createFilterTreeWalker(doc: Document): TreeWalker { + return doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT, { + acceptNode: function (node) { + return node.parentNode !== null && + ['CODE', 'PRE', 'A'].includes(node.parentNode.nodeName) + ? NodeFilter.FILTER_SKIP + : NodeFilter.FILTER_ACCEPT + }, + }) + } + + /** + * Takes a text node and creates multiple text and anchor nodes by inserting + * anchor tags where the matched issue mentions exist. + * + * Warning: This filter can create false positives. It assumes the issues + * exists that are mentioned. Thus, if a user references a non-existent + * #99999999 issue, it will still create a link for it. This is a deviation + * from dotcoms approach that verifies each link, but we do not want to incur + * the performance penalty of making that call. + * + * Example: + * Node = "Issue #1234 is the same thing" + * Output = ["Issue ", #1234, " is the same thing"] + */ + public async filter(node: Node): Promise | null> { + const { textContent: text } = node + if ( + node.nodeType !== node.TEXT_NODE || + text === null || + !IssueMentionMarker.test(text) + ) { + return null + } + + let lastMatchEndingPosition = 0 + const nodes: Array = [] + const matches = text.matchAll(this.issueReferenceWithLeader) + for (const match of matches) { + if (match.groups === undefined || match.index === undefined) { + continue + } + + const { marker, refNumber, ownerOrOwnerRepo } = match.groups + if (marker === undefined || refNumber === undefined) { + continue + } + + const link = this.createLinkElement(marker, refNumber, ownerOrOwnerRepo) + if (link === null) { + continue + } + + const textBefore = text.slice(lastMatchEndingPosition, match.index) + const textNodeBefore = document.createTextNode(textBefore) + nodes.push(textNodeBefore) + nodes.push(link) + + lastMatchEndingPosition = + match.index + + (ownerOrOwnerRepo?.length ?? 0) + + marker.length + + refNumber.length + } + + const trailingText = text.slice(lastMatchEndingPosition) + if (trailingText !== '') { + nodes.push(document.createTextNode(trailingText)) + } + + return nodes + } + + /** + * Method to create the issue mention anchor. If unable to parse ownerRepo, + * then returns a null as this would indicate an invalid reference. + */ + private createLinkElement( + marker: string, + refNumber: string, + ownerOrOwnerRepo?: string + ) { + let text = `${marker}${refNumber}` + + const ownerRepo = resolveOwnerRepo(ownerOrOwnerRepo, this.repository) + if (ownerRepo === null) { + return null + } + + let [owner, repo] = ownerRepo + if (owner !== undefined && repo !== undefined) { + text = `${ownerOrOwnerRepo}${text}` + } else { + owner = this.repository.owner.login + repo = this.repository.name + } + + const baseHref = getHTMLURL(this.repository.endpoint) + // We are choosing to use issues as GitHub will redirect an issues url to + // pull requests and discussions as needed. However, if a user erroneously + // referenced a pull request #2 with /discussions/2 marker, and we were to use + // `discussions` because of that, the user would end up at a not found page. + // This way they will end up at the pull request (same behavior in dotcom). + const href = `${baseHref}/${owner}/${repo}/issues/${refNumber}` + + const anchor = document.createElement('a') + anchor.textContent = text + anchor.href = href + return anchor + } +} diff --git a/app/src/lib/markdown-filters/markdown-filter.ts b/app/src/lib/markdown-filters/markdown-filter.ts new file mode 100644 index 0000000000..0151622616 --- /dev/null +++ b/app/src/lib/markdown-filters/markdown-filter.ts @@ -0,0 +1,90 @@ +import DOMPurify from 'dompurify' +import { Disposable, Emitter } from 'event-kit' +import { marked } from 'marked' +import { + applyNodeFilters, + buildCustomMarkDownNodeFilterPipe, + ICustomMarkdownFilterOptions, +} from './node-filter' + +/** + * The MarkdownEmitter extends the Emitter functionality to be able to keep + * track of the last emitted value and return it upon subscription. + */ +export class MarkdownEmitter extends Emitter { + public constructor(private markdown: null | string = null) { + super() + } + + public onMarkdownUpdated(handler: (value: string) => void): Disposable { + if (this.markdown !== null) { + handler(this.markdown) + } + return super.on('markdown', handler) + } + + public emit(value: string): void { + this.markdown = value + super.emit('markdown', value) + } + + public get latestMarkdown() { + return this.markdown + } +} + +/** + * Takes string of markdown and runs it through the MarkedJs parser with github + * flavored flags followed by sanitization with domPurify. + * + * If custom markdown options are provided, it applies the custom markdown + * filters. + * + * Rely `repository` custom markdown option: + * - TeamMentionFilter + * - MentionFilter + * - CommitMentionFilter + * - CommitMentionLinkFilter + * + * Rely `markdownContext` custom markdown option: + * - IssueMentionFilter + * - IssueLinkFilter + * - CloseKeyWordFilter + */ +export function parseMarkdown( + markdown: string, + customMarkdownOptions?: ICustomMarkdownFilterOptions +): MarkdownEmitter { + const parsedMarkdown = marked(markdown, { + // https://marked.js.org/using_advanced If true, use approved GitHub + // Flavored Markdown (GFM) specification. + gfm: true, + // https://marked.js.org/using_advanced, If true, add
on a single + // line break (copies GitHub behavior on comments, but not on rendered + // markdown files). Requires gfm be true. + breaks: true, + }) + + const sanitizedMarkdown = DOMPurify.sanitize(parsedMarkdown) + const markdownEmitter = new MarkdownEmitter(sanitizedMarkdown) + + if (customMarkdownOptions !== undefined) { + applyCustomMarkdownFilters(markdownEmitter, customMarkdownOptions) + } + + return markdownEmitter +} + +/** + * Applies custom markdown filters to parsed markdown html. This is done + * through converting the markdown html into a DOM document and then + * traversing the nodes to apply custom filters such as emoji, issue, username + * mentions, etc. (Expects a markdownEmitter with an initial markdown value) + */ +function applyCustomMarkdownFilters( + markdownEmitter: MarkdownEmitter, + options: ICustomMarkdownFilterOptions +): void { + const nodeFilters = buildCustomMarkDownNodeFilterPipe(options) + applyNodeFilters(nodeFilters, markdownEmitter) +} diff --git a/app/src/lib/markdown-filters/mention-filter.ts b/app/src/lib/markdown-filters/mention-filter.ts new file mode 100644 index 0000000000..335a6a42ff --- /dev/null +++ b/app/src/lib/markdown-filters/mention-filter.ts @@ -0,0 +1,142 @@ +import { GitHubRepository } from '../../models/github-repository' +import { getHTMLURL } from '../api' +import { INodeFilter } from './node-filter' + +/** + * The Mention Markdown filter looks for user logins and replaces them with + * links the users profile. Specifically looking at text nodes and if a + * reference like @user is found, it will replace the text node with three nodes + * - one being a link. + * + * Mentions in
,  and  elements are ignored. Contrary to dotcom
+ * where it confirms whether the user exists or not, we assume they do exist for
+ * the sake of performance (not doing database hits to see if the mentioned user
+ * exists)
+ *
+ * Example: A text node of "That is great @tidy-dev! Good Job!"
+ * Becomes three nodes:
+ * 1) "That is great "
+ * 2) @tidy-dev
+ * 3) "! Good Job!"
+ */
+export class MentionFilter implements INodeFilter {
+  // beginning of string or non-word, non-` char
+  private readonly beginStringNonWord = /(^|[^a-zA-Z0-9_`])/
+
+  // @username and @username_emu for enterprise managed users support
+  private readonly userNameRef =
+    /(?@[a-z0-9][a-z0-9-]*_[a-zA-Z0-9]+|@[a-z0-9][a-z0-9-]*)/
+
+  // without a trailing slash
+  private readonly withoutTrailingSlash = /(?!\/)/
+
+  // dots followed by space or non-word character
+  private readonly dotsFollowedBySpace = /\.+[\t\W]/
+
+  // dots at end of line
+  private readonly dotsAtEndOfLine = /\.+$/
+
+  // non-word character except dot, ` , or -
+  // Note: In the case of usernames, the hyphen is a word character.
+  private readonly nonWordExceptDotOrBackTickOrHyphen = /[^0-9a-zA-Z_.`-]/
+
+  // Pattern used to extract @mentions from text
+  // Looking for @user or @user_user
+  // Note: Enterprise managed users may have underscores
+  private readonly mentionRegex = new RegExp(
+    this.beginStringNonWord.source +
+      this.userNameRef.source +
+      this.withoutTrailingSlash.source +
+      '(?=' +
+      this.dotsFollowedBySpace.source +
+      '|' +
+      this.dotsAtEndOfLine.source +
+      '|' +
+      this.nonWordExceptDotOrBackTickOrHyphen.source +
+      '|' +
+      '$)', // end of line
+    'ig'
+  )
+
+  public constructor(
+    /** The repository which the markdown content originated from */
+    private readonly repository: GitHubRepository
+  ) {}
+
+  /**
+   * Mention filters iterates on all text nodes that are not inside a pre, code,
+   * or anchor tag.
+   */
+  public createFilterTreeWalker(doc: Document): TreeWalker {
+    return doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT, {
+      acceptNode: function (node) {
+        return node.parentNode !== null &&
+          ['CODE', 'PRE', 'A'].includes(node.parentNode.nodeName)
+          ? NodeFilter.FILTER_SKIP
+          : NodeFilter.FILTER_ACCEPT
+      },
+    })
+  }
+
+  /**
+   * Takes a text node and creates multiple text and link nodes by inserting
+   * user link nodes where username references are.
+   *
+   * Warning: This filter can create false positives. It assumes the users exist.
+   *
+   * Note: Mention filter requires text nodes; otherwise we may inadvertently
+   * replace non text elements.
+   */
+  public async filter(node: Node): Promise | null> {
+    const { textContent: text } = node
+    if (
+      node.nodeType !== node.TEXT_NODE ||
+      text === null ||
+      !text.includes('@')
+    ) {
+      return null
+    }
+
+    let lastMatchEndingPosition = 0
+    const nodes: Array = []
+    const matches = text.matchAll(this.mentionRegex)
+    for (const match of matches) {
+      if (match.groups === undefined || match.index === undefined) {
+        continue
+      }
+
+      const { userNameRef } = match.groups
+      if (userNameRef === undefined) {
+        continue
+      }
+
+      const link = this.createLinkElement(userNameRef)
+      const refPosition = match.index === 0 ? 0 : match.index + 1
+      const textBefore = text.slice(lastMatchEndingPosition, refPosition)
+      const textNodeBefore = document.createTextNode(textBefore)
+      nodes.push(textNodeBefore)
+      nodes.push(link)
+
+      lastMatchEndingPosition = refPosition + (userNameRef.length ?? 0)
+    }
+
+    const trailingText = text.slice(lastMatchEndingPosition)
+    if (trailingText !== '') {
+      nodes.push(document.createTextNode(trailingText))
+    }
+
+    return nodes
+  }
+
+  /**
+   * Method to create the user mention anchor.
+   **/
+  private createLinkElement(userNameRef: string) {
+    const baseHref = getHTMLURL(this.repository.endpoint)
+    const href = `${baseHref}/${userNameRef.slice(1)}`
+    const anchor = document.createElement('a')
+    anchor.textContent = userNameRef
+    anchor.href = href
+    return anchor
+  }
+}
diff --git a/app/src/lib/markdown-filters/node-filter.ts b/app/src/lib/markdown-filters/node-filter.ts
new file mode 100644
index 0000000000..83ad978260
--- /dev/null
+++ b/app/src/lib/markdown-filters/node-filter.ts
@@ -0,0 +1,163 @@
+import memoizeOne from 'memoize-one'
+import { EmojiFilter } from './emoji-filter'
+import { IssueLinkFilter } from './issue-link-filter'
+import { IssueMentionFilter } from './issue-mention-filter'
+import { MentionFilter } from './mention-filter'
+import { VideoLinkFilter } from './video-link-filter'
+import { VideoTagFilter } from './video-tag-filter'
+import { TeamMentionFilter } from './team-mention-filter'
+import { CommitMentionFilter } from './commit-mention-filter'
+import {
+  CloseKeywordFilter,
+  isIssueClosingContext,
+} from './close-keyword-filter'
+import { CommitMentionLinkFilter } from './commit-mention-link-filter'
+import { MarkdownEmitter } from './markdown-filter'
+import { GitHubRepository } from '../../models/github-repository'
+
+export interface INodeFilter {
+  /**
+   * Creates a document tree walker filtered to the nodes relevant to the node filter.
+   *
+   * Examples:
+   * 1) An Emoji filter operates on all text nodes, but not inside pre or code tags.
+   * 2) The issue mention filter operates on all text nodes, but not inside pre, code, or anchor tags
+   */
+  createFilterTreeWalker(doc: Document): TreeWalker
+
+  /**
+   * This filter accepts a document node and searches for it's pattern within it.
+   *
+   * If found, returns an array of nodes to replace the node with.
+   *    Example: [Node(contents before match), Node(match replacement), Node(contents after match)]
+   * If not found, returns null
+   *
+   * This is asynchronous as some filters have data must be fetched or, like in
+   * emoji, the conversion to base 64 data uri is asynchronous
+   * */
+  filter(node: Node): Promise | null>
+}
+
+export interface ICustomMarkdownFilterOptions {
+  emoji: Map
+  repository?: GitHubRepository
+  markdownContext?: MarkdownContext
+}
+
+/**
+ * Builds an array of node filters to apply to markdown html. Referring to it as pipe
+ * because they will be applied in the order they are entered in the returned
+ * array. This is important as some filters impact others.
+ */
+export const buildCustomMarkDownNodeFilterPipe = memoizeOne(
+  (options: ICustomMarkdownFilterOptions): ReadonlyArray => {
+    const { emoji, repository, markdownContext } = options
+    const filterPipe: Array = []
+
+    if (repository !== undefined) {
+      /* The CloseKeywordFilter must be applied before the IssueMentionFilter or
+       * IssueLinkFilter so we can scan for plain text or pasted link issue
+       * mentions in conjunction wth the keyword.
+       */
+      if (
+        markdownContext !== undefined &&
+        isIssueClosingContext(markdownContext)
+      ) {
+        filterPipe.push(new CloseKeywordFilter(markdownContext, repository))
+      }
+
+      filterPipe.push(
+        new IssueMentionFilter(repository),
+        new IssueLinkFilter(repository)
+      )
+    }
+
+    filterPipe.push(new EmojiFilter(emoji))
+
+    if (repository !== undefined) {
+      filterPipe.push(
+        // Note: TeamMentionFilter was placed before MentionFilter as they search
+        // for similar patterns with TeamMentionFilter having a larger application.
+        // @org/something vs @username. Thus, even tho the MentionFilter regex is
+        // meant to prevent this, in case a username could be encapsulated in the
+        // team mention like @username/something, we do the team mentions first to
+        // eliminate the possibility.
+        new TeamMentionFilter(repository),
+        new MentionFilter(repository),
+        new CommitMentionFilter(repository),
+        new CommitMentionLinkFilter(repository)
+      )
+    }
+
+    filterPipe.push(new VideoTagFilter(), new VideoLinkFilter())
+
+    return filterPipe
+  }
+)
+
+/**
+ * Method takes an array of node filters and applies them to a markdown string.
+ *
+ * It converts the markdown string into a DOM Document. Then, iterates over each
+ * provided filter. Each filter will have method to create a tree walker to
+ * limit the document nodes relative to the filter's purpose. Then, it will
+ * replace any affected node with the node(s) generated by the node filter. If a
+ * node is not impacted, it is not replace.
+ */
+export async function applyNodeFilters(
+  nodeFilters: ReadonlyArray,
+  markdownEmitter: MarkdownEmitter
+): Promise {
+  if (markdownEmitter.latestMarkdown === null || markdownEmitter.disposed) {
+    return
+  }
+
+  const mdDoc = new DOMParser().parseFromString(
+    markdownEmitter.latestMarkdown,
+    'text/html'
+  )
+
+  for (const nodeFilter of nodeFilters) {
+    await applyNodeFilter(nodeFilter, mdDoc)
+    if (markdownEmitter.disposed) {
+      break
+    }
+    markdownEmitter.emit(mdDoc.documentElement.innerHTML)
+  }
+}
+
+/**
+ * Method uses a NodeFilter to replace any nodes that match the filters tree
+ * walker and filter change criteria.
+ *
+ * Note: This mutates; it does not return a changed copy of the DOM Document
+ * provided.
+ */
+async function applyNodeFilter(
+  nodeFilter: INodeFilter,
+  mdDoc: Document
+): Promise {
+  const walker = nodeFilter.createFilterTreeWalker(mdDoc)
+
+  let textNode = walker.nextNode()
+  while (textNode !== null) {
+    const replacementNodes = await nodeFilter.filter(textNode)
+    const currentNode = textNode
+    textNode = walker.nextNode()
+    if (replacementNodes === null) {
+      continue
+    }
+
+    for (const replacementNode of replacementNodes) {
+      currentNode.parentNode?.insertBefore(replacementNode, currentNode)
+    }
+    currentNode.parentNode?.removeChild(currentNode)
+  }
+}
+
+/** The context of which markdown resides */
+export type MarkdownContext =
+  | 'PullRequest'
+  | 'PullRequestComment'
+  | 'IssueComment'
+  | 'Commit'
diff --git a/app/src/lib/markdown-filters/resolve-owner-repo.ts b/app/src/lib/markdown-filters/resolve-owner-repo.ts
new file mode 100644
index 0000000000..50a74eef13
--- /dev/null
+++ b/app/src/lib/markdown-filters/resolve-owner-repo.ts
@@ -0,0 +1,45 @@
+import { GitHubRepository } from '../../models/github-repository'
+
+/**
+ * The ownerOrOwnerRepo may be of the from owner or owner/repo.
+ * 1) If owner/repo and they don't both match the current repo, then we return
+ *    them as to distinguish them as a different from the current repo for the
+ *    reference url.
+ * 2) If (owner) and the owner !== current repo owner, it is an invalid
+ *    references - return null.
+ * 3) Otherwise, return [] as it is an valid references, but, was either and
+ *    empty string or in the current repo and is redundant owner/repo info.
+ */
+export function resolveOwnerRepo(
+  ownerOrOwnerRepo: string | undefined,
+  repository: GitHubRepository
+): ReadonlyArray | null {
+  if (ownerOrOwnerRepo === undefined) {
+    return []
+  }
+
+  const ownerAndRepo = ownerOrOwnerRepo.split('/')
+  // Invalid - This shouldn't happen based on the regex, but would mean
+  // something/something/something/#1 which isn't an commit ref.
+  if (ownerAndRepo.length > 3) {
+    return null
+  }
+
+  // Invalid - If it is only something@1234567 and that `something` isn't the
+  // current repositories owner login, then it is not an actual, 'relative to
+  // this user', commit ref.
+  if (ownerAndRepo.length === 1 && ownerAndRepo[0] !== repository.owner.login) {
+    return null
+  }
+
+  // If owner and repo are provided, we only care if they differ from the current repo.
+  if (
+    ownerAndRepo.length === 2 &&
+    (ownerAndRepo[0] !== repository.owner.login ||
+      ownerAndRepo[1] !== repository.name)
+  ) {
+    return ownerAndRepo
+  }
+
+  return []
+}
diff --git a/app/src/lib/markdown-filters/team-mention-filter.ts b/app/src/lib/markdown-filters/team-mention-filter.ts
new file mode 100644
index 0000000000..dc6705cfed
--- /dev/null
+++ b/app/src/lib/markdown-filters/team-mention-filter.ts
@@ -0,0 +1,133 @@
+import { GitHubRepository } from '../../models/github-repository'
+import { getHTMLURL } from '../api'
+import { caseInsensitiveEquals } from '../compare'
+import { INodeFilter } from './node-filter'
+
+/**
+ * The Mention Markdown filter looks for team mention in an organization's repo
+ * and replaces them with links to teams profile. Specifically looking at text
+ * nodes and if a reference like @org/teamname is found, it will replace the
+ * text node with three nodes - one being a link.
+ *
+ * Team mentions in 
,  and  elements are ignored.
+ *
+ * Contrary to dotcom where it confirms whether the team exists or not, we
+ * assume they do exist for the sake of performance (not doing database hit to
+ * see if the mentioned user exists).
+ *
+ * Example: A text node of "cc: @desktop/the-a-team - Got have the a teams input..."
+ * Becomes three nodes:
+ * 1) "cc: "
+ * 2) @desktop/the-a-team
+ * 3) " - Got have the a teams input..."
+ */
+export class TeamMentionFilter implements INodeFilter {
+  // beginning of string or non-word char
+  private readonly beginStringNonWordRegix = /(^|\W)/
+
+  // @organization part of @organization/team
+  private readonly orgRegix = /(?@[a-z0-9][a-z0-9-]*)/
+
+  // the /team part of @organization/team
+  private readonly teamRegix = /(?\/[a-z0-9][a-z0-9\-_]*)/
+
+  // Pattern used to extract @org/team mentions from text
+  private readonly teamMentionRegex = new RegExp(
+    this.beginStringNonWordRegix.source +
+      this.orgRegix.source +
+      this.teamRegix.source +
+      /\b/.source, // assert position at a word boundary
+    'ig'
+  )
+
+  public constructor(
+    /** The repository which the markdown content originated from */
+    private readonly repository: GitHubRepository
+  ) {}
+
+  /**
+   * Team mention filters iterates on all text nodes that are not inside a pre,
+   * code, or anchor tag. The text node would also at a minimum not be null and
+   * include the @ symbol.
+   */
+  public createFilterTreeWalker(doc: Document): TreeWalker {
+    return doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT, {
+      acceptNode: node => {
+        return (node.parentNode !== null &&
+          ['CODE', 'PRE', 'A'].includes(node.parentNode.nodeName)) ||
+          node.textContent === null ||
+          !node.textContent.includes('@')
+          ? NodeFilter.FILTER_SKIP
+          : NodeFilter.FILTER_ACCEPT
+      },
+    })
+  }
+
+  /**
+   * Takes a text node and creates multiple text and link nodes by inserting
+   * team link nodes where team mentions are.
+   *
+   * Warning: This filter can create false positives. It assumes the teams exist.
+   *
+   * Note: Team mention filter requires text nodes; otherwise we may inadvertently
+   * replace non text elements.
+   */
+  public async filter(node: Node): Promise | null> {
+    const { textContent: text } = node
+    if (
+      node.nodeType !== node.TEXT_NODE ||
+      text === null ||
+      // If the repo is not owned by an org, then there cannot be teams.
+      this.repository.owner.type !== 'Organization'
+    ) {
+      return null
+    }
+
+    let lastMatchEndingPosition = 0
+    const nodes: Array = []
+    const matches = text.matchAll(this.teamMentionRegex)
+    for (const match of matches) {
+      if (match.groups === undefined || match.index === undefined) {
+        continue
+      }
+
+      const { org, team } = match.groups
+      if (
+        org === undefined ||
+        team === undefined ||
+        // Team references are only added when the repository owner is the org to prevent linking to a team outside the repositories org.
+        caseInsensitiveEquals(org.slice(1), this.repository.owner.login)
+      ) {
+        continue
+      }
+
+      const link = this.createLinkElement(org.slice(1), team.slice(1))
+      const refPosition = match.index === 0 ? 0 : match.index + 1
+      const textBefore = text.slice(lastMatchEndingPosition, refPosition)
+      const textNodeBefore = document.createTextNode(textBefore)
+      nodes.push(textNodeBefore)
+      nodes.push(link)
+
+      lastMatchEndingPosition = refPosition + org.length + team.length
+    }
+
+    const trailingText = text.slice(lastMatchEndingPosition)
+    if (trailingText !== '') {
+      nodes.push(document.createTextNode(trailingText))
+    }
+
+    return nodes
+  }
+
+  /**
+   * Method to create the user mention anchor.
+   **/
+  private createLinkElement(org: string, team: string) {
+    const baseHref = getHTMLURL(this.repository.endpoint)
+    const href = `${baseHref}/orgs/${org}/teams/${team}`
+    const anchor = document.createElement('a')
+    anchor.textContent = `@${org}/${team}`
+    anchor.href = href
+    return anchor
+  }
+}
diff --git a/app/src/lib/markdown-filters/video-link-filter.ts b/app/src/lib/markdown-filters/video-link-filter.ts
new file mode 100644
index 0000000000..eea74d9271
--- /dev/null
+++ b/app/src/lib/markdown-filters/video-link-filter.ts
@@ -0,0 +1,79 @@
+import { INodeFilter } from './node-filter'
+import { githubAssetVideoRegex } from './video-url-regex'
+
+/**
+ * The Video Link filter matches a github-flavored markdown target of a link,
+ * like
+ * 

. + * + * This type of pattern is formed when a user pastes the video url in markdown + * editor and then the markdown parser auto links it. + * + * If the url is in the format of a github user asset, it will replace the + * paragraph with link with a a video tag. If not, the link is left unmodified. + */ +export class VideoLinkFilter implements INodeFilter { + /** + * Video link matches on p tags with an a tag with a single child of a tag + * with an href that's host matches a pattern of video url that is a github + * user asset. + */ + public createFilterTreeWalker(doc: Document): TreeWalker { + return doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT, { + acceptNode: (el: Element) => + this.getGithubVideoLink(el) === null + ? NodeFilter.FILTER_SKIP + : NodeFilter.FILTER_ACCEPT, + }) + } + + /** + * Takes a paragraph element with a single anchor element that's href appears + * to be be a video link and replaces it with a video tag. + * + * Example: + *

+ * + * https://user-images.githubusercontent.com/7559041/1234.mp4 + * + *

+ * + * Output = [ + * + * ] + */ + public async filter(node: Node): Promise | null> { + const videoSrc = this.getGithubVideoLink(node) + if (videoSrc === null) { + return null + } + + const videoNode = document.createElement('video') + videoNode.src = videoSrc + return [videoNode] + } + + /** + * If the give node is a video url post markdown parsing, it returns the video + * url, else return null. + * + * Video url post markdown parsing looks like: + *

+ * + * https://user-images.githubusercontent.com/7559041/1234.mp4 + * + *

+ * */ + private getGithubVideoLink(node: Node): string | null { + if ( + node instanceof HTMLParagraphElement && + node.childElementCount === 1 && + node.firstChild instanceof HTMLAnchorElement && + githubAssetVideoRegex.test(node.firstChild.href) + ) { + return node.firstChild.href + } + + return null + } +} diff --git a/app/src/lib/markdown-filters/video-tag-filter.ts b/app/src/lib/markdown-filters/video-tag-filter.ts new file mode 100644 index 0000000000..258e975707 --- /dev/null +++ b/app/src/lib/markdown-filters/video-tag-filter.ts @@ -0,0 +1,44 @@ +import { INodeFilter } from './node-filter' +import { githubAssetVideoRegex } from './video-url-regex' + +/** + * The Video Link filter matches embedded video tags, like + * + * + * + * If the url for the src does NOT match the pattern of a github user asset, we + * remove the video tag. + */ +export class VideoTagFilter implements INodeFilter { + /** + * Video link filter matches on video tags that src does not match a github user asset url. + */ + public createFilterTreeWalker(doc: Document): TreeWalker { + return doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT, { + acceptNode: function (el: Element) { + return !(el instanceof HTMLVideoElement) || + githubAssetVideoRegex.test(el.src) + ? NodeFilter.FILTER_SKIP + : NodeFilter.FILTER_ACCEPT + }, + }) + } + + /** + * Takes a video element who's src host is not a github user asset url and removes it. + */ + public async filter(node: Node): Promise | null> { + if ( + !(node instanceof HTMLVideoElement) || + githubAssetVideoRegex.test(node.src) + ) { + // If it is video element with a valid source, we return null to leave it alone. + // This is different than dotcom which regenerates a video tag because it + // verifies through a db call that the assets exists + return null + } + + // Return empty array so that video tag is removed + return [] + } +} diff --git a/app/src/lib/markdown-filters/video-url-regex.ts b/app/src/lib/markdown-filters/video-url-regex.ts new file mode 100644 index 0000000000..c83be25c56 --- /dev/null +++ b/app/src/lib/markdown-filters/video-url-regex.ts @@ -0,0 +1,14 @@ +import { escapeRegExp } from 'lodash' + +const user_images_cdn_url = 'https://user-images.githubusercontent.com' + +// List of common video formats obtained from +// https://developer.mozilla.org/en-US/docs/https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_codecs/Media/Formats/Video_codecs +// The MP4, WebM, and Ogg formats are supported by HTML standard. +const videoExtensionRegex = /(mp4|webm|ogg|mov|qt|avi|wmv|3gp|mpg|mpeg|)$/ + +/** Regex for checking if a url is a github asset cdn video url */ +export const githubAssetVideoRegex = new RegExp( + '^' + escapeRegExp(user_images_cdn_url) + '.+' + videoExtensionRegex.source, + 'i' +) diff --git a/app/src/lib/menu-item.ts b/app/src/lib/menu-item.ts new file mode 100644 index 0000000000..7d3d97918b --- /dev/null +++ b/app/src/lib/menu-item.ts @@ -0,0 +1,131 @@ +import { invokeContextualMenu } from '../ui/main-process-proxy' + +export interface IMenuItem { + /** The user-facing label. */ + readonly label?: string + + /** The action to invoke when the user selects the item. */ + readonly action?: () => void + + /** The type of item. */ + readonly type?: 'separator' + + /** Is the menu item enabled? Defaults to true. */ + readonly enabled?: boolean + + /** + * The predefined behavior of the menu item. + * + * When specified the click property will be ignored. + * See https://electronjs.org/docs/api/menu-item#roles + */ + readonly role?: Electron.MenuItemConstructorOptions['role'] + + /** + * Submenu that will appear when hovering this menu item. + */ + readonly submenu?: ReadonlyArray +} + +/** + * A menu item data structure that can be serialized and sent via IPC. + */ +export interface ISerializableMenuItem extends IMenuItem { + readonly action: undefined +} + +/** + * Converts Electron accelerator modifiers to their platform specific + * name or symbol. + * + * Example: CommandOrControl becomes either '⌘' or 'Ctrl' depending on platform. + * + * See https://github.com/electron/electron/blob/fb74f55/docs/api/accelerator.md + */ +export function getPlatformSpecificNameOrSymbolForModifier( + modifier: string +): string { + switch (modifier.toLowerCase()) { + case 'cmdorctrl': + case 'commandorcontrol': + return __DARWIN__ ? '⌘' : 'Ctrl' + + case 'ctrl': + case 'control': + return __DARWIN__ ? '⌃' : 'Ctrl' + + case 'shift': + return __DARWIN__ ? '⇧' : 'Shift' + case 'alt': + return __DARWIN__ ? '⌥' : 'Alt' + + // Mac only + case 'cmd': + case 'command': + return '⌘' + case 'option': + return '⌥' + + // Special case space because no one would be able to see it + case ' ': + return 'Space' + } + + // Not a known modifier, likely a normal key + return modifier +} + +/** Show the given menu items in a contextual menu. */ +export async function showContextualMenu( + items: ReadonlyArray, + addSpellCheckMenu = false +) { + const indices = await invokeContextualMenu( + serializeMenuItems(items), + addSpellCheckMenu + ) + + if (indices !== null) { + const menuItem = findSubmenuItem(items, indices) + + if (menuItem !== undefined && menuItem.action !== undefined) { + menuItem.action() + } + } +} + +/** + * Remove the menu items properties that can't be serializable in + * order to pass them via IPC. + */ +function serializeMenuItems( + items: ReadonlyArray +): ReadonlyArray { + return items.map(item => ({ + ...item, + action: undefined, + submenu: item.submenu ? serializeMenuItems(item.submenu) : undefined, + })) +} + +/** + * Traverse the submenus of the context menu until we find the appropriate index. + */ +function findSubmenuItem( + currentContextualMenuItems: ReadonlyArray, + indices: ReadonlyArray +): IMenuItem | undefined { + let foundMenuItem: IMenuItem | undefined = { + submenu: currentContextualMenuItems, + } + + for (const index of indices) { + if (foundMenuItem === undefined || foundMenuItem.submenu === undefined) { + return undefined + } + + foundMenuItem = foundMenuItem.submenu[index] + } + + return foundMenuItem +} diff --git a/app/src/lib/menu-update.ts b/app/src/lib/menu-update.ts new file mode 100644 index 0000000000..aa5353be27 --- /dev/null +++ b/app/src/lib/menu-update.ts @@ -0,0 +1,492 @@ +import { MenuIDs } from '../models/menu-ids' +import { merge } from './merge' +import { IAppState, SelectionType } from '../lib/app-state' +import { + Repository, + isRepositoryWithGitHubRepository, +} from '../models/repository' +import { CloningRepository } from '../models/cloning-repository' +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 { findContributionTargetDefaultBranch } from './branch' + +export interface IMenuItemState { + readonly enabled?: boolean +} + +/** + * Utility class for coalescing updates to menu items + */ +class MenuStateBuilder { + private readonly _state: Map + + public constructor(state: Map = new Map()) { + this._state = state + } + + /** + * Returns an Map where each key is a MenuID and the values + * are IMenuItemState instances containing information about + * whether a particular menu item should be enabled/disabled or + * visible/hidden. + */ + public get state() { + return new Map(this._state) + } + + private updateMenuItem( + id: MenuIDs, + state: Pick + ) { + const currentState = this._state.get(id) || {} + this._state.set(id, merge(currentState, state)) + } + + /** Set the state of the given menu item id to enabled */ + public enable(id: MenuIDs): this { + this.updateMenuItem(id, { enabled: true }) + return this + } + + /** Set the state of the given menu item id to disabled */ + public disable(id: MenuIDs): this { + this.updateMenuItem(id, { enabled: false }) + return this + } + + /** Set the enabledness of the given menu item id */ + public setEnabled(id: MenuIDs, enabled: boolean): this { + this.updateMenuItem(id, { enabled }) + return this + } + + /** + * Create a new state builder by merging the current state with the state from + * the other state builder. This will replace values in `this` with values + * from `other`. + */ + public merge(other: MenuStateBuilder): MenuStateBuilder { + const merged = new Map(this._state) + for (const [key, value] of other._state) { + merged.set(key, value) + } + return new MenuStateBuilder(merged) + } +} + +function isRepositoryHostedOnGitHub( + repository: Repository | CloningRepository +) { + if ( + !repository || + repository instanceof CloningRepository || + !repository.gitHubRepository + ) { + return false + } + + return repository.gitHubRepository.htmlURL !== null +} + +function menuItemStateEqual(state: IMenuItemState, menuItem: MenuItem) { + if ( + state.enabled !== undefined && + menuItem.type !== 'separator' && + menuItem.enabled !== state.enabled + ) { + return false + } + + return true +} + +const allMenuIds: ReadonlyArray = [ + 'rename-branch', + 'delete-branch', + 'discard-all-changes', + 'stash-all-changes', + 'preferences', + 'update-branch-with-contribution-target-branch', + 'compare-to-branch', + 'merge-branch', + 'rebase-branch', + 'view-repository-on-github', + 'compare-on-github', + 'branch-on-github', + 'open-in-shell', + 'push', + 'pull', + 'branch', + 'repository', + 'go-to-commit-message', + 'create-branch', + 'show-changes', + 'show-history', + 'show-repository-list', + 'show-branches-list', + 'open-working-directory', + 'show-repository-settings', + 'open-external-editor', + 'remove-repository', + 'new-repository', + 'add-local-repository', + 'clone-repository', + 'about', + 'create-pull-request', + 'preview-pull-request', + 'squash-and-merge-branch', +] + +function getAllMenusDisabledBuilder(): MenuStateBuilder { + const menuStateBuilder = new MenuStateBuilder() + + for (const menuId of allMenuIds) { + menuStateBuilder.disable(menuId) + } + + return menuStateBuilder +} + +function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder { + const selectedState = state.selectedState + const isHostedOnGitHub = selectedState + ? isRepositoryHostedOnGitHub(selectedState.repository) + : false + + let repositorySelected = false + let onNonDefaultBranch = false + let onBranch = false + let onDetachedHead = false + let hasChangedFiles = false + let hasConflicts = 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 = + selectedState !== null && + selectedState.repository instanceof Repository && + getRepoIssuesEnabled(selectedState.repository) + + if (selectedState && selectedState.type === SelectionType.Repository) { + repositorySelected = true + + const { branchesState, changesState } = selectedState.state + const tip = branchesState.tip + const defaultBranch = branchesState.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 + // 2. on an unborn branch, or + // 3. on a detached HEAD + // there's not much we can do. + if (tip.kind === TipState.Valid) { + if (defaultBranch !== null) { + onNonDefaultBranch = tip.branch.name !== defaultBranch.name + } else { + onNonDefaultBranch = true + } + + hasPublishedBranch = !!tip.branch.upstream + branchHasStashEntry = changesState.stashEntry !== null + } else { + onNonDefaultBranch = true + } + + networkActionInProgress = selectedState.state.isPushPullFetchInProgress + + const { conflictState, workingDirectory } = selectedState.state.changesState + + rebaseInProgress = conflictState !== null && conflictState.kind === 'rebase' + hasConflicts = + changesState.conflictState !== null || + hasConflictedFiles(workingDirectory) + hasChangedFiles = workingDirectory.files.length > 0 + } + + // These are IDs for menu items that are entirely _and only_ + // repository-scoped. They're always enabled if we're in a repository and + // always disabled if we're not. + const repositoryScopedIDs: ReadonlyArray = [ + 'branch', + 'repository', + 'remove-repository', + 'open-in-shell', + 'open-working-directory', + 'show-repository-settings', + 'go-to-commit-message', + 'show-changes', + 'show-history', + 'show-branches-list', + 'open-external-editor', + 'compare-to-branch', + ] + + const menuStateBuilder = new MenuStateBuilder() + + const windowOpen = state.windowState !== 'hidden' + const inWelcomeFlow = state.showWelcomeFlow + const repositoryActive = windowOpen && repositorySelected && !inWelcomeFlow + + if (repositoryActive) { + for (const id of repositoryScopedIDs) { + menuStateBuilder.enable(id) + } + + menuStateBuilder.setEnabled( + 'rename-branch', + (onNonDefaultBranch || !hasPublishedBranch) && + !branchIsUnborn && + !onDetachedHead + ) + menuStateBuilder.setEnabled( + 'delete-branch', + onNonDefaultBranch && !branchIsUnborn && !onDetachedHead + ) + menuStateBuilder.setEnabled( + 'update-branch-with-contribution-target-branch', + onBranch && + hasContributionTargetDefaultBranch && + !onContributionTargetDefaultBranch + ) + menuStateBuilder.setEnabled('merge-branch', onBranch) + menuStateBuilder.setEnabled('squash-and-merge-branch', onBranch) + menuStateBuilder.setEnabled('rebase-branch', onBranch) + menuStateBuilder.setEnabled( + 'compare-on-github', + isHostedOnGitHub && hasPublishedBranch + ) + + menuStateBuilder.setEnabled( + 'branch-on-github', + isHostedOnGitHub && hasPublishedBranch + ) + + menuStateBuilder.setEnabled('view-repository-on-github', isHostedOnGitHub) + menuStateBuilder.setEnabled( + 'create-issue-in-repository-on-github', + repoIssuesEnabled + ) + menuStateBuilder.setEnabled( + 'create-pull-request', + isHostedOnGitHub && !branchIsUnborn && !onDetachedHead + ) + menuStateBuilder.setEnabled( + 'preview-pull-request', + !branchIsUnborn && !onDetachedHead && isHostedOnGitHub + ) + + menuStateBuilder.setEnabled( + 'push', + !branchIsUnborn && !onDetachedHead && !networkActionInProgress + ) + menuStateBuilder.setEnabled( + 'pull', + hasPublishedBranch && !networkActionInProgress + ) + menuStateBuilder.setEnabled( + 'create-branch', + !tipStateIsUnknown && !branchIsUnborn && !rebaseInProgress + ) + + menuStateBuilder.setEnabled( + 'discard-all-changes', + repositoryActive && hasChangedFiles && !rebaseInProgress + ) + + menuStateBuilder.setEnabled( + 'stash-all-changes', + hasChangedFiles && onBranch && !rebaseInProgress && !hasConflicts + ) + + menuStateBuilder.setEnabled('compare-to-branch', !onDetachedHead) + menuStateBuilder.setEnabled('toggle-stashed-changes', branchHasStashEntry) + + if ( + selectedState && + selectedState.type === SelectionType.MissingRepository + ) { + menuStateBuilder.disable('open-external-editor') + } + } else { + for (const id of repositoryScopedIDs) { + menuStateBuilder.disable(id) + } + + menuStateBuilder.disable('view-repository-on-github') + menuStateBuilder.disable('create-pull-request') + menuStateBuilder.disable('preview-pull-request') + if ( + selectedState && + selectedState.type === SelectionType.MissingRepository + ) { + if (selectedState.repository.gitHubRepository) { + menuStateBuilder.enable('view-repository-on-github') + } + menuStateBuilder.enable('remove-repository') + } + + menuStateBuilder.disable('create-branch') + menuStateBuilder.disable('rename-branch') + menuStateBuilder.disable('delete-branch') + menuStateBuilder.disable('discard-all-changes') + menuStateBuilder.disable('stash-all-changes') + menuStateBuilder.disable('update-branch-with-contribution-target-branch') + menuStateBuilder.disable('merge-branch') + menuStateBuilder.disable('squash-and-merge-branch') + menuStateBuilder.disable('rebase-branch') + + menuStateBuilder.disable('push') + menuStateBuilder.disable('pull') + menuStateBuilder.disable('compare-to-branch') + menuStateBuilder.disable('compare-on-github') + menuStateBuilder.disable('branch-on-github') + menuStateBuilder.disable('toggle-stashed-changes') + } + + return menuStateBuilder +} + +function getMenuState(state: IAppState): Map { + if (state.currentPopup) { + return getAllMenusDisabledBuilder().state + } + + return getAllMenusEnabledBuilder() + .merge(getRepositoryMenuBuilder(state)) + .merge(getAppMenuBuilder(state)) + .merge(getInWelcomeFlowBuilder(state.showWelcomeFlow)) + .merge(getNoRepositoriesBuilder(state)).state +} + +function getAllMenusEnabledBuilder(): MenuStateBuilder { + const menuStateBuilder = new MenuStateBuilder() + for (const menuId of allMenuIds) { + menuStateBuilder.enable(menuId) + } + return menuStateBuilder +} + +function getInWelcomeFlowBuilder(inWelcomeFlow: boolean): MenuStateBuilder { + const welcomeScopedIds: ReadonlyArray = [ + 'new-repository', + 'add-local-repository', + 'clone-repository', + 'preferences', + 'about', + ] + + const menuStateBuilder = new MenuStateBuilder() + if (inWelcomeFlow) { + for (const id of welcomeScopedIds) { + menuStateBuilder.disable(id) + } + } else { + for (const id of welcomeScopedIds) { + menuStateBuilder.enable(id) + } + } + + return menuStateBuilder +} + +function getNoRepositoriesBuilder(state: IAppState): MenuStateBuilder { + const noRepositoriesDisabledIds: ReadonlyArray = [ + 'show-repository-list', + ] + + const menuStateBuilder = new MenuStateBuilder() + if (state.repositories.length === 0) { + for (const id of noRepositoriesDisabledIds) { + menuStateBuilder.disable(id) + } + } + + 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 + + if (ghRepo.parent) { + // issues enabled on parent repo + return ( + ghRepo.parent.issuesEnabled !== false && + ghRepo.parent.isArchived !== true + ) + } + + // issues enabled on repo + return ghRepo.issuesEnabled !== false && ghRepo.isArchived !== true + } + + return false +} + +/** + * Update the menu state in the main process. + * + * This function will set the enabledness and visibility of menu items + * in the main process based on the AppState. All changes will be + * batched together into one ipc message. + */ +export function updateMenuState( + state: IAppState, + currentAppMenu: AppMenu | null +) { + const menuState = getMenuState(state) + + // Try to avoid updating sending the IPC message at all + // if we have a current app menu that we can compare against. + if (currentAppMenu) { + for (const [id, menuItemState] of menuState.entries()) { + const appMenuItem = currentAppMenu.getItemById(id) + + if (appMenuItem && menuItemStateEqual(menuItemState, appMenuItem)) { + menuState.delete(id) + } + } + } + + if (menuState.size === 0) { + return + } + + // because we can't send Map over the wire, we need to convert + // the remaining entries into an array that can be serialized + const array = new Array<{ id: MenuIDs; state: IMenuItemState }>() + menuState.forEach((value, key) => array.push({ id: key, state: value })) + ipcUpdateMenuState(array) +} diff --git a/app/src/lib/merge.ts b/app/src/lib/merge.ts new file mode 100644 index 0000000000..79e912edca --- /dev/null +++ b/app/src/lib/merge.ts @@ -0,0 +1,11 @@ +/** Create a copy of an object by merging it with a subset of its properties. */ +export function merge( + obj: T | null | undefined, + subset: Pick +): T { + const copy = Object.assign({}, obj) + for (const k in subset) { + copy[k] = subset[k] + } + return copy +} diff --git a/app/src/lib/mouse-scroller.ts b/app/src/lib/mouse-scroller.ts new file mode 100644 index 0000000000..7f5b78052b --- /dev/null +++ b/app/src/lib/mouse-scroller.ts @@ -0,0 +1,178 @@ +/** + * The mouse scroller was built in conjunction with the drag functionality. + * Its purpose is to provide the ability to scroll a scrollable element when + * the mouse gets close to the scrollable elements edge. + * + * Thus, it is built on the premise that we are providing it a scrollable + * element and will continually provide it the mouse's position. + * (which is tracked as part of drag event) + * + * Note: This implementation only accounts for vertical scrolling, but + * horizontal scrolling would just be a matter of the same logic for left and + * right bounds. + */ +class MouseScroller { + private scrollTimer: number | undefined + private defaultScrollEdge = 30 + private scrollSpeed = 5 + + /** + * If provided element or a parent of that element is scrollable, it starts + * scrolling based on the mouse's position. + */ + public setupMouseScroll(element: Element, mouseY: number) { + const scrollable = this.getClosestScrollElement(element) + if (scrollable === null) { + this.clearScrollTimer() + return + } + + this.updateMouseScroll(scrollable, mouseY) + } + + /** + * The scrolling action is wrapped in a continual time out, it will + * continue to scroll until it reaches the end of the scroll area. + */ + private updateMouseScroll(scrollable: Element, mouseY: number) { + window.clearTimeout(this.scrollTimer) + + if (this.scrollVerticallyOnMouseNearEdge(scrollable, mouseY)) { + this.scrollTimer = window.setTimeout(() => { + this.updateMouseScroll(scrollable, mouseY) + }, 30) + } + } + + /** + * Cleat the scroller's timeout. + */ + public clearScrollTimer() { + window.clearTimeout(this.scrollTimer) + } + + /** + * Method to scroll elements based on the mouse position. If the user moves + * their mouse near to the edge of the scrollable container, then, we want to + * invoke the scroll. + * + * Returns false if mouse is not positioned near the edge of the scroll area + * or the the scroll position is already at end of scroll area. + */ + private scrollVerticallyOnMouseNearEdge( + scrollable: Element, + mouseY: number + ): boolean { + // how far away from the edge of container to invoke scroll + const { top, bottom } = scrollable.getBoundingClientRect() + const distanceFromBottom = bottom - mouseY + const distanceFromTop = mouseY - top + + if (distanceFromBottom > 0 && distanceFromBottom < this.defaultScrollEdge) { + const scrollDistance = this.getScrollDistance(distanceFromBottom) + return this.scrollDown(scrollable, scrollDistance) + } + + if (distanceFromTop > 0 && distanceFromTop < this.defaultScrollEdge) { + const scrollDistance = this.getScrollDistance(distanceFromTop) + return this.scrollUp(scrollable, scrollDistance) + } + + return false + } + + /** + * Calculate the scroll amount (which in turn is scroll speed). It uses the + * distance from the scroll edge to get faster as the user moves their mouse + * closer to the edge. + */ + private getScrollDistance(distanceFromScrollEdge: number) { + const intensity = this.defaultScrollEdge / distanceFromScrollEdge + return this.scrollSpeed * intensity + } + + /** + * Scrolls an element up by given scroll distance. + * Returns false if already at top limit else true. + */ + private scrollUp(scrollable: Element, scrollDistance: number): boolean { + const limit = 0 + if (scrollable.scrollTop <= limit) { + return false + } + + const inBounds = scrollable.scrollTop > scrollDistance + const scrollTo = inBounds ? scrollable.scrollTop - scrollDistance : limit + scrollable.scrollTo({ top: scrollTo }) + return true + } + + /** + * Scrolls an element up by given scroll distance. + * Returns false if already at bottom limit else true. + */ + private scrollDown(scrollable: Element, scrollDistance: number): boolean { + const limit = scrollable.scrollHeight - scrollable.clientHeight + if (scrollable.scrollTop >= limit) { + return false + } + + const inBounds = scrollable.scrollTop + scrollDistance < limit + const scrollTo = inBounds ? scrollable.scrollTop + scrollDistance : limit + scrollable.scrollTo({ top: scrollTo }) + return true + } + + /** + * Method to determine if an element is scrollable if not finds the closest + * parent that is scrollable or returns null. + */ + private getClosestScrollElement(element: Element): Element | null { + const { position: elemPosition } = getComputedStyle(element) + + if (elemPosition === 'fixed') { + return null + } + + if (this.isScrollable(element)) { + return element + } + + let parent: Element | null + for (parent = element; (parent = parent.parentElement); ) { + const { position: parentPosition } = getComputedStyle(parent) + + // exclude static parents + if (elemPosition === 'absolute' && parentPosition === 'static') { + continue + } + + if (this.isScrollable(parent) && this.hasScrollableContent(parent)) { + return parent + } + } + + return null + } + + /** + * Determines if element is scrollable based on elements styles. + */ + private isScrollable(element: Element): boolean { + const style = getComputedStyle(element) + const overflowRegex = /(auto|scroll)/ + return overflowRegex.test( + style.overflow + style.overflowY + style.overflowX + ) + } + + /** + * Determines if there is content overflow that could be handled by a + * scrollbar + */ + private hasScrollableContent(scrollable: Element): boolean { + return scrollable.clientHeight < scrollable.scrollHeight + } +} + +export const mouseScroller = new MouseScroller() diff --git a/app/src/lib/multi-commit-operation.ts b/app/src/lib/multi-commit-operation.ts new file mode 100644 index 0000000000..c1799a1dd1 --- /dev/null +++ b/app/src/lib/multi-commit-operation.ts @@ -0,0 +1,49 @@ +import { Branch } from '../models/branch' +import { + ChooseBranchStep, + conflictSteps, + MultiCommitOperationStepKind, +} from '../models/multi-commit-operation' +import { TipState } from '../models/tip' +import { IMultiCommitOperationState, IRepositoryState } from './app-state' + +/** + * Setup the multi commit operation state when the user needs to select a branch as the + * base for the operation. + */ +export function getMultiCommitOperationChooseBranchStep( + state: IRepositoryState, + initialBranch?: Branch | null +): ChooseBranchStep { + const { defaultBranch, allBranches, recentBranches, tip } = + state.branchesState + let currentBranch: Branch | null = null + + if (tip.kind === TipState.Valid) { + currentBranch = tip.branch + } else { + throw new Error( + 'Tip is not in a valid state, which is required to start the multi commit operation' + ) + } + + return { + kind: MultiCommitOperationStepKind.ChooseBranch, + defaultBranch, + currentBranch, + allBranches, + recentBranches, + initialBranch: initialBranch !== null ? initialBranch : undefined, + } +} + +export function isConflictsFlow( + isMultiCommitOperationPopupOpen: boolean, + multiCommitOperationState: IMultiCommitOperationState | null +): boolean { + return ( + isMultiCommitOperationPopupOpen && + multiCommitOperationState !== null && + conflictSteps.includes(multiCommitOperationState.step.kind) + ) +} diff --git a/app/src/lib/notifications/notification-handler.ts b/app/src/lib/notifications/notification-handler.ts new file mode 100644 index 0000000000..2d5217a010 --- /dev/null +++ b/app/src/lib/notifications/notification-handler.ts @@ -0,0 +1,31 @@ +import QuickLRU from 'quick-lru' +import * as ipcRenderer from '../ipc-renderer' +import { focusWindow } from '../../ui/main-process-proxy' +import { NotificationsStore } from '../stores/notifications-store' + +export const notificationCallbacks = new QuickLRU void>({ + maxSize: 200, +}) + +export function initializeRendererNotificationHandler( + notificationsStore: NotificationsStore +) { + ipcRenderer.on('notification-event', (_, event, id, userInfo) => { + if (event !== 'click') { + return + } + + focusWindow() + const callback = notificationCallbacks.get(id) + if (callback !== undefined) { + callback?.() + notificationCallbacks.delete(id) + return + } + + // For notifications without a callback (from previous app sessions), we'll + // let the notifications store to retreive the necessary data and handle + // the event. + notificationsStore.onNotificationEventReceived(event, id, userInfo) + }) +} diff --git a/app/src/lib/notifications/show-notification.ts b/app/src/lib/notifications/show-notification.ts new file mode 100644 index 0000000000..be3824ccfe --- /dev/null +++ b/app/src/lib/notifications/show-notification.ts @@ -0,0 +1,41 @@ +import { focusWindow } from '../../ui/main-process-proxy' +import { supportsNotifications } from 'desktop-notifications' +import { showNotification as invokeShowNotification } from '../../ui/main-process-proxy' +import { notificationCallbacks } from './notification-handler' +import { DesktopAliveEvent } from '../stores/alive-store' + +interface IShowNotificationOptions { + title: string + body: string + userInfo?: DesktopAliveEvent + onClick: () => void +} + +/** + * Shows a notification with a title, a body, and a function to handle when the + * user clicks on the notification. + */ +export async function showNotification(options: IShowNotificationOptions) { + // `supportNotifications` checks if `desktop-notifications` is supported by + // the current platform. Otherwise, we'll rely on the HTML5 notification API. + if (!supportsNotifications()) { + const notification = new Notification(options.title, { + body: options.body, + }) + + notification.onclick = () => { + focusWindow() + options.onClick() + } + return + } + + const notificationID = await invokeShowNotification( + options.title, + options.body, + options.userInfo + ) + if (notificationID !== null) { + notificationCallbacks.set(notificationID, options.onClick) + } +} diff --git a/app/src/lib/oauth.ts b/app/src/lib/oauth.ts new file mode 100644 index 0000000000..f2692cc92c --- /dev/null +++ b/app/src/lib/oauth.ts @@ -0,0 +1,94 @@ +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/offset-from.ts b/app/src/lib/offset-from.ts new file mode 100644 index 0000000000..0762af3f94 --- /dev/null +++ b/app/src/lib/offset-from.ts @@ -0,0 +1,37 @@ +const units = { + year: 31536000000, + years: 31536000000, + day: 86400000, + days: 86400000, + hour: 3600000, + hours: 3600000, + minute: 60000, + minutes: 60000, + second: 1000, + seconds: 1000, +} + +type Unit = keyof typeof units + +/** + * Returns milliseconds since the epoch offset from the current time by the + * given amount. + */ +export const offsetFromNow = (value: number, unit: Unit): number => + offsetFrom(Date.now(), value, unit) + +type Dateish = Date | number + +/** + * Returns milliseconds since the epoch offset by the given amount from the + * given time (in milliseconds) + */ +export function offsetFrom(time: number, value: number, unit: Unit): number +/** + * Returns a date object offset by the given amount from the given time + */ +export function offsetFrom(date: Date, value: number, unit: Unit): Date +export function offsetFrom(date: Dateish, value: number, unit: Unit): Dateish { + const t = date.valueOf() + value * units[unit] + return typeof date === 'number' ? t : new Date(t) +} diff --git a/app/src/lib/parse-app-url.ts b/app/src/lib/parse-app-url.ts new file mode 100644 index 0000000000..50f115e8c2 --- /dev/null +++ b/app/src/lib/parse-app-url.ts @@ -0,0 +1,143 @@ +import * as URL from 'url' +import { testForInvalidChars } from './sanitize-ref-name' + +export interface IOAuthAction { + readonly name: 'oauth' + readonly code: string + readonly state: string +} + +export interface IOpenRepositoryFromURLAction { + readonly name: 'open-repository-from-url' + + /** the remote repository location associated with the "Open in Desktop" action */ + readonly url: string + + /** the optional branch name which should be checked out. use the default branch otherwise. */ + readonly branch: string | null + + /** the pull request number, if pull request originates from a fork of the repository */ + readonly pr: string | null + + /** the file to open after cloning the repository */ + 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 +} + +export type URLActionType = + | IOAuthAction + | IOpenRepositoryFromURLAction + | IOpenRepositoryFromPathAction + | IUnknownAction + +// eslint-disable-next-line @typescript-eslint/naming-convention +interface ParsedUrlQueryWithUndefined { + // `undefined` is added here to ensure we handle the missing querystring key + // See https://github.com/Microsoft/TypeScript/issues/13778 for discussion about + // why this isn't supported natively in TypeScript + [key: string]: string | string[] | undefined +} + +/** + * Parse the URL to find a given key in the querystring text. + * + * @param url The source URL containing querystring key-value pairs + * @param key The key to look for in the querystring + */ +function getQueryStringValue( + query: ParsedUrlQueryWithUndefined, + key: string +): string | null { + const value = query[key] + if (value == null) { + return null + } + + if (Array.isArray(value)) { + return value[0] + } + + return value +} + +export function parseAppURL(url: string): URLActionType { + const parsedURL = URL.parse(url, true) + const hostname = parsedURL.hostname + const unknown: IUnknownAction = { name: 'unknown', url } + if (!hostname) { + return unknown + } + + const query = parsedURL.query + + const actionName = hostname.toLowerCase() + if (actionName === 'oauth') { + const code = getQueryStringValue(query, 'code') + const state = getQueryStringValue(query, 'state') + if (code != null && state != null) { + return { name: 'oauth', code, state } + } else { + return unknown + } + } + + // we require something resembling a URL first + // - bail out if it's not defined + // - bail out if you only have `/` + const pathName = parsedURL.pathname + if (!pathName || pathName.length <= 1) { + return unknown + } + + // Trim the trailing / from the URL + const parsedPath = pathName.substring(1) + + if (actionName === 'openrepo') { + const pr = getQueryStringValue(query, 'pr') + const branch = getQueryStringValue(query, 'branch') + const filepath = getQueryStringValue(query, 'filepath') + + if (pr != null) { + if (!/^\d+$/.test(pr)) { + return unknown + } + + // we also expect the branch for a forked PR to be a given ref format + if (branch != null && !/^pr\/\d+$/.test(branch)) { + return unknown + } + } + + if (branch != null && testForInvalidChars(branch)) { + return unknown + } + + return { + name: 'open-repository-from-url', + url: parsedPath, + branch, + pr, + filepath, + } + } + + 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 new file mode 100644 index 0000000000..4d0b532d14 --- /dev/null +++ b/app/src/lib/parse-carriage-return.ts @@ -0,0 +1,38 @@ +/** + * 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/parse-pac-string.ts b/app/src/lib/parse-pac-string.ts new file mode 100644 index 0000000000..460ddbd1fc --- /dev/null +++ b/app/src/lib/parse-pac-string.ts @@ -0,0 +1,123 @@ +/** + * Parse a Proxy Auto Configuration (PAC) string into one or more cURL- + * compatible proxy URLs. + * + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Proxy_servers_and_tunneling/Proxy_Auto-Configuration_(PAC)_file + * for a good primer on PAC files. + * + * Note that this method is not intended to be a fully compliant PAC parser + * nor is it intended to handle common PAC string mistakes (such as including + * the protocol in the host portion of the spec). It's specifically designed + * to translate PAC strings returned from Electron's resolveProxy method which + * in turn relies on Chromium's `ProxyList::ToPacString()` implementation. + * + * Proxy protocols not supported by cURL (QUIC) will be silently omitted. + * + * The format of a PAC string is included below for reference but we're in a + * special situation since what we get from Electron isn't the raw output from + * the PAC script but rather a PAC-formatted version of Chromiums internal proxy + * state. As such we can take several shortcuts not available to generic PAC + * parsers. + * + * See the following links for a high-level step-through of the logic involved + * in getting the PAC string from Electron/Chromium + * + * https://github.com/electron/electron/blob/d9321f4df751/shell/browser/net/resolve_proxy_helper.cc#L77 + * https://github.com/chromium/chromium/blob/98b0e0a61e78/net/proxy_resolution/proxy_list.cc#L134-L143 + * https://github.com/chromium/chromium/blob/2ca8c5037021/net/base/proxy_server.cc#L164-L184 + * + * PAC string BNF + * -------------- + * + * From https://dxr.mozilla.org/mozilla-central/source/netwerk/base/ProxyAutoConfig.h#48 + * + * result = proxy-spec *( proxy-sep proxy-spec ) + * proxy-spec = direct-type | proxy-type LWS proxy-host [":" proxy-port] + * direct-type = "DIRECT" + * proxy-type = "PROXY" | "HTTP" | "HTTPS" | "SOCKS" | "SOCKS4" | "SOCKS5" + * proxy-sep = ";" LWS + * proxy-host = hostname | ipv4-address-literal + * proxy-port = + * LWS = *( SP | HT ) + * SP = + * HT = + * + * NOTE: direct-type and proxy-type are case insensitive + * NOTE: SOCKS implies SOCKS4 + * + * Examples: + * "PROXY proxy1.foo.com:8080; PROXY proxy2.foo.com:8080; DIRECT" + * "SOCKS socksproxy" + * "DIRECT" + * + * Other notes: + * + * From https://curl.haxx.se/libcurl/c/CURLOPT_PROXY.html + * When you set a host name to use, do not assume that there's + * any particular single port number used widely for proxies. + * Specify it! + */ +export function parsePACString(pacString: string): Array | null { + // Happy path, this will be the case for the vast majority of users + if (pacString === 'DIRECT') { + return null + } + + const specs = pacString.trim().split(/\s*;\s*/) + const urls = new Array() + + for (const spec of specs) { + // No point in continuing after we get a DIRECT since we + // have no way of implementing a fallback logic in cURL/Git + if (spec.match(/^direct/i)) { + break + } + + const [protocol, endpoint] = spec.split(/\s+/, 2) + + if (endpoint !== undefined) { + const url = urlFromProtocolAndEndpoint(protocol, endpoint) + + if (url !== null) { + urls.push(url) + } else { + log.warn(`Skipping proxy spec: ${spec}`) + } + } + } + + return urls.length > 0 ? urls : null +} + +function urlFromProtocolAndEndpoint(protocol: string, endpoint: string) { + // Note that we explicitly want to preserve the port number (if provided). + // If we run these through url.parse or the URL constructor they will + // both attempt to be smart and remove the default port. So if a PAC + // string specified `PROXY myproxy:80` we'll generate `http://myproxy:80` + // which will get turned into `http://myproxy` by URL libraries since + // they think 80 is redundant. In our case it's not redundant though + // because cURL defaults to port 1080 for all proxy protocols, see + // + // https://curl.haxx.se/libcurl/c/CURLOPT_PROXY.html + // + // HTTP is an alias for PROXY (or vice versa idk). I don't believe + // we'll ever see an 'HTTP' protocol from Chromium based on my reading of + // https://github.com/chromium/chromium/blob/2ca8c5037021/net/base/proxy_server.cc#L164-L184 + // but we'll support it nonetheless. + // + // SOCKS is an alias for SOCKS4 + switch (protocol.toLowerCase()) { + case 'proxy': + case 'http': + return `http://${endpoint}` + case 'https': + return `https://${endpoint}` + case 'socks': + case 'socks4': + return `socks4://${endpoint}` + case 'socks5': + return `socks5://${endpoint}` + } + + return null +} diff --git a/app/src/lib/patch-formatter.ts b/app/src/lib/patch-formatter.ts new file mode 100644 index 0000000000..b1f0045736 --- /dev/null +++ b/app/src/lib/patch-formatter.ts @@ -0,0 +1,335 @@ +import { assertNever } from '../lib/fatal-error' +import { WorkingDirectoryFileChange, AppFileStatusKind } from '../models/status' +import { + DiffLineType, + ITextDiff, + DiffSelection, + ILargeTextDiff, +} from '../models/diff' + +/** + * Generates a string matching the format of a GNU unified diff header excluding + * the (optional) timestamp fields + * + * Note that this multi-line string includes a trailing newline. + * + * @param from The relative path to the original version of the file or + * null if the file is newly created. + * + * @param to The relative path to the new version of the file or + * null if the file is the file is newly created. + */ +function formatPatchHeader(from: string | null, to: string | null): string { + // https://en.wikipedia.org/wiki/Diff_utility + // + // > At the beginning of the patch is the file information, including the full + // > path and a time stamp delimited by a tab character. + // > + // > [...] the original file is preceded by "---" and the new file is preceded + // > by "+++". + // + // We skip the time stamp to match git + const fromPath = from ? `a/${from}` : '/dev/null' + const toPath = to ? `b/${to}` : '/dev/null' + + return `--- ${fromPath}\n+++ ${toPath}\n` +} + +/** + * Generates a string matching the format of a GNU unified diff header excluding + * the (optional) timestamp fields with the appropriate from/to file names based + * on the file state of the given WorkingDirectoryFileChange + */ +function formatPatchHeaderForFile(file: WorkingDirectoryFileChange) { + switch (file.status.kind) { + case AppFileStatusKind.New: + case AppFileStatusKind.Untracked: + return formatPatchHeader(null, file.path) + + // One might initially believe that renamed files should diff + // against their old path. This is, after all, how git diff + // does it right after a rename. But if we're creating a patch + // to be applied along with a rename we must target the renamed + // file. + case AppFileStatusKind.Renamed: + case AppFileStatusKind.Deleted: + case AppFileStatusKind.Modified: + case AppFileStatusKind.Copied: + // We should not have the ability to format a file that's marked as + // conflicted without more information about it's current state. + // I'd like to get to a point where `WorkingDirectoryFileChange` can be + // differentiated between ordinary, renamed/copied and unmerged entries + // and we can then verify the conflicted file is in a known good state but + // that work needs to be done waaaaaaaay before we get to this point. + case AppFileStatusKind.Conflicted: + return formatPatchHeader(file.path, file.path) + default: + return assertNever(file.status, `Unknown file status ${file.status}`) + } +} + +/** + * Generates a string matching the format of a GNU unified diff hunk header. + * Note that this single line string includes a single trailing newline. + * + * @param oldStartLine The line in the old (or original) file where this diff + * hunk starts. + * + * @param oldLineCount The number of lines in the old (or original) file that + * this diff hunk covers. + * + * @param newStartLine The line in the new file where this diff hunk starts + * + * @param newLineCount The number of lines in the new file that this diff hunk + * covers + */ +function formatHunkHeader( + oldStartLine: number, + oldLineCount: number, + newStartLine: number, + newLineCount: number, + sectionHeading?: string | null +) { + // > @@ -l,s +l,s @@ optional section heading + // > + // > The hunk range information contains two hunk ranges. The range for the hunk of the original + // > file is preceded by a minus symbol, and the range for the new file is preceded by a plus + // > symbol. Each hunk range is of the format l,s where l is the starting line number and s is + // > the number of lines the change hunk applies to for each respective file. + // > + // > In many versions of GNU diff, each range can omit the comma and trailing value s, + // > in which case s defaults to 1 + const lineInfoBefore = + oldLineCount === 1 ? `${oldStartLine}` : `${oldStartLine},${oldLineCount}` + + const lineInfoAfter = + newLineCount === 1 ? `${newStartLine}` : `${newStartLine},${newLineCount}` + + sectionHeading = sectionHeading ? ` ${sectionHeading}` : '' + + return `@@ -${lineInfoBefore} +${lineInfoAfter} @@${sectionHeading}\n` +} + +/** + * Creates a GNU unified diff based on the original diff and a number + * of selected or unselected lines (from file.selection). The patch is + * formatted with the intention of being used for applying against an index + * with git apply. + * + * Note that the file must have at least one selected addition or deletion, + * ie it's not supported to use this method as a general purpose diff + * formatter. + * + * @param file The file that the resulting patch will be applied to. + * This is used to determine the from and to paths for the + * patch header as well as retrieving the line selection state + * + * @param diff The source diff + */ +export function formatPatch( + file: WorkingDirectoryFileChange, + diff: ITextDiff | ILargeTextDiff +): string { + let patch = '' + + diff.hunks.forEach((hunk, hunkIndex) => { + let hunkBuf = '' + + let oldCount = 0 + let newCount = 0 + + let anyAdditionsOrDeletions = false + + hunk.lines.forEach((line, lineIndex) => { + const absoluteIndex = hunk.unifiedDiffStart + lineIndex + + // We write our own hunk headers + if (line.type === DiffLineType.Hunk) { + return + } + + // Context lines can always be let through, they will + // never appear for new files. + if (line.type === DiffLineType.Context) { + hunkBuf += `${line.text}\n` + oldCount++ + newCount++ + } else if (file.selection.isSelected(absoluteIndex)) { + // A line selected for inclusion. + + // Use the line as-is + hunkBuf += `${line.text}\n` + + if (line.type === DiffLineType.Add) { + newCount++ + } + if (line.type === DiffLineType.Delete) { + oldCount++ + } + + anyAdditionsOrDeletions = true + } else { + // Unselected lines in new files needs to be ignored. A new file by + // definition only consists of additions and therefore so will the + // partial patch. If the user has elected not to commit a particular + // addition we need to generate a patch that pretends that the line + // never existed. + if ( + file.status.kind === AppFileStatusKind.New || + file.status.kind === AppFileStatusKind.Untracked + ) { + return + } + + // An unselected added line has no impact on this patch, pretend + // it was never added to the old file by dropping it. + if (line.type === DiffLineType.Add) { + return + } + + // An unselected deleted line has never happened as far as this patch + // is concerned which means that we should treat it as if it's still + // in the old file so we'll convert it to a context line. + if (line.type === DiffLineType.Delete) { + hunkBuf += ` ${line.text.substring(1)}\n` + oldCount++ + newCount++ + } else { + // Guarantee that we've covered all the line types + assertNever(line.type, `Unsupported line type ${line.type}`) + } + } + + if (line.noTrailingNewLine) { + hunkBuf += '\\ No newline at end of file\n' + } + }) + + // Skip writing this hunk if all there is is context lines. + if (!anyAdditionsOrDeletions) { + return + } + + patch += formatHunkHeader( + hunk.header.oldStartLine, + oldCount, + hunk.header.newStartLine, + newCount + ) + patch += hunkBuf + }) + + // If we get into this state we should never have been called in the first + // place. Someone gave us a faulty diff and/or faulty selection state. + if (!patch.length) { + log.debug(`formatPatch: empty path for ${file.path}`) + throw new Error(`Could not generate a patch, no changes`) + } + + patch = formatPatchHeaderForFile(file) + patch + + return patch +} + +/** + * Creates a GNU unified diff to discard a set of changes (determined by the selection object) + * based on the passed diff and a number of selected or unselected lines. + * + * The patch is formatted with the intention of being used for applying against an index + * with git apply. + * + * Note that the diff must have at least one selected addition or deletion. + * + * This method is the opposite of formatPatch(). TODO: share logic between the two methods. + * + * @param filePath The path of the file that the resulting patch will be applied to. + * This is used to determine the from and to paths for the + * patch header. + * @param diff All the local changes for that file. + * @param selection A selection of lines from the diff object that we want to discard. + */ +export function formatPatchToDiscardChanges( + filePath: string, + diff: ITextDiff, + selection: DiffSelection +): string | null { + let patch = '' + + diff.hunks.forEach((hunk, hunkIndex) => { + let hunkBuf = '' + + let oldCount = 0 + let newCount = 0 + + let anyAdditionsOrDeletions = false + + hunk.lines.forEach((line, lineIndex) => { + const absoluteIndex = hunk.unifiedDiffStart + lineIndex + + // We write our own hunk headers + if (line.type === DiffLineType.Hunk) { + return + } + + // Context lines can always be let through, they will + // never appear for new files. + if (line.type === DiffLineType.Context) { + hunkBuf += `${line.text}\n` + oldCount++ + newCount++ + } else if (selection.isSelected(absoluteIndex)) { + // Reverse the change (if it was an added line, treat it as removed and vice versa). + if (line.type === DiffLineType.Add) { + hunkBuf += `-${line.text.substring(1)}\n` + newCount++ + } else if (line.type === DiffLineType.Delete) { + hunkBuf += `+${line.text.substring(1)}\n` + oldCount++ + } else { + assertNever(line.type, `Unsupported line type ${line.type}`) + } + + anyAdditionsOrDeletions = true + } else { + if (line.type === DiffLineType.Add) { + // An unselected added line will stay in the file after discarding the changes, + // so we just print it untouched on the diff. + oldCount++ + newCount++ + hunkBuf += ` ${line.text.substring(1)}\n` + } else if (line.type === DiffLineType.Delete) { + // An unselected removed line has no impact on this patch since it's not + // found on the current working copy of the file, so we can ignore it. + return + } else { + // Guarantee that we've covered all the line types. + assertNever(line.type, `Unsupported line type ${line.type}`) + } + } + + if (line.noTrailingNewLine) { + hunkBuf += '\\ No newline at end of file\n' + } + }) + + // Skip writing this hunk if all there is is context lines. + if (!anyAdditionsOrDeletions) { + return + } + + patch += formatHunkHeader( + hunk.header.newStartLine, + newCount, + hunk.header.oldStartLine, + oldCount + ) + patch += hunkBuf + }) + + if (patch.length === 0) { + // The selection resulted in an empty patch. + return null + } + + return formatPatchHeader(filePath, filePath) + patch +} diff --git a/app/src/lib/path.ts b/app/src/lib/path.ts new file mode 100644 index 0000000000..fc18d06d5b --- /dev/null +++ b/app/src/lib/path.ts @@ -0,0 +1,162 @@ +import * as Path from 'path' +import { realpath } from 'fs/promises' +import { pathToFileURL } from 'url' + +/** + * Resolve and encode the path information into a URL. + * + * @param pathSegments array of path segments to resolve + */ +export const encodePathAsUrl = (...pathSegments: string[]) => + pathToFileURL(Path.resolve(...pathSegments)).toString() + +/** + * Resolve one or more path sequences into an absolute path underneath + * or at the given root path. + * + * The path segments are expected to be relative paths although + * providing an absolute path is also supported. In the case of an + * absolute path segment this method will essentially only verify + * that the absolute path is equal to or deeper in the directory + * tree than the root path. + * + * If the fully resolved path does not reside underneath the root path + * this method will return null. + * + * @param rootPath The path to the root path. The resolved path + * is guaranteed to reside at, or underneath this + * path. + * @param pathSegments One or more paths to join with the root path + * @param options A subset of the Path module. Requires the join, + * resolve, and normalize path functions. Defaults + * to the platform specific path functions but can + * be overridden by providing either Path.win32 or + * Path.posix + */ +async function _resolveWithin( + rootPath: string, + pathSegments: string[], + options: { + join: (...pathSegments: string[]) => string + normalize: (p: string) => string + resolve: (...pathSegments: string[]) => string + } = Path +) { + // An empty root path would let all relative + // paths through. + if (rootPath.length === 0) { + return null + } + + const { join, normalize, resolve } = options + + const normalizedRoot = normalize(rootPath) + const normalizedRelative = normalize(join(...pathSegments)) + + // Null bytes has no place in paths. + if ( + normalizedRoot.indexOf('\0') !== -1 || + normalizedRelative.indexOf('\0') !== -1 + ) { + return null + } + + // Resolve to an absolute path. Note that this will not contain + // any directory traversal segments. + const resolved = resolve(normalizedRoot, normalizedRelative) + + const realRoot = await realpath(normalizedRoot) + const realResolved = await realpath(resolved) + + return realResolved.startsWith(realRoot) ? resolved : null +} + +/** + * Resolve one or more path sequences into an absolute path underneath + * or at the given root path. + * + * The path segments are expected to be relative paths although + * providing an absolute path is also supported. In the case of an + * absolute path segment this method will essentially only verify + * that the absolute path is equal to or deeper in the directory + * tree than the root path. + * + * If the fully resolved path does not reside underneath the root path + * this method will return null. + * + * This method will resolve paths using the current platform path + * structure. + * + * @param rootPath The path to the root path. The resolved path + * is guaranteed to reside at, or underneath this + * path. + * @param pathSegments One or more paths to join with the root path + */ +export function resolveWithin( + rootPath: string, + ...pathSegments: string[] +): Promise { + return _resolveWithin(rootPath, pathSegments) +} + +/** + * Resolve one or more path sequences into an absolute path underneath + * or at the given root path. + * + * The path segments are expected to be relative paths although + * providing an absolute path is also supported. In the case of an + * absolute path segment this method will essentially only verify + * that the absolute path is equal to or deeper in the directory + * tree than the root path. + * + * If the fully resolved path does not reside underneath the root path + * this method will return null. + * + * This method will resolve paths using POSIX path syntax. + * + * @param rootPath The path to the root path. The resolved path + * is guaranteed to reside at, or underneath this + * path. + * @param pathSegments One or more paths to join with the root path + */ +export function resolveWithinPosix( + rootPath: string, + ...pathSegments: string[] +): Promise { + return _resolveWithin(rootPath, pathSegments, Path.posix) +} + +/** + * Resolve one or more path sequences into an absolute path underneath + * or at the given root path. + * + * The path segments are expected to be relative paths although + * providing an absolute path is also supported. In the case of an + * absolute path segment this method will essentially only verify + * that the absolute path is equal to or deeper in the directory + * tree than the root path. + * + * If the fully resolved path does not reside underneath the root path + * this method will return null. + * + * This method will resolve paths using Windows path syntax. + * + * @param rootPath The path to the root path. The resolved path + * is guaranteed to reside at, or underneath this + * path. + * @param pathSegments One or more paths to join with the root path + */ +export function resolveWithinWin32( + rootPath: string, + ...pathSegments: string[] +): Promise { + return _resolveWithin(rootPath, pathSegments, Path.win32) +} + +export const win32 = { + resolveWithin: resolveWithinWin32, +} + +export const posix = { + resolveWithin: resolveWithinPosix, +} diff --git a/app/src/lib/pick.ts b/app/src/lib/pick.ts new file mode 100644 index 0000000000..4fd748d41b --- /dev/null +++ b/app/src/lib/pick.ts @@ -0,0 +1,9 @@ +/** + * Returns a shallow copy of the given object containing only the given subset + * of keys. This is to be thought of as a runtime equivalent of Pick + */ +export function pick(o: T, ...keys: K[]) { + const hasProperty = (k: K) => k in o + const toEntry = (k: K) => [k, o[k]] + return Object.fromEntries(keys.filter(hasProperty).map(toEntry)) as Pick +} diff --git a/app/src/lib/popup-manager.ts b/app/src/lib/popup-manager.ts new file mode 100644 index 0000000000..4025ee90ed --- /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 new file mode 100644 index 0000000000..a49358df2f --- /dev/null +++ b/app/src/lib/process/win32.ts @@ -0,0 +1,85 @@ +import { spawn as spawnInternal } from 'child_process' +import * as Path from 'path' +import { + HKEY, + RegistryValueType, + RegistryValue, + RegistryStringEntry, + enumerateValues, +} from 'registry-js' + +function isStringRegistryValue(rv: RegistryValue): rv is RegistryStringEntry { + return ( + rv.type === RegistryValueType.REG_SZ || + rv.type === RegistryValueType.REG_EXPAND_SZ + ) +} + +/** Get the path segments in the user's `Path`. */ +export function getPathSegments(): ReadonlyArray { + 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) + } + } + + throw new Error('Could not find PATH environment variable') +} + +/** 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' + } + + await spawn(setxPath, ['Path', paths.join(';')]) +} + +/** Spawn a command with arguments and capture its output. */ +export function spawn( + command: string, + args: ReadonlyArray +): Promise { + try { + const child = spawnInternal(command, args as string[]) + return new Promise((resolve, reject) => { + let stdout = '' + + // If Node.js encounters a synchronous runtime error while spawning + // `stdout` will be undefined and the error will be emitted asynchronously + if (child.stdout) { + child.stdout.on('data', data => { + stdout += data + }) + } + + child.on('close', code => { + if (code === 0) { + resolve(stdout) + } else { + reject(new Error(`Command "${command} ${args}" failed: "${stdout}"`)) + } + }) + + child.on('error', (err: Error) => { + reject(err) + }) + + if (child.stdin) { + // This is necessary if using Powershell 2 on Windows 7 to get the events + // to raise. + // See http://stackoverflow.com/questions/9155289/calling-powershell-from-nodejs + child.stdin.end() + } + }) + } catch (error) { + return Promise.reject(error) + } +} diff --git a/app/src/lib/progress/checkout.ts b/app/src/lib/progress/checkout.ts new file mode 100644 index 0000000000..3b8cc369a7 --- /dev/null +++ b/app/src/lib/progress/checkout.ts @@ -0,0 +1,13 @@ +import { GitProgressParser } from './git' + +const steps = [{ title: 'Checking out files', weight: 1 }] + +/** + * A class that parses output from `git checkout --progress` and provides + * structured progress events. + */ +export class CheckoutProgressParser extends GitProgressParser { + public constructor() { + super(steps) + } +} diff --git a/app/src/lib/progress/clone.ts b/app/src/lib/progress/clone.ts new file mode 100644 index 0000000000..c25181c631 --- /dev/null +++ b/app/src/lib/progress/clone.ts @@ -0,0 +1,23 @@ +import { GitProgressParser } from './git' + +/** + * Highly approximate (some would say outright inaccurate) division + * of the individual progress reporting steps in a clone operation + */ +const steps = [ + { title: 'remote: Compressing objects', weight: 0.1 }, + { title: 'Receiving objects', weight: 0.6 }, + { title: 'Resolving deltas', weight: 0.1 }, + { title: 'Checking out files', weight: 0.2 }, +] + +/** + * A utility class for interpreting the output from `git clone --progress` + * and turning that into a percentage value estimating the overall progress + * of the clone. + */ +export class CloneProgressParser extends GitProgressParser { + public constructor() { + super(steps) + } +} diff --git a/app/src/lib/progress/fetch.ts b/app/src/lib/progress/fetch.ts new file mode 100644 index 0000000000..e90e543c0a --- /dev/null +++ b/app/src/lib/progress/fetch.ts @@ -0,0 +1,22 @@ +import { GitProgressParser } from './git' + +/** + * Highly approximate (some would say outright inaccurate) division + * of the individual progress reporting steps in a fetch operation + */ +const steps = [ + { title: 'remote: Compressing objects', weight: 0.1 }, + { title: 'Receiving objects', weight: 0.7 }, + { title: 'Resolving deltas', weight: 0.2 }, +] + +/** + * A utility class for interpreting the output from `git fetch --progress` + * and turning that into a percentage value estimating the overall progress + * of the fetch. + */ +export class FetchProgressParser extends GitProgressParser { + public constructor() { + super(steps) + } +} diff --git a/app/src/lib/progress/from-process.ts b/app/src/lib/progress/from-process.ts new file mode 100644 index 0000000000..d86de2e43b --- /dev/null +++ b/app/src/lib/progress/from-process.ts @@ -0,0 +1,120 @@ +import { ChildProcess } from 'child_process' +import * as Fs from 'fs' +import * as Path from 'path' +import byline from 'byline' + +import { GitProgressParser, IGitProgress, IGitOutput } from './git' +import { IGitExecutionOptions } from '../git/core' +import { merge } from '../merge' +import { GitLFSProgressParser, createLFSProgressFile } from './lfs' +import { tailByLine } from '../file-system' + +/** + * Merges an instance of IGitExecutionOptions with a process callback provided + * by createProgressProcessCallback. + * + * If the given options object already has a processCallback specified it will + * be overwritten. + */ +export async function executionOptionsWithProgress( + options: IGitExecutionOptions, + parser: GitProgressParser, + progressCallback: (progress: IGitProgress | IGitOutput) => void +): Promise { + let lfsProgressPath = null + let env = {} + if (options.trackLFSProgress) { + try { + lfsProgressPath = await createLFSProgressFile() + env = { GIT_LFS_PROGRESS: lfsProgressPath } + } catch (e) { + log.error('Error writing LFS progress file', e) + env = { GIT_LFS_PROGRESS: null } + } + } + + return merge(options, { + processCallback: createProgressProcessCallback( + parser, + lfsProgressPath, + progressCallback + ), + env: merge(options.env, env), + }) +} + +/** + * Returns a callback which can be passed along to the processCallback option + * in IGitExecution. The callback then takes care of reading stderr of the + * process and parsing its contents using the provided parser. + */ +function createProgressProcessCallback( + parser: GitProgressParser, + lfsProgressPath: string | null, + progressCallback: (progress: IGitProgress | IGitOutput) => void +): (process: ChildProcess) => void { + return process => { + let lfsProgressActive = false + + if (lfsProgressPath) { + const lfsParser = new GitLFSProgressParser() + const disposable = tailByLine(lfsProgressPath, line => { + const progress = lfsParser.parse(line) + + if (progress.kind === 'progress') { + lfsProgressActive = true + progressCallback(progress) + } + }) + + process.on('close', () => { + disposable.dispose() + // the order of these callbacks is important because + // - unlink can only be done on files + // - rmdir can only be done when the directory is empty + // - we don't want to surface errors to the user if something goes + // wrong (these files can stay in TEMP and be cleaned up eventually) + Fs.unlink(lfsProgressPath, err => { + if (err == null) { + const directory = Path.dirname(lfsProgressPath) + Fs.rmdir(directory, () => {}) + } + }) + }) + } + + // If Node.js encounters a synchronous runtime error while spawning + // `stderr` will be undefined and the error will be emitted asynchronously + if (process.stderr) { + byline(process.stderr).on('data', (line: string) => { + const progress = parser.parse(line) + + if (lfsProgressActive) { + // While we're sending LFS progress we don't want to mix + // any non-progress events in with the output or we'll get + // flickering between the indeterminate LFS progress and + // the regular progress. + if (progress.kind === 'context') { + return + } + + const { title, done } = progress.details + + // The 'Filtering content' title happens while the LFS + // filter is running and when it's done we know that the + // filter is done but until then we don't want to display + // it for the same reason that we don't want to display + // the context above. + if (title === 'Filtering content') { + if (done) { + lfsProgressActive = false + } + return + } + } + + progressCallback(progress) + }) + } + } +} diff --git a/app/src/lib/progress/git.ts b/app/src/lib/progress/git.ts new file mode 100644 index 0000000000..3fe1024649 --- /dev/null +++ b/app/src/lib/progress/git.ts @@ -0,0 +1,307 @@ +/** + * Identifies a particular subset of progress events from Git by + * title. + */ +export interface IProgressStep { + /** + * The title of the git progress event. By title we refer to the + * exact value of the title field in the Git progress struct: + * + * https://github.com/git/git/blob/6a2c2f8d34fa1e8f3bb85d159d354810ed63692e/progress.c#L31-L39 + * + * In essence this means anything up to (but not including) the last colon (:) + * in a single progress line. Take this example progress line + * + * remote: Compressing objects: 14% (159/1133) + * + * In this case the title would be 'remote: Compressing objects'. + */ + readonly title: string + + /** + * The weight of this step in relation to others for a particular + * Git operation. This value can be any number as long as it's + * proportional to others in the same parser, it will all be scaled + * to a decimal value between 0 and 1 before being used to calculate + * overall progress. + */ + readonly weight: number +} + +/** + * The overall progress of one or more steps in a Git operation. + */ +export interface IGitProgress { + readonly kind: 'progress' + + /** + * The overall percent of the operation + */ + readonly percent: number + + /** + * The underlying progress line that this progress instance was + * constructed from. Note that the percent value in details + * doesn't correspond to that of percent in this instance for + * two reasons. Fist, we calculate percent by dividing value with total + * to produce a high precision decimal value between 0 and 1 while + * details.percent is a rounded integer between 0 and 100. + * + * Second, the percent in this instance is scaled in relation to any + * other steps included in the progress parser. + */ + readonly details: IGitProgressInfo +} + +export interface IGitOutput { + readonly kind: 'context' + readonly percent: number + readonly text: string +} + +/** + * A well-structured representation of a Git progress line. + */ +export interface IGitProgressInfo { + /** + * The title of the git progress event. By title we refer to the + * exact value of the title field in Git's progress struct: + * + * https://github.com/git/git/blob/6a2c2f8d34fa1e8f3bb85d159d354810ed63692e/progress.c#L31-L39 + * + * In essence this means anything up to (but not including) the last colon (:) + * in a single progress line. Take this example progress line + * + * remote: Compressing objects: 14% (159/1133) + * + * In this case the title would be 'remote: Compressing objects'. + */ + readonly title: string + + /** + * The progress value as parsed from the Git progress line. + * + * We define value to mean the same as it does in the Git progress struct, i.e + * it's the number of processed units. + * + * In the progress line 'remote: Compressing objects: 14% (159/1133)' the + * value is 159. + * + * In the progress line 'remote: Counting objects: 123' the value is 123. + * + */ + readonly value: number + + /** + * The progress total as parsed from the git progress line. + * + * We define total to mean the same as it does in the Git progress struct, i.e + * it's the total number of units in a given process. + * + * In the progress line 'remote: Compressing objects: 14% (159/1133)' the + * total is 1133. + * + * In the progress line 'remote: Counting objects: 123' the total is undefined. + * + */ + readonly total?: number + + /** + * The progress percent as parsed from the git progress line represented as + * an integer between 0 and 100. + * + * We define percent to mean the same as it does in the Git progress struct, i.e + * it's the value divided by total. + * + * In the progress line 'remote: Compressing objects: 14% (159/1133)' the + * percent is 14. + * + * In the progress line 'remote: Counting objects: 123' the percent is undefined. + * + */ + readonly percent?: number + + /** + * Whether or not the parsed git progress line indicates that the operation + * is done. + * + * This is denoted by a trailing ", done" string in the progress line. + * Example: Checking out files: 100% (728/728), done + */ + readonly done: boolean + + /** + * The untouched raw text line that this instance was parsed from. Useful + * for presenting the actual output from Git to the user. + */ + readonly text: string +} + +/** + * A utility class for interpreting progress output from `git` + * and turning that into a percentage value estimating the overall progress + * of the an operation. An operation could be something like `git fetch` + * which contains multiple steps, each individually reported by Git as + * progress events between 0 and 100%. + * + * A parser cannot be reused, it's mean to parse a single stderr stream + * for Git. + */ +export class GitProgressParser { + private readonly steps: ReadonlyArray + + /* The provided steps should always occur in order but some + * might not happen at all (like remote compression of objects) so + * we keep track of the "highest" seen step so that we can fill in + * progress with the assumption that we've already seen the previous + * steps. + */ + private stepIndex = 0 + + private lastPercent = 0 + + /** + * Initialize a new instance of a Git progress parser. + * + * @param steps - A series of steps that could be present in the git + * output with relative weight between these steps. Note + * that order is significant here as once the parser sees + * a progress line that matches a step all previous steps + * are considered completed and overall progress is adjusted + * accordingly. + */ + public constructor(steps: ReadonlyArray) { + if (!steps.length) { + throw new Error('must specify at least one step') + } + + // Scale the step weight so that they're all a percentage + // adjusted to the total weight of all steps. + const totalStepWeight = steps.reduce((sum, step) => sum + step.weight, 0) + + this.steps = steps.map(step => ({ + title: step.title, + weight: step.weight / totalStepWeight, + })) + } + + /** + * Parse the given line of output from Git, returns either an `IGitProgress` + * instance if the line could successfully be parsed as a Git progress + * event whose title was registered with this parser or an `IGitOutput` + * instance if the line couldn't be parsed or if the title wasn't + * registered with the parser. + */ + public parse(line: string): IGitProgress | IGitOutput { + const progress = parse(line) + + if (!progress) { + return { kind: 'context', text: line, percent: this.lastPercent } + } + + let percent = 0 + + for (let i = 0; i < this.steps.length; i++) { + const step = this.steps[i] + + if (i >= this.stepIndex && progress.title === step.title) { + if (progress.total) { + percent += step.weight * (progress.value / progress.total) + } + + this.stepIndex = i + this.lastPercent = percent + + return { kind: 'progress', percent, details: progress } + } else { + percent += step.weight + } + } + + return { kind: 'context', text: line, percent: this.lastPercent } + } +} + +const percentRe = /^(\d{1,3})% \((\d+)\/(\d+)\)$/ +const valueOnlyRe = /^\d+$/ + +/** + * Attempts to parse a single line of progress output from Git. + * + * For details about how Git formats progress see + * + * https://github.com/git/git/blob/6a2c2f8d34fa1e8f3bb85d159d354810ed63692e/progress.c + * + * Some examples: + * remote: Counting objects: 123 + * remote: Counting objects: 167587, done. + * Receiving objects: 99% (166741/167587), 272.10 MiB | 2.39 MiB/s + * Checking out files: 100% (728/728) + * Checking out files: 100% (728/728), done + * + * @returns An object containing well-structured information about the progress + * or null if the line could not be parsed as a Git progress line. + */ +export function parse(line: string): IGitProgressInfo | null { + const titleLength = line.lastIndexOf(': ') + + if (titleLength === 0) { + return null + } + + if (titleLength - 2 >= line.length) { + return null + } + + const title = line.substring(0, titleLength) + const progressText = line.substring(title.length + 2).trim() + + if (!progressText.length) { + return null + } + + const progressParts = progressText.split(', ') + + if (!progressParts.length) { + return null + } + + let value: number + let total: number | undefined = undefined + let percent: number | undefined = undefined + + if (valueOnlyRe.test(progressParts[0])) { + value = parseInt(progressParts[0], 10) + + if (isNaN(value)) { + return null + } + } else { + const percentMatch = percentRe.exec(progressParts[0]) + + if (!percentMatch || percentMatch.length !== 4) { + return null + } + + percent = parseInt(percentMatch[1], 10) + value = parseInt(percentMatch[2], 10) + total = parseInt(percentMatch[3], 10) + + if (isNaN(percent) || isNaN(value) || isNaN(total)) { + return null + } + } + + let done = false + + // We don't parse throughput at the moment so let's just loop + // through the remaining + for (let i = 1; i < progressParts.length; i++) { + if (progressParts[i] === 'done.') { + done = true + break + } + } + + return { title, value, percent, total, done, text: line } +} diff --git a/app/src/lib/progress/index.ts b/app/src/lib/progress/index.ts new file mode 100644 index 0000000000..e573fe4b0b --- /dev/null +++ b/app/src/lib/progress/index.ts @@ -0,0 +1,7 @@ +export * from './checkout' +export * from './clone' +export * from './push' +export * from './fetch' +export * from './git' +export * from './pull' +export * from './from-process' diff --git a/app/src/lib/progress/lfs.ts b/app/src/lib/progress/lfs.ts new file mode 100644 index 0000000000..03062d464e --- /dev/null +++ b/app/src/lib/progress/lfs.ts @@ -0,0 +1,128 @@ +import { getTempFilePath } from '../file-system' +import { IGitProgress, IGitProgressInfo, IGitOutput } from './git' +import { formatBytes } from '../../ui/lib/bytes' +import { open } from 'fs/promises' + +/** Create the Git LFS progress reporting file and return the path. */ +export async function createLFSProgressFile(): Promise { + const path = await getTempFilePath('GitHubDesktop-lfs-progress') + + // getTempFilePath will take care of creating the directory, we only need + // to make sure the file exists as well. We use `wx` to throw if the file + // already exists since we don't expect it to given that getTempFilePath + // creates a random path. + await open(path, 'wx').then(f => f.close()) + + return path +} + +// The regex for parsing LFS progress lines. See +// https://github.com/git-lfs/git-lfs/blob/dce20b0d18213d720ff2897267e68960d296eb5e/docs/man/git-lfs-config.5.ronn +// for more info. At a high level: +// +// ` / / ` +const LFSProgressLineRe = /^(.+?)\s{1}(\d+)\/(\d+)\s{1}(\d+)\/(\d+)\s{1}(.+)$/ + +interface IFileProgress { + /** + * The number of bytes that have been transferred + * for this file + */ + readonly transferred: number + + /** + * The total size of the file in bytes + */ + readonly size: number + + /** + * Whether this file has been transferred fully + */ + readonly done: boolean +} + +/** The progress parser for Git LFS. */ +export class GitLFSProgressParser { + /** + * A map keyed on the name of each file that LFS has reported + * progress on with the last seen progress as the value. + */ + private readonly files = new Map() + + /** Parse the progress line. */ + public parse(line: string): IGitProgress | IGitOutput { + const matches = line.match(LFSProgressLineRe) + if (!matches || matches.length !== 7) { + return { kind: 'context', percent: 0, text: line } + } + + const direction = matches[1] + const estimatedFileCount = parseInt(matches[3], 10) + const fileTransferred = parseInt(matches[4], 10) + const fileSize = parseInt(matches[5], 10) + const fileName = matches[6] + + if ( + isNaN(estimatedFileCount) || + isNaN(fileTransferred) || + isNaN(fileSize) + ) { + return { kind: 'context', percent: 0, text: line } + } + + this.files.set(fileName, { + transferred: fileTransferred, + size: fileSize, + done: fileTransferred === fileSize, + }) + + let totalTransferred = 0 + let totalEstimated = 0 + let finishedFiles = 0 + + // When uploading LFS files the estimate is accurate but not + // when downloading so we'll choose whichever is biggest of the estimate + // and the actual number of files we've seen + const fileCount = Math.max(estimatedFileCount, this.files.size) + + for (const file of this.files.values()) { + totalTransferred += file.transferred + totalEstimated += file.size + finishedFiles += file.done ? 1 : 0 + } + + const transferProgress = `${formatBytes( + totalTransferred, + 1 + )} / ${formatBytes(totalEstimated, 1)}` + + const verb = this.directionToHumanFacingVerb(direction) + const info: IGitProgressInfo = { + title: `${verb} "${fileName}"`, + value: totalTransferred, + total: totalEstimated, + percent: 0, + done: false, + text: `${verb} ${fileName} (${finishedFiles} out of an estimated ${fileCount} completed, ${transferProgress})`, + } + + return { + kind: 'progress', + percent: 0, + details: info, + } + } + + private directionToHumanFacingVerb(direction: string): string { + switch (direction) { + case 'download': + return 'Downloading' + case 'upload': + return 'Uploading' + case 'checkout': + return 'Checking out' + default: + return 'Downloading' + } + } +} diff --git a/app/src/lib/progress/pull.ts b/app/src/lib/progress/pull.ts new file mode 100644 index 0000000000..40fe694204 --- /dev/null +++ b/app/src/lib/progress/pull.ts @@ -0,0 +1,27 @@ +import { GitProgressParser } from './git' + +/** + * Highly approximate (some would say outright inaccurate) division + * of the individual progress reporting steps in a pull operation. + * + * Note: A pull is essentially the same as a fetch except we might + * have to check out some files at the end. We assume that these + * delta updates are fairly quick though. + */ +const steps = [ + { title: 'remote: Compressing objects', weight: 0.1 }, + { title: 'Receiving objects', weight: 0.7 }, + { title: 'Resolving deltas', weight: 0.15 }, + { title: 'Checking out files', weight: 0.15 }, +] + +/** + * A utility class for interpreting the output from `git pull --progress` + * and turning that into a percentage value estimating the overall progress + * of the pull. + */ +export class PullProgressParser extends GitProgressParser { + public constructor() { + super(steps) + } +} diff --git a/app/src/lib/progress/push.ts b/app/src/lib/progress/push.ts new file mode 100644 index 0000000000..0604fbfc4c --- /dev/null +++ b/app/src/lib/progress/push.ts @@ -0,0 +1,22 @@ +import { GitProgressParser } from './git' + +/** + * Highly approximate (some would say outright inaccurate) division + * of the individual progress reporting steps in a push operation + */ +const steps = [ + { title: 'Compressing objects', weight: 0.2 }, + { title: 'Writing objects', weight: 0.7 }, + { title: 'remote: Resolving deltas', weight: 0.1 }, +] + +/** + * A utility class for interpreting the output from `git push --progress` + * and turning that into a percentage value estimating the overall progress + * of the clone. + */ +export class PushProgressParser extends GitProgressParser { + public constructor() { + super(steps) + } +} diff --git a/app/src/lib/progress/revert.ts b/app/src/lib/progress/revert.ts new file mode 100644 index 0000000000..599ef70a8d --- /dev/null +++ b/app/src/lib/progress/revert.ts @@ -0,0 +1,13 @@ +import { GitProgressParser, IProgressStep } from './git' + +const steps: ReadonlyArray = [{ title: '', weight: 0 }] + +/** + * A class that parses output from `git revert` and provides structured progress + * events. + */ +export class RevertProgressParser extends GitProgressParser { + public constructor() { + super(steps) + } +} diff --git a/app/src/lib/promise.ts b/app/src/lib/promise.ts new file mode 100644 index 0000000000..e14b3da90a --- /dev/null +++ b/app/src/lib/promise.ts @@ -0,0 +1,53 @@ +/** + * Wrap a promise in a minimum timeout, so that it only returns after both the + * timeout and promise have completed. + * + * This is ideal for scenarios where a promises may complete quickly, but the + * caller wants to introduce a minimum latency so that any dependent UI is + * + * @param action the promise work to track + * @param timeoutMs the minimum time to wait before resolving the promise (in milliseconds) + */ +export function promiseWithMinimumTimeout( + action: () => Promise, + timeoutMs: number +): Promise { + return Promise.all([action(), sleep(timeoutMs)]).then(x => x[0]) +} + +/** + * `async`/`await`-friendly wrapper around `window.setTimeout` for places where + * callers want to defer async work and avoid the ceremony of this setup and + * using callbacks + * + * @param timeout the time to wait before resolving the promise (in milliseconds) + */ +export async function sleep(timeout: number): Promise { + return new Promise(resolve => window.setTimeout(resolve, timeout)) +} + +/** + * Helper function which lets callers define a maximum time to wait for + * a promise to complete after which a default value is returned instead. + * + * @param promise The promise to wait on + * @param timeout The maximum time to wait in milliseconds + * @param fallbackValue The default value to return should the promise + * not complete within `timeout` milliseconds. + */ +export async function timeout( + promise: Promise, + timeout: number, + fallbackValue: T +): Promise { + let timeoutId: number | null = null + const timeoutPromise = new Promise(resolve => { + timeoutId = window.setTimeout(() => resolve(fallbackValue), timeout) + }) + + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutId !== null) { + window.clearTimeout(timeoutId) + } + }) +} diff --git a/app/src/lib/queue-work.ts b/app/src/lib/queue-work.ts new file mode 100644 index 0000000000..36ff9eb958 --- /dev/null +++ b/app/src/lib/queue-work.ts @@ -0,0 +1,46 @@ +async function awaitAnimationFrame(): Promise { + return new Promise((resolve, reject) => { + requestAnimationFrame(resolve) + }) +} + +/** The amount of time in milliseconds that we'll dedicate to queued work. */ +const WorkWindowMs = 10 + +/** + * Split up high-priority synchronous work items across multiple animation frames. + * + * This function can be used to divvy up a set of tasks that needs to be executed + * as quickly as possible with minimal interference to the browser's rendering. + * + * It does so by executing one work item per animation frame, potentially + * squeezing in more if there's time left in the frame to do so. + * + * @param items A set of work items to be executed across one or more animation + * frames + * + * @param worker A worker which, given a work item, performs work and returns + * either a promise or a synchronous result + */ +export async function queueWorkHigh( + items: Iterable, + worker: (item: T) => Promise | any +) { + const iterator = items[Symbol.iterator]() + let next = iterator.next() + + while (!next.done) { + const start = await awaitAnimationFrame() + + // Run one or more work items inside the animation frame. We will always run + // at least one task but we may run more if we can squeeze them into a 10ms + // window (frames have 1s/60 = 16.6ms available and we want to leave a little + // for the browser). + do { + // Promise.resolve lets us pass either a const value or a promise and it'll + // ensure we get an awaitable promise back. + await Promise.resolve(worker(next.value)) + next = iterator.next() + } while (!next.done && performance.now() - start < WorkWindowMs) + } +} diff --git a/app/src/lib/range.ts b/app/src/lib/range.ts new file mode 100644 index 0000000000..a02e15ad48 --- /dev/null +++ b/app/src/lib/range.ts @@ -0,0 +1,26 @@ +// extracted from underscore, which in itself comes from Python +// https://github.com/jashkenas/underscore/blob/fc039f6a94fcf388d2b61ced4c02cd1ba116ecfd/underscore.js#L693-L710 + +export function range( + start: number, + stop: number, + step?: number +): ReadonlyArray { + if (stop === null) { + stop = start || 0 + start = 0 + } + + if (!step) { + step = stop < start ? -1 : 1 + } + + const length = Math.max(Math.ceil((stop - start) / step), 0) + const range = new Array(length) + + for (let idx = 0; idx < length; idx++, start += step) { + range[idx] = start + } + + return range +} diff --git a/app/src/lib/read-emoji.ts b/app/src/lib/read-emoji.ts new file mode 100644 index 0000000000..d5a67f2b97 --- /dev/null +++ b/app/src/lib/read-emoji.ts @@ -0,0 +1,131 @@ +import * as Fs from 'fs' +import * as Path from 'path' +import { encodePathAsUrl } from './path' + +/** + * Type representing the contents of the gemoji json database + * which consists of a top-level array containing objects describing + * emojis. + */ +type IGemojiDb = ReadonlyArray + +/** + * Partial (there's more in the db) interface describing the elements + * in the gemoji json array. + */ +interface IGemojiDefinition { + /** + * 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 + + /** One or more human readable aliases for the emoji character */ + readonly aliases: ReadonlyArray + + /** An optional, human readable, description of the emoji */ + readonly description?: string +} + +function getEmojiImageUrlFromRelativePath(relativePath: string): string { + return encodePathAsUrl(__dirname, 'emoji', relativePath) +} + +/** + * Given a unicode point number, returns a hexadecimal string + * which is lef padded with zeroes to be at least 4 characters + */ +function getHexCodePoint(cp: number): string { + const str = cp.toString(16) + + // The combining characters are always stored on disk + // as zero-padded 4 character strings. Don't ask me why. + return str.length >= 4 ? str : ('0000' + str).substring(str.length) +} + +/** + * Returns a url to the on disk location of the image + * representing the given emoji or null in case the + * emoji unicode string was invalid. + */ +function getUrlFromUnicodeEmoji(emoji: string): string | null { + const codePoint = emoji.codePointAt(0) + + if (!codePoint) { + return null + } + + let filename = getHexCodePoint(codePoint) + + // Some emoji are composed of two unicode code points, they're + // usually variants of the same theme (like :one:, :two: etc) + // and they're stored on disk as CP1-CP2. + if (emoji.length > 2) { + const combiningCodePoint = emoji.codePointAt(2) + + // 0xfe0f is VARIATION SELECTOR-16 which, best as I can tell means + // make the character before me all fancy pants. I don't know why + // but gemoji explicitly excludes this from its naming scheme so + // we'll do the same. + if (combiningCodePoint && combiningCodePoint !== 0xfe0f) { + filename = `${filename}-${getHexCodePoint(combiningCodePoint)}` + } + } + + return getEmojiImageUrlFromRelativePath(`unicode/${filename}.png`) +} + +/** + * Read the stored emoji list from JSON into an in-memory representation. + * + * @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) => { + const path = Path.join(rootDir, 'emoji.json') + Fs.readFile(path, 'utf8', (err, data) => { + if (err) { + reject(err) + return + } + + const tmp = new Map() + + try { + const db: IGemojiDb = JSON.parse(data) + db.forEach(emoji => { + // Custom emoji don't have a unicode string and are instead stored + // on disk as their first alias. + const url = emoji.emoji + ? getUrlFromUnicodeEmoji(emoji.emoji) + : getEmojiImageUrlFromRelativePath(`${emoji.aliases[0]}.png`) + + if (!url) { + log.error(`Could not calculate location of emoji: ${emoji}`) + return + } + + emoji.aliases.forEach(alias => { + tmp.set(`:${alias}:`, url) + }) + }) + } catch (e) { + reject(e) + } + + const emoji = new Map() + + // Sort and insert into actual map + const keys = Array.from(tmp.keys()).sort() + keys.forEach(k => { + const value = tmp.get(k) + if (value) { + emoji.set(k, value) + } + }) + + resolve(emoji) + }) + }) +} diff --git a/app/src/lib/rebase.ts b/app/src/lib/rebase.ts new file mode 100644 index 0000000000..2f8e68179f --- /dev/null +++ b/app/src/lib/rebase.ts @@ -0,0 +1,68 @@ +import { IBranchesState } from '../lib/app-state' +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 + * with floating point division. + */ +export function formatRebaseValue(value: number) { + return Math.round(clamp(value, 0, 1) * 100) / 100 +} + +/** + * Check application state to see whether the action applied to the current + * branch should be a force push + */ +export function getCurrentBranchForcePushState( + branchesState: IBranchesState, + aheadBehind: IAheadBehind | null +): ForcePushBranchState { + if (aheadBehind === null) { + // no tracking branch found + return ForcePushBranchState.NotAvailable + } + + 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 + const { sha } = tip.branch.tip + const foundEntry = forcePushBranches.get(localBranchName) + canForcePushBranch = foundEntry === sha + } + + return canForcePushBranch + ? ForcePushBranchState.Recommended + : ForcePushBranchState.Available +} diff --git a/app/src/lib/release-notes.ts b/app/src/lib/release-notes.ts new file mode 100644 index 0000000000..c9590800fe --- /dev/null +++ b/app/src/lib/release-notes.ts @@ -0,0 +1,168 @@ +import { readFile } from 'fs/promises' +import * as Path from 'path' +import * as semver from 'semver' +import { + ReleaseMetadata, + ReleaseNote, + ReleaseSummary, +} from '../models/release-notes' +import { getVersion } from '../ui/lib/app-proxy' +import { formatDate } from './format-date' +import { offsetFromNow } from './offset-from' +import { encodePathAsUrl } from './path' + +// expects a release note entry to contain a header and then some text +// example: +// [New] Fallback to Gravatar for loading avatars - #821 +const itemEntryRe = /^\[([a-z]{1,})\]\s((.|\n)*)/i + +function parseEntry(note: string): ReleaseNote | null { + const text = note.trim() + const match = itemEntryRe.exec(text) + if (match === null) { + log.debug(`[ReleaseNotes] unable to convert text into entry: ${note}`) + return null + } + + const kind = match[1].toLowerCase() + const message = match[2] + if ( + kind === 'new' || + kind === 'fixed' || + kind === 'improved' || + kind === 'added' || + kind === 'pretext' || + kind === 'removed' + ) { + return { kind, message } + } + + log.debug(`[ReleaseNotes] kind ${kind} was found but is not a valid entry`) + + return { + kind: 'other', + message, + } +} + +/** + * A filter function with type predicate to return non-null and non-undefined + * entries while also satisfying the TS compiler + * + * Source: https://stackoverflow.com/a/46700791/1363815 + */ +function notEmpty(value: TValue | null | undefined): value is TValue { + return value !== null && value !== undefined +} + +export function parseReleaseEntries( + notes: ReadonlyArray +): ReadonlyArray { + return notes.map(n => parseEntry(n)).filter(notEmpty) +} + +export function getReleaseSummary( + latestRelease: ReleaseMetadata +): ReleaseSummary { + const entries = parseReleaseEntries(latestRelease.notes) + + const enhancements = entries.filter( + e => e.kind === 'new' || e.kind === 'added' || e.kind === 'improved' + ) + const bugfixes = entries.filter(e => e.kind === 'fixed') + const other = entries.filter(e => e.kind === 'removed' || e.kind === 'other') + const thankYous = entries.filter(e => e.message.includes(' Thanks @')) + const pretext = entries.filter(e => e.kind === 'pretext') + + return { + latestVersion: latestRelease.version, + datePublished: formatDate(new Date(latestRelease.pub_date), { + dateStyle: 'long', + }), + pretext, + enhancements, + bugfixes, + other, + thankYous, + } +} + +export async function getChangeLog( + limit?: number +): Promise> { + const changelogURL = new URL( + 'https://central.github.com/deployments/desktop/desktop/changelog.json' + ) + + if (__RELEASE_CHANNEL__ === 'beta' || __RELEASE_CHANNEL__ === 'test') { + changelogURL.searchParams.set('env', __RELEASE_CHANNEL__) + } + + if (limit !== undefined) { + changelogURL.searchParams.set('limit', limit.toString()) + } + + const response = await fetch(changelogURL.toString()) + if (response.ok) { + const releases: ReadonlyArray = await response.json() + return releases + } else { + return [] + } +} + +export async function generateReleaseSummary( + version?: string +): Promise> { + const lastTenReleases = await getChangeLog() + const currentVersion = new semver.SemVer(version ?? getVersion()) + const recentReleases = lastTenReleases.filter( + r => + semver.gt(new semver.SemVer(r.version), currentVersion) && + new Date(r.pub_date).getTime() > offsetFromNow(-90, 'days') + ) + + // We should only be pulling release notes when a release just happened, so + // there should be one within the past 90 days. Thus, this is just precaution + // to ensure we always show at least the last set of release notes. + return recentReleases.length > 0 + ? recentReleases.map(getReleaseSummary) + : [getReleaseSummary(lastTenReleases[0])] +} + +/** + * This method is used in conjunction with the Help > Show Popup > Release notes + * menu item to test release notes on dev builds. + **/ +export async function generateDevReleaseSummary(): Promise< + ReadonlyArray +> { + // Remove version if want to use latest version in your dev build + const releases = [...(await generateReleaseSummary('3.0.0'))] + + const pretextDraft = await readFile( + Path.join(__dirname, 'static', 'pretext-draft.md'), + 'utf8' + ).catch(_ => null) + + if (pretextDraft === null) { + return releases + } + + return [ + { + ...releases[0], + pretext: [{ kind: 'pretext', message: pretextDraft }], + }, + ...releases.slice(1), + ] +} + +export const ReleaseNoteHeaderLeftUri = encodePathAsUrl( + __dirname, + 'static/release-note-header-left.svg' +) +export const ReleaseNoteHeaderRightUri = encodePathAsUrl( + __dirname, + 'static/release-note-header-right.svg' +) diff --git a/app/src/lib/remote-parsing.ts b/app/src/lib/remote-parsing.ts new file mode 100644 index 0000000000..2e8f15ca75 --- /dev/null +++ b/app/src/lib/remote-parsing.ts @@ -0,0 +1,95 @@ +export type GitProtocol = 'ssh' | 'https' + +interface IGitRemoteURL { + readonly protocol: GitProtocol + + /** The hostname of the remote. */ + readonly hostname: string + + /** + * The owner of the GitHub repository. This will be null if the URL doesn't + * take the form of a GitHub repository URL (e.g., owner/name). + */ + readonly owner: string + + /** + * The name of the GitHub repository. This will be null if the URL doesn't + * take the form of a GitHub repository URL (e.g., owner/name). + */ + readonly name: string +} + +// Examples: +// https://github.com/octocat/Hello-World.git +// https://github.com/octocat/Hello-World.git/ +// git@github.com:octocat/Hello-World.git +// git:github.com/octocat/Hello-World.git +const remoteRegexes: ReadonlyArray<{ protocol: GitProtocol; regex: RegExp }> = [ + { + protocol: 'https', + regex: new RegExp( + '^https?://(?:.+@)?(.+)/([^/]+)/([^/]+?)(?:/|\\.git/?)?$' + ), + }, + { + protocol: 'ssh', + regex: new RegExp('^git@(.+):([^/]+)/([^/]+?)(?:/|\\.git)?$'), + }, + { + protocol: 'ssh', + regex: new RegExp( + '^(?:.+)@(.+\\.ghe\\.com):([^/]+)/([^/]+?)(?:/|\\.git)?$' + ), + }, + { + protocol: 'ssh', + regex: new RegExp('^git:(.+)/([^/]+)/([^/]+?)(?:/|\\.git)?$'), + }, + { + protocol: 'ssh', + regex: new RegExp('^ssh://git@(.+)/(.+)/(.+?)(?:/|\\.git)?$'), + }, +] + +/** Parse the remote information from URL. */ +export function parseRemote(url: string): IGitRemoteURL | null { + for (const { protocol, regex } of remoteRegexes) { + const match = regex.exec(url) + if (match !== null && match.length >= 4) { + return { protocol, hostname: match[1], owner: match[2], name: match[3] } + } + } + + return null +} + +export interface IRepositoryIdentifier { + readonly hostname: string | null + readonly owner: string + readonly name: string +} + +/** Try to parse an owner and name from a URL or owner/name shortcut. */ +export function parseRepositoryIdentifier( + url: string +): IRepositoryIdentifier | null { + const parsed = parseRemote(url) + // If we can parse it as a remote URL, we'll assume they gave us a proper + // URL. If not, we'll try treating it as a GitHub repository owner/name + // shortcut. + if (parsed) { + const { owner, name, hostname } = parsed + if (owner && name) { + return { owner, name, hostname } + } + } + + const pieces = url.split('/') + if (pieces.length === 2 && pieces[0].length > 0 && pieces[1].length > 0) { + const owner = pieces[0] + const name = pieces[1] + return { owner, name, hostname: null } + } + + return null +} diff --git a/app/src/lib/remove-remote-prefix.ts b/app/src/lib/remove-remote-prefix.ts new file mode 100644 index 0000000000..f1d6a645c3 --- /dev/null +++ b/app/src/lib/remove-remote-prefix.ts @@ -0,0 +1,16 @@ +/** + * Remove the remote prefix from the string. If there is no prefix, returns + * null. E.g.: + * + * origin/my-branch -> my-branch + * origin/thing/my-branch -> thing/my-branch + * my-branch -> null + */ +export function removeRemotePrefix(name: string): string | null { + const pieces = name.match(/.*?\/(.*)/) + if (!pieces || pieces.length < 2) { + return null + } + + return pieces[1] +} diff --git a/app/src/lib/repository-matching.ts b/app/src/lib/repository-matching.ts new file mode 100644 index 0000000000..ca10921d0b --- /dev/null +++ b/app/src/lib/repository-matching.ts @@ -0,0 +1,149 @@ +import * as URL from 'url' +import * as Path from 'path' + +import { CloningRepository } from '../models/cloning-repository' +import { Repository } from '../models/repository' +import { Account } from '../models/account' +import { IRemote } from '../models/remote' +import { getHTMLURL } from './api' +import { parseRemote, parseRepositoryIdentifier } from './remote-parsing' +import { caseInsensitiveEquals } from './compare' +import { GitHubRepository } from '../models/github-repository' + +export interface IMatchedGitHubRepository { + /** + * The name of the repository, e.g., for https://github.com/user/repo, the + * name is `repo`. + */ + readonly name: string + + /** + * The login of the owner of the repository, e.g., for + * https://github.com/user/repo, the owner is `user`. + */ + readonly owner: string + + /** The account matching the repository remote */ + readonly account: Account +} + +/** Try to use the list of users and a remote URL to guess a GitHub repository. */ +export function matchGitHubRepository( + accounts: ReadonlyArray, + remote: string +): IMatchedGitHubRepository | null { + for (const account of accounts) { + const htmlURL = getHTMLURL(account.endpoint) + const { hostname } = URL.parse(htmlURL) + const parsedRemote = parseRemote(remote) + + if (parsedRemote !== null && hostname !== null) { + if (parsedRemote.hostname.toLowerCase() === hostname.toLowerCase()) { + return { name: parsedRemote.name, owner: parsedRemote.owner, account } + } + } + } + + return null +} + +/** + * Find an existing repository associated with this path + * + * @param repos The list of repositories tracked in the app + * @param path The path on disk which might be a repository + */ +export function matchExistingRepository< + T extends Repository | CloningRepository +>(repos: ReadonlyArray, path: string): T | undefined { + // Windows is guaranteed to be case-insensitive so we can be a bit less strict + const normalize = __WIN32__ + ? (p: string) => Path.normalize(p).toLowerCase() + : (p: string) => Path.normalize(p) + + const needle = normalize(path) + return repos.find(r => normalize(r.path) === needle) +} + +/** + * Check whether or not a GitHub repository matches a given remote. + * + * @param gitHubRepository the repository containing information from the GitHub API + * @param remote the remote details found in the Git repository + */ +export function repositoryMatchesRemote( + gitHubRepository: GitHubRepository, + remote: IRemote +): boolean { + return ( + urlMatchesRemote(gitHubRepository.htmlURL, remote) || + urlMatchesRemote(gitHubRepository.cloneURL, remote) + ) +} + +/** + * Check whether or not a GitHub repository URL matches a given remote, by + * parsing and comparing the structure of the each URL. + * + * @param url a URL associated with the GitHub repository + * @param remote the remote details found in the Git repository + */ +export function urlMatchesRemote(url: string | null, remote: IRemote): boolean { + if (url == null) { + return false + } + + const cloneUrl = parseRemote(url) + const remoteUrl = parseRemote(remote.url) + + if (remoteUrl == null || cloneUrl == null) { + return false + } + + if (!caseInsensitiveEquals(remoteUrl.hostname, cloneUrl.hostname)) { + return false + } + + if (remoteUrl.owner == null || cloneUrl.owner == null) { + return false + } + + if (remoteUrl.name == null || cloneUrl.name == null) { + return false + } + + return ( + caseInsensitiveEquals(remoteUrl.owner, cloneUrl.owner) && + caseInsensitiveEquals(remoteUrl.name, cloneUrl.name) + ) +} + +/** + * Match a URL-like string to the Clone URL of a GitHub Repository + * + * @param url A remote-like URL to verify against the existing information + * @param gitHubRepository GitHub API details for a repository + */ +export function urlMatchesCloneURL( + url: string, + gitHubRepository: GitHubRepository +): boolean { + if (gitHubRepository.cloneURL === null) { + return false + } + + return urlsMatch(gitHubRepository.cloneURL, url) +} + +export function urlsMatch(url1: string, url2: string) { + const firstIdentifier = parseRepositoryIdentifier(url1) + const secondIdentifier = parseRepositoryIdentifier(url2) + + return ( + firstIdentifier !== null && + secondIdentifier !== null && + firstIdentifier.hostname === secondIdentifier.hostname && + firstIdentifier.owner === secondIdentifier.owner && + firstIdentifier.name === secondIdentifier.name + ) +} diff --git a/app/src/lib/resolve-git-proxy.ts b/app/src/lib/resolve-git-proxy.ts new file mode 100644 index 0000000000..cf60632a6c --- /dev/null +++ b/app/src/lib/resolve-git-proxy.ts @@ -0,0 +1,42 @@ +import { resolveProxy } from '../ui/main-process-proxy' +import { parsePACString } from './parse-pac-string' + +export async function resolveGitProxy( + url: string +): Promise { + // resolveProxy doesn't throw an error (at least not in the + // current Electron version) but it could in the future and + // it's also possible that the IPC layer could throw an + // error (if the URL we're given is null or undefined despite + // our best type efforts for example). + // Better safe than sorry. + const pacString = await resolveProxy(url).catch(err => { + log.error(`Failed resolving proxy for '${url}'`, err) + return 'DIRECT' + }) + + const proxies = parsePACString(pacString) + + if (proxies === null) { + return undefined + } + + for (const proxy of proxies) { + // On Windows GitHub Desktop relies on the `schannel` `http.sslBackend` to + // be used in order to support things like self-signed certificates. + // Unfortunately it doesn't support https proxies so we'll exclude those + // here. Luckily for us https proxies are really rare. On macOS we use + // the OpenSSL ssl backend which does support https proxies. + // + // See + // https://github.com/jeroen/curl/issues/186#issuecomment-494560890 + // "The Schannel backend doesn't support HTTPS proxy" + if (__WIN32__ && proxy.startsWith('https://')) { + log.warn('ignoring https proxy, not supported in cURL/schannel') + } else { + return proxy + } + } + + return undefined +} diff --git a/app/src/lib/sanitize-ref-name.ts b/app/src/lib/sanitize-ref-name.ts new file mode 100644 index 0000000000..2a5196b20e --- /dev/null +++ b/app/src/lib/sanitize-ref-name.ts @@ -0,0 +1,16 @@ +// See https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html +// ASCII Control chars and space, DEL, ~ ^ : ? * [ \ +// | " < and > is technically a valid refname but not on Windows +// the magic sequence @{, consecutive dots, leading and trailing dot, ref ending in .lock +const invalidCharacterRegex = + /[\x00-\x20\x7F~^:?*\[\\|""<>]+|@{|\.\.+|^\.|\.$|\.lock$|\/$/g + +/** Sanitize a proposed reference name by replacing illegal characters. */ +export function sanitizedRefName(name: string): string { + return name.replace(invalidCharacterRegex, '-').replace(/^[-\+]*/g, '') +} + +/** Validate that a reference does not contain any invalid characters */ +export function testForInvalidChars(name: string): boolean { + return invalidCharacterRegex.test(name) +} diff --git a/app/src/lib/shell.ts b/app/src/lib/shell.ts new file mode 100644 index 0000000000..0f992842ad --- /dev/null +++ b/app/src/lib/shell.ts @@ -0,0 +1,68 @@ +import { ExecFileOptions } from 'child_process' +import { execFile } from './exec-file' +import { isMacOSCatalinaOrEarlier } from './get-os' + +/** + * The names of any env vars that we shouldn't copy from the shell environment. + */ +const ExcludedEnvironmentVars = new Set(['LOCAL_GIT_DIRECTORY']) + +/** + * Inspect whether the current process needs to be patched to get important + * environment variables for Desktop to work and integrate with other tools + * the user may invoke as part of their workflow. + * + * This is only applied to macOS installations due to how the application + * is launched. + * + * @param process The process to inspect. + */ +export function shellNeedsPatching(process: NodeJS.Process): boolean { + // We don't want to run this in the main process until the following issues + // are closed (and possibly not after they're closed either) + // + // See https://github.com/desktop/desktop/issues/13974 + // See https://github.com/electron/electron/issues/32718 + if (process.type === 'browser' && isMacOSCatalinaOrEarlier()) { + return false + } + + return __DARWIN__ && !process.env.PWD +} + +/** + * Update the current process's environment variables using environment + * variables from the user's shell, if they can be retrieved successfully. + */ +export async function updateEnvironmentForProcess(): Promise { + if (!__DARWIN__) { + return + } + + const shell = process.env.SHELL || '/bin/bash' + + // These options are leftovers of previous implementations and could + // _probably_ be removed. The default maxBuffer is 1Mb and I don't know why + // anyone would have 1Mb of env vars (previous implementation had no limit). + // + // The timeout is a leftover from when the process was detached and the reason + // we still have it is that if we happen to await this method it could block + // app launch + const opts: ExecFileOptions = { timeout: 5000, maxBuffer: 10 * 1024 * 1024 } + + // Deal with environment variables containing newlines by separating with \0 + // https://github.com/atom/atom/blob/d04abd683/src/update-process-env.js#L17 + const cmd = `command awk 'BEGIN{for(k in ENVIRON) printf("%c%s=%s%c", 0, k, ENVIRON[k], 0)}'` + + try { + const { stdout } = await execFile(shell, ['-ilc', cmd], opts) + + for (const [, k, v] of stdout.matchAll(/\0(.+?)=(.+?)\0/g)) { + if (!ExcludedEnvironmentVars.has(k)) { + process.env[k] = v + } + } + } catch (err) { + log.error('Failed updating process environment from shell', err) + } +} diff --git a/app/src/lib/shells/darwin.ts b/app/src/lib/shells/darwin.ts new file mode 100644 index 0000000000..f8363699c2 --- /dev/null +++ b/app/src/lib/shells/darwin.ts @@ -0,0 +1,164 @@ +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' + +export enum Shell { + Terminal = 'Terminal', + Hyper = 'Hyper', + iTerm2 = 'iTerm2', + PowerShellCore = 'PowerShell Core', + Kitty = 'Kitty', + Alacritty = 'Alacritty', + Tabby = 'Tabby', + WezTerm = 'WezTerm', + Warp = 'Warp', +} + +export const Default = Shell.Terminal + +export function parse(label: string): Shell { + return parseEnumValue(Shell, label) ?? Default +} + +function getBundleID(shell: Shell): string { + switch (shell) { + case Shell.Terminal: + return 'com.apple.Terminal' + case Shell.iTerm2: + return 'com.googlecode.iterm2' + case Shell.Hyper: + return 'co.zeit.hyper' + case Shell.PowerShellCore: + return 'com.microsoft.powershell' + case Shell.Kitty: + return 'net.kovidgoyal.kitty' + case Shell.Alacritty: + return 'io.alacritty' + case Shell.Tabby: + return 'org.tabby' + case Shell.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 + } +} + +export async function getAvailableShells(): Promise< + ReadonlyArray> +> { + const [ + terminalPath, + hyperPath, + iTermPath, + powerShellCorePath, + kittyPath, + alacrittyPath, + tabbyPath, + wezTermPath, + warpPath, + ] = await Promise.all([ + getShellPath(Shell.Terminal), + getShellPath(Shell.Hyper), + getShellPath(Shell.iTerm2), + getShellPath(Shell.PowerShellCore), + getShellPath(Shell.Kitty), + getShellPath(Shell.Alacritty), + getShellPath(Shell.Tabby), + getShellPath(Shell.WezTerm), + getShellPath(Shell.Warp), + ]) + + const shells: Array> = [] + if (terminalPath) { + shells.push({ shell: Shell.Terminal, path: terminalPath }) + } + + if (hyperPath) { + shells.push({ shell: Shell.Hyper, path: hyperPath }) + } + + if (iTermPath) { + shells.push({ shell: Shell.iTerm2, path: iTermPath }) + } + + if (powerShellCorePath) { + shells.push({ shell: Shell.PowerShellCore, path: powerShellCorePath }) + } + + if (kittyPath) { + const kittyExecutable = `${kittyPath}/Contents/MacOS/kitty` + shells.push({ shell: Shell.Kitty, path: kittyExecutable }) + } + + if (alacrittyPath) { + const alacrittyExecutable = `${alacrittyPath}/Contents/MacOS/alacritty` + shells.push({ shell: Shell.Alacritty, path: alacrittyExecutable }) + } + + if (tabbyPath) { + const tabbyExecutable = `${tabbyPath}/Contents/MacOS/Tabby` + shells.push({ shell: Shell.Tabby, path: tabbyExecutable }) + } + + if (wezTermPath) { + const wezTermExecutable = `${wezTermPath}/Contents/MacOS/wezterm` + shells.push({ shell: Shell.WezTerm, path: wezTermExecutable }) + } + + if (warpPath) { + const warpExecutable = `${warpPath}/Contents/MacOS/stable` + shells.push({ shell: Shell.Warp, path: warpExecutable }) + } + + return shells +} + +export function launch( + foundShell: IFoundShell, + path: string +): ChildProcess { + if (foundShell.shell === Shell.Kitty) { + // kitty does not handle arguments as expected when using `open` with + // an existing session but closed window (it reverts to the previous + // directory rather than using the new directory directory). + // + // This workaround launches the internal `kitty` executable which + // will open a new window to the desired path. + return spawn(foundShell.path, ['--single-instance', '--directory', path]) + } else if (foundShell.shell === Shell.Alacritty) { + // Alacritty cannot open files in the folder format. + // + // 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." + // + // It uses the subcommand `start`, followed by the option `--cwd` to set + // 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]) + } +} diff --git a/app/src/lib/shells/error.ts b/app/src/lib/shells/error.ts new file mode 100644 index 0000000000..43c1d8fc35 --- /dev/null +++ b/app/src/lib/shells/error.ts @@ -0,0 +1 @@ +export class ShellError extends Error {} diff --git a/app/src/lib/shells/found-shell.ts b/app/src/lib/shells/found-shell.ts new file mode 100644 index 0000000000..8993459431 --- /dev/null +++ b/app/src/lib/shells/found-shell.ts @@ -0,0 +1,5 @@ +export interface IFoundShell { + readonly shell: T + readonly path: string + readonly extraArgs?: string[] +} diff --git a/app/src/lib/shells/index.ts b/app/src/lib/shells/index.ts new file mode 100644 index 0000000000..bca773cfe4 --- /dev/null +++ b/app/src/lib/shells/index.ts @@ -0,0 +1,2 @@ +export * from './shared' +export { ShellError } from './error' diff --git a/app/src/lib/shells/linux.ts b/app/src/lib/shells/linux.ts new file mode 100644 index 0000000000..7ab0a4a5e8 --- /dev/null +++ b/app/src/lib/shells/linux.ts @@ -0,0 +1,185 @@ +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' + +export enum Shell { + Gnome = 'GNOME Terminal', + Mate = 'MATE Terminal', + Tilix = 'Tilix', + Terminator = 'Terminator', + Urxvt = 'URxvt', + Konsole = 'Konsole', + Xterm = 'XTerm', + Terminology = 'Terminology', + Deepin = 'Deepin Terminal', + Elementary = 'Elementary Terminal', + XFCE = 'XFCE Terminal', + Alacritty = 'Alacritty', + Kitty = 'Kitty', +} + +export const Default = Shell.Gnome + +export function parse(label: string): Shell { + return parseEnumValue(Shell, label) ?? Default +} + +async function getPathIfAvailable(path: string): Promise { + return (await pathExists(path)) ? path : null +} + +function getShellPath(shell: Shell): Promise { + switch (shell) { + case Shell.Gnome: + return getPathIfAvailable('/usr/bin/gnome-terminal') + case Shell.Mate: + return getPathIfAvailable('/usr/bin/mate-terminal') + case Shell.Tilix: + return getPathIfAvailable('/usr/bin/tilix') + case Shell.Terminator: + return getPathIfAvailable('/usr/bin/terminator') + case Shell.Urxvt: + return getPathIfAvailable('/usr/bin/urxvt') + case Shell.Konsole: + return getPathIfAvailable('/usr/bin/konsole') + case Shell.Xterm: + return getPathIfAvailable('/usr/bin/xterm') + case Shell.Terminology: + return getPathIfAvailable('/usr/bin/terminology') + case Shell.Deepin: + return getPathIfAvailable('/usr/bin/deepin-terminal') + case Shell.Elementary: + return getPathIfAvailable('/usr/bin/io.elementary.terminal') + case Shell.XFCE: + return getPathIfAvailable('/usr/bin/xfce4-terminal') + case Shell.Alacritty: + return getPathIfAvailable('/usr/bin/alacritty') + case Shell.Kitty: + return getPathIfAvailable('/usr/bin/kitty') + default: + return assertNever(shell, `Unknown shell: ${shell}`) + } +} + +export async function getAvailableShells(): Promise< + ReadonlyArray> +> { + const [ + gnomeTerminalPath, + mateTerminalPath, + tilixPath, + terminatorPath, + urxvtPath, + konsolePath, + xtermPath, + terminologyPath, + deepinPath, + elementaryPath, + xfcePath, + alacrittyPath, + kittyPath, + ] = await Promise.all([ + getShellPath(Shell.Gnome), + getShellPath(Shell.Mate), + getShellPath(Shell.Tilix), + getShellPath(Shell.Terminator), + getShellPath(Shell.Urxvt), + getShellPath(Shell.Konsole), + getShellPath(Shell.Xterm), + getShellPath(Shell.Terminology), + getShellPath(Shell.Deepin), + getShellPath(Shell.Elementary), + getShellPath(Shell.XFCE), + getShellPath(Shell.Alacritty), + getShellPath(Shell.Kitty), + ]) + + const shells: Array> = [] + if (gnomeTerminalPath) { + shells.push({ shell: Shell.Gnome, path: gnomeTerminalPath }) + } + + if (mateTerminalPath) { + shells.push({ shell: Shell.Mate, path: mateTerminalPath }) + } + + if (tilixPath) { + shells.push({ shell: Shell.Tilix, path: tilixPath }) + } + + if (terminatorPath) { + shells.push({ shell: Shell.Terminator, path: terminatorPath }) + } + + if (urxvtPath) { + shells.push({ shell: Shell.Urxvt, path: urxvtPath }) + } + + if (konsolePath) { + shells.push({ shell: Shell.Konsole, path: konsolePath }) + } + + if (xtermPath) { + shells.push({ shell: Shell.Xterm, path: xtermPath }) + } + + if (terminologyPath) { + shells.push({ shell: Shell.Terminology, path: terminologyPath }) + } + + if (deepinPath) { + shells.push({ shell: Shell.Deepin, path: deepinPath }) + } + + if (elementaryPath) { + shells.push({ shell: Shell.Elementary, path: elementaryPath }) + } + + if (xfcePath) { + shells.push({ shell: Shell.XFCE, path: xfcePath }) + } + + if (alacrittyPath) { + shells.push({ shell: Shell.Alacritty, path: alacrittyPath }) + } + + if (kittyPath) { + shells.push({ shell: Shell.Kitty, path: kittyPath }) + } + + return shells +} + +export function launch( + foundShell: IFoundShell, + path: string +): ChildProcess { + const shell = foundShell.shell + switch (shell) { + case Shell.Gnome: + case Shell.Mate: + case Shell.Tilix: + case Shell.Terminator: + case Shell.XFCE: + case Shell.Alacritty: + return spawn(foundShell.path, ['--working-directory', path]) + case Shell.Urxvt: + return spawn(foundShell.path, ['-cd', path]) + case Shell.Konsole: + return spawn(foundShell.path, ['--workdir', path]) + case Shell.Xterm: + return spawn(foundShell.path, ['-e', '/bin/bash'], { cwd: path }) + case Shell.Terminology: + return spawn(foundShell.path, ['-d', path]) + case Shell.Deepin: + return spawn(foundShell.path, ['-w', path]) + case Shell.Elementary: + return spawn(foundShell.path, ['-w', path]) + case Shell.Kitty: + return spawn(foundShell.path, ['--single-instance', '--directory', path]) + default: + return assertNever(shell, `Unknown shell: ${shell}`) + } +} diff --git a/app/src/lib/shells/shared.ts b/app/src/lib/shells/shared.ts new file mode 100644 index 0000000000..2f8c8be10a --- /dev/null +++ b/app/src/lib/shells/shared.ts @@ -0,0 +1,134 @@ +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' + +export type Shell = Darwin.Shell | Win32.Shell | Linux.Shell + +export type FoundShell = IFoundShell + +/** The default shell. */ +export const Default = (function () { + if (__DARWIN__) { + return Darwin.Default + } else if (__WIN32__) { + return Win32.Default + } else { + return Linux.Default + } +})() + +let shellCache: ReadonlyArray | null = null + +/** Parse the label into the specified shell type. */ +export function parse(label: string): Shell { + if (__DARWIN__) { + return Darwin.parse(label) + } else if (__WIN32__) { + return Win32.parse(label) + } else if (__LINUX__) { + return Linux.parse(label) + } + + throw new Error( + `Platform not currently supported for resolving shells: ${process.platform}` + ) +} + +/** Get the shells available for the user. */ +export async function getAvailableShells(): Promise> { + if (shellCache) { + return shellCache + } + + if (__DARWIN__) { + shellCache = await Darwin.getAvailableShells() + return shellCache + } else if (__WIN32__) { + shellCache = await Win32.getAvailableShells() + return shellCache + } else if (__LINUX__) { + shellCache = await Linux.getAvailableShells() + return shellCache + } + + return Promise.reject( + `Platform not currently supported for resolving shells: ${process.platform}` + ) +} + +/** Find the given shell or the default if the given shell can't be found. */ +export async function findShellOrDefault(shell: Shell): Promise { + const available = await getAvailableShells() + const found = available.find(s => s.shell === shell) + if (found) { + return found + } else { + return available.find(s => s.shell === Default)! + } +} + +/** Launch the given shell at the path. */ +export async function launchShell( + shell: FoundShell, + 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(shell.path) + if (!exists) { + 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.` + ) + } + + let cp: ChildProcess | null = null + + if (__DARWIN__) { + cp = Darwin.launch(shell as IFoundShell, path) + } else if (__WIN32__) { + cp = Win32.launch(shell as IFoundShell, path) + } else if (__LINUX__) { + cp = Linux.launch(shell as IFoundShell, path) + } + + if (cp != null) { + addErrorTracing(shell.shell, cp, onError) + return Promise.resolve() + } else { + return Promise.reject( + `Platform not currently supported for launching shells: ${process.platform}` + ) + } +} + +function addErrorTracing( + shell: Shell, + cp: ChildProcess, + onError: (error: Error) => void +) { + if (cp.stderr !== null) { + cp.stderr.on('data', chunk => { + const text = chunk instanceof Buffer ? chunk.toString() : chunk + log.debug(`[${shell}] stderr: '${text}'`) + }) + } + + cp.on('error', err => { + log.debug(`[${shell}] an error was encountered`, err) + onError(err) + }) + + cp.on('exit', code => { + if (code !== 0) { + log.debug(`[${shell}] exit code: ${code}`) + } + }) +} diff --git a/app/src/lib/shells/win32.ts b/app/src/lib/shells/win32.ts new file mode 100644 index 0000000000..8609d85e13 --- /dev/null +++ b/app/src/lib/shells/win32.ts @@ -0,0 +1,477 @@ +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' + +export enum Shell { + Cmd = 'Command Prompt', + PowerShell = 'PowerShell', + PowerShellCore = 'PowerShell Core', + Hyper = 'Hyper', + GitBash = 'Git Bash', + Cygwin = 'Cygwin', + WSL = 'WSL', + WindowTerminal = 'Windows Terminal', + FluentTerminal = 'Fluent Terminal', + Alacritty = 'Alacritty', +} + +export const Default = Shell.Cmd + +export function parse(label: string): Shell { + return parseEnumValue(Shell, label) ?? Default +} + +export async function getAvailableShells(): Promise< + ReadonlyArray> +> { + const gitPath = await findGitOnPath() + const rootDir = process.env.WINDIR || 'C:\\Windows' + const dosKeyExePath = `"${rootDir}\\system32\\doskey.exe git=^"${gitPath}^" $*"` + const shells: IFoundShell[] = [ + { + shell: Shell.Cmd, + path: process.env.comspec || 'C:\\Windows\\System32\\cmd.exe', + extraArgs: gitPath ? ['/K', dosKeyExePath] : [], + }, + ] + + const powerShellPath = await findPowerShell() + if (powerShellPath != null) { + shells.push({ + shell: Shell.PowerShell, + path: powerShellPath, + }) + } + + const powerShellCorePath = await findPowerShellCore() + if (powerShellCorePath != null) { + shells.push({ + shell: Shell.PowerShellCore, + path: powerShellCorePath, + }) + } + + const hyperPath = await findHyper() + if (hyperPath != null) { + shells.push({ + shell: Shell.Hyper, + path: hyperPath, + }) + } + + const gitBashPath = await findGitBash() + if (gitBashPath != null) { + shells.push({ + shell: Shell.GitBash, + path: gitBashPath, + }) + } + + const cygwinPath = await findCygwin() + if (cygwinPath != null) { + shells.push({ + shell: Shell.Cygwin, + path: cygwinPath, + }) + } + + if (enableWSLDetection()) { + const wslPath = await findWSL() + if (wslPath != null) { + shells.push({ + shell: Shell.WSL, + path: wslPath, + }) + } + } + + const alacrittyPath = await findAlacritty() + if (alacrittyPath != null) { + shells.push({ + shell: Shell.Alacritty, + path: alacrittyPath, + }) + } + + const windowsTerminal = await findWindowsTerminal() + if (windowsTerminal != null) { + shells.push({ + shell: Shell.WindowTerminal, + path: windowsTerminal, + }) + } + + const fluentTerminal = await findFluentTerminal() + if (fluentTerminal != null) { + shells.push({ + shell: Shell.FluentTerminal, + path: fluentTerminal, + }) + } + return shells +} + +async function findPowerShell(): Promise { + const powerShell = enumerateValues( + HKEY.HKEY_LOCAL_MACHINE, + 'Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\PowerShell.exe' + ) + + if (powerShell.length === 0) { + return null + } + + const first = powerShell[0] + + // NOTE: + // on Windows 7 these are both REG_SZ, which technically isn't supposed + // to contain unexpanded references to environment variables. But given + // it's also %SystemRoot% and we do the expanding here I think this is + // a fine workaround to do to support the maximum number of setups. + + if ( + first.type === RegistryValueType.REG_EXPAND_SZ || + first.type === RegistryValueType.REG_SZ + ) { + const path = first.data.replace( + /^%SystemRoot%/i, + process.env.SystemRoot || 'C:\\Windows' + ) + + if (await pathExists(path)) { + return path + } else { + log.debug( + `[PowerShell] registry entry found but does not exist at '${path}'` + ) + } + } + + return null +} + +async function findPowerShellCore(): Promise { + const powerShellCore = enumerateValues( + HKEY.HKEY_LOCAL_MACHINE, + 'Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pwsh.exe' + ) + + if (powerShellCore.length === 0) { + return null + } + + const first = powerShellCore[0] + if (first.type === RegistryValueType.REG_SZ) { + const path = first.data + + if (await pathExists(path)) { + return path + } else { + log.debug( + `[PowerShellCore] registry entry found but does not exist at '${path}'` + ) + } + } + + return null +} + +async function findHyper(): Promise { + const hyper = enumerateValues( + HKEY.HKEY_CURRENT_USER, + 'Software\\Classes\\Directory\\Background\\shell\\Hyper\\command' + ) + + if (hyper.length === 0) { + return null + } + + const first = hyper[0] + if (first.type === RegistryValueType.REG_SZ) { + // Registry key is structured as "{installationPath}\app-x.x.x\Hyper.exe" "%V" + + // This regex is designed to get the path to the version-specific Hyper. + // commandPieces = ['"{installationPath}\app-x.x.x\Hyper.exe"', '"', '{installationPath}\app-x.x.x\Hyper.exe', ...] + const commandPieces = first.data.match(/(["'])(.*?)\1/) + const localAppData = process.env.LocalAppData + + const path = commandPieces + ? commandPieces[2] + : localAppData != null + ? localAppData.concat('\\hyper\\Hyper.exe') + : null // fall back to the launcher in install root + + if (path == null) { + log.debug( + `[Hyper] LOCALAPPDATA environment variable is unset, aborting fallback behavior` + ) + } else if (await pathExists(path)) { + return path + } else { + log.debug(`[Hyper] registry entry found but does not exist at '${path}'`) + } + } + + return null +} + +async function findGitBash(): Promise { + const registryPath = enumerateValues( + HKEY.HKEY_LOCAL_MACHINE, + 'SOFTWARE\\GitForWindows' + ) + + if (registryPath.length === 0) { + return null + } + + const installPathEntry = registryPath.find(e => e.name === 'InstallPath') + if (installPathEntry && installPathEntry.type === RegistryValueType.REG_SZ) { + const path = Path.join(installPathEntry.data, 'git-bash.exe') + + if (await pathExists(path)) { + return path + } else { + log.debug( + `[Git Bash] registry entry found but does not exist at '${path}'` + ) + } + } + + return null +} + +async function findCygwin(): Promise { + const registryPath64 = enumerateValues( + HKEY.HKEY_LOCAL_MACHINE, + 'SOFTWARE\\Cygwin\\setup' + ) + const registryPath32 = enumerateValues( + HKEY.HKEY_LOCAL_MACHINE, + 'SOFTWARE\\WOW6432Node\\Cygwin\\setup' + ) + + if (registryPath64 == null || registryPath32 == null) { + return null + } + + const installPathEntry64 = registryPath64.find(e => e.name === 'rootdir') + const installPathEntry32 = registryPath32.find(e => e.name === 'rootdir') + if ( + installPathEntry64 && + installPathEntry64.type === RegistryValueType.REG_SZ + ) { + const path = Path.join(installPathEntry64.data, 'bin\\mintty.exe') + + if (await pathExists(path)) { + return path + } else if ( + installPathEntry32 && + installPathEntry32.type === RegistryValueType.REG_SZ + ) { + const path = Path.join(installPathEntry32.data, 'bin\\mintty.exe') + if (await pathExists(path)) { + return path + } + } else { + log.debug(`[Cygwin] registry entry found but does not exist at '${path}'`) + } + } + + return null +} + +async function findWSL(): Promise { + const system32 = Path.join( + process.env.SystemRoot || 'C:\\Windows', + 'System32' + ) + const wslPath = Path.join(system32, 'wsl.exe') + const wslConfigPath = Path.join(system32, 'wslconfig.exe') + + if (!(await pathExists(wslPath))) { + log.debug(`[WSL] wsl.exe does not exist at '${wslPath}'`) + return null + } + if (!(await pathExists(wslConfigPath))) { + log.debug( + `[WSL] found wsl.exe, but wslconfig.exe does not exist at '${wslConfigPath}'` + ) + return null + } + const exitCode = new Promise((resolve, reject) => { + const wslDistros = spawn(wslConfigPath, ['/list']) + wslDistros.on('error', reject) + wslDistros.on('exit', resolve) + }) + + try { + const result = await exitCode + if (result !== 0) { + log.debug( + `[WSL] found wsl.exe and wslconfig.exe, but no distros are installed. Error Code: ${result}` + ) + return null + } + return wslPath + } catch (err) { + log.error(`[WSL] unhandled error when invoking 'wsl /list'`, err) + } + return null +} + +async function findAlacritty(): Promise { + const registryPath = enumerateValues( + HKEY.HKEY_CLASSES_ROOT, + 'Directory\\Background\\shell\\Open Alacritty here' + ) + + if (registryPath.length === 0) { + return null + } + + const alacritty = registryPath.find(e => e.name === 'Icon') + if (alacritty && alacritty.type === RegistryValueType.REG_SZ) { + const path = alacritty.data + if (await pathExists(path)) { + return path + } else { + log.debug( + `[Alacritty] registry entry found but does not exist at '${path}'` + ) + } + } + + return null +} + +async function findWindowsTerminal(): Promise { + // Windows Terminal has a link at + // C:\Users\\AppData\Local\Microsoft\WindowsApps\wt.exe + const localAppData = process.env.LocalAppData + if (localAppData != null) { + const windowsTerminalpath = Path.join( + localAppData, + '\\Microsoft\\WindowsApps\\wt.exe' + ) + if (await pathExists(windowsTerminalpath)) { + return windowsTerminalpath + } else { + log.debug( + `[Windows Terminal] wt.exe doest not exist at '${windowsTerminalpath}'` + ) + } + } + return null +} + +async function findFluentTerminal(): Promise { + // Fluent Terminal has a link at + // C:\Users\\AppData\Local\Microsoft\WindowsApps\flute.exe + const localAppData = process.env.LocalAppData + if (localAppData != null) { + const fluentTerminalpath = Path.join( + localAppData, + '\\Microsoft\\WindowsApps\\flute.exe' + ) + if (await pathExists(fluentTerminalpath)) { + return fluentTerminalpath + } else { + log.debug( + `[Fluent Terminal] flute.exe doest not exist at '${fluentTerminalpath}'` + ) + } + } + return null +} + +export function launch( + foundShell: IFoundShell, + path: string +): ChildProcess { + const shell = foundShell.shell + + switch (shell) { + case Shell.PowerShell: + return spawn('START', ['"PowerShell"', `"${foundShell.path}"`], { + shell: true, + cwd: path, + }) + case Shell.PowerShellCore: + return spawn( + 'START', + [ + '"PowerShell Core"', + `"${foundShell.path}"`, + '-WorkingDirectory', + `"${path}"`, + ], + { + shell: true, + cwd: path, + } + ) + case Shell.Hyper: + const hyperPath = `"${foundShell.path}"` + log.info(`launching ${shell} at path: ${hyperPath}`) + return spawn(hyperPath, [`"${path}"`], { + shell: true, + cwd: path, + }) + case Shell.Alacritty: + const alacrittyPath = `"${foundShell.path}"` + log.info(`launching ${shell} at path: ${alacrittyPath}`) + return spawn(alacrittyPath, [`--working-directory "${path}"`], { + shell: true, + cwd: path, + }) + case Shell.GitBash: + const gitBashPath = `"${foundShell.path}"` + log.info(`launching ${shell} at path: ${gitBashPath}`) + return spawn(gitBashPath, [`--cd="${path}"`], { + shell: true, + cwd: path, + }) + case Shell.Cygwin: + const cygwinPath = `"${foundShell.path}"` + log.info(`launching ${shell} at path: ${cygwinPath}`) + return spawn( + cygwinPath, + [`/bin/sh -lc 'cd "$(cygpath "${path}")"; exec bash`], + { + shell: true, + cwd: path, + } + ) + case Shell.WSL: + return spawn('START', ['"WSL"', `"${foundShell.path}"`], { + shell: true, + cwd: path, + }) + case Shell.Cmd: + return spawn( + 'START', + ['"Command Prompt"', `"${foundShell.path}"`, ...foundShell.extraArgs!], + { + shell: true, + cwd: path, + } + ) + case Shell.WindowTerminal: + const windowsTerminalPath = `"${foundShell.path}"` + log.info(`launching ${shell} at path: ${windowsTerminalPath}`) + return spawn(windowsTerminalPath, ['-d .'], { shell: true, cwd: path }) + case Shell.FluentTerminal: + const fluentTerminalPath = `"${foundShell.path}"` + log.info(`launching ${shell} at path: ${fluentTerminalPath}`) + return spawn(fluentTerminalPath, ['new'], { shell: true, cwd: path }) + default: + return assertNever(shell, `Unknown shell: ${shell}`) + } +} diff --git a/app/src/lib/source-map-support.ts b/app/src/lib/source-map-support.ts new file mode 100644 index 0000000000..b31016bf0b --- /dev/null +++ b/app/src/lib/source-map-support.ts @@ -0,0 +1,136 @@ +import * as Path from 'path' +import * as Fs from 'fs' +import sourceMapSupport from 'source-map-support' +import { fileURLToPath } from 'url' + +/** + * This array tells the source map logic which files that we can expect to + * be able to resolve a source map for and they should reflect the chunks + * entry names from our webpack config. + * + * Note that we explicitly don't enable source maps for the crash process + * since it's possible that the error which caused us to spawn the crash + * process was related to source maps. + */ +const knownFilesWithSourceMap = ['renderer.js', 'main.js'] + +function retrieveSourceMap(source: string) { + // This is a happy path in case we know for certain that we won't be + // able to resolve a source map for the given location. + if (!knownFilesWithSourceMap.some(file => source.endsWith(file))) { + return null + } + + // We get a file uri when we're inside a renderer, convert to a path + if (source.startsWith('file://')) { + source = fileURLToPath(source) + } + + // We store our source maps right next to the bundle + const path = `${source}.map` + + if (__DEV__ && path.startsWith('http://')) { + try { + const xhr = new XMLHttpRequest() + xhr.open('GET', path, false) + xhr.send(null) + if (xhr.readyState === 4 && xhr.status === 200) { + return { url: Path.basename(path), map: xhr.responseText } + } + } catch (error) { + return null + } + return null + } + + // We don't have an option here, see + // https://github.com/v8/v8/wiki/Stack-Trace-API#customizing-stack-traces + // This happens on-demand when someone accesses the stack + // property on an error object and has to be synchronous :/ + // eslint-disable-next-line no-sync + if (!Fs.existsSync(path)) { + return null + } + + try { + // eslint-disable-next-line no-sync + const map = Fs.readFileSync(path, 'utf8') + return { url: Path.basename(path), map } + } catch (error) { + return null + } +} + +/** A map from errors to their stack frames. */ +const stackFrameMap = new WeakMap>() + +/** + * The `prepareStackTrace` that comes from the `source-map-support` module. + * We'll use this when the user explicitly wants the stack source mapped. + */ +let prepareStackTraceWithSourceMap: ( + error: Error, + frames: ReadonlyArray +) => string + +/** + * Capture the error's stack frames and return a standard, un-source mapped + * stack trace. + */ +function prepareStackTrace(error: Error, frames: ReadonlyArray) { + stackFrameMap.set(error, frames) + + // Ideally we'd use the default `Error.prepareStackTrace` here but it's + // undefined so V8 must doing something fancy. Instead we'll do a decent + // impression. + return error + frames.map(frame => `\n at ${frame}`).join('') +} + +/** Enable source map support in the current process. */ +export function enableSourceMaps() { + sourceMapSupport.install({ + environment: 'node', + handleUncaughtExceptions: false, + retrieveSourceMap, + }) + + const AnyError = Error as any + // We want to keep `source-map-support`s `prepareStackTrace` around to use + // later, but our cheaper `prepareStackTrace` should be the default. + prepareStackTraceWithSourceMap = AnyError.prepareStackTrace + AnyError.prepareStackTrace = prepareStackTrace +} + +/** + * Make a copy of the error with a source-mapped stack trace. If it couldn't + * perform the source mapping, it'll use the original error stack. + */ +export function withSourceMappedStack(error: Error): Error { + return { + name: error.name, + message: error.message, + stack: sourceMappedStackTrace(error), + } +} + +/** Get the source mapped stack trace for the error. */ +function sourceMappedStackTrace(error: Error): string | undefined { + let frames = stackFrameMap.get(error) + + if (!frames) { + // At this point there's no guarantee that anyone has actually retrieved the + // stack on this error which means that our custom prepareStackTrace handler + // hasn't run and as a result of that we don't have the native frames stored + // in our weak map. In order to get around that we'll eagerly access the + // stack, forcing our handler to run which should ensure that the native + // frames are stored in our weak map. + ;(error.stack || '').toString() + frames = stackFrameMap.get(error) + } + + if (!frames) { + return error.stack + } + + return prepareStackTraceWithSourceMap(error, frames) +} diff --git a/app/src/lib/squash/squashed-commit-description.ts b/app/src/lib/squash/squashed-commit-description.ts new file mode 100644 index 0000000000..82bb90cb8a --- /dev/null +++ b/app/src/lib/squash/squashed-commit-description.ts @@ -0,0 +1,17 @@ +import { Commit } from '../../models/commit' + +export function getSquashedCommitDescription( + commits: ReadonlyArray, + squashOnto: Commit +): string { + const commitMessages = commits.map( + c => `${c.summary.trim()}\n\n${c.bodyNoCoAuthors.trim()}` + ) + + const descriptions = [ + squashOnto.bodyNoCoAuthors.trim(), + ...commitMessages, + ].filter(d => d.trim() !== '') + + return descriptions.join('\n\n') +} diff --git a/app/src/lib/squirrel-error-parser.ts b/app/src/lib/squirrel-error-parser.ts new file mode 100644 index 0000000000..337a14865f --- /dev/null +++ b/app/src/lib/squirrel-error-parser.ts @@ -0,0 +1,36 @@ +// an error that Electron raises when it can't find the installation for the running app +const squirrelMissingRegex = /^Can not find Squirrel$/ + +// an error that occurs when Squirrel isn't able to reach the update server +const squirrelDNSRegex = + /System\.Net\.WebException: The remote name could not be resolved: 'central\.github\.com'/ + +// an error that occurs when the connection times out during updating +const squirrelTimeoutRegex = + /A connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond/ + +/** + * This method parses known error messages from Squirrel.Windows and returns a + * friendlier message to the user. + * + * @param error The underlying error from Squirrel. + */ +export function parseError(error: Error): Error | null { + if (squirrelMissingRegex.test(error.message)) { + return new Error( + 'The application is missing a dependency it needs to check and install updates. This is very, very bad.' + ) + } + if (squirrelDNSRegex.test(error.message)) { + return new Error( + 'GitHub Desktop was not able to contact the update server. Ensure you have internet connectivity and try again.' + ) + } + if (squirrelTimeoutRegex.test(error.message)) { + return new Error( + 'GitHub Desktop was not able to check for updates due to a timeout. Ensure you have internet connectivity and try again.' + ) + } + + return null +} diff --git a/app/src/lib/ssh/ssh-key-passphrase.ts b/app/src/lib/ssh/ssh-key-passphrase.ts new file mode 100644 index 0000000000..aeaa7dedad --- /dev/null +++ b/app/src/lib/ssh/ssh-key-passphrase.ts @@ -0,0 +1,53 @@ +import { getFileHash } from '../get-file-hash' +import { TokenStore } from '../stores' +import { + getSSHSecretStoreKey, + keepSSHSecretToStore, +} from './ssh-secret-storage' + +const SSHKeyPassphraseTokenStoreKey = getSSHSecretStoreKey( + 'SSH key passphrases' +) + +async function getHashForSSHKey(keyPath: string) { + return getFileHash(keyPath, 'sha256') +} + +/** Retrieves the passphrase for the SSH key in the given path. */ +export async function getSSHKeyPassphrase(keyPath: string) { + try { + const fileHash = await getHashForSSHKey(keyPath) + return TokenStore.getItem(SSHKeyPassphraseTokenStoreKey, fileHash) + } catch (e) { + log.error('Could not retrieve passphrase for SSH key:', e) + return null + } +} + +/** + * Keeps the SSH key passphrase 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 token for the + * ongoing git operation. + * @param keyPath Path to the SSH key. + * @param passphrase Passphrase for the SSH key. + */ +export async function keepSSHKeyPassphraseToStore( + operationGUID: string, + keyPath: string, + passphrase: string +) { + try { + const keyHash = await getHashForSSHKey(keyPath) + keepSSHSecretToStore( + operationGUID, + SSHKeyPassphraseTokenStoreKey, + keyHash, + passphrase + ) + } 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 new file mode 100644 index 0000000000..7f651591f5 --- /dev/null +++ b/app/src/lib/ssh/ssh-secret-storage.ts @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000000..4bb25c4301 --- /dev/null +++ b/app/src/lib/ssh/ssh-user-password.ts @@ -0,0 +1,40 @@ +import { TokenStore } from '../stores' +import { + getSSHSecretStoreKey, + keepSSHSecretToStore, +} from './ssh-secret-storage' + +const SSHUserPasswordTokenStoreKey = getSSHSecretStoreKey('SSH user password') + +/** Retrieves the password for the given SSH username. */ +export async function getSSHUserPassword(username: string) { + try { + return TokenStore.getItem(SSHUserPasswordTokenStoreKey, username) + } catch (e) { + log.error('Could not retrieve passphrase for SSH key:', e) + return null + } +} + +/** + * Keeps the SSH user password 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 token for the + * ongoing git operation. + * @param username SSH user name. Usually in the form of `user@hostname`. + * @param password Password for the given user. + */ +export async function keepSSHUserPasswordToStore( + operationGUID: string, + username: string, + password: string +) { + keepSSHSecretToStore( + operationGUID, + SSHUserPasswordTokenStoreKey, + username, + password + ) +} diff --git a/app/src/lib/ssh/ssh.ts b/app/src/lib/ssh/ssh.ts new file mode 100644 index 0000000000..c77f2650fe --- /dev/null +++ b/app/src/lib/ssh/ssh.ts @@ -0,0 +1,87 @@ +import memoizeOne from 'memoize-one' +import { pathExists } from '../../ui/lib/path-exists' +import { getBoolean } from '../local-storage' +import { + getDesktopTrampolinePath, + getSSHWrapperPath, +} from '../trampoline/trampoline-environment' + +const WindowsOpenSSHPath = 'C:/Windows/System32/OpenSSH/ssh.exe' + +export const UseWindowsOpenSSHKey: string = 'useWindowsOpenSSH' + +export const isWindowsOpenSSHAvailable = memoizeOne( + async (): Promise => { + if (!__WIN32__) { + return false + } + + // FIXME: for now, seems like we can't use Windows' OpenSSH binary on Windows + // for ARM. + if (process.arch === 'arm64') { + return false + } + + return await pathExists(WindowsOpenSSHPath) + } +) + +// HACK: The purpose of this function is to wrap `getBoolean` inside a try/catch +// block, because for some reason, accessing localStorage from tests sometimes +// fails. +function isWindowsOpenSSHUseEnabled() { + try { + return getBoolean(UseWindowsOpenSSHKey, false) + } catch (e) { + return false + } +} + +/** + * Returns the git environment variables related to SSH depending on the current + * context (OS and user settings). + */ +export async function getSSHEnvironment() { + const baseEnv = { + SSH_ASKPASS: getDesktopTrampolinePath(), + // DISPLAY needs to be set to _something_ so ssh actually uses SSH_ASKPASS + DISPLAY: '.', + } + + const canUseWindowsSSH = await isWindowsOpenSSHAvailable() + if (canUseWindowsSSH && isWindowsOpenSSHUseEnabled()) { + // Replace git ssh command with Windows' OpenSSH executable path + return { + ...baseEnv, + GIT_SSH_COMMAND: WindowsOpenSSHPath, + } + } + + if (__DARWIN__ && __DEV__) { + // Replace git ssh command with our wrapper in dev builds, since they are + // launched from a command line. + return { + ...baseEnv, + GIT_SSH_COMMAND: `"${getSSHWrapperPath()}"`, + } + } + + return baseEnv +} + +export function parseAddSSHHostPrompt(prompt: string) { + const promptRegex = + /^The authenticity of host '([^ ]+) \(([^\)]+)\)' can't be established[^.]*\.\n([^ ]+) key fingerprint is ([^.]+)\./ + + const matches = promptRegex.exec(prompt) + if (matches === null || matches.length < 5) { + return null + } + + return { + host: matches[1], + ip: matches[2], + keyType: matches[3], + fingerprint: matches[4], + } +} diff --git a/app/src/lib/stats/index.ts b/app/src/lib/stats/index.ts new file mode 100644 index 0000000000..af806655d1 --- /dev/null +++ b/app/src/lib/stats/index.ts @@ -0,0 +1,2 @@ +export { StatsDatabase, ILaunchStats } from './stats-database' +export { StatsStore, IStatsStore, SamplesURL } from './stats-store' diff --git a/app/src/lib/stats/stats-database.ts b/app/src/lib/stats/stats-database.ts new file mode 100644 index 0000000000..92101b6946 --- /dev/null +++ b/app/src/lib/stats/stats-database.ts @@ -0,0 +1,618 @@ +import Dexie from 'dexie' + +// NB: This _must_ be incremented whenever the DB key scheme changes. +const DatabaseVersion = 2 + +/** The timing stats for app launch. */ +export interface ILaunchStats { + /** + * The time (in milliseconds) it takes from when our main process code is + * first loaded until the app `ready` event is emitted. + */ + readonly mainReadyTime: number + + /** + * The time (in milliseconds) it takes from when loading begins to loading + * end. + */ + readonly loadTime: number + + /** + * The time (in milliseconds) it takes from when our renderer process code is + * first loaded until the renderer `ready` event is emitted. + */ + readonly rendererReadyTime: number +} + +/** The daily measures captured for stats. */ +export interface IDailyMeasures { + /** The ID in the database. */ + readonly id?: number + + /** The number of commits. */ + readonly commits: number + + /** The number of times the user has opened a shell from the app. */ + readonly openShellCount: number + + /** The number of partial commits. */ + readonly partialCommits: number + + /** The number of commits created with one or more co-authors. */ + readonly coAuthoredCommits: number + + /** The number of commits undone by the user with a dirty working directory. */ + readonly commitsUndoneWithChanges: number + + /** The number of commits undone by the user with a clean working directory. */ + readonly commitsUndoneWithoutChanges: number + + /** The number of times a branch is compared to an arbitrary branch */ + readonly branchComparisons: number + + /** The number of times a branch is compared to the default branch */ + readonly defaultBranchComparisons: number + + /** The number of times a merge is initiated in the `compare` sidebar */ + readonly mergesInitiatedFromComparison: number + + /** The number of times the `Branch -> Update From Default Branch` menu item is used */ + readonly updateFromDefaultBranchMenuCount: number + + /** The number of times the `Branch -> Merge Into Current Branch` menu item is used */ + readonly mergeIntoCurrentBranchMenuCount: number + + /** The number of times the user checks out a branch using the PR menu */ + readonly prBranchCheckouts: number + + /** The numbers of times a repo with indicators is clicked on repo list view */ + readonly repoWithIndicatorClicked: number + + /** The numbers of times a repo without indicators is clicked on repo list view */ + readonly repoWithoutIndicatorClicked: number + + /** The number of times the user pushes to GitHub.com */ + readonly dotcomPushCount: number + + /** The number of times the user pushes with `--force-with-lease` to GitHub.com */ + readonly dotcomForcePushCount: number + + /** The number of times the user pushed to a GitHub Enterprise instance */ + readonly enterprisePushCount: number + + /** The number of times the user pushes with `--force-with-lease` to a GitHub Enterprise instance */ + readonly enterpriseForcePushCount: number + + /** The number of times the users pushes to a generic remote */ + readonly externalPushCount: number + + /** The number of times the users pushes with `--force-with-lease` to a generic remote */ + readonly externalForcePushCount: number + + /** The number of times the user merged before seeing the result of the merge hint */ + readonly mergedWithLoadingHintCount: number + + /** The number of times the user has merged after seeing the 'no conflicts' merge hint */ + readonly mergedWithCleanMergeHintCount: number + + /** The number of times the user has merged after seeing the 'you have XX conflicted files' warning */ + readonly mergedWithConflictWarningHintCount: number + + /** Whether or not the app has been interacted with during the current reporting window */ + readonly active: boolean + + /** The number of times a `git pull` initiated by Desktop resulted in a merge conflict for the user */ + readonly mergeConflictFromPullCount: number + + /** The number of times a `git merge` initiated by Desktop resulted in a merge conflict for the user */ + readonly mergeConflictFromExplicitMergeCount: number + + /** The number of times a conflicted merge was successfully completed by the user */ + readonly mergeSuccessAfterConflictsCount: number + + /** The number of times a conflicted merge was aborted by the user */ + readonly mergeAbortedAfterConflictsCount: number + + /** The number of commits that will go unattributed to GitHub users */ + readonly unattributedCommits: number + + /** + * The number of times the user made a commit to a repo hosted on + * a GitHub Enterprise instance + */ + readonly enterpriseCommits: number + + /** 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 */ + readonly commitsToProtectedBranch: number + + /** The number of times the user made a commit to a repository with branch protections enabled */ + readonly commitsToRepositoryWithBranchProtections: number + + /** The number of times the user dismissed the merge conflicts dialog */ + readonly mergeConflictsDialogDismissalCount: number + + /** The number of times the user dismissed the merge conflicts dialog with conflicts left */ + readonly anyConflictsLeftOnMergeConflictsDialogDismissalCount: number + + /** The number of times the user reopened the merge conflicts dialog (after closing it) */ + readonly mergeConflictsDialogReopenedCount: number + + /** The number of times the user committed a conflicted merge via the merge conflicts dialog */ + readonly guidedConflictedMergeCompletionCount: number + + /** 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 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 + + /** The number of times the rebase conflicts dialog is reopened */ + readonly rebaseConflictsDialogReopenedCount: number + + /** The number of times an aborted rebase is detected */ + readonly rebaseAbortedAfterConflictsCount: number + + /** The number of times a successful rebase after handling conflicts is detected */ + readonly rebaseSuccessAfterConflictsCount: number + + /** The number of times a successful rebase without conflicts is detected */ + readonly rebaseSuccessWithoutConflictsCount: number + + /** The number of times a user performed a pull with `pull.rebase` in config set to `true` */ + readonly pullWithRebaseCount: number + + /** The number of times a user has pulled with `pull.rebase` unset or set to `false` */ + readonly pullWithDefaultSettingCount: number + + /** + * The number of stash entries created outside of Desktop + * in a given 24 hour day + */ + readonly stashEntriesCreatedOutsideDesktop: number + + /** + * The number of times the user is presented with the error + * message "Some of your changes would be overwritten" + */ + readonly errorWhenSwitchingBranchesWithUncommmittedChanges: number + + /** The number of times the user opens the "Rebase current branch" menu item */ + readonly rebaseCurrentBranchMenuCount: number + + /** The number of times the user views a stash entry after checking out a branch */ + readonly stashViewedAfterCheckoutCount: number + + /** The number of times the user **doesn't** view a stash entry after checking out a branch */ + readonly stashNotViewedAfterCheckoutCount: number + + /** The number of times the user elects to stash changes on the current branch */ + readonly stashCreatedOnCurrentBranchCount: number + + /** The number of times the user elects to take changes to new branch instead of stashing them */ + readonly changesTakenToNewBranchCount: number + + /** The number of times the user elects to restore an entry from their stash */ + readonly stashRestoreCount: number + + /** The number of times the user elects to discard a stash entry */ + readonly stashDiscardCount: number + + /** + * The number of times the user views the stash entry as a result + * of clicking the "Stashed changes" row directly + */ + readonly stashViewCount: number + + /** The number of times the user takes no action on a stash entry once viewed */ + readonly noActionTakenOnStashCount: number + /** + * The number of times the user has opened their external editor from the + * suggested next steps view + */ + readonly suggestedStepOpenInExternalEditor: number + + /** + * The number of times the user has opened their repository in Finder/Explorer + * from the suggested next steps view + */ + readonly suggestedStepOpenWorkingDirectory: number + + /** + * The number of times the user has opened their repository on GitHub from the + * suggested next steps view + */ + readonly suggestedStepViewOnGitHub: number + + /** + * The number of times the user has used the publish repository action from the + * suggested next steps view + */ + readonly suggestedStepPublishRepository: number + + /** + * The number of times the user has used the publish branch action branch from + * the suggested next steps view + */ + readonly suggestedStepPublishBranch: number + + /** + * The number of times the user has used the Create PR suggestion + * in the suggested next steps view. Note that this number is a + * subset of `createPullRequestCount`. I.e. if the Create PR suggestion + * is invoked both `suggestedStepCreatePR` and `createPullRequestCount` + * will increment whereas if a PR is created from the menu or from + * a keyboard shortcut only `createPullRequestCount` will increment. + */ + readonly suggestedStepCreatePullRequest: number + + /** + * The number of times the user has used the view stash action from + * the suggested next steps view + */ + readonly suggestedStepViewStash: number + + /** + * _[Onboarding tutorial]_ + * Has the user clicked the button to start the onboarding tutorial? + */ + readonly tutorialStarted: boolean + + /** + * _[Onboarding tutorial]_ + * Has the user successfully created a tutorial repo? + */ + readonly tutorialRepoCreated: boolean + + /** + * _[Onboarding tutorial]_ + * Has the user installed an editor, skipped this step, or have an editor already installed? + */ + readonly tutorialEditorInstalled: boolean + + /** + * _[Onboarding tutorial]_ + * Has the user successfully completed the create a branch step? + */ + readonly tutorialBranchCreated: boolean + + /** + * _[Onboarding tutorial]_ + * Has the user completed the edit a file step? + */ + readonly tutorialFileEdited: boolean + + /** + * _[Onboarding tutorial]_ + * Has the user completed the commit a file change step? + */ + readonly tutorialCommitCreated: boolean + + /** + * _[Onboarding tutorial]_ + * Has the user completed the push a branch step? + */ + readonly tutorialBranchPushed: boolean + + /** + * _[Onboarding tutorial]_ + * Has the user completed the create a PR step? + */ + readonly tutorialPrCreated: boolean + + /** + * _[Onboarding tutorial]_ + * Has the user completed all tutorial steps? + */ + readonly tutorialCompleted: boolean + + /** + * _[Onboarding tutorial]_ + * What's the highest tutorial step completed by user? + * (`0` is tutorial created, first step is `1`) + */ + readonly highestTutorialStepCompleted: number + + /** + * _[Forks]_ + * How many commits did the user make in a repo they + * don't have `write` access to? + */ + readonly commitsToRepositoryWithoutWriteAccess: number + + /** _[Forks]_ + * How many forks did the user create from Desktop? + */ + readonly forksCreated: number + + /** + * How many times has the user begun creating an issue from Desktop? + */ + readonly issueCreationWebpageOpenedCount: number + + /** + * How many tags have been created from the Desktop UI? + */ + readonly tagsCreatedInDesktop: number + + /** + * How many tags have been created in total. + */ + readonly tagsCreated: number + + /** + * How many tags have been deleted. + */ + readonly tagsDeleted: number + + /** Number of times the user has changed between unified and split diffs */ + readonly diffModeChangeCount: number + + /** Number of times the user has opened the diff options popover */ + readonly diffOptionsViewedCount: number + + /** Number of times the user has switched to or from History/Changes */ + readonly repositoryViewChangeCount: number + + /** Number of times the user has encountered an unhandled rejection */ + readonly unhandledRejectionCount: number + + /** The number of times a successful cherry pick occurs */ + readonly cherryPickSuccessfulCount: number + + /** The number of times a cherry pick is initiated through drag and drop */ + readonly cherryPickViaDragAndDropCount: number + + /** The number of times a cherry pick is initiated through the context menu */ + readonly cherryPickViaContextMenuCount: number + + /** The number of times a drag operation was started and canceled */ + readonly dragStartedAndCanceledCount: number + + /** The number of times conflicts encountered during a cherry pick */ + readonly cherryPickConflictsEncounteredCount: number + + /** The number of times cherry pick ended successfully after conflicts */ + readonly cherryPickSuccessfulWithConflictsCount: number + + /** The number of times cherry pick of multiple commits initiated */ + readonly cherryPickMultipleCommitsCount: number + + /** The number of times a cherry pick was undone */ + readonly cherryPickUndoneCount: number + + /** The number of times a branch was created during a cherry-pick */ + readonly cherryPickBranchCreatedCount: number + + /** The number of times the user started amending a commit */ + readonly amendCommitStartedCount: number + + /** The number of times the user amended a commit with file changes */ + readonly amendCommitSuccessfulWithFileChangesCount: number + + /** The number of times the user amended a commit without file changes */ + readonly amendCommitSuccessfulWithoutFileChangesCount: number + + /** The number of times a successful reorder occurs */ + readonly reorderSuccessfulCount: number + + /** The number of times a reorder is initiated */ + readonly reorderStartedCount: number + + /** The number of times conflicts encountered during a reorder */ + readonly reorderConflictsEncounteredCount: number + + /** The number of times reorder ended successfully after conflicts */ + readonly reorderSuccessfulWithConflictsCount: number + + /** The number of times reorder of multiple commits initiated */ + readonly reorderMultipleCommitsCount: number + + /** The number of times a reorder was undone */ + readonly reorderUndoneCount: number + + /** The number of times conflicts encountered during a squash */ + readonly squashConflictsEncounteredCount: number + + /** The number of times squash of multiple commits invoked */ + readonly squashMultipleCommitsInvokedCount: number + + /** The number of times a successful squash occurs */ + readonly squashSuccessfulCount: number + + /** The number of times squash ended successfully after conflicts */ + readonly squashSuccessfulWithConflictsCount: number + + /** The number of times a squash is initiated through the context menu */ + readonly squashViaContextMenuInvokedCount: number + + /** The number of times a squash is initiated through drag and drop */ + readonly squashViaDragAndDropInvokedCount: number + + /** The number of times a squash was undone */ + readonly squashUndoneCount: number + + /** The number of times the `Branch -> Squash and Merge Into Current Branch` menu item is used */ + readonly squashMergeIntoCurrentBranchMenuCount: number + + /** The number of times squash merge ended successfully after conflicts */ + readonly squashMergeSuccessfulWithConflictsCount: number + + /** The number of times a successful squash merge occurs */ + readonly squashMergeSuccessfulCount: number + + /** The number of times a squash merge is initiated */ + readonly squashMergeInvokedCount: number + + /** The number of times the user reset to a previous commit. */ + readonly resetToCommitCount: number + + /** The number of times the user opens the check run popover. */ + readonly opensCheckRunsPopover: number + + /** The number of times the user clicks link to view a check online */ + readonly viewsCheckOnline: number + + /** The number of times the user clicks link to view a check job step online */ + readonly viewsCheckJobStepOnline: number + + /** The number of times the user reruns checks */ + readonly rerunsChecks: number + + /** 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 + + /** The number of times the "checks failed" dialog was opened */ + readonly checksFailedDialogOpenCount: number + + /** + * The number of times the user decided to switch to the affected pull request + * from the "checks failed" dialog. + */ + readonly checksFailedDialogSwitchToPullRequestCount: number + + /** + * The number of times the user decided to re-run the checks from the "checks + * failed" dialog. + */ + 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 + + /** The number of "approved PR" notifications the user clicked */ + readonly pullRequestReviewApprovedNotificationClicked: number + + /** + * The number of times the user decided to switch to the affected pull request + * from the "approved PR" dialog. + */ + readonly pullRequestReviewApprovedDialogSwitchToPullRequestCount: number + + /** The number of "commented PR" notifications the user received */ + readonly pullRequestReviewCommentedNotificationCount: number + + /** The number of "commented PR" notifications the user clicked */ + readonly pullRequestReviewCommentedNotificationClicked: number + + /** + * The number of times the user decided to switch to the affected pull request + * from the "commented PR" dialog. + */ + readonly pullRequestReviewCommentedDialogSwitchToPullRequestCount: number + + /** The number of "changes requested" notifications the user received */ + readonly pullRequestReviewChangesRequestedNotificationCount: number + + /** The number of "changes requested" notifications the user clicked */ + readonly pullRequestReviewChangesRequestedNotificationClicked: number + + /** + * The number of times the user decided to switch to the affected pull request + * 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 { + public declare launches: Dexie.Table + public declare dailyMeasures: Dexie.Table + + public constructor(name: string) { + super(name) + + this.version(1).stores({ + launches: '++', + }) + + this.version(DatabaseVersion).stores({ + dailyMeasures: '++id', + }) + } +} diff --git a/app/src/lib/stats/stats-store.ts b/app/src/lib/stats/stats-store.ts new file mode 100644 index 0000000000..fe03ecaa79 --- /dev/null +++ b/app/src/lib/stats/stats-store.ts @@ -0,0 +1,2126 @@ +import { StatsDatabase, ILaunchStats, IDailyMeasures } from './stats-database' +import { getDotComAPIEndpoint } from '../api' +import { getVersion } from '../../ui/lib/app-proxy' +import { hasShownWelcomeFlow } from '../welcome' +import { Account } from '../../models/account' +import { getOS } from '../get-os' +import { Repository } from '../../models/repository' +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 { assertNever } from '../fatal-error' +import { + getNumber, + setNumber, + getBoolean, + setBoolean, + getNumberArray, + setNumberArray, +} from '../local-storage' +import { PushOptions } from '../git' +import { getShowSideBySideDiff } from '../../ui/lib/diff-mode' +import { getAppArchitecture } from '../../ui/main-process-proxy' +import { Architecture } from '../get-architecture' +import { MultiCommitOperationKind } from '../../models/multi-commit-operation' +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' + +type PullRequestReviewStatFieldInfix = + | 'Approved' + | 'ChangesRequested' + | 'Commented' + +type PullRequestReviewStatFieldSuffix = + | 'NotificationCount' + | 'NotificationClicked' + | 'DialogSwitchToPullRequestCount' + +type PullRequestReviewStatField = + `pullRequestReview${PullRequestReviewStatFieldInfix}${PullRequestReviewStatFieldSuffix}` + +const StatsEndpoint = 'https://central.github.com/api/usage/desktop' + +/** The URL to the stats samples page. */ +export const SamplesURL = 'https://desktop.github.com/usage-data/' + +const LastDailyStatsReportKey = 'last-daily-stats-report' + +/** The localStorage key for whether the user has opted out. */ +const StatsOptOutKey = 'stats-opt-out' + +/** Have we successfully sent the stats opt-in? */ +const HasSentOptInPingKey = 'has-sent-stats-opt-in-ping' + +const WelcomeWizardInitiatedAtKey = 'welcome-wizard-initiated-at' +const WelcomeWizardCompletedAtKey = 'welcome-wizard-terminated-at' +const FirstRepositoryAddedAtKey = 'first-repository-added-at' +const FirstRepositoryClonedAtKey = 'first-repository-cloned-at' +const FirstRepositoryCreatedAtKey = 'first-repository-created-at' +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' + +const RepositoriesCommittedInWithoutWriteAccessKey = + 'repositories-committed-in-without-write-access' + +/** How often daily stats should be submitted (i.e., 24 hours). */ +const DailyStatsReportInterval = 1000 * 60 * 60 * 24 + +const DefaultDailyMeasures: IDailyMeasures = { + commits: 0, + partialCommits: 0, + openShellCount: 0, + coAuthoredCommits: 0, + commitsUndoneWithChanges: 0, + commitsUndoneWithoutChanges: 0, + branchComparisons: 0, + defaultBranchComparisons: 0, + mergesInitiatedFromComparison: 0, + updateFromDefaultBranchMenuCount: 0, + mergeIntoCurrentBranchMenuCount: 0, + prBranchCheckouts: 0, + repoWithIndicatorClicked: 0, + repoWithoutIndicatorClicked: 0, + dotcomPushCount: 0, + dotcomForcePushCount: 0, + enterprisePushCount: 0, + enterpriseForcePushCount: 0, + externalPushCount: 0, + externalForcePushCount: 0, + active: false, + mergeConflictFromPullCount: 0, + mergeConflictFromExplicitMergeCount: 0, + mergedWithLoadingHintCount: 0, + mergedWithCleanMergeHintCount: 0, + mergedWithConflictWarningHintCount: 0, + mergeSuccessAfterConflictsCount: 0, + mergeAbortedAfterConflictsCount: 0, + unattributedCommits: 0, + enterpriseCommits: 0, + dotcomCommits: 0, + mergeConflictsDialogDismissalCount: 0, + anyConflictsLeftOnMergeConflictsDialogDismissalCount: 0, + mergeConflictsDialogReopenedCount: 0, + guidedConflictedMergeCompletionCount: 0, + unguidedConflictedMergeCompletionCount: 0, + createPullRequestCount: 0, + createPullRequestFromPreviewCount: 0, + rebaseConflictsDialogDismissalCount: 0, + rebaseConflictsDialogReopenedCount: 0, + rebaseAbortedAfterConflictsCount: 0, + rebaseSuccessAfterConflictsCount: 0, + rebaseSuccessWithoutConflictsCount: 0, + pullWithRebaseCount: 0, + pullWithDefaultSettingCount: 0, + stashEntriesCreatedOutsideDesktop: 0, + errorWhenSwitchingBranchesWithUncommmittedChanges: 0, + rebaseCurrentBranchMenuCount: 0, + stashViewedAfterCheckoutCount: 0, + stashCreatedOnCurrentBranchCount: 0, + stashNotViewedAfterCheckoutCount: 0, + changesTakenToNewBranchCount: 0, + stashRestoreCount: 0, + stashDiscardCount: 0, + stashViewCount: 0, + noActionTakenOnStashCount: 0, + suggestedStepOpenInExternalEditor: 0, + suggestedStepOpenWorkingDirectory: 0, + suggestedStepViewOnGitHub: 0, + suggestedStepPublishRepository: 0, + suggestedStepPublishBranch: 0, + suggestedStepCreatePullRequest: 0, + suggestedStepViewStash: 0, + commitsToProtectedBranch: 0, + commitsToRepositoryWithBranchProtections: 0, + tutorialStarted: false, + tutorialRepoCreated: false, + tutorialEditorInstalled: false, + tutorialBranchCreated: false, + tutorialFileEdited: false, + tutorialCommitCreated: false, + tutorialBranchPushed: false, + tutorialPrCreated: false, + tutorialCompleted: false, + // this is `-1` because `0` signifies "tutorial created" + highestTutorialStepCompleted: -1, + commitsToRepositoryWithoutWriteAccess: 0, + forksCreated: 0, + issueCreationWebpageOpenedCount: 0, + tagsCreatedInDesktop: 0, + tagsCreated: 0, + tagsDeleted: 0, + diffModeChangeCount: 0, + diffOptionsViewedCount: 0, + repositoryViewChangeCount: 0, + unhandledRejectionCount: 0, + cherryPickSuccessfulCount: 0, + cherryPickViaDragAndDropCount: 0, + cherryPickViaContextMenuCount: 0, + dragStartedAndCanceledCount: 0, + cherryPickConflictsEncounteredCount: 0, + cherryPickSuccessfulWithConflictsCount: 0, + cherryPickMultipleCommitsCount: 0, + cherryPickUndoneCount: 0, + cherryPickBranchCreatedCount: 0, + amendCommitStartedCount: 0, + amendCommitSuccessfulWithFileChangesCount: 0, + amendCommitSuccessfulWithoutFileChangesCount: 0, + reorderSuccessfulCount: 0, + reorderStartedCount: 0, + reorderConflictsEncounteredCount: 0, + reorderSuccessfulWithConflictsCount: 0, + reorderMultipleCommitsCount: 0, + reorderUndoneCount: 0, + squashConflictsEncounteredCount: 0, + squashMultipleCommitsInvokedCount: 0, + squashSuccessfulCount: 0, + squashSuccessfulWithConflictsCount: 0, + squashViaContextMenuInvokedCount: 0, + squashViaDragAndDropInvokedCount: 0, + squashUndoneCount: 0, + squashMergeIntoCurrentBranchMenuCount: 0, + squashMergeSuccessfulWithConflictsCount: 0, + squashMergeSuccessfulCount: 0, + squashMergeInvokedCount: 0, + resetToCommitCount: 0, + opensCheckRunsPopover: 0, + viewsCheckOnline: 0, + 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, + pullRequestReviewCommentedNotificationCount: 0, + pullRequestReviewCommentedNotificationClicked: 0, + pullRequestReviewCommentedDialogSwitchToPullRequestCount: 0, + 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, +} + +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. + * + * 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. + * + * 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. + * + * 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. + * + * 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. + */ + 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. + * + * 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. + */ + 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. + * + * 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 { + /** The app version. */ + readonly version: string + + /** The OS version. */ + readonly osVersion: string + + /** The platform. */ + readonly platform: string + + /** The architecture. */ + readonly architecture: Architecture + + /** The number of total repositories. */ + readonly repositoryCount: number + + /** The number of GitHub repositories. */ + readonly gitHubRepositoryCount: number + + /** The install ID. */ + readonly guid: string + + /** Is the user logged in with a GitHub.com account? */ + readonly dotComAccount: boolean + + /** Is the user logged in with an Enterprise account? */ + readonly enterpriseAccount: boolean + + /** + * The name of the currently selected theme/application + * appearance as set at time of stats submission. + */ + readonly theme: string + + /** The selected terminal emulator at the time of stats submission */ + readonly selectedTerminalEmulator: string + + /** The selected text editor at the time of stats submission */ + readonly selectedTextEditor: string + + readonly eventType: 'usage' + + /** + * _[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 + */ + readonly repositoriesCommittedInWithoutWriteAccess: number + + /** + * whether not to the user has chosent to view diffs in split, or unified (the + * default) diff view mode + */ + readonly diffMode: 'split' | 'unified' + + /** + * Whether the app was launched from the Applications folder or not. This is + * only relevant on macOS, null will be sent otherwise. + */ + readonly launchedFromApplicationsFolder: boolean | null + + /** Whether or not the user has enabled high-signal notifications */ + readonly notificationsEnabled: boolean +} + +type DailyStats = ICalculatedStats & + ILaunchStats & + IDailyMeasures & + IOnboardingStats + +/** + * Testable interface for StatsStore + * + * Note: for the moment this only contains methods that are needed for testing, + * so fight the urge to implement every public method from StatsStore here + * + */ +export interface IStatsStore { + recordMergeAbortedAfterConflicts: () => void + recordMergeSuccessAfterConflicts: () => void + recordRebaseAbortedAfterConflicts: () => void + recordRebaseSuccessAfterConflicts: () => void +} + +/** 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 + + const storedValue = getHasOptedOutOfStats() + + this.optOut = storedValue || false + + // If the user has set an opt out value but we haven't sent the ping yet, + // give it a shot now. + if (!getBoolean(HasSentOptInPingKey, false)) { + this.sendOptInStatusPing(this.optOut, storedValue) + } + + this.enableUiActivityMonitoring() + + window.addEventListener('unhandledrejection', async () => { + try { + this.recordUnhandledRejection() + } catch (err) { + log.error(`Failed recording unhandled rejection`, err) + } + }) + } + + /** Should the app report its daily stats? */ + private shouldReportDailyStats(): boolean { + const lastDate = getNumber(LastDailyStatsReportKey, 0) + const now = Date.now() + return now - lastDate > DailyStatsReportInterval + } + + /** Report any stats which are eligible for reporting. */ + public async reportStats( + accounts: ReadonlyArray, + repositories: ReadonlyArray + ) { + if (this.optOut) { + return + } + + // Never report stats while in dev or test. They could be pretty crazy. + if (__DEV__ || process.env.TEST_ENV) { + return + } + + // don't report until the user has had a chance to view and opt-in for + // sharing their stats with us + if (!hasShownWelcomeFlow()) { + return + } + + if (!this.shouldReportDailyStats()) { + return + } + + const now = Date.now() + const payload = await this.getDailyStats(accounts, repositories) + + try { + const response = await this.post(payload) + if (!response.ok) { + throw new Error( + `Unexpected status: ${response.statusText} (${response.status})` + ) + } + + log.info('Stats reported.') + + await this.clearDailyStats() + setNumber(LastDailyStatsReportKey, now) + } catch (e) { + log.error('Error reporting stats:', e) + } + } + + /** Record the given launch stats. */ + public async recordLaunchStats(stats: ILaunchStats) { + await this.db.launches.add(stats) + } + + /** + * 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 + localStorage.removeItem(RepositoriesCommittedInWithoutWriteAccessKey) + + this.enableUiActivityMonitoring() + } + + private enableUiActivityMonitoring() { + if (this.uiActivityMonitorSubscription !== null) { + return + } + + this.uiActivityMonitorSubscription = this.uiActivityMonitor.onActivity( + this.onUiActivity + ) + } + + private disableUiActivityMonitoring() { + if (this.uiActivityMonitorSubscription === null) { + return + } + + this.uiActivityMonitorSubscription.dispose() + this.uiActivityMonitorSubscription = null + } + + /** Get the daily stats. */ + private async getDailyStats( + accounts: ReadonlyArray, + repositories: ReadonlyArray + ): Promise { + const launchStats = await this.getAverageLaunchStats() + const dailyMeasures = await this.getDailyMeasures() + 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 repositoriesCommittedInWithoutWriteAccess = getNumberArray( + RepositoriesCommittedInWithoutWriteAccessKey + ).length + const diffMode = getShowSideBySideDiff() ? 'split' : 'unified' + + // isInApplicationsFolder is undefined when not running on Darwin + const launchedFromApplicationsFolder = __DARWIN__ + ? await isInApplicationFolder() + : null + + return { + eventType: 'usage', + version: getVersion(), + osVersion: getOS(), + platform: process.platform, + architecture: await getAppArchitecture(), + theme: getPersistedThemeName(), + selectedTerminalEmulator, + selectedTextEditor, + notificationsEnabled: getNotificationsEnabled(), + ...launchStats, + ...dailyMeasures, + ...userType, + ...onboardingStats, + guid: await getRendererGUID(), + ...repositoryCounts, + repositoriesCommittedInWithoutWriteAccess, + diffMode, + launchedFromApplicationsFolder, + } + } + + private getOnboardingStats(): IOnboardingStats { + const wizardInitiatedAt = getLocalStorageTimestamp( + WelcomeWizardInitiatedAtKey + ) + + // 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. + if (wizardInitiatedAt === null) { + return {} + } + + const timeToWelcomeWizardTerminated = timeTo(WelcomeWizardCompletedAtKey) + const timeToFirstAddedRepository = timeTo(FirstRepositoryAddedAtKey) + const timeToFirstClonedRepository = timeTo(FirstRepositoryClonedAtKey) + const timeToFirstCreatedRepository = timeTo(FirstRepositoryCreatedAtKey) + const timeToFirstCommit = timeTo(FirstCommitCreatedAtKey) + const timeToFirstGitHubPush = timeTo(FirstPushToGitHubAtKey) + const timeToFirstNonDefaultBranchCheckout = timeTo( + FirstNonDefaultBranchCheckoutAtKey + ) + + const welcomeWizardSignInMethod = getWelcomeWizardSignInMethod() + + return { + timeToWelcomeWizardTerminated, + timeToFirstAddedRepository, + timeToFirstClonedRepository, + timeToFirstCreatedRepository, + timeToFirstCommit, + timeToFirstGitHubPush, + timeToFirstNonDefaultBranchCheckout, + welcomeWizardSignInMethod, + } + } + + private categorizedRepositoryCounts(repositories: ReadonlyArray) { + return { + repositoryCount: repositories.length, + gitHubRepositoryCount: repositories.filter(r => r.gitHubRepository) + .length, + } + } + + /** Determines if an account is a dotCom and/or enterprise user */ + private determineUserType(accounts: ReadonlyArray) { + const dotComAccount = !!accounts.find( + a => a.endpoint === getDotComAPIEndpoint() + ) + const enterpriseAccount = !!accounts.find( + a => a.endpoint !== getDotComAPIEndpoint() + ) + + return { + dotComAccount, + enterpriseAccount, + } + } + + /** Calculate the average launch stats. */ + private async getAverageLaunchStats(): Promise { + const launches: ReadonlyArray | undefined = + await this.db.launches.toArray() + if (!launches || !launches.length) { + return { + mainReadyTime: -1, + loadTime: -1, + rendererReadyTime: -1, + } + } + + const start: ILaunchStats = { + mainReadyTime: 0, + loadTime: 0, + rendererReadyTime: 0, + } + + const totals = launches.reduce((running, current) => { + return { + mainReadyTime: running.mainReadyTime + current.mainReadyTime, + loadTime: running.loadTime + current.loadTime, + rendererReadyTime: + running.rendererReadyTime + current.rendererReadyTime, + } + }, start) + + return { + mainReadyTime: totals.mainReadyTime / launches.length, + loadTime: totals.loadTime / launches.length, + rendererReadyTime: totals.rendererReadyTime / launches.length, + } + } + + /** Get the daily measures. */ + private async getDailyMeasures(): Promise { + const measures: IDailyMeasures | undefined = await this.db.dailyMeasures + .limit(1) + .first() + return { + ...DefaultDailyMeasures, + ...measures, + // We could spread the database ID in, but we really don't want it. + id: undefined, + } + } + + private async updateDailyMeasures( + fn: (measures: IDailyMeasures) => Pick + ): Promise { + const defaultMeasures = DefaultDailyMeasures + await this.db.transaction('rw', this.db.dailyMeasures, async () => { + const measures = await this.db.dailyMeasures.limit(1).first() + const measuresWithDefaults = { + ...defaultMeasures, + ...measures, + } + const newMeasures = merge(measuresWithDefaults, fn(measuresWithDefaults)) + + return this.db.dailyMeasures.put(newMeasures) + }) + } + + /** Record that a commit was accomplished. */ + public async recordCommit(): Promise { + await this.updateDailyMeasures(m => ({ + commits: m.commits + 1, + })) + + 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, + })) + } + + /** + * 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, + })) + } + + /** 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 the user made a commit to repository which has branch protections enabled */ + public recordCommitToRepositoryWithBranchProtections(): Promise { + return this.updateDailyMeasures(m => ({ + commitsToRepositoryWithBranchProtections: + m.commitsToRepositoryWithBranchProtections + 1, + })) + } + + /** Set whether the user has opted out of stats reporting. */ + public async setOptOut( + optOut: boolean, + userViewedPrompt: boolean + ): Promise { + const changed = this.optOut !== optOut + + this.optOut = optOut + + const previousValue = getBoolean(StatsOptOutKey) + + setBoolean(StatsOptOutKey, optOut) + + if (changed || userViewedPrompt) { + await this.sendOptInStatusPing(optOut, previousValue) + } + } + + /** Has the user opted out of stats reporting? */ + public getOptOut(): boolean { + return this.optOut + } + + public async recordPush( + githubAccount: Account | null, + options?: PushOptions + ) { + if (githubAccount === null) { + await this.recordPushToGenericRemote(options) + } else if (githubAccount.endpoint === getDotComAPIEndpoint()) { + await this.recordPushToGitHub(options) + } else { + await this.recordPushToGitHubEnterprise(options) + } + } + + /** 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, + })) + + createLocalStorageTimestamp(FirstPushToGitHubAtKey) + } + + /** Record that the user pushed to a GitHub Enterprise instance */ + 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, + })) + + // 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, + })) + } + + await this.updateDailyMeasures(m => ({ + externalPushCount: m.externalPushCount + 1, + })) + } + + /** 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, + })) + } + + /** 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, + })) + } + + /** 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, + })) + } + + /** + * Increments the `mergeConflictsDialogDismissalCount` metric + */ + public recordMergeConflictsDialogDismissal(): Promise { + return this.updateDailyMeasures(m => ({ + mergeConflictsDialogDismissalCount: + m.mergeConflictsDialogDismissalCount + 1, + })) + } + + /** + * Increments the `anyConflictsLeftOnMergeConflictsDialogDismissalCount` metric + */ + public recordAnyConflictsLeftOnMergeConflictsDialogDismissal(): Promise { + return this.updateDailyMeasures(m => ({ + anyConflictsLeftOnMergeConflictsDialogDismissalCount: + m.anyConflictsLeftOnMergeConflictsDialogDismissalCount + 1, + })) + } + + /** + * Increments the `mergeConflictsDialogReopenedCount` metric + */ + public recordMergeConflictsDialogReopened(): Promise { + return this.updateDailyMeasures(m => ({ + mergeConflictsDialogReopenedCount: + m.mergeConflictsDialogReopenedCount + 1, + })) + } + + /** + * Increments the `guidedConflictedMergeCompletionCount` metric + */ + public recordGuidedConflictedMergeCompletion(): Promise { + return this.updateDailyMeasures(m => ({ + guidedConflictedMergeCompletionCount: + m.guidedConflictedMergeCompletionCount + 1, + })) + } + + /** + * Increments the `unguidedConflictedMergeCompletionCount` metric + */ + public recordUnguidedConflictedMergeCompletion(): Promise { + return this.updateDailyMeasures(m => ({ + unguidedConflictedMergeCompletionCount: + m.unguidedConflictedMergeCompletionCount + 1, + })) + } + + /** + * Increments the `createPullRequestCount` metric + */ + public recordCreatePullRequest(): Promise { + return this.updateDailyMeasures(m => ({ + createPullRequestCount: m.createPullRequestCount + 1, + })) + } + + /** + * Increments the `createPullRequestFromPreviewCount` metric + */ + public recordCreatePullRequestFromPreview(): Promise { + return this.updateDailyMeasures(m => ({ + createPullRequestFromPreviewCount: + m.createPullRequestFromPreviewCount + 1, + })) + } + + /** + * Increments the `rebaseConflictsDialogDismissalCount` metric + */ + public recordRebaseConflictsDialogDismissal(): Promise { + return this.updateDailyMeasures(m => ({ + rebaseConflictsDialogDismissalCount: + m.rebaseConflictsDialogDismissalCount + 1, + })) + } + + /** + * Increments the `rebaseConflictsDialogReopenedCount` metric + */ + public recordRebaseConflictsDialogReopened(): Promise { + return this.updateDailyMeasures(m => ({ + rebaseConflictsDialogReopenedCount: + m.rebaseConflictsDialogReopenedCount + 1, + })) + } + + /** + * Increments the `rebaseAbortedAfterConflictsCount` metric + */ + public recordRebaseAbortedAfterConflicts(): Promise { + return this.updateDailyMeasures(m => ({ + rebaseAbortedAfterConflictsCount: m.rebaseAbortedAfterConflictsCount + 1, + })) + } + /** + * Increments the `pullWithRebaseCount` metric + */ + public recordPullWithRebaseEnabled() { + return this.updateDailyMeasures(m => ({ + pullWithRebaseCount: m.pullWithRebaseCount + 1, + })) + } + + /** + * Increments the `rebaseSuccessWithoutConflictsCount` metric + */ + public recordRebaseSuccessWithoutConflicts(): Promise { + return this.updateDailyMeasures(m => ({ + rebaseSuccessWithoutConflictsCount: + m.rebaseSuccessWithoutConflictsCount + 1, + })) + } + + /** + * Increments the `rebaseSuccessAfterConflictsCount` metric + */ + public recordRebaseSuccessAfterConflicts(): Promise { + return this.updateDailyMeasures(m => ({ + rebaseSuccessAfterConflictsCount: m.rebaseSuccessAfterConflictsCount + 1, + })) + } + + /** + * Increments the `pullWithDefaultSettingCount` metric + */ + public recordPullWithDefaultSetting() { + return this.updateDailyMeasures(m => ({ + pullWithDefaultSettingCount: m.pullWithDefaultSettingCount + 1, + })) + } + + public recordWelcomeWizardInitiated() { + setNumber(WelcomeWizardInitiatedAtKey, Date.now()) + localStorage.removeItem(WelcomeWizardCompletedAtKey) + } + + 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, + })) + } + + public async recordOperationConflictsEncounteredCount( + kind: MultiCommitOperationKind + ): Promise { + switch (kind) { + case MultiCommitOperationKind.Squash: + return this.recordSquashConflictsEncountered() + case MultiCommitOperationKind.Reorder: + return this.recordReorderConflictsEncountered() + case MultiCommitOperationKind.Rebase: + // ignored because rebase records different stats + return + case MultiCommitOperationKind.CherryPick: + case MultiCommitOperationKind.Merge: + log.error( + `[recordOperationConflictsEncounteredCount] - Operation not supported: ${kind}` + ) + return + default: + return assertNever(kind, `Unknown operation kind of ${kind}.`) + } + } + + public async recordOperationSuccessful( + kind: MultiCommitOperationKind + ): Promise { + switch (kind) { + case MultiCommitOperationKind.Squash: + return this.recordSquashSuccessful() + case MultiCommitOperationKind.Reorder: + return this.recordReorderSuccessful() + case MultiCommitOperationKind.CherryPick: + return this.recordCherryPickSuccessful() + case MultiCommitOperationKind.Rebase: + // ignored because rebase records different stats + return + case MultiCommitOperationKind.Merge: + log.error( + `[recordOperationSuccessful] - Operation not supported: ${kind}` + ) + return + default: + return assertNever(kind, `Unknown operation kind of ${kind}.`) + } + } + + public async recordOperationSuccessfulWithConflicts( + kind: MultiCommitOperationKind + ): Promise { + switch (kind) { + case MultiCommitOperationKind.Squash: + return this.recordSquashSuccessfulWithConflicts() + case MultiCommitOperationKind.Reorder: + return this.recordReorderSuccessfulWithConflicts() + case MultiCommitOperationKind.Rebase: + return this.recordRebaseSuccessAfterConflicts() + case MultiCommitOperationKind.CherryPick: + case MultiCommitOperationKind.Merge: + log.error( + `[recordOperationSuccessfulWithConflicts] - Operation not supported: ${kind}` + ) + return + default: + return assertNever(kind, `Unknown operation kind of ${kind}.`) + } + } + + public async recordOperationUndone( + kind: MultiCommitOperationKind + ): Promise { + switch (kind) { + case MultiCommitOperationKind.Squash: + return this.recordSquashUndone() + case MultiCommitOperationKind.Reorder: + return this.recordReorderUndone() + case MultiCommitOperationKind.CherryPick: + return this.recordCherryPickUndone() + case MultiCommitOperationKind.Rebase: + case MultiCommitOperationKind.Merge: + log.error(`[recordOperationUndone] - Operation not supported: ${kind}`) + return + default: + return assertNever(kind, `Unknown operation kind of ${kind}.`) + } + } + + 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 recordChecksFailedNotificationFromRecentRepo(): Promise { + return this.updateDailyMeasures(m => ({ + checksFailedNotificationFromRecentRepoCount: + m.checksFailedNotificationFromRecentRepoCount + 1, + })) + } + + public recordChecksFailedNotificationFromNonRecentRepo(): Promise { + return this.updateDailyMeasures(m => ({ + checksFailedNotificationFromNonRecentRepoCount: + m.checksFailedNotificationFromNonRecentRepoCount + 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, + })) + } + + public recordMultiCommitDiffFromHistoryCount(): Promise { + return this.updateDailyMeasures(m => ({ + multiCommitDiffFromHistoryCount: m.multiCommitDiffFromHistoryCount + 1, + })) + } + + public recordMultiCommitDiffFromCompareCount(): Promise { + return this.updateDailyMeasures(m => ({ + multiCommitDiffFromCompareCount: m.multiCommitDiffFromCompareCount + 1, + })) + } + + public recordMultiCommitDiffWithUnreachableCommitWarningCount(): Promise { + return this.updateDailyMeasures(m => ({ + multiCommitDiffWithUnreachableCommitWarningCount: + m.multiCommitDiffWithUnreachableCommitWarningCount + 1, + })) + } + + public recordMultiCommitDiffUnreachableCommitsDialogOpenedCount(): Promise { + return this.updateDailyMeasures(m => ({ + multiCommitDiffUnreachableCommitsDialogOpenedCount: + m.multiCommitDiffUnreachableCommitsDialogOpenedCount + 1, + })) + } + + // Generates the stat field name for the given PR review type and suffix. + private getStatFieldForRequestReviewState( + reviewType: ValidNotificationPullRequestReviewState, + suffix: PullRequestReviewStatFieldSuffix + ): PullRequestReviewStatField { + const infixMap: Record< + ValidNotificationPullRequestReviewState, + PullRequestReviewStatFieldInfix + > = { + CHANGES_REQUESTED: 'ChangesRequested', + APPROVED: 'Approved', + COMMENTED: 'Commented', + } + + return `pullRequestReview${infixMap[reviewType]}${suffix}` + } + + public recordPullRequestReviewNotificationFromRecentRepo(): Promise { + return this.updateDailyMeasures(m => ({ + pullRequestReviewNotificationFromRecentRepoCount: + m.pullRequestReviewNotificationFromRecentRepoCount + 1, + })) + } + + public recordPullRequestReviewNotificationFromNonRecentRepo(): Promise { + return this.updateDailyMeasures(m => ({ + pullRequestReviewNotificationFromNonRecentRepoCount: + m.pullRequestReviewNotificationFromNonRecentRepoCount + 1, + })) + } + + // 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) + ) + } + + public recordPullRequestReviewNotificationShown( + reviewType: ValidNotificationPullRequestReviewState + ): Promise { + return this.recordPullRequestReviewStat(reviewType, 'NotificationCount') + } + + public recordPullRequestReviewNotificationClicked( + reviewType: ValidNotificationPullRequestReviewState + ): Promise { + return this.recordPullRequestReviewStat(reviewType, 'NotificationClicked') + } + + public recordPullRequestReviewDialogSwitchToPullRequest( + reviewType: ValidNotificationPullRequestReviewState + ): Promise { + return this.recordPullRequestReviewStat( + reviewType, + 'DialogSwitchToPullRequestCount' + ) + } + + public recordPullRequestCommentNotificationShown() { + return this.updateDailyMeasures(m => ({ + pullRequestCommentNotificationCount: + m.pullRequestCommentNotificationCount + 1, + })) + } + public recordPullRequestCommentNotificationClicked() { + return this.updateDailyMeasures(m => ({ + pullRequestCommentNotificationClicked: + m.pullRequestCommentNotificationClicked + 1, + })) + } + public recordPullRequestCommentNotificationFromNonRecentRepo() { + return this.updateDailyMeasures(m => ({ + pullRequestCommentNotificationFromNonRecentRepoCount: + m.pullRequestCommentNotificationFromNonRecentRepoCount + 1, + })) + } + public recordPullRequestCommentNotificationFromRecentRepo() { + return this.updateDailyMeasures(m => ({ + pullRequestCommentNotificationFromRecentRepoCount: + m.pullRequestCommentNotificationFromRecentRepoCount + 1, + })) + } + + public recordPullRequestCommentDialogSwitchToPullRequest() { + return this.updateDailyMeasures(m => ({ + pullRequestCommentDialogSwitchToPullRequestCount: + m.pullRequestCommentDialogSwitchToPullRequestCount + 1, + })) + } + + public recordSubmoduleDiffViewedFromChangesList(): Promise { + return this.updateDailyMeasures(m => ({ + submoduleDiffViewedFromChangesListCount: + m.submoduleDiffViewedFromChangesListCount + 1, + })) + } + + public recordSubmoduleDiffViewedFromHistory(): Promise { + return this.updateDailyMeasures(m => ({ + submoduleDiffViewedFromHistoryCount: + m.submoduleDiffViewedFromHistoryCount + 1, + })) + } + + public recordOpenSubmoduleFromDiffCount(): Promise { + return this.updateDailyMeasures(m => ({ + openSubmoduleFromDiffCount: m.openSubmoduleFromDiffCount + 1, + })) + } + + /** 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) + } + + /** + * 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. + */ + 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. + const optIn = !optOut + const previousOptInValue = + previousValue === undefined ? null : !previousValue + const direction = optIn ? 'in' : 'out' + + try { + const response = await this.post({ + eventType: 'ping', + optIn, + previousOptInValue, + }) + if (!response.ok) { + throw new Error( + `Unexpected status: ${response.statusText} (${response.status})` + ) + } + + setBoolean(HasSentOptInPingKey, true) + + log.info(`Opt ${direction} reported.`) + } catch (e) { + log.error(`Error reporting opt ${direction}:`, e) + } + } + + /** + * Increments the `previewedPullRequestCount` metric + */ + public recordPreviewedPullRequest(): Promise { + return this.updateDailyMeasures(m => ({ + previewedPullRequestCount: m.previewedPullRequestCount + 1, + })) + } +} + +/** + * Store the current date (in unix time) in localStorage. + * + * If the provided key already exists it will not be + * overwritten. + */ +function createLocalStorageTimestamp(key: string) { + if (localStorage.getItem(key) === null) { + setNumber(key, Date.now()) + } +} + +/** + * 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. + */ +function getLocalStorageTimestamp(key: string): number | null { + const timestamp = getNumber(key) + return timestamp === undefined ? null : timestamp +} + +/** + * 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. + */ +function timeTo(key: string): number | undefined { + const startTime = getLocalStorageTimestamp(WelcomeWizardInitiatedAtKey) + + if (startTime === null) { + return undefined + } + + const endTime = getLocalStorageTimestamp(key) + return endTime === null || endTime <= startTime + ? -1 + : 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. + */ +export function getHasOptedOutOfStats() { + return getBoolean(StatsOptOutKey) +} diff --git a/app/src/lib/status-parser.ts b/app/src/lib/status-parser.ts new file mode 100644 index 0000000000..f56b8c5abc --- /dev/null +++ b/app/src/lib/status-parser.ts @@ -0,0 +1,416 @@ +import { + FileEntry, + GitStatusEntry, + SubmoduleStatus, + UnmergedEntrySummary, +} from '../models/status' + +type StatusItem = IStatusHeader | IStatusEntry + +export interface IStatusHeader { + readonly kind: 'header' + readonly value: string +} + +/** A representation of a parsed status entry from git status */ +export interface IStatusEntry { + readonly kind: 'entry' + + /** The path to the file relative to the repository root */ + readonly path: string + + /** 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 +} + +export function isStatusHeader( + statusItem: StatusItem +): statusItem is IStatusHeader { + return statusItem.kind === 'header' +} + +export function isStatusEntry( + statusItem: StatusItem +): statusItem is IStatusEntry { + return statusItem.kind === 'entry' +} + +const ChangedEntryType = '1' +const RenamedOrCopiedEntryType = '2' +const UnmergedEntryType = 'u' +const UntrackedEntryType = '?' +const IgnoredEntryType = '!' + +/** Parses output from git status --porcelain -z into file status entries */ +export function parsePorcelainStatus( + output: string +): ReadonlyArray { + const entries = new Array() + + // See https://git-scm.com/docs/git-status + // + // In the short-format, the status of each path is shown as + // XY PATH1 -> PATH2 + // + // There is also an alternate -z format recommended for machine parsing. In that + // format, the status field is the same, but some other things change. First, + // the -> is omitted from rename entries and the field order is reversed (e.g + // from -> to becomes to from). Second, a NUL (ASCII 0) follows each filename, + // replacing space as a field separator and the terminating newline (but a space + // still separates the status field from the first filename). Third, filenames + // containing special characters are not specially formatted; no quoting or + // backslash-escaping is performed. + + const tokens = output.split('\0') + + for (let i = 0; i < tokens.length; i++) { + const field = tokens[i] + if (field.startsWith('# ') && field.length > 2) { + entries.push({ kind: 'header', value: field.substring(2) }) + continue + } + + const entryKind = field.substring(0, 1) + + if (entryKind === ChangedEntryType) { + entries.push(parseChangedEntry(field)) + } else if (entryKind === RenamedOrCopiedEntryType) { + entries.push(parsedRenamedOrCopiedEntry(field, tokens[++i])) + } else if (entryKind === UnmergedEntryType) { + entries.push(parseUnmergedEntry(field)) + } else if (entryKind === UntrackedEntryType) { + entries.push(parseUntrackedEntry(field)) + } else if (entryKind === IgnoredEntryType) { + // Ignored, we don't care about these for now + } + } + + return entries +} + +// 1 +const changedEntryRe = + /^1 ([MADRCUTX?!.]{2}) (N\.\.\.|S[C.][M.][U.]) (\d+) (\d+) (\d+) ([a-f0-9]+) ([a-f0-9]+) ([\s\S]*?)$/ + +function parseChangedEntry(field: string): IStatusEntry { + const match = changedEntryRe.exec(field) + + if (!match) { + log.debug(`parseChangedEntry parse error: ${field}`) + throw new Error(`Failed to parse status line for changed entry`) + } + + return { + kind: 'entry', + statusCode: match[1], + submoduleStatusCode: match[2], + path: match[8], + } +} + +// 2 +const renamedOrCopiedEntryRe = + /^2 ([MADRCUTX?!.]{2}) (N\.\.\.|S[C.][M.][U.]) (\d+) (\d+) (\d+) ([a-f0-9]+) ([a-f0-9]+) ([RC]\d+) ([\s\S]*?)$/ + +function parsedRenamedOrCopiedEntry( + field: string, + oldPath: string | undefined +): IStatusEntry { + const match = renamedOrCopiedEntryRe.exec(field) + + if (!match) { + log.debug(`parsedRenamedOrCopiedEntry parse error: ${field}`) + throw new Error(`Failed to parse status line for renamed or copied entry`) + } + + if (!oldPath) { + throw new Error( + 'Failed to parse renamed or copied entry, could not parse old path' + ) + } + + return { + kind: 'entry', + statusCode: match[1], + submoduleStatusCode: match[2], + oldPath, + path: match[9], + } +} + +// u

+const unmergedEntryRe = + /^u ([DAU]{2}) (N\.\.\.|S[C.][M.][U.]) (\d+) (\d+) (\d+) (\d+) ([a-f0-9]+) ([a-f0-9]+) ([a-f0-9]+) ([\s\S]*?)$/ + +function parseUnmergedEntry(field: string): IStatusEntry { + const match = unmergedEntryRe.exec(field) + + if (!match) { + log.debug(`parseUnmergedEntry parse error: ${field}`) + throw new Error(`Failed to parse status line for unmerged entry`) + } + + return { + kind: 'entry', + statusCode: match[1], + submoduleStatusCode: match[2], + path: match[10], + } +} + +function parseUntrackedEntry(field: string): IStatusEntry { + const path = field.substring(2) + return { + kind: 'entry', + // 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( + statusCode: string, + submoduleStatusCode: string +): FileEntry { + const submoduleStatus = mapSubmoduleStatus(submoduleStatusCode) + + if (statusCode === '??') { + return { kind: 'untracked', submoduleStatus } + } + + if (statusCode === '.M') { + return { + kind: 'ordinary', + type: 'modified', + index: GitStatusEntry.Unchanged, + workingTree: GitStatusEntry.Modified, + submoduleStatus, + } + } + + if (statusCode === 'M.') { + return { + kind: 'ordinary', + type: 'modified', + index: GitStatusEntry.Modified, + workingTree: GitStatusEntry.Unchanged, + submoduleStatus, + } + } + + if (statusCode === '.A') { + return { + kind: 'ordinary', + type: 'added', + index: GitStatusEntry.Unchanged, + workingTree: GitStatusEntry.Added, + submoduleStatus, + } + } + + if (statusCode === 'A.') { + return { + kind: 'ordinary', + type: 'added', + index: GitStatusEntry.Added, + workingTree: GitStatusEntry.Unchanged, + submoduleStatus, + } + } + + if (statusCode === '.D') { + return { + kind: 'ordinary', + type: 'deleted', + index: GitStatusEntry.Unchanged, + workingTree: GitStatusEntry.Deleted, + submoduleStatus, + } + } + + if (statusCode === 'D.') { + return { + kind: 'ordinary', + type: 'deleted', + index: GitStatusEntry.Deleted, + workingTree: GitStatusEntry.Unchanged, + submoduleStatus, + } + } + + if (statusCode === 'R.') { + return { + kind: 'renamed', + index: GitStatusEntry.Renamed, + workingTree: GitStatusEntry.Unchanged, + submoduleStatus, + } + } + + if (statusCode === '.R') { + return { + kind: 'renamed', + index: GitStatusEntry.Unchanged, + workingTree: GitStatusEntry.Renamed, + submoduleStatus, + } + } + + if (statusCode === 'C.') { + return { + kind: 'copied', + index: GitStatusEntry.Copied, + workingTree: GitStatusEntry.Unchanged, + submoduleStatus, + } + } + + if (statusCode === '.C') { + return { + kind: 'copied', + index: GitStatusEntry.Unchanged, + workingTree: GitStatusEntry.Copied, + submoduleStatus, + } + } + + if (statusCode === 'AD') { + return { + kind: 'ordinary', + type: 'added', + index: GitStatusEntry.Added, + workingTree: GitStatusEntry.Deleted, + submoduleStatus, + } + } + + if (statusCode === 'AM') { + return { + kind: 'ordinary', + type: 'added', + index: GitStatusEntry.Added, + workingTree: GitStatusEntry.Modified, + submoduleStatus, + } + } + + if (statusCode === 'RM') { + return { + kind: 'renamed', + index: GitStatusEntry.Renamed, + workingTree: GitStatusEntry.Modified, + submoduleStatus, + } + } + + if (statusCode === 'RD') { + return { + kind: 'renamed', + index: GitStatusEntry.Renamed, + workingTree: GitStatusEntry.Deleted, + submoduleStatus, + } + } + + if (statusCode === 'DD') { + return { + kind: 'conflicted', + action: UnmergedEntrySummary.BothDeleted, + us: GitStatusEntry.Deleted, + them: GitStatusEntry.Deleted, + submoduleStatus, + } + } + + if (statusCode === 'AU') { + return { + kind: 'conflicted', + action: UnmergedEntrySummary.AddedByUs, + us: GitStatusEntry.Added, + them: GitStatusEntry.UpdatedButUnmerged, + submoduleStatus, + } + } + + if (statusCode === 'UD') { + return { + kind: 'conflicted', + action: UnmergedEntrySummary.DeletedByThem, + us: GitStatusEntry.UpdatedButUnmerged, + them: GitStatusEntry.Deleted, + submoduleStatus, + } + } + + if (statusCode === 'UA') { + return { + kind: 'conflicted', + action: UnmergedEntrySummary.AddedByThem, + us: GitStatusEntry.UpdatedButUnmerged, + them: GitStatusEntry.Added, + submoduleStatus, + } + } + + if (statusCode === 'DU') { + return { + kind: 'conflicted', + action: UnmergedEntrySummary.DeletedByUs, + us: GitStatusEntry.Deleted, + them: GitStatusEntry.UpdatedButUnmerged, + submoduleStatus, + } + } + + if (statusCode === 'AA') { + return { + kind: 'conflicted', + action: UnmergedEntrySummary.BothAdded, + us: GitStatusEntry.Added, + them: GitStatusEntry.Added, + submoduleStatus, + } + } + + if (statusCode === 'UU') { + return { + kind: 'conflicted', + action: UnmergedEntrySummary.BothModified, + us: GitStatusEntry.UpdatedButUnmerged, + them: GitStatusEntry.UpdatedButUnmerged, + submoduleStatus, + } + } + + // as a fallback, we assume the file is modified in some way + return { + kind: 'ordinary', + type: 'modified', + submoduleStatus, + } +} diff --git a/app/src/lib/status.ts b/app/src/lib/status.ts new file mode 100644 index 0000000000..c10a34a91d --- /dev/null +++ b/app/src/lib/status.ts @@ -0,0 +1,173 @@ +import { + AppFileStatusKind, + AppFileStatus, + ConflictedFileStatus, + WorkingDirectoryStatus, + isConflictWithMarkers, + GitStatusEntry, + isConflictedFileStatus, + WorkingDirectoryFileChange, +} from '../models/status' +import { assertNever } from './fatal-error' +import { ManualConflictResolution } from '../models/manual-conflict-resolution' + +/** + * Convert a given `AppFileStatusKind` value to a human-readable string to be + * presented to users which describes the state of a file. + * + * Typically this will be the same value as that of the enum key. + * + * Used in file lists. + */ +export function mapStatus(status: AppFileStatus): string { + switch (status.kind) { + case AppFileStatusKind.New: + case AppFileStatusKind.Untracked: + return 'New' + case AppFileStatusKind.Modified: + return 'Modified' + case AppFileStatusKind.Deleted: + return 'Deleted' + case AppFileStatusKind.Renamed: + return 'Renamed' + case AppFileStatusKind.Conflicted: + if (isConflictWithMarkers(status)) { + const conflictsCount = status.conflictMarkerCount + return conflictsCount > 0 ? 'Conflicted' : 'Resolved' + } + + return 'Conflicted' + case AppFileStatusKind.Copied: + return 'Copied' + default: + return assertNever(status, `Unknown file status ${status}`) + } +} + +/** Typechecker helper to identify conflicted files */ +export function isConflictedFile( + file: AppFileStatus +): file is ConflictedFileStatus { + return file.kind === AppFileStatusKind.Conflicted +} + +/** + * Returns a value indicating whether any of the files in the + * working directory is in a conflicted state. See `isConflictedFile` + * for the definition of a conflicted file. + */ +export function hasConflictedFiles( + workingDirectoryStatus: WorkingDirectoryStatus +): boolean { + return workingDirectoryStatus.files.some(f => isConflictedFile(f.status)) +} + +/** + * Determine if we have any conflict markers or if its been resolved manually + */ +export function hasUnresolvedConflicts( + status: ConflictedFileStatus, + manualResolution?: ManualConflictResolution +) { + // if there's a manual resolution, the file does not have unresolved conflicts + if (manualResolution !== undefined) { + return false + } + + if (isConflictWithMarkers(status)) { + // text file may have conflict markers present + return status.conflictMarkerCount > 0 + } + + // binary file doesn't contain markers + return true +} + +/** the possible git status entries for a manually conflicted file status + * only intended for use in this file, but could evolve into an official type someday + */ +type UnmergedStatusEntry = + | GitStatusEntry.Added + | GitStatusEntry.UpdatedButUnmerged + | GitStatusEntry.Deleted + +/** Returns a human-readable description for a chosen version of a file + * intended for use with manually resolved merge conflicts + */ +export function getUnmergedStatusEntryDescription( + entry: UnmergedStatusEntry, + branch?: string +): string { + const suffix = branch ? ` from ${branch}` : '' + + switch (entry) { + case GitStatusEntry.Added: + return `Using the added file${suffix}` + case GitStatusEntry.UpdatedButUnmerged: + return `Using the modified file${suffix}` + case GitStatusEntry.Deleted: + return `Using the deleted file${suffix}` + default: + return assertNever(entry, 'Unknown status entry to format') + } +} + +/** Returns a human-readable description for an available manual resolution method + * intended for use with manually resolved merge conflicts + */ +export function getLabelForManualResolutionOption( + entry: UnmergedStatusEntry, + branch?: string +): string { + const suffix = branch ? ` from ${branch}` : '' + + switch (entry) { + case GitStatusEntry.Added: + return `Use the added file${suffix}` + case GitStatusEntry.UpdatedButUnmerged: + return `Use the modified file${suffix}` + case GitStatusEntry.Deleted: + const deleteSuffix = branch ? ` on ${branch}` : '' + return `Do not include this file${deleteSuffix}` + default: + return assertNever(entry, 'Unknown status entry to format') + } +} + +/** Filter working directory changes for conflicted or resolved files */ +export function getUnmergedFiles(status: WorkingDirectoryStatus) { + return status.files.filter(f => isConflictedFile(f.status)) +} + +/** Filter working directory changes for untracked files */ +export function getUntrackedFiles( + workingDirectoryStatus: WorkingDirectoryStatus +): ReadonlyArray { + return workingDirectoryStatus.files.filter( + file => file.status.kind === AppFileStatusKind.Untracked + ) +} + +/** Filter working directory changes for resolved files */ +export function getResolvedFiles( + status: WorkingDirectoryStatus, + manualResolutions: Map +) { + return status.files.filter( + f => + isConflictedFileStatus(f.status) && + !hasUnresolvedConflicts(f.status, manualResolutions.get(f.path)) + ) +} + +/** Filter working directory changes for conflicted files */ +export function getConflictedFiles( + status: WorkingDirectoryStatus, + manualResolutions: Map +) { + return status.files.filter( + f => + isConflictedFileStatus(f.status) && + hasUnresolvedConflicts(f.status, manualResolutions.get(f.path)) + ) +} diff --git a/app/src/lib/stores/accounts-store.ts b/app/src/lib/stores/accounts-store.ts new file mode 100644 index 0000000000..ea871253ca --- /dev/null +++ b/app/src/lib/stores/accounts-store.ts @@ -0,0 +1,222 @@ +import { IDataStore, ISecureStore } from './stores' +import { getKeyForAccount } from '../auth' +import { Account } from '../../models/account' +import { fetchUser, EmailVisibility } from '../api' +import { fatalError } from '../fatal-error' +import { TypedBaseStore } from './base-store' + +/** The data-only interface for storage. */ +interface IEmail { + readonly email: string + /** + * Represents whether GitHub has confirmed the user has access to this + * email address. New users require a verified email address before + * they can sign into GitHub Desktop. + */ + readonly verified: boolean + /** + * Flag for the user's preferred email address. Other email addresses + * are provided for associating commit authors with the one GitHub account. + */ + readonly primary: boolean + + /** The way in which the email is visible. */ + readonly visibility: EmailVisibility +} + +function isKeyChainError(e: any) { + const error = e as Error + return ( + error.message && + error.message.startsWith( + 'The user name or passphrase you entered is not correct' + ) + ) +} + +/** The data-only interface for storage. */ +interface IAccount { + readonly token: string + readonly login: string + readonly endpoint: string + readonly emails: ReadonlyArray + readonly avatarURL: string + readonly id: number + readonly name: string + readonly plan?: string +} + +/** The store for logged in accounts. */ +export class AccountsStore extends TypedBaseStore> { + private dataStore: IDataStore + private secureStore: ISecureStore + + private accounts: ReadonlyArray = [] + + /** A promise that will resolve when the accounts have been loaded. */ + private loadingPromise: Promise + + public constructor(dataStore: IDataStore, secureStore: ISecureStore) { + super() + + this.dataStore = dataStore + this.secureStore = secureStore + this.loadingPromise = this.loadFromStore() + } + + /** + * Get the list of accounts in the cache. + */ + public async getAll(): Promise> { + await this.loadingPromise + + return this.accounts.slice() + } + + /** + * Add the account to the store. + */ + public async addAccount(account: Account): Promise { + await this.loadingPromise + + try { + const key = getKeyForAccount(account) + await this.secureStore.setItem(key, account.login, account.token) + } catch (e) { + log.error(`Error adding account '${account.login}'`, e) + + if (__DARWIN__ && isKeyChainError(e)) { + this.emitError( + new Error( + `GitHub Desktop was unable to store the account token in the keychain. Please check you have unlocked access to the 'login' keychain.` + ) + ) + } else { + this.emitError(e) + } + return null + } + + const accountsByEndpoint = this.accounts.reduce( + (map, x) => map.set(x.endpoint, x), + new Map() + ) + accountsByEndpoint.set(account.endpoint, account) + + this.accounts = [...accountsByEndpoint.values()] + + this.save() + return account + } + + /** Refresh all accounts by fetching their latest info from the API. */ + public async refresh(): Promise { + this.accounts = await Promise.all( + this.accounts.map(acc => this.tryUpdateAccount(acc)) + ) + + this.save() + this.emitUpdate(this.accounts) + } + + /** + * Attempts to update the Account with new information from + * the API. + * + * If the update fails for whatever reason this function + * will return the old Account instance. Usually updates fails + * due to connectivity issues but in the future we should + * investigate whether we're able to detect here that the + * token is definitely not valid anymore and let the + * user know that they've been signed out. + */ + private async tryUpdateAccount(account: Account): Promise { + try { + return await updatedAccount(account) + } catch (e) { + log.warn(`Error refreshing account '${account.login}'`, e) + return account + } + } + + /** + * Remove the account from the store. + */ + public async removeAccount(account: Account): Promise { + await this.loadingPromise + + try { + await this.secureStore.deleteItem( + getKeyForAccount(account), + account.login + ) + } catch (e) { + log.error(`Error removing account '${account.login}'`, e) + this.emitError(e) + return + } + + this.accounts = this.accounts.filter( + a => !(a.endpoint === account.endpoint && a.id === account.id) + ) + + this.save() + } + + /** + * Load the users into memory from storage. + */ + private async loadFromStore(): Promise { + const raw = this.dataStore.getItem('users') + if (!raw || !raw.length) { + return + } + + const rawAccounts: ReadonlyArray = JSON.parse(raw) + const accountsWithTokens = [] + for (const account of rawAccounts) { + const accountWithoutToken = new Account( + account.login, + account.endpoint, + '', + account.emails, + account.avatarURL, + account.id, + account.name, + account.plan + ) + + const key = getKeyForAccount(accountWithoutToken) + try { + const token = await this.secureStore.getItem(key, account.login) + accountsWithTokens.push(accountWithoutToken.withToken(token || '')) + } catch (e) { + log.error(`Error getting token for '${key}'. Skipping.`, e) + + this.emitError(e) + } + } + + this.accounts = accountsWithTokens + this.emitUpdate(this.accounts) + } + + private save() { + const usersWithoutTokens = this.accounts.map(account => + account.withToken('') + ) + this.dataStore.setItem('users', JSON.stringify(usersWithoutTokens)) + + this.emitUpdate(this.accounts) + } +} + +async function updatedAccount(account: Account): Promise { + if (!account.token) { + return fatalError( + `Cannot update an account which doesn't have a token: ${account.login}` + ) + } + + return fetchUser(account.endpoint, account.token) +} diff --git a/app/src/lib/stores/ahead-behind-store.ts b/app/src/lib/stores/ahead-behind-store.ts new file mode 100644 index 0000000000..12dbc4d219 --- /dev/null +++ b/app/src/lib/stores/ahead-behind-store.ts @@ -0,0 +1,134 @@ +import pLimit from 'p-limit' +import QuickLRU from 'quick-lru' +import { DisposableLike, Disposable } from 'event-kit' +import { IAheadBehind } from '../../models/branch' +import { revSymmetricDifference, getAheadBehind } from '../git' +import { Repository } from '../../models/repository' + +export type AheadBehindCallback = (aheadBehind: IAheadBehind) => void + +/** Creates a cache key for a particular commit range in a specific repository */ +function getCacheKey(repository: Repository, from: string, to: string) { + return `${repository.path}:${from}:${to}` +} + +/** + * The maximum number of _concurrent_ `git rev-list` operations we'll run. We're + * gonna play it safe and stick to no concurrent operations initially since + * that's how the previous ahead/behind logic worked but it should be safe to + * bump this to 3 or so to squeeze some more performance out of it. + */ +const MaxConcurrent = 1 + +export class AheadBehindStore { + /** + * A map keyed on the value of `getCacheKey` containing one object per + * reference (repository specific) with the last retrieved ahead behind status + * for that reference. + * + * This map also functions as a least recently used cache and will evict the + * least recently used comparisons to ensure the cache won't grow unbounded + */ + private readonly cache = new QuickLRU({ + maxSize: 2500, + }) + + /** Currently executing workers. Contains at most `MaxConcurrent` workers */ + private readonly workers = new Map>() + + /** + * A concurrency limiter which ensures that we only run `MaxConcurrent` + * ahead/behind calculations concurrently + */ + private readonly limit = pLimit(MaxConcurrent) + + /** + * Attempt to _synchronously_ retrieve an ahead behind status for a particular + * range. If the range doesn't exist in the cache this function returns + * undefined. + * + * Useful for component who wish to have a value for the initial render + * instead of waiting for the subscription to produce an event. + * + * Note that while it's technically possible to use refs or revision + * expressions instead of commit ids here it's strongly recommended against as + * the store has no way of knowing when these refs are updated. Using oids + * means we can rely on the ids themselves for invalidation. + */ + public tryGetAheadBehind(repository: Repository, from: string, to: string) { + return this.cache.get(getCacheKey(repository, from, to)) ?? undefined + } + + /** + * Subscribe to the result of calculating the ahead behind status for the + * given range. The operation can be aborted using the returned Disposable. + * + * Aborting means that the callback won't execute and if that we'll try to + * avoid invoking Git unless we've already done so or there's another caller + * requesting that calculation. Aborting after the callback has been invoked + * is a no-op. + * + * The callback will not fire if we were unsuccessful in calculating the + * ahead/behind status. + */ + public getAheadBehind( + repository: Repository, + from: string, + to: string, + callback: AheadBehindCallback + ): DisposableLike { + const key = getCacheKey(repository, from, to) + const existing = this.cache.get(key) + const disposable = new Disposable(() => {}) + + // We failed loading on the last attempt in which case we won't retry + if (existing === null) { + return disposable + } + + if (existing !== undefined) { + callback(existing) + return disposable + } + + this.limit(async () => { + const existing = this.cache.get(key) + + // The caller has either aborted or we've previously failed loading ahead/ + // behind status for this ref pair. We don't retry previously failed ops + if (disposable.disposed || existing === null) { + return + } + + if (existing !== undefined) { + callback(existing) + return + } + + let worker = this.workers.get(key) + + if (worker === undefined) { + worker = getAheadBehind(repository, revSymmetricDifference(from, to)) + .catch(e => { + log.error('Failed calculating ahead/behind status', e) + return null + }) + .then(aheadBehind => { + this.cache.set(key, aheadBehind) + return aheadBehind + }) + .finally(() => this.workers.delete(key)) + + this.workers.set(key, worker) + } + + const aheadBehind = await worker + + if (aheadBehind !== null && !disposable.disposed) { + callback(aheadBehind) + } + }).catch(e => log.error('Failed calculating ahead/behind status', e)) + + return disposable + } +} diff --git a/app/src/lib/stores/alive-store.ts b/app/src/lib/stores/alive-store.ts new file mode 100644 index 0000000000..a77cd3eec5 --- /dev/null +++ b/app/src/lib/stores/alive-store.ts @@ -0,0 +1,268 @@ +import { AccountsStore } from './accounts-store' +import { Account, accountEquals } from '../../models/account' +import { API } from '../api' +import { AliveSession, AliveEvent, Subscription } from '@github/alive-client' +import { Emitter } from 'event-kit' +import { supportsAliveSessions } from '../endpoint-capabilities' + +/** Checks whether or not an account is included in a list of accounts. */ +function accountIncluded(account: Account, accounts: ReadonlyArray) { + return accounts.find(a => accountEquals(a, account)) +} + +export interface IDesktopChecksFailedAliveEvent { + readonly type: 'pr-checks-failed' + readonly timestamp: number + readonly owner: string + readonly repo: string + readonly pull_request_number: number + readonly check_suite_id: number + readonly commit_sha: string +} + +export interface IDesktopPullRequestReviewSubmitAliveEvent { + readonly type: 'pr-review-submit' + readonly timestamp: number + readonly owner: string + readonly repo: string + readonly pull_request_number: number + readonly state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' + readonly review_id: string +} + +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 +} + +interface IAliveEndpointSession { + readonly session: AliveSession + readonly webSocketUrl: string +} + +/** + * This class manages the subscriptions to Alive channels as the user signs in + * and out of their GH or GHE accounts. + */ +export class AliveStore { + private readonly ALIVE_EVENT_RECEIVED_EVENT = 'alive-event-received' + + private readonly sessionPerEndpoint: Map = + new Map() + private readonly emitter = new Emitter() + private subscriptions: Array = [] + private enabled: boolean = false + private accountSubscriptionPromise: Promise | null = null + + public constructor(private readonly accountsStore: AccountsStore) { + this.accountsStore.onDidUpdate(this.subscribeToAccounts) + } + + /** + * Enable or disable Alive subscriptions. + * + * When enabled, it will immediately try to subscribe to the Alive channel for + * all accounts currently signed in. + * + * When disabled, it will immediately unsubscribe from all Alive channels. + */ + public setEnabled(enabled: boolean) { + if (this.enabled === enabled) { + return + } + + this.enabled = enabled + + if (enabled) { + this.subscribeToAllAccounts() + } else { + this.unsubscribeFromAllAccounts() + } + } + + /** Listen to Alive events received. */ + public onAliveEventReceived(callback: (event: DesktopAliveEvent) => void) { + this.emitter.on(this.ALIVE_EVENT_RECEIVED_EVENT, callback) + } + + private async subscribeToAllAccounts() { + const accounts = await this.accountsStore.getAll() + this.subscribeToAccounts(accounts) + } + + private async unsubscribeFromAllAccounts() { + // Wait until previous (un)subscriptions finish + await this.accountSubscriptionPromise + + const subscribedAccounts = this.subscriptions.map(s => s.account) + for (const account of subscribedAccounts) { + this.unsubscribeFromAccount(account) + } + } + + private subscribeToAccounts = async (accounts: ReadonlyArray) => { + if (!this.enabled) { + return + } + + // Wait until previous (un)subscriptions finish + await this.accountSubscriptionPromise + + this.accountSubscriptionPromise = this._subscribeToAccounts(accounts) + } + + /** + * This method just wraps the async logic to subscribe to a list of accounts, + * so that we can wait until the previous (un)subscriptions finish. + * Do not use directly, use `subscribeToAccounts` instead. + */ + private async _subscribeToAccounts(accounts: ReadonlyArray) { + const subscribedAccounts = this.subscriptions.map(s => s.account) + + // Clear subscriptions for accounts that are no longer in the list + for (const account of subscribedAccounts) { + if (!accountIncluded(account, accounts)) { + this.unsubscribeFromAccount(account) + } + } + + // Subscribe to new accounts + for (const account of accounts) { + if (!accountIncluded(account, subscribedAccounts)) { + await this.subscribeToAccount(account) + } + } + } + + private sessionForAccount( + account: Account + ): IAliveEndpointSession | undefined { + return this.sessionPerEndpoint.get(account.endpoint) + } + + private async createSessionForAccount( + account: Account + ): Promise { + const session = this.sessionForAccount(account) + if (session !== undefined) { + return session + } + + const api = API.fromAccount(account) + let webSocketUrl = null + + try { + webSocketUrl = await api.getAliveWebSocketURL() + } catch (e) { + log.error(`Could not get Alive web socket URL for '${account.login}'`, e) + return null + } + + if (webSocketUrl === null) { + return null + } + + const aliveSession = new AliveSession( + webSocketUrl, + () => api.getAliveWebSocketURL(), + false, + this.notify + ) + + const newSession = { + session: aliveSession, + webSocketUrl, + } + + this.sessionPerEndpoint.set(account.endpoint, newSession) + + return newSession + } + + private unsubscribeFromAccount(account: Account) { + const endpointSession = this.sessionForAccount(account) + if (endpointSession === undefined) { + return + } + + const subscription = this.subscriptions.find(s => + accountEquals(s.account, account) + ) + if (subscription === undefined) { + return + } + + endpointSession.session.unsubscribe([subscription.subscription]) + this.subscriptions = this.subscriptions.filter( + s => !accountEquals(s.account, account) + ) + + this.sessionPerEndpoint.delete(account.endpoint) + + endpointSession.session.offline() + + log.info(`Unubscribed '${account.login}' from Alive channel`) + } + + private subscribeToAccount = async (account: Account) => { + if (!supportsAliveSessions(account.endpoint)) { + return + } + + const endpointSession = await this.createSessionForAccount(account) + const api = API.fromAccount(account) + const channelInfo = await api.getAliveDesktopChannel() + + if (endpointSession === null || channelInfo === null) { + return + } + + const subscription = { + subscriber: this, + topic: { + name: channelInfo.channel_name, + signed: channelInfo.signed_channel, + offset: '', + }, + } + + endpointSession.session.subscribe([subscription]) + + this.subscriptions.push({ + account, + subscription, + }) + + log.info(`Subscribed '${account.login}' to Alive channel`) + } + + private notify = (subscribers: Iterable, event: AliveEvent) => { + if (event.type !== 'message') { + return + } + + const data = event.data as any as DesktopAliveEvent + 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 new file mode 100644 index 0000000000..aa70271360 --- /dev/null +++ b/app/src/lib/stores/api-repositories-store.ts @@ -0,0 +1,238 @@ +import { BaseStore } from './base-store' +import { AccountsStore } from './accounts-store' +import { IAPIRepository, API } from '../api' +import { Account, accountEquals } from '../../models/account' +import { merge } from '../merge' + +/** + * Attempt to look up an existing account in the account state + * map based on endpoint and user id equality (see accountEquals). + * + * The purpose of this method is to ensure that we're using the + * most recent Account instance during our asynchronous refresh + * operations. While we're refreshing the list of repositories + * that a user has explicit permissions to access it's possible + * that the accounts store will emit updated account instances + * (for example updating the user real name, or the list of + * email addresses associated with an account) and in order to + * guarantee reference equality with the accounts emitted by + * the accounts store we need to ensure we're in sync. + * + * If no match is found the provided account is returned. + */ +function resolveAccount( + account: Account, + accountState: ReadonlyMap +) { + // The set uses reference equality so if we find our + // account instance in the set there's no need to look + // any further. + if (accountState.has(account)) { + return account + } + + // If we can't find our account instance in the set one + // of two things have happened. Either the account has + // been removed (by the user explicitly signing out) or + // the accounts store has refreshed the account details + // from the API and as such the reference equality no + // longer holds. In the latter case we attempt to + // find the updated account instance by comparing its + // user id and endpoint to the provided account. + for (const existingAccount of accountState.keys()) { + if (accountEquals(existingAccount, account)) { + return existingAccount + } + } + + // If we can't find a matching account it's likely + // that it's the first time we're loading the list + // of repositories for this account so we return + // whatever was provided to us such that it may be + // inserted into the set as a new entry. + return account +} + +/** + * An interface describing the current state of + * repositories that a particular account has explicit + * permissions to access and whether or not the list of + * repositories is being loaded or refreshed. + * + * This main purpose of this interface is to describe + * the state necessary to render a list of cloneable + * repositories. + */ +export interface IAccountRepositories { + /** + * The list of repositories that a particular account + * has explicit permissions to access. + */ + readonly repositories: ReadonlyArray + + /** + * Whether or not the list of repositories is currently + * being loaded for the first time or refreshed. + */ + readonly loading: boolean +} + +/** + * A store responsible for providing lists of repositories + * that the currently signed in user(s) have explicit access + * to. It's primary purpose is to serve state required for + * the application to present a list of cloneable repositories + * for a particular user. + */ +export class ApiRepositoriesStore extends BaseStore { + /** + * The main internal state of the store. Note that + * all state in this store should be treated as immutable such + * that consumers can use reference equality to determine whether + * state has actually changed or not. + */ + private accountState: ReadonlyMap = new Map< + Account, + IAccountRepositories + >() + + public constructor(accountsStore: AccountsStore) { + super() + accountsStore.onDidUpdate(this.onAccountsChanged) + } + + /** + * Called whenever the accounts store emits an update which + * usually means that a new account was added or an account + * was removed due to sign out but it could also mean that + * the account data has been updated. It's crucial that + * the ApiRepositories store match (through reference + * equality) the accounts in the accounts store and this + * method therefore attempts to merge its internal state + * with the new accounts. + */ + private onAccountsChanged = (accounts: ReadonlyArray) => { + const newState = new Map() + + for (const account of accounts) { + for (const [key, value] of this.accountState.entries()) { + // Check to see whether the accounts store only emitted an + // updated Account for the same login and endpoint meaning + // that we don't need to discard our cached data. + if (accountEquals(key, account)) { + newState.set(account, value) + break + } + } + } + + this.accountState = newState + this.emitUpdate() + } + + private updateAccount( + account: Account, + repositories: Pick + ) { + const newState = new Map(this.accountState) + + // The account instance might have changed between the refresh and + // the update so we'll need to look it up by endpoint and user id. + // If we can't find it we're likely being asked to insert info for + // an account for the first time. + const newOrExistingAccount = resolveAccount(account, newState) + const existingRepositories = newState.get(newOrExistingAccount) + + const newRepositories = + existingRepositories === undefined + ? merge({ loading: false, repositories: [] }, repositories) + : merge(existingRepositories, repositories) + + newState.set(newOrExistingAccount, newRepositories) + + this.accountState = newState + 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 currentState = this.getAccountState(account) + + if (currentState?.loading) { + return + } + + 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()] }) + } + + 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 { + return this.accountState + } +} diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts new file mode 100644 index 0000000000..8783ec189f --- /dev/null +++ b/app/src/lib/stores/app-store.ts @@ -0,0 +1,7909 @@ +import * as Path from 'path' +import { + AccountsStore, + CloningRepositoriesStore, + GitHubUserStore, + GitStore, + IssuesStore, + PullRequestCoordinator, + RepositoriesStore, + SignInStore, + UpstreamRemoteName, +} from '.' +import { Account } from '../../models/account' +import { AppMenu, IMenu } from '../../models/app-menu' +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, + shortenSHA, +} from '../../models/commit' +import { + DiffSelection, + DiffSelectionType, + DiffType, + ImageDiffType, + ITextDiff, +} from '../../models/diff' +import { FetchType } from '../../models/fetch' +import { + GitHubRepository, + hasWritePermission, +} from '../../models/github-repository' +import { + defaultPullRequestSuggestedNextAction, + PullRequest, + PullRequestSuggestedNextAction, +} from '../../models/pull-request' +import { + forkPullRequestRemoteName, + IRemote, + remoteEquals, +} from '../../models/remote' +import { + ILocalRepositoryState, + nameOf, + Repository, + isRepositoryWithGitHubRepository, + RepositoryWithGitHubRepository, + getNonForkGitHubRepository, + isForkedRepositoryContributingToParent, +} from '../../models/repository' +import { + CommittedFileChange, + WorkingDirectoryFileChange, + WorkingDirectoryStatus, + AppFileStatusKind, +} from '../../models/status' +import { TipState, tipEquals, IValidBranch } from '../../models/tip' +import { ICommitMessage } from '../../models/commit-message' +import { + Progress, + ICheckoutProgress, + IFetchProgress, + IRevertProgress, + 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 { + ApplicableTheme, + ApplicationTheme, + getCurrentlyAppliedTheme, + getPersistedThemeName, + setPersistedTheme, +} from '../../ui/lib/application-theme' +import { + getAppMenu, + getCurrentWindowState, + getCurrentWindowZoomFactor, + updatePreferredAppMenuItemLabels, + updateAccounts, + setWindowZoomFactor, + onShowInstallingUpdate, + sendWillQuitEvenIfUpdatingSync, + quitApp, + sendCancelQuittingSync, +} from '../../ui/main-process-proxy' +import { + API, + getAccountForEndpoint, + getDotComAPIEndpoint, + IAPIOrganization, + getEndpointForRepository, + IAPIFullRepository, + IAPIComment, + IAPIRepoRuleset, +} from '../api' +import { shell } from '../app-shell' +import { + CompareAction, + HistoryTabMode, + Foldout, + FoldoutType, + IAppState, + ICompareBranch, + ICompareFormUpdate, + ICompareToBranch, + IDisplayHistory, + PossibleSelections, + RepositorySectionTab, + SelectionType, + IRepositoryState, + ChangesSelectionKind, + ChangesWorkingDirectorySelection, + isRebaseConflictState, + isCherryPickConflictState, + isMergeConflictState, + IMultiCommitOperationState, + IConstrainedValue, + ICompareState, +} from '../app-state' +import { + findEditorOrDefault, + getAvailableEditors, + 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, + addRemote, + checkoutBranch, + createCommit, + getAuthorIdentity, + getChangedFiles, + getCommitDiff, + getMergeBase, + getRemotes, + getWorkingDirectoryDiff, + isCoAuthoredByTrailer, + pull as pullRepo, + push as pushRepo, + renameBranch, + saveGitIgnore, + appendIgnoreRule, + createMergeCommit, + getBranchesPointedAt, + abortRebase, + continueRebase, + rebase, + PushOptions, + RebaseResult, + getRebaseSnapshot, + IStatusResult, + GitError, + MergeResult, + getBranchesDifferingFromUpstream, + deleteLocalBranch, + deleteRemoteBranch, + fastForwardBranches, + GitResetMode, + reset, + getBranchAheadBehind, + getRebaseInternalState, + getCommit, + appendIgnoreFile, + getRepositoryType, + RepositoryType, + getCommitRangeDiff, + getCommitRangeChangedFiles, + updateRemoteHEAD, + getBranchMergeBaseChangedFiles, + getBranchMergeBaseDiff, + checkoutCommit, +} from '../git' +import { + installGlobalLFSFilters, + installLFSHooks, + isUsingLFS, +} from '../git/lfs' +import { inferLastPushForRepository } from '../infer-last-push-for-repository' +import { updateMenuState } from '../menu-update' +import { merge } from '../merge' +import { + IMatchedGitHubRepository, + matchGitHubRepository, + matchExistingRepository, + urlMatchesRemote, +} from '../repository-matching' +import { ForcePushBranchState, getCurrentBranchForcePushState } from '../rebase' +import { RetryAction, RetryActionType } from '../../models/retry-actions' +import { + Default as DefaultShell, + findShellOrDefault, + launchShell, + parse as parseShell, + Shell, +} from '../shells' +import { ILaunchStats, StatsStore } from '../stats' +import { hasShownWelcomeFlow, markWelcomeFlowComplete } from '../welcome' +import { WindowState } from '../window-state' +import { TypedBaseStore } from './base-store' +import { MergeTreeResult } from '../../models/merge' +import { promiseWithMinimumTimeout } from '../promise' +import { BackgroundFetcher } from './helpers/background-fetcher' +import { RepositoryStateCache } from './repository-state-cache' +import { readEmoji } from '../read-emoji' +import { GitStoreCache } from './git-store-cache' +import { GitErrorContext } from '../git-error-context' +import { + setNumber, + setBoolean, + getBoolean, + getNumber, + getNumberArray, + setNumberArray, + getEnum, + getObject, + setObject, + getFloatNumber, +} from '../local-storage' +import { ExternalEditorError, suggestedExternalEditor } from '../editors/shared' +import { ApiRepositoriesStore } from './api-repositories-store' +import { + updateChangedFiles, + updateConflictState, + selectWorkingDirectoryFiles, +} from './updates/changes-state' +import { ManualConflictResolution } from '../../models/manual-conflict-resolution' +import { BranchPruner } from './helpers/branch-pruner' +import { enableMoveStash } from '../feature-flag' +import { Banner, BannerType } from '../../models/banner' +import { ComputedAction } from '../../models/computed-action' +import { + createDesktopStashEntry, + getLastDesktopStashEntryForBranch, + popStashEntry, + dropDesktopStashEntry, + moveStashEntry, +} from '../git/stash' +import { + UncommittedChangesStrategy, + defaultUncommittedChangesStrategy, +} from '../../models/uncommitted-changes-strategy' +import { IStashEntry, StashedChangesLoadStates } from '../../models/stash-entry' +import { arrayEquals } from '../equality' +import { MenuLabelsEvent } from '../../models/menu-labels' +import { findRemoteBranchName } from './helpers/find-branch-name' +import { updateRemoteUrl } from './updates/update-remote-url' +import { + TutorialStep, + orderedTutorialSteps, + isValidTutorialStep, +} from '../../models/tutorial-step' +import { OnboardingTutorialAssessor } from './helpers/tutorial-assessor' +import { getUntrackedFiles } from '../status' +import { isBranchPushable } from '../helpers/push-control' +import { + findAssociatedPullRequest, + isPullRequestAssociatedWithBranch, +} from '../helpers/pull-request-matching' +import { parseRemote } from '../../lib/remote-parsing' +import { createTutorialRepository } from './helpers/create-tutorial-repository' +import { sendNonFatalException } from '../helpers/non-fatal-exception' +import { getDefaultDir } from '../../ui/lib/default-dir' +import { WorkflowPreferences } from '../../models/workflow-preferences' +import { RepositoryIndicatorUpdater } from './helpers/repository-indicator-updater' +import { isAttributableEmailFor } from '../email' +import { TrashNameLabel } from '../../ui/lib/context-menu' +import { GitError as DugiteError } from 'dugite' +import { + ErrorWithMetadata, + CheckoutError, + DiscardChangesError, +} from '../error-with-metadata' +import { + ShowSideBySideDiffDefault, + getShowSideBySideDiff, + setShowSideBySideDiff, +} from '../../ui/lib/diff-mode' +import { + abortCherryPick, + cherryPick, + CherryPickResult, + continueCherryPick, + getCherryPickSnapshot, + isCherryPickHeadFound, +} from '../git/cherry-pick' +import { DragElement } from '../../models/drag-drop' +import { ILastThankYou } from '../../models/last-thank-you' +import { squash } from '../git/squash' +import { getTipSha } from '../tip' +import { + MultiCommitOperationDetail, + MultiCommitOperationKind, + MultiCommitOperationStep, + MultiCommitOperationStepKind, +} from '../../models/multi-commit-operation' +import { reorder } from '../git/reorder' +import { UseWindowsOpenSSHKey } from '../ssh/ssh' +import { isConflictsFlow } from '../multi-commit-operation' +import { clamp } from '../clamp' +import { EndpointToken } from '../endpoint-token' +import { IRefCheck } from '../ci-checks/ci-checks' +import { + NotificationsStore, + getNotificationsEnabled, +} from './notifications-store' +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' + +const LastSelectedRepositoryIDKey = 'last-selected-repository-id' + +const RecentRepositoriesKey = 'recently-selected-repositories' +/** + * maximum number of repositories shown in the "Recent" repositories group + * in the repository switcher dropdown + */ +const RecentRepositoriesLength = 3 + +const defaultSidebarWidth: number = 250 +const sidebarWidthConfigKey: string = 'sidebar-width' + +const defaultCommitSummaryWidth: number = 250 +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 askToMoveToApplicationsFolderDefault: boolean = true +const confirmRepoRemovalDefault: boolean = true +const confirmDiscardChangesDefault: boolean = true +const confirmDiscardChangesPermanentlyDefault: boolean = true +const confirmDiscardStashDefault: boolean = true +const confirmCheckoutCommitDefault: boolean = true +const askForConfirmationOnForcePushDefault = true +const confirmUndoCommitDefault: boolean = true +const askToMoveToApplicationsFolderKey: string = 'askToMoveToApplicationsFolder' +const confirmRepoRemovalKey: string = 'confirmRepoRemoval' +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 uncommittedChangesStrategyKey = 'uncommittedChangesStrategyKind' + +const externalEditorKey: string = 'externalEditor' + +const imageDiffTypeDefault = ImageDiffType.TwoUp +const imageDiffTypeKey = 'image-diff-type' + +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' + +const shellKey = 'shell' + +const repositoryIndicatorsEnabledKey = 'enable-repository-indicators' + +// background fetching should occur hourly when Desktop is active, but this +// lower interval ensures user interactions like switching repositories and +// switching between apps does not result in excessive fetching in the app +const BackgroundFetchMinimumInterval = 30 * 60 * 1000 + +/** + * Wait 2 minutes before refreshing repository indicators + */ +const InitialRepositoryIndicatorTimeout = 2 * 60 * 1000 + +const MaxInvalidFoldersToDisplay = 3 + +const lastThankYouKey = 'version-and-users-of-last-thank-you' +const pullRequestSuggestedNextActionKey = + 'pull-request-suggested-next-action-key' + +export class AppStore extends TypedBaseStore { + private readonly gitStoreCache: GitStoreCache + + private accounts: ReadonlyArray = new Array() + private repositories: ReadonlyArray = new Array() + private recentRepositories: ReadonlyArray = new Array() + + private selectedRepository: Repository | CloningRepository | null = null + + /** The background fetcher for the currently selected repository. */ + private currentBackgroundFetcher: BackgroundFetcher | null = null + + private currentBranchPruner: BranchPruner | null = null + + private readonly repositoryIndicatorUpdater: RepositoryIndicatorUpdater + + private showWelcomeFlow = false + private focusCommitMessage = false + private currentFoldout: Foldout | null = null + private currentBanner: Banner | null = null + private emitQueued = false + + private readonly localRepositoryStateLookup = new Map< + number, + ILocalRepositoryState + >() + + /** Map from shortcut (e.g., :+1:) to on disk URL. */ + private emoji = new Map() + + /** + * The Application menu as an AppMenu instance or null if + * the main process has not yet provided the renderer with + * a copy of the application menu structure. + */ + private appMenu: AppMenu | null = null + + /** + * Used to highlight access keys throughout the app when the + * Alt key is pressed. Only applicable on non-macOS platforms. + */ + private highlightAccessKeys: boolean = false + + /** + * A value indicating whether or not the current application + * window has focus. + */ + private appIsFocused: boolean = false + + private sidebarWidth = constrain(defaultSidebarWidth) + private commitSummaryWidth = constrain(defaultCommitSummaryWidth) + private stashedFilesWidth = constrain(defaultStashedFilesWidth) + private pullRequestFileListWidth = constrain(defaultPullRequestFileListWidth) + + 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 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 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 + + private uncommittedChangesStrategy = defaultUncommittedChangesStrategy + + private selectedExternalEditor: string | null = null + + private resolvedExternalEditor: string | null = null + + /** The user's preferred shell. */ + private selectedShell = DefaultShell + + /** The current repository filter text */ + private repositoryFilterText: string = '' + + private currentMergeTreePromise: Promise | null = null + + /** The function to resolve the current Open in Desktop flow. */ + private resolveOpenInDesktop: + | ((repository: Repository | null) => void) + | null = null + + private selectedCloneRepositoryTab = CloneRepositoryTab.DotCom + + private selectedBranchesTab = BranchesTab.Branches + private selectedTheme = ApplicationTheme.System + private currentTheme: ApplicableTheme = ApplicationTheme.Light + + private useWindowsOpenSSH: boolean = false + + private hasUserViewedStash = false + + private repositoryIndicatorsEnabled: boolean + + /** Which step the user needs to complete next in the onboarding tutorial */ + private currentOnboardingTutorialStep = TutorialStep.NotApplicable + private readonly tutorialAssessor: OnboardingTutorialAssessor + + private currentDragElement: DragElement | null = null + private lastThankYou: ILastThankYou | undefined + private showCIStatusPopover: boolean = false + + /** A service for managing the stack of open popups */ + private popupManager = new PopupManager() + + private pullRequestSuggestedNextAction: + | PullRequestSuggestedNextAction + | undefined = undefined + + private cachedRepoRulesets = new Map() + + public constructor( + private readonly gitHubUserStore: GitHubUserStore, + private readonly cloningRepositoriesStore: CloningRepositoriesStore, + private readonly issuesStore: IssuesStore, + private readonly statsStore: StatsStore, + private readonly signInStore: SignInStore, + private readonly accountsStore: AccountsStore, + private readonly repositoriesStore: RepositoriesStore, + private readonly pullRequestCoordinator: PullRequestCoordinator, + private readonly repositoryStateCache: RepositoryStateCache, + private readonly apiRepositoriesStore: ApiRepositoriesStore, + private readonly notificationsStore: NotificationsStore + ) { + super() + + this.showWelcomeFlow = !hasShownWelcomeFlow() + + if (__WIN32__) { + const useWindowsOpenSSH = getBoolean(UseWindowsOpenSSHKey) + + // If the user never selected whether to use Windows OpenSSH or not, use it + // by default if we have to show the welcome flow (i.e. if it's a new install) + if (useWindowsOpenSSH === undefined) { + this._setUseWindowsOpenSSH(this.showWelcomeFlow) + } else { + this.useWindowsOpenSSH = useWindowsOpenSSH + } + } + + this.gitStoreCache = new GitStoreCache( + shell, + this.statsStore, + (repo, store) => this.onGitStoreUpdated(repo, store), + error => this.emitError(error) + ) + + window.addEventListener('resize', () => { + this.updateResizableConstraints() + this.emitUpdate() + }) + + this.initializeWindowState() + this.initializeZoomFactor() + this.wireupIpcEventHandlers() + this.wireupStoreEventHandlers() + getAppMenu() + this.tutorialAssessor = new OnboardingTutorialAssessor( + this.getResolvedExternalEditor + ) + + // We're considering flipping the default value and have new users + // start off with repository indicators disabled. As such we'll start + // persisting the current default to localstorage right away so we + // can change the default in the future without affecting current + // users. + if (getBoolean(repositoryIndicatorsEnabledKey) === undefined) { + setBoolean(repositoryIndicatorsEnabledKey, true) + } + + this.repositoryIndicatorsEnabled = + getBoolean(repositoryIndicatorsEnabledKey) ?? true + + this.repositoryIndicatorUpdater = new RepositoryIndicatorUpdater( + this.getRepositoriesForIndicatorRefresh, + this.refreshIndicatorForRepository + ) + + window.setTimeout(() => { + if (this.repositoryIndicatorsEnabled) { + this.repositoryIndicatorUpdater.start() + } + }, InitialRepositoryIndicatorTimeout) + + API.onTokenInvalidated(this.onTokenInvalidated) + + this.notificationsStore.onChecksFailedNotification( + this.onChecksFailedNotification + ) + + this.notificationsStore.onPullRequestReviewSubmitNotification( + this.onPullRequestReviewSubmitNotification + ) + + this.notificationsStore.onPullRequestCommentNotification( + this.onPullRequestCommentNotification + ) + + onShowInstallingUpdate(this.onShowInstallingUpdate) + } + + private initializeWindowState = async () => { + const currentWindowState = await getCurrentWindowState() + if (currentWindowState === undefined) { + return + } + + this.windowState = currentWindowState + } + + private initializeZoomFactor = async () => { + const zoomFactor = await this.getWindowZoomFactor() + if (zoomFactor === undefined) { + return + } + this.onWindowZoomFactorChanged(zoomFactor) + } + + /** + * On Windows OS, whenever a user toggles their zoom factor, chromium stores it + * in their `%AppData%/Roaming/GitHub Desktop/Preferences.js` denoted by the + * file path to the application. That file path contains the apps version. + * Thus, on every update, the users set zoom level gets reset as there is not + * defined value for the current app version. + * */ + private async getWindowZoomFactor() { + const zoomFactor = await getCurrentWindowZoomFactor() + // One is the default value, we only care about checking the locally stored + // value if it is one because that is the default value after an + // update + if (zoomFactor !== 1 || !__WIN32__) { + return zoomFactor + } + + const locallyStoredZoomFactor = getFloatNumber('zoom-factor') + if ( + locallyStoredZoomFactor !== undefined && + locallyStoredZoomFactor !== zoomFactor + ) { + setWindowZoomFactor(locallyStoredZoomFactor) + return locallyStoredZoomFactor + } + + return zoomFactor + } + + private onTokenInvalidated = (endpoint: 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.popupManager.isAPopupOpen) { + return + } + + // If the token was invalidated for an account, sign out from that account + this._removeAccount(account) + + this._showPopup({ + type: PopupType.InvalidatedToken, + account, + }) + } + + 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 + ): Promise { + const currentStep = await this.tutorialAssessor.getCurrentStep( + repository.isTutorialRepository, + this.repositoryStateCache.get(repository) + ) + // only emit an update if its changed + if (currentStep !== this.currentOnboardingTutorialStep) { + this.currentOnboardingTutorialStep = currentStep + log.info(`Current tutorial step is now ${currentStep}`) + this.recordTutorialStepCompleted(currentStep) + this.emitUpdate() + } + } + + private recordTutorialStepCompleted(step: TutorialStep): void { + if (!isValidTutorialStep(step)) { + return + } + + this.statsStore.recordHighestTutorialStepCompleted( + orderedTutorialSteps.indexOf(step) + ) + + switch (step) { + case TutorialStep.PickEditor: + // don't need to record anything for the first step + break + case TutorialStep.CreateBranch: + this.statsStore.recordTutorialEditorInstalled() + break + case TutorialStep.EditFile: + this.statsStore.recordTutorialBranchCreated() + break + case TutorialStep.MakeCommit: + this.statsStore.recordTutorialFileEdited() + break + case TutorialStep.PushBranch: + this.statsStore.recordTutorialCommitCreated() + break + case TutorialStep.OpenPullRequest: + this.statsStore.recordTutorialBranchPushed() + break + case TutorialStep.AllDone: + this.statsStore.recordTutorialPrCreated() + this.statsStore.recordTutorialCompleted() + break + default: + assertNever(step, 'Unaccounted for step type') + } + } + + public async _resumeTutorial(repository: Repository) { + this.tutorialAssessor.resumeTutorial() + await this.updateCurrentTutorialStep(repository) + } + + public async _pauseTutorial(repository: Repository) { + this.tutorialAssessor.pauseTutorial() + await this.updateCurrentTutorialStep(repository) + } + + /** Call via `Dispatcher` when the user opts to skip the pick editor step of the onboarding tutorial */ + public async _skipPickEditorTutorialStep(repository: Repository) { + this.tutorialAssessor.skipPickEditor() + await this.updateCurrentTutorialStep(repository) + } + + /** + * Call via `Dispatcher` when the user has either created a pull request or opts to + * skip the create pull request step of the onboarding tutorial + */ + public async _markPullRequestTutorialStepAsComplete(repository: Repository) { + this.tutorialAssessor.markPullRequestTutorialStepAsComplete() + await this.updateCurrentTutorialStep(repository) + } + + private wireupIpcEventHandlers() { + ipcRenderer.on('window-state-changed', (_, windowState) => { + this.windowState = windowState + this.emitUpdate() + }) + + ipcRenderer.on('zoom-factor-changed', (event: any, zoomFactor: number) => { + this.onWindowZoomFactorChanged(zoomFactor) + }) + + ipcRenderer.on('app-menu', (_, menu) => this.setAppMenu(menu)) + } + + private wireupStoreEventHandlers() { + this.gitHubUserStore.onDidUpdate(() => { + this.emitUpdate() + }) + + this.cloningRepositoriesStore.onDidUpdate(() => { + this.emitUpdate() + }) + + this.cloningRepositoriesStore.onDidError(e => this.emitError(e)) + + this.signInStore.onDidAuthenticate((account, method) => { + this._addAccount(account) + + if (this.showWelcomeFlow) { + this.statsStore.recordWelcomeWizardSignInMethod(method) + } + }) + this.signInStore.onDidUpdate(() => this.emitUpdate()) + this.signInStore.onDidError(error => this.emitError(error)) + + this.accountsStore.onDidUpdate(accounts => { + this.accounts = accounts + const endpointTokens = accounts.map( + ({ endpoint, token }) => ({ endpoint, token }) + ) + + updateAccounts(endpointTokens) + + this.emitUpdate() + }) + this.accountsStore.onDidError(error => this.emitError(error)) + + this.repositoriesStore.onDidUpdate(updateRepositories => { + this.repositories = updateRepositories + this.updateRepositorySelectionAfterRepositoriesChanged() + this.emitUpdate() + }) + + this.pullRequestCoordinator.onPullRequestsChanged((repo, pullRequests) => + this.onPullRequestChanged(repo, pullRequests) + ) + this.pullRequestCoordinator.onIsLoadingPullRequests( + (repository, isLoadingPullRequests) => { + this.repositoryStateCache.updateBranchesState(repository, () => { + return { isLoadingPullRequests } + }) + this.emitUpdate() + } + ) + + this.apiRepositoriesStore.onDidUpdate(() => this.emitUpdate()) + this.apiRepositoriesStore.onDidError(error => this.emitError(error)) + } + + /** Load the emoji from disk. */ + public async loadEmoji() { + const rootDir = await getAppPath() + readEmoji(rootDir) + .then(emoji => { + this.emoji = emoji + this.emitUpdate() + }) + .catch(err => { + log.warn(`Unexpected issue when trying to read emoji into memory`, err) + }) + } + + protected emitUpdate() { + // If the window is hidden then we won't get an animation frame, but there + // may still be work we wanna do in response to the state change. So + // immediately emit the update. + if (this.windowState === 'hidden') { + this.emitUpdateNow() + return + } + + if (this.emitQueued) { + return + } + + this.emitQueued = true + + window.requestAnimationFrame(() => { + this.emitUpdateNow() + }) + } + + private emitUpdateNow() { + this.emitQueued = false + const state = this.getState() + + super.emitUpdate(state) + updateMenuState(state, this.appMenu) + } + + /** + * Called when we have reason to suspect that the zoom factor + * has changed. Note that this doesn't necessarily mean that it + * has changed with regards to our internal state which is why + * we double check before emitting an update. + */ + private onWindowZoomFactorChanged(zoomFactor: number) { + const current = this.windowZoomFactor + this.windowZoomFactor = zoomFactor + + if (zoomFactor !== current) { + setNumber('zoom-factor', zoomFactor) + this.updateResizableConstraints() + this.emitUpdate() + } + } + + private getSelectedState(): PossibleSelections | null { + const repository = this.selectedRepository + if (!repository) { + return null + } + + if (repository instanceof CloningRepository) { + const progress = + this.cloningRepositoriesStore.getRepositoryState(repository) + if (!progress) { + return null + } + + return { + type: SelectionType.CloningRepository, + repository, + progress, + } + } + + if (repository.missing) { + return { type: SelectionType.MissingRepository, repository } + } + + return { + type: SelectionType.Repository, + repository, + state: this.repositoryStateCache.get(repository), + } + } + + public getState(): IAppState { + const repositories = [ + ...this.repositories, + ...this.cloningRepositoriesStore.repositories, + ] + + return { + accounts: this.accounts, + repositories, + recentRepositories: this.recentRepositories, + localRepositoryStateLookup: this.localRepositoryStateLookup, + windowState: this.windowState, + windowZoomFactor: this.windowZoomFactor, + appIsFocused: this.appIsFocused, + selectedState: this.getSelectedState(), + signInState: this.signInStore.getState(), + currentPopup: this.popupManager.currentPopup, + allPopups: this.popupManager.allPopups, + currentFoldout: this.currentFoldout, + errorCount: this.popupManager.getPopupsOfType(PopupType.Error).length, + showWelcomeFlow: this.showWelcomeFlow, + focusCommitMessage: this.focusCommitMessage, + emoji: this.emoji, + sidebarWidth: this.sidebarWidth, + commitSummaryWidth: this.commitSummaryWidth, + stashedFilesWidth: this.stashedFilesWidth, + pullRequestFilesListWidth: this.pullRequestFileListWidth, + appMenuState: this.appMenu ? this.appMenu.openMenus : [], + highlightAccessKeys: this.highlightAccessKeys, + isUpdateAvailableBannerVisible: this.isUpdateAvailableBannerVisible, + isUpdateShowcaseVisible: this.isUpdateShowcaseVisible, + currentBanner: this.currentBanner, + askToMoveToApplicationsFolderSetting: + this.askToMoveToApplicationsFolderSetting, + askForConfirmationOnRepositoryRemoval: + this.askForConfirmationOnRepositoryRemoval, + askForConfirmationOnDiscardChanges: this.confirmDiscardChanges, + askForConfirmationOnDiscardChangesPermanently: + this.confirmDiscardChangesPermanently, + askForConfirmationOnDiscardStash: this.confirmDiscardStash, + askForConfirmationOnCheckoutCommit: this.confirmCheckoutCommit, + askForConfirmationOnForcePush: this.askForConfirmationOnForcePush, + askForConfirmationOnUndoCommit: this.confirmUndoCommit, + 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, + resolvedExternalEditor: this.resolvedExternalEditor, + selectedCloneRepositoryTab: this.selectedCloneRepositoryTab, + selectedBranchesTab: this.selectedBranchesTab, + selectedTheme: this.selectedTheme, + currentTheme: this.currentTheme, + apiRepositories: this.apiRepositoriesStore.getState(), + useWindowsOpenSSH: this.useWindowsOpenSSH, + optOutOfUsageTracking: this.statsStore.getOptOut(), + currentOnboardingTutorialStep: this.currentOnboardingTutorialStep, + repositoryIndicatorsEnabled: this.repositoryIndicatorsEnabled, + commitSpellcheckEnabled: this.commitSpellcheckEnabled, + currentDragElement: this.currentDragElement, + lastThankYou: this.lastThankYou, + showCIStatusPopover: this.showCIStatusPopover, + notificationsEnabled: getNotificationsEnabled(), + pullRequestSuggestedNextAction: this.pullRequestSuggestedNextAction, + resizablePaneActive: this.resizablePaneActive, + cachedRepoRulesets: this.cachedRepoRulesets, + } + } + + private onGitStoreUpdated(repository: Repository, gitStore: GitStore) { + const prevRepositoryState = this.repositoryStateCache.get(repository) + + this.repositoryStateCache.updateBranchesState(repository, state => { + let { currentPullRequest } = state + const { tip, currentRemote: remote } = gitStore + + // If the tip has changed we need to re-evaluate whether or not the + // current pull request is still valid. Note that we're not using + // updateCurrentPullRequest here because we know for certain that + // the list of open pull requests haven't changed so we can find + // a happy path where the tip has changed but the current PR is + // still valid which doesn't require us to iterate through the + // list of open PRs. + if ( + !tipEquals(state.tip, tip) || + !remoteEquals(prevRepositoryState.remote, remote) + ) { + if (tip.kind !== TipState.Valid || remote === null) { + // The tip isn't a branch so or the current branch doesn't have a remote + // so there can't be a current pull request. + currentPullRequest = null + } else { + const { branch } = tip + + if ( + !currentPullRequest || + !isPullRequestAssociatedWithBranch( + branch, + currentPullRequest, + remote + ) + ) { + // Either we don't have a current pull request or the current pull + // request no longer matches the tip, let's go hunting for a new one. + const prs = state.openPullRequests + currentPullRequest = findAssociatedPullRequest(branch, prs, remote) + } + + if ( + tip.kind === TipState.Valid && + state.tip.kind === TipState.Valid && + tip.branch.name !== state.tip.branch.name + ) { + this.refreshBranchProtectionState(repository) + } + } + } + + return { + tip: gitStore.tip, + defaultBranch: gitStore.defaultBranch, + upstreamDefaultBranch: gitStore.upstreamDefaultBranch, + allBranches: gitStore.allBranches, + recentBranches: gitStore.recentBranches, + pullWithRebase: gitStore.pullWithRebase, + currentPullRequest, + } + }) + + let selectWorkingDirectory = false + let selectStashEntry = false + + this.repositoryStateCache.updateChangesState(repository, state => { + const stashEntry = gitStore.currentBranchStashEntry + + // Figure out what selection changes we need to make as a result of this + // change. + if (state.selection.kind === ChangesSelectionKind.Stash) { + if (state.stashEntry !== null) { + if (stashEntry === null) { + // We're showing a stash now and the stash entry has just disappeared + // so we need to switch back over to the working directory. + selectWorkingDirectory = true + } else if (state.stashEntry.stashSha !== stashEntry.stashSha) { + // The current stash entry has changed from underneath so we must + // ensure we have a valid selection. + selectStashEntry = true + } + } + } + + return { + commitMessage: gitStore.commitMessage, + showCoAuthoredBy: gitStore.showCoAuthoredBy, + coAuthors: gitStore.coAuthors, + stashEntry, + } + }) + + this.repositoryStateCache.update(repository, () => ({ + commitLookup: gitStore.commitLookup, + localCommitSHAs: gitStore.localCommitSHAs, + localTags: gitStore.localTags, + aheadBehind: gitStore.aheadBehind, + tagsToPush: gitStore.tagsToPush, + remote: gitStore.currentRemote, + lastFetched: gitStore.lastFetched, + })) + + // _selectWorkingDirectoryFiles and _selectStashedFile will + // emit updates by themselves. + if (selectWorkingDirectory) { + this._selectWorkingDirectoryFiles(repository) + } else if (selectStashEntry) { + this._selectStashedFile(repository) + } else { + this.emitUpdate() + } + } + + private clearBranchProtectionState(repository: Repository) { + this.repositoryStateCache.updateChangesState(repository, () => ({ + currentBranchProtected: false, + currentRepoRulesInfo: new RepoRulesInfo(), + })) + this.emitUpdate() + } + + private async refreshBranchProtectionState(repository: Repository) { + const { tip, currentRemote } = this.gitStoreCache.get(repository) + + if (tip.kind !== TipState.Valid || repository.gitHubRepository === null) { + return + } + + const gitHubRepo = repository.gitHubRepository + const branchName = findRemoteBranchName(tip, currentRemote, gitHubRepo) + + if (branchName !== null) { + const account = getAccountForEndpoint(this.accounts, gitHubRepo.endpoint) + + if (account === null) { + return + } + + // If the user doesn't have write access to the repository + // it doesn't matter if the branch is protected or not and + // we can avoid the API call. See the `showNoWriteAccess` + // prop in the `CommitMessage` component where we specifically + // test for this scenario and show a message specifically + // about write access before showing a branch protection + // warning. + if (!hasWritePermission(gitHubRepo)) { + this.repositoryStateCache.updateChangesState(repository, () => ({ + currentBranchProtected: false, + currentRepoRulesInfo: new RepoRulesInfo(), + })) + this.emitUpdate() + return + } + + const name = gitHubRepo.name + const owner = gitHubRepo.owner.login + const api = API.fromAccount(account) + + 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 = parseRepoRules( + branchRules, + this.cachedRepoRulesets + ) + } + } + + 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: [], + file: null, + changesetData: { files: [], linesAdded: 0, linesDeleted: 0 }, + diff: null, + })) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _changeCommitSelection( + repository: Repository, + shas: ReadonlyArray, + isContiguous: boolean + ): void { + const { commitSelection, commitLookup, compareState } = + this.repositoryStateCache.get(repository) + + if ( + commitSelection.shas.length === shas.length && + commitSelection.shas.every((sha, i) => sha === shas[i]) + ) { + 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 }, + diff: null, + })) + + this.emitUpdate() + } + + private recordMultiCommitDiff( + shas: ReadonlyArray, + shasInDiff: ReadonlyArray, + compareState: ICompareState + ) { + const isHistoryTab = compareState.formState.kind === HistoryTabMode.History + + if (isHistoryTab) { + this.statsStore.recordMultiCommitDiffFromHistoryCount() + } else { + this.statsStore.recordMultiCommitDiffFromCompareCount() + } + + const hasUnreachableCommitWarning = !shas.every(s => shasInDiff.includes(s)) + + if (hasUnreachableCommitWarning) { + this.statsStore.recordMultiCommitDiffWithUnreachableCommitWarningCount() + } + } + + /** 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 + ) { + const state = this.repositoryStateCache.get(repository) + let selectedSHA = + state.commitSelection.shas.length > 0 + ? state.commitSelection.shas[0] + : null + + if (selectedSHA != null) { + const index = commitSHAs.findIndex(sha => sha === selectedSHA) + if (index < 0) { + // selected SHA is not in this list + // -> clear the selection in the app state + selectedSHA = null + this.clearSelectedCommit(repository) + } + } + + if (selectedSHA === null && commitSHAs.length > 0) { + this._changeCommitSelection(repository, [commitSHAs[0]], true) + this._loadChangedFilesForCurrentSelection(repository) + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _initializeCompare( + repository: Repository, + initialAction?: CompareAction + ) { + const state = this.repositoryStateCache.get(repository) + + const { branchesState, compareState } = state + const { tip } = branchesState + const currentBranch = tip.kind === TipState.Valid ? tip.branch : null + + const branches = branchesState.allBranches.filter( + b => b.name !== currentBranch?.name && !b.isDesktopForkRemoteBranch + ) + const recentBranches = currentBranch + ? branchesState.recentBranches.filter(b => b.name !== currentBranch.name) + : branchesState.recentBranches + + const cachedDefaultBranch = branchesState.defaultBranch + + // only include the default branch when comparing if the user is not on the default branch + // and it also exists in the repository + const defaultBranch = + currentBranch != null && + cachedDefaultBranch != null && + currentBranch.name !== cachedDefaultBranch.name + ? cachedDefaultBranch + : null + + this.repositoryStateCache.updateCompareState(repository, () => ({ + branches, + recentBranches, + defaultBranch, + })) + + const cachedState = compareState.formState + const action = + initialAction != null ? initialAction : getInitialAction(cachedState) + this._executeCompare(repository, action) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _executeCompare( + repository: Repository, + action: CompareAction + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + const kind = action.kind + + if (action.kind === HistoryTabMode.History) { + const { tip } = gitStore + + let currentSha: string | null = null + + if (tip.kind === TipState.Valid) { + currentSha = tip.branch.tip.sha + } else if (tip.kind === TipState.Detached) { + currentSha = tip.currentSha + } + + const { compareState } = this.repositoryStateCache.get(repository) + const { formState, commitSHAs } = compareState + const previousTip = compareState.tip + + const tipIsUnchanged = + currentSha !== null && + previousTip !== null && + currentSha === previousTip + + if ( + tipIsUnchanged && + formState.kind === HistoryTabMode.History && + commitSHAs.length > 0 + ) { + // don't refresh the history view here because we know nothing important + // has changed and we don't want to rebuild this state + return + } + + // load initial group of commits for current branch + const commits = await gitStore.loadCommitBatch('HEAD', 0) + + if (commits === null) { + return + } + + const newState: IDisplayHistory = { + kind: HistoryTabMode.History, + } + + this.repositoryStateCache.updateCompareState(repository, () => ({ + tip: currentSha, + formState: newState, + commitSHAs: commits, + filterText: '', + showBranchList: false, + })) + this.updateOrSelectFirstCommit(repository, commits) + + return this.emitUpdate() + } + + if (action.kind === HistoryTabMode.Compare) { + return this.updateCompareToBranch(repository, action) + } + + return assertNever(action, `Unknown action: ${kind}`) + } + + private async updateCompareToBranch( + repository: Repository, + action: ICompareToBranch + ) { + const gitStore = this.gitStoreCache.get(repository) + + const comparisonBranch = action.branch + const compare = await gitStore.getCompareCommits( + comparisonBranch, + action.comparisonMode + ) + + this.statsStore.recordBranchComparison() + const { branchesState } = this.repositoryStateCache.get(repository) + + if ( + branchesState.defaultBranch !== null && + comparisonBranch.name === branchesState.defaultBranch.name + ) { + this.statsStore.recordDefaultBranchComparison() + } + + if (compare == null) { + return + } + + const { ahead, behind } = compare + const aheadBehind = { ahead, behind } + + const commitSHAs = compare.commits.map(commit => commit.sha) + + const newState: ICompareBranch = { + kind: HistoryTabMode.Compare, + comparisonBranch, + comparisonMode: action.comparisonMode, + aheadBehind, + } + + this.repositoryStateCache.updateCompareState(repository, () => ({ + formState: newState, + filterText: comparisonBranch.name, + commitSHAs, + })) + + const tip = gitStore.tip + + const loadingMerge: MergeTreeResult = { + kind: ComputedAction.Loading, + } + + this.repositoryStateCache.updateCompareState(repository, () => ({ + mergeStatus: loadingMerge, + })) + + this.emitUpdate() + + this.updateOrSelectFirstCommit(repository, commitSHAs) + + if (this.currentMergeTreePromise != null) { + return this.currentMergeTreePromise + } + + if (tip.kind === TipState.Valid && aheadBehind.behind > 0) { + this.currentMergeTreePromise = this.setupMergabilityPromise( + repository, + tip.branch, + action.branch + ) + .then(mergeStatus => { + this.repositoryStateCache.updateCompareState(repository, () => ({ + mergeStatus, + })) + + this.emitUpdate() + }) + .finally(() => { + this.currentMergeTreePromise = null + }) + + return this.currentMergeTreePromise + } else { + this.repositoryStateCache.updateCompareState(repository, () => ({ + mergeStatus: null, + })) + + return this.emitUpdate() + } + } + + 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, + newState: Pick + ) { + this.repositoryStateCache.updateCompareState(repository, state => { + return merge(state, newState) + }) + + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _loadNextCommitBatch(repository: Repository): Promise { + const gitStore = this.gitStoreCache.get(repository) + + const state = this.repositoryStateCache.get(repository) + const { formState } = state.compareState + if (formState.kind === HistoryTabMode.History) { + const commits = state.compareState.commitSHAs + + const newCommits = await gitStore.loadCommitBatch('HEAD', commits.length) + if (newCommits == null) { + return + } + + this.repositoryStateCache.updateCompareState(repository, () => ({ + commitSHAs: commits.concat(newCommits), + })) + this.emitUpdate() + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _loadChangedFilesForCurrentSelection( + repository: Repository + ): Promise { + const state = this.repositoryStateCache.get(repository) + const { commitSelection } = state + const { shas: currentSHAs, isContiguous } = commitSelection + 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, + this.orderShasByHistory(repository, currentSHAs) + ) + : getChangedFiles(repository, currentSHAs[0]) + ) + if (!changesetData) { + return + } + + // The selection could have changed between when we started loading the + // changed files and we finished. We might wanna store the changed files per + // SHA/path. + if ( + commitSelection.shas.length !== currentSHAs.length || + !commitSelection.shas.every((sha, i) => sha === currentSHAs[i]) + ) { + return + } + + // if we're selecting a commit for the first time, we should select the + // first file in the commit and render the diff immediately + + const noFileSelected = commitSelection.file === null + + const firstFileOrDefault = + noFileSelected && changesetData.files.length + ? changesetData.files[0] + : commitSelection.file + + this.repositoryStateCache.updateCommitSelection(repository, () => ({ + file: firstFileOrDefault, + changesetData, + diff: null, + })) + + this.emitUpdate() + + if (firstFileOrDefault !== null) { + this._changeFileSelection(repository, firstFileOrDefault) + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _setRepositoryFilterText(text: string): Promise { + this.repositoryFilterText = text + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _changeFileSelection( + repository: Repository, + file: CommittedFileChange + ): Promise { + this.repositoryStateCache.updateCommitSelection(repository, () => ({ + file, + diff: null, + })) + this.emitUpdate() + + const stateBeforeLoad = this.repositoryStateCache.get(repository) + const { shas, isContiguous } = stateBeforeLoad.commitSelection + + if (shas.length === 0) { + if (__DEV__) { + throw new Error( + "No currently selected sha yet we've been asked to switch file selection" + ) + } else { + return + } + } + + if (shas.length > 1 && !isContiguous) { + return + } + + const diff = + shas.length > 1 + ? await getCommitRangeDiff( + repository, + file, + this.orderShasByHistory(repository, shas), + this.hideWhitespaceInHistoryDiff + ) + : await getCommitDiff( + repository, + file, + shas[0], + this.hideWhitespaceInHistoryDiff + ) + + const stateAfterLoad = this.repositoryStateCache.get(repository) + const { shas: shasAfter } = stateAfterLoad.commitSelection + // A whole bunch of things could have happened since we initiated the diff load + if ( + shasAfter.length !== shas.length || + !shas.every((sha, i) => sha === shasAfter[i]) + ) { + return + } + + if (!stateAfterLoad.commitSelection.file) { + return + } + if (stateAfterLoad.commitSelection.file.id !== file.id) { + return + } + + this.repositoryStateCache.updateCommitSelection(repository, () => ({ + diff, + })) + + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _selectRepository( + repository: Repository | CloningRepository | null + ): Promise { + const previouslySelectedRepository = this.selectedRepository + + // do this quick check to see if we have a tutorial repository + // cause if its not we can quickly hide the tutorial pane + // in the first `emitUpdate` below + const previouslyInTutorial = + this.currentOnboardingTutorialStep !== TutorialStep.NotApplicable + if ( + previouslyInTutorial && + (!(repository instanceof Repository) || !repository.isTutorialRepository) + ) { + this.currentOnboardingTutorialStep = TutorialStep.NotApplicable + } + + this.selectedRepository = repository + + this.emitUpdate() + this.stopBackgroundFetching() + this.stopPullRequestUpdater() + this._clearBanner() + this.stopBackgroundPruner() + + if (repository == null) { + return Promise.resolve(null) + } + + if (!(repository instanceof Repository)) { + return Promise.resolve(null) + } + + setNumber(LastSelectedRepositoryIDKey, repository.id) + + const previousRepositoryId = previouslySelectedRepository + ? previouslySelectedRepository.id + : null + + this.updateRecentRepositories(previousRepositoryId, repository.id) + + // if repository might be marked missing, try checking if it has been restored + const refreshedRepository = await this.recoverMissingRepository(repository) + if (refreshedRepository.missing) { + // as the repository is no longer found on disk, cleaning this up + // ensures we don't accidentally run any Git operations against the + // wrong location if the user then relocates the `.git` folder elsewhere + this.gitStoreCache.remove(repository) + return Promise.resolve(null) + } + + // This is now purely for metrics collection for `commitsToRepositoryWithBranchProtections` + // Understanding how many users actually contribute to repos with branch protections gives us + // insight into who our users are and what kinds of work they do + this.updateBranchProtectionsFromAPI(repository) + + this.notificationsStore.selectRepository(repository) + + return this._selectRepositoryRefreshTasks( + refreshedRepository, + previouslySelectedRepository + ) + } + + // update the stored list of recently opened repositories + private updateRecentRepositories( + previousRepositoryId: number | null, + currentRepositoryId: number + ) { + // No need to update the recent repositories if the selected repository is + // the same as the old one (this could happen when the alias of the selected + // repository is changed). + if (previousRepositoryId === currentRepositoryId) { + return + } + + const recentRepositories = getNumberArray(RecentRepositoriesKey).filter( + el => el !== currentRepositoryId && el !== previousRepositoryId + ) + if (previousRepositoryId !== null) { + recentRepositories.unshift(previousRepositoryId) + } + const slicedRecentRepositories = recentRepositories.slice( + 0, + RecentRepositoriesLength + ) + setNumberArray(RecentRepositoriesKey, slicedRecentRepositories) + this.recentRepositories = slicedRecentRepositories + this.notificationsStore.setRecentRepositories( + this.repositories.filter(r => this.recentRepositories.includes(r.id)) + ) + this.emitUpdate() + } + + // finish `_selectRepository`s refresh tasks + private async _selectRepositoryRefreshTasks( + repository: Repository, + previouslySelectedRepository: Repository | CloningRepository | null + ): Promise { + this._refreshRepository(repository) + + if (isRepositoryWithGitHubRepository(repository)) { + // Load issues from the upstream or fork depending + // on workflow preferences. + const ghRepo = getNonForkGitHubRepository(repository) + + this._refreshIssues(ghRepo) + this.refreshMentionables(ghRepo) + + this.pullRequestCoordinator.getAllPullRequests(repository).then(prs => { + this.onPullRequestChanged(repository, prs) + }) + } + + // The selected repository could have changed while we were refreshing. + if (this.selectedRepository !== repository) { + return null + } + + // "Clone in Desktop" from a cold start can trigger this twice, and + // for edge cases where _selectRepository is re-entract, calling this here + // ensures we clean up the existing background fetcher correctly (if set) + this.stopBackgroundFetching() + this.stopPullRequestUpdater() + this.stopBackgroundPruner() + + this.startBackgroundFetching(repository, !previouslySelectedRepository) + this.startPullRequestUpdater(repository) + + this.startBackgroundPruner(repository) + + this.addUpstreamRemoteIfNeeded(repository) + + return this.repositoryWithRefreshedGitHubRepository(repository) + } + + private stopBackgroundPruner() { + const pruner = this.currentBranchPruner + + if (pruner !== null) { + pruner.stop() + this.currentBranchPruner = null + } + } + + private startBackgroundPruner(repository: Repository) { + if (this.currentBranchPruner !== null) { + fatalError( + `A branch pruner is already active and cannot start updating on ${repository.name}` + ) + } + + const pruner = new BranchPruner( + repository, + this.gitStoreCache, + this.repositoriesStore, + this.repositoryStateCache, + repository => this._refreshRepository(repository) + ) + this.currentBranchPruner = pruner + this.currentBranchPruner.start() + } + + public async _refreshIssues(repository: GitHubRepository) { + const user = getAccountForEndpoint(this.accounts, repository.endpoint) + if (!user) { + return + } + + try { + await this.issuesStore.refreshIssues(repository, user) + } catch (e) { + log.warn(`Unable to fetch issues for ${repository.fullName}`, e) + } + } + + private stopBackgroundFetching() { + const backgroundFetcher = this.currentBackgroundFetcher + if (backgroundFetcher) { + backgroundFetcher.stop() + this.currentBackgroundFetcher = null + } + } + + private refreshMentionables(repository: GitHubRepository) { + const account = getAccountForEndpoint(this.accounts, repository.endpoint) + if (!account) { + return + } + + this.gitHubUserStore.updateMentionables(repository, account) + } + + private startPullRequestUpdater(repository: Repository) { + // We don't want to run the pull request updater when the app is in + // the background. + if (this.appIsFocused && isRepositoryWithGitHubRepository(repository)) { + const account = getAccountForRepository(this.accounts, repository) + if (account !== null) { + return this.pullRequestCoordinator.startPullRequestUpdater( + repository, + account + ) + } + } + // we always want to stop the current one, to be safe + this.pullRequestCoordinator.stopPullRequestUpdater() + } + + private stopPullRequestUpdater() { + this.pullRequestCoordinator.stopPullRequestUpdater() + } + + public async fetchPullRequest(repoUrl: string, pr: string) { + const endpoint = getEndpointForRepository(repoUrl) + const account = getAccountForEndpoint(this.accounts, endpoint) + + if (account) { + const api = API.fromAccount(account) + const remoteUrl = parseRemote(repoUrl) + if (remoteUrl && remoteUrl.owner && remoteUrl.name) { + return await api.fetchPullRequest(remoteUrl.owner, remoteUrl.name, pr) + } + } + return null + } + + private async shouldBackgroundFetch( + repository: Repository, + lastPush: Date | null + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + const lastFetched = await gitStore.updateLastFetched() + + if (lastFetched === null) { + return true + } + + const now = new Date() + const timeSinceFetch = now.getTime() - lastFetched.getTime() + const repoName = nameOf(repository) + if (timeSinceFetch < BackgroundFetchMinimumInterval) { + const timeInSeconds = Math.floor(timeSinceFetch / 1000) + + log.debug( + `Skipping background fetch as '${repoName}' was fetched ${timeInSeconds}s ago` + ) + return false + } + + if (lastPush === null) { + return true + } + + // we should fetch if the last push happened after the last fetch + if (lastFetched < lastPush) { + return true + } + + log.debug( + `Skipping background fetch since nothing has been pushed to '${repoName}' since the last fetch at ${lastFetched}` + ) + + return false + } + + private startBackgroundFetching( + repository: Repository, + withInitialSkew: boolean + ) { + if (this.currentBackgroundFetcher) { + fatalError( + `We should only have on background fetcher active at once, but we're trying to start background fetching on ${repository.name} while another background fetcher is still active!` + ) + } + + const account = getAccountForRepository(this.accounts, repository) + if (!account) { + return + } + + if (!repository.gitHubRepository) { + return + } + + // Todo: add logic to background checker to check the API before fetching + // similar to what's being done in `refreshAllIndicators` + const fetcher = new BackgroundFetcher( + repository, + account, + r => this.performFetch(r, account, FetchType.BackgroundTask), + r => this.shouldBackgroundFetch(r, null) + ) + fetcher.start(withInitialSkew) + this.currentBackgroundFetcher = fetcher + } + + /** Load the initial state for the app. */ + public async loadInitialState() { + const [accounts, repositories] = await Promise.all([ + this.accountsStore.getAll(), + this.repositoriesStore.getAll(), + ]) + + log.info( + `[AppStore] loading ${repositories.length} repositories from store` + ) + accounts.forEach(a => { + log.info(`[AppStore] found account: ${a.login} (${a.name})`) + }) + + this.accounts = accounts + this.repositories = repositories + + this.updateRepositorySelectionAfterRepositoriesChanged() + + this.sidebarWidth = constrain( + getNumber(sidebarWidthConfigKey, defaultSidebarWidth) + ) + this.commitSummaryWidth = constrain( + getNumber(commitSummaryWidthConfigKey, defaultCommitSummaryWidth) + ) + this.stashedFilesWidth = constrain( + getNumber(stashedFilesWidthConfigKey, defaultStashedFilesWidth) + ) + this.pullRequestFileListWidth = constrain( + getNumber(pullRequestFileListConfigKey, defaultPullRequestFileListWidth) + ) + + this.updateResizableConstraints() + // TODO: Initiliaze here for now... maybe move to dialog mounting + this.updatePullRequestResizableConstraints() + + this.askToMoveToApplicationsFolderSetting = getBoolean( + askToMoveToApplicationsFolderKey, + askToMoveToApplicationsFolderDefault + ) + + this.askForConfirmationOnRepositoryRemoval = getBoolean( + confirmRepoRemovalKey, + confirmRepoRemovalDefault + ) + + this.confirmDiscardChanges = getBoolean( + confirmDiscardChangesKey, + confirmDiscardChangesDefault + ) + + this.confirmDiscardChangesPermanently = getBoolean( + confirmDiscardChangesPermanentlyKey, + confirmDiscardChangesPermanentlyDefault + ) + + this.confirmDiscardStash = getBoolean( + confirmDiscardStashKey, + confirmDiscardStashDefault + ) + + this.confirmCheckoutCommit = getBoolean( + confirmCheckoutCommitKey, + confirmCheckoutCommitDefault + ) + + this.askForConfirmationOnForcePush = getBoolean( + confirmForcePushKey, + askForConfirmationOnForcePushDefault + ) + + this.confirmUndoCommit = getBoolean( + confirmUndoCommitKey, + confirmUndoCommitDefault + ) + + this.uncommittedChangesStrategy = + getEnum(uncommittedChangesStrategyKey, UncommittedChangesStrategy) ?? + defaultUncommittedChangesStrategy + + this.updateSelectedExternalEditor( + await this.lookupSelectedExternalEditor() + ).catch(e => log.error('Failed resolving current editor at startup', e)) + + const shellValue = localStorage.getItem(shellKey) + this.selectedShell = shellValue ? parseShell(shellValue) : DefaultShell + + this.updateMenuLabelsForSelectedRepository() + + const imageDiffTypeValue = localStorage.getItem(imageDiffTypeKey) + this.imageDiffType = + imageDiffTypeValue === null + ? imageDiffTypeDefault + : parseInt(imageDiffTypeValue) + + this.hideWhitespaceInChangesDiff = getBoolean( + hideWhitespaceInChangesDiffKey, + false + ) + this.hideWhitespaceInHistoryDiff = getBoolean( + hideWhitespaceInHistoryDiffKey, + false + ) + this.hideWhitespaceInPullRequestDiff = getBoolean( + hideWhitespaceInPullRequestDiffKey, + false + ) + this.commitSpellcheckEnabled = getBoolean( + commitSpellcheckEnabledKey, + commitSpellcheckEnabledDefault + ) + this.showSideBySideDiff = getShowSideBySideDiff() + + this.selectedTheme = getPersistedThemeName() + // Make sure the persisted theme is applied + setPersistedTheme(this.selectedTheme) + + this.currentTheme = await getCurrentlyAppliedTheme() + + themeChangeMonitor.onThemeChanged(theme => { + this.currentTheme = theme + this.emitUpdate() + }) + + this.lastThankYou = getObject(lastThankYouKey) + + this.pullRequestSuggestedNextAction = + getEnum( + pullRequestSuggestedNextActionKey, + PullRequestSuggestedNextAction + ) ?? defaultPullRequestSuggestedNextAction + + this.emitUpdateNow() + + this.accountsStore.refresh() + } + + /** + * Calculate the constraints of our resizable panes whenever the window + * dimensions change. + */ + private updateResizableConstraints() { + // 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 + // off screen. + const toolbarButtonsWidth = 460 + + // Start with all the available width + let available = window.innerWidth + + // Working our way from left to right (i.e. giving priority to the leftmost + // pane when we need to constrain the width) + // + // 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 + this.sidebarWidth = constrain(this.sidebarWidth, 220, maxSidebarWidth) + + // Now calculate the width we have left to distribute for the other panes + available -= clamp(this.sidebarWidth) + + // 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 filesMax = available - diffPaneMinWidth + + this.commitSummaryWidth = constrain(this.commitSummaryWidth, 100, filesMax) + this.stashedFilesWidth = constrain(this.stashedFilesWidth, 100, filesMax) + } + + /** + * 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( + selectedEditor: string | null + ): Promise { + this.selectedExternalEditor = selectedEditor + + // Make sure we keep the resolved (cached) editor + // in sync when the user changes their editor choice. + return this._resolveCurrentEditor() + } + + private async lookupSelectedExternalEditor(): Promise { + const editors = (await getAvailableEditors()).map(found => found.editor) + + const value = localStorage.getItem(externalEditorKey) + // ensure editor is still installed + if (value && editors.includes(value)) { + return value + } + + if (editors.length) { + const value = editors[0] + // store this value to avoid the lookup next time + localStorage.setItem(externalEditorKey, value) + return value + } + + return null + } + + /** + * Update menu labels for the selected repository. + * + * If selected repository type is a `CloningRepository` or + * `MissingRepository`, the menu labels will be updated but they will lack + * the expected `IRepositoryState` and revert to the default values. + */ + private updateMenuLabelsForSelectedRepository() { + const { selectedState } = this.getState() + + if ( + selectedState !== null && + selectedState.type === SelectionType.Repository + ) { + this.updateMenuItemLabels(selectedState.state) + } else { + this.updateMenuItemLabels(null) + } + } + + /** + * Update the menus in the main process using the provided repository state + * + * @param state the current repository state, or `null` if the repository is + * being cloned or is missing + */ + private updateMenuItemLabels(state: IRepositoryState | null) { + const { + selectedShell, + selectedRepository, + selectedExternalEditor, + askForConfirmationOnRepositoryRemoval, + askForConfirmationOnForcePush, + } = this + + const labels: MenuLabelsEvent = { + selectedShell, + selectedExternalEditor, + askForConfirmationOnRepositoryRemoval, + askForConfirmationOnForcePush, + } + + if (state === null) { + updatePreferredAppMenuItemLabels(labels) + return + } + + const { changesState, branchesState, aheadBehind } = state + const { currentPullRequest } = branchesState + + let contributionTargetDefaultBranch: string | undefined + if (selectedRepository instanceof Repository) { + contributionTargetDefaultBranch = + findContributionTargetDefaultBranch(selectedRepository, branchesState) + ?.name ?? undefined + } + + // 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 + + const askForConfirmationWhenStashingAllChanges = + changesState.stashEntry !== null + + updatePreferredAppMenuItemLabels({ + ...labels, + contributionTargetDefaultBranch, + isForcePushForCurrentRepository, + isStashedChangesVisible, + hasCurrentPullRequest: currentPullRequest !== null, + askForConfirmationWhenStashingAllChanges, + }) + } + + private updateRepositorySelectionAfterRepositoriesChanged() { + const selectedRepository = this.selectedRepository + let newSelectedRepository: Repository | CloningRepository | null = + this.selectedRepository + if (selectedRepository) { + const r = + this.repositories.find( + r => + r.constructor === selectedRepository.constructor && + r.id === selectedRepository.id + ) || null + + newSelectedRepository = r + } + + if (newSelectedRepository === null && this.repositories.length > 0) { + const lastSelectedID = getNumber(LastSelectedRepositoryIDKey, 0) + if (lastSelectedID > 0) { + newSelectedRepository = + this.repositories.find(r => r.id === lastSelectedID) || null + } + + if (!newSelectedRepository) { + newSelectedRepository = this.repositories[0] + } + } + + const repositoryChanged = + (selectedRepository && + newSelectedRepository && + selectedRepository.hash !== newSelectedRepository.hash) || + (selectedRepository && !newSelectedRepository) || + (!selectedRepository && newSelectedRepository) + if (repositoryChanged) { + this._selectRepository(newSelectedRepository) + this.emitUpdate() + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _loadStatus( + repository: Repository, + clearPartialState: boolean = false + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + const status = await gitStore.loadStatus() + + if (status === null) { + return null + } + + this.repositoryStateCache.updateChangesState(repository, state => + updateChangedFiles(state, status, clearPartialState) + ) + + this.repositoryStateCache.updateChangesState(repository, state => ({ + conflictState: updateConflictState(state, status, this.statsStore), + })) + + this.updateMultiCommitOperationConflictsIfFound(repository) + await this.initializeMultiCommitOperationIfConflictsFound( + repository, + status + ) + + if (this.selectedRepository === repository) { + this._triggerConflictsFlow(repository, status) + } + + this.emitUpdate() + + this.updateChangesWorkingDirectoryDiff(repository) + + return status + } + + /** + * This method is to initialize a multi commit operation state on app load + * if conflicts are found but not multi commmit operation exists. + */ + private async initializeMultiCommitOperationIfConflictsFound( + repository: Repository, + status: IStatusResult + ) { + const state = this.repositoryStateCache.get(repository) + const { + changesState: { conflictState }, + multiCommitOperationState, + branchesState, + } = state + + if (conflictState === null) { + this.clearConflictsFlowVisuals(state) + return + } + + if (multiCommitOperationState !== null) { + return + } + + let operationDetail: MultiCommitOperationDetail + let targetBranch: Branch | null = null + let commits: ReadonlyArray = [] + let originalBranchTip: string | null = '' + let progress: IMultiCommitOperationProgress | undefined = undefined + + if (branchesState.tip.kind === TipState.Valid) { + targetBranch = branchesState.tip.branch + originalBranchTip = targetBranch.tip.sha + } + + if (isMergeConflictState(conflictState)) { + operationDetail = { + kind: MultiCommitOperationKind.Merge, + isSquash: status.squashMsgFound, + sourceBranch: null, + } + originalBranchTip = targetBranch !== null ? targetBranch.tip.sha : null + } else if (isRebaseConflictState(conflictState)) { + const snapshot = await getRebaseSnapshot(repository) + const rebaseState = await getRebaseInternalState(repository) + if (snapshot === null || rebaseState === null) { + return + } + + originalBranchTip = rebaseState.originalBranchTip + commits = snapshot.commits + progress = snapshot.progress + operationDetail = { + kind: MultiCommitOperationKind.Rebase, + sourceBranch: null, + commits, + currentTip: rebaseState.baseBranchTip, + } + + const commit = await getCommit(repository, rebaseState.originalBranchTip) + + if (commit !== null) { + targetBranch = new Branch( + rebaseState.targetBranch, + null, + commit, + BranchType.Local, + `refs/heads/${rebaseState.targetBranch}` + ) + } + } else if (isCherryPickConflictState(conflictState)) { + const snapshot = await getCherryPickSnapshot(repository) + if (snapshot === null) { + return + } + + originalBranchTip = null + commits = snapshot.commits + progress = snapshot.progress + operationDetail = { + kind: MultiCommitOperationKind.CherryPick, + sourceBranch: null, + branchCreated: false, + commits, + } + + this.repositoryStateCache.updateMultiCommitOperationUndoState( + repository, + () => ({ + undoSha: snapshot.targetBranchUndoSha, + branchName: '', + }) + ) + } else { + assertNever(conflictState, `Unsupported conflict kind`) + } + + this._initializeMultiCommitOperation( + repository, + operationDetail, + targetBranch, + commits, + originalBranchTip, + false + ) + + if (progress === undefined) { + return + } + + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + progress: progress as IMultiCommitOperationProgress, + }) + ) + } + /** + * Push changes from latest conflicts into current multi step operation step, if needed + * - i.e. - multiple instance of running in to conflicts + */ + private updateMultiCommitOperationConflictsIfFound(repository: Repository) { + const state = this.repositoryStateCache.get(repository) + const { changesState, multiCommitOperationState } = + this.repositoryStateCache.get(repository) + const { conflictState } = changesState + + if (conflictState === null || multiCommitOperationState === null) { + this.clearConflictsFlowVisuals(state) + return + } + + const { step, operationDetail } = multiCommitOperationState + if (step.kind !== MultiCommitOperationStepKind.ShowConflicts) { + return + } + + const { manualResolutions } = conflictState + + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + step: { ...step, manualResolutions }, + }) + ) + + if (isRebaseConflictState(conflictState)) { + const { currentTip } = conflictState + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ operationDetail: { ...operationDetail, currentTip } }) + ) + } + } + + private async _triggerConflictsFlow( + repository: Repository, + status: IStatusResult + ) { + const state = this.repositoryStateCache.get(repository) + const { + changesState: { conflictState }, + multiCommitOperationState, + } = state + + if (conflictState === null) { + this.clearConflictsFlowVisuals(state) + return + } + + if (multiCommitOperationState === null) { + return + } + + const displayingBanner = + this.currentBanner !== null && + this.currentBanner.type === BannerType.ConflictsFound + + if ( + displayingBanner || + isConflictsFlow( + this.popupManager.areTherePopupsOfType(PopupType.MultiCommitOperation), + multiCommitOperationState + ) + ) { + return + } + + const { manualResolutions } = conflictState + let ourBranch, theirBranch + + if (isMergeConflictState(conflictState)) { + theirBranch = await this.getMergeConflictsTheirBranch( + repository, + status.squashMsgFound, + multiCommitOperationState + ) + ourBranch = conflictState.currentBranch + } else if (isRebaseConflictState(conflictState)) { + theirBranch = conflictState.targetBranch + ourBranch = conflictState.baseBranch + } else if (isCherryPickConflictState(conflictState)) { + if ( + multiCommitOperationState !== null && + multiCommitOperationState.operationDetail.kind === + MultiCommitOperationKind.CherryPick && + multiCommitOperationState.operationDetail.sourceBranch !== null + ) { + theirBranch = + multiCommitOperationState.operationDetail.sourceBranch.name + } + ourBranch = conflictState.targetBranchName + } else { + assertNever(conflictState, `Unsupported conflict kind`) + } + + this._setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.ShowConflicts, + conflictState: { + kind: 'multiCommitOperation', + manualResolutions, + ourBranch, + theirBranch, + }, + }) + + this._showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + } + + private async getMergeConflictsTheirBranch( + repository: Repository, + isSquash: boolean, + multiCommitOperationState: IMultiCommitOperationState | null + ): Promise { + let theirBranch: string | undefined + if ( + multiCommitOperationState !== null && + multiCommitOperationState.operationDetail.kind === + MultiCommitOperationKind.Merge && + multiCommitOperationState.operationDetail.sourceBranch !== null + ) { + theirBranch = multiCommitOperationState.operationDetail.sourceBranch.name + } + + if (theirBranch === undefined && !isSquash) { + const possibleTheirsBranches = await getBranchesPointedAt( + repository, + 'MERGE_HEAD' + ) + + // null means we encountered an error + if (possibleTheirsBranches === null) { + return + } + + theirBranch = + possibleTheirsBranches.length === 1 + ? possibleTheirsBranches[0] + : undefined + } + return theirBranch + } + + /** + * Cleanup any related UI related to conflicts if still in use. + */ + private clearConflictsFlowVisuals(state: IRepositoryState) { + const { multiCommitOperationState } = state + if ( + userIsStartingMultiCommitOperation( + this.popupManager.currentPopup, + multiCommitOperationState + ) + ) { + return + } + + this._closePopup(PopupType.MultiCommitOperation) + this._clearBanner(BannerType.ConflictsFound) + this._clearBanner(BannerType.MergeConflictsFound) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _changeRepositorySection( + repository: Repository, + selectedSection: RepositorySectionTab + ): Promise { + this.repositoryStateCache.update(repository, state => { + if (state.selectedSection !== selectedSection) { + this.statsStore.recordRepositoryViewChanged() + } + return { selectedSection } + }) + this.emitUpdate() + + if (selectedSection === RepositorySectionTab.History) { + return this.refreshHistorySection(repository) + } else if (selectedSection === RepositorySectionTab.Changes) { + return this.refreshChangesSection(repository, { + includingStatus: true, + clearPartialState: false, + }) + } + } + + /** + * Changes the selection in the changes view to the working directory and + * optionally selects one or more files from the working directory. + * + * @param files An array of files to select when showing the working directory. + * If undefined this method will preserve the previously selected + * files or pick the first changed file if no selection exists. + * + * Note: This shouldn't be called directly. See `Dispatcher`. + */ + public async _selectWorkingDirectoryFiles( + repository: Repository, + files?: ReadonlyArray + ): Promise { + this.repositoryStateCache.updateChangesState(repository, state => + selectWorkingDirectoryFiles(state, files) + ) + + this.updateMenuLabelsForSelectedRepository() + this.emitUpdate() + this.updateChangesWorkingDirectoryDiff(repository) + } + + /** + * Loads or re-loads (refreshes) the diff for the currently selected file + * in the working directory. This operation is a noop if there's no currently + * selected file. + */ + private async updateChangesWorkingDirectoryDiff( + repository: Repository + ): Promise { + const stateBeforeLoad = this.repositoryStateCache.get(repository) + const changesStateBeforeLoad = stateBeforeLoad.changesState + + if ( + changesStateBeforeLoad.selection.kind !== + ChangesSelectionKind.WorkingDirectory + ) { + return + } + + const selectionBeforeLoad = changesStateBeforeLoad.selection + const selectedFileIDsBeforeLoad = selectionBeforeLoad.selectedFileIDs + + // We only render diffs when a single file is selected. + if (selectedFileIDsBeforeLoad.length !== 1) { + if (selectionBeforeLoad.diff !== null) { + this.repositoryStateCache.updateChangesState(repository, () => ({ + selection: { + ...selectionBeforeLoad, + diff: null, + }, + })) + this.emitUpdate() + } + return + } + + const selectedFileIdBeforeLoad = selectedFileIDsBeforeLoad[0] + const selectedFileBeforeLoad = + changesStateBeforeLoad.workingDirectory.findFileWithID( + selectedFileIdBeforeLoad + ) + + if (selectedFileBeforeLoad === null) { + return + } + + const diff = await getWorkingDirectoryDiff( + repository, + selectedFileBeforeLoad, + this.hideWhitespaceInChangesDiff + ) + + const stateAfterLoad = this.repositoryStateCache.get(repository) + const changesState = stateAfterLoad.changesState + + // A different file (or files) could have been selected while we were + // loading the diff in which case we no longer care about the diff we + // just loaded. + if ( + changesState.selection.kind !== ChangesSelectionKind.WorkingDirectory || + !arrayEquals( + changesState.selection.selectedFileIDs, + selectedFileIDsBeforeLoad + ) + ) { + return + } + + const selectedFileID = changesState.selection.selectedFileIDs[0] + + if (selectedFileID !== selectedFileIdBeforeLoad) { + return + } + + const currentlySelectedFile = + changesState.workingDirectory.findFileWithID(selectedFileID) + if (currentlySelectedFile === null) { + return + } + + const selectableLines = new Set() + if (diff.kind === DiffType.Text || diff.kind === DiffType.LargeText) { + // The diff might have changed dramatically since last we loaded it. + // Ideally we would be more clever about validating that any partial + // selection state is still valid by ensuring that selected lines still + // exist but for now we'll settle on just updating the selectable lines + // such that any previously selected line which now no longer exists or + // has been turned into a context line isn't still selected. + diff.hunks.forEach(h => { + h.lines.forEach((line, index) => { + if (line.isIncludeableLine()) { + selectableLines.add(h.unifiedDiffStart + index) + } + }) + }) + } + + const newSelection = + currentlySelectedFile.selection.withSelectableLines(selectableLines) + const selectedFile = currentlySelectedFile.withSelection(newSelection) + const updatedFiles = changesState.workingDirectory.files.map(f => + f.id === selectedFile.id ? selectedFile : f + ) + const workingDirectory = WorkingDirectoryStatus.fromFiles(updatedFiles) + + const selection: ChangesWorkingDirectorySelection = { + ...changesState.selection, + diff, + } + + this.repositoryStateCache.updateChangesState(repository, () => ({ + selection, + workingDirectory, + })) + this.emitUpdate() + } + + public _hideStashedChanges(repository: Repository) { + const { changesState } = this.repositoryStateCache.get(repository) + + // makes this safe to call even when the stash ui is not visible + if (changesState.selection.kind !== ChangesSelectionKind.Stash) { + return + } + + this.repositoryStateCache.updateChangesState(repository, state => { + const files = state.workingDirectory.files + const selectedFileIds = files + .filter(f => f.selection.getSelectionType() !== DiffSelectionType.None) + .map(f => f.id) + + return { + selection: { + kind: ChangesSelectionKind.WorkingDirectory, + diff: null, + selectedFileIDs: selectedFileIds, + }, + } + }) + this.emitUpdate() + + this.updateMenuLabelsForSelectedRepository() + } + + /** + * Changes the selection in the changes view to the stash entry view and + * optionally selects a particular file from the current stash entry. + * + * @param file A file to select when showing the stash entry. + * If undefined this method will preserve the previously selected + * file or pick the first changed file if no selection exists. + * + * Note: This shouldn't be called directly. See `Dispatcher`. + */ + public async _selectStashedFile( + repository: Repository, + file?: CommittedFileChange | null + ): Promise { + this.repositoryStateCache.update(repository, () => ({ + selectedSection: RepositorySectionTab.Changes, + })) + this.repositoryStateCache.updateChangesState(repository, state => { + let selectedStashedFile: CommittedFileChange | null = null + const { stashEntry, selection } = state + + const currentlySelectedFile = + selection.kind === ChangesSelectionKind.Stash + ? selection.selectedStashedFile + : null + + const currentFiles = + stashEntry !== null && + stashEntry.files.kind === StashedChangesLoadStates.Loaded + ? stashEntry.files.files + : [] + + if (file === undefined) { + if (currentlySelectedFile !== null) { + // Ensure the requested file exists in the stash entry and + // that we can use reference equality to figure out which file + // is selected in the list. If we can't find it we'll pick the + // first file available or null if no files have been loaded. + selectedStashedFile = + currentFiles.find(x => x.id === currentlySelectedFile.id) || + currentFiles[0] || + null + } else { + // No current selection, let's just pick the first file available + // or null if no files have been loaded. + selectedStashedFile = currentFiles[0] || null + } + } else if (file !== null) { + // Look up the selected file in the stash entry, it's possible that + // the stash entry or file list has changed since the consumer called + // us. The working directory selection handles this by using IDs rather + // than references. + selectedStashedFile = currentFiles.find(x => x.id === file.id) || null + } + + return { + selection: { + kind: ChangesSelectionKind.Stash, + selectedStashedFile, + selectedStashedFileDiff: null, + }, + } + }) + + this.updateMenuLabelsForSelectedRepository() + this.emitUpdate() + this.updateChangesStashDiff(repository) + + if (!this.hasUserViewedStash) { + // `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.hasUserViewedStash = true + } + } + + private async updateChangesStashDiff(repository: Repository) { + const stateBeforeLoad = this.repositoryStateCache.get(repository) + const changesStateBeforeLoad = stateBeforeLoad.changesState + const selectionBeforeLoad = changesStateBeforeLoad.selection + + if (selectionBeforeLoad.kind !== ChangesSelectionKind.Stash) { + return + } + + const stashEntry = changesStateBeforeLoad.stashEntry + + if (stashEntry === null) { + return + } + + let file = selectionBeforeLoad.selectedStashedFile + + if (file === null) { + if (stashEntry.files.kind === StashedChangesLoadStates.Loaded) { + if (stashEntry.files.files.length > 0) { + file = stashEntry.files.files[0] + } + } + } + + if (file === null) { + this.repositoryStateCache.updateChangesState(repository, () => ({ + selection: { + kind: ChangesSelectionKind.Stash, + selectedStashedFile: null, + selectedStashedFileDiff: null, + }, + })) + this.emitUpdate() + return + } + + const diff = await getCommitDiff(repository, file, file.commitish) + + const stateAfterLoad = this.repositoryStateCache.get(repository) + const changesStateAfterLoad = stateAfterLoad.changesState + + // Something has changed during our async getCommitDiff, bail + if ( + changesStateAfterLoad.selection.kind !== ChangesSelectionKind.Stash || + changesStateAfterLoad.selection.selectedStashedFile !== + selectionBeforeLoad.selectedStashedFile + ) { + return + } + + this.repositoryStateCache.updateChangesState(repository, () => ({ + selection: { + kind: ChangesSelectionKind.Stash, + selectedStashedFile: file, + selectedStashedFileDiff: diff, + }, + })) + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _commitIncludedChanges( + repository: Repository, + context: ICommitContext + ): Promise { + const state = this.repositoryStateCache.get(repository) + const files = state.changesState.workingDirectory.files + const selectedFiles = files.filter(file => { + return file.selection.getSelectionType() !== DiffSelectionType.None + }) + + const gitStore = this.gitStoreCache.get(repository) + + return this.withIsCommitting(repository, async () => { + const result = await gitStore.performFailableOperation(async () => { + const message = await formatCommitMessage(repository, context) + return createCommit(repository, message, selectedFiles, context.amend) + }) + + if (result !== undefined) { + await this._recordCommitStats( + gitStore, + repository, + state, + context, + selectedFiles, + context.amend === true + ) + + this.repositoryStateCache.update(repository, () => { + return { + commitToAmend: null, + } + }) + + await this.refreshChangesSection(repository, { + includingStatus: true, + clearPartialState: true, + }) + + // Do not await for refreshing the repository, otherwise this will block + // the commit button unnecessarily for a long time in big repos. + this._refreshRepositoryAfterCommit( + repository, + result, + state.commitToAmend + ) + } + + return result !== undefined + }) + } + + private async _refreshRepositoryAfterCommit( + repository: Repository, + newCommitSha: string, + amendedCommit: Commit | null + ) { + await this._refreshRepository(repository) + + const amendedCommitSha = amendedCommit?.sha + + if (amendedCommitSha !== undefined && newCommitSha !== amendedCommitSha) { + const newState = this.repositoryStateCache.get(repository) + const newTip = newState.branchesState.tip + if (newTip.kind === TipState.Valid) { + this._addBranchToForcePushList(repository, newTip, amendedCommitSha) + } + } + } + + private async _recordCommitStats( + gitStore: GitStore, + repository: Repository, + repositoryState: IRepositoryState, + context: ICommitContext, + selectedFiles: readonly WorkingDirectoryFileChange[], + isAmend: boolean + ) { + this.statsStore.recordCommit() + + const includedPartialSelections = selectedFiles.some( + file => file.selection.getSelectionType() === DiffSelectionType.Partial + ) + if (includedPartialSelections) { + this.statsStore.recordPartialCommit() + } + + if (isAmend) { + this.statsStore.recordAmendCommitSuccessful(selectedFiles.length > 0) + } + + const { trailers } = context + if (trailers !== undefined && trailers.some(isCoAuthoredByTrailer)) { + this.statsStore.recordCoAuthoredCommit() + } + + const account = getAccountForRepository(this.accounts, repository) + if (repository.gitHubRepository !== null) { + if (account !== null) { + if (account.endpoint === getDotComAPIEndpoint()) { + this.statsStore.recordCommitToDotcom() + } else { + this.statsStore.recordCommitToEnterprise() + } + + const { commitAuthor } = repositoryState + if (commitAuthor !== null) { + if (!isAttributableEmailFor(account, commitAuthor.email)) { + this.statsStore.recordUnattributedCommit() + } + } + } + + const branchProtectionsFound = + await this.repositoriesStore.hasBranchProtectionsConfigured( + repository.gitHubRepository + ) + + if (branchProtectionsFound) { + this.statsStore.recordCommitToRepositoryWithBranchProtections() + } + + const branchName = findRemoteBranchName( + gitStore.tip, + gitStore.currentRemote, + repository.gitHubRepository + ) + + if (branchName !== null) { + const { changesState } = this.repositoryStateCache.get(repository) + if (changesState.currentBranchProtected) { + this.statsStore.recordCommitToProtectedBranch() + } + } + + if ( + repository.gitHubRepository !== null && + !hasWritePermission(repository.gitHubRepository) + ) { + this.statsStore.recordCommitToRepositoryWithoutWriteAccess() + this.statsStore.recordRepositoryCommitedInWithoutWriteAccess( + repository.gitHubRepository.dbID + ) + } + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _changeFileIncluded( + repository: Repository, + file: WorkingDirectoryFileChange, + include: boolean + ): Promise { + const selection = include + ? file.selection.withSelectAll() + : file.selection.withSelectNone() + this.updateWorkingDirectoryFileSelection(repository, file, selection) + return Promise.resolve() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _changeFileLineSelection( + repository: Repository, + file: WorkingDirectoryFileChange, + diffSelection: DiffSelection + ): Promise { + this.updateWorkingDirectoryFileSelection(repository, file, diffSelection) + return Promise.resolve() + } + + /** + * Updates the selection for the given file in the working directory state and + * emits an update event. + */ + private updateWorkingDirectoryFileSelection( + repository: Repository, + file: WorkingDirectoryFileChange, + selection: DiffSelection + ) { + this.repositoryStateCache.updateChangesState(repository, state => { + const newFiles = state.workingDirectory.files.map(f => + f.id === file.id ? f.withSelection(selection) : f + ) + + const workingDirectory = WorkingDirectoryStatus.fromFiles(newFiles) + + return { workingDirectory } + }) + + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _changeIncludeAllFiles( + repository: Repository, + includeAll: boolean + ): Promise { + this.repositoryStateCache.updateChangesState(repository, state => { + const workingDirectory = + state.workingDirectory.withIncludeAllFiles(includeAll) + return { workingDirectory } + }) + + this.emitUpdate() + + return Promise.resolve() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _refreshOrRecoverRepository( + repository: Repository + ): Promise { + // if repository is missing, try checking if it has been restored + if (repository.missing) { + const updatedRepository = await this.recoverMissingRepository(repository) + if (!updatedRepository.missing) { + // repository has been restored, attempt to refresh it now. + return this._refreshRepository(updatedRepository) + } + } else { + return this._refreshRepository(repository) + } + } + + private async recoverMissingRepository( + repository: Repository + ): Promise { + if (!repository.missing) { + return repository + } + + const foundRepository = + (await pathExists(repository.path)) && + (await getRepositoryType(repository.path)).kind === 'regular' && + (await this._loadStatus(repository)) !== null + + if (foundRepository) { + return await this._updateRepositoryMissing(repository, false) + } + return repository + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _refreshRepository(repository: Repository): Promise { + if (repository.missing) { + return + } + + // if the repository path doesn't exist on disk, + // set the flag and don't try anything Git-related + const exists = await pathExists(repository.path) + if (!exists) { + this._updateRepositoryMissing(repository, true) + return + } + + const state = this.repositoryStateCache.get(repository) + const gitStore = this.gitStoreCache.get(repository) + + // if we cannot get a valid status it's a good indicator that the repository + // is in a bad state - let's mark it as missing here and give up on the + // further work + const status = await this._loadStatus(repository) + this.updateSidebarIndicator(repository, status) + + if (status === null) { + await this._updateRepositoryMissing(repository, true) + return + } + + // loadBranches needs the default remote to determine the default branch + await gitStore.loadRemotes() + await gitStore.loadBranches() + + const section = state.selectedSection + let refreshSectionPromise: Promise + + if (section === RepositorySectionTab.History) { + refreshSectionPromise = this.refreshHistorySection(repository) + } else if (section === RepositorySectionTab.Changes) { + refreshSectionPromise = this.refreshChangesSection(repository, { + includingStatus: false, + clearPartialState: false, + }) + } else { + return assertNever(section, `Unknown section: ${section}`) + } + + await Promise.all([ + gitStore.updateLastFetched(), + gitStore.loadStashEntries(), + this._refreshAuthor(repository), + refreshSectionPromise, + ]) + + await gitStore.refreshTags() + + // this promise is fire-and-forget, so no need to await it + this.updateStashEntryCountMetric( + repository, + gitStore.desktopStashEntryCount, + gitStore.stashEntryCount + ) + this.updateCurrentPullRequest(repository) + + const latestState = this.repositoryStateCache.get(repository) + this.updateMenuItemLabels(latestState) + + this._initializeCompare(repository) + + this.updateCurrentTutorialStep(repository) + } + + private async updateStashEntryCountMetric( + repository: Repository, + desktopStashEntryCount: number, + stashEntryCount: number + ) { + const lastStashEntryCheck = + await this.repositoriesStore.getLastStashCheckDate(repository) + const threshold = offsetFromNow(-24, 'hours') + // `lastStashEntryCheck` being equal to `null` means + // we've never checked for the given repo + if (lastStashEntryCheck == null || threshold > lastStashEntryCheck) { + await this.repositoriesStore.updateLastStashCheckDate(repository) + const numEntriesCreatedOutsideDesktop = + stashEntryCount - desktopStashEntryCount + this.statsStore.addStashEntriesCreatedOutsideDesktop( + numEntriesCreatedOutsideDesktop + ) + } + } + + /** + * Update the repository sidebar indicator for the repository + */ + private async updateSidebarIndicator( + repository: Repository, + status: IStatusResult | null + ): Promise { + const lookup = this.localRepositoryStateLookup + + if (repository.missing) { + lookup.delete(repository.id) + return + } + + if (status === null) { + lookup.delete(repository.id) + return + } + + lookup.set(repository.id, { + aheadBehind: status.branchAheadBehind || null, + changedFilesCount: status.workingDirectory.files.length, + }) + } + /** + * Refresh indicator in repository list for a specific repository + */ + private refreshIndicatorForRepository = async (repository: Repository) => { + const lookup = this.localRepositoryStateLookup + + if (repository.missing) { + lookup.delete(repository.id) + return + } + + const exists = await pathExists(repository.path) + if (!exists) { + lookup.delete(repository.id) + return + } + + const gitStore = this.gitStoreCache.get(repository) + const status = await gitStore.loadStatus() + if (status === null) { + lookup.delete(repository.id) + return + } + + this.updateSidebarIndicator(repository, status) + this.emitUpdate() + + const lastPush = await inferLastPushForRepository( + this.accounts, + gitStore, + repository + ) + + if (await this.shouldBackgroundFetch(repository, lastPush)) { + const aheadBehind = await this.fetchForRepositoryIndicator(repository) + + const existing = lookup.get(repository.id) + lookup.set(repository.id, { + aheadBehind: aheadBehind, + // We don't need to update changedFilesCount here since it was already + // set when calling `updateSidebarIndicator()` with the status object. + changedFilesCount: existing?.changedFilesCount ?? 0, + }) + this.emitUpdate() + } + } + + private getRepositoriesForIndicatorRefresh = () => { + // The currently selected repository will get refreshed by both the + // BackgroundFetcher and the refreshRepository call from the + // focus event. No point in having the RepositoryIndicatorUpdater do + // it as well. + // + // Note that this method should never leak the actual repositories + // instance since that's a mutable array. We should always return + // a copy. + return this.repositories.filter(x => x !== this.selectedRepository) + } + + /** + * A slimmed down version of performFetch which is only used when fetching + * the repository in order to compute the repository indicator status. + * + * As opposed to `performFetch` this method will not perform a full refresh + * of the repository after fetching, nor will it refresh issues, branch + * protection information etc. It's intention is to only do the bare minimum + * amount of work required to calculate an up-to-date ahead/behind status + * of the current branch to its upstream tracking branch. + */ + private fetchForRepositoryIndicator(repo: Repository) { + return this.withAuthenticatingUser(repo, async (repo, account) => { + const isBackgroundTask = true + const gitStore = this.gitStoreCache.get(repo) + + await this.withPushPullFetch(repo, () => + gitStore.fetch(account, isBackgroundTask, progress => + this.updatePushPullFetchProgress(repo, progress) + ) + ) + this.updatePushPullFetchProgress(repo, null) + + return gitStore.aheadBehind + }) + } + + public _setRepositoryIndicatorsEnabled(repositoryIndicatorsEnabled: boolean) { + if (this.repositoryIndicatorsEnabled === repositoryIndicatorsEnabled) { + return + } + + setBoolean(repositoryIndicatorsEnabledKey, repositoryIndicatorsEnabled) + this.repositoryIndicatorsEnabled = repositoryIndicatorsEnabled + if (repositoryIndicatorsEnabled) { + this.repositoryIndicatorUpdater.start() + } else { + this.repositoryIndicatorUpdater.stop() + } + + this.emitUpdate() + } + + public _setCommitSpellcheckEnabled(commitSpellcheckEnabled: boolean) { + if (this.commitSpellcheckEnabled === commitSpellcheckEnabled) { + return + } + + setBoolean(commitSpellcheckEnabledKey, commitSpellcheckEnabled) + this.commitSpellcheckEnabled = commitSpellcheckEnabled + + this.emitUpdate() + } + + public _setUseWindowsOpenSSH(useWindowsOpenSSH: boolean) { + setBoolean(UseWindowsOpenSSHKey, useWindowsOpenSSH) + this.useWindowsOpenSSH = useWindowsOpenSSH + + this.emitUpdate() + } + + public _setNotificationsEnabled(notificationsEnabled: boolean) { + this.notificationsStore.setNotificationsEnabled(notificationsEnabled) + this.emitUpdate() + } + + /** + * Refresh all the data for the Changes section. + * + * This will be called automatically when appropriate. + */ + private async refreshChangesSection( + repository: Repository, + options: { + includingStatus: boolean + clearPartialState: boolean + } + ): Promise { + if (options.includingStatus) { + await this._loadStatus(repository, options.clearPartialState) + } + + const gitStore = this.gitStoreCache.get(repository) + const state = this.repositoryStateCache.get(repository) + + if (state.branchesState.tip.kind === TipState.Valid) { + const currentBranch = state.branchesState.tip.branch + await gitStore.loadLocalCommits(currentBranch) + } else if (state.branchesState.tip.kind === TipState.Unborn) { + await gitStore.loadLocalCommits(null) + } + } + + /** + * Refresh all the data for the History section. + * + * This will be called automatically when appropriate. + */ + private async refreshHistorySection(repository: Repository): Promise { + const gitStore = this.gitStoreCache.get(repository) + const state = this.repositoryStateCache.get(repository) + const tip = state.branchesState.tip + + if (tip.kind === TipState.Valid) { + await gitStore.loadLocalCommits(tip.branch) + } + + return this.updateOrSelectFirstCommit( + repository, + state.compareState.commitSHAs + ) + } + + public async _refreshAuthor(repository: Repository): Promise { + const gitStore = this.gitStoreCache.get(repository) + const commitAuthor = + (await gitStore.performFailableOperation(() => + getAuthorIdentity(repository) + )) || null + + this.repositoryStateCache.update(repository, () => ({ + commitAuthor, + })) + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _showPopup(popup: Popup): Promise { + // 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.popupManager.addPopup(popup) + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _closePopup(popupType?: PopupType) { + const currentPopup = this.popupManager.currentPopup + if (currentPopup === null) { + 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) + } + + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _closePopupById(popupId: string) { + if (this.popupManager.currentPopup === null) { + return + } + + this.popupManager.removePopupById(popupId) + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _showFoldout(foldout: Foldout): Promise { + this.currentFoldout = foldout + this.emitUpdate() + + // If the user is opening the repository list and we haven't yet + // started to refresh the repository indicators let's do so. + if ( + foldout.type === FoldoutType.Repository && + this.repositoryIndicatorsEnabled + ) { + // N.B: RepositoryIndicatorUpdater.prototype.start is + // idempotent. + this.repositoryIndicatorUpdater.start() + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _closeCurrentFoldout(): Promise { + if (this.currentFoldout == null) { + return + } + + this.currentFoldout = null + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _closeFoldout(foldout: FoldoutType): Promise { + if (this.currentFoldout == null) { + return + } + + if (foldout !== undefined && this.currentFoldout.type !== foldout) { + return + } + + this.currentFoldout = null + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _createBranch( + repository: Repository, + name: string, + startPoint: string | null, + noTrackOption: boolean = false, + checkoutBranch: boolean = true + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + const branch = await gitStore.createBranch(name, startPoint, noTrackOption) + + if (branch !== undefined && checkoutBranch) { + await this._checkoutBranch(repository, branch) + } + + return branch + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _createTag(repository: Repository, name: string, sha: string) { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.createTag(name, sha) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _deleteTag(repository: Repository, name: string) { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.deleteTag(name) + } + + private updateCheckoutProgress( + repository: Repository, + checkoutProgress: ICheckoutProgress | null + ) { + this.repositoryStateCache.update(repository, () => ({ + checkoutProgress, + })) + + if ( + this.selectedRepository instanceof Repository && + this.selectedRepository.id === repository.id + ) { + this.emitUpdate() + } + } + + /** + * Checkout the given branch, using given stashing strategy or the default. + * + * When `explicitStrategy` is undefined we'll use the default strategy + * configurable by the user in preferences. Without an explicit strategy + * this method will take care of presenting the user with any necessary + * confirmation dialogs and choices depending on the state of their + * repository. + * + * When provided with an explicit strategy other than `AskForConfirmation` + * we assume the user has been informed of any risks of overwritten stashes + * and such. In other words the only consumers who should pass an explicit + * strategy are dialogs and other confirmation constructs where the user + * has made an explicit choice about how to proceed. + * + * Note: This shouldn't be called directly. See `Dispatcher`. + */ + public async _checkoutBranch( + repository: Repository, + branch: Branch, + explicitStrategy?: UncommittedChangesStrategy + ): Promise { + const repositoryState = this.repositoryStateCache.get(repository) + const { changesState, branchesState } = repositoryState + const { currentBranchProtected, stashEntry } = changesState + const { tip } = branchesState + const hasChanges = changesState.workingDirectory.files.length > 0 + + // No point in checking out the currently checked out branch. + if (tip.kind === TipState.Valid && tip.branch.name === branch.name) { + return repository + } + + let strategy = explicitStrategy ?? this.uncommittedChangesStrategy + + // The user hasn't been presented with an explicit choice + if (explicitStrategy === undefined) { + // Even if the user has chosen to "always stash on current branch" in + // preferences we still want to let them know changes might be lost + if (strategy === UncommittedChangesStrategy.StashOnCurrentBranch) { + if (hasChanges && stashEntry !== null) { + const type = PopupType.ConfirmOverwriteStash + this._showPopup({ type, repository, branchToCheckout: branch }) + return repository + } + } + } + + // Always move changes to new branch if we're on a detached head, unborn + // branch, or a protected branch. + if (tip.kind !== TipState.Valid || currentBranchProtected) { + strategy = UncommittedChangesStrategy.MoveToNewBranch + } + + if (strategy === UncommittedChangesStrategy.AskForConfirmation) { + if (hasChanges) { + const type = PopupType.StashAndSwitchBranch + this._showPopup({ type, branchToCheckout: branch, repository }) + return repository + } + } + + return this.withAuthenticatingUser(repository, (repository, account) => { + // 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) + .then(() => this.onSuccessfulCheckout(repository, branch)) + .catch(e => this.emitError(new CheckoutError(e, repository, branch))) + .then(() => this.refreshAfterCheckout(repository, branch.name)) + .finally(() => this.updateCheckoutProgress(repository, null)) + }) + } + + /** Invoke the best checkout implementation for the selected strategy */ + private checkoutImplementation( + repository: Repository, + branch: Branch, + account: IGitAccount | null, + strategy: UncommittedChangesStrategy + ) { + if (strategy === UncommittedChangesStrategy.StashOnCurrentBranch) { + return this.checkoutAndLeaveChanges(repository, branch, account) + } else if (strategy === UncommittedChangesStrategy.MoveToNewBranch) { + return this.checkoutAndBringChanges(repository, branch, account) + } else { + return this.checkoutIgnoringChanges(repository, branch, account) + } + } + + /** Checkout the given branch without taking local changes into account */ + private async checkoutIgnoringChanges( + repository: Repository, + branch: Branch, + account: IGitAccount | null + ) { + await checkoutBranch(repository, account, branch, progress => { + this.updateCheckoutProgress(repository, progress) + }) + } + + /** + * Checkout the given branch and leave any local changes on the current branch + * + * Note that this will ovewrite any existing stash enty on the current branch. + */ + private async checkoutAndLeaveChanges( + repository: Repository, + branch: Branch, + account: IGitAccount | null + ) { + const repositoryState = this.repositoryStateCache.get(repository) + const { workingDirectory } = repositoryState.changesState + const { tip } = repositoryState.branchesState + + if (tip.kind === TipState.Valid && workingDirectory.files.length > 0) { + await this.createStashAndDropPreviousEntry(repository, tip.branch) + this.statsStore.recordStashCreatedOnCurrentBranch() + } + + return this.checkoutIgnoringChanges(repository, branch, account) + } + + /** + * Checkout the given branch and move any local changes along. + * + * Will attempt to simply check out the branch and if that fails due to + * local changes risking being overwritten it'll create a transient stash + * entry, switch branches, and pop said stash entry. + * + * Note that the transient stash entry will not overwrite any current stash + * entry for the target branch. + */ + private async checkoutAndBringChanges( + repository: Repository, + branch: Branch, + account: IGitAccount | null + ) { + try { + await this.checkoutIgnoringChanges(repository, branch, account) + } catch (checkoutError) { + if (!isLocalChangesOverwrittenError(checkoutError)) { + throw checkoutError + } + + const stash = (await this.createStashEntry(repository, branch)) + ? await getLastDesktopStashEntryForBranch(repository, branch) + : null + + // Failing to stash the changes when we know that there are changes + // preventing a checkout is very likely due to assume-unchanged or + // skip-worktree. So instead of showing a "could not create stash" error + // we'll show the checkout error to the user and let them figure it out. + if (stash === null) { + throw checkoutError + } + + await this.checkoutIgnoringChanges(repository, branch, account) + await popStashEntry(repository, stash.stashSha) + + this.statsStore.recordChangesTakenToNewBranch() + } + } + + private async onSuccessfulCheckout(repository: Repository, branch: Branch) { + const repositoryState = this.repositoryStateCache.get(repository) + const { stashEntry } = repositoryState.changesState + const { defaultBranch } = repositoryState.branchesState + + this.clearBranchProtectionState(repository) + + // Make sure changes or suggested next step are visible after branch checkout + await this._selectWorkingDirectoryFiles(repository) + + this._initializeCompare(repository, { kind: HistoryTabMode.History }) + + if (defaultBranch !== null && branch.name !== defaultBranch.name) { + this.statsStore.recordNonDefaultBranchCheckout() + } + + if (stashEntry !== null && !this.hasUserViewedStash) { + this.statsStore.recordStashNotViewedAfterCheckout() + } + + this.hasUserViewedStash = false + } + + /** + * @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, + 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 + + // 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.withAuthenticatingUser(repository, (repository, account) => { + // 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, account) + .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, + account: IGitAccount | null + ) { + await checkoutCommit(repository, account, commit, progress => { + this.updateCheckoutProgress(repository, progress) + }) + } + + /** + * Creates a stash associated to the current checked out branch. + * + * @param repository + * @param showConfirmationDialog Whether to show a confirmation + * dialog if an existing stash exists. + */ + public async _createStashForCurrentBranch( + repository: Repository, + showConfirmationDialog: boolean + ): Promise { + const repositoryState = this.repositoryStateCache.get(repository) + const tip = repositoryState.branchesState.tip + const currentBranch = tip.kind === TipState.Valid ? tip.branch : null + const hasExistingStash = repositoryState.changesState.stashEntry !== null + + if (currentBranch === null) { + return false + } + + if (showConfirmationDialog && hasExistingStash) { + this._showPopup({ + type: PopupType.ConfirmOverwriteStash, + branchToCheckout: null, + repository, + }) + return false + } + + if (await this.createStashAndDropPreviousEntry(repository, currentBranch)) { + this.statsStore.recordStashCreatedOnCurrentBranch() + await this._refreshRepository(repository) + return true + } + + return false + } + + /** + * refetches the associated GitHub remote repository, if possible + * + * if refetching fails, will return the given `repository` with + * the same info it was passed in with + * + * @param repository + * @returns repository model (hopefully with fresh `gitHubRepository` info) + */ + private async repositoryWithRefreshedGitHubRepository( + repository: Repository + ): Promise { + const repoStore = this.repositoriesStore + const match = await this.matchGitHubRepository(repository) + + // TODO: We currently never clear GitHub repository associations (see + // https://github.com/desktop/desktop/issues/1144). So we can bail early at + // this point. + if (!match) { + return repository + } + + const { account, owner, name } = match + const { endpoint } = account + const api = API.fromAccount(account) + const apiRepo = await api.fetchRepository(owner, name) + + if (apiRepo === null) { + // If the request fails, we want to preserve the existing GitHub + // repository info. But if we didn't have a GitHub repository already or + // the endpoint changed, the skeleton repository is better than nothing. + if (endpoint !== repository.gitHubRepository?.endpoint) { + const ghRepo = await repoStore.upsertGitHubRepositoryFromMatch(match) + return repoStore.setGitHubRepository(repository, ghRepo) + } + + return repository + } + + if (repository.gitHubRepository) { + const gitStore = this.gitStoreCache.get(repository) + await updateRemoteUrl(gitStore, repository.gitHubRepository, apiRepo) + } + + const ghRepo = await repoStore.upsertGitHubRepository(endpoint, apiRepo) + const freshRepo = await repoStore.setGitHubRepository(repository, ghRepo) + + await this.refreshBranchProtectionState(freshRepo) + return freshRepo + } + + private async updateBranchProtectionsFromAPI(repository: Repository) { + if (repository.gitHubRepository === null) { + return + } + + const { owner, name } = repository.gitHubRepository + + const account = getAccountForEndpoint( + this.accounts, + repository.gitHubRepository.endpoint + ) + + if (account === null) { + return + } + + const api = API.fromAccount(account) + + const branches = await api.fetchProtectedBranches(owner.login, name) + + await this.repositoriesStore.updateBranchProtections( + repository.gitHubRepository, + branches + ) + } + + private async matchGitHubRepository( + repository: Repository + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + + if (!gitStore.defaultRemote) { + await gitStore.loadRemotes() + } + + const remote = gitStore.defaultRemote + return remote !== null + ? matchGitHubRepository(this.accounts, remote.url) + : null + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _pushError(error: Error): Promise { + this.popupManager.addErrorPopup(error) + this.emitUpdate() + + return Promise.resolve() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _changeRepositoryAlias( + repository: Repository, + newAlias: string | null + ): Promise { + return this.repositoriesStore.updateRepositoryAlias(repository, newAlias) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _renameBranch( + repository: Repository, + branch: Branch, + newName: string + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.performFailableOperation(async () => { + await renameBranch(repository, branch, newName) + + if (enableMoveStash()) { + const stashEntry = gitStore.desktopStashEntries.get(branch.name) + + if (stashEntry) { + await moveStashEntry(repository, stashEntry, newName) + } + } + }) + + return this._refreshRepository(repository) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _deleteBranch( + repository: Repository, + branch: Branch, + includeUpstream?: boolean, + toCheckout?: Branch | null + ): Promise { + return this.withAuthenticatingUser(repository, async (r, account) => { + const gitStore = this.gitStoreCache.get(r) + + // If solely a remote branch, there is no need to checkout a branch. + if (branch.type === BranchType.Remote) { + const { remoteName, tip, nameWithoutRemote } = branch + if (remoteName === null) { + // This is based on the branches ref. It should not be null for a + // remote branch + throw new Error( + `Could not determine remote name from: ${branch.ref}.` + ) + } + + await gitStore.performFailableOperation(() => + deleteRemoteBranch(r, account, remoteName, nameWithoutRemote) + ) + + // We log the remote branch's sha so that the user can recover it. + log.info( + `Deleted branch ${branch.upstreamWithoutRemote} (was ${tip.sha})` + ) + + return this._refreshRepository(r) + } + + // 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) + + if (branchToCheckout !== null) { + await gitStore.performFailableOperation(() => + checkoutBranch(r, account, branchToCheckout) + ) + } + + await gitStore.performFailableOperation(() => { + return this.deleteLocalBranchAndUpstreamBranch( + repository, + branch, + account, + includeUpstream + ) + }) + + return this._refreshRepository(r) + }) + } + + /** + * Deletes the local branch. If the parameter `includeUpstream` is true, the + * upstream branch will be deleted also. + */ + private async deleteLocalBranchAndUpstreamBranch( + repository: Repository, + branch: Branch, + account: IGitAccount | null, + includeUpstream?: boolean + ): Promise { + await deleteLocalBranch(repository, branch.name) + + if ( + includeUpstream === true && + branch.upstreamRemoteName !== null && + branch.upstreamWithoutRemote !== null + ) { + await deleteRemoteBranch( + repository, + account, + branch.upstreamRemoteName, + branch.upstreamWithoutRemote + ) + } + return + } + + private getBranchToCheckoutAfterDelete( + branchToDelete: Branch, + repository: Repository + ): Branch | null { + const { branchesState } = this.repositoryStateCache.get(repository) + const tip = branchesState.tip + const currentBranch = tip.kind === TipState.Valid ? tip.branch : null + // if current branch is not the branch being deleted, no need to switch + // branches + if (currentBranch !== null && branchToDelete.name !== currentBranch.name) { + return null + } + + // If the default branch is null, use the most recent branch excluding the branch + // the branch to delete as the branch to checkout. + const branchToCheckout = + branchesState.defaultBranch ?? + branchesState.recentBranches.find(x => x.name !== branchToDelete.name) + + if (branchToCheckout === undefined) { + throw new Error( + `It's not possible to delete the only existing branch in a repository.` + ) + } + + return branchToCheckout + } + + private updatePushPullFetchProgress( + repository: Repository, + pushPullFetchProgress: Progress | null + ) { + this.repositoryStateCache.update(repository, () => ({ + pushPullFetchProgress, + })) + if (this.selectedRepository === repository) { + this.emitUpdate() + } + } + + public async _push( + repository: Repository, + options?: PushOptions + ): Promise { + return this.withAuthenticatingUser(repository, (repository, account) => { + return this.performPush(repository, account, options) + }) + } + + private async performPush( + repository: Repository, + account: IGitAccount | null, + options?: PushOptions + ): Promise { + const state = this.repositoryStateCache.get(repository) + const { remote } = state + if (remote === null) { + this._showPopup({ + type: PopupType.PublishRepository, + repository, + }) + + return + } + + return this.withPushPullFetch(repository, async () => { + 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) { + const { branch } = tip + + const remoteName = branch.upstreamRemoteName || remote.name + + 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, + }) + + // 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 + + // Scale pull and fetch weights to be between 0 and 0.9. + const scale = (1 / (pushWeight + fetchWeight)) * (1 - refreshWeight) + + pushWeight *= scale + fetchWeight *= scale + + 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') + ) + } + + 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 + + this.updatePushPullFetchProgress(repository, { + kind: 'generic', + title: refreshTitle, + description: 'Fast-forwarding branches', + value: refreshStartProgress, + }) + + await this.fastForwardBranches(repository) + + 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) + + await this._refreshRepository(repository) + }, + { retryAction } + ) + + this.updatePushPullFetchProgress(repository, null) + + 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 + ) + } + }) + } + + private async withIsCommitting( + repository: Repository, + fn: () => Promise + ): Promise { + const state = this.repositoryStateCache.get(repository) + // ensure the user doesn't try and commit again + if (state.isCommitting) { + return false + } + + this.repositoryStateCache.update(repository, () => ({ + isCommitting: true, + })) + this.emitUpdate() + + try { + return await fn() + } finally { + this.repositoryStateCache.update(repository, () => ({ + isCommitting: false, + })) + this.emitUpdate() + } + } + + private async withPushPullFetch( + repository: Repository, + fn: () => Promise + ): Promise { + const state = this.repositoryStateCache.get(repository) + // Don't allow concurrent network operations. + if (state.isPushPullFetchInProgress) { + return + } + + this.repositoryStateCache.update(repository, () => ({ + isPushPullFetchInProgress: true, + })) + this.emitUpdate() + + try { + await fn() + } finally { + this.repositoryStateCache.update(repository, () => ({ + isPushPullFetchInProgress: false, + })) + this.emitUpdate() + } + } + + public async _pull(repository: Repository): Promise { + return this.withAuthenticatingUser(repository, (repository, account) => { + return this.performPull(repository, account) + }) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + private async performPull( + repository: Repository, + account: IGitAccount | null + ): Promise { + return this.withPushPullFetch(repository, async () => { + const gitStore = this.gitStoreCache.get(repository) + const remote = gitStore.currentRemote + + if (!remote) { + throw new Error('The repository has no remotes.') + } + + const state = this.repositoryStateCache.get(repository) + const tip = state.branchesState.tip + + 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) { + let mergeBase: string | null = null + let gitContext: GitErrorContext | undefined = undefined + + if (tip.branch.upstream !== null) { + mergeBase = await getMergeBase( + repository, + tip.branch.name, + tip.branch.upstream + ) + + gitContext = { + kind: 'pull', + theirBranch: tip.branch.upstream, + currentBranch: tip.branch.name, + } + } + + const title = `Pulling ${remote.name}` + const kind = 'pull' + this.updatePushPullFetchProgress(repository, { + kind, + title, + value: 0, + remote: remote.name, + }) + + try { + // Let's say that a pull takes twice as long as a fetch, + // this is of course highly inaccurate. + let pullWeight = 2 + let fetchWeight = 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 / (pullWeight + fetchWeight)) * (1 - refreshWeight) + + pullWeight *= scale + fetchWeight *= scale + + const retryAction: RetryAction = { + type: RetryActionType.Pull, + repository, + } + + if (gitStore.pullWithRebase) { + this.statsStore.recordPullWithRebaseEnabled() + } else { + this.statsStore.recordPullWithDefaultSetting() + } + + await gitStore.performFailableOperation( + () => + pullRepo(repository, account, remote, progress => { + this.updatePushPullFetchProgress(repository, { + ...progress, + value: progress.value * pullWeight, + }) + }), + { + gitContext, + retryAction, + } + ) + + await updateRemoteHEAD(repository, account, remote) + + const refreshStartProgress = pullWeight + fetchWeight + const refreshTitle = __DARWIN__ + ? 'Refreshing Repository' + : 'Refreshing repository' + + this.updatePushPullFetchProgress(repository, { + kind: 'generic', + title: refreshTitle, + description: 'Fast-forwarding branches', + value: refreshStartProgress, + }) + + await this.fastForwardBranches(repository) + + this.updatePushPullFetchProgress(repository, { + kind: 'generic', + title: refreshTitle, + value: refreshStartProgress + refreshWeight * 0.5, + }) + + if (mergeBase) { + await gitStore.reconcileHistory(mergeBase) + } + + // 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) + } finally { + this.updatePushPullFetchProgress(repository, null) + } + } + }) + } + + private async fastForwardBranches(repository: Repository) { + try { + const eligibleBranches = await getBranchesDifferingFromUpstream( + repository + ) + + await fastForwardBranches(repository, eligibleBranches) + } catch (e) { + log.error('Branch fast-forwarding failed', e) + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _publishRepository( + repository: Repository, + name: string, + description: string, + private_: boolean, + account: Account, + org: IAPIOrganization | null + ): Promise { + const api = API.fromAccount(account) + const apiRepository = await api.createRepository( + org, + name, + description, + private_ + ) + + const gitStore = this.gitStoreCache.get(repository) + await gitStore.performFailableOperation(() => + addRemote(repository, 'origin', apiRepository.clone_url) + ) + await gitStore.loadRemotes() + + // 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) + } + + await gitStore.refreshDefaultBranch() + + 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 } + } + + log.info( + `[AppStore.getAccountForRemoteURL] no generic credentials found for '${remote}'` + ) + + return null + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _clone( + url: string, + path: 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 repository = this.cloningRepositoriesStore.repositories.find( + r => r.url === url && r.path === path + )! + + promise.then(success => { + if (success) { + this.statsStore.recordCloneRepository() + } + }) + + return { promise, repository } + } + + public _removeCloningRepository(repository: CloningRepository) { + this.cloningRepositoriesStore.remove(repository) + } + + public async _discardChanges( + repository: Repository, + files: ReadonlyArray, + moveToTrash: boolean = true + ) { + const gitStore = this.gitStoreCache.get(repository) + + const { askForConfirmationOnDiscardChangesPermanently } = this.getState() + + try { + await gitStore.discardChanges( + files, + moveToTrash, + askForConfirmationOnDiscardChangesPermanently + ) + } catch (error) { + if (!(error instanceof DiscardChangesError)) { + log.error('Failed discarding changes', error) + } + + this.emitError(error) + return + } + + return this._refreshRepository(repository) + } + + public async _discardChangesFromSelection( + repository: Repository, + filePath: string, + diff: ITextDiff, + selection: DiffSelection + ) { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.discardChangesFromSelection(filePath, diff, selection) + + return this._refreshRepository(repository) + } + + public _setRepositoryCommitToAmend( + repository: Repository, + commit: Commit | null + ) { + this.repositoryStateCache.update(repository, () => { + return { + commitToAmend: commit, + } + }) + + this.emitUpdate() + } + + public async _undoCommit( + repository: Repository, + commit: Commit, + showConfirmationDialog: boolean + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + const repositoryState = this.repositoryStateCache.get(repository) + const { changesState } = repositoryState + const isWorkingDirectoryClean = + 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 && + ((this.confirmUndoCommit && !isWorkingDirectoryClean) || + commit.isMergeCommit) + ) { + return this._showPopup({ + type: PopupType.WarnLocalChangesBeforeUndo, + repository, + commit, + isWorkingDirectoryClean, + }) + } + + // Make sure we show the changes after undoing the commit + await this._changeRepositorySection( + repository, + RepositorySectionTab.Changes + ) + + await gitStore.undoCommit(commit) + + this.statsStore.recordCommitUndone(isWorkingDirectoryClean) + + return this._refreshRepository(repository) + } + + public async _resetToCommit( + repository: Repository, + commit: Commit, + showConfirmationDialog: boolean + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + const repositoryState = this.repositoryStateCache.get(repository) + const { changesState } = repositoryState + const isWorkingDirectoryClean = + changesState.workingDirectory.files.length === 0 + + // Warn the user if there are changes in the working directory + if (showConfirmationDialog && !isWorkingDirectoryClean) { + return this._showPopup({ + type: PopupType.WarningBeforeReset, + repository, + commit, + }) + } + + // Make sure we show the changes after resetting to the commit + await this._changeRepositorySection( + repository, + RepositorySectionTab.Changes + ) + + await gitStore.performFailableOperation(() => + reset(repository, GitResetMode.Mixed, commit.sha) + ) + + // this.statsStore.recordCommitUndone(isWorkingDirectoryClean) + + return this._refreshRepository(repository) + } + + /** + * Fetch a specific refspec for the repository. + * + * As this action is required to complete when viewing a Pull Request from + * a fork, it does not opt-in to checks that prevent multiple concurrent + * network actions. This might require some rework in the future to chain + * these actions. + * + */ + public async _fetchRefspec( + 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._refreshRepository(repository) + } + ) + } + + /** + * Fetch all relevant remotes in the the repository. + * + * See gitStore.fetch for more details. + * + * Note that this method will not perform the fetch of the specified remote + * 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) + }) + } + + /** + * Fetch a particular remote in a repository. + * + * Note that this method will not perform the fetch of the specified remote + * if _any_ fetches or pulls are currently in-progress. + */ + private _fetchRemote( + repository: Repository, + remote: IRemote, + fetchType: FetchType + ): Promise { + return this.withAuthenticatingUser(repository, (repository, account) => { + return this.performFetch(repository, account, fetchType, [remote]) + }) + } + + /** + * Fetch all relevant remotes or one or more given remotes in the repository. + * + * @param remotes Optional, one or more remotes to fetch if undefined all + * relevant remotes will be fetched. See gitStore.fetch for + * more detail on what constitutes a relevant remote. + */ + private async performFetch( + repository: Repository, + account: IGitAccount | null, + fetchType: FetchType, + remotes?: IRemote[] + ): Promise { + await this.withPushPullFetch(repository, async () => { + const gitStore = this.gitStoreCache.get(repository) + + try { + const fetchWeight = 0.9 + const refreshWeight = 0.1 + const isBackgroundTask = fetchType === FetchType.BackgroundTask + + const progressCallback = (progress: IFetchProgress) => { + this.updatePushPullFetchProgress(repository, { + ...progress, + value: progress.value * fetchWeight, + }) + } + + if (remotes === undefined) { + await gitStore.fetch(account, isBackgroundTask, progressCallback) + } else { + await gitStore.fetchRemotes( + account, + remotes, + isBackgroundTask, + progressCallback + ) + } + + const refreshTitle = __DARWIN__ + ? 'Refreshing Repository' + : 'Refreshing repository' + + this.updatePushPullFetchProgress(repository, { + kind: 'generic', + title: refreshTitle, + description: 'Fast-forwarding branches', + value: fetchWeight, + }) + + await this.fastForwardBranches(repository) + + this.updatePushPullFetchProgress(repository, { + kind: 'generic', + title: refreshTitle, + value: fetchWeight + refreshWeight * 0.5, + }) + + // 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) + } finally { + this.updatePushPullFetchProgress(repository, null) + + if (fetchType === FetchType.UserInitiatedTask) { + if (repository.gitHubRepository != null) { + this._refreshIssues(repository.gitHubRepository) + } + } + } + }) + } + + public _endWelcomeFlow(): Promise { + this.showWelcomeFlow = false + this.emitUpdate() + + markWelcomeFlowComplete() + + this.statsStore.recordWelcomeWizardTerminated() + + return Promise.resolve() + } + + public _setCommitMessageFocus(focus: boolean) { + if (this.focusCommitMessage !== focus) { + this.focusCommitMessage = focus + this.emitUpdate() + } + } + + public _setSidebarWidth(width: number): Promise { + this.sidebarWidth = { ...this.sidebarWidth, value: width } + setNumber(sidebarWidthConfigKey, width) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _resetSidebarWidth(): Promise { + this.sidebarWidth = { ...this.sidebarWidth, value: defaultSidebarWidth } + localStorage.removeItem(sidebarWidthConfigKey) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _setCommitSummaryWidth(width: number): Promise { + this.commitSummaryWidth = { ...this.commitSummaryWidth, value: width } + setNumber(commitSummaryWidthConfigKey, width) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _resetCommitSummaryWidth(): Promise { + this.commitSummaryWidth = { + ...this.commitSummaryWidth, + value: defaultCommitSummaryWidth, + } + localStorage.removeItem(commitSummaryWidthConfigKey) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _setCommitMessage( + repository: Repository, + message: ICommitMessage + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + return gitStore.setCommitMessage(message) + } + + /** + * Set the global application menu. + * + * This is called in response to the main process emitting an event signalling + * that the application menu has changed in some way like an item being + * added/removed or an item having its visibility toggled. + * + * This method should not be called by the renderer in any other circumstance + * than as a directly result of the main-process event. + * + */ + private setAppMenu(menu: IMenu): Promise { + if (this.appMenu) { + this.appMenu = this.appMenu.withMenu(menu) + } else { + this.appMenu = AppMenu.fromMenu(menu) + } + + this.emitUpdate() + return Promise.resolve() + } + + public _setAppMenuState( + update: (appMenu: AppMenu) => AppMenu + ): Promise { + if (this.appMenu) { + this.appMenu = update(this.appMenu) + this.emitUpdate() + } + return Promise.resolve() + } + + public _setAccessKeyHighlightState(highlight: boolean): Promise { + if (this.highlightAccessKeys !== highlight) { + this.highlightAccessKeys = highlight + this.emitUpdate() + } + + return Promise.resolve() + } + + public async _mergeBranch( + repository: Repository, + sourceBranch: Branch, + mergeStatus: MergeTreeResult | null, + isSquash: boolean = false + ): Promise { + const { multiCommitOperationState: opState } = + this.repositoryStateCache.get(repository) + + if ( + opState === null || + opState.operationDetail.kind !== MultiCommitOperationKind.Merge + ) { + log.error('[mergeBranch] - Not in merge operation state') + return + } + + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + operationDetail: { ...opState.operationDetail, sourceBranch }, + }) + ) + + const gitStore = this.gitStoreCache.get(repository) + + if (isSquash) { + this.statsStore.recordSquashMergeInvokedCount() + } + + if (mergeStatus !== null) { + if (mergeStatus.kind === ComputedAction.Clean) { + this.statsStore.recordMergeHintSuccessAndUserProceeded() + } else if (mergeStatus.kind === ComputedAction.Conflicts) { + this.statsStore.recordUserProceededAfterConflictWarning() + } else if (mergeStatus.kind === ComputedAction.Loading) { + this.statsStore.recordUserProceededWhileLoading() + } + } + + const mergeResult = await gitStore.merge(sourceBranch, isSquash) + const { tip } = gitStore + + if (mergeResult === MergeResult.Success && tip.kind === TipState.Valid) { + this._setBanner({ + type: BannerType.SuccessfulMerge, + ourBranch: tip.branch.name, + theirBranch: sourceBranch.name, + }) + if (isSquash) { + // 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._endMultiCommitOperation(repository) + } else if ( + mergeResult === MergeResult.AlreadyUpToDate && + tip.kind === TipState.Valid + ) { + this._setBanner({ + type: BannerType.BranchAlreadyUpToDate, + ourBranch: tip.branch.name, + theirBranch: sourceBranch.name, + }) + this._endMultiCommitOperation(repository) + } + + return this._refreshRepository(repository) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _setConflictsResolved(repository: Repository) { + const { multiCommitOperationState } = + this.repositoryStateCache.get(repository) + + // the operation has already completed. + if (multiCommitOperationState === null) { + return + } + + // an update is not emitted here because there is no need + // to trigger a re-render at this point + + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + userHasResolvedConflicts: true, + }) + ) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _rebase( + repository: Repository, + baseBranch: Branch, + targetBranch: Branch + ): Promise { + const progressCallback = + this.getMultiCommitOperationProgressCallBack(repository) + const gitStore = this.gitStoreCache.get(repository) + const result = await gitStore.performFailableOperation( + () => rebase(repository, baseBranch, targetBranch, progressCallback), + { + retryAction: { + type: RetryActionType.Rebase, + repository, + baseBranch, + targetBranch, + }, + } + ) + + return result || RebaseResult.Error + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _abortRebase(repository: Repository) { + const gitStore = this.gitStoreCache.get(repository) + return await gitStore.performFailableOperation(() => + abortRebase(repository) + ) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _continueRebase( + repository: Repository, + workingDirectory: WorkingDirectoryStatus, + manualResolutions: ReadonlyMap + ): Promise { + const progressCallback = + this.getMultiCommitOperationProgressCallBack(repository) + + const gitStore = this.gitStoreCache.get(repository) + const result = await gitStore.performFailableOperation(() => + continueRebase( + repository, + workingDirectory.files, + manualResolutions, + progressCallback + ) + ) + + return result || RebaseResult.Error + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _abortMerge(repository: Repository): Promise { + const gitStore = this.gitStoreCache.get(repository) + return await gitStore.performFailableOperation(() => abortMerge(repository)) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _abortSquashMerge(repository: Repository): Promise { + const gitStore = this.gitStoreCache.get(repository) + const { + branchesState, + changesState: { workingDirectory }, + } = this.repositoryStateCache.get(repository) + + const commitResult = await this._finishConflictedMerge( + repository, + workingDirectory, + new Map() + ) + + // By committing, we clear out the SQUASH_MSG (and anything else git would + // choose to store for the --squash merge operation) + if (commitResult === undefined) { + log.error( + `[_abortSquashMerge] - Could not abort squash merge - commiting squash msg failed` + ) + return + } + + // Since we have not reloaded the status, this tip is the tip before the + // squash commit above. + const { tip } = branchesState + if (tip.kind !== TipState.Valid) { + log.error( + `[_abortSquashMerge] - Could not abort squash merge - tip was invalid` + ) + return + } + + await gitStore.performFailableOperation(() => + reset(repository, GitResetMode.Hard, tip.branch.tip.sha) + ) + } + + /** This shouldn't be called directly. See `Dispatcher`. + * This method only used in the Merge Conflicts dialog flow, + * not committing a conflicted merge via the "Changes" pane. + */ + public async _finishConflictedMerge( + repository: Repository, + workingDirectory: WorkingDirectoryStatus, + manualResolutions: Map + ): Promise { + /** + * The assumption made here is that all other files that were part of this merge + * have already been staged by git automatically (or manually by the user via CLI). + * When the user executes a merge and there are conflicts, + * git stages all files that are part of the merge that _don't_ have conflicts + * This means that we only need to stage the conflicted files + * (whether they are manual or markered) to get all changes related to + * this merge staged. This also means that any uncommitted changes in the index + * that were in place before the merge was started will _not_ be included, unless + * the user stages them manually via CLI. + * + * Its also worth noting this method only used in the Merge Conflicts dialog flow, not committing a conflicted merge via the "Changes" pane. + * + * *TLDR we only stage conflicts here because git will have already staged the rest of the changes related to this merge.* + */ + const conflictedFiles = workingDirectory.files.filter(f => { + return f.status.kind === AppFileStatusKind.Conflicted + }) + const gitStore = this.gitStoreCache.get(repository) + return await gitStore.performFailableOperation(() => + createMergeCommit(repository, conflictedFiles, manualResolutions) + ) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _setRemoteURL( + repository: Repository, + name: string, + url: string + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.setRemoteURL(name, url) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _openShell(path: string) { + this.statsStore.recordOpenShell() + + try { + const match = await findShellOrDefault(this.selectedShell) + await launchShell(match, path, error => this._pushError(error)) + } catch (error) { + this.emitError(error) + } + } + + /** Takes a URL and opens it using the system default application */ + public _openInBrowser(url: string): Promise { + return shell.openExternal(url) + } + + /** Open a path to a repository or file using the user's configured editor */ + public async _openInExternalEditor(fullPath: string): Promise { + const { selectedExternalEditor } = 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 } + ) + ) + return + } + + await launchExternalEditor(fullPath, match) + } catch (error) { + this.emitError(error) + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _saveGitIgnore( + repository: Repository, + text: string + ): Promise { + await saveGitIgnore(repository, text) + return this._refreshRepository(repository) + } + + /** Set whether the user has opted out of stats reporting. */ + public async setStatsOptOut( + optOut: boolean, + userViewedPrompt: boolean + ): Promise { + await this.statsStore.setOptOut(optOut, userViewedPrompt) + + this.emitUpdate() + } + + public _setAskToMoveToApplicationsFolderSetting( + value: boolean + ): Promise { + this.askToMoveToApplicationsFolderSetting = value + + setBoolean(askToMoveToApplicationsFolderKey, value) + this.emitUpdate() + + return Promise.resolve() + } + + public _setConfirmRepositoryRemovalSetting( + confirmRepoRemoval: boolean + ): Promise { + this.askForConfirmationOnRepositoryRemoval = confirmRepoRemoval + setBoolean(confirmRepoRemovalKey, confirmRepoRemoval) + + this.updateMenuLabelsForSelectedRepository() + + this.emitUpdate() + + return Promise.resolve() + } + + public _setConfirmDiscardChangesSetting(value: boolean): Promise { + this.confirmDiscardChanges = value + + setBoolean(confirmDiscardChangesKey, value) + this.emitUpdate() + + return Promise.resolve() + } + + public _setConfirmDiscardChangesPermanentlySetting( + value: boolean + ): Promise { + this.confirmDiscardChangesPermanently = value + + setBoolean(confirmDiscardChangesPermanentlyKey, value) + this.emitUpdate() + + 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) + + this.updateMenuLabelsForSelectedRepository() + + this.emitUpdate() + + return Promise.resolve() + } + + public _setConfirmUndoCommitSetting(value: boolean): Promise { + this.confirmUndoCommit = value + setBoolean(confirmUndoCommitKey, value) + + this.emitUpdate() + + return Promise.resolve() + } + + public _setUncommittedChangesStrategySetting( + value: UncommittedChangesStrategy + ): Promise { + this.uncommittedChangesStrategy = value + + localStorage.setItem(uncommittedChangesStrategyKey, value) + + this.emitUpdate() + return Promise.resolve() + } + + public _setExternalEditor(selectedEditor: string) { + const promise = this.updateSelectedExternalEditor(selectedEditor) + localStorage.setItem(externalEditorKey, selectedEditor) + this.emitUpdate() + + this.updateMenuLabelsForSelectedRepository() + return promise + } + + public _setShell(shell: Shell): Promise { + this.selectedShell = shell + localStorage.setItem(shellKey, shell) + this.emitUpdate() + + this.updateMenuLabelsForSelectedRepository() + + return Promise.resolve() + } + + public _changeImageDiffType(type: ImageDiffType): Promise { + this.imageDiffType = type + localStorage.setItem(imageDiffTypeKey, JSON.stringify(this.imageDiffType)) + this.emitUpdate() + + return Promise.resolve() + } + + public _setHideWhitespaceInChangesDiff( + hideWhitespaceInDiff: boolean, + repository: Repository + ): Promise { + setBoolean(hideWhitespaceInChangesDiffKey, hideWhitespaceInDiff) + this.hideWhitespaceInChangesDiff = hideWhitespaceInDiff + + return this.refreshChangesSection(repository, { + includingStatus: true, + clearPartialState: true, + }) + } + + public _setHideWhitespaceInHistoryDiff( + hideWhitespaceInDiff: boolean, + repository: Repository, + file: CommittedFileChange | null + ): Promise { + setBoolean(hideWhitespaceInHistoryDiffKey, hideWhitespaceInDiff) + this.hideWhitespaceInHistoryDiff = hideWhitespaceInDiff + + if (file === null) { + return this.updateChangesWorkingDirectoryDiff(repository) + } else { + return this._changeFileSelection(repository, file) + } + } + + 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.emitUpdate() + } + } + + public _setUpdateBannerVisibility(visibility: boolean) { + this.isUpdateAvailableBannerVisible = visibility + + this.emitUpdate() + } + + public _setUpdateShowCaseVisibility(visibility: boolean) { + this.isUpdateShowcaseVisible = visibility + + this.emitUpdate() + } + + public _setBanner(state: Banner) { + this.currentBanner = state + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _clearBanner(bannerType?: BannerType) { + const { currentBanner } = this + if (currentBanner === null) { + return + } + + if (bannerType !== undefined && currentBanner.type !== bannerType) { + return + } + + this.currentBanner = null + this.emitUpdate() + } + + public _reportStats() { + return this.statsStore.reportStats(this.accounts, this.repositories) + } + + public _recordLaunchStats(stats: ILaunchStats): Promise { + return this.statsStore.recordLaunchStats(stats) + } + + public async _appendIgnoreRule( + repository: Repository, + pattern: string | string[] + ): Promise { + await appendIgnoreRule(repository, pattern) + return this._refreshRepository(repository) + } + + public async _appendIgnoreFile( + repository: Repository, + filePath: string | string[] + ): Promise { + await appendIgnoreFile(repository, filePath) + return this._refreshRepository(repository) + } + + public _resetSignInState(): Promise { + this.signInStore.reset() + return Promise.resolve() + } + + public _beginDotComSignIn(): Promise { + this.signInStore.beginDotComSignIn() + return Promise.resolve() + } + + public _beginEnterpriseSignIn(): Promise { + this.signInStore.beginEnterpriseSignIn() + return Promise.resolve() + } + + 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 async _setAppFocusState(isFocused: boolean): Promise { + if (this.appIsFocused !== isFocused) { + this.appIsFocused = isFocused + this.emitUpdate() + } + + if (this.appIsFocused) { + this.repositoryIndicatorUpdater.resume() + if (this.selectedRepository instanceof Repository) { + this.startPullRequestUpdater(this.selectedRepository) + // if we're in the tutorial and we don't have an editor yet, check for one! + if (this.currentOnboardingTutorialStep === TutorialStep.PickEditor) { + await this._resolveCurrentEditor() + } + } + } else { + this.repositoryIndicatorUpdater.pause() + this.stopPullRequestUpdater() + } + } + + /** + * Start an Open in Desktop flow. This will return a new promise which will + * resolve when `_completeOpenInDesktop` is called. + */ + public _startOpenInDesktop(fn: () => void): Promise { + const p = new Promise( + resolve => (this.resolveOpenInDesktop = resolve) + ) + fn() + return p + } + + /** + * Complete any active Open in Desktop flow with the repository returned by + * the given function. + */ + public async _completeOpenInDesktop( + fn: () => Promise + ): Promise { + const resolve = this.resolveOpenInDesktop + this.resolveOpenInDesktop = null + + const result = await fn() + if (resolve) { + resolve(result) + } + + return result + } + + public _updateRepositoryPath( + repository: Repository, + path: string + ): Promise { + return this.repositoriesStore.updateRepositoryPath(repository, path) + } + + public _removeAccount(account: Account): Promise { + log.info( + `[AppStore] removing account ${account.login} (${account.name}) from store` + ) + return this.accountsStore.removeAccount(account) + } + + private async _addAccount(account: Account): Promise { + log.info( + `[AppStore] adding account ${account.login} (${account.name}) to store` + ) + const storedAccount = await this.accountsStore.addAccount(account) + + // If we're in the welcome flow and a user signs in we want to trigger + // a refresh of the repositories available for cloning straight away + // in order to have the list of repositories ready for them when they + // get to the blankslate. + if (this.showWelcomeFlow && storedAccount !== null) { + this.apiRepositoriesStore.loadRepositories(storedAccount) + } + } + + public _updateRepositoryMissing( + repository: Repository, + missing: boolean + ): Promise { + return this.repositoriesStore.updateRepositoryMissing(repository, missing) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _updateRepositoryWorkflowPreferences( + repository: Repository, + workflowPreferences: WorkflowPreferences + ): Promise { + await this.repositoriesStore.updateRepositoryWorkflowPreferences( + repository, + workflowPreferences + ) + } + + /** + * Add a tutorial repository. + * + * This method differs from the `_addRepositories` method in that it + * requires that the repository has been created on the remote and + * set up to track it. Given that tutorial repositories are created + * from the no-repositories blank slate it shouldn't be possible for + * another repository with the same path to exist in the repositories + * table but in case that hangs in the future this method will set + * the tutorial flag on the existing repository at the given path. + */ + public async _addTutorialRepository( + path: string, + endpoint: string, + apiRepository: IAPIFullRepository + ) { + const type = await getRepositoryType(path) + if (type.kind === 'regular') { + const validatedPath = type.topLevelWorkingDirectory + log.info( + `[AppStore] adding tutorial repository at ${validatedPath} to store` + ) + + await this.repositoriesStore.addTutorialRepository( + validatedPath, + endpoint, + apiRepository + ) + this.tutorialAssessor.onNewTutorialRepository() + } else { + const error = new Error(`${path} isn't a git repository.`) + this.emitError(error) + } + } + + public async _addRepositories( + paths: ReadonlyArray + ): Promise> { + const addedRepositories = new Array() + const lfsRepositories = new Array() + const invalidPaths = new Array() + + for (const path of paths) { + const repositoryType = await getRepositoryType(path).catch(e => { + log.error('Could not determine repository type', e) + return { kind: 'missing' } as RepositoryType + }) + + if (repositoryType.kind === 'unsafe') { + const repository = await this.repositoriesStore.addRepository(path, { + missing: true, + }) + + addedRepositories.push(repository) + continue + } + + if (repositoryType.kind === 'regular') { + const validatedPath = repositoryType.topLevelWorkingDirectory + log.info(`[AppStore] adding repository at ${validatedPath} to store`) + + const repositories = this.repositories + const existing = matchExistingRepository(repositories, validatedPath) + + // We don't have to worry about repositoryWithRefreshedGitHubRepository + // and isUsingLFS if the repo already exists in the app. + if (existing !== undefined) { + addedRepositories.push(existing) + continue + } + + const addedRepo = await this.repositoriesStore.addRepository( + validatedPath + ) + + // initialize the remotes for this new repository to ensure it can fetch + // it's GitHub-related details using the GitHub API (if applicable) + const gitStore = this.gitStoreCache.get(addedRepo) + await gitStore.loadRemotes() + + const [refreshedRepo, usingLFS] = await Promise.all([ + this.repositoryWithRefreshedGitHubRepository(addedRepo), + this.isUsingLFS(addedRepo), + ]) + addedRepositories.push(refreshedRepo) + + if (usingLFS) { + lfsRepositories.push(refreshedRepo) + } + } else { + invalidPaths.push(path) + } + } + + if (invalidPaths.length > 0) { + this.emitError(new Error(this.getInvalidRepoPathsMessage(invalidPaths))) + } + + if (lfsRepositories.length > 0) { + this._showPopup({ + type: PopupType.InitializeLFS, + repositories: lfsRepositories, + }) + } + + return addedRepositories + } + + public async _removeRepository( + repository: Repository | CloningRepository, + moveToTrash: boolean + ): Promise { + try { + if (moveToTrash) { + try { + await shell.moveItemToTrash(repository.path) + } catch (error) { + log.error('Failed moving repository to trash', error) + + this.emitError( + new Error( + `Failed to move the repository directory to ${TrashNameLabel}.\n\nA common reason for this is that the directory or one of its files is open in another program.` + ) + ) + return + } + } + + if (repository instanceof CloningRepository) { + this._removeCloningRepository(repository) + } else { + await this.repositoriesStore.removeRepository(repository) + } + } catch (err) { + this.emitError(err) + return + } + + const allRepositories = await this.repositoriesStore.getAll() + if (allRepositories.length === 0) { + this._closeFoldout(FoldoutType.Repository) + } else { + this._showFoldout({ type: FoldoutType.Repository }) + } + } + + public async _cloneAgain(url: string, path: string): Promise { + const { promise, repository } = this._clone(url, path) + await this._selectRepository(repository) + const success = await promise + if (!success) { + return + } + + const repositories = this.repositories + const found = repositories.find(r => r.path === path) + + if (found) { + const updatedRepository = await this._updateRepositoryMissing( + found, + false + ) + await this._selectRepository(updatedRepository) + } + } + + private getInvalidRepoPathsMessage( + invalidPaths: ReadonlyArray + ): string { + if (invalidPaths.length === 1) { + return `${invalidPaths} isn't a Git repository.` + } + + return `The following paths aren't Git repositories:\n\n${invalidPaths + .slice(0, MaxInvalidFoldersToDisplay) + .map(path => `- ${path}`) + .join('\n')}${ + invalidPaths.length > MaxInvalidFoldersToDisplay + ? `\n\n(and ${invalidPaths.length - MaxInvalidFoldersToDisplay} more)` + : '' + }` + } + + private async withAuthenticatingUser( + repository: Repository, + fn: (repository: Repository, account: IGitAccount | null) => Promise + ): Promise { + let updatedRepository = repository + let account: IGitAccount | null = getAccountForRepository( + this.accounts, + updatedRepository + ) + + // If we don't have a user association, it might be because we haven't yet + // tried to associate the repository with a GitHub repository, or that + // association is out of date. So try again before we bail on providing an + // authenticating user. + if (!account) { + 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) + } + + private updateRevertProgress( + repository: Repository, + progress: IRevertProgress | null + ) { + this.repositoryStateCache.update(repository, () => ({ + revertProgress: progress, + })) + + if (this.selectedRepository === repository) { + this.emitUpdate() + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _revertCommit( + repository: Repository, + commit: Commit + ): Promise { + return this.withAuthenticatingUser(repository, async (repo, account) => { + const gitStore = this.gitStoreCache.get(repo) + + await gitStore.revertCommit(repo, commit, account, progress => { + this.updateRevertProgress(repo, progress) + }) + + this.updateRevertProgress(repo, 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) + } catch (error) { + this.emitError(error) + } + } + + private async isUsingLFS(repository: Repository): Promise { + try { + return await isUsingLFS(repository) + } catch (error) { + return false + } + } + + public async _installLFSHooks( + repositories: ReadonlyArray + ): Promise { + for (const repo of repositories) { + try { + // At this point we've asked the user if we should install them, so + // force installation. + await installLFSHooks(repo, true) + } catch (error) { + this.emitError(error) + } + } + } + + public _changeCloneRepositoriesTab(tab: CloneRepositoryTab): Promise { + this.selectedCloneRepositoryTab = tab + + this.emitUpdate() + + return Promise.resolve() + } + + /** + * Request a refresh of the list of repositories that + * the provided account has explicit permissions to access. + * See ApiRepositoriesStore for more details. + */ + public _refreshApiRepositories(account: Account) { + return this.apiRepositoriesStore.loadRepositories(account) + } + + public _changeBranchesTab(tab: BranchesTab): Promise { + this.selectedBranchesTab = tab + + this.emitUpdate() + + return Promise.resolve() + } + + public async _showGitHubExplore(repository: Repository): Promise { + const { gitHubRepository } = repository + if (!gitHubRepository || gitHubRepository.htmlURL === null) { + return + } + + const url = new URL(gitHubRepository.htmlURL) + url.pathname = '/explore' + + await this._openInBrowser(url.toString()) + } + + public async _createPullRequest( + repository: Repository, + baseBranch?: Branch + ): Promise { + const gitHubRepository = repository.gitHubRepository + if (!gitHubRepository) { + return + } + + const state = this.repositoryStateCache.get(repository) + const tip = state.branchesState.tip + + if (tip.kind !== TipState.Valid) { + return + } + + const compareBranch = tip.branch + const aheadBehind = state.aheadBehind + + if (aheadBehind == null) { + this._showPopup({ + type: PopupType.PushBranchCommits, + repository, + branch: compareBranch, + }) + } else if (aheadBehind.ahead > 0) { + this._showPopup({ + type: PopupType.PushBranchCommits, + repository, + branch: compareBranch, + unPushedCommits: aheadBehind.ahead, + }) + } else { + await this._openCreatePullRequestInBrowser( + repository, + compareBranch, + baseBranch + ) + } + } + + public async _showPullRequest(repository: Repository): Promise { + // no pull requests from non github repos + if (repository.gitHubRepository === null) { + return + } + + const currentPullRequest = + this.repositoryStateCache.get(repository).branchesState.currentPullRequest + + if (currentPullRequest === null) { + return + } + + return this._showPullRequestByPR(currentPullRequest) + } + + public async _showPullRequestByPR(pr: PullRequest): Promise { + const { htmlURL: baseRepoUrl } = pr.base.gitHubRepository + + if (baseRepoUrl === null) { + return + } + + const showPrUrl = `${baseRepoUrl}/pull/${pr.pullRequestNumber}` + + await this._openInBrowser(showPrUrl) + } + + public async _refreshPullRequests(repository: Repository): Promise { + if (isRepositoryWithGitHubRepository(repository)) { + const account = getAccountForRepository(this.accounts, repository) + if (account !== null) { + await this.pullRequestCoordinator.refreshPullRequests( + repository, + account + ) + } + } + } + + private async onPullRequestChanged( + repository: Repository, + openPullRequests: ReadonlyArray + ) { + this.repositoryStateCache.updateBranchesState(repository, () => { + return { openPullRequests } + }) + + this.updateCurrentPullRequest(repository) + this.gitStoreCache.get(repository).pruneForkedRemotes(openPullRequests) + + const selectedState = this.getSelectedState() + + // Update menu labels if the currently selected repository is the + // repository for which we received an update. + if (selectedState && selectedState.type === SelectionType.Repository) { + if (selectedState.repository.id === repository.id) { + this.updateMenuLabelsForSelectedRepository() + } + } + this.emitUpdate() + } + + private updateCurrentPullRequest(repository: Repository) { + const gitHubRepository = repository.gitHubRepository + + if (!gitHubRepository) { + return + } + + this.repositoryStateCache.updateBranchesState(repository, state => { + let currentPullRequest: PullRequest | null = null + + const { remote } = this.repositoryStateCache.get(repository) + + if (state.tip.kind === TipState.Valid && remote) { + currentPullRequest = findAssociatedPullRequest( + state.tip.branch, + state.openPullRequests, + remote + ) + } + + return { currentPullRequest } + }) + + this.emitUpdate() + } + + public async _openCreatePullRequestInBrowser( + repository: Repository, + compareBranch: Branch, + baseBranch?: Branch + ): Promise { + const gitHubRepository = repository.gitHubRepository + if (!gitHubRepository) { + return + } + + const { parent, owner, name, htmlURL } = gitHubRepository + const isForkContributingToParent = + isForkedRepositoryContributingToParent(repository) + + const baseForkPreface = + isForkContributingToParent && parent !== null + ? `${parent.owner.login}:${parent.name}:` + : '' + const encodedBaseBranch = + baseBranch !== undefined + ? baseForkPreface + + encodeURIComponent(baseBranch.nameWithoutRemote) + + '...' + : '' + + const compareForkPreface = isForkContributingToParent + ? `${owner.login}:${name}:` + : '' + + const encodedCompareBranch = + compareForkPreface + encodeURIComponent(compareBranch.nameWithoutRemote) + + const compareString = `${encodedBaseBranch}${encodedCompareBranch}` + const baseURL = `${htmlURL}/pull/new/${compareString}` + + await this._openInBrowser(baseURL) + + if (this.currentOnboardingTutorialStep === TutorialStep.OpenPullRequest) { + this._markPullRequestTutorialStepAsComplete(repository) + } + } + + public async _updateExistingUpstreamRemote( + repository: Repository + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.updateExistingUpstreamRemote() + + return this._refreshRepository(repository) + } + + private getIgnoreExistingUpstreamRemoteKey(repository: Repository): string { + return `repository/${repository.id}/ignoreExistingUpstreamRemote` + } + + public _ignoreExistingUpstreamRemote(repository: Repository): Promise { + const key = this.getIgnoreExistingUpstreamRemoteKey(repository) + setBoolean(key, true) + + return Promise.resolve() + } + + private getIgnoreExistingUpstreamRemote( + repository: Repository + ): Promise { + const key = this.getIgnoreExistingUpstreamRemoteKey(repository) + return Promise.resolve(getBoolean(key, false)) + } + + private async addUpstreamRemoteIfNeeded(repository: Repository) { + const gitStore = this.gitStoreCache.get(repository) + const ignored = await this.getIgnoreExistingUpstreamRemote(repository) + if (ignored) { + return + } + + return gitStore.addUpstreamRemoteIfNeeded() + } + + public async _checkoutPullRequest( + repository: RepositoryWithGitHubRepository, + prNumber: number, + headRepoOwner: string, + headCloneUrl: string, + headRefName: string + ): Promise { + const prBranch = await this._findPullRequestBranch( + repository, + prNumber, + headRepoOwner, + headCloneUrl, + headRefName + ) + if (prBranch !== undefined) { + await this._checkoutBranch(repository, prBranch) + this.statsStore.recordPRBranchCheckout() + } + } + + public async _findPullRequestBranch( + repository: RepositoryWithGitHubRepository, + prNumber: number, + headRepoOwner: string, + headCloneUrl: string, + headRefName: string + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + const remotes = await getRemotes(repository) + + // Find an existing remote (regardless if set up by us or outside of + // Desktop). + let remote = remotes.find(r => urlMatchesRemote(headCloneUrl, r)) + + // If we can't find one we'll create a Desktop fork remote. + if (remote === undefined) { + try { + const forkRemoteName = forkPullRequestRemoteName(headRepoOwner) + remote = await addRemote(repository, forkRemoteName, headCloneUrl) + } catch (e) { + this.emitError( + new Error( + `Couldn't find PR branch, adding remote failed: ${e.message}` + ) + ) + return + } + } + + const remoteRef = `${remote.name}/${headRefName}` + + // Start by trying to find a local branch that is tracking the remote ref. + let existingBranch = gitStore.allBranches.find( + x => x.type === BranchType.Local && x.upstream === remoteRef + ) + + // If we found one, let's check it out and get out of here, quick + if (existingBranch !== undefined) { + return existingBranch + } + + const findRemoteBranch = (name: string) => + gitStore.allBranches.find( + x => x.type === BranchType.Remote && x.name === name + ) + + // No such luck, let's see if we can at least find the remote branch then + existingBranch = findRemoteBranch(remoteRef) + + // It's quite possible that the PR was created after our last fetch of the + // remote so let's fetch it and then try again. + if (existingBranch === undefined) { + try { + await this._fetchRemote(repository, remote, FetchType.UserInitiatedTask) + existingBranch = findRemoteBranch(remoteRef) + } catch (e) { + log.error(`Failed fetching remote ${remote?.name}`, e) + } + } + + if (existingBranch === undefined) { + this.emitError( + new Error( + `Couldn't find branch '${headRefName}' in remote '${remote.name}'. ` + + `A common reason for this is that the PR author has deleted their ` + + `branch or their forked repository.` + ) + ) + return + } + + // For fork remotes we checkout the ref as pr/[123] instead of using the + // head ref name since many PRs from forks are created from their default + // branch so we'll have a very high likelihood of a conflicting local branch + const isForkRemote = + remote.name !== gitStore.defaultRemote?.name && + remote.name !== gitStore.upstreamRemote?.name + + if (isForkRemote) { + return await this._createBranch( + repository, + `pr/${prNumber}`, + remoteRef, + false + ) + } + + return existingBranch + } + + /** + * Set whether the user has chosen to hide or show the + * co-authors field in the commit message component + */ + public _setShowCoAuthoredBy( + repository: Repository, + showCoAuthoredBy: boolean + ) { + this.gitStoreCache.get(repository).setShowCoAuthoredBy(showCoAuthoredBy) + return Promise.resolve() + } + + /** + * Update the per-repository co-authors list + * + * @param repository Co-author settings are per-repository + * @param coAuthors Zero or more authors + */ + public _setCoAuthors( + repository: Repository, + coAuthors: ReadonlyArray + ) { + this.gitStoreCache.get(repository).setCoAuthors(coAuthors) + return Promise.resolve() + } + + /** + * Set the application-wide theme + */ + public _setSelectedTheme(theme: ApplicationTheme) { + setPersistedTheme(theme) + this.selectedTheme = theme + this.emitUpdate() + + return Promise.resolve() + } + + public async _resolveCurrentEditor() { + const match = await findEditorOrDefault(this.selectedExternalEditor) + const resolvedExternalEditor = match != null ? match.editor : null + if (this.resolvedExternalEditor !== resolvedExternalEditor) { + this.resolvedExternalEditor = resolvedExternalEditor + + // Make sure we let the tutorial assessor know that we have a new editor + // in case it's stuck waiting for one to be selected. + if (this.currentOnboardingTutorialStep === TutorialStep.PickEditor) { + if (this.selectedRepository instanceof Repository) { + this.updateCurrentTutorialStep(this.selectedRepository) + } + } + + this.emitUpdate() + } + } + + public getResolvedExternalEditor = () => { + return this.resolvedExternalEditor + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _updateManualConflictResolution( + repository: Repository, + path: string, + manualResolution: ManualConflictResolution | null + ) { + this.repositoryStateCache.updateChangesState(repository, state => { + const { conflictState } = state + + if (conflictState === null) { + // not currently in a conflict, whatever + return { conflictState } + } + + const updatedManualResolutions = new Map(conflictState.manualResolutions) + + if (manualResolution !== null) { + updatedManualResolutions.set(path, manualResolution) + } else { + updatedManualResolutions.delete(path) + } + + return { + conflictState: { + ...conflictState, + manualResolutions: updatedManualResolutions, + }, + } + }) + + this.updateMultiCommitOperationStateAfterManualResolution(repository) + + this.emitUpdate() + } + + /** + * Updates the multi commit operation conflict step state as the manual + * resolutions have been changed. + */ + private updateMultiCommitOperationStateAfterManualResolution( + repository: Repository + ): void { + const currentState = this.repositoryStateCache.get(repository) + + const { changesState, multiCommitOperationState } = currentState + + if ( + changesState.conflictState === null || + multiCommitOperationState === null || + multiCommitOperationState.step.kind !== + MultiCommitOperationStepKind.ShowConflicts + ) { + return + } + const { step } = multiCommitOperationState + + const { manualResolutions } = changesState.conflictState + const conflictState = { ...step.conflictState, manualResolutions } + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + step: { ...step, conflictState }, + }) + ) + } + + private async createStashAndDropPreviousEntry( + repository: Repository, + branch: Branch + ) { + const entry = await getLastDesktopStashEntryForBranch(repository, branch) + const gitStore = this.gitStoreCache.get(repository) + + const createdStash = await gitStore.performFailableOperation(() => + this.createStashEntry(repository, branch) + ) + + if (createdStash === true && entry !== null) { + const { stashSha, branchName } = entry + await gitStore.performFailableOperation(async () => { + await dropDesktopStashEntry(repository, stashSha) + log.info(`Dropped stash '${stashSha}' associated with ${branchName}`) + }) + } + + return createdStash === true + } + + private async createStashEntry(repository: Repository, branch: Branch) { + const { changesState } = this.repositoryStateCache.get(repository) + const { workingDirectory } = changesState + const untrackedFiles = getUntrackedFiles(workingDirectory) + + return createDesktopStashEntry(repository, branch, untrackedFiles) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _popStashEntry(repository: Repository, stashEntry: IStashEntry) { + await popStashEntry(repository, stashEntry.stashSha) + log.info( + `[AppStore. _popStashEntry] popped stash with commit id ${stashEntry.stashSha}` + ) + + this.statsStore.recordStashRestore() + await this._refreshRepository(repository) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _dropStashEntry( + repository: Repository, + stashEntry: IStashEntry + ) { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.performFailableOperation(() => { + return dropDesktopStashEntry(repository, stashEntry.stashSha) + }) + log.info( + `[AppStore. _dropStashEntry] dropped stash with commit id ${stashEntry.stashSha}` + ) + + this.statsStore.recordStashDiscard() + await gitStore.loadStashEntries() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _setStashedFilesWidth(width: number): Promise { + this.stashedFilesWidth = { ...this.stashedFilesWidth, value: width } + setNumber(stashedFilesWidthConfigKey, width) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _resetStashedFilesWidth(): Promise { + this.stashedFilesWidth = { + ...this.stashedFilesWidth, + value: defaultStashedFilesWidth, + } + localStorage.removeItem(stashedFilesWidthConfigKey) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public async _testPruneBranches() { + if (this.currentBranchPruner === null) { + return + } + + await this.currentBranchPruner.testPrune() + } + + public async _showCreateForkDialog( + repository: RepositoryWithGitHubRepository + ) { + const account = getAccountForRepository(this.accounts, repository) + if (account === null) { + return + } + await this._showPopup({ + type: PopupType.CreateFork, + repository, + account, + }) + } + + /** + * Converts a local repository to use the given fork + * as its default remote and associated `GitHubRepository`. + */ + public async _convertRepositoryToFork( + repository: RepositoryWithGitHubRepository, + fork: IAPIFullRepository + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + const defaultRemoteName = gitStore.defaultRemote?.name + const remoteUrl = gitStore.defaultRemote?.url + const { endpoint } = repository.gitHubRepository + + // make sure there is a default remote (there should be) + if (defaultRemoteName !== undefined && remoteUrl !== undefined) { + // update default remote + if (await gitStore.setRemoteURL(defaultRemoteName, fork.clone_url)) { + await gitStore.ensureUpstreamRemoteURL(remoteUrl) + // update associated github repo + return this.repositoriesStore.setGitHubRepository( + repository, + await this.repositoriesStore.upsertGitHubRepository(endpoint, fork) + ) + } + } + return repository + } + + /** + * Create a tutorial repository using the given account. The account + * determines which host (i.e. GitHub.com or a GHES instance) that + * the tutorial repository should be created on. + * + * @param account The account (and thereby the GitHub host) under + * which the repository is to be created created + */ + public async _createTutorialRepository(account: Account) { + try { + await this.statsStore.recordTutorialStarted() + + const name = 'desktop-tutorial' + const path = Path.resolve(await getDefaultDir(), name) + + const apiRepository = await createTutorialRepository( + account, + name, + path, + (title, value, description) => { + if ( + this.popupManager.currentPopup?.type === + PopupType.CreateTutorialRepository + ) { + this.popupManager.updatePopup({ + ...this.popupManager.currentPopup, + progress: { kind: 'generic', title, value, description }, + }) + this.emitUpdate() + } + } + ) + + await this._addTutorialRepository(path, account.endpoint, apiRepository) + await this.statsStore.recordTutorialRepoCreated() + } catch (err) { + sendNonFatalException('tutorialRepoCreation', err) + + if (err instanceof GitError) { + this.emitError(err) + } else { + this.emitError( + new Error( + `Failed creating the tutorial repository.\n\n${err.message}` + ) + ) + } + } finally { + this._closePopup(PopupType.CreateTutorialRepository) + } + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _initializeCherryPickProgress( + repository: Repository, + commits: ReadonlyArray + ) { + // This shouldn't happen... but in case throw error. + const lastCommit = forceUnwrap( + 'Unable to initialize cherry-pick progress. No commits provided.', + commits.at(-1) + ) + + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + progress: { + kind: 'multiCommitOperation', + value: 0, + position: 1, + totalCommitCount: commits.length, + currentCommitSummary: lastCommit.summary, + }, + }) + ) + + this.emitUpdate() + } + + private getMultiCommitOperationProgressCallBack(repository: Repository) { + return (progress: IMultiCommitOperationProgress) => { + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + progress, + }) + ) + this.emitUpdate() + } + } + + /** + * 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].sort((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].sort((a, b) => + compare(commitIndexBySha.get(b), commitIndexBySha.get(a)) + ) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _cherryPick( + repository: Repository, + commits: ReadonlyArray + ): Promise { + if (commits.length === 0) { + log.error('[_cherryPick] - Unable to cherry-pick. No commits provided.') + 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, orderedCommits, progressCallback) + ) + + return result || CherryPickResult.Error + } + + /** + * Checks for uncommitted changes + * + * If uncommitted changes exist, ask user to stash, retry provided retry + * action and return true. + * + * If no uncommitted changes, return false. + * + * This shouldn't be called directly. See `Dispatcher`. + */ + public _checkForUncommittedChanges( + repository: Repository, + retryAction: RetryAction + ): boolean { + const { changesState } = this.repositoryStateCache.get(repository) + const hasChanges = changesState.workingDirectory.files.length > 0 + if (!hasChanges) { + return false + } + + this._showPopup({ + type: PopupType.LocalChangesOverwritten, + repository, + retryAction, + files: changesState.workingDirectory.files.map(f => f.path), + }) + + return true + } + + /** + * Attempts to checkout target branch and return it's name after checkout. + * This is useful if you want the local name when checking out a potentially + * remote branch during an operation. + * + * Note: This does not do any existing changes checking like _checkout does. + * + * This shouldn't be called directly. See `Dispatcher`. + */ + public async _checkoutBranchReturnName( + repository: Repository, + targetBranch: Branch + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + + const checkoutSuccessful = await this.withAuthenticatingUser( + repository, + (r, account) => { + return gitStore.performFailableOperation(() => + checkoutBranch(repository, account, targetBranch) + ) + } + ) + + if (checkoutSuccessful !== true) { + return + } + + const status = await gitStore.loadStatus() + return status?.currentBranch + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _abortCherryPick( + repository: Repository, + sourceBranch: Branch | null + ): Promise { + const gitStore = this.gitStoreCache.get(repository) + + await gitStore.performFailableOperation(() => abortCherryPick(repository)) + + await this.checkoutBranchIfNotNull(repository, sourceBranch) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _setCherryPickBranchCreated( + repository: Repository, + branchCreated: boolean + ): void { + const { multiCommitOperationState: opState } = + this.repositoryStateCache.get(repository) + + if ( + opState === null || + opState.operationDetail.kind !== MultiCommitOperationKind.CherryPick + ) { + log.error( + '[setCherryPickBranchCreated] - Not in cherry-pick operation state' + ) + return + } + + // An update is not emitted here because there is no need + // to trigger a re-render at this point. (storing for later) + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + operationDetail: { ...opState.operationDetail, branchCreated }, + }) + ) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _continueCherryPick( + repository: Repository, + files: ReadonlyArray, + manualResolutions: ReadonlyMap + ): Promise { + const progressCallback = + this.getMultiCommitOperationProgressCallBack(repository) + + const gitStore = this.gitStoreCache.get(repository) + const result = await gitStore.performFailableOperation(() => + continueCherryPick(repository, files, manualResolutions, progressCallback) + ) + + return result || CherryPickResult.Error + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _setCherryPickProgressFromState(repository: Repository) { + const snapshot = await getCherryPickSnapshot(repository) + if (snapshot === null) { + return + } + + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + progress: snapshot.progress, + }) + ) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _clearCherryPickingHead( + repository: Repository, + sourceBranch: Branch | null + ): Promise { + if (!isCherryPickHeadFound(repository)) { + return + } + + const gitStore = this.gitStoreCache.get(repository) + await gitStore.performFailableOperation(() => abortCherryPick(repository)) + + await this.checkoutBranchIfNotNull(repository, sourceBranch) + + return this._refreshRepository(repository) + } + + private async checkoutBranchIfNotNull( + repository: Repository, + sourceBranch: Branch | null + ) { + if (sourceBranch === null) { + return + } + + const gitStore = this.gitStoreCache.get(repository) + await this.withAuthenticatingUser(repository, async (r, account) => { + await gitStore.performFailableOperation(() => + checkoutBranch(repository, account, sourceBranch) + ) + }) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _setDragElement(dragElement: DragElement | null): Promise { + this.currentDragElement = dragElement + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _getBranchAheadBehind( + repository: Repository, + branch: Branch + ): Promise { + return getBranchAheadBehind(repository, branch) + } + + public _setLastThankYou(lastThankYou: ILastThankYou) { + // don't update if same length and same version (assumption + // is that update will be either adding a user or updating version) + const sameVersion = + this.lastThankYou !== undefined && + this.lastThankYou.version === lastThankYou.version + + const sameNumCheckedUsers = + this.lastThankYou !== undefined && + this.lastThankYou.checkedUsers.length === lastThankYou.checkedUsers.length + + if (sameVersion && sameNumCheckedUsers) { + return + } + + setObject(lastThankYouKey, lastThankYou) + this.lastThankYou = lastThankYou + + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _reorderCommits( + repository: Repository, + commitsToReorder: ReadonlyArray, + beforeCommit: Commit | null, + lastRetainedCommitRef: string | null + ): Promise { + if (commitsToReorder.length === 0) { + log.error('[_reorder] - Unable to reorder. No commits provided.') + return RebaseResult.Error + } + + const progressCallback = + this.getMultiCommitOperationProgressCallBack(repository) + const gitStore = this.gitStoreCache.get(repository) + const result = await gitStore.performFailableOperation(() => + reorder( + repository, + commitsToReorder, + beforeCommit, + lastRetainedCommitRef, + progressCallback + ) + ) + + return result || RebaseResult.Error + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _squash( + repository: Repository, + toSquash: ReadonlyArray, + squashOnto: Commit, + lastRetainedCommitRef: string | null, + commitContext: ICommitContext + ): Promise { + if (toSquash.length === 0) { + log.error('[_squash] - Unable to squash. No commits provided.') + return RebaseResult.Error + } + + const progressCallback = + this.getMultiCommitOperationProgressCallBack(repository) + const commitMessage = await formatCommitMessage(repository, commitContext) + const gitStore = this.gitStoreCache.get(repository) + const result = await gitStore.performFailableOperation(() => + squash( + repository, + toSquash, + squashOnto, + lastRetainedCommitRef, + commitMessage, + progressCallback + ) + ) + + return result || RebaseResult.Error + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _undoMultiCommitOperation( + mcos: IMultiCommitOperationState, + repository: Repository, + commitsCount: number + ): Promise { + const { + branchesState, + multiCommitOperationUndoState, + changesState: { workingDirectory }, + } = this.repositoryStateCache.get(repository) + const { operationDetail } = mcos + const { kind } = operationDetail + + if (multiCommitOperationUndoState === null) { + log.error( + `[_undoMultiCommitOperation] - Could not undo ${kind}. There is no undo info available.` + ) + return false + } + + const { undoSha, branchName } = multiCommitOperationUndoState + + if (workingDirectory.files.length > 0) { + log.error( + `[_undoMultiCommitOperation] - Could not undo ${kind}. This would delete the local changes that exist on the branch.` + ) + return false + } + + const { tip } = branchesState + if (tip.kind !== TipState.Valid || tip.branch.name !== branchName) { + log.error( + `[_undoMultiCommitOperation] - Could not undo ${kind}. User no longer on branch the ${kind} occurred on.` + ) + return false + } + + if (undoSha === null) { + log.error('[_undoMultiCommitOperation] - Could not determine undo sha') + return false + } + + // If a new branch is created as part of the cherry-pick, + // We just want to delete it, no need to reset it. + if ( + operationDetail.kind === MultiCommitOperationKind.CherryPick && + operationDetail.branchCreated + ) { + this._deleteBranch( + repository, + tip.branch, + false, + operationDetail.sourceBranch + ) + return true + } + + const gitStore = this.gitStoreCache.get(repository) + const result = await gitStore.performFailableOperation(() => + reset(repository, GitResetMode.Hard, undoSha) + ) + + if (result !== true) { + return false + } + + let banner: Banner + + switch (kind) { + case MultiCommitOperationKind.Squash: + banner = { + type: BannerType.SquashUndone, + commitsCount, + } + break + case MultiCommitOperationKind.Reorder: + banner = { + type: BannerType.ReorderUndone, + commitsCount, + } + break + case MultiCommitOperationKind.CherryPick: + const sourceBranch = + operationDetail.kind === MultiCommitOperationKind.CherryPick + ? operationDetail.sourceBranch + : null + await this.checkoutBranchIfNotNull(repository, sourceBranch) + banner = { + type: BannerType.CherryPickUndone, + targetBranchName: branchName, + countCherryPicked: commitsCount, + } + break + case MultiCommitOperationKind.Rebase: + case MultiCommitOperationKind.Merge: + throw new Error( + `Unexpected multi commit operation kind to undo ${kind}` + ) + default: + assertNever(kind, `Unsupported multi operation kind to undo ${kind}`) + } + + this._setBanner(banner) + + await this._loadStatus(repository) + + const stateAfter = this.repositoryStateCache.get(repository) + // Cherry-pick doesn't require a force push but squash and reorder may. (rebase, merge not supported) + if ( + stateAfter.branchesState.tip.kind === TipState.Valid && + kind !== MultiCommitOperationKind.CherryPick + ) { + this._addBranchToForcePushList( + repository, + stateAfter.branchesState.tip, + tip.branch.tip.sha + ) + } + + await this._refreshRepository(repository) + + return true + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _addBranchToForcePushList = ( + repository: Repository, + tipWithBranch: IValidBranch, + beforeChangeSha: string + ) => { + // if the commit id of the branch is unchanged, it can be excluded from + // this list + if (tipWithBranch.branch.tip.sha === beforeChangeSha) { + return + } + + const currentState = this.repositoryStateCache.get(repository) + const { forcePushBranches } = currentState.branchesState + + const updatedMap = new Map(forcePushBranches) + updatedMap.set( + tipWithBranch.branch.nameWithoutRemote, + tipWithBranch.branch.tip.sha + ) + + this.repositoryStateCache.updateBranchesState(repository, () => ({ + forcePushBranches: updatedMap, + })) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _setMultiCommitOperationUndoState( + repository: Repository, + tip: IValidBranch + ): void { + // An update is not emitted here because there is no need + // to trigger a re-render at this point. (storing for later) + this.repositoryStateCache.updateMultiCommitOperationUndoState( + repository, + () => ({ + undoSha: getTipSha(tip), + branchName: tip.branch.name, + }) + ) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _handleConflictsDetectedOnError( + repository: Repository, + currentBranch: string, + theirBranch: string + ): Promise { + const { multiCommitOperationState } = + this.repositoryStateCache.get(repository) + + if (multiCommitOperationState === null) { + const gitStore = this.gitStoreCache.get(repository) + + const targetBranch = gitStore.allBranches.find( + branch => branch.name === currentBranch + ) + + if (targetBranch === undefined) { + return + } + + const sourceBranch = gitStore.allBranches.find( + branch => branch.name === theirBranch + ) + + this._initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.Merge, + isSquash: false, + sourceBranch: sourceBranch ?? null, + }, + targetBranch, + [], + targetBranch.tip.sha + ) + } + + this._setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.ShowConflicts, + conflictState: { + kind: 'multiCommitOperation', + manualResolutions: new Map(), + ourBranch: currentBranch, + theirBranch, + }, + }) + + return this._showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _setMultiCommitOperationStep( + repository: Repository, + step: MultiCommitOperationStep + ): Promise { + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + step, + }) + ) + + this.emitUpdate() + } + + public _setMultiCommitOperationTargetBranch( + repository: Repository, + targetBranch: Branch + ): void { + this.repositoryStateCache.updateMultiCommitOperationState( + repository, + () => ({ + targetBranch, + }) + ) + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _endMultiCommitOperation(repository: Repository): void { + this.repositoryStateCache.clearMultiCommitOperationState(repository) + this.emitUpdate() + } + + /** This shouldn't be called directly. See `Dispatcher`. */ + public _initializeMultiCommitOperation( + repository: Repository, + operationDetail: MultiCommitOperationDetail, + targetBranch: Branch | null, + commits: ReadonlyArray, + originalBranchTip: string | null, + emitUpdate: boolean = true + ): void { + this.repositoryStateCache.initializeMultiCommitOperationState(repository, { + step: { + kind: MultiCommitOperationStepKind.ShowProgress, + }, + operationDetail, + progress: { + kind: 'multiCommitOperation', + currentCommitSummary: commits.length > 0 ? commits[0].summary : '', + position: 1, + totalCommitCount: commits.length, + value: 0, + }, + userHasResolvedConflicts: false, + originalBranchTip, + targetBranch, + }) + + if (!emitUpdate) { + return + } + + this.emitUpdate() + } + + public _setShowCIStatusPopover(showCIStatusPopover: boolean) { + if (this.showCIStatusPopover !== showCIStatusPopover) { + this.showCIStatusPopover = showCIStatusPopover + this.emitUpdate() + } + } + + public _toggleCIStatusPopover() { + this.showCIStatusPopover = !this.showCIStatusPopover + this.emitUpdate() + } + + private onChecksFailedNotification = async ( + repository: RepositoryWithGitHubRepository, + pullRequest: PullRequest, + commitMessage: string, + commitSha: string, + checks: ReadonlyArray + ) => { + const selectedRepository = + this.selectedRepository ?? (await this._selectRepository(repository)) + + const popup: Popup = { + type: PopupType.PullRequestChecksFailed, + pullRequest, + repository, + shouldChangeRepository: true, + commitMessage, + commitSha, + checks, + } + + // If the repository doesn't match the one from the notification, just show + // the popup which will suggest to switch to that repo. + if ( + selectedRepository === null || + selectedRepository.hash !== repository.hash + ) { + this.statsStore.recordChecksFailedDialogOpen() + return this._showPopup(popup) + } + + const state = this.repositoryStateCache.get(repository) + + const { branchesState } = state + const { tip } = branchesState + const currentBranch = tip.kind === TipState.Valid ? tip.branch : null + + if (currentBranch !== null && currentBranch.name === pullRequest.head.ref) { + // If it's the same branch, just show the existing CI check run popover + this._setShowCIStatusPopover(true) + } else { + this.statsStore.recordChecksFailedDialogOpen() + + // 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 + // repository. + return this._showPopup({ + ...popup, + shouldChangeRepository: false, + }) + } + } + + private onPullRequestReviewSubmitNotification = async ( + repository: RepositoryWithGitHubRepository, + pullRequest: PullRequest, + review: ValidNotificationPullRequestReview + ) => { + 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.PullRequestReview, + shouldCheckoutBranch: + currentBranch !== null && currentBranch.name !== pullRequest.head.ref, + shouldChangeRepository: + selectedRepository === null || + selectedRepository.hash !== repository.hash, + review, + pullRequest, + repository, + }) + } + + 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.recordPreviewedPullRequest() + + 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() + } + } +} + +/** + * Map the cached state of the compare view to an action + * to perform which is then used to compute the compare + * view contents. + */ +function getInitialAction( + cachedState: IDisplayHistory | ICompareBranch +): CompareAction { + if (cachedState.kind === HistoryTabMode.History) { + return { + kind: HistoryTabMode.History, + } + } + + const { comparisonMode, comparisonBranch } = cachedState + + return { + kind: HistoryTabMode.Compare, + comparisonMode, + branch: comparisonBranch, + } +} + +function userIsStartingMultiCommitOperation( + currentPopup: Popup | null, + state: IMultiCommitOperationState | null +) { + if (currentPopup === null || state === null) { + return false + } + + if (currentPopup.type !== PopupType.MultiCommitOperation) { + return false + } + + if ( + state.step.kind === MultiCommitOperationStepKind.ChooseBranch || + state.step.kind === MultiCommitOperationStepKind.WarnForcePush || + state.step.kind === MultiCommitOperationStepKind.ShowProgress + ) { + return true + } + + return false +} + +function isLocalChangesOverwrittenError(error: Error): boolean { + if (error instanceof ErrorWithMetadata) { + return isLocalChangesOverwrittenError(error.underlyingError) + } + + return ( + error instanceof GitError && + error.result.gitError === DugiteError.LocalChangesOverwritten + ) +} + +function constrain( + value: IConstrainedValue | number, + min = -Infinity, + max = Infinity +): IConstrainedValue { + // 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/base-store.ts b/app/src/lib/stores/base-store.ts new file mode 100644 index 0000000000..8eb8acb423 --- /dev/null +++ b/app/src/lib/stores/base-store.ts @@ -0,0 +1,55 @@ +import { Emitter, Disposable } from 'event-kit' + +export abstract class BaseStore { + protected readonly emitter = new Emitter() + + protected emitUpdate() { + this.emitter.emit('did-update', {}) + } + + protected emitError(error: Error) { + this.emitter.emit('did-error', error) + } + + /** Register a function to be called when the store updates. */ + public onDidUpdate(fn: () => void): Disposable { + return this.emitter.on('did-update', fn) + } + + /** + * Register an event handler which will be invoked whenever + * an unexpected error occurs during the sign-in process. Note + * that some error are handled in the flow and passed along in + * the sign in state for inline presentation to the user. + */ + public onDidError(fn: (e: Error) => void): Disposable { + return this.emitter.on('did-error', fn) + } +} + +export class TypedBaseStore { + protected readonly emitter = new Emitter() + + protected emitUpdate(data: T) { + this.emitter.emit('did-update', data) + } + + protected emitError(error: Error) { + this.emitter.emit('did-error', error) + } + + /** Register a function to be called when the store updates. */ + public onDidUpdate(fn: (data: T) => void): Disposable { + return this.emitter.on('did-update', fn) + } + + /** + * Register an event handler which will be invoked whenever + * an unexpected error occurs during the sign-in process. Note + * that some error are handled in the flow and passed along in + * the sign in state for inline presentation to the user. + */ + public onDidError(fn: (e: Error) => void): Disposable { + return this.emitter.on('did-error', fn) + } +} diff --git a/app/src/lib/stores/cloning-repositories-store.ts b/app/src/lib/stores/cloning-repositories-store.ts new file mode 100644 index 0000000000..0e12579fd2 --- /dev/null +++ b/app/src/lib/stores/cloning-repositories-store.ts @@ -0,0 +1,82 @@ +import { CloningRepository } from '../../models/cloning-repository' +import { ICloneProgress } from '../../models/progress' +import { CloneOptions } from '../../models/clone-options' +import { RetryAction, RetryActionType } from '../../models/retry-actions' + +import { clone as cloneRepo } from '../git' +import { ErrorWithMetadata } from '../error-with-metadata' +import { BaseStore } from './base-store' + +/** The store in charge of repository currently being cloned. */ +export class CloningRepositoriesStore extends BaseStore { + private readonly _repositories = new Array() + private readonly stateByID = new Map() + + /** + * Clone the repository at the URL to the path. + * + * Returns a {Promise} which resolves to whether the clone was successful. + */ + public async clone( + url: string, + path: string, + options: CloneOptions + ): Promise { + const repository = new CloningRepository(path, url) + this._repositories.push(repository) + + const title = `Cloning into ${path}` + + this.stateByID.set(repository.id, { kind: 'clone', title, value: 0 }) + this.emitUpdate() + + let success = true + try { + await cloneRepo(url, path, options, progress => { + this.stateByID.set(repository.id, progress) + this.emitUpdate() + }) + } catch (e) { + success = false + + const retryAction: RetryAction = { + type: RetryActionType.Clone, + name: repository.name, + url, + path, + options, + } + e = new ErrorWithMetadata(e, { retryAction, repository }) + + this.emitError(e) + } + + this.remove(repository) + + return success + } + + /** Get the repositories currently being cloned. */ + public get repositories(): ReadonlyArray { + return Array.from(this._repositories) + } + + /** Get the state of the repository. */ + public getRepositoryState( + repository: CloningRepository + ): ICloneProgress | null { + return this.stateByID.get(repository.id) || null + } + + /** Remove the repository. */ + public remove(repository: CloningRepository) { + this.stateByID.delete(repository.id) + + const repoIndex = this._repositories.findIndex(r => r.id === repository.id) + if (repoIndex > -1) { + this._repositories.splice(repoIndex, 1) + } + + this.emitUpdate() + } +} diff --git a/app/src/lib/stores/commit-status-store.ts b/app/src/lib/stores/commit-status-store.ts new file mode 100644 index 0000000000..a43ab95bc1 --- /dev/null +++ b/app/src/lib/stores/commit-status-store.ts @@ -0,0 +1,595 @@ +import pLimit from 'p-limit' +import QuickLRU from 'quick-lru' + +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, + getCheckRunActionsWorkflowRuns, + manuallySetChecksToPending, +} from '../ci-checks/ci-checks' +import _ from 'lodash' +import { offsetFromNow } from '../offset-from' + +interface ICommitStatusCacheEntry { + /** + * The combined ref status from the API or null if + * the status could not be retrieved. + */ + readonly check: ICombinedRefCheck | null + + /** + * The timestamp for when this cache entry was last + * fetched from the API (i.e. when it was created). + */ + readonly fetchedAt: Date +} + +export type StatusCallBack = (status: ICombinedRefCheck | null) => void + +/** + * An interface describing one or more subscriptions for + * which to deliver updates about commit status for a particular + * ref. + */ +interface IRefStatusSubscription { + /** + * TThe repository endpoint (for example https://api.github.com for + * GitHub.com and https://github.corporation.local/api for GHE) + */ + readonly endpoint: string + + /** Owner The repository owner's login (i.e niik for niik/desktop) */ + readonly owner: string + + /** The repository name */ + readonly name: string + + /** The commit ref (can be a SHA or a Git ref) for which to fetch status. */ + readonly ref: string + + /** One or more callbacks to notify when the commit status is updated */ + readonly callbacks: Set + + /** If provided, we retrieve the actions workflow runs or the checks with this sub */ + readonly branchName?: string +} + +/** + * Creates a cache key for a particular ref in a specific repository. + * + * Remarks: The cache key is currently the same as the canonical API status + * URI but that has no bearing on the functionality, it does, however + * help with debugging. + * + * @param repository The GitHub repository to use when looking up commit status. + * @param ref The commit ref (can be a SHA or a Git ref) for which to + * fetch status. + */ +function getCacheKeyForRepository(repository: GitHubRepository, ref: string) { + const { endpoint, owner, name } = repository + return getCacheKey(endpoint, owner.login, name, ref) +} + +/** + * Creates a cache key for a particular ref in a specific repository. + * + * @param endpoint The repository endpoint (for example https://api.github.com for + * GitHub.com and https://github.corporation.local/api for GHE) + * @param owner The repository owner's login (i.e niik for niik/desktop) + * @param name The repository name + * @param ref The commit ref (can be a SHA or a Git ref) for which to fetch + * status. + */ +function getCacheKey( + endpoint: string, + owner: string, + name: string, + ref: string +) { + return `${endpoint}/repos/${owner}/${name}/commits/${ref}` +} + +/** + * Returns a value indicating whether or not the cache entry provided + * should be considered stale enough that a refresh from the API is + * warranted. + */ +function entryIsEligibleForRefresh(entry: ICommitStatusCacheEntry) { + // The age (in milliseconds) of the cache entry, i.e. how long it has + // sat in the cache since last being fetched. + const now = Date.now() + const age = now - entry.fetchedAt.valueOf() + + // The GitHub API has a max-age of 60, so no need to refresh + // any more frequently than that since Chromium would just give + // us the cached value. + return age > 60 * 1000 +} + +/** + * The interval (in milliseconds) between background updates for active + * commit status subscriptions. Background refresh occurs only when the + * application is focused. + */ +const BackgroundRefreshInterval = 3 * 60 * 1000 +const MaxConcurrentFetches = 6 + +export class CommitStatusStore { + /** The list of signed-in accounts, kept in sync with the accounts store */ + private accounts: ReadonlyArray = [] + + private backgroundRefreshHandle: number | null = null + private refreshQueued = false + + /** + * A map keyed on the value of `getCacheKey` containing one object + * per active subscription which contain all the information required + * to update a commit status from the API and notify subscribers. + */ + private readonly subscriptions = new Map() + + /** + * A map keyed on the value of `getCacheKey` containing one object per + * reference (repository specific) with the last retrieved commit status + * for that reference. + * + * This map also functions as a least recently used cache and will evict + * the least recently used commit statuses to ensure the cache won't + * grow unbounded + */ + private readonly cache = new QuickLRU({ + maxSize: 250, + }) + + /** + * A set containing the currently executing (i.e. refreshing) cache + * keys (produced by `getCacheKey`). + */ + private readonly queue = new Set() + + /** + * A concurrency limiter which ensures that we only run `MaxConcurrentFetches` + * API requests simultaneously. + */ + private readonly limit = pLimit(MaxConcurrentFetches) + + public constructor(accountsStore: AccountsStore) { + accountsStore.getAll().then(this.onAccountsUpdated) + accountsStore.onDidUpdate(this.onAccountsUpdated) + } + + private readonly onAccountsUpdated = (accounts: ReadonlyArray) => { + this.accounts = accounts + } + + /** + * Called to ensure that background refreshing is running and fetching + * updated commit statuses for active subscriptions. The intention is + * for background refreshing to be active while the application is + * focused. + * + * Remarks: this method will do nothing if background fetching is + * already active. + */ + public startBackgroundRefresh() { + if (this.backgroundRefreshHandle === null) { + this.backgroundRefreshHandle = window.setInterval( + () => this.queueRefresh(), + BackgroundRefreshInterval + ) + this.queueRefresh() + } + } + + /** + * Called to ensure that background refreshing is stopped. The intention + * is for background refreshing to be active while the application is + * focused. + * + * Remarks: this method will do nothing if background fetching is + * not currently active. + */ + public stopBackgroundRefresh() { + if (this.backgroundRefreshHandle !== null) { + window.clearInterval(this.backgroundRefreshHandle) + this.backgroundRefreshHandle = null + } + } + + private queueRefresh() { + if (!this.refreshQueued) { + this.refreshQueued = true + setImmediate(() => { + this.refreshQueued = false + this.refreshEligibleSubscriptions() + }) + } + } + + /** + * Looks through all active commit status subscriptions and + * figure out which, if any, needs to be refreshed from the + * API. + */ + private refreshEligibleSubscriptions() { + for (const key of this.subscriptions.keys()) { + // Is it already being worked on? + if (this.queue.has(key)) { + continue + } + + const entry = this.cache.get(key) + + if (entry && !entryIsEligibleForRefresh(entry)) { + continue + } + + this.limit(() => this.refreshSubscription(key)) + .catch(e => log.error('Failed refreshing commit status', e)) + .then(() => this.queue.delete(key)) + + this.queue.add(key) + } + } + + public async manualRefreshSubscription( + repository: GitHubRepository, + ref: string, + pendingChecks: ReadonlyArray + ) { + const key = getCacheKeyForRepository(repository, ref) + const subscription = this.subscriptions.get(key) + + if (subscription === undefined) { + return + } + + const cache = this.cache.get(key)?.check + if (cache === undefined || cache === null) { + return + } + + const check = manuallySetChecksToPending(cache.checks, pendingChecks) + this.cache.set(key, { check, fetchedAt: new Date() }) + subscription.callbacks.forEach(cb => cb(check)) + } + + private async refreshSubscription(key: string) { + // Make sure it's still a valid subscription that + // someone might care about before fetching + const subscription = this.subscriptions.get(key) + + if (subscription === undefined) { + return + } + + const { endpoint, owner, name, ref } = subscription + const account = this.accounts.find(a => a.endpoint === endpoint) + + if (account === undefined) { + return + } + + const api = API.fromAccount(account) + + const [statuses, checkRuns] = await Promise.all([ + api.fetchCombinedRefStatus(owner, name, ref), + api.fetchRefCheckRuns(owner, name, ref), + ]) + + const checks = new Array() + + if (statuses === null && checkRuns === null) { + // Okay, so we failed retrieving the status for one reason or another. + // That's a bummer, but we still need to put something in the cache + // or else we'll consider this subscription eligible for refresh + // from here on until we succeed in fetching. By putting a blank + // cache entry (or potentially reusing the last entry) in and not + // notifying subscribers we ensure they keep their current status + // if they have one and that we attempt to fetch it again on the same + // schedule as the others. + const existingEntry = this.cache.get(key) + const check = existingEntry?.check ?? null + + this.cache.set(key, { check, fetchedAt: new Date() }) + return + } + + if (statuses !== null) { + checks.push(...statuses.statuses.map(apiStatusToRefCheck)) + } + + if (checkRuns !== null) { + const latestCheckRunsByName = getLatestCheckRunsByName( + checkRuns.check_runs + ) + checks.push(...latestCheckRunsByName.map(apiCheckRunToRefCheck)) + } + + let checksWithActions = null + if (subscription.branchName !== undefined) { + checksWithActions = await this.getAndMapActionWorkflowRunsToCheckRuns( + checks, + key, + subscription.branchName + ) + } + + const check = createCombinedCheckFromChecks(checksWithActions ?? checks) + this.cache.set(key, { check, fetchedAt: new Date() }) + subscription.callbacks.forEach(cb => cb(check)) + } + + private async getAndMapActionWorkflowRunsToCheckRuns( + checks: ReadonlyArray, + key: string, + branchName: string + ): Promise | null> { + const existingChecks = this.cache.get(key) + + // If the checks haven't changed since last status refresh, don't bother + // retrieving actions workflows and they could be stale if this is directly after a rerun + if ( + existingChecks !== undefined && + existingChecks.check !== null && + existingChecks.check.checks.some(c => c.actionsWorkflow !== undefined) && + _.xor( + existingChecks.check.checks.map(cr => cr.id), + checks.map(cr => cr.id) + ).length === 0 + ) { + // Apply existing action workflow and job steps from cache to refreshed checks + const mapped = new Array() + for (const cr of checks) { + const matchingCheck = existingChecks.check.checks.find( + c => c.id === cr.id + ) + + if (matchingCheck === undefined) { + // Shouldn't happen, but if it did just keep what we have + mapped.push(cr) + continue + } + + const { actionsWorkflow, actionJobSteps } = matchingCheck + mapped.push({ + ...cr, + actionsWorkflow, + actionJobSteps, + }) + } + return mapped + } + + const checkRunsWithActionsWorkflows = + await this.getCheckRunActionsWorkflowRuns(key, branchName, checks) + + const checkRunsWithActionsWorkflowJobs = + await this.mapActionWorkflowRunsJobsToCheckRuns( + key, + checkRunsWithActionsWorkflows + ) + + return checkRunsWithActionsWorkflowJobs + } + + /** + * Attempt to _synchronously_ retrieve a commit status for a particular + * ref. If the ref doesn't exist in the cache this function returns null. + * + * Useful for component who wish to have a value for the initial render + * instead of waiting for the subscription to produce an event. + */ + public tryGetStatus( + repository: GitHubRepository, + ref: string, + branchName?: string + ): ICombinedRefCheck | null { + const key = getCacheKeyForRepository(repository, ref) + if ( + branchName !== undefined && + this.subscriptions.get(key)?.branchName !== branchName + ) { + return null + } + + return this.cache.get(key)?.check ?? null + } + + private getOrCreateSubscription( + repository: GitHubRepository, + ref: string, + branchName?: string + ) { + const key = getCacheKeyForRepository(repository, ref) + let subscription = this.subscriptions.get(key) + + if (subscription !== undefined) { + if (subscription.branchName === branchName) { + return subscription + } + + const withBranchName = { ...subscription, branchName } + this.subscriptions.set(key, withBranchName) + const cache = this.cache.get(key) + if (cache !== undefined) { + this.cache.set(key, { + ...cache, + // The commit status store is set to only retreive on a refresh + // trigger if the subscription has not been fetched for 60 minutes + // (cache/api limit). This sets this sub back to 61 so that on next + // refresh triggered, it will be reretreived, as this time, it will be + // different given the branch name is provided. + fetchedAt: new Date(offsetFromNow(-61, 'minutes')), + }) + } + + return withBranchName + } + + subscription = { + endpoint: repository.endpoint, + owner: repository.owner.login, + name: repository.name, + ref, + callbacks: new Set(), + branchName, + } + + this.subscriptions.set(key, subscription) + + return subscription + } + + /** + * Subscribe to commit status updates for a particular ref. + * + * @param repository The GitHub repository to use when looking up commit status. + * @param ref The commit ref (can be a SHA or a Git ref) for which to + * fetch status. + * @param callback A callback which will be invoked whenever the + * store updates a commit status for the given ref. + */ + public subscribe( + repository: GitHubRepository, + ref: string, + callback: StatusCallBack, + branchName?: string + ): DisposableLike { + const key = getCacheKeyForRepository(repository, ref) + const subscription = this.getOrCreateSubscription( + repository, + ref, + branchName + ) + + subscription.callbacks.add(callback) + this.queueRefresh() + + return new Disposable(() => { + subscription.callbacks.delete(callback) + if (subscription.callbacks.size === 0) { + this.subscriptions.delete(key) + } + }) + } + + /** + * Retrieve GitHub Actions workflows and maps them to the check runs if + * applicable + */ + private async getCheckRunActionsWorkflowRuns( + key: string, + branchName: string, + checkRuns: ReadonlyArray + ): Promise> { + const subscription = this.subscriptions.get(key) + if (subscription === undefined) { + return checkRuns + } + + const { endpoint, owner, name } = subscription + const account = this.accounts.find(a => a.endpoint === endpoint) + if (account === undefined) { + return checkRuns + } + + return getCheckRunActionsWorkflowRuns( + account, + owner, + name, + branchName, + checkRuns + ) + } + + /** + * Retrieve GitHub Actions job and logs for the check runs. + */ + private async mapActionWorkflowRunsJobsToCheckRuns( + key: string, + checkRuns: ReadonlyArray + ): Promise> { + const subscription = this.subscriptions.get(key) + if (subscription === undefined) { + return checkRuns + } + + const { endpoint, owner, name } = subscription + const account = this.accounts.find(a => a.endpoint === endpoint) + + if (account === undefined) { + return checkRuns + } + + const api = API.fromAccount(account) + + return getLatestPRWorkflowRunsLogsForCheckRun(api, owner, name, checkRuns) + } + + public async rerequestCheckSuite( + repository: GitHubRepository, + checkSuiteId: number + ): Promise { + const { owner, name } = repository + const account = getAccountForEndpoint(this.accounts, repository.endpoint) + if (account === null) { + return false + } + + const api = API.fromAccount(account) + return api.rerequestCheckSuite(owner.login, name, checkSuiteId) + } + + public async rerunJob( + repository: GitHubRepository, + jobId: number + ): Promise { + const { owner, name } = repository + const account = getAccountForEndpoint(this.accounts, repository.endpoint) + if (account === null) { + return false + } + + const api = API.fromAccount(account) + return api.rerunJob(owner.login, name, jobId) + } + + public async rerunFailedJobs( + repository: GitHubRepository, + workflowRunId: number + ): Promise { + const { owner, name } = repository + const account = getAccountForEndpoint(this.accounts, repository.endpoint) + if (account === null) { + return false + } + + const api = API.fromAccount(account) + return api.rerunFailedJobs(owner.login, name, workflowRunId) + } + + public async fetchCheckSuite( + repository: GitHubRepository, + checkSuiteId: number + ): Promise { + const { owner, name } = repository + const account = getAccountForEndpoint(this.accounts, repository.endpoint) + if (account === null) { + return null + } + + const api = API.fromAccount(account) + return api.fetchCheckSuite(owner.login, name, checkSuiteId) + } +} diff --git a/app/src/lib/stores/git-store-cache.ts b/app/src/lib/stores/git-store-cache.ts new file mode 100644 index 0000000000..ecc847c1a8 --- /dev/null +++ b/app/src/lib/stores/git-store-cache.ts @@ -0,0 +1,38 @@ +import { GitStore } from './git-store' +import { Repository } from '../../models/repository' +import { IAppShell } from '../app-shell' +import { StatsStore } from '../stats' + +export class GitStoreCache { + /** GitStores keyed by their hash. */ + private readonly gitStores = new Map() + + public constructor( + private readonly shell: IAppShell, + private readonly statsStore: StatsStore, + private readonly onGitStoreUpdated: ( + repository: Repository, + gitStore: GitStore + ) => void, + private readonly onDidError: (error: Error) => void + ) {} + + public remove(repository: Repository) { + if (this.gitStores.has(repository.hash)) { + this.gitStores.delete(repository.hash) + } + } + + public get(repository: Repository): GitStore { + let gitStore = this.gitStores.get(repository.hash) + if (gitStore === undefined) { + gitStore = new GitStore(repository, this.shell, this.statsStore) + gitStore.onDidUpdate(() => this.onGitStoreUpdated(repository, gitStore!)) + gitStore.onDidError(error => this.onDidError(error)) + + this.gitStores.set(repository.hash, gitStore) + } + + return gitStore + } +} diff --git a/app/src/lib/stores/git-store.ts b/app/src/lib/stores/git-store.ts new file mode 100644 index 0000000000..d388b1586c --- /dev/null +++ b/app/src/lib/stores/git-store.ts @@ -0,0 +1,1687 @@ +import * as Path from 'path' +import { + getNonForkGitHubRepository, + isRepositoryWithForkedGitHubRepository, + Repository, +} from '../../models/repository' +import { + WorkingDirectoryFileChange, + AppFileStatusKind, +} from '../../models/status' +import { + Branch, + BranchType, + IAheadBehind, + ICompareResult, +} from '../../models/branch' +import { Tip, TipState } from '../../models/tip' +import { Commit } from '../../models/commit' +import { IRemote } from '../../models/remote' +import { IFetchProgress, IRevertProgress } from '../../models/progress' +import { + ICommitMessage, + DefaultCommitMessage, +} from '../../models/commit-message' +import { ComparisonMode } from '../app-state' + +import { IAppShell } from '../app-shell' +import { + DiscardChangesError, + ErrorWithMetadata, + IErrorMetadata, +} from '../error-with-metadata' +import { queueWorkHigh } from '../../lib/queue-work' + +import { + reset, + GitResetMode, + getRemotes, + fetch as fetchRepo, + fetchRefspec, + getRecentBranches, + getBranches, + deleteRef, + getCommits, + merge, + setRemoteURL, + getStatus, + IStatusResult, + getCommit, + IndexStatus, + getIndexChanges, + checkoutIndex, + discardChangesFromSelection, + checkoutPaths, + resetPaths, + revertCommit, + unstageAllFiles, + addRemote, + listSubmodules, + resetSubmodulePaths, + parseTrailers, + mergeTrailers, + getTrailerSeparatorCharacters, + parseSingleUnfoldedTrailer, + isCoAuthoredByTrailer, + getAheadBehind, + revRange, + revSymmetricDifference, + getConfigValue, + removeRemote, + createTag, + getAllTags, + deleteTag, + MergeResult, + createBranch, + updateRemoteHEAD, + getRemoteHEAD, +} from '../git' +import { GitError as DugiteError } from '../../lib/git' +import { GitError } from 'dugite' +import { RetryAction, RetryActionType } from '../../models/retry-actions' +import { UpstreamAlreadyExistsError } from './upstream-already-exists-error' +import { forceUnwrap } from '../fatal-error' +import { + findUpstreamRemote, + UpstreamRemoteName, +} from './helpers/find-upstream-remote' +import { findDefaultRemote } from './helpers/find-default-remote' +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' +import { PullRequest } from '../../models/pull-request' +import { StatsStore } from '../stats' +import { getTagsToPush, storeTagsToPush } from './helpers/tags-to-push-storage' +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 + +const LoadingHistoryRequestKey = 'history' + +/** The max number of recent branches to find. */ +const RecentBranchesLimit = 5 + +/** The store for a repository's git data. */ +export class GitStore extends BaseStore { + /** The commits keyed by their SHA. */ + public readonly commitLookup = new Map() + + public pullWithRebase?: boolean + + private _history: ReadonlyArray = [] + + private readonly requestsInFight = new Set() + + private _tip: Tip = { kind: TipState.Unknown } + + private _defaultBranch: Branch | null = null + + private _upstreamDefaultBranch: Branch | null = null + + private _localTags: Map | null = null + + private _allBranches: ReadonlyArray = [] + + private _recentBranches: ReadonlyArray = [] + + private _localCommitSHAs: ReadonlyArray = [] + + private _commitMessage: ICommitMessage = DefaultCommitMessage + + private _showCoAuthoredBy: boolean = false + + private _coAuthors: ReadonlyArray = [] + + private _aheadBehind: IAheadBehind | null = null + + private _tagsToPush: ReadonlyArray = [] + + private _defaultRemote: IRemote | null = null + + private _currentRemote: IRemote | null = null + + private _upstreamRemote: IRemote | null = null + + private _lastFetched: Date | null = null + + private _desktopStashEntries = new Map() + + private _stashEntryCount = 0 + + public constructor( + private readonly repository: Repository, + private readonly shell: IAppShell, + private readonly statsStore: StatsStore + ) { + super() + + this._tagsToPush = getTagsToPush(repository) + } + + /** + * Reconcile the local history view with the repository state + * after a pull has completed, to include merged remote commits. + */ + public async reconcileHistory(mergeBase: string): Promise { + if (this._history.length === 0) { + return + } + + if (this.requestsInFight.has(LoadingHistoryRequestKey)) { + return + } + + this.requestsInFight.add(LoadingHistoryRequestKey) + + const range = revRange('HEAD', mergeBase) + + const commits = await this.performFailableOperation(() => + getCommits(this.repository, range, CommitBatchSize) + ) + if (commits == null) { + return + } + + const existingHistory = this._history + const index = existingHistory.findIndex(c => c === mergeBase) + + if (index > -1) { + log.debug( + `reconciling history - adding ${ + commits.length + } commits before merge base ${mergeBase.substring(0, 8)}` + ) + + // rebuild the local history state by combining the commits _before_ the + // merge base with the current commits on the tip of this current branch + const remainingHistory = existingHistory.slice(index) + this._history = [...commits.map(c => c.sha), ...remainingHistory] + } + + this.storeCommits(commits) + this.requestsInFight.delete(LoadingHistoryRequestKey) + this.emitUpdate() + } + + /** Load a batch of commits from the repository, using a given commitish object as the starting point */ + public async loadCommitBatch(commitish: string, skip: number) { + if (this.requestsInFight.has(LoadingHistoryRequestKey)) { + return null + } + + const requestKey = `history/compare/${commitish}/skip/${skip}` + if (this.requestsInFight.has(requestKey)) { + return null + } + + this.requestsInFight.add(requestKey) + + const commits = await this.performFailableOperation(() => + getCommits(this.repository, commitish, CommitBatchSize, skip) + ) + + this.requestsInFight.delete(requestKey) + if (!commits) { + return null + } + + this.storeCommits(commits) + return commits.map(c => c.sha) + } + + public async refreshTags() { + const previousTags = this._localTags + const newTags = await this.performFailableOperation(() => + getAllTags(this.repository) + ) + + if (newTags === undefined) { + return + } + + this._localTags = newTags + + // Remove any unpushed tag that cannot be found in the list + // of local tags. This can happen when the user deletes an + // unpushed tag from outside of Desktop. + for (const tagToPush of this._tagsToPush) { + if (!this._localTags.has(tagToPush)) { + this.removeTagToPush(tagToPush) + } + } + + if (previousTags !== null) { + // We don't await for the emition of updates to finish + // to make this method return earlier. + this.emitUpdatesForChangedTags(previousTags, this._localTags) + } + } + + /** + * Calculates the commits that have changed based on the changes in existing tags + * to emit the correct updates. + * + * This is specially important when tags are created/modified/deleted from outside of Desktop. + */ + private async emitUpdatesForChangedTags( + previousTags: Map, + newTags: Map + ) { + const commitsToUpdate = new Set() + let numCreatedTags = 0 + + for (const [tagName, previousCommitSha] of previousTags) { + const newCommitSha = newTags.get(tagName) + + if (!newCommitSha) { + // the tag has been deleted. + commitsToUpdate.add(previousCommitSha) + } else if (newCommitSha !== previousCommitSha) { + // the tag has been moved to a different commit. + commitsToUpdate.add(previousCommitSha) + commitsToUpdate.add(newCommitSha) + } + } + + for (const [tagName, newCommitSha] of newTags) { + if (!previousTags.has(tagName)) { + // the tag has just been created. + commitsToUpdate.add(newCommitSha) + numCreatedTags++ + } + } + + if (numCreatedTags > 0) { + this.statsStore.recordTagCreated(numCreatedTags) + } + + const commitsToStore = [] + for (const commitSha of commitsToUpdate) { + const commit = await getCommit(this.repository, commitSha) + + if (commit !== null) { + commitsToStore.push(commit) + } + } + + this.storeCommits(commitsToStore) + } + + public async createBranch( + name: string, + startPoint: string | null, + noTrackOption: boolean = false + ) { + const createdBranch = await this.performFailableOperation(async () => { + await createBranch(this.repository, name, startPoint, noTrackOption) + return true + }) + + if (createdBranch === true) { + await this.loadBranches() + return this.allBranches.find( + x => x.type === BranchType.Local && x.name === name + ) + } + + return undefined + } + + public async createTag(name: string, targetCommitSha: string) { + const result = await this.performFailableOperation(async () => { + await createTag(this.repository, name, targetCommitSha) + return true + }) + + if (result === undefined) { + return + } + + await this.refreshTags() + this.addTagToPush(name) + + this.statsStore.recordTagCreatedInDesktop() + } + + public async deleteTag(name: string) { + const result = await this.performFailableOperation(async () => { + await deleteTag(this.repository, name) + return true + }) + + if (result === undefined) { + return + } + + await this.refreshTags() + this.removeTagToPush(name) + + this.statsStore.recordTagDeleted() + } + + /** The list of ordered SHAs. */ + public get history(): ReadonlyArray { + return this._history + } + + public get tagsToPush(): ReadonlyArray | null { + return this._tagsToPush + } + + public get localTags(): Map | null { + return this._localTags + } + + /** Load all the branches. */ + public async loadBranches() { + const [localAndRemoteBranches, recentBranchNames] = await Promise.all([ + this.performFailableOperation(() => getBranches(this.repository)) || [], + this.performFailableOperation(() => + // Chances are that the recent branches list will contain the default + // branch which we filter out in refreshRecentBranches. So grab one + // more than we need to account for that. + getRecentBranches(this.repository, RecentBranchesLimit + 1) + ), + ]) + + if (!localAndRemoteBranches) { + return + } + + this._allBranches = this.mergeRemoteAndLocalBranches(localAndRemoteBranches) + + // refreshRecentBranches is dependent on having a default branch + await this.refreshDefaultBranch() + this.refreshRecentBranches(recentBranchNames) + + await this.checkPullWithRebase() + + this.emitUpdate() + } + + /** + * Takes a list of local and remote branches and filters out "duplicate" + * remote branches, i.e. remote branches that we already have a local + * branch tracking. + */ + private mergeRemoteAndLocalBranches( + branches: ReadonlyArray + ): ReadonlyArray { + const localBranches = new Array() + const remoteBranches = new Array() + + for (const branch of branches) { + if (branch.type === BranchType.Local) { + localBranches.push(branch) + } else if (branch.type === BranchType.Remote) { + remoteBranches.push(branch) + } + } + + const upstreamBranchesAdded = new Set() + const allBranchesWithUpstream = new Array() + + for (const branch of localBranches) { + allBranchesWithUpstream.push(branch) + + if (branch.upstream) { + upstreamBranchesAdded.add(branch.upstream) + } + } + + for (const branch of remoteBranches) { + // This means we already added the local branch of this remote branch, so + // we don't need to add it again. + if (upstreamBranchesAdded.has(branch.name)) { + continue + } + + allBranchesWithUpstream.push(branch) + } + + return allBranchesWithUpstream + } + + private async checkPullWithRebase() { + const result = await getConfigValue(this.repository, 'pull.rebase') + + if (result === null || result === '') { + this.pullWithRebase = undefined + } else if (result === 'true') { + this.pullWithRebase = true + } else if (result === 'false') { + this.pullWithRebase = false + } else { + log.warn(`Unexpected value found for pull.rebase in config: '${result}'`) + // ensure any previous value is purged from app state + this.pullWithRebase = undefined + } + } + + public async refreshDefaultBranch() { + this._defaultBranch = await findDefaultBranch( + this.repository, + this.allBranches, + this.defaultRemote?.name + ) + + // 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) { + this._tagsToPush = [...this._tagsToPush, tagName] + + storeTagsToPush(this.repository, this._tagsToPush) + this.emitUpdate() + } + + private removeTagToPush(tagToDelete: string) { + this._tagsToPush = this._tagsToPush.filter( + tagName => tagName !== tagToDelete + ) + + storeTagsToPush(this.repository, this._tagsToPush) + this.emitUpdate() + } + + public clearTagsToPush() { + this._tagsToPush = [] + + storeTagsToPush(this.repository, this._tagsToPush) + this.emitUpdate() + } + + private refreshRecentBranches( + recentBranchNames: ReadonlyArray | undefined + ) { + if (!recentBranchNames || !recentBranchNames.length) { + this._recentBranches = [] + return + } + + const branchesByName = new Map() + + for (const branch of this._allBranches) { + // This is slightly redundant as remote branches should never show up as + // having been checked out in the reflog but it makes the intention clear. + if (branch.type === BranchType.Local) { + branchesByName.set(branch.name, branch) + } + } + + const recentBranches = new Array() + for (const name of recentBranchNames) { + // The default branch already has its own section in the branch + // list so we exclude it here. + if (name === this.defaultBranch?.name) { + continue + } + + const branch = branchesByName.get(name) + if (!branch) { + // This means the recent branch has been deleted. That's fine. + continue + } + + recentBranches.push(branch) + + if (recentBranches.length >= RecentBranchesLimit) { + break + } + } + + this._recentBranches = recentBranches + } + + /** The current branch. */ + public get tip(): Tip { + return this._tip + } + + /** The default branch or null if the default branch could not be inferred. */ + public get defaultBranch(): Branch | null { + 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 + } + + /** The most recently checked out branches. */ + public get recentBranches(): ReadonlyArray { + return this._recentBranches + } + + /** + * Load local commits into memory for the current repository. + * + * @param branch The branch to query for unpublished commits. + * + * If the tip of the repository does not have commits (i.e. is unborn), this + * should be invoked with `null`, which clears any existing commits from the + * store. + */ + public async loadLocalCommits(branch: Branch | null): Promise { + if (branch === null) { + this._localCommitSHAs = [] + return + } + + let localCommits: ReadonlyArray | undefined + if (branch.upstream) { + const range = revRange(branch.upstream, branch.name) + localCommits = await this.performFailableOperation(() => + getCommits(this.repository, range, CommitBatchSize) + ) + } else { + localCommits = await this.performFailableOperation(() => + getCommits(this.repository, 'HEAD', CommitBatchSize, undefined, [ + '--not', + '--remotes', + ]) + ) + } + + if (!localCommits) { + return + } + + this.storeCommits(localCommits) + this._localCommitSHAs = localCommits.map(c => c.sha) + this.emitUpdate() + } + + /** + * The ordered array of local commit SHAs. The commits themselves can be + * looked up in `commits`. + */ + public get localCommitSHAs(): ReadonlyArray { + return this._localCommitSHAs + } + + /** Store the given commits. */ + private storeCommits(commits: ReadonlyArray) { + for (const commit of commits) { + this.commitLookup.set(commit.sha, commit) + } + } + + private async undoFirstCommit( + repository: Repository + ): Promise { + // What are we doing here? + // The state of the working directory here is rather important, because we + // want to ensure that any deleted files are restored to your working + // directory for the next stage. Doing doing a `git checkout -- .` here + // isn't suitable because we should preserve the other working directory + // changes. + + const status = await this.performFailableOperation(() => + getStatus(this.repository) + ) + + if (status == null) { + throw new Error( + `Unable to undo commit because there are too many files in your repository's working directory.` + ) + } + + const paths = status.workingDirectory.files + + const deletedFiles = paths.filter( + p => p.status.kind === AppFileStatusKind.Deleted + ) + const deletedFilePaths = deletedFiles.map(d => d.path) + + await checkoutPaths(repository, deletedFilePaths) + + // Now that we have the working directory changes, as well the restored + // deleted files, we can remove the HEAD ref to make the current branch + // disappear + await deleteRef(repository, 'HEAD', 'Reverting first commit') + + // Finally, ensure any changes in the index are unstaged. This ensures all + // files in the repository will be untracked. + await unstageAllFiles(repository) + return true + } + + /** + * Undo a specific commit for the current repository. + * + * @param commit - The commit to remove - should be the tip of the current branch. + */ + public async undoCommit(commit: Commit): Promise { + // For an initial commit, just delete the reference but leave HEAD. This + // will make the branch unborn again. + const success = await this.performFailableOperation(() => + commit.parentSHAs.length === 0 + ? this.undoFirstCommit(this.repository) + : reset(this.repository, GitResetMode.Mixed, commit.parentSHAs[0]) + ) + + if (success === undefined) { + return + } + + // 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, + // let's just fall back to the old way of restoring the + // entire message + if (this.repository.gitHubRepository) { + try { + await this.loadCommitAndCoAuthors(commit) + this.emitUpdate() + return + } catch (e) { + log.error('Failed to restore commit and co-authors, falling back', e) + } + } + + this._commitMessage = { + summary: commit.summary, + description: commit.body, + } + this.emitUpdate() + } + + /** + * Attempt to restore both the commit message and any co-authors + * in it after an undo operation. + * + * This is a deceivingly simple task which complicated by the + * us wanting to follow the heuristics of Git when finding, and + * parsing trailers. + */ + private async loadCommitAndCoAuthors(commit: Commit) { + const repository = this.repository + + // git-interpret-trailers is really only made for working + // with full commit messages so let's start with that + const message = await formatCommitMessage(repository, { + summary: commit.summary, + description: commit.body, + }) + + // Next we extract any co-authored-by trailers we + // can find. We use interpret-trailers for this + const foundTrailers = await parseTrailers(repository, message) + const coAuthorTrailers = foundTrailers.filter(isCoAuthoredByTrailer) + + // This is the happy path, nothing more for us to do + if (coAuthorTrailers.length === 0) { + this._commitMessage = { + summary: commit.summary, + description: commit.body, + } + + return + } + + // call interpret-trailers --unfold so that we can be sure each + // trailer sits on a single line + const unfolded = await mergeTrailers(repository, message, [], true) + 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 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 + // value + const coAuthorRe = /^co-authored-by(.)\s(.*)/i + const extractedTrailers = [] + + // Iterate backwards from the unfolded message and look for trailers that we've + // already seen when calling parseTrailers earlier. + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i] + const match = coAuthorRe.exec(line) + + // Not a trailer line, we're sure of that + 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 + } + + const trailer = parseSingleUnfoldedTrailer(line, match[1]) + + if (!trailer) { + continue + } + + // We already know that the key is Co-Authored-By so we only + // need to compare by value. Let's see if we can find the thing + // that we believe to be a trailer among what interpret-trailers + // --parse told us was a trailer. This step is a bit redundant + // but it ensure we match exactly with what Git thinks is a trailer + const foundTrailerIx = coAuthorTrailers.findIndex( + t => t.value === trailer.value + ) + + if (foundTrailerIx === -1) { + continue + } + + // We're running backwards + extractedTrailers.unshift(coAuthorTrailers[foundTrailerIx]) + + // Remove the trailer that matched so that we can be sure + // we're not picking it up again + coAuthorTrailers.splice(foundTrailerIx, 1) + + // This line was a co-author trailer so we'll remove it to + // make sure it doesn't end up in the restored commit body + lines.splice(i, 1) + } + + // Get rid of the summary/title + lines.splice(0, 2) + + const newBody = lines.join('\n').trim() + + this._commitMessage = { + summary: commit.summary, + description: newBody, + } + + const extractedAuthors = extractedTrailers.map(t => + GitAuthor.parse(t.value) + ) + const newAuthors = new Array() + + // Last step, phew! The most likely scenario where we + // get called is when someone has just made a commit and + // either forgot to add a co-author or forgot to remove + // someone so chances are high that we already have a + // co-author which includes a username. If we don't we'll + // add it without a username which is fine as well + for (let i = 0; i < extractedAuthors.length; i++) { + const extractedAuthor = extractedAuthors[i] + + // If GitAuthor failed to parse + if (extractedAuthor === null) { + continue + } + + const { name, email } = extractedAuthor + 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 } + ) + } + + this._coAuthors = newAuthors + + if (this._coAuthors.length > 0 && this._showCoAuthoredBy === false) { + this._showCoAuthoredBy = true + } + } + + /** + * Perform an operation that may fail by throwing an error. If an error is + * thrown, catch it and emit it, and return `undefined`. + * + * @param errorMetadata - The metadata which should be attached to any errors + * that are thrown. + */ + public async performFailableOperation( + fn: () => Promise, + errorMetadata?: IErrorMetadata + ): Promise { + try { + const result = await fn() + return result + } catch (e) { + e = new ErrorWithMetadata(e, { + repository: this.repository, + ...errorMetadata, + }) + + this.emitError(e) + return undefined + } + } + + /** The commit message for a work-in-progress commit in the changes view. */ + public get commitMessage(): ICommitMessage { + return this._commitMessage + } + + /** + * Gets a value indicating whether the user has chosen to + * hide or show the co-authors field in the commit message + * component + */ + public get showCoAuthoredBy(): boolean { + return this._showCoAuthoredBy + } + + /** + * Gets a list of co-authors to use when crafting the next + * commit. + */ + public get coAuthors(): ReadonlyArray { + return this._coAuthors + } + + /** + * Fetch the default, current, and upstream remotes, using the given account for + * authentication. + * + * @param account - The account to use for authentication if needed. + * @param backgroundTask - Was the fetch done as part of a background task? + * @param progressCallback - A function that's called with information about + * the overall fetch progress. + */ + public async fetch( + account: IGitAccount | null, + backgroundTask: boolean, + progressCallback?: (fetchProgress: IFetchProgress) => void + ): Promise { + // Use a map as a simple way of getting a unique set of remotes. + // Note that maps iterate in insertion order so the order in which + // we insert these will affect the order in which we fetch them + const remotes = new Map() + + // We want to fetch the current remote first + if (this.currentRemote !== null) { + remotes.set(this.currentRemote.name, this.currentRemote) + } + + // And then the default remote if it differs from the current + if (this.defaultRemote !== null) { + remotes.set(this.defaultRemote.name, this.defaultRemote) + } + + // And finally the upstream if we're a fork + if (this.upstreamRemote !== null) { + remotes.set(this.upstreamRemote.name, this.upstreamRemote) + } + + if (remotes.size > 0) { + await this.fetchRemotes( + account, + [...remotes.values()], + backgroundTask, + progressCallback + ) + } + + // check the upstream ref against the current branch to see if there are + // any new commits available + if (this.tip.kind === TipState.Valid) { + const currentBranch = this.tip.branch + if ( + currentBranch.upstreamRemoteName !== null && + currentBranch.upstream !== null + ) { + const range = revSymmetricDifference( + currentBranch.name, + currentBranch.upstream + ) + this._aheadBehind = await getAheadBehind(this.repository, range) + } else { + this._aheadBehind = null + } + } else { + this._aheadBehind = null + } + + this.emitUpdate() + } + + /** + * Fetch the specified remotes, using the given account for authentication. + * + * @param account - The account to use for authentication if needed. + * @param remotes - The remotes to fetch from. + * @param backgroundTask - Was the fetch done as part of a background task? + * @param progressCallback - A function that's called with information about + * the overall fetch progress. + */ + public async fetchRemotes( + account: IGitAccount | null, + remotes: ReadonlyArray, + backgroundTask: boolean, + progressCallback?: (fetchProgress: IFetchProgress) => void + ): Promise { + if (!remotes.length) { + return + } + + const weight = 1 / remotes.length + + for (let i = 0; i < remotes.length; i++) { + const remote = remotes[i] + const startProgressValue = i * weight + + await this.fetchRemote(account, remote, backgroundTask, progress => { + if (progress && progressCallback) { + progressCallback({ + ...progress, + value: startProgressValue + progress.value * weight, + }) + } + }) + } + } + + /** + * Fetch a remote, using the given account for authentication. + * + * @param account - The account to use for authentication if needed. + * @param remote - The name of the remote to fetch from. + * @param backgroundTask - Was the fetch done as part of a background task? + * @param progressCallback - A function that's called with information about + * the overall fetch progress. + */ + public async fetchRemote( + account: IGitAccount | null, + remote: IRemote, + backgroundTask: boolean, + progressCallback?: (fetchProgress: IFetchProgress) => void + ): Promise { + const retryAction: RetryAction = { + type: RetryActionType.Fetch, + repository: this.repository, + } + await this.performFailableOperation( + () => fetchRepo(this.repository, account, remote, progressCallback), + { backgroundTask, retryAction } + ) + + await updateRemoteHEAD(this.repository, account, remote) + } + + /** + * Fetch a given refspec, using the given account for authentication. + * + * @param user - The user to use for authentication if needed. + * @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 { + // 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) + ) + } + } + + public async loadStatus(): Promise { + const status = await this.performFailableOperation(() => + getStatus(this.repository) + ) + + if (!status) { + return null + } + + this._aheadBehind = status.branchAheadBehind || null + + const { currentBranch, currentTip } = status + + if (currentBranch || currentTip) { + if (currentTip && currentBranch) { + const branchTipCommit = await this.lookupCommit(currentTip) + + const branch = new Branch( + currentBranch, + status.currentUpstreamBranch || null, + branchTipCommit, + BranchType.Local, + `refs/heads/${currentBranch}` + ) + this._tip = { kind: TipState.Valid, branch } + } else if (currentTip) { + this._tip = { kind: TipState.Detached, currentSha: currentTip } + } else if (currentBranch) { + this._tip = { kind: TipState.Unborn, ref: currentBranch } + } + } else { + this._tip = { kind: TipState.Unknown } + } + + this.emitUpdate() + + return status + } + + /** + * Find a commit in the local cache, or load in the commit from the underlying + * repository. + * + * This will error if the commit ID cannot be resolved. + */ + private async lookupCommit(sha: string): Promise { + const cachedCommit = this.commitLookup.get(sha) + if (cachedCommit != null) { + return Promise.resolve(cachedCommit) + } + + const foundCommit = await this.performFailableOperation(() => + getCommit(this.repository, sha) + ) + + if (foundCommit != null) { + this.commitLookup.set(sha, foundCommit) + return foundCommit + } + + throw new Error(`Could not load commit: '${sha}'`) + } + + /** + * Refreshes the list of GitHub Desktop created stash entries for the repository + */ + public async loadStashEntries(): Promise { + const map = new Map() + const stash = await getStashes(this.repository) + + for (const entry of stash.desktopEntries) { + // we only want the first entry we find for each branch, + // so we skip all subsequent ones + if (!map.has(entry.branchName)) { + const existing = this._desktopStashEntries.get(entry.branchName) + + // If we've already loaded the files for this stash there's + // no point in us doing it again. We know the contents haven't + // changed since the SHA is the same. + if (existing !== undefined && existing.stashSha === entry.stashSha) { + map.set(entry.branchName, { ...entry, files: existing.files }) + } else { + map.set(entry.branchName, entry) + } + } + } + + this._desktopStashEntries = map + this._stashEntryCount = stash.stashEntryCount + this.emitUpdate() + + this.loadFilesForCurrentStashEntry() + } + + /** + * A GitHub Desktop created stash entries for the current branch or + * null if no entry exists + */ + public get currentBranchStashEntry() { + return this._tip && this._tip.kind === TipState.Valid + ? this._desktopStashEntries.get(this._tip.branch.name) || null + : null + } + + public get desktopStashEntries(): ReadonlyMap { + return this._desktopStashEntries + } + + /** The total number of stash entries */ + public get stashEntryCount(): number { + return this._stashEntryCount + } + + /** The number of stash entries created by Desktop */ + public get desktopStashEntryCount(): number { + return this._desktopStashEntries.size + } + + /** + * Updates the latest stash entry with a list of files that it changes + */ + private async loadFilesForCurrentStashEntry() { + const stashEntry = this.currentBranchStashEntry + + if ( + !stashEntry || + stashEntry.files.kind !== StashedChangesLoadStates.NotLoaded + ) { + return + } + + const { branchName } = stashEntry + + this._desktopStashEntries.set(branchName, { + ...stashEntry, + files: { kind: StashedChangesLoadStates.Loading }, + }) + this.emitUpdate() + + const files = await getStashedFiles(this.repository, stashEntry.stashSha) + + // It's possible that we've refreshed the list of stash entries since we + // started getStashedFiles. Load the latest entry for the branch and make + // sure the SHAs match up. + const currentEntry = this._desktopStashEntries.get(branchName) + + if (!currentEntry || currentEntry.stashSha !== stashEntry.stashSha) { + return + } + + this._desktopStashEntries.set(branchName, { + ...currentEntry, + files: { + kind: StashedChangesLoadStates.Loaded, + files, + }, + }) + this.emitUpdate() + } + + public async loadRemotes(): Promise { + const remotes = await getRemotes(this.repository) + this._defaultRemote = findDefaultRemote(remotes) + + const currentRemoteName = + this.tip.kind === TipState.Valid && + this.tip.branch.upstreamRemoteName !== null + ? this.tip.branch.upstreamRemoteName + : null + + // Load the remote that the current branch is tracking. If the branch + // is not tracking any remote or the remote which it's tracking has + // been removed we'll default to the default branch. + this._currentRemote = + currentRemoteName !== null + ? remotes.find(r => r.name === currentRemoteName) || this._defaultRemote + : this._defaultRemote + + const parent = + this.repository.gitHubRepository && + this.repository.gitHubRepository.parent + + this._upstreamRemote = parent ? findUpstreamRemote(parent, remotes) : null + + this.emitUpdate() + } + + /** + * Add the upstream remote if the repository is a fork and an upstream remote + * doesn't already exist. + */ + public async addUpstreamRemoteIfNeeded(): Promise { + const parent = + this.repository.gitHubRepository && + this.repository.gitHubRepository.parent + if (!parent) { + return + } + + const remotes = await getRemotes(this.repository) + const upstream = findUpstreamRemote(parent, remotes) + if (upstream) { + return + } + + const remoteWithUpstreamName = remotes.find( + r => r.name === UpstreamRemoteName + ) + if (remoteWithUpstreamName) { + const error = new UpstreamAlreadyExistsError( + this.repository, + remoteWithUpstreamName + ) + this.emitError(error) + return + } + + const url = forceUnwrap( + 'Parent repositories are fully loaded', + parent.cloneURL + ) + + this._upstreamRemote = + (await this.performFailableOperation(() => + addRemote(this.repository, UpstreamRemoteName, url) + )) ?? null + } + + /** + * Sets the upstream remote to a new url, + * creating the upstream remote if it doesn't already exist + * + * @param remoteUrl url to be used for the upstream remote + */ + public async ensureUpstreamRemoteURL(remoteUrl: string): Promise { + await this.performFailableOperation(async () => { + try { + await addRemote(this.repository, UpstreamRemoteName, remoteUrl) + } catch (e) { + if ( + e instanceof DugiteError && + e.result.gitError === GitError.RemoteAlreadyExists + ) { + // update upstream remote if it already exists + await setRemoteURL(this.repository, UpstreamRemoteName, remoteUrl) + } else { + throw e + } + } + }) + } + + /** + * The number of commits the current branch is ahead and behind, relative to + * its upstream. + * + * It will be `null` if ahead/behind hasn't been calculated yet, or if the + * branch doesn't have an upstream. + */ + public get aheadBehind(): IAheadBehind | null { + return this._aheadBehind + } + + /** + * The remote considered to be the "default" remote in the repository. + * + * - the 'origin' remote, if found + * - the first remote, listed alphabetically + * + * If no remotes are defined in the repository, this will be `null`. + */ + public get defaultRemote(): IRemote | null { + return this._defaultRemote + } + + /** + * The remote associated with the current branch in the repository. + * + * If the branch has a valid tip, the tracking branch name is used here. + * Otherwise this will be the same value as `this.defaultRemote`. + */ + public get currentRemote(): IRemote | null { + return this._currentRemote + } + + /** + * The remote for the upstream repository. + * + * This will be `null` if the repository isn't a fork, or if the fork doesn't + * have an upstream remote. + */ + public get upstreamRemote(): IRemote | null { + return this._upstreamRemote + } + + /** + * Set whether the user has chosen to hide or show the + * co-authors field in the commit message component + */ + public setShowCoAuthoredBy(showCoAuthoredBy: boolean) { + this._showCoAuthoredBy = showCoAuthoredBy + // Clear co-authors when hiding + if (!showCoAuthoredBy) { + this._coAuthors = [] + } + this.emitUpdate() + } + + /** + * Update co-authors list + * + * @param coAuthors Zero or more authors + */ + public setCoAuthors(coAuthors: ReadonlyArray) { + this._coAuthors = coAuthors + this.emitUpdate() + } + + public setCommitMessage(message: ICommitMessage): Promise { + this._commitMessage = message + + this.emitUpdate() + return Promise.resolve() + } + + /** The date the repository was last fetched. */ + public get lastFetched(): Date | null { + return this._lastFetched + } + + /** Update the last fetched date. */ + public async updateLastFetched() { + const fetchHeadPath = Path.join(this.repository.path, '.git', 'FETCH_HEAD') + + try { + const fstat = await stat(fetchHeadPath) + + // If the file's empty then it _probably_ means the fetch failed and we + // shouldn't update the last fetched date. + if (fstat.size > 0) { + this._lastFetched = fstat.mtime + } + } catch { + // An error most likely means the repository's never been published. + this._lastFetched = null + } + + this.emitUpdate() + return this._lastFetched + } + + /** Merge the named branch into the current branch. */ + public merge( + branch: Branch, + isSquash: boolean = false + ): Promise { + if (this.tip.kind !== TipState.Valid) { + throw new Error( + `unable to merge as tip state is '${this.tip.kind}' and the application expects the repository to be on a branch currently` + ) + } + + const currentBranch = this.tip.branch.name + + return this.performFailableOperation( + () => merge(this.repository, branch.name, isSquash), + { + gitContext: { + kind: 'merge', + currentBranch, + theirBranch: branch.name, + }, + retryAction: { + type: RetryActionType.Merge, + currentBranch, + theirBranch: branch, + repository: this.repository, + }, + } + ) + } + + /** Changes the URL for the remote that matches the given name */ + public async setRemoteURL(name: string, url: string): Promise { + const wasSuccessful = + (await this.performFailableOperation(() => + setRemoteURL(this.repository, name, url) + )) === true + await this.loadRemotes() + + this.emitUpdate() + return wasSuccessful + } + + public async discardChanges( + files: ReadonlyArray, + moveToTrash: boolean = true, + askForConfirmationOnDiscardChangesPermanently: boolean = false + ): Promise { + const pathsToCheckout = new Array() + const pathsToReset = new Array() + + const submodules = await listSubmodules(this.repository) + + await queueWorkHigh(files, async file => { + const foundSubmodule = submodules.some(s => s.path === file.path) + + if ( + file.status.kind !== AppFileStatusKind.Deleted && + !foundSubmodule && + moveToTrash + ) { + // N.B. moveItemToTrash can take a fair bit of time which is why we're + // running it inside this work queue that spreads out the calls across + // as many animation frames as it needs to. + try { + await this.shell.moveItemToTrash( + Path.resolve(this.repository.path, file.path) + ) + } catch (e) { + if (askForConfirmationOnDiscardChangesPermanently) { + throw new DiscardChangesError(e, this.repository, files) + } + } + } + + if ( + file.status.kind === AppFileStatusKind.Copied || + file.status.kind === AppFileStatusKind.Renamed + ) { + // file.path is the "destination" or "new" file in a copy or rename. + // we've already deleted it so all we need to do is make sure the + // index forgets about it. + pathsToReset.push(file.path) + + // checkout the old path too + pathsToCheckout.push(file.status.oldPath) + pathsToReset.push(file.status.oldPath) + } else { + pathsToCheckout.push(file.path) + pathsToReset.push(file.path) + } + }) + + // Check the index to see which files actually have changes there as compared to HEAD + const changedFilesInIndex = await getIndexChanges(this.repository) + + // Only reset paths if they have changes in the index + const necessaryPathsToReset = pathsToReset.filter(x => + changedFilesInIndex.has(x) + ) + + const submodulePaths = pathsToCheckout.filter(p => + submodules.find(s => s.path === p) + ) + + // Don't attempt to checkout files that are submodules or don't exist in the index after our reset + const necessaryPathsToCheckout = pathsToCheckout.filter( + x => + submodulePaths.indexOf(x) === -1 || + changedFilesInIndex.get(x) !== IndexStatus.Added + ) + + // We're trying to not invoke git linearly with the number of files to discard + // so we're doing our discards in three conceptual steps. + // + // 1. Figure out what the index thinks has changed as compared to the previous + // commit. For users who exclusive interact with Git using Desktop this will + // almost always empty which, as it turns out, is great for us. + // + // 2. Figure out if any of the files that we've been asked to discard are changed + // in the index and if so, reset them such that the index is set up just as + // the previous commit for the paths we're discarding. + // + // 3. Checkout all the files that we've discarded that existed in the previous + // commit from the index. + await this.performFailableOperation(async () => { + if (submodulePaths.length > 0) { + await resetSubmodulePaths(this.repository, submodulePaths) + } + + await resetPaths( + this.repository, + GitResetMode.Mixed, + 'HEAD', + necessaryPathsToReset + ) + await checkoutIndex(this.repository, necessaryPathsToCheckout) + }) + } + + public async discardChangesFromSelection( + filePath: string, + diff: ITextDiff, + selection: DiffSelection + ) { + await this.performFailableOperation(() => + discardChangesFromSelection(this.repository, filePath, diff, selection) + ) + } + + /** Reverts the commit with the given SHA */ + public async revertCommit( + repository: Repository, + commit: Commit, + account: IGitAccount | null, + progressCallback?: (fetchProgress: IRevertProgress) => void + ): Promise { + await this.performFailableOperation(() => + revertCommit(repository, commit, account, progressCallback) + ) + + this.emitUpdate() + } + + /** + * Update the repository's existing upstream remote to point to the parent + * repository. + */ + public async updateExistingUpstreamRemote(): Promise { + const gitHubRepository = forceUnwrap( + 'To update an upstream remote, the repository must be a GitHub repository', + this.repository.gitHubRepository + ) + const parent = forceUnwrap( + 'To update an upstream remote, the repository must have a parent', + gitHubRepository.parent + ) + const url = forceUnwrap( + 'Parent repositories are always fully loaded', + parent.cloneURL + ) + + await this.performFailableOperation(() => + setRemoteURL(this.repository, UpstreamRemoteName, url) + ) + } + + /** + * Returns the commits associated with `branch` and ahead/behind info; + */ + public async getCompareCommits( + branch: Branch, + comparisonMode: ComparisonMode + ): Promise { + if (this.tip.kind !== TipState.Valid) { + return null + } + + const base = this.tip.branch + const aheadBehind = await getAheadBehind( + this.repository, + revSymmetricDifference(base.name, branch.name) + ) + + if (aheadBehind == null) { + return null + } + + const revisionRange = + comparisonMode === ComparisonMode.Ahead + ? revRange(branch.name, base.name) + : revRange(base.name, branch.name) + const commitsToLoad = + comparisonMode === ComparisonMode.Ahead + ? aheadBehind.ahead + : aheadBehind.behind + const commits = await getCommits( + this.repository, + revisionRange, + commitsToLoad + ) + + if (commits.length > 0) { + this.storeCommits(commits) + } + + return { + commits, + ahead: aheadBehind.ahead, + behind: aheadBehind.behind, + } + } + + public async pruneForkedRemotes(openPRs: ReadonlyArray) { + const remotes = await getRemotes(this.repository) + + const branches = this.allBranches + const remotesToPrune = findForkedRemotesToPrune(remotes, openPRs, branches) + + for (const remote of remotesToPrune) { + 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/github-user-store.ts b/app/src/lib/stores/github-user-store.ts new file mode 100644 index 0000000000..ca3b9574fc --- /dev/null +++ b/app/src/lib/stores/github-user-store.ts @@ -0,0 +1,205 @@ +import { Account } from '../../models/account' +import { GitHubRepository } from '../../models/github-repository' +import { API } from '../api' +import { + GitHubUserDatabase, + IMentionableUser, +} from '../databases/github-user-database' + +import { compare } from '../compare' +import { BaseStore } from './base-store' +import { getStealthEmailForUser, getLegacyStealthEmailForUser } from '../email' +import { DefaultMaxHits } from '../../ui/autocompletion/common' + +/** Don't fetch mentionables more often than every 10 minutes */ +const MaxFetchFrequency = 10 * 60 * 1000 + +/** + * The max time (in milliseconds) that we'll keep a mentionable query + * cache around before pruning it. + */ +const QueryCacheTimeout = 60 * 1000 + +interface IQueryCache { + readonly repository: GitHubRepository + readonly users: ReadonlyArray +} + +/** + * The store for GitHub users. This is used to match commit authors to GitHub + * users and avatars. + */ +export class GitHubUserStore extends BaseStore { + private queryCache: IQueryCache | null = null + private pruneQueryCacheTimeoutId: number | null = null + + public constructor(private readonly database: GitHubUserDatabase) { + super() + } + + /** + * Retrieve a public user profile from the API based on the + * user login. + * + * @param account The account to use when querying the API + * for information about the user + * @param login The login (i.e. handle) of the user + */ + public async getByLogin( + account: Account, + login: string + ): Promise { + const api = API.fromAccount(account) + const apiUser = await api.fetchUser(login).catch(e => null) + + if (!apiUser || apiUser.type !== 'User') { + return null + } + + const email = + apiUser.email !== null && apiUser.email.length > 0 + ? apiUser.email + : getStealthEmailForUser(apiUser.id, login, account.endpoint) + + return { + avatarURL: apiUser.avatar_url, + email, + name: apiUser.name || apiUser.login, + login: apiUser.login, + } + } + + /** Update the mentionable users for the repository. */ + public async updateMentionables( + repository: GitHubRepository, + account: Account + ): Promise { + const api = API.fromAccount(account) + + const cacheEntry = await this.database.getMentionableCacheEntry( + repository.dbID + ) + + if ( + cacheEntry !== undefined && + Date.now() - cacheEntry.lastUpdated < MaxFetchFrequency + ) { + return + } + + const response = await api.fetchMentionables( + repository.owner.login, + repository.name, + cacheEntry?.eTag + ) + + if (response === null) { + await this.database.touchMentionableCacheEntry( + repository.dbID, + cacheEntry?.eTag + ) + return + } + + const { endpoint } = account + + const mentionables = response.users.map(u => { + const { name, login, avatar_url: avatarURL } = u + const email = u.email || getLegacyStealthEmailForUser(login, endpoint) + return { name, login, email, avatarURL } + }) + + await this.database.updateMentionablesForRepository( + repository.dbID, + mentionables, + response.etag + ) + + if (this.queryCache?.repository.dbID === repository.dbID) { + this.queryCache = null + this.clearCachePruneTimeout() + } + } + + /** Get the mentionable users in the repository. */ + public async getMentionableUsers( + repository: GitHubRepository + ): Promise> { + return this.database.getAllMentionablesForRepository(repository.dbID) + } + + /** + * Get the mentionable users which match the text in some way. + * + * Hit results are ordered by how close in the search string + * they matched. Search strings start with username and are followed + * by real name. Only the first substring hit is considered + * + * @param repository The GitHubRepository for which to look up + * mentionables. + * + * @param text A string to use when looking for a matching + * user. A user is considered a hit if this text + * matches any subtext of the username or real name + * + * @param maxHits The maximum number of hits to return. + */ + public async getMentionableUsersMatching( + repository: GitHubRepository, + query: string, + maxHits: number = DefaultMaxHits + ): Promise> { + const users = + this.queryCache?.repository.dbID === repository.dbID + ? this.queryCache.users + : await this.getMentionableUsers(repository) + + this.setQueryCache(repository, users) + + const hits = [] + const needle = query.toLowerCase() + + // Simple substring comparison on login and real name + for (const user of users) { + const ix = `${user.login} ${user.name}` + .trim() + .toLowerCase() + .indexOf(needle) + + if (ix >= 0) { + hits.push({ user, ix }) + } + } + + // Sort hits primarily based on how early in the text the match + // was found and then secondarily using alphabetic order. Ideally + // we'd use the GitHub user id in order to match dotcom behavior + // but sadly we don't have it handy here. The id property on IGitHubUser + // refers to our internal database id. + return hits + .sort( + (x, y) => compare(x.ix, y.ix) || compare(x.user.login, y.user.login) + ) + .slice(0, maxHits) + .map(h => h.user) + } + + private setQueryCache( + repository: GitHubRepository, + users: ReadonlyArray + ) { + this.clearCachePruneTimeout() + this.queryCache = { repository, users } + this.pruneQueryCacheTimeoutId = window.setTimeout(() => { + this.pruneQueryCacheTimeoutId = null + this.queryCache = null + }, QueryCacheTimeout) + } + + private clearCachePruneTimeout() { + if (this.pruneQueryCacheTimeoutId !== null) { + clearTimeout(this.pruneQueryCacheTimeoutId) + this.pruneQueryCacheTimeoutId = null + } + } +} diff --git a/app/src/lib/stores/helpers/background-fetcher.ts b/app/src/lib/stores/helpers/background-fetcher.ts new file mode 100644 index 0000000000..8dd6e46897 --- /dev/null +++ b/app/src/lib/stores/helpers/background-fetcher.ts @@ -0,0 +1,170 @@ +import { Repository } from '../../../models/repository' +import { Account } from '../../../models/account' +import { GitHubRepository } from '../../../models/github-repository' +import { API } from '../../api' +import { fatalError } from '../../fatal-error' + +/** + * A default interval at which to automatically fetch repositories, if the + * server doesn't specify one or the header is malformed. + */ +const DefaultFetchInterval = 1000 * 60 * 60 + +/** + * A minimum fetch interval, to protect against the server accidentally sending + * us a crazy value. + */ +const MinimumInterval = 1000 * 5 * 60 + +/** + * An upper bound to the skew that should be applied to the fetch interval to + * prevent clients from accidentally syncing up. + */ +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 + + /** Flag to indicate whether `stop` has been called. */ + 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 + } + + /** Start background fetching. */ + public start(withInitialSkew: boolean) { + if (this.stopped) { + fatalError('Cannot start a background fetcher that has been stopped.') + } + + const gitHubRepository = this.repository.gitHubRepository + if (!gitHubRepository) { + return + } + + if (withInitialSkew) { + this.timeoutHandle = window.setTimeout( + () => this.performAndScheduleFetch(gitHubRepository), + skewInterval() + ) + } else { + this.performAndScheduleFetch(gitHubRepository) + } + } + + /** + * Stop background fetching. Once this is called, the fetcher cannot be + * restarted. + */ + public stop() { + this.stopped = true + + const handle = this.timeoutHandle + if (handle) { + window.clearTimeout(handle) + this.timeoutHandle = null + } + } + + /** Perform a fetch and schedule the next one. */ + private async performAndScheduleFetch( + repository: GitHubRepository + ): Promise { + if (this.stopped) { + return + } + + const shouldFetch = await this.shouldPerformFetch(this.repository) + + if (this.stopped) { + return + } + + if (shouldFetch) { + try { + await this.fetch(this.repository) + } catch (e) { + const ghRepo = this.repository.gitHubRepository + const repoName = + ghRepo !== null ? ghRepo.fullName : this.repository.name + + log.error(`Error performing periodic fetch for '${repoName}'`, e) + } + } + + if (this.stopped) { + return + } + + const interval = await this.getFetchInterval(repository) + if (this.stopped) { + return + } + + this.timeoutHandle = window.setTimeout( + () => this.performAndScheduleFetch(repository), + interval + ) + } + + /** Get the allowed fetch interval from the server. */ + private async getFetchInterval( + repository: GitHubRepository + ): Promise { + const api = API.fromAccount(this.account) + + let interval = DefaultFetchInterval + 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) + } + + return interval + skewInterval() + } +} + +let _skewInterval: number | null = null + +/** + * The milliseconds by which the fetch interval should be skewed, to prevent + * clients from accidentally syncing up. + */ +function skewInterval(): number { + if (_skewInterval !== null) { + return _skewInterval + } + + // We don't need cryptographically secure random numbers for + // the skew. Pseudo-random should be just fine. + // eslint-disable-next-line insecure-random + const skew = Math.ceil(Math.random() * SkewUpperBound) + _skewInterval = skew + return skew +} diff --git a/app/src/lib/stores/helpers/branch-pruner.ts b/app/src/lib/stores/helpers/branch-pruner.ts new file mode 100644 index 0000000000..e9ab9b64fc --- /dev/null +++ b/app/src/lib/stores/helpers/branch-pruner.ts @@ -0,0 +1,268 @@ +import { + Repository, + isRepositoryWithGitHubRepository, +} from '../../../models/repository' +import { RepositoriesStore } from '../repositories-store' +import { Branch } from '../../../models/branch' +import { GitStoreCache } from '../git-store-cache' +import { + getMergedBranches, + getBranchCheckouts, + getSymbolicRef, + formatAsLocalRef, + getBranches, + deleteLocalBranch, +} from '../../git' +import { fatalError } from '../../fatal-error' +import { RepositoryStateCache } from '../repository-state-cache' +import { offsetFromNow } from '../../offset-from' +import { formatRelative } from '../../format-relative' + +/** Check if a repo needs to be pruned at least every 4 hours */ +const BackgroundPruneMinimumInterval = 1000 * 60 * 60 * 4 +const ReservedRefs = [ + 'HEAD', + 'refs/heads/main', + 'refs/heads/master', + 'refs/heads/gh-pages', + 'refs/heads/develop', + 'refs/heads/dev', + 'refs/heads/development', + 'refs/heads/trunk', + 'refs/heads/devel', + 'refs/heads/release', +] + +/** + * Behavior flags for the branch prune execution, to aid with testing and + * verifying locally. + */ +type PruneRuntimeOptions = { + /** + * By default the branch pruner will only run every 24 hours + * + * Set this flag to `false` to ignore this check. + */ + readonly enforcePruneThreshold: boolean + /** + * By default the branch pruner will also delete the branches it believes can + * be pruned safely. + * + * Set this to `false` to keep these in your repository. + */ + readonly deleteBranch: boolean +} + +const DefaultPruneOptions: PruneRuntimeOptions = { + enforcePruneThreshold: true, + deleteBranch: true, +} + +export class BranchPruner { + private timer: number | null = null + + public constructor( + private readonly repository: Repository, + private readonly gitStoreCache: GitStoreCache, + private readonly repositoriesStore: RepositoriesStore, + private readonly repositoriesStateCache: RepositoryStateCache, + private readonly onPruneCompleted: (repository: Repository) => Promise + ) {} + + public async start() { + if (this.timer !== null) { + fatalError( + `A background prune task is already active and cannot begin pruning on ${this.repository.name}` + ) + } + + await this.pruneLocalBranches(DefaultPruneOptions) + this.timer = window.setInterval( + () => this.pruneLocalBranches(DefaultPruneOptions), + BackgroundPruneMinimumInterval + ) + } + + public stop() { + if (this.timer === null) { + return + } + + clearInterval(this.timer) + this.timer = null + } + + public async testPrune(): Promise { + return this.pruneLocalBranches({ + enforcePruneThreshold: false, + deleteBranch: false, + }) + } + + /** @returns a map of canonical refs to their shas */ + private async findBranchesMergedIntoDefaultBranch( + repository: Repository, + defaultBranch: Branch + ): Promise> { + const gitStore = this.gitStoreCache.get(repository) + const mergedBranches = await gitStore.performFailableOperation(() => + getMergedBranches(repository, defaultBranch.name) + ) + + if (mergedBranches === undefined) { + return new Map() + } + + const currentBranchCanonicalRef = await getSymbolicRef(repository, 'HEAD') + + // remove the current branch + if (currentBranchCanonicalRef) { + mergedBranches.delete(currentBranchCanonicalRef) + } + + return mergedBranches + } + + /** + * Prune the local branches for the repository + * + * @param options configure the behaviour of the branch pruning process + */ + private async pruneLocalBranches( + options: PruneRuntimeOptions + ): Promise { + if (!isRepositoryWithGitHubRepository(this.repository)) { + return + } + + // Get the last time this repo was pruned + const lastPruneDate = await this.repositoriesStore.getLastPruneDate( + this.repository + ) + + // Only prune if it's been at least 24 hours since the last time + const threshold = offsetFromNow(-24, 'hours') + + // Using type coalescing behavior to deal with Dexie returning `undefined` + // for records that haven't been updated with the new field yet + if ( + options.enforcePruneThreshold && + lastPruneDate != null && + threshold < lastPruneDate + ) { + const timeAgo = formatRelative(lastPruneDate - Date.now()) + log.info(`[BranchPruner] Last prune took place ${timeAgo} - skipping`) + return + } + + // update the last prune date first thing after we check it! + await this.repositoriesStore.updateLastPruneDate( + this.repository, + Date.now() + ) + + // Get list of branches that have been merged + const { branchesState } = this.repositoriesStateCache.get(this.repository) + const { defaultBranch, allBranches } = branchesState + + if (defaultBranch === null) { + return + } + + const mergedBranches = await this.findBranchesMergedIntoDefaultBranch( + this.repository, + defaultBranch + ) + + if (mergedBranches.size === 0) { + log.info('[BranchPruner] No branches to prune.') + return + } + + // Get all branches checked out within the past 2 weeks + const twoWeeksAgo = new Date(offsetFromNow(-14, 'days')) + const recentlyCheckedOutBranches = await getBranchCheckouts( + this.repository, + twoWeeksAgo + ) + const recentlyCheckedOutCanonicalRefs = new Set( + [...recentlyCheckedOutBranches.keys()].map(formatAsLocalRef) + ) + + // get the locally cached branches of remotes (ie `remotes/origin/main`) + const remoteBranches = ( + await getBranches(this.repository, `refs/remotes/`) + ).map(b => formatAsLocalRef(b.name)) + + // create list of branches to be pruned + const branchesReadyForPruning = Array.from(mergedBranches.keys()).filter( + ref => { + if (ReservedRefs.includes(ref)) { + return false + } + if (recentlyCheckedOutCanonicalRefs.has(ref)) { + return false + } + const upstreamRef = getUpstreamRefForLocalBranchRef(ref, allBranches) + if (upstreamRef === undefined) { + return false + } + return !remoteBranches.includes(upstreamRef) + } + ) + + log.info( + `[BranchPruner] Pruning ${branchesReadyForPruning.length} branches that have been merged into the default branch, ${defaultBranch.name} (${defaultBranch.tip.sha}), from '${this.repository.name}` + ) + + const gitStore = this.gitStoreCache.get(this.repository) + const branchRefPrefix = `refs/heads/` + + for (const branchCanonicalRef of branchesReadyForPruning) { + if (!branchCanonicalRef.startsWith(branchRefPrefix)) { + continue + } + + const branchName = branchCanonicalRef.substring(branchRefPrefix.length) + + if (options.deleteBranch) { + const isDeleted = await gitStore.performFailableOperation(() => + deleteLocalBranch(this.repository, branchName) + ) + + if (isDeleted) { + log.info( + `[BranchPruner] Pruned branch ${branchName} ((was ${mergedBranches.get( + branchCanonicalRef + )}))` + ) + } + } else { + log.info(`[BranchPruner] Branch '${branchName}' marked for deletion`) + } + } + this.onPruneCompleted(this.repository) + } +} + +/** + * @param ref the canonical ref for a local branch + * @param allBranches a list of all branches in the Repository model + * @returns the canonical upstream branch ref or undefined if upstream can't be reliably determined + */ +function getUpstreamRefForLocalBranchRef( + ref: string, + allBranches: ReadonlyArray +): string | undefined { + const branch = allBranches.find(b => formatAsLocalRef(b.name) === ref) + // if we can't find a branch model, we can't determine the ref's upstream + if (branch === undefined) { + return undefined + } + const { upstream } = branch + // if there's no upstream in the branch, there's nothing to lookup + if (upstream === null) { + return undefined + } + return formatAsLocalRef(upstream) +} diff --git a/app/src/lib/stores/helpers/create-tutorial-repository.ts b/app/src/lib/stores/helpers/create-tutorial-repository.ts new file mode 100644 index 0000000000..dea55ed220 --- /dev/null +++ b/app/src/lib/stores/helpers/create-tutorial-repository.ts @@ -0,0 +1,149 @@ +import * as Path from 'path' + +import { Account } from '../../../models/account' +import { mkdir, writeFile } from 'fs/promises' +import { API } from '../../api' +import { APIError } from '../../http' +import { + executionOptionsWithProgress, + PushProgressParser, +} from '../../progress' +import { git } from '../../git' +import { friendlyEndpointName } from '../../friendly-endpoint-name' +import { IRemote } from '../../../models/remote' +import { getDefaultBranch } from '../../helpers/default-branch' +import { envForRemoteOperation } from '../../git/environment' +import { pathExists } from '../../../ui/lib/path-exists' + +const nl = __WIN32__ ? '\r\n' : '\n' +const InitialReadmeContents = + `# Welcome to GitHub Desktop!${nl}${nl}` + + `This is your README. READMEs are where you can communicate ` + + `what your project is and how to use it.${nl}${nl}` + + `Write your name on line 6, save it, and then head ` + + `back to GitHub Desktop.${nl}` + +async function createAPIRepository(account: Account, name: string) { + const api = new API(account.endpoint, account.token) + + try { + return await api.createRepository( + null, + name, + 'GitHub Desktop tutorial repository', + true + ) + } catch (err) { + if ( + err instanceof APIError && + err.responseStatus === 422 && + err.apiError !== null + ) { + if (err.apiError.message === 'Repository creation failed.') { + if ( + err.apiError.errors && + err.apiError.errors.some( + x => x.message === 'name already exists on this account' + ) + ) { + throw new Error( + 'You already have a repository named ' + + `"${name}" on your account at ${friendlyEndpointName( + account + )}.\n\n` + + 'Please delete the repository and try again.' + ) + } + } + } + + throw err + } +} + +async function pushRepo( + path: string, + account: Account, + remote: IRemote, + remoteBranchName: string, + progressCb: (title: string, value: number, description?: string) => void +) { + const pushTitle = `Pushing repository to ${friendlyEndpointName(account)}` + progressCb(pushTitle, 0) + + const pushOpts = await executionOptionsWithProgress( + { + env: await envForRemoteOperation(account, remote.url), + }, + new PushProgressParser(), + progress => { + if (progress.kind === 'progress') { + progressCb(pushTitle, progress.percent, progress.details.text) + } + } + ) + + const args = ['push', '-u', remote.name, remoteBranchName] + await git(args, path, 'tutorial:push', pushOpts) +} + +/** + * Creates a repository on the remote (as specified by the Account + * parameter), initializes an empty repository at the given path, + * sets up the expected tutorial contents, and pushes the repository + * to the remote. + * + * @param path The path on the local machine where the tutorial + * repository is to be created + * + * @param account The account (and thereby the GitHub host) under + * which the repository is to be created created + */ +export async function createTutorialRepository( + account: Account, + name: string, + path: string, + progressCb: (title: string, value: number, description?: string) => void +) { + const endpointName = friendlyEndpointName(account) + progressCb(`Creating repository on ${endpointName}`, 0) + + if (await pathExists(path)) { + throw new Error( + `The path '${path}' already exists. Please move it ` + + 'out of the way, or remove it, and then try again.' + ) + } + + const repo = await createAPIRepository(account, name) + const branch = repo.default_branch ?? (await getDefaultBranch()) + progressCb('Initializing local repository', 0.2) + + await mkdir(path, { recursive: true }) + + await git( + ['-c', `init.defaultBranch=${branch}`, 'init'], + path, + 'tutorial:init' + ) + + await writeFile(Path.join(path, 'README.md'), InitialReadmeContents) + + await git(['add', '--', 'README.md'], path, 'tutorial:add') + await git(['commit', '-m', 'Initial commit'], path, 'tutorial:commit') + + const remote: IRemote = { name: 'origin', url: repo.clone_url } + await git( + ['remote', 'add', remote.name, remote.url], + path, + 'tutorial:add-remote' + ) + + await pushRepo(path, account, remote, branch, (title, value, description) => { + progressCb(title, 0.3 + value * 0.6, description) + }) + + progressCb('Finalizing tutorial repository', 0.9) + + return repo +} diff --git a/app/src/lib/stores/helpers/find-branch-name.ts b/app/src/lib/stores/helpers/find-branch-name.ts new file mode 100644 index 0000000000..e5e63869b9 --- /dev/null +++ b/app/src/lib/stores/helpers/find-branch-name.ts @@ -0,0 +1,33 @@ +import { Tip, TipState } from '../../../models/tip' +import { IRemote } from '../../../models/remote' +import { GitHubRepository } from '../../../models/github-repository' +import { urlMatchesCloneURL } from '../../repository-matching' + +/** + * Function to determine which branch name to use when looking for branch + * protection information. + * + * If the remote branch matches the current `githubRepository` associated with + * the repository, this will be used. Otherwise we will fall back to using the + * branch name as that's a reasonable approximation for what would happen if the + * user tries to push the new branch. + */ +export function findRemoteBranchName( + tip: Tip, + remote: IRemote | null, + gitHubRepository: GitHubRepository +): string | null { + if (tip.kind !== TipState.Valid) { + return null + } + + if ( + tip.branch.upstreamWithoutRemote !== null && + remote !== null && + urlMatchesCloneURL(remote.url, gitHubRepository) + ) { + return tip.branch.upstreamWithoutRemote + } + + return tip.branch.nameWithoutRemote +} diff --git a/app/src/lib/stores/helpers/find-default-remote.ts b/app/src/lib/stores/helpers/find-default-remote.ts new file mode 100644 index 0000000000..e2037fa0cd --- /dev/null +++ b/app/src/lib/stores/helpers/find-default-remote.ts @@ -0,0 +1,16 @@ +import { IRemote } from '../../../models/remote' + +/** + * Attempt to find the remote which we consider to be the "default" + * remote, i.e. in most cases the 'origin' remote. + * + * If no remotes are given this method will return null, if no "default" + * branch could be found the first remote is considered the default. + * + * @param remotes A list of remotes for a given repository + */ +export function findDefaultRemote( + remotes: ReadonlyArray +): IRemote | null { + return remotes.find(x => x.name === 'origin') || remotes[0] || null +} diff --git a/app/src/lib/stores/helpers/find-forked-remotes-to-prune.ts b/app/src/lib/stores/helpers/find-forked-remotes-to-prune.ts new file mode 100644 index 0000000000..7cc8b03653 --- /dev/null +++ b/app/src/lib/stores/helpers/find-forked-remotes-to-prune.ts @@ -0,0 +1,32 @@ +import { Branch } from '../../../models/branch' +import { PullRequest } from '../../../models/pull-request' +import { ForkedRemotePrefix, IRemote } from '../../../models/remote' + +/** + * Function to determine which of the fork remotes added by the app are not + * referenced anymore (by pull requests or local branches) and can be removed + * from a repository. + * + * @param remotes All remotes available in the repository. + * @param openPRs All open pull requests available in the repository. + * @param allBranches All branches available in the repository. + */ +export function findForkedRemotesToPrune( + remotes: readonly IRemote[], + openPRs: ReadonlyArray, + allBranches: readonly Branch[] +) { + const prRemoteUrls = new Set( + openPRs.map(pr => pr.head.gitHubRepository.cloneURL) + ) + const branchRemotes = new Set( + allBranches.map(branch => branch.upstreamRemoteName) + ) + + return remotes.filter( + r => + r.name.startsWith(ForkedRemotePrefix) && + !prRemoteUrls.has(r.url) && + !branchRemotes.has(r.name) + ) +} diff --git a/app/src/lib/stores/helpers/find-upstream-remote.ts b/app/src/lib/stores/helpers/find-upstream-remote.ts new file mode 100644 index 0000000000..514fde298a --- /dev/null +++ b/app/src/lib/stores/helpers/find-upstream-remote.ts @@ -0,0 +1,22 @@ +import { GitHubRepository } from '../../../models/github-repository' +import { IRemote } from '../../../models/remote' +import { repositoryMatchesRemote } from '../../repository-matching' + +/** The name for a fork's upstream remote. */ +export const UpstreamRemoteName = 'upstream' + +/** + * Find the upstream remote based on the parent repository and the list of + * remotes. + */ +export function findUpstreamRemote( + parent: GitHubRepository, + remotes: ReadonlyArray +): IRemote | null { + const upstream = remotes.find(r => r.name === UpstreamRemoteName) + if (!upstream) { + return null + } + + return repositoryMatchesRemote(parent, upstream) ? upstream : null +} diff --git a/app/src/lib/stores/helpers/pull-request-updater.ts b/app/src/lib/stores/helpers/pull-request-updater.ts new file mode 100644 index 0000000000..02c05a8dbc --- /dev/null +++ b/app/src/lib/stores/helpers/pull-request-updater.ts @@ -0,0 +1,80 @@ +import { Account } from '../../../models/account' +import { RepositoryWithGitHubRepository } from '../../../models/repository' +import { PullRequestCoordinator } from '../pull-request-coordinator' + +/** Check for new or updated pull requests every 30 minutes */ +const PullRequestInterval = 30 * 60 * 1000 + +/** + * Never check for new or updated pull requests more + * frequently than every 2 minutes + */ +const MaxPullRequestRefreshFrequency = 2 * 60 * 1000 + +/** + * Periodically requests a refresh of the list of open pull requests + * for a particular GitHub repository. The intention is for the + * updater to only run when the app is in focus. When the updater + * is started (in other words when the app is focused) it will + * refresh the list of open pull requests as soon as possible while + * ensuring that we never update more frequently than the value + * indicated by the `MaxPullRequestRefreshFrequency` variable. + */ +export class PullRequestUpdater { + private timeoutId: number | null = null + private running = false + + public constructor( + private readonly repository: RepositoryWithGitHubRepository, + private readonly account: Account, + private readonly coordinator: PullRequestCoordinator + ) {} + + /** Starts the updater */ + public start() { + if (!this.running) { + this.running = true + this.scheduleTick(MaxPullRequestRefreshFrequency) + } + } + + private getTimeSinceLastRefresh() { + const lastRefreshed = this.coordinator.getLastRefreshed(this.repository) + const timeSince = + lastRefreshed === undefined ? Infinity : Date.now() - lastRefreshed + return timeSince + } + + private scheduleTick(timeout: number = PullRequestInterval) { + if (this.running) { + const due = Math.max(timeout - this.getTimeSinceLastRefresh(), 0) + this.timeoutId = window.setTimeout(() => this.tick(), due) + } + } + + private tick() { + if (!this.running) { + return + } + + this.timeoutId = null + if (this.getTimeSinceLastRefresh() < MaxPullRequestRefreshFrequency) { + this.scheduleTick() + } + + this.coordinator + .refreshPullRequests(this.repository, this.account) + .catch(() => {}) + .then(() => this.scheduleTick()) + } + + public stop() { + if (this.running) { + if (this.timeoutId !== null) { + window.clearTimeout(this.timeoutId) + this.timeoutId = null + } + this.running = false + } + } +} diff --git a/app/src/lib/stores/helpers/repository-indicator-updater.ts b/app/src/lib/stores/helpers/repository-indicator-updater.ts new file mode 100644 index 0000000000..1a911f0ded --- /dev/null +++ b/app/src/lib/stores/helpers/repository-indicator-updater.ts @@ -0,0 +1,161 @@ +import { Repository } from '../../../models/repository' + +/** + * Refresh repository indicators every 15 minutes. + */ +const RefreshInterval = 15 * 60 * 1000 + +/** + * An upper bound to the skew that should be applied to the fetch interval to + * prevent clients from accidentally syncing up. + */ +const SkewUpperBound = 30 * 1000 + +// We don't need cryptographically secure random numbers for +// the skew. Pseudo-random should be just fine. +// eslint-disable-next-line insecure-random +const skew = Math.ceil(Math.random() * SkewUpperBound) + +export class RepositoryIndicatorUpdater { + private running = false + private refreshTimeoutId: number | null = null + private paused = false + private pausePromise: Promise = Promise.resolve() + private resolvePausePromise: (() => void) | null = null + private lastRefreshStartedAt: number | null = null + + public constructor( + private readonly getRepositories: () => ReadonlyArray, + private readonly refreshRepositoryIndicators: ( + repository: Repository + ) => Promise + ) {} + + public start() { + if (!this.running) { + log.debug('[RepositoryIndicatorUpdater] Starting') + + this.running = true + this.scheduleRefresh() + } + } + + private scheduleRefresh() { + if (this.running && this.refreshTimeoutId === null) { + const timeSinceLastRefresh = + this.lastRefreshStartedAt === null + ? Infinity + : Date.now() - this.lastRefreshStartedAt + + const timeout = Math.max(RefreshInterval - timeSinceLastRefresh, 0) + skew + const lastRefreshText = isFinite(timeSinceLastRefresh) + ? `${(timeSinceLastRefresh / 1000).toFixed(3)}s ago` + : 'never' + const timeoutText = `${(timeout / 1000).toFixed(3)}s` + + log.debug( + `[RepositoryIndicatorUpdater] Last refresh: ${lastRefreshText}, scheduling in ${timeoutText}` + ) + + this.refreshTimeoutId = window.setTimeout( + () => this.refreshAllRepositories(), + timeout + ) + } + } + + private async refreshAllRepositories() { + // We're only ever called by the setTimeout so it's safe for us to clear + // this without calling clearTimeout + this.refreshTimeoutId = null + log.debug('[RepositoryIndicatorUpdater] Running refreshAllRepositories') + if (this.paused) { + log.debug( + '[RepositoryIndicatorUpdater] Paused before starting refreshAllRepositories' + ) + await this.pausePromise + + if (!this.running) { + return + } + } + + this.lastRefreshStartedAt = Date.now() + + let repository + const done = new Set() + const getNextRepository = () => + this.getRepositories().find(x => !done.has(x.id)) + + const startTime = Date.now() + let pausedTime = 0 + + while (this.running && (repository = getNextRepository()) !== undefined) { + await this.refreshRepositoryIndicators(repository) + + if (this.paused) { + log.debug( + `[RepositoryIndicatorUpdater] Pausing after ${done.size} repositories` + ) + const pauseTimeStart = Date.now() + await this.pausePromise + pausedTime += Date.now() - pauseTimeStart + log.debug( + `[RepositoryIndicatorUpdater] Resuming after ${pausedTime / 1000}s` + ) + } + + done.add(repository.id) + } + + if (done.size >= 1) { + const totalTime = Date.now() - startTime + const activeTime = totalTime - pausedTime + const activeTimeSeconds = (activeTime / 1000).toFixed(1) + const pausedTimeSeconds = (pausedTime / 1000).toFixed(1) + const totalTimeSeconds = (totalTime / 1000).toFixed(1) + + log.info( + `[RepositoryIndicatorUpdater]: Refreshing sidebar indicators for ${done.size} repositories took ${activeTimeSeconds}s of which ${pausedTimeSeconds}s paused, total ${totalTimeSeconds}s` + ) + } + + this.scheduleRefresh() + } + + private clearRefreshTimeout() { + if (this.refreshTimeoutId !== null) { + window.clearTimeout(this.refreshTimeoutId) + this.refreshTimeoutId = null + } + } + + public stop() { + if (this.running) { + log.debug('[RepositoryIndicatorUpdater] Stopping') + this.running = false + this.clearRefreshTimeout() + } + } + + public pause() { + if (this.paused === false) { + this.pausePromise = new Promise(resolve => { + this.resolvePausePromise = resolve + }) + + this.paused = true + } + } + + public resume() { + if (this.paused) { + if (this.resolvePausePromise !== null) { + this.resolvePausePromise() + this.resolvePausePromise = null + } + + this.paused = false + } + } +} diff --git a/app/src/lib/stores/helpers/tags-to-push-storage.ts b/app/src/lib/stores/helpers/tags-to-push-storage.ts new file mode 100644 index 0000000000..cd8b5aa741 --- /dev/null +++ b/app/src/lib/stores/helpers/tags-to-push-storage.ts @@ -0,0 +1,41 @@ +import { setStringArray, getStringArray } from '../../local-storage' +import { Repository } from '../../../models/repository' + +/** + * Store in localStorage the tags to push for the given repository + * + * @param repository the repository object + * @param tagsToPush array with the tags to push + */ +export function storeTagsToPush( + repository: Repository, + tagsToPush: ReadonlyArray +) { + if (tagsToPush.length === 0) { + clearTagsToPush(repository) + } else { + setStringArray(getTagsToPushKey(repository), tagsToPush) + } +} + +/** + * Get from local storage the tags to push for the given repository + * + * @param repository the repository object + */ +export function getTagsToPush(repository: Repository) { + return getStringArray(getTagsToPushKey(repository)) +} + +/** + * Clear from local storage the tags to push for the given repository + * + * @param repository the repository object + */ +export function clearTagsToPush(repository: Repository) { + localStorage.removeItem(getTagsToPushKey(repository)) +} + +function getTagsToPushKey(repository: Repository) { + return `tags-to-push-${repository.id}` +} diff --git a/app/src/lib/stores/helpers/tutorial-assessor.ts b/app/src/lib/stores/helpers/tutorial-assessor.ts new file mode 100644 index 0000000000..ab52366eb3 --- /dev/null +++ b/app/src/lib/stores/helpers/tutorial-assessor.ts @@ -0,0 +1,173 @@ +import { IRepositoryState } from '../../app-state' +import { TutorialStep } from '../../../models/tutorial-step' +import { TipState } from '../../../models/tip' +import { setBoolean, getBoolean } from '../../local-storage' + +const skipInstallEditorKey = 'tutorial-install-editor-skipped' +const pullRequestStepCompleteKey = 'tutorial-pull-request-step-complete' +const tutorialPausedKey = 'tutorial-paused' + +/** + * Used to determine which step of the onboarding + * tutorial the user needs to complete next + * + * Stores some state that only it needs to know about. The + * actual step result is stored in App Store so the rest of + * the app can access it. + */ +export class OnboardingTutorialAssessor { + /** Has the user opted to skip the install editor step? */ + private installEditorSkipped: boolean = getBoolean( + skipInstallEditorKey, + false + ) + /** Has the user opted to skip the create pull request step? */ + private prStepComplete: boolean = getBoolean( + pullRequestStepCompleteKey, + false + ) + /** Is the tutorial currently paused? */ + private tutorialPaused: boolean = getBoolean(tutorialPausedKey, false) + + public constructor( + /** Method to call when we need to get the current editor */ + private getResolvedExternalEditor: () => string | null + ) {} + + /** Determines what step the user needs to complete next in the Onboarding Tutorial */ + public async getCurrentStep( + isTutorialRepo: boolean, + repositoryState: IRepositoryState + ): Promise { + if (!isTutorialRepo) { + // If a new repo has been added, we can unpause the tutorial repo + // as we will no longer present the no-repos blank slate view resume button + // Fixes https://github.com/desktop/desktop/issues/8341 + if (this.tutorialPaused) { + this.resumeTutorial() + } + return TutorialStep.NotApplicable + } else if (this.tutorialPaused) { + return TutorialStep.Paused + } else if (!(await this.isEditorInstalled())) { + return TutorialStep.PickEditor + } else if (!this.isBranchCheckedOut(repositoryState)) { + return TutorialStep.CreateBranch + } else if (!this.hasChangedFile(repositoryState)) { + return TutorialStep.EditFile + } else if (!this.hasMultipleCommits(repositoryState)) { + return TutorialStep.MakeCommit + } else if (!this.commitPushed(repositoryState)) { + return TutorialStep.PushBranch + } else if (!this.pullRequestCreated(repositoryState)) { + return TutorialStep.OpenPullRequest + } else { + return TutorialStep.AllDone + } + } + + private async isEditorInstalled(): Promise { + return ( + this.installEditorSkipped || this.getResolvedExternalEditor() !== null + ) + } + + private isBranchCheckedOut(repositoryState: IRepositoryState): boolean { + const { branchesState } = repositoryState + const { tip } = branchesState + + const currentBranchName = + tip.kind === TipState.Valid ? tip.branch.name : null + const defaultBranchName = + branchesState.defaultBranch !== null + ? branchesState.defaultBranch.name + : null + + return ( + currentBranchName !== null && + defaultBranchName !== null && + currentBranchName !== defaultBranchName + ) + } + + private hasChangedFile(repositoryState: IRepositoryState): boolean { + if (this.hasMultipleCommits(repositoryState)) { + // User has already committed a change + return true + } + const { changesState } = repositoryState + return changesState.workingDirectory.files.length > 0 + } + + private hasMultipleCommits(repositoryState: IRepositoryState): boolean { + const { branchesState } = repositoryState + const { tip } = branchesState + + if (tip.kind === TipState.Valid) { + const commit = repositoryState.commitLookup.get(tip.branch.tip.sha) + + // For some reason sometimes the initial commit has a parent sha + // listed as an empty string... + // For now I'm filtering those out. Would be better to prevent that from happening + return commit !== undefined && commit.parentSHAs.some(x => x.length > 0) + } + + return false + } + + private commitPushed(repositoryState: IRepositoryState): boolean { + const { aheadBehind } = repositoryState + return aheadBehind !== null && aheadBehind.ahead === 0 + } + + private pullRequestCreated(repositoryState: IRepositoryState): boolean { + // If we see a PR at any point let's persist that. This is for the + // edge case where a user leaves the app to manually create the PR + if (repositoryState.branchesState.currentPullRequest !== null) { + this.markPullRequestTutorialStepAsComplete() + } + + return this.prStepComplete + } + + /** Call when the user opts to skip the install editor step */ + public skipPickEditor = () => { + this.installEditorSkipped = true + setBoolean(skipInstallEditorKey, this.installEditorSkipped) + } + + /** + * Call when the user has either created a pull request or opts to + * skip the create pull request step of the onboarding tutorial + */ + public markPullRequestTutorialStepAsComplete = () => { + this.prStepComplete = true + setBoolean(pullRequestStepCompleteKey, this.prStepComplete) + } + + /** + * Call when a new tutorial repository is created + * + * (Resets its internal skipped steps state.) + */ + public onNewTutorialRepository = () => { + this.installEditorSkipped = false + localStorage.removeItem(skipInstallEditorKey) + this.prStepComplete = false + localStorage.removeItem(pullRequestStepCompleteKey) + this.tutorialPaused = false + localStorage.removeItem(tutorialPausedKey) + } + + /** Call when the user pauses the tutorial */ + public pauseTutorial() { + this.tutorialPaused = true + setBoolean(tutorialPausedKey, this.tutorialPaused) + } + + /** Call when the user resumes the tutorial */ + public resumeTutorial() { + this.tutorialPaused = false + setBoolean(tutorialPausedKey, this.tutorialPaused) + } +} diff --git a/app/src/lib/stores/index.ts b/app/src/lib/stores/index.ts new file mode 100644 index 0000000000..949a614e4a --- /dev/null +++ b/app/src/lib/stores/index.ts @@ -0,0 +1,12 @@ +export * from './accounts-store' +export * from './app-store' +export * from './cloning-repositories-store' +export * from './git-store' +export * from './github-user-store' +export * from './issues-store' +export * from './repositories-store' +export * from './sign-in-store' +export * from './token-store' +export * from './pull-request-store' +export * from './pull-request-coordinator' +export { UpstreamRemoteName } from './helpers/find-upstream-remote' diff --git a/app/src/lib/stores/issues-store.ts b/app/src/lib/stores/issues-store.ts new file mode 100644 index 0000000000..5227798b10 --- /dev/null +++ b/app/src/lib/stores/issues-store.ts @@ -0,0 +1,216 @@ +import { IssuesDatabase, IIssue } from '../databases/issues-database' +import { API, IAPIIssue } from '../api' +import { Account } from '../../models/account' +import { GitHubRepository } from '../../models/github-repository' +import { compare, compareDescending } from '../compare' +import { DefaultMaxHits } from '../../ui/autocompletion/common' + +/** An autocompletion hit for an issue. */ +export interface IIssueHit { + /** The title of the issue. */ + readonly title: string + + /** The issue's number. */ + readonly number: number +} + +/** + * The max time (in milliseconds) that we'll keep a mentionable query + * cache around before pruning it. + */ +const QueryCacheTimeout = 60 * 1000 + +interface IQueryCache { + readonly repository: GitHubRepository + readonly issues: ReadonlyArray +} + +/** The store for GitHub issues. */ +export class IssuesStore { + private db: IssuesDatabase + private queryCache: IQueryCache | null = null + private pruneQueryCacheTimeoutId: number | null = null + + /** Initialize the store with the given database. */ + public constructor(db: IssuesDatabase) { + this.db = db + } + + /** + * Get the highest value of the 'updated_at' field for issues in a given + * repository. This value is used to request delta updates from the API + * using the 'since' parameter. + */ + private async getLatestUpdatedAt( + repository: GitHubRepository + ): Promise { + const db = this.db + + const latestUpdatedIssue = await db.issues + .where('[gitHubRepositoryID+updated_at]') + .between([repository.dbID], [repository.dbID + 1], true, false) + .last() + + if (!latestUpdatedIssue || !latestUpdatedIssue.updated_at) { + return null + } + + const lastUpdatedAt = new Date(latestUpdatedIssue.updated_at) + + return !isNaN(lastUpdatedAt.getTime()) ? lastUpdatedAt : null + } + + /** + * Refresh the issues for the current repository. This will delete any issues that have + * been closed and update or add any issues that have changed or been added. + */ + public async refreshIssues(repository: GitHubRepository, account: Account) { + const api = API.fromAccount(account) + const lastUpdatedAt = await this.getLatestUpdatedAt(repository) + + // If we don't have a lastUpdatedAt that mean we haven't fetched any issues + // for the repository yet which in turn means we only have to fetch the + // currently open issues. If we have fetched before we get all issues + // that have been modified since the last time we fetched so that we + // can prune closed issues from our database. Note that since the GitHub + // API returns all issues modified _at_ or after the timestamp we give it + // we will always get at least one issue back but we won't have to transfer + // it since we should get a 304 response from GitHub. + const state = lastUpdatedAt ? 'all' : 'open' + + const issues = await api.fetchIssues( + repository.owner.login, + repository.name, + state, + lastUpdatedAt + ) + + this.storeIssues(issues, repository) + } + + private async storeIssues( + issues: ReadonlyArray, + repository: GitHubRepository + ): Promise { + const issuesToDelete = issues.filter(i => i.state === 'closed') + const issuesToUpsert = issues + .filter(i => i.state === 'open') + .map(i => { + return { + gitHubRepositoryID: repository.dbID, + number: i.number, + title: i.title, + updated_at: i.updated_at, + } + }) + + const db = this.db + + function findIssueInRepositoryByNumber( + gitHubRepositoryID: number, + issueNumber: number + ) { + return db.issues + .where('[gitHubRepositoryID+number]') + .equals([gitHubRepositoryID, issueNumber]) + .limit(1) + .first() + } + + await this.db.transaction('rw', this.db.issues, async () => { + for (const issue of issuesToDelete) { + const existing = await findIssueInRepositoryByNumber( + repository.dbID, + issue.number + ) + if (existing) { + await this.db.issues.delete(existing.id!) + } + } + + for (const issue of issuesToUpsert) { + const existing = await findIssueInRepositoryByNumber( + repository.dbID, + issue.number + ) + if (existing) { + await db.issues.update(existing.id!, issue) + } else { + await db.issues.add(issue) + } + } + }) + + if (this.queryCache?.repository.dbID === repository.dbID) { + this.queryCache = null + this.clearCachePruneTimeout() + } + } + + private async getAllIssueHitsFor(repository: GitHubRepository) { + const hits = await this.db.getIssuesForRepository(repository.dbID) + return hits.map(i => ({ number: i.number, title: i.title })) + } + + /** Get issues whose title or number matches the text. */ + public async getIssuesMatching( + repository: GitHubRepository, + text: string, + maxHits = DefaultMaxHits + ): Promise> { + const issues = + this.queryCache?.repository.dbID === repository.dbID + ? // Dexie gets confused if we return without wrapping in promise + await Promise.resolve(this.queryCache?.issues) + : await this.getAllIssueHitsFor(repository) + + this.setQueryCache(repository, issues) + + if (!text.length) { + return issues + .slice() + .sort((x, y) => compareDescending(x.number, y.number)) + .slice(0, maxHits) + } + + const hits = [] + const needle = text.toLowerCase() + + for (const issue of issues) { + const ix = `${issue.number} ${issue.title}` + .trim() + .toLowerCase() + .indexOf(needle) + + if (ix >= 0) { + hits.push({ hit: { number: issue.number, title: issue.title }, ix }) + } + } + + // Sort hits primarily based on how early in the text the match + // was found and then secondarily using alphabetic order. + return hits + .sort((x, y) => compare(x.ix, y.ix) || compare(x.hit.title, y.hit.title)) + .slice(0, maxHits) + .map(h => h.hit) + } + + private setQueryCache( + repository: GitHubRepository, + issues: ReadonlyArray + ) { + this.clearCachePruneTimeout() + this.queryCache = { repository, issues } + this.pruneQueryCacheTimeoutId = window.setTimeout(() => { + this.pruneQueryCacheTimeoutId = null + this.queryCache = null + }, QueryCacheTimeout) + } + + private clearCachePruneTimeout() { + if (this.pruneQueryCacheTimeoutId !== null) { + clearTimeout(this.pruneQueryCacheTimeoutId) + this.pruneQueryCacheTimeoutId = null + } + } +} 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 0000000000..f218bdd920 --- /dev/null +++ b/app/src/lib/stores/notifications-debug-store.ts @@ -0,0 +1,128 @@ +import { GitHubRepository } from '../../models/github-repository' +import { PullRequest } from '../../models/pull-request' +import { RepositoryWithGitHubRepository } from '../../models/repository' +import { API, IAPIComment } from '../api' +import { + isValidNotificationPullRequestReview, + ValidNotificationPullRequestReview, +} from '../valid-notification-pull-request-review' +import { AccountsStore } from './accounts-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 { + 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) { + return this.pullRequestCoordinator.getAllPullRequests(repository) + } + + /** Fetch all reviews for the given pull request. */ + public async getPullRequestReviews( + repository: RepositoryWithGitHubRepository, + pullRequestNumber: number + ) { + 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 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(), + }) + } +} diff --git a/app/src/lib/stores/notifications-store.ts b/app/src/lib/stores/notifications-store.ts new file mode 100644 index 0000000000..e42d91778f --- /dev/null +++ b/app/src/lib/stores/notifications-store.ts @@ -0,0 +1,571 @@ +import { + Repository, + isRepositoryWithGitHubRepository, + RepositoryWithGitHubRepository, + isRepositoryWithForkedGitHubRepository, + getForkContributionTarget, +} from '../../models/repository' +import { ForkContributionTarget } from '../../models/workflow-preferences' +import { getPullRequestCommitRef, PullRequest } from '../../models/pull-request' +import { API, APICheckConclusion, IAPIComment } from '../api' +import { + createCombinedCheckFromChecks, + getLatestCheckRunsByName, + apiStatusToRefCheck, + apiCheckRunToRefCheck, + IRefCheck, +} 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, shortenSHA } from '../../models/commit' +import { + AliveStore, + DesktopAliveEvent, + IDesktopChecksFailedAliveEvent, + IDesktopPullRequestCommentAliveEvent, + IDesktopPullRequestReviewSubmitAliveEvent, +} from './alive-store' +import { setBoolean, getBoolean } 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 { + isValidNotificationPullRequestReview, + ValidNotificationPullRequestReview, +} from '../valid-notification-pull-request-review' +import { NotificationCallback } from 'desktop-notifications/dist/notification-callback' + +type OnChecksFailedCallback = ( + repository: RepositoryWithGitHubRepository, + pullRequest: PullRequest, + commitMessage: string, + commitSha: string, + checkRuns: ReadonlyArray +) => void + +type OnPullRequestReviewSubmitCallback = ( + repository: RepositoryWithGitHubRepository, + pullRequest: PullRequest, + review: ValidNotificationPullRequestReview +) => void + +type OnPullRequestCommentCallback = ( + repository: RepositoryWithGitHubRepository, + pullRequest: PullRequest, + comment: IAPIComment +) => void + +/** + * The localStorage key for whether the user has enabled high-signal + * notifications. + */ +const NotificationsEnabledKey = 'high-signal-notifications-enabled' + +/** Whether or not the user has enabled high-signal notifications */ +export function getNotificationsEnabled() { + return getBoolean(NotificationsEnabledKey, true) +} + +/** + * This class manages the coordination between Alive events and actual OS-level + * notifications. + */ +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, + private readonly aliveStore: AliveStore, + private readonly pullRequestCoordinator: PullRequestCoordinator, + private readonly statsStore: StatsStore + ) { + this.aliveStore.setEnabled(getNotificationsEnabled()) + this.aliveStore.onAliveEventReceived(this.onAliveEventReceived) + } + + /** Enables or disables high-signal notifications entirely. */ + public setNotificationsEnabled(enabled: boolean) { + const previousValue = getBoolean(NotificationsEnabledKey, true) + + if (previousValue === enabled) { + return + } + + setBoolean(NotificationsEnabledKey, enabled) + this.aliveStore.setEnabled(enabled) + } + + private onAliveEventReceived = async (e: DesktopAliveEvent) => + this.handleAliveEvent(e, false) + + public onNotificationEventReceived: NotificationCallback = + async (event, id, userInfo) => this.handleAliveEvent(userInfo, true) + + public simulateAliveEvent(event: DesktopAliveEvent) { + if (__DEV__) { + this.handleAliveEvent(event, false) + } + } + + private async handleAliveEvent( + e: DesktopAliveEvent, + skipNotification: boolean + ) { + switch (e.type) { + case 'pr-checks-failed': + return this.handleChecksFailedEvent(e, skipNotification) + case 'pr-review-submit': + return this.handlePullRequestReviewSubmitEvent(e, skipNotification) + case 'pr-comment': + return this.handlePullRequestCommentEvent(e, skipNotification) + } + } + + private async handlePullRequestCommentEvent( + event: IDesktopPullRequestCommentAliveEvent, + skipNotification: boolean + ) { + const repository = this.repository + if (repository === null) { + return + } + + if (!this.isValidRepositoryForEvent(repository, event)) { + if (this.isRecentRepositoryEvent(event)) { + this.statsStore.recordPullRequestCommentNotificationFromRecentRepo() + } else { + this.statsStore.recordPullRequestCommentNotificationFromNonRecentRepo() + } + 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 your pull request` + const body = `${pullRequest.title} #${ + pullRequest.pullRequestNumber + }\n${truncateWithEllipsis(comment.body, 50)}` + const onClick = () => { + this.statsStore.recordPullRequestCommentNotificationClicked() + + this.onPullRequestCommentCallback?.(repository, pullRequest, comment) + } + + if (skipNotification) { + onClick() + return + } + + showNotification({ + title, + body, + userInfo: event, + onClick, + }) + + this.statsStore.recordPullRequestCommentNotificationShown() + } + + 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.recordPullRequestReviewNotificationFromRecentRepo() + } else { + this.statsStore.recordPullRequestReviewNotificationFromNonRecentRepo() + } + 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 + // from Desktop, so we can maybe ignore it? + if (pullRequest === undefined) { + return + } + + // 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( + pullsRepository.owner.login, + pullsRepository.name, + pullRequest.pullRequestNumber.toString(), + event.review_id + ) + + if (review === null || !isValidNotificationPullRequestReview(review)) { + return + } + + const reviewVerb = getVerbForPullRequestReview(review) + const title = `@${review.user.login} ${reviewVerb} your pull request` + const body = `${pullRequest.title} #${ + pullRequest.pullRequestNumber + }\n${truncateWithEllipsis(review.body, 50)}` + const onClick = () => { + this.statsStore.recordPullRequestReviewNotificationClicked(review.state) + + this.onPullRequestReviewSubmitCallback?.(repository, pullRequest, review) + } + + if (skipNotification) { + onClick() + return + } + + showNotification({ + title, + body, + userInfo: event, + onClick, + }) + + this.statsStore.recordPullRequestReviewNotificationShown(review.state) + } + + private async handleChecksFailedEvent( + event: IDesktopChecksFailedAliveEvent, + skipNotification: boolean + ) { + const repository = this.repository + if (repository === null) { + return + } + + if (!this.isValidRepositoryForEvent(repository, event)) { + if (this.isRecentRepositoryEvent(event)) { + this.statsStore.recordChecksFailedNotificationFromRecentRepo() + } else { + this.statsStore.recordChecksFailedNotificationFromNonRecentRepo() + } + 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 it the checks weren't + // triggered by a push from Desktop, so we can maybe ignore it? + if (pullRequest === undefined) { + return + } + + const account = await this.getAccountForRepository( + repository.gitHubRepository + ) + + if (account === null) { + return + } + + const commitSHA = event.commit_sha + + if (this.skipCommitShas.has(commitSHA)) { + return + } + + const commit = + this.cachedCommits.get(commitSHA) ?? + (await getCommit(repository, commitSHA)) + if (commit === null) { + this.skipCommitShas.add(commitSHA) + return + } + + this.cachedCommits.set(commitSHA, commit) + + if (!account.emails.map(e => e.email).includes(commit.author.email)) { + this.skipCommitShas.add(commitSHA) + return + } + + // 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 + + // Sometimes we could get a checks-failed event for a PR whose checks just + // got restarted, so we won't get failed checks at that point. In that + // scenario, just ignore the event and don't show a notification. + if (numberOfFailedChecks === 0) { + 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 = 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 + ) + } + + if (skipNotification) { + onClick() + return + } + + showNotification({ + title, + body, + userInfo: event, + onClick, + }) + + this.statsStore.recordChecksFailedNotificationShown() + } + + 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) + ) + } + + /** + * Makes the store to keep track of the currently selected repository. Only + * 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) { + 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) + } + + private async getChecksForRef(repository: GitHubRepository, ref: string) { + const { owner, name } = repository + + 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, true), + api.fetchRefCheckRuns(owner.login, name, ref, true), + ]) + + const checks = new Array() + + if (statuses === null || checkRuns === null) { + return null + } + + if (statuses !== null) { + checks.push(...statuses.statuses.map(apiStatusToRefCheck)) + } + + if (checkRuns !== null) { + const latestCheckRunsByName = getLatestCheckRunsByName( + checkRuns.check_runs + ) + checks.push(...latestCheckRunsByName.map(apiCheckRunToRefCheck)) + } + + const check = createCombinedCheckFromChecks(checks) + + if (check === null || check.checks.length === 0) { + return null + } + + return check.checks + } + + /** Observe when the user reacted to a "Checks Failed" notification. */ + public onChecksFailedNotification(callback: OnChecksFailedCallback) { + this.onChecksFailedCallback = callback + } + + /** Observe when the user reacted to a "PR review submit" notification. */ + public onPullRequestReviewSubmitNotification( + callback: OnPullRequestReviewSubmitCallback + ) { + 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 new file mode 100644 index 0000000000..6bd4c6c3f5 --- /dev/null +++ b/app/src/lib/stores/pull-request-coordinator.ts @@ -0,0 +1,272 @@ +import { Account } from '../../models/account' +import { PullRequest } from '../../models/pull-request' +import { + RepositoryWithGitHubRepository, + isRepositoryWithGitHubRepository, + getNonForkGitHubRepository, +} from '../../models/repository' +import { PullRequestStore } from '.' +import { PullRequestUpdater } from './helpers/pull-request-updater' +import { RepositoriesStore } from './repositories-store' +import { GitHubRepository } from '../../models/github-repository' +import { Disposable, Emitter } from 'event-kit' + +/** + * Provides a single point of access for getting pull requests + * associated with a local repository (assuming its connected + * to a repository on GitHub). + * + * Primarily a layer between AppStore and the + * PullRequestStore + PullRequestUpdaters. + */ +export class PullRequestCoordinator { + /** + * Currently running PullRequestUpdater (should be for + * the "selected" repository in `AppStore`) + */ + private currentPullRequestUpdater: PullRequestUpdater | null = null + /** + * All `Repository`s in RepositoryStore associated with `GitHubRepository` + * This is updated whenever `RepositoryStore` emits an update + */ + private repositories: Promise> + + /** + * Contains the last set of PRs retrieved by `PullRequestCoordinator` + * from `PullRequestStore` for a specific `GitHubRepository`. + * Keyed by `GitHubRepository` database ID to a list of pull requests. + * + * This is used to improve performance by reducing + * duplicate queries to the pull request database. + * + */ + private readonly prCache = new Map>() + + /** Used to emit pull request loading events */ + protected readonly emitter = new Emitter() + + public constructor( + private readonly pullRequestStore: PullRequestStore, + private readonly repositoriesStore: RepositoriesStore + ) { + // register an update handler for the repositories store + this.repositoriesStore.onDidUpdate(allRepositories => { + this.repositories = Promise.resolve( + allRepositories.filter(isRepositoryWithGitHubRepository) + ) + }) + + // The `onDidUpdate` event only triggers when the list of repositories + // changes or a repository's information is changed. This may now happen for + // a very long time so we need to eagerly load the list of repositories. + this.repositories = this.repositoriesStore + .getAll() + .then(x => x.filter(isRepositoryWithGitHubRepository)) + .catch(e => { + log.error(`PullRequestCoordinator: Error loading repositories`, e) + return [] + }) + } + + /** + * Register a function to be called when the PullRequestStore updates. + * + * @param fn to be called with a `Repository` and an updated + + * complete list of pull requests whenever `PullRequestStore` + * emits an update for a related repo on GitHub. + * + * Related repos include: + * * the corresponding GitHub repo (the `origin` remote for + * the `Repository`) + * * the parent GitHub repo, if the `Repository` has one (the + * `upstream` remote for the `Repository`) + */ + public onPullRequestsChanged( + fn: ( + repository: RepositoryWithGitHubRepository, + pullRequests: ReadonlyArray + ) => void + ): Disposable { + return this.pullRequestStore.onPullRequestsChanged( + async (ghRepo, pullRequests) => { + this.prCache.set(ghRepo.dbID, pullRequests) + + // find all related repos + const matches = findRepositoriesForGitHubRepository( + ghRepo, + await this.repositories + ) + + // emit updates for matches + for (const match of matches) { + fn(match, pullRequests) + } + } + ) + } + + /** + * Register a function to be called when PullRequestStore + * emits a "loading" event. + * + * @param fn to be called with a `Repository` whenever + * `PullRequestStore` emits an update for a + * related repo on GitHub. + * + * Related repos include: + * * the corresponding GitHub repo (the `origin` remote for + * the `Repository`) + * * the parent GitHub repo, if the `Repository` has one (the + * `upstream` remote for the `Repository`) + */ + public onIsLoadingPullRequests( + fn: ( + repository: RepositoryWithGitHubRepository, + isLoadingPullRequests: boolean + ) => void + ): Disposable { + return this.emitter.on('onIsLoadingPullRequest', value => { + const { repository, isLoadingPullRequests } = value + fn(repository, isLoadingPullRequests) + }) + } + + /** + * Fetches all pull requests for the given repository. + * This **will** attempt to hit the GitHub API. + * + * This method has some specific logic for emitting loading + * events. Multiple clones of the same remote GitHub repo + * will share the same fields in the pull request database, + * but the larger app considers every local copy separate. + * The `findRepositoriesForGitHubRepository` logic ensures + * that we emit loading events for all those repositories. + */ + public async refreshPullRequests( + repository: RepositoryWithGitHubRepository, + account: Account + ) { + const gitHubRepository = getNonForkGitHubRepository(repository) + + // get all matches for the repository to be refreshed + const matches = findRepositoriesForGitHubRepository( + gitHubRepository, + await this.repositories + ) + // mark all matching repos as now loading + for (const match of matches) { + this.emitIsLoadingPullRequests(match, true) + } + + await this.pullRequestStore.refreshPullRequests(gitHubRepository, account) + + // mark all matching repos as done loading + for (const match of matches) { + this.emitIsLoadingPullRequests(match, false) + } + } + + /** + * Get the last time a repository's pull requests were fetched + * from the GitHub API + * + * Since `PullRequestStore` stores these timestamps by + * `GitHubRepository`, we get timestamps for this + * repo's `GitHubRepository` or its parent (if it has one). + * + * If no timestamp is stored, returns `undefined` + */ + public getLastRefreshed( + repository: RepositoryWithGitHubRepository + ): number | undefined { + const ghr = getNonForkGitHubRepository(repository) + + return this.pullRequestStore.getLastRefreshed(ghr) + } + + /** + * Get all Pull Requests that are stored locally for the given Repository + * (Doesn't load anything new from the GitHub API.) + */ + public async getAllPullRequests( + repository: RepositoryWithGitHubRepository + ): Promise> { + return this.getPullRequestsFor(getNonForkGitHubRepository(repository)) + } + + /** Start background pull request fetching machinery for this Repository */ + public startPullRequestUpdater( + repository: RepositoryWithGitHubRepository, + account: Account + ) { + if (this.currentPullRequestUpdater !== null) { + this.stopPullRequestUpdater() + } + + this.currentPullRequestUpdater = new PullRequestUpdater( + repository, + account, + this + ) + this.currentPullRequestUpdater.start() + } + + /** Stop background pull request fetching machinery for this Repository */ + public stopPullRequestUpdater() { + if (this.currentPullRequestUpdater !== null) { + this.currentPullRequestUpdater.stop() + this.currentPullRequestUpdater = null + } + } + + /** Emits a "pull requests are loading" event */ + private emitIsLoadingPullRequests( + repository: RepositoryWithGitHubRepository, + isLoadingPullRequests: boolean + ) { + this.emitter.emit('onIsLoadingPullRequest', { + repository, + isLoadingPullRequests, + }) + } + + /** + * Get Pull Requests stored in the database (or + * `PullRequestCoordinator`'s cache) for a single `GitHubRepository`) + * + * Will query `PullRequestStore`'s database if nothing is cached for that repo. + */ + private async getPullRequestsFor( + gitHubRepository: GitHubRepository + ): Promise> { + if (!this.prCache.has(gitHubRepository.dbID)) { + this.prCache.set( + gitHubRepository.dbID, + await this.pullRequestStore.getAll(gitHubRepository) + ) + } + return this.prCache.get(gitHubRepository.dbID) || [] + } +} + +/** + * Finds local repositories related to a GitHubRepository + * + * * Related repos include the corresponding GitHub repo (the `origin` remote for + * the `Repository`) or the parent GitHub repo, if the `Repository` has one (the + * `upstream` remote for the `Repository`) + * + * @param gitHubRepository + * @param repositories list of repositories to search for a match + * @returns the list of repositories. + */ +function findRepositoriesForGitHubRepository( + gitHubRepository: GitHubRepository, + repositories: ReadonlyArray +): ReadonlyArray { + const { dbID } = gitHubRepository + + return repositories.filter( + repository => getNonForkGitHubRepository(repository).dbID === dbID + ) +} diff --git a/app/src/lib/stores/pull-request-store.ts b/app/src/lib/stores/pull-request-store.ts new file mode 100644 index 0000000000..b83442a456 --- /dev/null +++ b/app/src/lib/stores/pull-request-store.ts @@ -0,0 +1,360 @@ +import mem from 'mem' + +import { + PullRequestDatabase, + IPullRequest, + PullRequestKey, + getPullRequestKey, +} from '../databases/pull-request-database' +import { GitHubRepository } from '../../models/github-repository' +import { Account } from '../../models/account' +import { API, IAPIPullRequest, MaxResultsError } from '../api' +import { fatalError } from '../fatal-error' +import { RepositoriesStore } from './repositories-store' +import { PullRequest, PullRequestRef } from '../../models/pull-request' +import { structuralEquals } from '../equality' +import { Emitter, Disposable } from 'event-kit' +import { APIError } from '../http' + +/** The store for GitHub Pull Requests. */ +export class PullRequestStore { + protected readonly emitter = new Emitter() + private readonly currentRefreshOperations = new Map>() + private readonly lastRefreshForRepository = new Map() + + public constructor( + private readonly db: PullRequestDatabase, + private readonly repositoryStore: RepositoriesStore + ) {} + + private emitPullRequestsChanged( + repository: GitHubRepository, + pullRequests: ReadonlyArray + ) { + this.emitter.emit('onPullRequestsChanged', { repository, pullRequests }) + } + + /** Register a function to be called when the store updates. */ + public onPullRequestsChanged( + fn: ( + repository: GitHubRepository, + pullRequests: ReadonlyArray + ) => void + ): Disposable { + return this.emitter.on('onPullRequestsChanged', value => { + const { repository, pullRequests } = value + fn(repository, pullRequests) + }) + } + + /** Loads all pull requests against the given repository. */ + public refreshPullRequests(repo: GitHubRepository, account: Account) { + const currentOp = this.currentRefreshOperations.get(repo.dbID) + + if (currentOp !== undefined) { + return currentOp + } + + this.lastRefreshForRepository.set(repo.dbID, Date.now()) + + const promise = this.fetchAndStorePullRequests(repo, account) + .catch(err => { + log.error(`Error refreshing pull requests for '${repo.fullName}'`, err) + }) + .then(() => { + this.currentRefreshOperations.delete(repo.dbID) + }) + + this.currentRefreshOperations.set(repo.dbID, promise) + return promise + } + + /** + * Fetches pull requests from the API (either all open PRs if it's the + * first time fetching for this repository or all updated PRs if not). + * + * Returns a value indicating whether it's safe to avoid + * emitting an event that the store has been updated. In other words, when + * this method returns false it's safe to say that nothing has been changed + * in the pull requests table. + */ + private async fetchAndStorePullRequests( + repo: GitHubRepository, + account: Account + ) { + const api = API.fromAccount(account) + const lastUpdatedAt = await this.db.getLastUpdated(repo) + + // If we don't have a lastUpdatedAt that mean we haven't fetched any PRs + // for the repository yet which in turn means we only have to fetch the + // currently open PRs. If we have fetched before we get all PRs + // If we have a lastUpdatedAt that mean we have fetched PRs + // for the repository before. If we have fetched before we get all PRs + // that have been modified since the last time we fetched so that we + // can prune closed issues from our database. Note that since + // `api.fetchUpdatedPullRequests` returns all issues modified _at_ or + // after the timestamp we give it we will always get at least one issue + // back. See `storePullRequests` for details on how that's handled. + if (!lastUpdatedAt) { + return this.fetchAndStoreOpenPullRequests(api, repo) + } else { + return this.fetchAndStoreUpdatedPullRequests(api, repo, lastUpdatedAt) + } + } + + private async fetchAndStoreOpenPullRequests( + api: API, + repository: GitHubRepository + ) { + const { name, owner } = getNameWithOwner(repository) + const open = await api.fetchAllOpenPullRequests(owner, name) + await this.storePullRequestsAndEmitUpdate(open, repository) + } + + private async fetchAndStoreUpdatedPullRequests( + api: API, + repository: GitHubRepository, + lastUpdatedAt: Date + ) { + const { name, owner } = getNameWithOwner(repository) + const updated = await api + .fetchUpdatedPullRequests(owner, name, lastUpdatedAt) + .catch(e => + // Any other error we'll bubble up but these ones we + // can handle, see below. + e instanceof MaxResultsError || e instanceof APIError + ? Promise.resolve(null) + : Promise.reject(e) + ) + + if (updated !== null) { + return await this.storePullRequestsAndEmitUpdate(updated, repository) + } else { + // If we fail to load updated pull requests either because + // there's too many updated PRs since the last time we + // fetched (and it's likely that it'll be much more + // efficient to just load the open PRs) or it's because the + // API told us we couldn't load PRs (rate limit or permissions + // problems). In either case we delete the PRs we've got + // for this repo and attempt to load just the open ones. + // + // This scenario can happen for repositories that are + // very active while simultaneously infrequently used + // by the user. Think of a very active open source repository + // where the user only visits once a year to make a contribution. + // It's likely that there's at most a few hundred PRs open but + // the number of merged PRs since the last time we fetched could + // number in the thousands. + await this.db.deleteAllPullRequestsInRepository(repository) + await this.fetchAndStoreOpenPullRequests(api, repository) + } + } + + public getLastRefreshed(repository: GitHubRepository) { + return repository.dbID + ? this.lastRefreshForRepository.get(repository.dbID) + : undefined + } + + /** Gets all stored pull requests for the given repository. */ + public async getAll(repository: GitHubRepository) { + const records = await this.db.getAllPullRequestsInRepository(repository) + const result = new Array() + + // In order to avoid what would otherwise be a very expensive + // N+1 (N+2 really) query where we look up the head and base + // GitHubRepository from IndexedDB for each pull request we'll memoize + // already retrieved GitHubRepository instances. + // + // This optimization decreased the run time of this method from 6 + // seconds to just under 26 ms while testing using an internal + // repository with 1k+ PRs. Even in the worst-case scenario (i.e + // a repository with a huge number of open PRs from forks) this + // will reduce the N+2 to N+1. + const store = this.repositoryStore + const getRepo = mem(store.findGitHubRepositoryByID.bind(store)) + + for (const record of records) { + const headRepository = await getRepo(record.head.repoId) + const baseRepository = await getRepo(record.base.repoId) + + if (headRepository === null) { + return fatalError("head repository can't be null") + } + + if (baseRepository === null) { + return fatalError("base repository can't be null") + } + + result.push( + new PullRequest( + new Date(record.createdAt), + record.title, + record.number, + new PullRequestRef(record.head.ref, record.head.sha, headRepository), + new PullRequestRef(record.base.ref, record.base.sha, baseRepository), + record.author, + record.draft ?? false, + record.body + ) + ) + } + + // Reversing the results in place manually instead of using + // .reverse on the IndexedDB query has been measured to have favorable + // performance characteristics for repositories with a lot of pull + // requests since it means Dexie is able to leverage the IndexedDB + // getAll method as opposed to creating a reverse cursor. Reversing + // in place versus unshifting is also dramatically more performant. + return result.reverse() + } + + /** + * Stores all pull requests that are open and deletes all that are merged + * or closed. Returns a value indicating whether an update notification + * has been emitted, see `storePullRequests` for more details. + */ + private async storePullRequestsAndEmitUpdate( + pullRequestsFromAPI: ReadonlyArray, + repository: GitHubRepository + ) { + if (await this.storePullRequests(pullRequestsFromAPI, repository)) { + this.emitPullRequestsChanged(repository, await this.getAll(repository)) + } + } + + /** + * Stores all pull requests that are open and deletes all that are merged + * or closed. Returns a value indicating whether it's safe to avoid + * emitting an event that the store has been updated. In other words, when + * this method returns false it's safe to say that nothing has been changed + * in the pull requests table. + */ + private async storePullRequests( + pullRequestsFromAPI: ReadonlyArray, + repository: GitHubRepository + ) { + if (pullRequestsFromAPI.length === 0) { + return false + } + + let mostRecentlyUpdated = pullRequestsFromAPI[0].updated_at + + const prsToDelete = new Array() + const prsToUpsert = new Array() + + // The API endpoint for this PR, i.e api.github.com or a GHE url + const { endpoint } = repository + const store = this.repositoryStore + + // Upsert will always query the database for a repository. Given that + // we've receive these repositories in a batch response from the API + // it's pretty unlikely that they'd differ between PRs so we're going + // to use the upsert just to ensure that the repo exists in the database + // and reuse the same object without going to the database for all that + // follow. + const upsertRepo = mem(store.upsertGitHubRepositoryLight.bind(store), { + // The first argument which we're ignoring here is the endpoint + // which is constant throughout the lifetime of this function. + // The second argument is an `IAPIRepository` which is basically + // the raw object that we got from the API which could consist of + // more than just the fields we've modelled in the interface. The + // only thing we really care about to determine whether the + // repository has already been inserted in the database is the clone + // url since that's what the upsert method uses as its key. + cacheKey: (_, repo) => repo.clone_url, + }) + + for (const pr of pullRequestsFromAPI) { + // We can do this string comparison here rather than convert to date + // because ISO8601 is lexicographically sortable + if (pr.updated_at > mostRecentlyUpdated) { + mostRecentlyUpdated = pr.updated_at + } + + // We know the base repo isn't null since that's where we got the PR from + // in the first place. + if (pr.base.repo === null) { + return fatalError('PR cannot have a null base repo') + } + + const baseGitHubRepo = await upsertRepo(endpoint, pr.base.repo) + + if (pr.state === 'closed') { + prsToDelete.push(getPullRequestKey(baseGitHubRepo, pr.number)) + continue + } + + // `pr.head.repo` represents the source of the pull request. It might be + // a branch associated with the current repository, or a fork of the + // current repository. + // + // In cases where the user has removed the fork of the repository after + // opening a pull request, this can be `null`, and the app will not store + // this pull request. + if (pr.head.repo == null) { + log.debug( + `Unable to store pull request #${pr.number} for repository ${repository.fullName} as it has no head repository associated with it` + ) + prsToDelete.push(getPullRequestKey(baseGitHubRepo, pr.number)) + continue + } + + const headRepo = await upsertRepo(endpoint, pr.head.repo) + + prsToUpsert.push({ + number: pr.number, + title: pr.title, + createdAt: pr.created_at, + updatedAt: pr.updated_at, + head: { + ref: pr.head.ref, + sha: pr.head.sha, + repoId: headRepo.dbID, + }, + base: { + ref: pr.base.ref, + sha: pr.base.sha, + repoId: baseGitHubRepo.dbID, + }, + body: pr.body, + author: pr.user.login, + draft: pr.draft ?? false, + }) + } + + // When loading only PRs that has changed since the last fetch + // we get back all PRs modified _at_ or after the timestamp we give it + // meaning we will always get at least one issue back but. This + // check detect this particular condition and lets us avoid expensive + // branch pruning and updates for a single PR that hasn't actually + // been updated. + if (prsToDelete.length === 0 && prsToUpsert.length === 1) { + const cur = prsToUpsert[0] + const prev = await this.db.getPullRequest(repository, cur.number) + + if (prev !== undefined && structuralEquals(cur, prev)) { + return false + } + } + + await this.db.transaction( + 'rw', + this.db.pullRequests, + this.db.pullRequestsLastUpdated, + async () => { + await this.db.deletePullRequests(prsToDelete) + await this.db.putPullRequests(prsToUpsert) + await this.db.setLastUpdated(repository, new Date(mostRecentlyUpdated)) + } + ) + + return true + } +} + +function getNameWithOwner(repository: GitHubRepository) { + const owner = repository.owner.login + const name = repository.name + return { name, owner } +} diff --git a/app/src/lib/stores/repositories-store.ts b/app/src/lib/stores/repositories-store.ts new file mode 100644 index 0000000000..8ecf70fa22 --- /dev/null +++ b/app/src/lib/stores/repositories-store.ts @@ -0,0 +1,741 @@ +import { + RepositoriesDatabase, + IDatabaseGitHubRepository, + IDatabaseProtectedBranch, + IDatabaseRepository, + getOwnerKey, +} from '../databases/repositories-database' +import { Owner } from '../../models/owner' +import { + GitHubRepository, + GitHubRepositoryPermission, +} from '../../models/github-repository' +import { + Repository, + RepositoryWithGitHubRepository, + assertIsRepositoryWithGitHubRepository, + isRepositoryWithGitHubRepository, +} from '../../models/repository' +import { fatalError, assertNonNullable, forceUnwrap } from '../fatal-error' +import { + IAPIRepository, + IAPIBranch, + IAPIFullRepository, + GitHubAccountType, +} from '../api' +import { TypedBaseStore } from './base-store' +import { WorkflowPreferences } from '../../models/workflow-preferences' +import { clearTagsToPush } from './helpers/tags-to-push-storage' +import { IMatchedGitHubRepository } from '../repository-matching' +import { shallowEquals } from '../equality' + +type AddRepositoryOptions = { + missing?: boolean +} + +/** The store for local repositories. */ +export class RepositoriesStore extends TypedBaseStore< + ReadonlyArray +> { + // Key-repo ID, Value-date + private lastStashCheckCache = new Map() + + /** + * Key is the GitHubRepository id, value is the protected branch count reported + * by the GitHub API. + */ + private branchProtectionSettingsFoundCache = new Map() + + /** + * Key is the lookup by the GitHubRepository id and branch name, value is the + * flag whether this branch is considered protected by the GitHub API + */ + private protectionEnabledForBranchCache = new Map() + + private emitQueued = false + + public constructor(private readonly db: RepositoriesDatabase) { + super() + } + + /** + * Insert or update the GitHub repository database record based on the + * provided API information while preserving any knowledge of the repository's + * parent. + * + * See the documentation inside putGitHubRepository for more information but + * the TL;DR is that if you've got an IAPIRepository you should use this + * method and if you've got an IAPIFullRepository you should use + * `upsertGitHubRepository` + */ + public async upsertGitHubRepositoryLight( + endpoint: string, + apiRepository: IAPIRepository + ) { + return this.db.transaction( + 'rw', + this.db.gitHubRepositories, + this.db.owners, + () => this._upsertGitHubRepository(endpoint, apiRepository, true) + ) + } + + /** + * Insert or update the GitHub repository database record based on the + * provided API information + */ + public async upsertGitHubRepository( + endpoint: string, + apiRepository: IAPIFullRepository + ): Promise { + return this.db.transaction( + 'rw', + this.db.gitHubRepositories, + this.db.owners, + () => this._upsertGitHubRepository(endpoint, apiRepository, false) + ) + } + + private async toGitHubRepository( + repo: IDatabaseGitHubRepository, + owner?: Owner, + parent?: GitHubRepository | null + ): Promise { + assertNonNullable(repo.id, 'Need db id to create GitHubRepository') + + // Note the difference between parent being null and undefined. Null means + // that the caller explicitly wants us to initialize a GitHubRepository + // without a parent, undefined means we should try to dig it up. + if (parent === undefined && repo.parentID !== null) { + const dbParent = await this.db.gitHubRepositories.get(repo.parentID) + assertNonNullable(dbParent, `Missing parent '${repo.id}'`) + parent = await this.toGitHubRepository(dbParent) + } + + if (owner === undefined) { + const dbOwner = await this.db.owners.get(repo.ownerID) + assertNonNullable(dbOwner, `Missing owner '${repo.ownerID}'`) + owner = new Owner( + dbOwner.login, + dbOwner.endpoint, + dbOwner.id!, + dbOwner.type + ) + } + + const ghRepo = new GitHubRepository( + repo.name, + owner, + repo.id, + repo.private, + repo.htmlURL, + repo.cloneURL, + repo.issuesEnabled, + repo.isArchived, + repo.permissions, + parent + ) + + // Dexie gets confused if we return a non-promise value (e.g. if this function + // didn't need to await for the parent repo or the owner) + return Promise.resolve(ghRepo) + } + + private async toRepository(repo: IDatabaseRepository) { + assertNonNullable(repo.id, "can't convert to Repository without id") + return new Repository( + repo.path, + repo.id, + repo.gitHubRepositoryID !== null + ? await this.findGitHubRepositoryByID(repo.gitHubRepositoryID) + : await Promise.resolve(null), // Dexie gets confused if we return null + repo.missing, + repo.alias, + repo.workflowPreferences, + repo.isTutorialRepository + ) + } + + /** Find a GitHub repository by its DB ID. */ + public async findGitHubRepositoryByID( + id: number + ): Promise { + const gitHubRepository = await this.db.gitHubRepositories.get(id) + return gitHubRepository !== undefined + ? this.toGitHubRepository(gitHubRepository) + : Promise.resolve(null) // Dexie gets confused if we return null + } + + /** Get all the local repositories. */ + public getAll(): Promise> { + return this.db.transaction( + 'r', + this.db.repositories, + this.db.gitHubRepositories, + this.db.owners, + async () => { + const repos = new Array() + + for (const dbRepo of await this.db.repositories.toArray()) { + assertNonNullable(dbRepo.id, 'no id after loading from db') + repos.push(await this.toRepository(dbRepo)) + } + + return repos + } + ) + } + + /** + * Add a tutorial repository. + * + * This method differs from the `addRepository` method in that it requires + * that the repository has been created on the remote and set up to track it. + * Given that tutorial repositories are created from the no-repositories blank + * slate it shouldn't be possible for another repository with the same path to + * exist but in case that changes in the future this method will set the + * tutorial flag on the existing repository at the given path. + */ + public async addTutorialRepository( + path: string, + endpoint: string, + apiRepo: IAPIFullRepository + ) { + await this.db.transaction( + 'rw', + this.db.repositories, + this.db.gitHubRepositories, + this.db.owners, + async () => { + const ghRepo = await this.upsertGitHubRepository(endpoint, apiRepo) + const existingRepo = await this.db.repositories.get({ path }) + + return await this.db.repositories.put({ + ...(existingRepo?.id !== undefined && { id: existingRepo.id }), + path, + alias: null, + gitHubRepositoryID: ghRepo.dbID, + missing: false, + lastStashCheckDate: null, + isTutorialRepository: true, + }) + } + ) + + this.emitUpdatedRepositories() + } + + /** + * Add a new local repository. + * + * If a repository already exists with that path, it will be returned instead. + */ + public async addRepository( + path: string, + opts?: AddRepositoryOptions + ): Promise { + const repository = await this.db.transaction( + 'rw', + this.db.repositories, + this.db.gitHubRepositories, + this.db.owners, + async () => { + const existing = await this.db.repositories.get({ path }) + + if (existing !== undefined) { + return await this.toRepository(existing) + } + + const dbRepo: IDatabaseRepository = { + path, + gitHubRepositoryID: null, + missing: opts?.missing ?? false, + lastStashCheckDate: null, + alias: null, + } + const id = await this.db.repositories.add(dbRepo) + return this.toRepository({ id, ...dbRepo }) + } + ) + + this.emitUpdatedRepositories() + + return repository + } + + /** Remove the given repository. */ + public async removeRepository(repository: Repository): Promise { + await this.db.repositories.delete(repository.id) + clearTagsToPush(repository) + + this.emitUpdatedRepositories() + } + + /** Update the repository's `missing` flag. */ + public async updateRepositoryMissing( + repository: Repository, + missing: boolean + ): Promise { + await this.db.repositories.update(repository.id, { missing }) + + this.emitUpdatedRepositories() + + return new Repository( + repository.path, + repository.id, + repository.gitHubRepository, + missing, + repository.alias, + repository.workflowPreferences, + repository.isTutorialRepository + ) + } + + /** + * Update the alias for the specified repository. + * + * @param repository The repository to update. + * @param alias The new alias to use. + */ + public async updateRepositoryAlias( + repository: Repository, + alias: string | null + ): Promise { + await this.db.repositories.update(repository.id, { alias }) + + this.emitUpdatedRepositories() + } + + /** + * Update the workflow preferences for the specified repository. + * + * @param repository The repository to update. + * @param workflowPreferences The object with the workflow settings to use. + */ + public async updateRepositoryWorkflowPreferences( + repository: Repository, + workflowPreferences: WorkflowPreferences + ): Promise { + await this.db.repositories.update(repository.id, { workflowPreferences }) + + this.emitUpdatedRepositories() + } + + /** Update the repository's path. */ + public async updateRepositoryPath( + repository: Repository, + path: string + ): Promise { + await this.db.repositories.update(repository.id, { missing: false, path }) + + this.emitUpdatedRepositories() + + return new Repository( + path, + repository.id, + repository.gitHubRepository, + false, + repository.alias, + repository.workflowPreferences, + repository.isTutorialRepository + ) + } + + /** + * Sets the last time the repository was checked for stash entries + * + * @param repository The repository in which to update the last stash check date for + * @param date The date and time in which the last stash check took place; defaults to + * the current time + */ + public async updateLastStashCheckDate( + repository: Repository, + date: number = Date.now() + ): Promise { + await this.db.repositories.update(repository.id, { + lastStashCheckDate: date, + }) + + this.lastStashCheckCache.set(repository.id, date) + + // this update doesn't affect the list (or its items) we emit from this store, so no need to `emitUpdatedRepositories` + } + + /** + * Gets the last time the repository was checked for stash entries + * + * @param repository The repository in which to update the last stash check date for + */ + public async getLastStashCheckDate( + repository: Repository + ): Promise { + let lastCheckDate = this.lastStashCheckCache.get(repository.id) || null + if (lastCheckDate !== null) { + return lastCheckDate + } + + const record = await this.db.repositories.get(repository.id) + + if (record === undefined) { + return fatalError( + `'getLastStashCheckDate' - unable to find repository with ID: ${repository.id}` + ) + } + + lastCheckDate = record.lastStashCheckDate ?? null + if (lastCheckDate !== null) { + this.lastStashCheckCache.set(repository.id, lastCheckDate) + } + + return lastCheckDate + } + + private async putOwner( + endpoint: string, + login: string, + ownerType?: GitHubAccountType + ): Promise { + const key = getOwnerKey(endpoint, login) + const existingOwner = await this.db.owners.get({ key }) + let id + + // Since we look up the owner based on a key which is the product of the + // lowercased endpoint and login we know that we've found our match but it's + // possible that the case differs (i.e we found `usera` but the actual login + // is `userA`). In that case we want to update our database to persist the + // login with the proper case. + if ( + existingOwner === undefined || + existingOwner.login !== login || + // This is added so that we update existing owners with an undefined type. + (ownerType !== undefined && existingOwner.type !== ownerType) + ) { + id = existingOwner?.id + const existingId = id !== undefined ? { id } : {} + id = await this.db.owners.put({ + ...existingId, + key, + endpoint, + login, + type: ownerType, + }) + } else { + id = forceUnwrap('Missing owner id', existingOwner.id) + } + + return new Owner(login, endpoint, id, ownerType ?? existingOwner?.type) + } + + public async upsertGitHubRepositoryFromMatch( + match: IMatchedGitHubRepository + ) { + return await this.db.transaction( + 'rw', + this.db.gitHubRepositories, + this.db.owners, + async () => { + const { account } = match + const owner = await this.putOwner(account.endpoint, match.owner) + const existingRepo = await this.db.gitHubRepositories + .where('[ownerID+name]') + .equals([owner.id, match.name]) + .first() + + if (existingRepo) { + return this.toGitHubRepository(existingRepo, owner) + } + + const skeletonRepo: IDatabaseGitHubRepository = { + cloneURL: null, + htmlURL: null, + lastPruneDate: null, + name: match.name, + ownerID: owner.id, + parentID: null, + private: null, + } + + const id = await this.db.gitHubRepositories.put(skeletonRepo) + return this.toGitHubRepository({ ...skeletonRepo, id }, owner, null) + } + ) + } + + public async setGitHubRepository(repo: Repository, ghRepo: GitHubRepository) { + // If nothing has changed we can skip writing to the database and (more + // importantly) avoid telling store consumers that the repo store has + // changed and just return the repo that was given to us. + if (isRepositoryWithGitHubRepository(repo)) { + if (repo.gitHubRepository.hash === ghRepo.hash) { + return repo + } + } + + await this.db.transaction('rw', this.db.repositories, () => + this.db.repositories.update(repo.id, { gitHubRepositoryID: ghRepo.dbID }) + ) + this.emitUpdatedRepositories() + + const updatedRepo = new Repository( + repo.path, + repo.id, + ghRepo, + repo.missing, + repo.alias, + repo.workflowPreferences, + repo.isTutorialRepository + ) + + assertIsRepositoryWithGitHubRepository(updatedRepo) + return updatedRepo + } + + private async _upsertGitHubRepository( + endpoint: string, + gitHubRepository: IAPIRepository | IAPIFullRepository, + ignoreParent = false + ): Promise { + const parent = + 'parent' in gitHubRepository && gitHubRepository.parent !== undefined + ? await this._upsertGitHubRepository( + endpoint, + gitHubRepository.parent, + true + ) + : await Promise.resolve(null) // Dexie gets confused if we return null + + const { login, type } = gitHubRepository.owner + const owner = await this.putOwner(endpoint, login, type) + + const existingRepo = await this.db.gitHubRepositories + .where('[ownerID+name]') + .equals([owner.id, gitHubRepository.name]) + .first() + + // If we can't resolve permissions for the current repository chances are + // that it's because it's the parent repository of another repository and we + // ended up here because the "actual" repository is trying to upsert its + // parent. Since parent repository hashes don't include a permissions hash + // and since it's possible that the user has both the fork and the parent + // repositories in the app we don't want to overwrite the permissions hash + // in the parent repository if we can help it or else we'll end up in a + // perpetual race condition where updating the fork will clear the + // permissions on the parent and updating the parent will reinstate them. + const permissions = + getPermissionsString(gitHubRepository) ?? + existingRepo?.permissions ?? + undefined + + // If we're told to ignore the parent then we'll attempt to use the existing + // parent and if that fails set it to null. This happens when we want to + // ensure we have a GitHubRepository record but we acquired the API data for + // said repository from an API endpoint that doesn't include the parent + // property like when loading pull requests. Similarly even when retrieving + // a full API repository its parent won't be a full repo so we'll never know + // if the parent of a repository has a parent (confusing, right?) + // + // We do all this to ensure that we only set the parent to null when we know + // that it needs to be cleared. Otherwise we could have a scenario where + // we've got a repository network where C is a fork of B and B is a fork of + // A which is the root. If we attempt to upsert C without these checks in + // place we'd wipe our knowledge of B being a fork of A. + // + // Since going from having a parent to not having a parent is incredibly + // rare (deleting a forked repository and creating it from scratch again + // with the same name or the parent getting deleted, etc) we assume that the + // value we've got is valid until we're certain its not. + const parentID = ignoreParent + ? existingRepo?.parentID ?? null + : parent?.dbID ?? null + + const updatedGitHubRepo: IDatabaseGitHubRepository = { + ...(existingRepo?.id !== undefined && { id: existingRepo.id }), + ownerID: owner.id, + name: gitHubRepository.name, + private: gitHubRepository.private, + htmlURL: gitHubRepository.html_url, + cloneURL: gitHubRepository.clone_url, + parentID, + lastPruneDate: existingRepo?.lastPruneDate ?? null, + issuesEnabled: gitHubRepository.has_issues, + isArchived: gitHubRepository.archived, + permissions, + } + + if (existingRepo !== undefined) { + // If nothing has changed since the last time we persisted the API info + // we can skip writing to the database and (more importantly) avoid + // telling store consumers that the repo store has changed. + if (shallowEquals(existingRepo, updatedGitHubRepo)) { + return this.toGitHubRepository(existingRepo, owner, parent) + } + } + + const id = await this.db.gitHubRepositories.put(updatedGitHubRepo) + this.emitUpdatedRepositories() + return this.toGitHubRepository({ ...updatedGitHubRepo, id }, owner, parent) + } + + /** Add or update the branch protections associated with a GitHub repository. */ + public async updateBranchProtections( + gitHubRepository: GitHubRepository, + protectedBranches: ReadonlyArray + ): Promise { + const dbID = gitHubRepository.dbID + + await this.db.transaction('rw', this.db.protectedBranches, async () => { + // This update flow is organized into two stages: + // + // - update the in-memory cache + // - update the underlying database state + // + // This should ensure any stale values are not being used, and avoids + // the need to query the database while the results are in memory. + + const prefix = getKeyPrefix(dbID) + + for (const key of this.protectionEnabledForBranchCache.keys()) { + // invalidate any cached entries belonging to this repository + if (key.startsWith(prefix)) { + this.protectionEnabledForBranchCache.delete(key) + } + } + + const branchRecords = protectedBranches.map( + b => ({ repoId: dbID, name: b.name }) + ) + + // update cached values to avoid database lookup + for (const item of branchRecords) { + const key = getKey(dbID, item.name) + this.protectionEnabledForBranchCache.set(key, true) + } + + await this.db.protectedBranches.where('repoId').equals(dbID).delete() + + const protectionsFound = branchRecords.length > 0 + this.branchProtectionSettingsFoundCache.set(dbID, protectionsFound) + + if (branchRecords.length > 0) { + await this.db.protectedBranches.bulkAdd(branchRecords) + } + }) + + // this update doesn't affect the list (or its items) we emit from this store, so no need to `emitUpdatedRepositories` + } + + /** + * Set's the last time the repository was checked for pruning + * + * @param repository The repository in which to update the prune date for + * @param date The date and time in which the last prune took place + */ + public async updateLastPruneDate( + repository: RepositoryWithGitHubRepository, + date: number + ): Promise { + await this.db.gitHubRepositories.update(repository.gitHubRepository.dbID, { + lastPruneDate: date, + }) + + // this update doesn't affect the list (or its items) we emit from this store, so no need to `emitUpdatedRepositories` + } + + public async getLastPruneDate( + repository: RepositoryWithGitHubRepository + ): Promise { + const id = repository.gitHubRepository.dbID + const record = await this.db.gitHubRepositories.get(id) + + if (record === undefined) { + return fatalError(`getLastPruneDate: No such GitHub repository: ${id}`) + } + + return record.lastPruneDate + } + + /** + * Load the branch protection information for a repository from the database + * and cache the results in memory + */ + private async loadAndCacheBranchProtection(dbID: number) { + // query the database to find any protected branches + const branches = await this.db.protectedBranches + .where('repoId') + .equals(dbID) + .toArray() + + const branchProtectionsFound = branches.length > 0 + this.branchProtectionSettingsFoundCache.set(dbID, branchProtectionsFound) + + // fill the retrieved records into the per-branch cache + for (const branch of branches) { + const key = getKey(dbID, branch.name) + this.protectionEnabledForBranchCache.set(key, true) + } + + return branchProtectionsFound + } + + /** + * Check if any branch protection settings are enabled for the repository + * through the GitHub API. + */ + public async hasBranchProtectionsConfigured( + gitHubRepository: GitHubRepository + ): Promise { + const branchProtectionsFound = this.branchProtectionSettingsFoundCache.get( + gitHubRepository.dbID + ) + + if (branchProtectionsFound === undefined) { + return this.loadAndCacheBranchProtection(gitHubRepository.dbID) + } + + return branchProtectionsFound + } + + /** + * Helper method to emit updates consistently + * (This is the only way we emit updates from this store.) + */ + private emitUpdatedRepositories() { + if (!this.emitQueued) { + setImmediate(() => { + this.getAll() + .then(repos => this.emitUpdate(repos)) + .catch(e => log.error(`Failed emitting update`, e)) + .finally(() => (this.emitQueued = false)) + }) + this.emitQueued = true + } + } +} + +/** Compute the key for the branch protection cache */ +function getKey(dbID: number, branchName: string) { + return `${getKeyPrefix(dbID)}${branchName}` +} + +/** Compute the key prefix for the branch protection cache */ +function getKeyPrefix(dbID: number) { + return `${dbID}-` +} + +function getPermissionsString( + repo: IAPIRepository | IAPIFullRepository +): GitHubRepositoryPermission { + const permissions = 'permissions' in repo ? repo.permissions : undefined + + if (permissions === undefined) { + return null + } else if (permissions.admin) { + return 'admin' + } else if (permissions.push) { + return 'write' + } else if (permissions.pull) { + return 'read' + } else { + return null + } +} diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts new file mode 100644 index 0000000000..05e2f904b1 --- /dev/null +++ b/app/src/lib/stores/repository-state-cache.ts @@ -0,0 +1,366 @@ +import { Branch } from '../../models/branch' +import { Commit } from '../../models/commit' +import { PullRequest } from '../../models/pull-request' +import { Repository } from '../../models/repository' +import { + WorkingDirectoryFileChange, + WorkingDirectoryStatus, +} from '../../models/status' +import { TipState } from '../../models/tip' +import { + HistoryTabMode, + IBranchesState, + IChangesState, + ICompareState, + IRepositoryState, + RepositorySectionTab, + ICommitSelection, + 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) + if (existing != null) { + return existing + } + + const newItem = getInitialRepositoryState() + this.repositoryState.set(repository.hash, newItem) + return newItem + } + + public update( + repository: Repository, + fn: (state: IRepositoryState) => Pick + ) { + const currentState = this.get(repository) + const newValues = fn(currentState) + const newState = merge(currentState, newValues) + + const currentTip = currentState.branchesState.tip + const newTip = newState.branchesState.tip + + // Only keep the "is amending" state if the head commit hasn't changed, it + // matches the commit to amend, and there is no "fixing conflicts" state. + const isAmending = + newState.commitToAmend !== null && + newTip.kind === TipState.Valid && + currentTip.kind === TipState.Valid && + currentTip.branch.tip.sha === newTip.branch.tip.sha && + newTip.branch.tip.sha === newState.commitToAmend.sha && + newState.changesState.conflictState === null + + this.repositoryState.set(repository.hash, { + ...newState, + commitToAmend: isAmending ? newState.commitToAmend : null, + }) + } + + public updateCompareState( + repository: Repository, + fn: (state: ICompareState) => Pick + ) { + this.update(repository, state => { + const compareState = state.compareState + const newValues = fn(compareState) + + return { compareState: merge(compareState, newValues) } + }) + } + + public updateChangesState( + repository: Repository, + fn: (changesState: IChangesState) => Pick + ) { + 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.recordSubmoduleDiffViewedFromChangesList() + } + + public updateCommitSelection( + repository: Repository, + fn: (state: ICommitSelection) => Pick + ) { + 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.recordSubmoduleDiffViewedFromHistory() + } + } + + public updateBranchesState( + repository: Repository, + fn: (branchesState: IBranchesState) => Pick + ) { + this.update(repository, state => { + const changesState = state.branchesState + const newState = merge(changesState, fn(changesState)) + return { branchesState: newState } + }) + } + + public updateMultiCommitOperationUndoState< + K extends keyof IMultiCommitOperationUndoState + >( + repository: Repository, + fn: ( + state: IMultiCommitOperationUndoState | null + ) => Pick | null + ) { + this.update(repository, state => { + const { multiCommitOperationUndoState } = state + const computedState = fn(multiCommitOperationUndoState) + const newState = + computedState === null + ? null + : merge(multiCommitOperationUndoState, computedState) + return { multiCommitOperationUndoState: newState } + }) + } + + public updateMultiCommitOperationState< + K extends keyof IMultiCommitOperationState + >( + repository: Repository, + fn: ( + state: IMultiCommitOperationState | null + ) => Pick + ) { + this.update(repository, state => { + const { multiCommitOperationState } = state + const toUpdate = fn(multiCommitOperationState) + + if (multiCommitOperationState === null) { + // This is not expected, but we see instances in error reporting. Best + // guess is that it would indicate that the user ended the state another + // way such as via command line/on state load detection, in which we + // would not want to crash the app. + const msg = `Cannot update a null state, trying to update object with keys: ${Object.keys( + toUpdate + ).join(', ')}` + sendNonFatalException('multiCommitOperation', new Error(msg)) + return { multiCommitOperationState: null } + } + + const newState = merge(multiCommitOperationState, toUpdate) + return { multiCommitOperationState: newState } + }) + } + + public initializeMultiCommitOperationState( + repository: Repository, + multiCommitOperationState: IMultiCommitOperationState + ) { + this.update(repository, () => { + return { multiCommitOperationState } + }) + } + + public clearMultiCommitOperationState(repository: Repository) { + this.update(repository, () => { + 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 }, + diff: null, + }, + changesState: { + workingDirectory: WorkingDirectoryStatus.fromFiles( + new Array() + ), + selection: { + kind: ChangesSelectionKind.WorkingDirectory, + selectedFileIDs: [], + diff: null, + }, + commitMessage: DefaultCommitMessage, + coAuthors: [], + showCoAuthoredBy: false, + 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(), + currentPullRequest: null, + isLoadingPullRequests: false, + forcePushBranches: new Map(), + }, + compareState: { + formState: { + kind: HistoryTabMode.History, + }, + tip: null, + mergeStatus: null, + showBranchList: false, + filterText: '', + commitSHAs: [], + shasToHighlight: [], + branches: new Array(), + recentBranches: new Array(), + defaultBranch: null, + }, + pullRequestState: null, + commitAuthor: null, + commitLookup: new Map(), + localCommitSHAs: [], + localTags: null, + tagsToPush: null, + aheadBehind: null, + remote: null, + isPushPullFetchInProgress: false, + isCommitting: false, + commitToAmend: null, + lastFetched: null, + checkoutProgress: null, + pushPullFetchProgress: null, + revertProgress: null, + multiCommitOperationUndoState: null, + multiCommitOperationState: null, + } +} diff --git a/app/src/lib/stores/sign-in-store.ts b/app/src/lib/stores/sign-in-store.ts new file mode 100644 index 0000000000..cd981ad766 --- /dev/null +++ b/app/src/lib/stores/sign-in-store.ts @@ -0,0 +1,684 @@ +import { Disposable } from 'event-kit' +import { Account } from '../../models/account' +import { assertNever, fatalError } from '../fatal-error' +import { askUserToOAuth } from '../../lib/oauth' +import { + validateURL, + InvalidURLErrorName, + InvalidProtocolErrorName, +} from '../../ui/lib/enterprise-validate-url' + +import { + createAuthorization, + AuthorizationResponse, + fetchUser, + AuthorizationResponseKind, + getHTMLURL, + getDotComAPIEndpoint, + getEnterpriseAPIURL, + fetchMetadata, +} 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.` + +/** + * An enumeration of the possible steps that the sign in + * store can be in save for the uninitialized state (null). + */ +export enum SignInStep { + EndpointEntry = 'EndpointEntry', + Authentication = 'Authentication', + TwoFactorAuthentication = 'TwoFactorAuthentication', + Success = 'Success', +} + +/** + * The union type of all possible states that the sign in + * store can be in save the uninitialized state (null). + */ +export type SignInState = + | IEndpointEntryState + | IAuthenticationState + | ITwoFactorAuthenticationState + | ISuccessState + +/** + * Base interface for shared properties between states + */ +export interface ISignInState { + /** + * The sign in step represented by this state + */ + readonly kind: SignInStep + + /** + * An error which, if present, should be presented to the + * user in close proximity to the actions or input fields + * related to the current step. + */ + readonly error: Error | null + + /** + * A value indicating whether or not the sign in store is + * busy processing a request. While this value is true all + * form inputs and actions save for a cancel action should + * be disabled and the user should be made aware that the + * sign in process is ongoing. + */ + readonly loading: boolean +} + +/** + * 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 +} + +/** + * 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 + + /** + * The URL to the host which we're currently authenticating + * against. This will be either https://api.github.com when + * signing in against GitHub.com or a user-specified + * URL when signing in against a GitHub Enterprise + * instance. + */ + 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 + + /** + * The endpoint-specific URL for resetting credentials. + */ + readonly forgotPasswordUrl: string +} + +/** + * 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. + */ +export interface ITwoFactorAuthenticationState extends ISignInState { + readonly kind: SignInStep.TwoFactorAuthentication + + /** + * The URL to the host which we're currently authenticating + * against. This will be either https://api.github.com when + * signing in against GitHub.com or a user-specified + * URL when signing in against a GitHub Enterprise + * instance. + */ + 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 + + /** + * The 2FA type expected by the GitHub endpoint. + */ + readonly type: AuthenticationMode +} + +/** + * Sentinel step representing a successful sign in process. Sign in + * components may use this as a signal to dismiss the ongoing flow + * or to show a message to the user indicating that they've been + * successfully signed in. + */ +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', +} + +interface IAuthenticationEvent { + readonly account: Account + readonly method: SignInMethod +} + +/** The maximum time to wait for a `/meta` API call in milliseconds */ +const ServerMetaDataTimeout = 2000 + +/** + * A store encapsulating all logic related to signing in a user + * to GitHub.com, or a GitHub Enterprise instance. + */ +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 } + this.emitter.emit('did-authenticate', event) + } + + /** + * 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 { + return this.emitter.on( + 'did-authenticate', + ({ account, method }: IAuthenticationEvent) => { + fn(account, method) + } + ) + } + + /** + * Returns the current state of the sign in store or null if + * no sign in process is in flight. + */ + public getState(): SignInState | null { + return this.state + } + + /** + * Update the internal state of the store and emit an update + * event. + */ + private setState(state: SignInState | null) { + this.state = state + 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() { + this.setState(null) + } + + /** + * Initiate a sign in flow for github.com. This will put the store + * in the Authentication step ready to receive user credentials. + */ + public beginDotComSignIn() { + const endpoint = getDotComAPIEndpoint() + + this.setState({ + kind: SignInStep.Authentication, + endpoint, + supportsBasicAuth: false, + error: null, + loading: false, + forgotPasswordUrl: this.getForgotPasswordURL(endpoint), + }) + + // 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 }) + } + }) + .catch(err => + log.error( + 'Failed resolving whether GitHub.com supports password authentication', + err + ) + ) + } + + /** + * 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. + */ + public async authenticateWithBasicAuth( + username: string, + password: string + ): 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 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 (response.kind === AuthorizationResponseKind.Authorized) { + const token = response.token + const user = await fetchUser(endpoint, token) + + 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 + ) { + this.setState({ + kind: SignInStep.TwoFactorAuthentication, + endpoint, + username, + password, + type: response.type, + error: null, + loading: false, + }) + } 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.'), + }) + } + } 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) { + this.setState({ + ...currentState, + loading: false, + supportsBasicAuth: false, + kind: SignInStep.Authentication, + }) + } else { + return assertNever(response, `Unsupported response: ${response}`) + } + } + } + + /** + * 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` + ) + } + + 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 }) + return + } + + if (!this.state || this.state.kind !== SignInStep.Authentication) { + // Looks like the sign in flow has been aborted + return + } + + this.emitAuthenticate(account, SignInMethod.Web) + this.setState({ kind: SignInStep.Success }) + } + + /** + * Initiate a sign in flow for a GitHub Enterprise instance. + * This will put the store in the EndpointEntry step ready to + * receive the url to the enterprise instance. + */ + public beginEnterpriseSignIn() { + this.setState({ + kind: SignInStep.EndpointEntry, + error: null, + loading: false, + }) + } + + /** + * Attempt to advance from the EndpointEntry step with the given endpoint + * url. This method must only be called when the store is in the authentication + * step or an error will be thrown. + * + * The provided endpoint url will be validated for syntactic correctness as + * well as connectivity before the promise resolves. If the endpoint url is + * invalid or the host can't be reached the promise will be rejected and the + * sign in state updated with an error to be presented to the user. + * + * If validation is successful the store will advance to the authentication + * step. + */ + public async setEndpoint(url: string): Promise { + const currentState = this.state + + if (!currentState || currentState.kind !== SignInStep.EndpointEntry) { + const stepText = currentState ? currentState.kind : 'null' + return fatalError( + `Sign in step '${stepText}' not compatible with endpoint entry` + ) + } + + this.setState({ ...currentState, loading: true }) + + let validUrl: string + try { + validUrl = validateURL(url) + } catch (e) { + let error = e + if (e.name === InvalidURLErrorName) { + error = new Error( + `The GitHub Enterprise instance address doesn't appear to be a valid URL. We're expecting something like https://github.example.com.` + ) + } else if (e.name === InvalidProtocolErrorName) { + error = new Error( + 'Unsupported protocol. Only http or https is supported when authenticating with GitHub Enterprise instances.' + ) + } + + this.setState({ ...currentState, loading: false, error }) + return + } + + 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 + } + + this.setState({ + kind: SignInStep.Authentication, + endpoint, + supportsBasicAuth, + error: null, + loading: false, + forgotPasswordUrl: this.getForgotPasswordURL(endpoint), + }) + } 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}`) + } + } + } +} diff --git a/app/src/lib/stores/stores.ts b/app/src/lib/stores/stores.ts new file mode 100644 index 0000000000..bf9c31b69b --- /dev/null +++ b/app/src/lib/stores/stores.ts @@ -0,0 +1,10 @@ +export interface IDataStore { + setItem(key: string, value: string): void + getItem(key: string): string | null +} + +export interface ISecureStore { + setItem(key: string, login: string, value: string): Promise + getItem(key: string, login: string): Promise + deleteItem(key: string, login: string): Promise +} diff --git a/app/src/lib/stores/token-store.ts b/app/src/lib/stores/token-store.ts new file mode 100644 index 0000000000..0af6524e78 --- /dev/null +++ b/app/src/lib/stores/token-store.ts @@ -0,0 +1,19 @@ +import * as keytar from 'keytar' + +function setItem(key: string, login: string, value: string) { + return keytar.setPassword(key, login, value) +} + +function getItem(key: string, login: string) { + return keytar.getPassword(key, login) +} + +function deleteItem(key: string, login: string) { + return keytar.deletePassword(key, login) +} + +export const TokenStore = { + setItem, + getItem, + deleteItem, +} diff --git a/app/src/lib/stores/updates/changes-state.ts b/app/src/lib/stores/updates/changes-state.ts new file mode 100644 index 0000000000..8f274f82bc --- /dev/null +++ b/app/src/lib/stores/updates/changes-state.ts @@ -0,0 +1,353 @@ +import { + WorkingDirectoryStatus, + WorkingDirectoryFileChange, +} from '../../../models/status' +import { IStatusResult } from '../../git' +import { + IChangesState, + ConflictState, + MergeConflictState, + isMergeConflictState, + isRebaseConflictState, + RebaseConflictState, + ChangesSelection, + ChangesSelectionKind, +} from '../../app-state' +import { DiffSelectionType } from '../../../models/diff' +import { caseInsensitiveCompare } from '../../compare' +import { IStatsStore } from '../../stats/stats-store' +import { ManualConflictResolution } from '../../../models/manual-conflict-resolution' +import { assertNever } from '../../fatal-error' + +/** + * Internal shape of the return value from this response because the compiler + * seems to complain about attempts to create an object which satisfies the + * constraints of Pick + */ +type ChangedFilesResult = { + readonly workingDirectory: WorkingDirectoryStatus + readonly selection: ChangesSelection +} + +export function updateChangedFiles( + state: IChangesState, + status: IStatusResult, + clearPartialState: boolean +): ChangedFilesResult { + // Populate a map for all files in the current working directory state + const filesByID = new Map() + state.workingDirectory.files.forEach(f => filesByID.set(f.id, f)) + + // Attempt to preserve the selection state for each file in the new + // working directory state by looking at the current files + const mergedFiles = status.workingDirectory.files + .map(file => { + const existingFile = filesByID.get(file.id) + if (existingFile) { + if (clearPartialState) { + if ( + existingFile.selection.getSelectionType() === + DiffSelectionType.Partial + ) { + return file.withIncludeAll(false) + } + } + + return file.withSelection(existingFile.selection) + } else { + return file + } + }) + .sort((x, y) => caseInsensitiveCompare(x.path, y.path)) + + // Collect all the currently available file ids into a set to avoid O(N) + // lookups using .find on the mergedFiles array. + const mergedFileIds = new Set(mergedFiles.map(x => x.id)) + + // The file selection could have changed if the previously selected files + // are no longer selectable (they were discarded or committed) but if they + // were not changed we can reuse the diff. Note, however that we only render + // a diff when a single file is selected. If the previous selection was + // a single file with the same id as the current selection we can keep the + // diff we had, if not we'll clear it. + const workingDirectory = WorkingDirectoryStatus.fromFiles(mergedFiles) + + const selectionKind = state.selection.kind + if (state.selection.kind === ChangesSelectionKind.WorkingDirectory) { + // The previously selected files might not be available in the working + // directory any more due to having been committed or discarded so we'll + // do a pass over and filter out any selected files that aren't available. + let selectedFileIDs = state.selection.selectedFileIDs.filter(id => + mergedFileIds.has(id) + ) + + // Select the first file if we don't have anything selected and we + // have something to select. + if (selectedFileIDs.length === 0 && mergedFiles.length > 0) { + selectedFileIDs = [mergedFiles[0].id] + } + + const diff = + selectedFileIDs.length === 1 && + state.selection.selectedFileIDs.length === 1 && + state.selection.selectedFileIDs[0] === selectedFileIDs[0] + ? state.selection.diff + : null + + return { + workingDirectory, + selection: { + kind: ChangesSelectionKind.WorkingDirectory, + selectedFileIDs, + diff, + }, + } + } else if (state.selection.kind === ChangesSelectionKind.Stash) { + return { + workingDirectory, + selection: state.selection, + } + } else { + return assertNever( + state.selection, + `Unknown selection kind ${selectionKind}` + ) + } +} + +/** + * Convert the received status information into a conflict state + */ +function getConflictState( + status: IStatusResult, + manualResolutions: Map +): ConflictState | null { + if (status.rebaseInternalState !== null) { + const { currentTip } = status + if (currentTip == null) { + return null + } + + const { targetBranch, originalBranchTip, baseBranchTip } = + status.rebaseInternalState + + return { + kind: 'rebase', + currentTip, + manualResolutions, + targetBranch, + originalBranchTip, + baseBranchTip, + } + } + + if (status.isCherryPickingHeadFound) { + const { currentBranch: targetBranchName } = status + if (targetBranchName == null) { + return null + } + return { + kind: 'cherryPick', + manualResolutions, + targetBranchName, + } + } + + const { currentBranch, currentTip, mergeHeadFound, squashMsgFound } = status + if ( + currentBranch == null || + currentTip == null || + (!mergeHeadFound && !squashMsgFound) || + // If there are no conflicts, we want to ignore the squash msg found. + // However, we do want to prompt the conflicts showing all resolved + // if a regular merge conflicts are all resolves so user can + // commit the merge commit. + (!mergeHeadFound && !status.doConflictedFilesExist) + ) { + return null + } + + return { + kind: 'merge', + currentBranch, + currentTip, + manualResolutions, + } +} + +function performEffectsForMergeStateChange( + prevConflictState: MergeConflictState | null, + newConflictState: MergeConflictState | null, + status: IStatusResult, + statsStore: IStatsStore +): void { + const previousBranchName = + prevConflictState != null ? prevConflictState.currentBranch : null + const currentBranchName = + newConflictState != null ? newConflictState.currentBranch : null + + const branchNameChanged = + previousBranchName != null && + currentBranchName != null && + previousBranchName !== currentBranchName + + // The branch name has changed while remaining conflicted -> the merge must have been aborted + if (branchNameChanged) { + statsStore.recordMergeAbortedAfterConflicts() + return + } + + const { currentTip } = status + + // if the repository is no longer conflicted, what do we think happened? + if ( + prevConflictState != null && + newConflictState == null && + currentTip != null + ) { + const previousTip = prevConflictState.currentTip + + if (previousTip !== currentTip) { + statsStore.recordMergeSuccessAfterConflicts() + } else { + statsStore.recordMergeAbortedAfterConflicts() + } + } +} + +function performEffectsForRebaseStateChange( + prevConflictState: RebaseConflictState | null, + newConflictState: RebaseConflictState | null, + status: IStatusResult, + statsStore: IStatsStore +) { + const previousBranchName = + prevConflictState != null ? prevConflictState.targetBranch : null + const currentBranchName = + newConflictState != null ? newConflictState.targetBranch : null + + const branchNameChanged = + previousBranchName != null && + currentBranchName != null && + previousBranchName !== currentBranchName + + // The branch name has changed while remaining conflicted -> the rebase must have been aborted + if (branchNameChanged) { + statsStore.recordRebaseAbortedAfterConflicts() + return + } + + const { currentTip, currentBranch } = status + + // if the repository is no longer conflicted, what do we think happened? + if ( + prevConflictState != null && + newConflictState == null && + currentTip != null && + currentBranch != null + ) { + const previousTip = prevConflictState.originalBranchTip + + const previousTipChanged = + previousTip !== currentTip && + currentBranch === prevConflictState.targetBranch + + if (!previousTipChanged) { + statsStore.recordRebaseAbortedAfterConflicts() + } + } + + return +} + +export function updateConflictState( + state: IChangesState, + status: IStatusResult, + statsStore: IStatsStore +): ConflictState | null { + const prevConflictState = state.conflictState + + const manualResolutions = + prevConflictState !== null + ? prevConflictState.manualResolutions + : new Map() + + const newConflictState = getConflictState(status, manualResolutions) + + if (prevConflictState == null && newConflictState == null) { + return null + } + + if ( + (prevConflictState == null || isMergeConflictState(prevConflictState)) && + (newConflictState == null || isMergeConflictState(newConflictState)) + ) { + performEffectsForMergeStateChange( + prevConflictState, + newConflictState, + status, + statsStore + ) + return newConflictState + } + + if ( + (prevConflictState == null || isRebaseConflictState(prevConflictState)) && + (newConflictState == null || isRebaseConflictState(newConflictState)) + ) { + performEffectsForRebaseStateChange( + prevConflictState, + newConflictState, + status, + statsStore + ) + return newConflictState + } + + // Otherwise we transitioned from a merge conflict to a rebase conflict or + // vice versa, and we should avoid any side effects here + + return newConflictState +} + +/** + * Generate the partial state needed to update ChangesState selection property + * when a user or external constraints require us to do so. + * + * @param state The current changes state + * @param files An array of files to select when showing the working directory. + * If undefined this method will preserve the previously selected + * files or pick the first changed file if no selection exists. + */ +export function selectWorkingDirectoryFiles( + state: IChangesState, + files?: ReadonlyArray +): Pick { + let selectedFileIDs: Array + + if (files === undefined) { + if (state.selection.kind === ChangesSelectionKind.WorkingDirectory) { + // No files provided, just a desire to make sure selection is + // working directory. If it already is there's nothing for us to do. + return { selection: state.selection } + } else if (state.workingDirectory.files.length > 0) { + // No files provided and the current selection is stash, pick the + // first file we've got. + selectedFileIDs = [state.workingDirectory.files[0].id] + } else { + // Not much to do here. No files provided, nothing in the + // working directory. + selectedFileIDs = new Array() + } + } else { + selectedFileIDs = files.map(x => x.id) + } + + return { + selection: { + kind: ChangesSelectionKind.WorkingDirectory as ChangesSelectionKind.WorkingDirectory, + selectedFileIDs, + diff: null, + }, + } +} diff --git a/app/src/lib/stores/updates/update-remote-url.ts b/app/src/lib/stores/updates/update-remote-url.ts new file mode 100644 index 0000000000..a631627dd4 --- /dev/null +++ b/app/src/lib/stores/updates/update-remote-url.ts @@ -0,0 +1,45 @@ +import { IAPIRepository } from '../../api' +import { GitStore } from '../git-store' +import { urlMatchesRemote } from '../../repository-matching' +import * as URL from 'url' +import { GitHubRepository } from '../../../models/github-repository' + +export async function updateRemoteUrl( + gitStore: GitStore, + gitHubRepository: GitHubRepository, + apiRepo: IAPIRepository +): Promise { + // I'm not sure when these early exit conditions would be met. But when they are + // we don't have enough information to continue so exit early! + if (gitStore.defaultRemote === null) { + return + } + + const remoteUrl = gitStore.defaultRemote.url + const updatedRemoteUrl = apiRepo.clone_url + const urlsMatch = urlMatchesRemote(updatedRemoteUrl, gitStore.defaultRemote) + + // Verify that protocol hasn't changed. If it has we don't want + // to alter the protocol in case they are relying on a specific one. + // If protocol is null that implies the url is a ssh url + // of the format git@github.com:octocat/Hello-World.git, which + // can't be parsed by URL.parse. In this case we assume the user + // manually configured their remote to use this format and we don't + // want to change what they've done just to be safe + const parsedRemoteUrl = URL.parse(remoteUrl) + const parsedUpdatedRemoteUrl = URL.parse(updatedRemoteUrl) + const protocolsMatch = + parsedRemoteUrl.protocol !== null && + parsedUpdatedRemoteUrl.protocol !== null && + parsedRemoteUrl.protocol === parsedUpdatedRemoteUrl.protocol + + // Check if the default remote url has been manually changed from the + // clone url retrieved from the GitHub API previously + const remoteUrlUnchanged = + gitStore.defaultRemote && + urlMatchesRemote(gitHubRepository.cloneURL, gitStore.defaultRemote) + + if (protocolsMatch && remoteUrlUnchanged && !urlsMatch) { + await gitStore.setRemoteURL(gitStore.defaultRemote.name, updatedRemoteUrl) + } +} diff --git a/app/src/lib/stores/upstream-already-exists-error.ts b/app/src/lib/stores/upstream-already-exists-error.ts new file mode 100644 index 0000000000..4c075ef9e4 --- /dev/null +++ b/app/src/lib/stores/upstream-already-exists-error.ts @@ -0,0 +1,18 @@ +import { Repository } from '../../models/repository' +import { IRemote } from '../../models/remote' + +/** + * The error thrown when a repository is a fork but its upstream remote isn't + * the parent. + */ +export class UpstreamAlreadyExistsError extends Error { + public readonly repository: Repository + public readonly existingRemote: IRemote + + public constructor(repository: Repository, existingRemote: IRemote) { + super(`The remote '${existingRemote.name}' already exists`) + + this.repository = repository + this.existingRemote = existingRemote + } +} diff --git a/app/src/lib/tailer.ts b/app/src/lib/tailer.ts new file mode 100644 index 0000000000..b3499a23ec --- /dev/null +++ b/app/src/lib/tailer.ts @@ -0,0 +1,112 @@ +import * as Fs from 'fs' +import { Emitter, Disposable } from 'event-kit' + +interface ICurrentFileTailState { + /** The current read position in the file. */ + readonly position: number + + /** The currently active watcher instance. */ + readonly watcher: Fs.FSWatcher +} + +/** Tail a file and read changes as they happen. */ +export class Tailer { + public readonly path: string + + private readonly emitter = new Emitter() + + private state: ICurrentFileTailState | null = null + + /** Create a new instance for tailing the given file. */ + public constructor(path: string) { + this.path = path + } + + /** + * Register a function to be called whenever new data is available to be read. + * The function will be given a read stream which has been created to read the + * new data. + */ + public onDataAvailable(fn: (stream: Fs.ReadStream) => void): Disposable { + return this.emitter.on('data', fn) + } + + /** + * Register a function to be called whenever an error is reported by the underlying + * filesystem watcher. + */ + public onError(fn: (error: Error) => void): Disposable { + return this.emitter.on('error', fn) + } + + private handleError(error: Error) { + this.state = null + this.emitter.emit('error', error) + } + + /** + * Start tailing the file. This can only be called again after calling `stop`. + */ + public start() { + if (this.state) { + throw new Error(`Tailer already running`) + } + + try { + const watcher = Fs.watch(this.path, this.onWatchEvent) + watcher.on('error', error => { + this.handleError(error) + }) + this.state = { watcher, position: 0 } + } catch (error) { + this.handleError(error) + } + } + + private onWatchEvent = (event: string) => { + if (event !== 'change') { + return + } + + if (!this.state) { + return + } + + Fs.stat(this.path, (err, stats) => { + if (err) { + return + } + + const state = this.state + if (!state) { + return + } + + if (stats.size <= state.position) { + return + } + + this.state = { ...state, position: stats.size } + + this.readChunk(stats, state.position) + }) + } + + private readChunk(stats: Fs.Stats, position: number) { + const stream = Fs.createReadStream(this.path, { + start: position, + end: stats.size, + }) + + this.emitter.emit('data', stream) + } + + /** Stop tailing the file. */ + public stop() { + const state = this.state + if (state) { + state.watcher.close() + this.state = null + } + } +} diff --git a/app/src/lib/text-token-parser.ts b/app/src/lib/text-token-parser.ts new file mode 100644 index 0000000000..3dc6fce6b1 --- /dev/null +++ b/app/src/lib/text-token-parser.ts @@ -0,0 +1,345 @@ +import { + Repository, + isRepositoryWithGitHubRepository, + getNonForkGitHubRepository, +} from '../models/repository' +import { GitHubRepository } from '../models/github-repository' +import { getHTMLURL } from './api' + +export enum TokenType { + /* + * A token that should be rendered as-is, without any formatting. + */ + Text, + /* + * A token representing an emoji character - should be replaced with an image. + */ + Emoji, + /* + * A token representing a generic link - should be drawn as a hyperlink + * to launch the browser. + */ + Link, +} + +export type EmojiMatch = { + readonly kind: TokenType.Emoji + // The alternate text to display with the image, e.g. ':+1:' + readonly text: string + // The path on disk to the image. + readonly path: string +} + +export type HyperlinkMatch = { + readonly kind: TokenType.Link + // The text to display inside the rendered link, e.g. @shiftkey + readonly text: string + // The URL to launch when clicking on the link + readonly url: string +} + +export type PlainText = { + readonly kind: TokenType.Text + // The text to render. + readonly text: string +} + +export type TokenResult = PlainText | EmojiMatch | HyperlinkMatch + +type LookupResult = { + nextIndex: number +} + +/** + * A look-ahead tokenizer designed for scanning commit messages for emoji, issues, mentions and links. + */ +export class Tokenizer { + private readonly emoji: Map + private readonly repository: GitHubRepository | null = null + + private _results = new Array() + private _currentString = '' + + public constructor(emoji: Map, repository?: Repository) { + this.emoji = emoji + + if (repository && isRepositoryWithGitHubRepository(repository)) { + this.repository = getNonForkGitHubRepository(repository) + } + } + + private reset() { + this._results = new Array() + this._currentString = '' + } + + private append(character: string) { + this._currentString += character + } + + private flush() { + if (this._currentString.length) { + this._results.push({ kind: TokenType.Text, text: this._currentString }) + this._currentString = '' + } + } + + private getLastProcessedChar = () => this._currentString?.at(-1) + + private scanForEndOfWord(text: string, index: number): number { + const indexOfNextNewline = text.indexOf('\n', index + 1) + const indexOfNextSpace = text.indexOf(' ', index + 1) + + if (indexOfNextNewline > -1 && indexOfNextSpace > -1) { + // if we find whitespace and a newline, take whichever is closest + return Math.min(indexOfNextNewline, indexOfNextSpace) + } + + // favouring newlines over whitespace here because people often like to + // use mentions or issues at the end of a sentence. + if (indexOfNextNewline > -1) { + return indexOfNextNewline + } + if (indexOfNextSpace > -1) { + return indexOfNextSpace + } + + // as a fallback use the entire remaining string + return text.length + } + + private scanForEmoji(text: string, index: number): LookupResult | null { + const nextIndex = this.scanForEndOfWord(text, index) + const maybeEmoji = text.slice(index, nextIndex) + if (!/^:.*?:$/.test(maybeEmoji)) { + return null + } + + const path = this.emoji.get(maybeEmoji) + if (!path) { + return null + } + + this.flush() + this._results.push({ kind: TokenType.Emoji, text: maybeEmoji, path }) + return { nextIndex } + } + + private scanForIssue( + text: string, + index: number, + repository: GitHubRepository + ): LookupResult | null { + let nextIndex = this.scanForEndOfWord(text, index) + let maybeIssue = text.slice(index, nextIndex) + + // handle situation where issue reference is wrapped in parentheses + // like the generated "squash and merge" commits on GitHub + if (maybeIssue.endsWith(')')) { + nextIndex -= 1 + maybeIssue = text.slice(index, nextIndex) + } + + // release notes may add a full stop as part of formatting the entry + if (maybeIssue.endsWith('.')) { + nextIndex -= 1 + maybeIssue = text.slice(index, nextIndex) + } + + // handle list of issues + if (maybeIssue.endsWith(',')) { + nextIndex -= 1 + maybeIssue = text.slice(index, nextIndex) + } + + if (!/^#\d+$/.test(maybeIssue)) { + return null + } + + this.flush() + const id = parseInt(maybeIssue.substring(1), 10) + if (isNaN(id)) { + return null + } + + const url = `${repository.htmlURL}/issues/${id}` + this._results.push({ kind: TokenType.Link, text: maybeIssue, url }) + return { nextIndex } + } + + private scanForMention( + text: string, + index: number, + repository: GitHubRepository + ): LookupResult | null { + // to ensure this isn't part of an email address, peek at the previous + // character - if something is found and it's not whitespace, bail out + const lastItem = this.getLastProcessedChar() + if (lastItem && !/\s/.test(lastItem)) { + return null + } + + let nextIndex = this.scanForEndOfWord(text, index) + let maybeMention = text.slice(index, nextIndex) + + // release notes add a ! to the very last user, or use , to separate users + if (maybeMention.endsWith('!') || maybeMention.endsWith(',')) { + nextIndex -= 1 + maybeMention = text.slice(index, nextIndex) + } + + if (!/^@[a-zA-Z0-9\-]+$/.test(maybeMention)) { + return null + } + + this.flush() + const name = maybeMention.substring(1) + const url = `${getHTMLURL(repository.endpoint)}/${name}` + this._results.push({ kind: TokenType.Link, text: maybeMention, url }) + return { nextIndex } + } + + private scanForHyperlink( + text: string, + index: number, + repository?: GitHubRepository + ): LookupResult | null { + // to ensure this isn't just the part of some word - if something is + // found and it's not whitespace, bail out + const lastItem = this.getLastProcessedChar() + if (lastItem && !/\s/.test(lastItem)) { + return null + } + + const nextIndex = this.scanForEndOfWord(text, index) + const maybeHyperlink = text.slice(index, nextIndex) + if (!/^https?:\/\/.+/.test(maybeHyperlink)) { + return null + } + + this.flush() + if (repository && repository.htmlURL) { + // case-insensitive regex to see if this matches the issue URL template for the current repository + const compare = repository.htmlURL.toLowerCase() + if (maybeHyperlink.toLowerCase().startsWith(`${compare}/issues/`)) { + const issueMatch = /\/issues\/(\d+)/.exec(maybeHyperlink) + if (issueMatch) { + const idText = issueMatch[1] + this._results.push({ + kind: TokenType.Link, + url: maybeHyperlink, + text: `#${idText}`, + }) + return { nextIndex } + } + } + } + + // just render a hyperlink with the full URL + this._results.push({ + kind: TokenType.Link, + url: maybeHyperlink, + text: maybeHyperlink, + }) + return { nextIndex } + } + + private inspectAndMove( + element: string, + index: number, + callback: () => LookupResult | null + ): number { + const match = callback() + if (match) { + return match.nextIndex + } else { + this.append(element) + return index + 1 + } + } + + private tokenizeNonGitHubRepository( + text: string + ): ReadonlyArray { + let i = 0 + while (i < text.length) { + const element = text[i] + switch (element) { + case ':': + i = this.inspectAndMove(element, i, () => this.scanForEmoji(text, i)) + break + + case 'h': + i = this.inspectAndMove(element, i, () => + this.scanForHyperlink(text, i) + ) + break + + default: + this.append(element) + i++ + break + } + } + + this.flush() + return this._results + } + + private tokenizeGitHubRepository( + text: string, + repository: GitHubRepository + ): ReadonlyArray { + let i = 0 + while (i < text.length) { + const element = text[i] + switch (element) { + case ':': + i = this.inspectAndMove(element, i, () => this.scanForEmoji(text, i)) + break + + case '#': + i = this.inspectAndMove(element, i, () => + this.scanForIssue(text, i, repository) + ) + break + + case '@': + i = this.inspectAndMove(element, i, () => + this.scanForMention(text, i, repository) + ) + break + + case 'h': + i = this.inspectAndMove(element, i, () => + this.scanForHyperlink(text, i, repository) + ) + break + + default: + this.append(element) + i++ + break + } + } + + this.flush() + return this._results + } + + /** + * Scan the string for tokens that match with entities an application + * might be interested in. + * + * @returns an array of tokens representing the scan results. + */ + public tokenize(text: string): ReadonlyArray { + this.reset() + + if (this.repository) { + return this.tokenizeGitHubRepository(text, this.repository) + } else { + return this.tokenizeNonGitHubRepository(text) + } + } +} diff --git a/app/src/lib/thank-you.ts b/app/src/lib/thank-you.ts new file mode 100644 index 0000000000..8a01962c30 --- /dev/null +++ b/app/src/lib/thank-you.ts @@ -0,0 +1,98 @@ +import { ILastThankYou } from '../models/last-thank-you' +import { ReleaseNote } from '../models/release-notes' +import { Dispatcher } from '../ui/dispatcher' +import { getChangeLog, getReleaseSummary } from './release-notes' + +/** + * Compares locally stored version and user of last thank you to currently + * login in user and version + */ +export function hasUserAlreadyBeenCheckedOrThanked( + lastThankYou: ILastThankYou | undefined, + login: string, + currentVersion: string +): boolean { + if (lastThankYou === undefined) { + return false + } + + const { version, checkedUsers } = lastThankYou + return checkedUsers.includes(login) && version === currentVersion +} + +/** Updates the local storage of version and users that have been checked for + * external contributions. We do this regardless of contributions so that + * we don't keep pinging for release notes. */ +export function updateLastThankYou( + dispatcher: Dispatcher, + lastThankYou: ILastThankYou | undefined, + login: string, + currentVersion: string +): void { + const newCheckedUsers = [login] + // If new version, clear out last versions checked users. + const lastCheckedUsers = + lastThankYou === undefined || lastThankYou.version !== currentVersion + ? [] + : lastThankYou.checkedUsers + + const updatedLastThankYou = { + version: currentVersion, + checkedUsers: [...lastCheckedUsers, ...newCheckedUsers], + } + + dispatcher.setLastThankYou(updatedLastThankYou) +} + +export async function getThankYouByUser( + isOnlyLastRelease: boolean +): Promise>> { + // 250 is more than total number beta release to date (5/5/2021) and the + // purpose of getting more is to retroactively thank contributors to date. + let releaseMetaData = await getChangeLog(250) + if (isOnlyLastRelease) { + releaseMetaData = releaseMetaData.slice(0, 1) + } + const summaries = releaseMetaData.map(getReleaseSummary) + const thankYousByUser = new Map>() + + summaries.forEach(s => { + if (s.thankYous.length === 0) { + return + } + + // This assumes the thank you is of the form that the draft-release notes generates: + // [type] some release note. Thanks @user_handle! + // Tho not sure if even allowed, if a user had a `!` in their user name, + // we would not get the thank them also we could erroneously thank someone if + // the `. Thanks @someusername!` was elsewhere in the message. Both, + // those scenarios are low risk tho enough to not try to mitigate. + const thanksRE = /\.\sThanks\s@.+!/i + s.thankYous.forEach(ty => { + const match = thanksRE.exec(ty.message) + if (match === null) { + return + } + + const userHandle = match[0].slice(10, -1) + let usersThanksYous = thankYousByUser.get(userHandle) + if (usersThanksYous === undefined) { + usersThanksYous = [ty] + } else { + usersThanksYous.push(ty) + } + thankYousByUser.set(userHandle, usersThanksYous) + }) + }) + + return thankYousByUser +} + +export async function getUserContributions( + isOnlyLastRelease: boolean, + login: string +): Promise | null> { + const allThankYous = await getThankYouByUser(isOnlyLastRelease) + const byLogin = allThankYous.get(login) + return byLogin !== undefined ? byLogin : null +} diff --git a/app/src/lib/tip.ts b/app/src/lib/tip.ts new file mode 100644 index 0000000000..617ff311a9 --- /dev/null +++ b/app/src/lib/tip.ts @@ -0,0 +1,12 @@ +import { TipState, Tip } from '../models/tip' + +export function getTipSha(tip: Tip) { + if (tip.kind === TipState.Valid) { + return tip.branch.tip.sha + } + + if (tip.kind === TipState.Detached) { + return tip.currentSha + } + return '(unknown)' +} diff --git a/app/src/lib/trampoline/trampoline-askpass-handler.ts b/app/src/lib/trampoline/trampoline-askpass-handler.ts new file mode 100644 index 0000000000..68d6cc62a9 --- /dev/null +++ b/app/src/lib/trampoline/trampoline-askpass-handler.ts @@ -0,0 +1,157 @@ +import { getKeyForEndpoint } from '../auth' +import { + getSSHKeyPassphrase, + keepSSHKeyPassphraseToStore, +} from '../ssh/ssh-key-passphrase' +import { TokenStore } from '../stores' +import { TrampolineCommandHandler } from './trampoline-command' +import { trampolineUIHelper } from './trampoline-ui-helper' +import { parseAddSSHHostPrompt } from '../ssh/ssh' +import { + getSSHUserPassword, + keepSSHUserPasswordToStore, +} from '../ssh/ssh-user-password' +import { removePendingSSHSecretToStore } from '../ssh/ssh-secret-storage' + +async function handleSSHHostAuthenticity( + prompt: string +): Promise<'yes' | 'no' | undefined> { + const info = parseAddSSHHostPrompt(prompt) + + if (info === null) { + return undefined + } + + // We'll accept github.com as valid host automatically. GitHub's public key + // fingerprint can be obtained from + // https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints + if ( + info.host === 'github.com' && + info.keyType === 'RSA' && + info.fingerprint === 'SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8' + ) { + return 'yes' + } + + const addHost = await trampolineUIHelper.promptAddingSSHHost( + info.host, + info.ip, + info.keyType, + info.fingerprint + ) + return addHost ? 'yes' : 'no' +} + +async function handleSSHKeyPassphrase( + operationGUID: string, + prompt: string +): Promise { + const promptRegex = /^Enter passphrase for key '(.+)': $/ + + const matches = promptRegex.exec(prompt) + if (matches === null || matches.length < 2) { + return undefined + } + + let keyPath = matches[1] + + // The ssh bundled with Desktop on Windows, for some reason, provides Unix-like + // paths for the keys (e.g. /c/Users/.../id_rsa). We need to convert them to + // Windows-like paths (e.g. C:\Users\...\id_rsa). + if (__WIN32__ && /^\/\w\//.test(keyPath)) { + const driveLetter = keyPath[1] + keyPath = keyPath.slice(2) + keyPath = `${driveLetter}:${keyPath}` + } + + const storedPassphrase = await getSSHKeyPassphrase(keyPath) + if (storedPassphrase !== null) { + return storedPassphrase + } + + const { secret: passphrase, storeSecret: storePassphrase } = + await trampolineUIHelper.promptSSHKeyPassphrase(keyPath) + + // If the user wanted us to remember the passphrase, we'll keep it around to + // store it later if the git operation succeeds. + // However, when running a git command, it's possible that the user will need + // to enter the passphrase multiple times if there are failed attempts. + // Because of that, we need to remove any pending passphrases to be stored + // when, in one of those multiple attempts, the user chooses NOT to remember + // the passphrase. + if (passphrase !== undefined && storePassphrase) { + keepSSHKeyPassphraseToStore(operationGUID, keyPath, passphrase) + } else { + removePendingSSHSecretToStore(operationGUID) + } + + return passphrase ?? '' +} + +async function handleSSHUserPassword(operationGUID: string, prompt: string) { + const promptRegex = /^(.+@.+)'s password: $/ + + const matches = promptRegex.exec(prompt) + if (matches === null || matches.length < 2) { + return undefined + } + + const username = matches[1] + + const storedPassword = await getSSHUserPassword(username) + if (storedPassword !== null) { + return storedPassword + } + + const { secret: password, storeSecret: storePassword } = + await trampolineUIHelper.promptSSHUserPassword(username) + + if (password !== undefined && storePassword) { + keepSSHUserPasswordToStore(operationGUID, username, password) + } else { + removePendingSSHSecretToStore(operationGUID) + } + + return password ?? '' +} + +export const askpassTrampolineHandler: TrampolineCommandHandler = + async command => { + if (command.parameters.length !== 1) { + return undefined + } + + const firstParameter = command.parameters[0] + + if (firstParameter.startsWith('The authenticity of host ')) { + return handleSSHHostAuthenticity(firstParameter) + } + + if (firstParameter.startsWith('Enter passphrase for key ')) { + return handleSSHKeyPassphrase(command.trampolineToken, firstParameter) + } + + if (firstParameter.endsWith("'s password: ")) { + 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 new file mode 100644 index 0000000000..9dee69d77c --- /dev/null +++ b/app/src/lib/trampoline/trampoline-command-parser.ts @@ -0,0 +1,162 @@ +import { parseEnumValue } from '../enum' +import { sendNonFatalException } from '../helpers/non-fatal-exception' +import { + ITrampolineCommand, + TrampolineCommandIdentifier, +} from './trampoline-command' + +enum TrampolineCommandParserState { + ParameterCount, + Parameters, + EnvironmentVariablesCount, + EnvironmentVariables, + Finished, +} + +/** + * The purpose of this class is to process the data received from the trampoline + * client and build a command from it. + */ +export class TrampolineCommandParser { + private parameterCount: number = 0 + private readonly parameters: string[] = [] + private environmentVariablesCount: number = 0 + private readonly environmentVariables = new Map() + + private state: TrampolineCommandParserState = + TrampolineCommandParserState.ParameterCount + + /** Whether or not it has finished parsing the command. */ + public hasFinished() { + return this.state === TrampolineCommandParserState.Finished + } + + /** + * Takes a chunk of data and processes it depending on the current state. + * + * Throws an error if it's invoked after the parser has finished, or if + * anything unexpected is received. + **/ + public processValue(value: string) { + switch (this.state) { + case TrampolineCommandParserState.ParameterCount: + this.parameterCount = parseInt(value) + + if (this.parameterCount > 0) { + this.state = TrampolineCommandParserState.Parameters + } else { + this.state = TrampolineCommandParserState.EnvironmentVariablesCount + } + + break + + case TrampolineCommandParserState.Parameters: + this.parameters.push(value) + if (this.parameters.length === this.parameterCount) { + this.state = TrampolineCommandParserState.EnvironmentVariablesCount + } + break + + case TrampolineCommandParserState.EnvironmentVariablesCount: + this.environmentVariablesCount = parseInt(value) + + if (this.environmentVariablesCount > 0) { + this.state = TrampolineCommandParserState.EnvironmentVariables + } else { + this.state = TrampolineCommandParserState.Finished + } + + break + + case TrampolineCommandParserState.EnvironmentVariables: + // Split after the first '=' + const match = /([^=]+)=(.*)/.exec(value) + + if ( + match === null || + // Length must be 3: the 2 groups + the whole string + match.length !== 3 + ) { + throw new Error(`Unexpected environment variable format: ${value}`) + } + + const variableKey = match[1] + const variableValue = match[2] + + this.environmentVariables.set(variableKey, variableValue) + + if (this.environmentVariables.size === this.environmentVariablesCount) { + this.state = TrampolineCommandParserState.Finished + } + break + + default: + throw new Error(`Received value during invalid state: ${this.state}`) + } + } + + /** + * Returns a command. + * + * It will return null if the parser hasn't finished yet, or if the identifier + * is missing or invalid. + **/ + public toCommand(): ITrampolineCommand | null { + if (this.hasFinished() === false) { + const error = new Error( + 'The command cannot be generated if parsing is not finished' + ) + this.logCommandCreationError(error) + return null + } + + const identifierString = this.environmentVariables.get( + 'DESKTOP_TRAMPOLINE_IDENTIFIER' + ) + + if (identifierString === undefined) { + const error = new Error( + `The command identifier is missing. Env variables received: ${Array.from( + this.environmentVariables.keys() + )}` + ) + this.logCommandCreationError(error) + return null + } + + const identifier = parseEnumValue( + TrampolineCommandIdentifier, + identifierString + ) + + if (identifier === undefined) { + const error = new Error( + `The command identifier ${identifierString} is not supported` + ) + this.logCommandCreationError(error) + return null + } + + const trampolineToken = this.environmentVariables.get( + 'DESKTOP_TRAMPOLINE_TOKEN' + ) + + if (trampolineToken === undefined) { + const error = new Error(`The trampoline token is missing`) + this.logCommandCreationError(error) + return null + } + + return { + identifier, + trampolineToken, + parameters: this.parameters, + environmentVariables: this.environmentVariables, + } + } + + private logCommandCreationError(error: Error) { + log.error('Error creating trampoline command:', error) + sendNonFatalException('trampolineCommandParser', error) + } +} diff --git a/app/src/lib/trampoline/trampoline-command.ts b/app/src/lib/trampoline/trampoline-command.ts new file mode 100644 index 0000000000..18834e553c --- /dev/null +++ b/app/src/lib/trampoline/trampoline-command.ts @@ -0,0 +1,43 @@ +export enum TrampolineCommandIdentifier { + AskPass = 'ASKPASS', +} + +/** Represents a command in our trampoline mechanism. */ +export interface ITrampolineCommand { + /** + * Identifier of the command. + * + * This will be used to find a suitable handler in the app to react to the + * command. + */ + readonly identifier: TrampolineCommandIdentifier + + /** + * Trampoline token sent with this command via the DESKTOP_TRAMPOLINE_TOKEN + * environment variable. + */ + readonly trampolineToken: string + + /** + * Parameters of the command. + * + * This corresponds to the command line arguments (argv) except the name of + * the program (argv[0]). + */ + readonly parameters: ReadonlyArray + + /** Environment variables that were set when the command was invoked. */ + readonly environmentVariables: ReadonlyMap +} + +/** + * Represents a handler function for a trampoline command. + * + * @param command The invoked trampoline command to handle. + * @returns A string with the result of the command (which will be + * printed via + * stdout by the trampoline client), or undefined + */ +export type TrampolineCommandHandler = ( + command: ITrampolineCommand +) => Promise diff --git a/app/src/lib/trampoline/trampoline-environment.ts b/app/src/lib/trampoline/trampoline-environment.ts new file mode 100644 index 0000000000..9710659435 --- /dev/null +++ b/app/src/lib/trampoline/trampoline-environment.ts @@ -0,0 +1,94 @@ +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' +import { GitProcess } from 'dugite' +import memoizeOne from 'memoize-one' +import { enableCustomGitUserAgent } from '../feature-flag' + +export const GitUserAgent = memoizeOne(() => + // Can't use git() as that will call withTrampolineEnv which calls this method + GitProcess.exec(['--version'], process.cwd()) + // https://github.com/git/git/blob/a9e066fa63149291a55f383cfa113d8bdbdaa6b3/help.c#L733-L739 + .then(r => /git version (.*)/.exec(r.stdout)?.at(1)) + .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})` + }) +) + +/** + * Allows invoking a function with a set of environment variables to use when + * invoking a Git subcommand that needs to use the trampoline (mainly git + * operations requiring an askpass script) and with a token to use in the + * trampoline server. + * It will handle saving SSH key passphrases when needed if the git operation + * succeeds. + * + * @param fn Function to invoke with all the necessary environment + * variables. + */ +export async function withTrampolineEnv( + fn: (env: object) => Promise +): Promise { + const sshEnv = await getSSHEnvironment() + + return withTrampolineToken(async token => { + // 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) + // 2. Those two functions always thrown an error when something went wrong, + // and just return a result when everything went fine. + // + // With those two premises in mind, we can safely assume that right after + // `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({ + DESKTOP_PORT: await trampolineServer.getPort(), + DESKTOP_TRAMPOLINE_TOKEN: token, + GIT_ASKPASS: getDesktopTrampolinePath(), + DESKTOP_TRAMPOLINE_IDENTIFIER: TrampolineCommandIdentifier.AskPass, + ...(enableCustomGitUserAgent() + ? { GIT_USER_AGENT: await GitUserAgent() } + : {}), + + ...sshEnv, + }) + + await storePendingSSHSecret(token) + + return result + } finally { + removePendingSSHSecretToStore(token) + } + }) +} + +/** Returns the path of the desktop-trampoline binary. */ +export function getDesktopTrampolinePath(): string { + return Path.resolve( + __dirname, + 'desktop-trampoline', + getDesktopTrampolineFilename() + ) +} + +/** Returns the path of the ssh-wrapper binary. */ +export function getSSHWrapperPath(): string { + return Path.resolve(__dirname, 'desktop-trampoline', 'ssh-wrapper') +} diff --git a/app/src/lib/trampoline/trampoline-server.ts b/app/src/lib/trampoline/trampoline-server.ts new file mode 100644 index 0000000000..cb64fd48b5 --- /dev/null +++ b/app/src/lib/trampoline/trampoline-server.ts @@ -0,0 +1,199 @@ +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, + TrampolineCommandIdentifier, +} from './trampoline-command' +import { TrampolineCommandParser } from './trampoline-command-parser' +import { isValidTrampolineToken } from './trampoline-tokens' + +/** + * This class represents the "trampoline server". The trampoline is something + * we'll hand to git in order to communicate with Desktop without noticing. A + * notable example of this would be GIT_ASKPASS. + * + * This server is designed so that it will start lazily when the app performs a + * remote git operation. At that point, the app will try to retrieve the + * server's port, which will run the server first if needed. + * + * The idea behind this is to simplify the retry approach in case of error: + * instead of reacting to errors with an immediate retry, the server will remain + * closed until the next time the app needs it (i.e. in the next git remote + * operation). + */ +export class TrampolineServer { + private readonly server: Server + private listeningPromise: Promise | null = null + + private readonly commandHandlers = new Map< + TrampolineCommandIdentifier, + TrampolineCommandHandler + >() + + public constructor() { + this.server = createServer(socket => this.onNewConnection(socket)) + + // Make sure the server is always unref'ed, so it doesn't keep the app alive + // for longer than needed. Not having this made the CI tasks on Windows + // timeout because the unit tests completed in about 7min, but the test + // 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 { + this.listeningPromise = new Promise((resolve, reject) => { + // Observe errors while trying to start the server + this.server.on('error', error => { + reject(error) + this.close() + }) + + this.server.listen(0, '127.0.0.1', async () => { + // Replace the error handler + this.server.removeAllListeners('error') + this.server.on('error', this.onServerError) + + resolve() + }) + }) + + return this.listeningPromise + } + + private async close() { + // Make sure the server is not trying to start + if (this.listeningPromise !== null) { + await this.listeningPromise + } + + // Reset the server, it will be restarted lazily the next time it's needed + this.server.close() + this.server.removeAllListeners('error') + this.listeningPromise = null + } + + /** + * This function will retrieve the port of the server, or null if the server + * is not running. + * + * In order to get the server port, it might need to start the server if it's + * not running already. + */ + public async getPort() { + if (this.port !== null) { + return this.port + } + + if (this.listeningPromise !== null) { + await this.listeningPromise + } else { + await this.listen() + } + + return this.port + } + + private get port(): number | null { + const address = this.server.address() as AddressInfo + + if (address && address.port) { + return address.port + } + + return null + } + + private onNewConnection(socket: Socket) { + const parser = new TrampolineCommandParser() + + // Messages coming from the trampoline client will be separated by \0 + socket.pipe(split2(/\0/)).on('data', data => { + this.onDataReceived(socket, parser, data) + }) + + socket.on('error', this.onClientError) + } + + private onDataReceived( + socket: Socket, + parser: TrampolineCommandParser, + data: Buffer + ) { + const value = data.toString('utf8') + + try { + parser.processValue(value) + } catch (error) { + log.error('Error processing trampoline data', error) + socket.end() + return + } + + if (!parser.hasFinished()) { + return + } + + const command = parser.toCommand() + if (command === null) { + socket.end() + return + } + + this.processCommand(socket, command) + } + + /** + * Registers a handler for commands with a specific identifier. This will be + * invoked when the server receives a command with the given identifier. + * + * @param identifier Identifier of the command. + * @param handler Handler to register. + */ + private registerCommandHandler( + identifier: TrampolineCommandIdentifier, + handler: TrampolineCommandHandler + ) { + this.commandHandlers.set(identifier, handler) + } + + private async processCommand(socket: Socket, command: ITrampolineCommand) { + if (!isValidTrampolineToken(command.trampolineToken)) { + throw new Error('Tried to use invalid trampoline token') + } + + const handler = this.commandHandlers.get(command.identifier) + + if (handler === undefined) { + socket.end() + return + } + + const result = await handler(command) + + if (result !== undefined) { + socket.end(result) + } else { + socket.end() + } + } + + private onServerError = (error: Error) => { + sendNonFatalException('trampolineServer', error) + this.close() + } + + private onClientError = (error: Error) => { + log.error('Trampoline client error', error) + } +} + +export const trampolineServer = new TrampolineServer() diff --git a/app/src/lib/trampoline/trampoline-tokens.ts b/app/src/lib/trampoline/trampoline-tokens.ts new file mode 100644 index 0000000000..67874b5824 --- /dev/null +++ b/app/src/lib/trampoline/trampoline-tokens.ts @@ -0,0 +1,39 @@ +import { uuid } from '../uuid' + +const trampolineTokens = new Set() + +function requestTrampolineToken() { + const token = uuid() + trampolineTokens.add(token) + return token +} + +function revokeTrampolineToken(token: string) { + trampolineTokens.delete(token) +} + +/** Checks if a given trampoline token is valid. */ +export function isValidTrampolineToken(token: string) { + return trampolineTokens.has(token) +} + +/** + * Allows invoking a function with a short-lived trampoline token that will be + * revoked right after the function finishes. + * + * @param fn Function to invoke with the trampoline token. + */ +export async function withTrampolineToken( + fn: (token: string) => Promise +): Promise { + const token = requestTrampolineToken() + let result + + try { + result = await fn(token) + } finally { + revokeTrampolineToken(token) + } + + return result +} diff --git a/app/src/lib/trampoline/trampoline-ui-helper.ts b/app/src/lib/trampoline/trampoline-ui-helper.ts new file mode 100644 index 0000000000..7e6fe66d5e --- /dev/null +++ b/app/src/lib/trampoline/trampoline-ui-helper.ts @@ -0,0 +1,62 @@ +import { PopupType } from '../../models/popup' +import { Dispatcher } from '../../ui/dispatcher' + +type PromptSSHSecretResponse = { + readonly secret: string | undefined + readonly storeSecret: boolean +} + +class TrampolineUIHelper { + // The dispatcher must be set before this helper can do anything + private dispatcher!: Dispatcher + + public setDispatcher(dispatcher: Dispatcher) { + this.dispatcher = dispatcher + } + + public promptAddingSSHHost( + host: string, + ip: string, + keyType: string, + fingerprint: string + ): Promise { + return new Promise(resolve => { + this.dispatcher.showPopup({ + type: PopupType.AddSSHHost, + host, + ip, + keyType, + fingerprint, + onSubmit: addHost => resolve(addHost), + }) + }) + } + + public promptSSHKeyPassphrase( + keyPath: string + ): Promise { + return new Promise(resolve => { + this.dispatcher.showPopup({ + type: PopupType.SSHKeyPassphrase, + keyPath, + onSubmit: (passphrase, storePassphrase) => + resolve({ secret: passphrase, storeSecret: storePassphrase }), + }) + }) + } + + public promptSSHUserPassword( + username: string + ): Promise { + return new Promise(resolve => { + this.dispatcher.showPopup({ + type: PopupType.SSHUserPassword, + username, + onSubmit: (password, storePassword) => + resolve({ secret: password, storeSecret: storePassword }), + }) + }) + } +} + +export const trampolineUIHelper = new TrampolineUIHelper() diff --git a/app/src/lib/truncate-with-ellipsis.ts b/app/src/lib/truncate-with-ellipsis.ts new file mode 100644 index 0000000000..06d330faac --- /dev/null +++ b/app/src/lib/truncate-with-ellipsis.ts @@ -0,0 +1,32 @@ +/** Truncate a single line unicode string by a given maxLength and add ellipsis if necessary */ +export function truncateWithEllipsis(str: string, maxLength: number) { + if (str.length <= maxLength) { + return str + } + + // String.prototype[@@iterator]() is unicode-aware, using it here to get + // correct unicode string length + const codePoints = [...str] + if (codePoints.length <= maxLength) { + return str + } + + // combine variation selectors with corresponding characters + const characters = codePoints.reduce((characters: Array, code) => { + if (code >= '\uFE00' && code <= '\uFE0F') { + if (characters.length) { + characters.push(`${characters.pop()}${code}`) + } + } else { + characters.push(code) + } + return characters + }, []) + + if (characters.length <= maxLength) { + return str + } + + const result = characters.slice(0, maxLength).join('') + return `${result}…` +} diff --git a/app/src/lib/unique-coauthors-as-authors.ts b/app/src/lib/unique-coauthors-as-authors.ts new file mode 100644 index 0000000000..3911427415 --- /dev/null +++ b/app/src/lib/unique-coauthors-as-authors.ts @@ -0,0 +1,21 @@ +import _ from 'lodash' +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) + ) + + const uniqueCoAuthors = _.uniqWith( + allCommitsCoAuthors, + (a, b) => a.email === b.email && a.name === b.name + ) + + return uniqueCoAuthors.map(ca => { + return { kind: 'known', name: ca.name, email: ca.email, username: null } + }) +} diff --git a/app/src/lib/uuid.ts b/app/src/lib/uuid.ts new file mode 100644 index 0000000000..171fd58a12 --- /dev/null +++ b/app/src/lib/uuid.ts @@ -0,0 +1,33 @@ +import { randomBytes as nodeCryptoGetRandomBytes } from 'crypto' +import guid from 'uuid/v4' + +/** + * Fills a buffer with the required number of random bytes. + * + * Attempt to use the Chromium-provided crypto library rather than + * Node.JS. For some reason the Node.JS randomBytes function adds + * _considerable_ (1s+) synchronous load time to the start up. + * + * See + * https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto + * https://github.com/kelektiv/node-uuid/issues/189 + */ +function getRandomBytes(count: number) { + if (typeof window !== 'undefined' && window.crypto) { + const rndBuf = new Uint8Array(count) + crypto.getRandomValues(rndBuf) + + return rndBuf + } + + return nodeCryptoGetRandomBytes(count) +} + +/** + * Wrapper function over uuid's v4 method that attempts to source + * entropy using the window Crypto instance rather than through + * Node.JS. + */ +export function uuid() { + return guid({ random: getRandomBytes(16) }) +} diff --git a/app/src/lib/valid-notification-pull-request-review.ts b/app/src/lib/valid-notification-pull-request-review.ts new file mode 100644 index 0000000000..87325a5e7d --- /dev/null +++ b/app/src/lib/valid-notification-pull-request-review.ts @@ -0,0 +1,25 @@ +import { IAPIPullRequestReview } from './api' + +export type ValidNotificationPullRequestReviewState = + | 'APPROVED' + | 'CHANGES_REQUESTED' + | 'COMMENTED' + +export type ValidNotificationPullRequestReview = IAPIPullRequestReview & { + state: ValidNotificationPullRequestReviewState +} + +/** + * Returns whether or not the given review is valid from a notifications point + * of view: in order to get a notification from a review, it must be approved, + * changes requested, or commented. + */ +export function isValidNotificationPullRequestReview( + review: IAPIPullRequestReview +): review is ValidNotificationPullRequestReview { + return ( + review.state === 'APPROVED' || + review.state === 'CHANGES_REQUESTED' || + review.state === 'COMMENTED' + ) +} diff --git a/app/src/lib/web-flow-committer.ts b/app/src/lib/web-flow-committer.ts new file mode 100644 index 0000000000..e25c986b55 --- /dev/null +++ b/app/src/lib/web-flow-committer.ts @@ -0,0 +1,50 @@ +import { Commit } from '../models/commit' +import { GitHubRepository } from '../models/github-repository' +import { getDotComAPIEndpoint } from './api' + +/** + * Best-effort attempt to figure out if this commit was committed using + * the web flow on GitHub.com or GitHub Enterprise. Web flow + * commits (such as PR merges) will have a special GitHub committer + * with a noreply email address. + * + * For GitHub.com we can be spot on but for GitHub Enterprise it's + * possible we could fail if they've set up a custom smtp host + * that doesn't correspond to the hostname. + */ +export function isWebFlowCommitter( + commit: Commit, + gitHubRepository: GitHubRepository +) { + if (!gitHubRepository) { + return false + } + + const endpoint = gitHubRepository.owner.endpoint + const { name, email } = commit.committer + + if ( + endpoint === getDotComAPIEndpoint() && + name === 'GitHub' && + email === 'noreply@github.com' + ) { + return true + } + + if (endpoint !== getDotComAPIEndpoint() && name === 'GitHub Enterprise') { + // We can't assume that the email address will match any specific format + // here since the web flow committer email address on GHES is the same as + // the noreply email which can be configured by domain administrators so + // we'll just have to assume that for a GitHub hosted repository (but not + // GitHub.com) a commit author of the name 'GitHub Enterprise' is the web + // flow author. + // + // Hello future contributor: Turns out the web flow committer name is based + // on the "flavor" of GitHub so it's possible that you're here wondering why + // this isn't working and chances are it's because we've updated the + // GHES branding or introduced some new flavor. + return true + } + + return false +} diff --git a/app/src/lib/welcome.ts b/app/src/lib/welcome.ts new file mode 100644 index 0000000000..3c1a61c52c --- /dev/null +++ b/app/src/lib/welcome.ts @@ -0,0 +1,18 @@ +import { getBoolean, setBoolean } from './local-storage' + +/** The `localStorage` key for whether we've shown the Welcome flow yet. */ +const HasShownWelcomeFlowKey = 'has-shown-welcome-flow' + +/** + * Check if the current user has completed the welcome flow. + */ +export function hasShownWelcomeFlow(): boolean { + return getBoolean(HasShownWelcomeFlowKey, false) +} + +/** + * Update local storage to indicate the welcome flow has been completed. + */ +export function markWelcomeFlowComplete() { + setBoolean(HasShownWelcomeFlowKey, true) +} diff --git a/app/src/lib/window-state.ts b/app/src/lib/window-state.ts new file mode 100644 index 0000000000..5c4f1ea1a0 --- /dev/null +++ b/app/src/lib/window-state.ts @@ -0,0 +1,64 @@ +import * as ipcWebContents from '../main-process/ipc-webcontents' + +export type WindowState = + | 'minimized' + | 'normal' + | 'maximized' + | 'full-screen' + | 'hidden' + +export function getWindowState(window: Electron.BrowserWindow): WindowState { + if (window.isFullScreen()) { + return 'full-screen' + } else if (window.isMaximized()) { + return 'maximized' + } else if (window.isMinimized()) { + return 'minimized' + } else if (!window.isVisible()) { + return 'hidden' + } else { + return 'normal' + } +} + +/** + * Registers event handlers for all window state transition events and + * forwards those to the renderer process for a given window. + */ +export function registerWindowStateChangedEvents( + window: Electron.BrowserWindow +) { + window.on('enter-full-screen', () => + sendWindowStateEvent(window, 'full-screen') + ) + + // So this is a bit of a hack. If we call window.isFullScreen directly after + // receiving the leave-full-screen event it'll return true which isn't what + // we're after. So we'll say that we're transitioning to 'normal' even though + // we might be maximized. This works because electron will emit a 'maximized' + // event after 'leave-full-screen' if the state prior to full-screen was maximized. + window.on('leave-full-screen', () => sendWindowStateEvent(window, 'normal')) + + window.on('maximize', () => sendWindowStateEvent(window, 'maximized')) + window.on('minimize', () => sendWindowStateEvent(window, 'minimized')) + window.on('unmaximize', () => sendWindowStateEvent(window, 'normal')) + window.on('restore', () => sendWindowStateEvent(window, 'normal')) + window.on('hide', () => sendWindowStateEvent(window, 'hidden')) + window.on('show', () => { + // because the app can be maximized before being closed - which will restore it + // maximized on the next launch - this function should inspect the current state + // rather than always assume it is a 'normal' launch + sendWindowStateEvent(window, getWindowState(window)) + }) +} + +/** + * Short hand convenience function for sending a window state change event + * over the window-state-changed channel to the render process. + */ +function sendWindowStateEvent( + window: Electron.BrowserWindow, + state: WindowState +) { + ipcWebContents.send(window.webContents, 'window-state-changed', state) +} diff --git a/app/src/lib/wrap-rich-text-commit-message.ts b/app/src/lib/wrap-rich-text-commit-message.ts new file mode 100644 index 0000000000..5204e7a7b6 --- /dev/null +++ b/app/src/lib/wrap-rich-text-commit-message.ts @@ -0,0 +1,117 @@ +import { + Tokenizer, + TokenType, + TokenResult, + PlainText, + HyperlinkMatch, +} from './text-token-parser' +import { assertNever } from './fatal-error' + +export const MaxSummaryLength = 72 +export const IdealSummaryLength = 50 + +/** + * A method used to wrap long commit summaries and put any overflow + * into the commit body while taking rich text into consideration. + * + * See https://github.com/desktop/desktop/issues/9185 for a description + * of the problem and https://github.com/desktop/desktop/pull/2575 for + * the initial naive implementation. + * + * Note that this method doesn't wrap multibyte chars like unicode emojis + * correctly (i.e. it could end up splitting a multibyte char). + * + * @param summaryText The commit message summary text (i.e. the first line) + * @param bodyText The commit message body text + * @param tokenizer The tokenizer to use when converting the raw text to + * rich text tokens + * @param maxSummaryLength The maximum width of the commit summary (defaults + * to 72), note that this does not include any ellipsis + * that may be appended when wrapping. In other words + * it's possible that the commit summary ends up being + * maxSummaryLength + 1 long when rendered. + */ +export function wrapRichTextCommitMessage( + summaryText: string, + bodyText: string, + tokenizer: Tokenizer, + maxSummaryLength = MaxSummaryLength +): { summary: ReadonlyArray; body: ReadonlyArray } { + const tokens = tokenizer.tokenize(summaryText.trimRight()) + + const summary = new Array() + const overflow = new Array() + + let remainder = maxSummaryLength + + for (const token of tokens) { + // An emoji token like ":white_square_button: would still only take + // up a little bit more space than a regular character when rendered + // as an image, we take that into consideration here with an approximation + // that an emoji is twice as wide as a normal character. + const charCount = token.kind === TokenType.Emoji ? 2 : token.text.length + + if (remainder <= 0) { + // There's no room left in the summary, everything needs to + // go into the overflow + overflow.push(token) + } else if (remainder >= charCount) { + // The token fits without us having to think about wrapping! + summary.push(token) + remainder -= charCount + } else { + // There's not enough room to include the token in its entirety, + // we've got to make a decision between hard wrapping or pushing + // to overflow. + if (token.kind === TokenType.Text) { + // We always hard-wrap text, it'd be nice if we could attempt + // to break at word boundaries in the future but that's too + // complex for now. + summary.push(text(token.text.substring(0, remainder))) + overflow.push(text(token.text.substring(remainder))) + } else if (token.kind === TokenType.Emoji) { + // Can't hard-wrap inside an emoji + overflow.push(token) + } else if (token.kind === TokenType.Link) { + // Hard wrapping an issue link is confusing so we treat them + // as atomic. For all other links (@mentions or https://...) + // We want at least the first couple of characters of the link + // text showing otherwise we'll end up with weird links like "h" + // or "@" + if (!token.text.startsWith('#') && remainder > 5) { + summary.push(link(token.text.substring(0, remainder), token.text)) + overflow.push(link(token.text.substring(remainder), token.text)) + } else { + overflow.push(token) + } + } else { + return assertNever(token, `Unknown token type`) + } + + remainder = 0 + } + } + + let body = tokenizer.tokenize(bodyText.trimRight()) + + if (overflow.length > 0) { + summary.push(ellipsis()) + if (body.length > 0) { + body = [ellipsis(), ...overflow, text('\n\n'), ...body] + } else { + body = [ellipsis(), ...overflow] + } + } + + return { summary, body } +} + +function ellipsis() { + return text('…') +} +function text(text: string): PlainText { + return { kind: TokenType.Text, text } +} +function link(text: string, url: string): HyperlinkMatch { + return { kind: TokenType.Link, text, url } +} diff --git a/app/src/main-process/alive-origin-filter.ts b/app/src/main-process/alive-origin-filter.ts new file mode 100644 index 0000000000..5b6800da1c --- /dev/null +++ b/app/src/main-process/alive-origin-filter.ts @@ -0,0 +1,26 @@ +import { OrderedWebRequest } from './ordered-webrequest' + +/** + * Installs a web request filter to override the default Origin used to connect + * to Alive web sockets + */ +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', + }, + } + } + + return {} + }) +} diff --git a/app/src/main-process/app-window.ts b/app/src/main-process/app-window.ts new file mode 100644 index 0000000000..e321e5662e --- /dev/null +++ b/app/src/main-process/app-window.ts @@ -0,0 +1,482 @@ +import { + Menu, + app, + dialog, + BrowserWindow, + autoUpdater, + nativeTheme, +} from 'electron' +import { Emitter, Disposable } from 'event-kit' +import { encodePathAsUrl } from '../lib/path' +import { + getWindowState, + registerWindowStateChangedEvents, +} from '../lib/window-state' +import { MenuEvent } from './menu' +import { URLActionType } from '../lib/parse-app-url' +import { ILaunchStats } from '../lib/stats' +import { menuFromElectronMenu } from '../models/app-menu' +import { now } from './now' +import * as path from 'path' +import windowStateKeeper from 'electron-window-state' +import * as ipcMain from './ipc-main' +import * as ipcWebContents from './ipc-webcontents' +import { + installNotificationCallback, + terminateDesktopNotifications, +} from './notifications' +import { addTrustedIPCSender } from './trusted-ipc-sender' + +export class AppWindow { + private window: Electron.BrowserWindow + private emitter = new Emitter() + + private _loadTime: number | null = null + private _rendererReadyTime: number | null = null + private isDownloadingUpdate: boolean = false + + private minWidth = 960 + private minHeight = 660 + + // See https://github.com/desktop/desktop/pull/11162 + private shouldMaximizeOnShow = false + + public constructor() { + const savedWindowState = windowStateKeeper({ + defaultWidth: this.minWidth, + defaultHeight: this.minHeight, + maximize: false, + }) + + const windowOptions: Electron.BrowserWindowConstructorOptions = { + x: savedWindowState.x, + y: savedWindowState.y, + width: savedWindowState.width, + height: savedWindowState.height, + minWidth: this.minWidth, + minHeight: this.minHeight, + show: false, + // This fixes subpixel aliasing on Windows + // See https://github.com/atom/atom/commit/683bef5b9d133cb194b476938c77cc07fd05b972 + backgroundColor: '#fff', + webPreferences: { + // Disable auxclick event + // See https://developers.google.com/web/updates/2016/10/auxclick + disableBlinkFeatures: 'Auxclick', + nodeIntegration: true, + spellcheck: true, + contextIsolation: false, + }, + acceptFirstMouse: true, + } + + if (__DARWIN__) { + windowOptions.titleBarStyle = 'hidden' + } else if (__WIN32__) { + windowOptions.frame = false + } else if (__LINUX__) { + windowOptions.icon = path.join(__dirname, 'static', 'icon-logo.png') + } + + this.window = new BrowserWindow(windowOptions) + addTrustedIPCSender(this.window.webContents) + + installNotificationCallback(this.window) + + savedWindowState.manage(this.window) + this.shouldMaximizeOnShow = savedWindowState.isMaximized + + let quitting = false + let quittingEvenIfUpdating = false + app.on('before-quit', () => { + quitting = true + }) + + ipcMain.on('will-quit', event => { + quitting = true + 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. + if (__DARWIN__ && !quitting) { + e.preventDefault() + // https://github.com/desktop/desktop/issues/12838 + if (this.window.isFullScreen()) { + this.window.setFullScreen(false) + this.window.once('leave-full-screen', () => this.window.hide()) + } else { + this.window.hide() + } + return + } + nativeTheme.removeAllListeners() + autoUpdater.removeAllListeners() + terminateDesktopNotifications() + }) + + if (__WIN32__) { + // workaround for known issue with fullscreen-ing the app and restoring + // is that some Chromium API reports the incorrect bounds, so that it + // will leave a small space at the top of the screen on every other + // maximize + // + // adapted from https://github.com/electron/electron/issues/12971#issuecomment-403956396 + // + // can be tidied up once https://github.com/electron/electron/issues/12971 + // has been confirmed as resolved + this.window.once('ready-to-show', () => { + this.window.on('unmaximize', () => { + setTimeout(() => { + const bounds = this.window.getBounds() + bounds.width += 1 + this.window.setBounds(bounds) + bounds.width -= 1 + this.window.setBounds(bounds) + }, 5) + }) + }) + } + } + + public load() { + let startLoad = 0 + // We only listen for the first of the loading events to avoid a bug in + // Electron/Chromium where they can sometimes fire more than once. See + // See + // https://github.com/desktop/desktop/pull/513#issuecomment-253028277. This + // shouldn't really matter as in production builds loading _should_ only + // happen once. + this.window.webContents.once('did-start-loading', () => { + this._rendererReadyTime = null + this._loadTime = null + + startLoad = now() + }) + + this.window.webContents.once('did-finish-load', () => { + if (process.env.NODE_ENV === 'development') { + this.window.webContents.openDevTools() + } + + this._loadTime = now() - startLoad + + this.maybeEmitDidLoad() + }) + + this.window.webContents.on('did-finish-load', () => { + this.window.webContents.setVisualZoomLevelLimits(1, 1) + }) + + this.window.webContents.on('did-fail-load', () => { + this.window.webContents.openDevTools() + this.window.show() + }) + + // TODO: This should be scoped by the window. + ipcMain.once('renderer-ready', (_, readyTime) => { + this._rendererReadyTime = readyTime + this.maybeEmitDidLoad() + }) + + this.window.on('focus', () => + ipcWebContents.send(this.window.webContents, 'focus') + ) + this.window.on('blur', () => + ipcWebContents.send(this.window.webContents, 'blur') + ) + + registerWindowStateChangedEvents(this.window) + this.window.loadURL(encodePathAsUrl(__dirname, 'index.html')) + + nativeTheme.addListener('updated', (event: string, userInfo: any) => { + ipcWebContents.send(this.window.webContents, 'native-theme-updated') + }) + + this.setupAutoUpdater() + } + + /** + * Emit the `onDidLoad` event if the page has loaded and the renderer has + * signalled that it's ready. + */ + private maybeEmitDidLoad() { + if (!this.rendererLoaded) { + return + } + + this.emitter.emit('did-load', null) + } + + /** Is the page loaded and has the renderer signalled it's ready? */ + private get rendererLoaded(): boolean { + return !!this.loadTime && !!this.rendererReadyTime + } + + public onClosed(fn: () => void) { + this.window.on('closed', fn) + } + + /** + * Register a function to call when the window is done loading. At that point + * the page has loaded and the renderer has signalled that it is ready. + */ + public onDidLoad(fn: () => void): Disposable { + return this.emitter.on('did-load', fn) + } + + public isMinimized() { + return this.window.isMinimized() + } + + /** Is the window currently visible? */ + public isVisible() { + return this.window.isVisible() + } + + public restore() { + this.window.restore() + } + + public isFocused() { + return this.window.isFocused() + } + + public focus() { + this.window.focus() + } + + /** Selects all the windows web contents */ + public selectAllWindowContents() { + this.window.webContents.selectAll() + } + + /** Show the window. */ + public show() { + this.window.show() + if (this.shouldMaximizeOnShow) { + // Only maximize the window the first time it's shown, not every time. + // Otherwise, it causes the problem described in desktop/desktop#11590 + this.shouldMaximizeOnShow = false + this.window.maximize() + } + } + + /** Send the menu event to the renderer. */ + public sendMenuEvent(name: MenuEvent) { + this.show() + + ipcWebContents.send(this.window.webContents, 'menu-event', name) + } + + /** Send the URL action to the renderer. */ + public sendURLAction(action: URLActionType) { + this.show() + + ipcWebContents.send(this.window.webContents, 'url-action', action) + } + + /** Send the app launch timing stats to the renderer. */ + public sendLaunchTimingStats(stats: ILaunchStats) { + ipcWebContents.send(this.window.webContents, 'launch-timing-stats', stats) + } + + /** Send the app menu to the renderer. */ + public sendAppMenu() { + const appMenu = Menu.getApplicationMenu() + if (appMenu) { + const menu = menuFromElectronMenu(appMenu) + ipcWebContents.send(this.window.webContents, 'app-menu', menu) + } + } + + /** Send a certificate error to the renderer. */ + public sendCertificateError( + certificate: Electron.Certificate, + error: string, + url: string + ) { + ipcWebContents.send( + this.window.webContents, + 'certificate-error', + certificate, + error, + url + ) + } + + public showCertificateTrustDialog( + certificate: Electron.Certificate, + message: string + ) { + // The Electron type definitions don't include `showCertificateTrustDialog` + // yet. + const d = dialog as any + d.showCertificateTrustDialog( + this.window, + { certificate, message }, + () => {} + ) + } + + /** + * Get the time (in milliseconds) spent loading the page. + * + * This will be `null` until `onDidLoad` is called. + */ + public get loadTime(): number | null { + return this._loadTime + } + + /** + * Get the time (in milliseconds) elapsed from the renderer being loaded to it + * signaling it was ready. + * + * This will be `null` until `onDidLoad` is called. + */ + public get rendererReadyTime(): number | null { + return this._rendererReadyTime + } + + public destroy() { + this.window.destroy() + } + + 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' + ) + }) + + autoUpdater.on('update-available', () => { + this.isDownloadingUpdate = true + ipcWebContents.send( + this.window.webContents, + 'auto-updater-update-available' + ) + }) + + autoUpdater.on('update-not-available', () => { + this.isDownloadingUpdate = false + ipcWebContents.send( + this.window.webContents, + 'auto-updater-update-not-available' + ) + }) + + autoUpdater.on('update-downloaded', () => { + this.isDownloadingUpdate = false + ipcWebContents.send( + this.window.webContents, + 'auto-updater-update-downloaded' + ) + }) + } + + public checkForUpdates(url: string) { + try { + autoUpdater.setFeedURL({ url }) + autoUpdater.checkForUpdates() + } catch (e) { + return e + } + return undefined + } + + public quitAndInstallUpdate() { + autoUpdater.quitAndInstall() + } + + public minimizeWindow() { + this.window.minimize() + } + + public maximizeWindow() { + this.window.maximize() + } + + public unmaximizeWindow() { + this.window.unmaximize() + } + + public closeWindow() { + this.window.close() + } + + public isMaximized() { + return this.window.isMaximized() + } + + public getCurrentWindowState() { + return getWindowState(this.window) + } + + public getCurrentWindowZoomFactor() { + return this.window.webContents.zoomFactor + } + + public setWindowZoomFactor(zoomFactor: number) { + this.window.webContents.zoomFactor = zoomFactor + } + + /** + * Method to show the save dialog and return the first file path it returns. + */ + public async showSaveDialog(options: Electron.SaveDialogOptions) { + const { canceled, filePath } = await dialog.showSaveDialog( + this.window, + options + ) + return !canceled && filePath !== undefined ? filePath : null + } + + /** + * Method to show the open dialog and return the first file path it returns. + */ + public async showOpenDialog(options: Electron.OpenDialogOptions) { + const { filePaths } = await dialog.showOpenDialog(this.window, options) + return filePaths.length > 0 ? filePaths[0] : null + } +} 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 0000000000..951684e0c5 --- /dev/null +++ b/app/src/main-process/authenticated-image-filter.ts @@ -0,0 +1,60 @@ +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) +} + +/** + * 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 new file mode 100644 index 0000000000..d91fee11ef --- /dev/null +++ b/app/src/main-process/crash-window.ts @@ -0,0 +1,174 @@ +import { BrowserWindow } from 'electron' +import { Emitter, Disposable } from 'event-kit' +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 + +/** + * A wrapper around the BrowserWindow instance for our crash process. + * + * The crash process is responsible for presenting the user with an + * error after the main process or any renderer process has crashed due + * to an uncaught exception or when the main renderer has failed to load. + */ +export class CrashWindow { + private readonly window: Electron.BrowserWindow + private readonly emitter = new Emitter() + private readonly errorType: ErrorType + private readonly error: Error + + private hasFinishedLoading = false + private hasSentReadyEvent = false + + public constructor(errorType: ErrorType, error: Error) { + const windowOptions: Electron.BrowserWindowConstructorOptions = { + width: minWidth, + height: minHeight, + minWidth: minWidth, + minHeight: minHeight, + show: false, + // This fixes subpixel aliasing on Windows + // See https://github.com/atom/atom/commit/683bef5b9d133cb194b476938c77cc07fd05b972 + backgroundColor: '#fff', + webPreferences: { + // Disable auxclick event + // See https://developers.google.com/web/updates/2016/10/auxclick + disableBlinkFeatures: 'Auxclick', + nodeIntegration: true, + spellcheck: false, + contextIsolation: false, + }, + } + + if (__DARWIN__) { + windowOptions.titleBarStyle = 'hidden' + } else if (__WIN32__) { + windowOptions.frame = false + } + + this.window = new BrowserWindow(windowOptions) + addTrustedIPCSender(this.window.webContents) + + this.error = error + this.errorType = errorType + } + + public load() { + log.debug('Starting crash process') + + // We only listen for the first of the loading events to avoid a bug in + // Electron/Chromium where they can sometimes fire more than once. See + // See + // https://github.com/desktop/desktop/pull/513#issuecomment-253028277. This + // shouldn't really matter as in production builds loading _should_ only + // happen once. + this.window.webContents.once('did-start-loading', () => { + log.debug('Crash process in startup') + }) + + this.window.webContents.once('did-finish-load', () => { + log.debug('Crash process started') + if (process.env.NODE_ENV === 'development') { + this.window.webContents.openDevTools() + } + + this.hasFinishedLoading = true + this.maybeEmitDidLoad() + }) + + this.window.webContents.on('did-finish-load', () => { + this.window.webContents.setVisualZoomLevelLimits(1, 1) + }) + + this.window.webContents.on('did-fail-load', () => { + log.error('Crash process failed to load') + if (__DEV__) { + this.window.webContents.openDevTools() + this.window.show() + } else { + this.emitter.emit('did-fail-load', null) + } + }) + + ipcMain.on('crash-ready', () => { + log.debug(`Crash process is ready`) + + this.hasSentReadyEvent = true + + this.sendError() + this.maybeEmitDidLoad() + }) + + ipcMain.on('crash-quit', () => { + log.debug('Got quit signal from crash process') + this.window.close() + }) + + registerWindowStateChangedEvents(this.window) + + this.window.loadURL(`file://${__dirname}/crash.html`) + } + + /** + * Emit the `onDidLoad` event if the page has loaded and the renderer has + * signalled that it's ready. + */ + private maybeEmitDidLoad() { + if (this.hasFinishedLoading && this.hasSentReadyEvent) { + this.emitter.emit('did-load', null) + } + } + + public onClose(fn: () => void) { + this.window.on('closed', fn) + } + + public onFailedToLoad(fn: () => void) { + this.emitter.on('did-fail-load', fn) + } + + /** + * Register a function to call when the window is done loading. At that point + * the page has loaded and the renderer has signalled that it is ready. + */ + public onDidLoad(fn: () => void): Disposable { + return this.emitter.on('did-load', fn) + } + + public focus() { + this.window.focus() + } + + /** Show the window. */ + public show() { + log.debug('Showing crash process window') + this.window.show() + } + + /** Report the error to the renderer. */ + private sendError() { + // `Error` can't be JSONified so it doesn't transport nicely over IPC. So + // we'll just manually copy the properties we care about. + const friendlyError = { + stack: this.error.stack, + message: this.error.message, + name: this.error.name, + } + + const details: ICrashDetails = { + type: this.errorType, + error: friendlyError, + } + + ipcWebContents.send(this.window.webContents, 'error', details) + } + + public destroy() { + this.window.destroy() + } +} diff --git a/app/src/main-process/desktop-console-transport.ts b/app/src/main-process/desktop-console-transport.ts new file mode 100644 index 0000000000..db864c4ac4 --- /dev/null +++ b/app/src/main-process/desktop-console-transport.ts @@ -0,0 +1,46 @@ +import TransportStream from 'winston-transport' +import { LEVEL, MESSAGE } from 'triple-beam' + +const logFunctions: Record = { + error: console.error, + warn: console.warn, + info: console.info, + debug: console.debug, +} + +/** + * A thin re-implementation of winston's Console transport + * + * The Console transport shipped with Winston will fail to catch errors when + * attempting to log after stderr/stdout has been closed. console.log in Node.js + * specifically deals with this scenario[1] so instead of trying to detect + * whether we're in a Node context or not like Winston does[2] we'll just use + * console.* regardelss of whether we're in the renderer or in the main process. + * + * 1. https://github.com/nodejs/node/blob/916227b3ed041ff13d588b02f98e9be0846a5a7c/lib/internal/console/constructor.js#L277-L295 + * 2. https://github.com/winstonjs/winston/commit/4d52541df505c3fd464d92f3efd477e5ba3c935b + */ +export class DesktopConsoleTransport extends TransportStream { + public log(info: any, callback: () => void) { + setImmediate(() => this.emit('logged', info)) + + // Winston users can use custom levels but Desktop only uses the levels + // defined in the LogLevel type. + // + // Node.js only differentiates between warn and log but we'll use info and + // debug here as well in case we're used in the renderer + // https://github.com/nodejs/node/blob/916227b3ed041ff13d588b02f98e9be0846a5a7c/lib/internal/console/constructor.js#L666-L670 + const logFn = logFunctions[info[LEVEL]] ?? console.log + + // Surprisingly we are seeing rare crashes originating from within + // console.log when the underlying stream is closed. Our understanding based + // on reading the Node source code was that only stack overflow errors would + // escape console.log (when _ignoreErrors is set to false that is) but + // that's not what we're seeing so we'll safeguard here. + try { + logFn(info[MESSAGE]) + } catch {} + + callback?.() + } +} diff --git a/app/src/main-process/desktop-file-transport.ts b/app/src/main-process/desktop-file-transport.ts new file mode 100644 index 0000000000..0ec6e714ed --- /dev/null +++ b/app/src/main-process/desktop-file-transport.ts @@ -0,0 +1,91 @@ +import { createWriteStream, WriteStream } from 'fs' +import { join } from 'path' +import { MESSAGE } from 'triple-beam' +import TransportStream, { TransportStreamOptions } from 'winston-transport' +import { EOL } from 'os' +import { readdir, unlink } from 'fs/promises' +import { promisify } from 'util' +import { escapeRegExp } from 'lodash' + +type DesktopFileTransportOptions = TransportStreamOptions & { + readonly logDirectory: string +} + +const MaxRetainedLogFiles = 14 +const fileSuffix = `.desktop.${__RELEASE_CHANNEL__}.log` +const pathRe = new RegExp( + '(\\d{4}-\\d{2}-\\d{2})' + escapeRegExp(fileSuffix) + '$' +) + +const error = (operation: string) => (error: any) => { + if (__DEV__) { + console.error(`DesktopFileTransport: ${operation}`, error) + } + return undefined +} + +/** + * A re-implementation of the winston-daily-rotate-file module + * + * winston-daily-rotate-file depends on moment.js which we've unshipped + * so we're using this instead. + * + * Please note that this is in no way a general purpose transport like + * winston-daily-rotate-file, it's highly specific to the needs of GitHub + * Desktop. + */ +export class DesktopFileTransport extends TransportStream { + private stream?: WriteStream + private logDirectory: string + + public constructor(opts: DesktopFileTransportOptions) { + const { logDirectory, ...rest } = opts + super(rest) + this.logDirectory = logDirectory + } + + public async log(info: any, callback: () => void) { + const path = getFilePath(this.logDirectory) + + if (this.stream === undefined || this.stream.path !== path) { + this.stream?.end() + this.stream = createWriteStream(path, { flags: 'a' }) + this.stream.on('error', error('stream error')) + + await pruneDirectory(this.logDirectory).catch(error('prune')) + } + + if (this.stream !== undefined) { + await write(this.stream, `${info[MESSAGE]}${EOL}`).catch(error('write')) + this.emit('logged', info) + } + + callback?.() + } + + public close(cb?: () => void) { + this.stream?.end(cb) + this.stream = undefined + } +} + +const write = promisify((s, c, cb) => s.write(c, cb)) +const getFilePrefix = (d = new Date()) => d.toISOString().split('T', 1)[0] +const getFilePath = (p: string) => join(p, `${getFilePrefix()}${fileSuffix}`) +const getLogFilesIn = (p: string) => + readdir(p, { withFileTypes: true }) + .then(entries => entries.filter(x => x.isFile() && pathRe.test(x.name))) + .catch(error('readdir')) + +const pruneDirectory = async (p: string) => { + const all = await getLogFilesIn(p) + + if (all && all.length > MaxRetainedLogFiles) { + const end = all.length - MaxRetainedLogFiles + 1 + const old = all.sort().slice(0, end) + + for (const f of old) { + await unlink(join(p, f.name)).catch(error('unlink')) + } + } +} diff --git a/app/src/main-process/exception-reporting.ts b/app/src/main-process/exception-reporting.ts new file mode 100644 index 0000000000..993fc6bb88 --- /dev/null +++ b/app/src/main-process/exception-reporting.ts @@ -0,0 +1,85 @@ +import { app, net } from 'electron' +import { getArchitecture } from '../lib/get-architecture' +import { getMainGUID } from '../lib/get-main-guid' + +const ErrorEndpoint = 'https://central.github.com/api/desktop/exception' +const NonFatalErrorEndpoint = + 'https://central.github.com/api/desktop-non-fatal/exception' + +let hasSentFatalError = false + +/** Report the error to Central. */ +export async function reportError( + error: Error, + extra?: { [key: string]: string }, + nonFatal?: boolean +) { + if (__DEV__) { + return + } + + // We never want to send more than one fatal error (i.e. crash) per + // application session. This guards against us ending up in a feedback loop + // where the act of reporting a crash triggers another unhandled exception + // which causes us to report a crash and so on and so forth. + if (nonFatal !== true) { + if (hasSentFatalError) { + return + } + hasSentFatalError = true + } + + const data = new Map() + + data.set('name', error.name) + data.set('message', error.message) + + if (error.stack) { + data.set('stack', error.stack) + } + + data.set('platform', process.platform) + data.set('architecture', getArchitecture(app)) + data.set('sha', __SHA__) + data.set('version', app.getVersion()) + data.set('guid', await getMainGUID()) + + if (extra) { + for (const key of Object.keys(extra)) { + data.set(key, extra[key]) + } + } + + const body = [...data.entries()] + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ) + .join('&') + + try { + await new Promise((resolve, reject) => { + const url = nonFatal ? NonFatalErrorEndpoint : ErrorEndpoint + const request = net.request({ method: 'POST', url }) + + request.setHeader('Content-Type', 'application/x-www-form-urlencoded') + + request.on('response', response => { + if (response.statusCode === 200) { + resolve() + } else { + reject( + `Got ${response.statusCode} - ${response.statusMessage} from central` + ) + } + }) + + request.on('error', reject) + + request.end(body) + }) + log.info('Error report submitted') + } catch (e) { + log.error('Failed submitting error report', error) + } +} diff --git a/app/src/main-process/ipc-main.ts b/app/src/main-process/ipc-main.ts new file mode 100644 index 0000000000..a68f4c99f5 --- /dev/null +++ b/app/src/main-process/ipc-main.ts @@ -0,0 +1,66 @@ +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 + * the channel name, and request parameters. This is the equivalent of + * using ipcMain.on. + */ +export function on( + channel: T, + listener: RequestChannelListener +) { + ipcMain.on(channel, safeListener(listener)) +} + +/** + * Subscribes to the specified IPC channel and provides strong typing of + * the channel name, and request parameters. This is the equivalent of + * using ipcMain.once + */ +export function once( + channel: T, + listener: RequestChannelListener +) { + ipcMain.once(channel, safeListener(listener)) +} + +/** + * Subscribes to the specified invokeable IPC channel and provides strong typing + * of the channel name, and request parameters. This is the equivalent of using + * ipcMain.handle. + */ +export function handle( + channel: T, + listener: RequestResponseChannelListener +) { + ipcMain.handle(channel, safeListener(listener)) +} + +function safeListener( + listener: (event: E, ...a: any) => R +) { + 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/ipc-webcontents.ts b/app/src/main-process/ipc-webcontents.ts new file mode 100644 index 0000000000..b28ea8d198 --- /dev/null +++ b/app/src/main-process/ipc-webcontents.ts @@ -0,0 +1,24 @@ +/* eslint-disable no-loosely-typed-webcontents-ipc */ + +import { WebContents } from 'electron' +import { RequestChannels } from '../lib/ipc-shared' + +/** + * Send a message to a renderer process via its webContents asynchronously. This + * is the equivalent of webContents.send except with strong typing guarantees. + */ +export function send( + webContents: WebContents, + channel: T, + ...args: Parameters +): void { + if (webContents.isDestroyed()) { + const msg = `failed to send on ${channel}, webContents was destroyed` + if (__DEV__) { + throw new Error(msg) + } + log.error(msg) + } else { + webContents.send(channel, ...args) + } +} diff --git a/app/src/main-process/log.ts b/app/src/main-process/log.ts new file mode 100644 index 0000000000..213280ef63 --- /dev/null +++ b/app/src/main-process/log.ts @@ -0,0 +1,89 @@ +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 { DesktopConsoleTransport } from './desktop-console-transport' +import memoizeOne from 'memoize-one' +import { mkdir } from 'fs/promises' +import { DesktopFileTransport } from './desktop-file-transport' + +/** + * Initializes winston and returns a subset of the available log level + * methods (debug, info, error). This method should only be called once + * during an application's lifetime. + * + * @param path The path where to write log files. + */ +function initializeWinston(path: string): winston.LogMethod { + const timestamp = () => new Date().toISOString() + + const fileLogger = new DesktopFileTransport({ + logDirectory: path, + level: 'info', + format: winston.format.printf( + ({ level, message }) => `${timestamp()} - ${level}: ${message}` + ), + }) + + // The file transport shouldn't emit anything but just in case it does we want + // a listener or else it'll bubble to an unhandled exception. + fileLogger.on('error', noop) + + const consoleLogger = new DesktopConsoleTransport({ + level: process.env.NODE_ENV === 'development' ? 'debug' : 'error', + }) + + winston.configure({ + transports: [consoleLogger, fileLogger], + format: winston.format.simple(), + }) + + return winston.log +} + +/** + * Initializes and configures winston (if necessary) to write to Electron's + * console as well as to disk. + * + * @returns a function reference which can be used to write log entries, + * this function is equivalent to that of winston.log in that + * it accepts a log level, a message and an optional callback + * for when the event has been written to all destinations. + */ +const getLogger = memoizeOne(async () => { + const logDirectory = getLogDirectoryPath() + await mkdir(logDirectory, { recursive: true }) + return initializeWinston(logDirectory) +}) + +/** + * Write the given log entry to all configured transports, + * see initializeWinston in logger.ts for more details about + * what transports we set up. + * + * Returns a promise that will never yield an error and which + * resolves when the log entry has been written to all transports + * or if the entry could not be written due to an error. + */ +export async function log(level: LogLevel, message: string) { + try { + const logger = await getLogger() + await new Promise((resolve, reject) => { + logger(level, message, error => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) + } catch (error) { + /** + * Welp. I guess we have to ignore this for now, we + * don't have any good mechanisms for reporting this. + * In the future we can discuss whether we should + * IPC to the renderer or dump it somewhere else + * but for now logging isn't a critical thing. + */ + } +} diff --git a/app/src/main-process/main.ts b/app/src/main-process/main.ts new file mode 100644 index 0000000000..b7728befaf --- /dev/null +++ b/app/src/main-process/main.ts @@ -0,0 +1,789 @@ +import '../lib/logging/main/install' + +import { + app, + Menu, + BrowserWindow, + shell, + session, + systemPreferences, + 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 { fatalError } from '../lib/fatal-error' + +import { log as writeLog } from './log' +import { UNSAFE_openDirectory } from './shell' +import { reportError } from './exception-reporting' +import { + enableSourceMaps, + withSourceMappedStack, +} from '../lib/source-map-support' +import { now } from './now' +import { showUncaughtException } from './show-uncaught-exception' +import { buildContextMenu } from './menu/build-context-menu' +import { OrderedWebRequest } from './ordered-webrequest' +import { installAuthenticatedImageFilter } from './authenticated-image-filter' +import { installAliveOriginFilter } from './alive-origin-filter' +import { installSameOriginFilter } from './same-origin-filter' +import * as ipcMain from './ipc-main' +import { + getArchitecture, + isAppRunningUnderARM64Translation, +} from '../lib/get-architecture' +import { buildSpellCheckMenu } from './menu/build-spell-check-menu' +import { getMainGUID, saveGUIDFile } from '../lib/get-main-guid' +import { + getNotificationsPermission, + requestNotificationsPermission, + showNotification, +} from 'desktop-notifications' +import { initializeDesktopNotifications } from './notifications' + +app.setAppLogsPath() +enableSourceMaps() + +let mainWindow: AppWindow | null = null + +const launchTime = now() + +let preventQuit = false +let readyTime: number | null = null + +type OnDidLoadFn = (window: AppWindow) => void +/** See the `onDidLoad` function. */ +let onDidLoadFns: Array | null = [] + +function handleUncaughtException(error: Error) { + preventQuit = true + + // If we haven't got a window we'll assume it's because + // we've just launched and haven't created it yet. + // It could also be because we're encountering an unhandled + // exception on shutdown but that's less likely and since + // this only affects the presentation of the crash dialog + // it's a safe assumption to make. + const isLaunchError = mainWindow === null + + if (mainWindow) { + mainWindow.destroy() + mainWindow = null + } + + showUncaughtException(isLaunchError, error) +} + +/** + * Calculates the number of seconds the app has been running + */ +function getUptimeInSeconds() { + return (now() - launchTime) / 1000 +} + +function getExtraErrorContext(): Record { + return { + uptime: getUptimeInSeconds().toFixed(3), + time: new Date().toString(), + } +} + +/** Extra argument for the protocol launcher on Windows */ +const protocolLauncherArg = '--protocol-launcher' + +const possibleProtocols = new Set(['x-github-client']) +if (__DEV__) { + possibleProtocols.add('x-github-desktop-dev-auth') +} else { + possibleProtocols.add('x-github-desktop-auth') +} +// Also support Desktop Classic's protocols. +if (__DARWIN__) { + possibleProtocols.add('github-mac') +} else if (__WIN32__) { + possibleProtocols.add('github-windows') +} + +// On Windows, in order to get notifications properly working for dev builds, +// we'll want to set the right App User Model ID from production builds. +if (__WIN32__ && __DEV__) { + app.setAppUserModelId('com.squirrel.GitHubDesktop.GitHubDesktop') +} + +app.on('window-all-closed', () => { + // If we don't subscribe to this event and all windows are closed, the default + // behavior is to quit the app. We don't want that though, we control that + // behavior through the mainWindow onClose event such that on macOS we only + // hide the main window when a user attempts to close it. + // + // If we don't subscribe to this and change the default behavior we break + // the crash process window which is shown after the main window is closed. +}) + +process.on('uncaughtException', (error: Error) => { + error = withSourceMappedStack(error) + reportError(error, getExtraErrorContext()) + handleUncaughtException(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) + } +} + +initializeDesktopNotifications() + +function handleAppURL(url: string) { + log.info('Processing protocol url') + const action = parseAppURL(url) + 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.sendURLAction(action) + }) +} + +let isDuplicateInstance = false +// If we're handling a Squirrel event we don't want to enforce single instance. +// We want to let the updated instance launch and do its work. It will then quit +// once it's done. +if (!handlingSquirrelEvent) { + const gotSingleInstanceLock = app.requestSingleInstanceLock() + isDuplicateInstance = !gotSingleInstanceLock + + app.on('second-instance', (event, args, workingDirectory) => { + // Someone tried to run a second instance, we should focus our window. + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + + if (!mainWindow.isVisible()) { + mainWindow.show() + } + + mainWindow.focus() + } + + handlePossibleProtocolLauncherArgs(args) + }) + + if (isDuplicateInstance) { + app.quit() + } +} + +if (shellNeedsPatching(process)) { + updateEnvironmentForProcess() +} + +app.on('will-finish-launching', () => { + // macOS only + app.on('open-url', (event, url) => { + event.preventDefault() + handleAppURL(url) + }) +}) + +if (__DARWIN__) { + app.on('open-file', async (event, path) => { + event.preventDefault() + + log.info(`[main] a path to ${path} was triggered`) + + Fs.stat(path, (err, stats) => { + if (err) { + log.error(`Unable to open path '${path}' in Desktop`, err) + return + } + + if (stats.isFile()) { + log.warn( + `A file at ${path} was dropped onto Desktop, but it can only handle folders. Ignoring this action.` + ) + return + } + + handleAppURL( + `x-github-client://openLocalRepo/${encodeURIComponent(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}`) + + 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 + } + }) + + 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]) + } +} + +/** + * Wrapper around app.setAsDefaultProtocolClient that adds our + * custom prefix command line switches on Windows. + */ +function setAsDefaultProtocolClient(protocol: string) { + if (__WIN32__) { + app.setAsDefaultProtocolClient(protocol, process.execPath, [ + protocolLauncherArg, + ]) + } else { + app.setAsDefaultProtocolClient(protocol) + } +} + +if (process.env.GITHUB_DESKTOP_DISABLE_HARDWARE_ACCELERATION) { + log.info( + `GITHUB_DESKTOP_DISABLE_HARDWARE_ACCELERATION environment variable set, disabling hardware acceleration` + ) + app.disableHardwareAcceleration() +} + +app.on('ready', () => { + if (isDuplicateInstance || handlingSquirrelEvent) { + return + } + + readyTime = now() - launchTime + + possibleProtocols.forEach(protocol => setAsDefaultProtocolClient(protocol)) + + createWindow() + + const orderedWebRequest = new OrderedWebRequest( + session.defaultSession.webRequest + ) + + // Ensures auth-related headers won't traverse http redirects to hosts + // on different origins than the originating request. + installSameOriginFilter(orderedWebRequest) + + // Ensures Alive websocket sessions are initiated with an acceptable Origin + installAliveOriginFilter(orderedWebRequest) + + // Adds an authorization header for requests of avatars on GHES and private + // repo assets + const updateAccounts = installAuthenticatedImageFilter(orderedWebRequest) + + Menu.setApplicationMenu( + buildDefaultMenu({ + selectedShell: null, + selectedExternalEditor: null, + askForConfirmationOnRepositoryRemoval: false, + askForConfirmationOnForcePush: false, + }) + ) + + ipcMain.on('update-accounts', (_, accounts) => updateAccounts(accounts)) + + ipcMain.on('update-preferred-app-menu-item-labels', (_, labels) => { + // The current application menu is mutable and we frequently + // change whether particular items are enabled or not through + // the update-menu-state IPC event. This menu that we're creating + // now will have all the items enabled so we need to merge the + // current state with the new in order to not get a temporary + // race conditions where menu items which shouldn't be enabled + // are. + const newMenu = buildDefaultMenu(labels) + + const currentMenu = Menu.getApplicationMenu() + + // This shouldn't happen but whenever one says that it does + // so here's the escape hatch when we can't merge the current + // menu with the new one; we just use the new one. + if (currentMenu === null) { + // https://github.com/electron/electron/issues/2717 + Menu.setApplicationMenu(newMenu) + + if (mainWindow !== null) { + mainWindow.sendAppMenu() + } + + return + } + + // It's possible that after rebuilding the menu we'll end up + // with the exact same structural menu as we had before so we + // keep track of whether anything has actually changed in order + // to avoid updating the global menu and telling the renderer + // about it. + let menuHasChanged = false + + for (const newItem of getAllMenuItems(newMenu)) { + // Our menu items always have ids and Electron.MenuItem takes on whatever + // properties was defined on the MenuItemOptions template used to create it + // but doesn't surface those in the type declaration. + const id = (newItem as any).id + + if (!id) { + continue + } + + const currentItem = currentMenu.getMenuItemById(id) + + // Unfortunately the type information for getMenuItemById + // doesn't specify if it'll return null or undefined when + // the item doesn't exist so we'll do a falsy check here. + if (!currentItem) { + menuHasChanged = true + } else { + if (currentItem.label !== newItem.label) { + menuHasChanged = true + } + + // Copy the enabled property from the existing menu + // item since it'll be the most recent reflection of + // what the renderer wants. + if (currentItem.enabled !== newItem.enabled) { + newItem.enabled = currentItem.enabled + menuHasChanged = true + } + } + } + + if (menuHasChanged && mainWindow) { + // https://github.com/electron/electron/issues/2717 + Menu.setApplicationMenu(newMenu) + mainWindow.sendAppMenu() + } + }) + + /** + * An event sent by the renderer asking that the menu item with the given id + * is executed (ie clicked). + */ + ipcMain.on('execute-menu-item-by-id', (event, id) => { + const currentMenu = Menu.getApplicationMenu() + + if (currentMenu === null) { + return + } + + const menuItem = currentMenu.getMenuItemById(id) + if (menuItem) { + const window = BrowserWindow.fromWebContents(event.sender) || undefined + const fakeEvent = { preventDefault: () => {}, sender: event.sender } + menuItem.click(fakeEvent, window, event.sender) + } + }) + + ipcMain.on('update-menu-state', (_, items) => { + let sendMenuChangedEvent = false + + const currentMenu = Menu.getApplicationMenu() + + if (currentMenu === null) { + log.debug(`unable to get current menu, bailing out...`) + return + } + + for (const item of items) { + const { id, state } = item + + const menuItem = currentMenu.getMenuItemById(id) + + if (menuItem) { + // Only send the updated app menu when the state actually changes + // or we might end up introducing a never ending loop between + // the renderer and the main process + if (state.enabled !== undefined && menuItem.enabled !== state.enabled) { + menuItem.enabled = state.enabled + sendMenuChangedEvent = true + } + } else { + fatalError(`Unknown menu id: ${id}`) + } + } + + if (sendMenuChangedEvent && mainWindow) { + Menu.setApplicationMenu(currentMenu) + mainWindow.sendAppMenu() + } + }) + + /** + * Handle the action to show a contextual menu. + * + * It responds an array of indices that maps to the path to reach + * the menu (or submenu) item that was clicked or null if the menu was closed + * without clicking on any item or the item click was handled by the main + * process as opposed to the renderer. + */ + ipcMain.handle('show-contextual-menu', (event, items, addSpellCheckMenu) => { + return new Promise(async resolve => { + const window = BrowserWindow.fromWebContents(event.sender) || undefined + + const spellCheckMenuItems = addSpellCheckMenu + ? await buildSpellCheckMenu(window) + : undefined + + const menu = buildContextMenu( + items, + indices => resolve(indices), + spellCheckMenuItems + ) + + menu.popup({ window, callback: () => resolve(null) }) + }) + }) + + ipcMain.handle('check-for-updates', async (_, url) => + mainWindow?.checkForUpdates(url) + ) + + ipcMain.on('quit-and-install-updates', () => + mainWindow?.quitAndInstallUpdate() + ) + + ipcMain.on('quit-app', () => app.quit()) + + ipcMain.on('minimize-window', () => mainWindow?.minimizeWindow()) + + ipcMain.on('maximize-window', () => mainWindow?.maximizeWindow()) + + ipcMain.on('unmaximize-window', () => mainWindow?.unmaximizeWindow()) + + ipcMain.on('close-window', () => mainWindow?.closeWindow()) + + ipcMain.handle( + 'is-window-maximized', + async () => mainWindow?.isMaximized() ?? false + ) + + ipcMain.handle('get-apple-action-on-double-click', async () => + systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string') + ) + + ipcMain.handle('get-current-window-state', async () => + mainWindow?.getCurrentWindowState() + ) + + ipcMain.handle('get-current-window-zoom-factor', async () => + mainWindow?.getCurrentWindowZoomFactor() + ) + + ipcMain.on('set-window-zoom-factor', (_, zoomFactor: number) => + mainWindow?.setWindowZoomFactor(zoomFactor) + ) + + /** + * An event sent by the renderer asking for a copy of the current + * application menu. + */ + ipcMain.on('get-app-menu', () => mainWindow?.sendAppMenu()) + + ipcMain.on('show-certificate-trust-dialog', (_, certificate, message) => { + // This API is only implemented for macOS and Windows right now. + if (__DARWIN__ || __WIN32__) { + onDidLoad(window => { + window.showCertificateTrustDialog(certificate, message) + }) + } + }) + + ipcMain.on('log', (_, level, message) => writeLog(level, message)) + + ipcMain.on('uncaught-exception', (_, error) => handleUncaughtException(error)) + + ipcMain.on('send-error-report', (_, error, extra, nonFatal) => { + reportError(error, { ...getExtraErrorContext(), ...extra }, nonFatal) + }) + + ipcMain.handle('open-external', async (_, path: string) => { + const pathLowerCase = path.toLowerCase() + if ( + pathLowerCase.startsWith('http://') || + pathLowerCase.startsWith('https://') + ) { + log.info(`opening in browser: ${path}`) + } + + try { + await shell.openExternal(path) + return true + } catch (e) { + log.error(`Call to openExternal failed: '${e}'`) + return false + } + }) + + /** + * An event sent by the renderer asking for the app's architecture + */ + ipcMain.handle('get-path', async (_, path) => app.getPath(path)) + + /** + * An event sent by the renderer asking for the app's architecture + */ + ipcMain.handle('get-app-architecture', async () => getArchitecture(app)) + + /** + * An event sent by the renderer asking for the app's path + */ + ipcMain.handle('get-app-path', async () => app.getAppPath()) + + /** + * An event sent by the renderer asking for whether the app is running under + * rosetta translation + */ + ipcMain.handle('is-running-under-arm64-translation', async () => + isAppRunningUnderARM64Translation(app) + ) + + /** + * An event sent by the renderer asking to move the app to the application + * folder + */ + ipcMain.handle('move-to-applications-folder', async () => { + app.moveToApplicationsFolder?.() + }) + + ipcMain.handle('move-to-trash', (_, path) => shell.trashItem(path)) + ipcMain.handle('show-item-in-folder', async (_, path) => + shell.showItemInFolder(path) + ) + + ipcMain.on('unsafe-open-directory', async (_, path) => + UNSAFE_openDirectory(path) + ) + + /** An event sent by the renderer asking to select all of the window's contents */ + ipcMain.on('select-all-window-contents', () => + mainWindow?.selectAllWindowContents() + ) + + /** + * An event sent by the renderer asking whether the Desktop is in the + * applications folder + * + * Note: This will return null when not running on Darwin + */ + ipcMain.handle('is-in-application-folder', async () => { + // Contrary to what the types tell you the `isInApplicationsFolder` will be undefined + // when not on macOS + return app.isInApplicationsFolder?.() ?? null + }) + + /** + * Handle action to resolve proxy + */ + ipcMain.handle('resolve-proxy', async (_, url: string) => { + return session.defaultSession.resolveProxy(url) + }) + + /** + * An event sent by the renderer asking to show the save dialog + * + * Returns null if filepath is undefined or if dialog is canceled. + */ + ipcMain.handle( + 'show-save-dialog', + async (_, options) => mainWindow?.showSaveDialog(options) ?? null + ) + + /** + * An event sent by the renderer asking to show the open dialog + */ + ipcMain.handle( + 'show-open-dialog', + async (_, options) => mainWindow?.showOpenDialog(options) ?? null + ) + + /** + * An event sent by the renderer asking obtain whether the window is focused + */ + ipcMain.handle( + 'is-window-focused', + async () => mainWindow?.isFocused() ?? false + ) + + /** An event sent by the renderer asking to focus the main window. */ + ipcMain.on('focus-window', () => { + mainWindow?.focus() + }) + + ipcMain.on('set-native-theme-source', (_, themeName) => { + nativeTheme.themeSource = themeName + }) + + ipcMain.handle( + 'should-use-dark-colors', + async () => nativeTheme.shouldUseDarkColors + ) + + ipcMain.handle('get-guid', () => getMainGUID()) + + ipcMain.handle('save-guid', (_, guid) => saveGUIDFile(guid)) + + ipcMain.handle('show-notification', async (_, title, body, userInfo) => + showNotification(title, body, userInfo) + ) + + ipcMain.handle('get-notifications-permission', async () => + getNotificationsPermission() + ) + ipcMain.handle('request-notifications-permission', async () => + requestNotificationsPermission() + ) +}) + +app.on('activate', () => { + onDidLoad(window => { + window.show() + }) +}) + +app.on('web-contents-created', (event, contents) => { + 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) => { + event.preventDefault() + log.warn(`Prevented navigation to: ${url}`) + }) +}) + +app.on( + 'certificate-error', + (event, webContents, url, error, certificate, callback) => { + callback(false) + + onDidLoad(window => { + window.sendCertificateError(certificate, error, url) + }) + } +) + +function createWindow() { + const window = new AppWindow() + + if (__DEV__) { + const { + default: installExtension, + REACT_DEVELOPER_TOOLS, + } = require('electron-devtools-installer') + + require('electron-debug')({ showDevTools: true }) + + const ChromeLens = { + id: 'idikgljglpfilbhaboonnpnnincjhjkd', + electron: '>=1.2.1', + } + + const axeDevTools = { + id: 'lhdoppojpmngadmnindnejefpokejbdd', + electron: '>=1.2.1', + Permissions: ['tabs', 'debugger'], + } + + const extensions = [REACT_DEVELOPER_TOOLS, ChromeLens, axeDevTools] + + for (const extension of extensions) { + try { + installExtension(extension, { + loadExtensionOptions: { allowFileAccess: true }, + }) + } catch (e) {} + } + } + + window.onClosed(() => { + mainWindow = null + if (!__DARWIN__ && !preventQuit) { + app.quit() + } + }) + + window.onDidLoad(() => { + window.show() + window.sendLaunchTimingStats({ + mainReadyTime: readyTime!, + loadTime: window.loadTime!, + rendererReadyTime: window.rendererReadyTime!, + }) + + const fns = onDidLoadFns! + onDidLoadFns = null + for (const fn of fns) { + fn(window) + } + }) + + window.load() + + mainWindow = window +} + +/** + * Register a function to be called once the window has been loaded. If the + * window has already been loaded, the function will be called immediately. + */ +function onDidLoad(fn: OnDidLoadFn) { + if (onDidLoadFns) { + onDidLoadFns.push(fn) + } else { + if (mainWindow) { + fn(mainWindow) + } + } +} diff --git a/app/src/main-process/menu/build-context-menu.ts b/app/src/main-process/menu/build-context-menu.ts new file mode 100644 index 0000000000..0a673a277e --- /dev/null +++ b/app/src/main-process/menu/build-context-menu.ts @@ -0,0 +1,98 @@ +import { ISerializableMenuItem } from '../../lib/menu-item' +import { Menu, MenuItem } from 'electron' + +/** + * Gets a value indicating whether or not two roles are considered + * equal using a case-insensitive comparison. + */ +function roleEquals(x: string | undefined, y: string | undefined) { + return (x ? x.toLowerCase() : x) === (y ? y.toLowerCase() : y) +} + +/** + * Get platform-specific edit menu items by leveraging Electron's + * built-in editMenu role. + */ +function getEditMenuItems(): ReadonlyArray { + const menu = Menu.buildFromTemplate([{ role: 'editMenu' }]).items[0] + + // Electron is violating its contract if there's no subMenu but + // we'd rather just ignore it than crash. It's not the end of + // the world if we don't have edit menu items. + const items = menu && menu.submenu ? menu.submenu.items : [] + + // We don't use styled inputs anywhere at the moment + // so let's skip this for now and when/if we do we + // can make it configurable from the callee + return items.filter(x => !roleEquals(x.role, 'pasteandmatchstyle')) +} + +/** + * Create an Electron menu object for use in a context menu based on + * a template provided by the renderer. + * + * If the template contains a menu item with the role 'editMenu' the + * platform standard edit menu items will be inserted at the position + * of the 'editMenu' template. + * + * @param template One or more menu item templates as passed from + * the renderer. + * @param onClick A callback function for when one of the menu items + * constructed from the template is clicked. Callback + * is passed an array of indices corresponding to the + * positions of each of the parent menus of the clicked + * item (so when clicking a top-level menu item an array + * with a single element will be passed). Note that the + * callback will not be called when expanded/automatically + * created edit menu items are clicked. + */ +export function buildContextMenu( + template: ReadonlyArray, + onClick: (indices: ReadonlyArray) => void, + spellCheckMenuItems?: ReadonlyArray +): Menu { + const menu = buildRecursiveContextMenu(template, onClick) + + if (spellCheckMenuItems === undefined) { + return menu + } + + for (const spellCheckMenuItem of spellCheckMenuItems) { + menu.append(spellCheckMenuItem) + } + + return menu +} + +function buildRecursiveContextMenu( + menuItems: ReadonlyArray, + actionFn: (indices: ReadonlyArray) => void, + currentIndices: ReadonlyArray = [] +): Menu { + const menu = new Menu() + + for (const [idx, item] of menuItems.entries()) { + if (roleEquals(item.role, 'editmenu')) { + for (const editItem of getEditMenuItems()) { + menu.append(editItem) + } + } else { + const indices = [...currentIndices, idx] + + menu.append( + new MenuItem({ + label: item.label, + type: item.type, + enabled: item.enabled, + role: item.role, + click: () => actionFn(indices), + submenu: item.submenu + ? buildRecursiveContextMenu(item.submenu, actionFn, indices) + : undefined, + }) + ) + } + } + + return menu +} diff --git a/app/src/main-process/menu/build-default-menu.ts b/app/src/main-process/menu/build-default-menu.ts new file mode 100644 index 0000000000..df6d4b574c --- /dev/null +++ b/app/src/main-process/menu/build-default-menu.ts @@ -0,0 +1,720 @@ +import { Menu, shell, app, BrowserWindow } from 'electron' +import { ensureItemIds } from './ensure-item-ids' +import { MenuEvent } from './menu-event' +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 * as ipcWebContents from '../ipc-webcontents' +import { mkdir } from 'fs/promises' + +const platformDefaultShell = __WIN32__ ? 'Command Prompt' : 'Terminal' +const createPullRequestLabel = __DARWIN__ + ? 'Create Pull Request' + : 'Create &pull request' +const showPullRequestLabel = __DARWIN__ + ? 'View Pull Request on GitHub' + : 'View &pull request on GitHub' +const defaultBranchNameValue = __DARWIN__ ? 'Default Branch' : 'default branch' +const confirmRepositoryRemovalLabel = __DARWIN__ ? 'Remove…' : '&Remove…' +const repositoryRemovalLabel = __DARWIN__ ? 'Remove' : '&Remove' +const confirmStashAllChangesLabel = __DARWIN__ + ? 'Stash All Changes…' + : '&Stash all changes…' +const stashAllChangesLabel = __DARWIN__ + ? 'Stash All Changes' + : '&Stash all changes' + +enum ZoomDirection { + Reset, + In, + Out, +} + +export function buildDefaultMenu({ + selectedExternalEditor, + selectedShell, + askForConfirmationOnForcePush, + askForConfirmationOnRepositoryRemoval, + hasCurrentPullRequest = false, + contributionTargetDefaultBranch = defaultBranchNameValue, + isForcePushForCurrentRepository = false, + isStashedChangesVisible = false, + askForConfirmationWhenStashingAllChanges = true, +}: MenuLabelsEvent): Electron.Menu { + contributionTargetDefaultBranch = truncateWithEllipsis( + contributionTargetDefaultBranch, + 25 + ) + + const removeRepoLabel = askForConfirmationOnRepositoryRemoval + ? confirmRepositoryRemovalLabel + : repositoryRemovalLabel + + const pullRequestLabel = hasCurrentPullRequest + ? showPullRequestLabel + : createPullRequestLabel + + const template = new Array() + const separator: Electron.MenuItemConstructorOptions = { type: 'separator' } + + if (__DARWIN__) { + template.push({ + label: 'GitHub Desktop', + submenu: [ + { + label: 'About GitHub Desktop', + click: emit('show-about'), + id: 'about', + }, + separator, + { + label: 'Settings…', + id: 'preferences', + accelerator: 'CmdOrCtrl+,', + click: emit('show-preferences'), + }, + separator, + { + label: 'Install Command Line Tool…', + id: 'install-cli', + click: emit('install-cli'), + }, + separator, + { + role: 'services', + submenu: [], + }, + separator, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + separator, + { role: 'quit' }, + ], + }) + } + + const fileMenu: Electron.MenuItemConstructorOptions = { + label: __DARWIN__ ? 'File' : '&File', + submenu: [ + { + label: __DARWIN__ ? 'New Repository…' : 'New &repository…', + id: 'new-repository', + click: emit('create-repository'), + accelerator: 'CmdOrCtrl+N', + }, + separator, + { + label: __DARWIN__ ? 'Add Local Repository…' : 'Add &local repository…', + id: 'add-local-repository', + accelerator: 'CmdOrCtrl+O', + click: emit('add-local-repository'), + }, + { + label: __DARWIN__ ? 'Clone Repository…' : 'Clo&ne repository…', + id: 'clone-repository', + accelerator: 'CmdOrCtrl+Shift+O', + click: emit('clone-repository'), + }, + ], + } + + if (!__DARWIN__) { + const fileItems = fileMenu.submenu as Electron.MenuItemConstructorOptions[] + + fileItems.push( + separator, + { + label: '&Options…', + id: 'preferences', + accelerator: 'CmdOrCtrl+,', + click: emit('show-preferences'), + }, + separator, + { + role: 'quit', + label: 'E&xit', + accelerator: 'Alt+F4', + } + ) + } + + template.push(fileMenu) + + template.push({ + label: __DARWIN__ ? 'Edit' : '&Edit', + submenu: [ + { role: 'undo', label: __DARWIN__ ? 'Undo' : '&Undo' }, + { role: 'redo', label: __DARWIN__ ? 'Redo' : '&Redo' }, + separator, + { role: 'cut', label: __DARWIN__ ? 'Cut' : 'Cu&t' }, + { role: 'copy', label: __DARWIN__ ? 'Copy' : '&Copy' }, + { role: 'paste', label: __DARWIN__ ? 'Paste' : '&Paste' }, + { + label: __DARWIN__ ? 'Select All' : 'Select &all', + accelerator: 'CmdOrCtrl+A', + click: emit('select-all'), + }, + separator, + { + id: 'find', + label: __DARWIN__ ? 'Find' : '&Find', + accelerator: 'CmdOrCtrl+F', + click: emit('find-text'), + }, + ], + }) + + template.push({ + label: __DARWIN__ ? 'View' : '&View', + submenu: [ + { + label: __DARWIN__ ? 'Show Changes' : '&Changes', + id: 'show-changes', + accelerator: 'CmdOrCtrl+1', + click: emit('show-changes'), + }, + { + label: __DARWIN__ ? 'Show History' : '&History', + id: 'show-history', + accelerator: 'CmdOrCtrl+2', + click: emit('show-history'), + }, + { + label: __DARWIN__ ? 'Show Repository List' : 'Repository &list', + id: 'show-repository-list', + accelerator: 'CmdOrCtrl+T', + click: emit('choose-repository'), + }, + { + label: __DARWIN__ ? 'Show Branches List' : '&Branches list', + id: 'show-branches-list', + accelerator: 'CmdOrCtrl+B', + click: emit('show-branches'), + }, + separator, + { + label: __DARWIN__ ? 'Go to Summary' : 'Go to &Summary', + id: 'go-to-commit-message', + accelerator: 'CmdOrCtrl+G', + click: emit('go-to-commit-message'), + }, + { + label: getStashedChangesLabel(isStashedChangesVisible), + id: 'toggle-stashed-changes', + accelerator: 'Ctrl+H', + click: isStashedChangesVisible + ? emit('hide-stashed-changes') + : emit('show-stashed-changes'), + }, + { + label: __DARWIN__ ? 'Toggle Full Screen' : 'Toggle &full screen', + role: 'togglefullscreen', + }, + separator, + { + label: __DARWIN__ ? 'Reset Zoom' : 'Reset zoom', + accelerator: 'CmdOrCtrl+0', + click: zoom(ZoomDirection.Reset), + }, + { + label: __DARWIN__ ? 'Zoom In' : 'Zoom in', + accelerator: 'CmdOrCtrl+=', + click: zoom(ZoomDirection.In), + }, + { + label: __DARWIN__ ? 'Zoom Out' : 'Zoom out', + 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', + id: 'reload-window', + // Ctrl+Alt is interpreted as AltGr on international keyboards and this + // can clash with other shortcuts. We should always use Ctrl+Shift for + // 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) { + focusedWindow.reload() + } + }, + visible: __RELEASE_CHANNEL__ === 'development', + }, + { + id: 'show-devtools', + label: __DARWIN__ + ? 'Toggle Developer Tools' + : '&Toggle developer tools', + accelerator: (() => { + return __DARWIN__ ? 'Alt+Command+I' : 'Ctrl+Shift+I' + })(), + click(item: any, focusedWindow: Electron.BrowserWindow | undefined) { + if (focusedWindow) { + focusedWindow.webContents.toggleDevTools() + } + }, + }, + ], + }) + + const pushLabel = getPushLabel( + isForcePushForCurrentRepository, + askForConfirmationOnForcePush + ) + + const pushEventType = isForcePushForCurrentRepository ? 'force-push' : 'push' + + template.push({ + label: __DARWIN__ ? 'Repository' : '&Repository', + id: 'repository', + submenu: [ + { + id: 'push', + label: pushLabel, + accelerator: 'CmdOrCtrl+P', + click: emit(pushEventType), + }, + { + id: 'pull', + label: __DARWIN__ ? 'Pull' : 'Pu&ll', + 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', + accelerator: 'CmdOrCtrl+Backspace', + click: emit('remove-repository'), + }, + separator, + { + id: 'view-repository-on-github', + label: __DARWIN__ ? 'View on GitHub' : '&View on GitHub', + accelerator: 'CmdOrCtrl+Shift+G', + click: emit('view-repository-on-github'), + }, + { + label: __DARWIN__ + ? `Open in ${selectedShell ?? platformDefaultShell}` + : `O&pen in ${selectedShell ?? platformDefaultShell}`, + id: 'open-in-shell', + accelerator: 'Ctrl+`', + click: emit('open-in-shell'), + }, + { + label: __DARWIN__ + ? 'Show in Finder' + : __WIN32__ + ? 'Show in E&xplorer' + : 'Show in your File Manager', + id: 'open-working-directory', + accelerator: 'CmdOrCtrl+Shift+F', + click: emit('open-working-directory'), + }, + { + label: __DARWIN__ + ? `Open in ${selectedExternalEditor ?? 'External Editor'}` + : `&Open in ${selectedExternalEditor ?? 'external editor'}`, + id: 'open-external-editor', + accelerator: 'CmdOrCtrl+Shift+A', + click: emit('open-external-editor'), + }, + separator, + { + id: 'create-issue-in-repository-on-github', + label: __DARWIN__ + ? 'Create Issue on GitHub' + : 'Create &issue on GitHub', + accelerator: 'CmdOrCtrl+I', + click: emit('create-issue-in-repository-on-github'), + }, + separator, + { + label: __DARWIN__ ? 'Repository Settings…' : 'Repository &settings…', + id: 'show-repository-settings', + click: emit('show-repository-settings'), + }, + ], + }) + + const branchSubmenu = [ + { + label: __DARWIN__ ? 'New Branch…' : 'New &branch…', + id: 'create-branch', + accelerator: 'CmdOrCtrl+Shift+N', + click: emit('create-branch'), + }, + { + label: __DARWIN__ ? 'Rename…' : '&Rename…', + id: 'rename-branch', + accelerator: 'CmdOrCtrl+Shift+R', + click: emit('rename-branch'), + }, + { + label: __DARWIN__ ? 'Delete…' : '&Delete…', + id: 'delete-branch', + accelerator: 'CmdOrCtrl+Shift+D', + click: emit('delete-branch'), + }, + separator, + { + label: __DARWIN__ ? 'Discard All Changes…' : 'Discard all changes…', + id: 'discard-all-changes', + accelerator: 'CmdOrCtrl+Shift+Backspace', + click: emit('discard-all-changes'), + }, + { + label: askForConfirmationWhenStashingAllChanges + ? confirmStashAllChangesLabel + : stashAllChangesLabel, + id: 'stash-all-changes', + accelerator: 'CmdOrCtrl+Shift+S', + click: emit('stash-all-changes'), + }, + separator, + { + label: __DARWIN__ + ? `Update from ${contributionTargetDefaultBranch}` + : `&Update from ${contributionTargetDefaultBranch}`, + id: 'update-branch-with-contribution-target-branch', + accelerator: 'CmdOrCtrl+Shift+U', + click: emit('update-branch-with-contribution-target-branch'), + }, + { + label: __DARWIN__ ? 'Compare to Branch' : '&Compare to branch', + id: 'compare-to-branch', + accelerator: 'CmdOrCtrl+Shift+B', + click: emit('compare-to-branch'), + }, + { + label: __DARWIN__ + ? 'Merge into Current Branch…' + : '&Merge into current branch…', + id: 'merge-branch', + accelerator: 'CmdOrCtrl+Shift+M', + click: emit('merge-branch'), + }, + { + 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'), + }, + { + label: __DARWIN__ ? 'Rebase Current Branch…' : 'R&ebase current branch…', + id: 'rebase-branch', + accelerator: 'CmdOrCtrl+Shift+E', + click: emit('rebase-branch'), + }, + separator, + { + label: __DARWIN__ ? 'Compare on GitHub' : 'Compare on &GitHub', + id: 'compare-on-github', + accelerator: 'CmdOrCtrl+Shift+C', + click: emit('compare-on-github'), + }, + { + label: __DARWIN__ ? 'View Branch on GitHub' : 'View branch on GitHub', + id: 'branch-on-github', + accelerator: 'CmdOrCtrl+Alt+B', + click: emit('branch-on-github'), + }, + ] + + 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', + id: 'branch', + submenu: branchSubmenu, + }) + + if (__DARWIN__) { + template.push({ + role: 'window', + submenu: [ + { role: 'minimize' }, + { role: 'zoom' }, + { role: 'close' }, + separator, + { role: 'front' }, + ], + }) + } + + const submitIssueItem: Electron.MenuItemConstructorOptions = { + label: __DARWIN__ ? 'Report Issue…' : 'Report issue…', + click() { + shell + .openExternal('https://github.com/desktop/desktop/issues/new/choose') + .catch(err => log.error('Failed opening issue creation page', err)) + }, + } + + const contactSupportItem: Electron.MenuItemConstructorOptions = { + label: __DARWIN__ ? 'Contact GitHub Support…' : '&Contact GitHub support…', + click() { + shell + .openExternal( + `https://github.com/contact?from_desktop_app=1&app_version=${app.getVersion()}` + ) + .catch(err => log.error('Failed opening contact support page', err)) + }, + } + + const showUserGuides: Electron.MenuItemConstructorOptions = { + label: 'Show User Guides', + click() { + shell + .openExternal('https://docs.github.com/en/desktop') + .catch(err => log.error('Failed opening user guides page', err)) + }, + } + + const showKeyboardShortcuts: Electron.MenuItemConstructorOptions = { + label: __DARWIN__ ? 'Show Keyboard Shortcuts' : 'Show keyboard shortcuts', + click() { + shell + .openExternal( + 'https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/overview/keyboard-shortcuts' + ) + .catch(err => log.error('Failed opening keyboard shortcuts page', err)) + }, + } + + const showLogsLabel = __DARWIN__ + ? 'Show Logs in Finder' + : __WIN32__ + ? 'S&how logs in Explorer' + : 'S&how logs in your File Manager' + + const showLogsItem: Electron.MenuItemConstructorOptions = { + label: showLogsLabel, + click() { + const logPath = getLogDirectoryPath() + mkdir(logPath, { recursive: true }) + .then(() => UNSAFE_openDirectory(logPath)) + .catch(err => log.error('Failed opening logs directory', err)) + }, + } + + const helpItems = [ + submitIssueItem, + contactSupportItem, + showUserGuides, + showKeyboardShortcuts, + 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: 'Show App Error', + click: emit('show-app-error'), + }, + ], + }, + { + 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'), + }) + } + + if (__DARWIN__) { + template.push({ + role: 'help', + submenu: helpItems, + }) + } else { + template.push({ + label: '&Help', + submenu: [ + ...helpItems, + separator, + { + label: '&About GitHub Desktop', + click: emit('show-about'), + id: 'about', + }, + ], + }) + } + + ensureItemIds(template) + + return Menu.buildFromTemplate(template) +} + +function getPushLabel( + isForcePushForCurrentRepository: boolean, + askForConfirmationOnForcePush: boolean +): string { + if (!isForcePushForCurrentRepository) { + return __DARWIN__ ? 'Push' : 'P&ush' + } + + if (askForConfirmationOnForcePush) { + return __DARWIN__ ? 'Force Push…' : 'Force P&ush…' + } + + return __DARWIN__ ? 'Force Push' : 'Force P&ush' +} + +function getStashedChangesLabel(isStashedChangesVisible: boolean): string { + if (isStashedChangesVisible) { + return __DARWIN__ ? 'Hide Stashed Changes' : 'H&ide stashed changes' + } + + return __DARWIN__ ? 'Show Stashed Changes' : 'Sho&w stashed changes' +} + +type ClickHandler = ( + menuItem: Electron.MenuItem, + browserWindow: Electron.BrowserWindow | undefined, + event: Electron.KeyboardEvent +) => void + +/** + * Utility function returning a Click event handler which, when invoked, emits + * the provided menu event over IPC. + */ +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] + if (window !== undefined) { + ipcWebContents.send(window.webContents, 'menu-event', name) + } + } +} + +/** The zoom steps that we support, these factors must sorted */ +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() + +/** + * Returns the element in the array that's closest to the value parameter. Note + * that this function will throw if passed an empty array. + */ +function findClosestValue(arr: Array, value: number) { + return arr.reduce((previous, current) => { + return Math.abs(current - value) < Math.abs(previous - value) + ? current + : previous + }) +} + +/** + * Figure out the next zoom level for the given direction and alert the renderer + * about a change in zoom factor if necessary. + */ +function zoom(direction: ZoomDirection): ClickHandler { + return (menuItem, window) => { + if (!window) { + return + } + + const { webContents } = window + + if (direction === ZoomDirection.Reset) { + webContents.zoomFactor = 1 + ipcWebContents.send(webContents, 'zoom-factor-changed', 1) + } else { + const rawZoom = webContents.zoomFactor + const zoomFactors = + direction === ZoomDirection.In ? ZoomInFactors : ZoomOutFactors + + // So the values that we get from zoomFactor property are floating point + // precision numbers from chromium, that don't always round nicely, so + // we'll have to do a little trick to figure out which of our supported + // zoom factors the value is referring to. + const currentZoom = findClosestValue(zoomFactors, rawZoom) + + const nextZoomLevel = zoomFactors.find(f => + direction === ZoomDirection.In ? f > currentZoom : f < currentZoom + ) + + // If we couldn't find a zoom level (likely due to manual manipulation + // of the zoom factor in devtools) we'll just snap to the closest valid + // factor we've got. + const newZoom = nextZoomLevel === undefined ? currentZoom : nextZoomLevel + + webContents.zoomFactor = newZoom + ipcWebContents.send(webContents, 'zoom-factor-changed', newZoom) + } + } +} diff --git a/app/src/main-process/menu/build-spell-check-menu.ts b/app/src/main-process/menu/build-spell-check-menu.ts new file mode 100644 index 0000000000..d5d5d6130e --- /dev/null +++ b/app/src/main-process/menu/build-spell-check-menu.ts @@ -0,0 +1,143 @@ +import { app, BrowserWindow, MenuItem } from 'electron' + +export async function buildSpellCheckMenu( + window: BrowserWindow | undefined +): Promise | undefined> { + if (window === undefined) { + return + } + + /* + When a user right clicks on a misspelled word in an input, we get event from + electron. That event comes after the context menu event that we get from the + dom. + */ + return new Promise(resolve => { + window.webContents.once('context-menu', (event, params) => + resolve(getSpellCheckMenuItems(event, params, window.webContents)) + ) + }) +} + +function getSpellCheckMenuItems( + event: Electron.Event, + params: Electron.ContextMenuParams, + webContents: Electron.WebContents +): ReadonlyArray | undefined { + const { misspelledWord, dictionarySuggestions } = params + if (!misspelledWord && dictionarySuggestions.length === 0) { + return + } + + const items = new Array() + + items.push( + new MenuItem({ + type: 'separator', + }) + ) + + for (const suggestion of dictionarySuggestions) { + items.push( + new MenuItem({ + label: suggestion, + click: () => webContents.replaceMisspelling(suggestion), + }) + ) + } + + if (misspelledWord) { + items.push( + new MenuItem({ + label: __DARWIN__ ? 'Add to Dictionary' : 'Add to dictionary', + click: () => + webContents.session.addWordToSpellCheckerDictionary(misspelledWord), + }) + ) + } + + if (!__DARWIN__) { + // 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 { session } = webContents + const spellCheckLanguageItem = getSpellCheckLanguageMenuItemOptions( + app.getLocale(), + session.getSpellCheckerLanguages(), + session.availableSpellCheckerLanguages + ) + if (spellCheckLanguageItem !== null) { + 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 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. + * + * @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. + */ +export function getSpellCheckLanguageMenuItemOptions( + userLanguageCode: string, + spellcheckLanguageCodes: string[], + availableSpellcheckLanguages: string[] +): ISpellCheckMenuItemOption | null { + const englishLanguageCode = 'en-US' + + if ( + (userLanguageCode === englishLanguageCode && + spellcheckLanguageCodes.includes(englishLanguageCode)) || + !availableSpellcheckLanguages.includes(userLanguageCode) + ) { + return null + } + + const languageCode = + spellcheckLanguageCodes.includes(englishLanguageCode) && + !spellcheckLanguageCodes.includes(userLanguageCode) + ? userLanguageCode + : englishLanguageCode + + const label = + languageCode === englishLanguageCode + ? SpellcheckEnglishLabel + : SpellcheckSystemLabel + + return { + label, + languages: [languageCode], + } +} diff --git a/app/src/main-process/menu/crash-menu.ts b/app/src/main-process/menu/crash-menu.ts new file mode 100644 index 0000000000..e7edebb72f --- /dev/null +++ b/app/src/main-process/menu/crash-menu.ts @@ -0,0 +1,46 @@ +import { Menu } from 'electron' + +/** + * Update the menu to disable all non-essential menu items. + * + * Used when the app has detected a non-recoverable error and + * the ui process has been terminated. Since most of the app + * menu items require the ui process to work we'll have to + * disable them. + */ +export function setCrashMenu() { + const menu = Menu.getApplicationMenu() + + if (!menu) { + return + } + + for (const topLevelItem of menu.items) { + disable(topLevelItem) + } +} + +function disable(item: Electron.MenuItem) { + let anyEnabled = false + + if (item.submenu instanceof Menu) { + for (const submenuItem of item.submenu.items) { + if (disable(submenuItem)) { + anyEnabled = true + } + } + } + + if (anyEnabled || item.role) { + return true + } + + const id = (item as any).id + + if (id === 'show-devtools' || id === 'reload-window') { + return true + } + + item.enabled = false + return false +} diff --git a/app/src/main-process/menu/ensure-item-ids.ts b/app/src/main-process/menu/ensure-item-ids.ts new file mode 100644 index 0000000000..9104ccde08 --- /dev/null +++ b/app/src/main-process/menu/ensure-item-ids.ts @@ -0,0 +1,43 @@ +function getItemId(template: Electron.MenuItemConstructorOptions) { + return template.id || template.label || template.role || 'unknown' +} + +/** + * Ensures that all menu items in the given template are assigned an id + * by recursively traversing the template and mutating items in place. + * + * Items which already have an id are left alone, the other get a unique, + * but consistent id based on their label or role and their position in + * the menu hierarchy. + * + * Note that this does not do anything to prevent the case where items have + * explicitly been given duplicate ids. + */ +export function ensureItemIds( + template: ReadonlyArray, + prefix = '@', + seenIds = new Set() +) { + for (const item of template) { + let counter = 0 + let id = item.id + + // Automatically generate an id if one hasn't been explicitly provided + if (!id) { + // Ensure that multiple items with the same key gets suffixed with a number + // i.e. @.separator, @.separator1 @.separator2 etc + do { + id = `${prefix}.${getItemId(item)}${counter++ || ''}` + } while (seenIds.has(id)) + } + + item.id = id + seenIds.add(id) + + if (item.submenu) { + const subMenuTemplate = + item.submenu as ReadonlyArray + ensureItemIds(subMenuTemplate, item.id, seenIds) + } + } +} diff --git a/app/src/main-process/menu/get-all-menu-items.ts b/app/src/main-process/menu/get-all-menu-items.ts new file mode 100644 index 0000000000..4045c09636 --- /dev/null +++ b/app/src/main-process/menu/get-all-menu-items.ts @@ -0,0 +1,15 @@ +import { Menu, MenuItem } from 'electron' + +/** + * Returns an iterator that traverses the menu and all + * submenus and yields each menu item therein. + */ +export function* getAllMenuItems(menu: Menu): IterableIterator { + for (const menuItem of menu.items) { + yield menuItem + + if (menuItem.type === 'submenu' && menuItem.submenu !== undefined) { + yield* getAllMenuItems(menuItem.submenu) + } + } +} diff --git a/app/src/main-process/menu/index.ts b/app/src/main-process/menu/index.ts new file mode 100644 index 0000000000..f772978bc7 --- /dev/null +++ b/app/src/main-process/menu/index.ts @@ -0,0 +1,5 @@ +export * from './build-default-menu' +export * from './ensure-item-ids' +export * from './menu-event' +export * from './crash-menu' +export * from './get-all-menu-items' diff --git a/app/src/main-process/menu/menu-event.ts b/app/src/main-process/menu/menu-event.ts new file mode 100644 index 0000000000..bce87ade91 --- /dev/null +++ b/app/src/main-process/menu/menu-event.ts @@ -0,0 +1,49 @@ +export type MenuEvent = + | 'push' + | 'force-push' + | 'pull' + | 'fetch' + | 'show-changes' + | 'show-history' + | 'add-local-repository' + | 'create-branch' + | 'show-branches' + | 'remove-repository' + | 'create-repository' + | 'rename-branch' + | 'delete-branch' + | 'discard-all-changes' + | 'stash-all-changes' + | 'show-preferences' + | 'choose-repository' + | 'open-working-directory' + | 'update-branch-with-contribution-target-branch' + | 'compare-to-branch' + | 'merge-branch' + | 'squash-and-merge-branch' + | 'rebase-branch' + | 'show-repository-settings' + | 'open-in-shell' + | 'compare-on-github' + | 'branch-on-github' + | 'view-repository-on-github' + | 'clone-repository' + | 'show-about' + | 'go-to-commit-message' + | 'boomtown' + | 'open-pull-request' + | 'install-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' + | 'show-app-error' + | 'decrease-active-resizable-width' + | 'increase-active-resizable-width' diff --git a/app/src/main-process/notifications.ts b/app/src/main-process/notifications.ts new file mode 100644 index 0000000000..0ae209ca46 --- /dev/null +++ b/app/src/main-process/notifications.ts @@ -0,0 +1,58 @@ +import { + initializeNotifications, + onNotificationEvent, + terminateNotifications, +} from 'desktop-notifications' +import { BrowserWindow } from 'electron' +import { findToastActivatorClsid } from '../lib/find-toast-activator-clsid' +import { DesktopAliveEvent } from '../lib/stores/alive-store' +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 + } + + if (windowsToastActivatorClsid !== undefined) { + return + } + + windowsToastActivatorClsid = findToastActivatorClsid() + + if (windowsToastActivatorClsid === undefined) { + log.error( + 'Toast activator CLSID not found in any of the shortcuts. Falling back to known CLSIDs.' + ) + + // This is generated by Squirrel.Windows here: + // https://github.com/Squirrel/Squirrel.Windows/blob/7396fa50ccebf97e28c79ef519c07cb9eee121be/src/Squirrel/UpdateManager.ApplyReleases.cs#L258 + windowsToastActivatorClsid = '{27D44D0C-A542-5B90-BCDB-AC3126048BA2}' + } + + log.info(`Using toast activator CLSID ${windowsToastActivatorClsid}`) + initializeNotifications({ toastActivatorClsid: windowsToastActivatorClsid }) +} + +export function terminateDesktopNotifications() { + terminateNotifications() +} + +export function installNotificationCallback(window: BrowserWindow) { + onNotificationEvent((event, id, userInfo) => { + ipcWebContents.send( + window.webContents, + 'notification-event', + event, + id, + userInfo + ) + }) +} diff --git a/app/src/main-process/now.ts b/app/src/main-process/now.ts new file mode 100644 index 0000000000..4b0554a261 --- /dev/null +++ b/app/src/main-process/now.ts @@ -0,0 +1,11 @@ +/** + * Get the time from some arbitrary fixed starting point. The time will not be + * based on clock time. + * + * Ideally we'd just use `performance.now` but that's a browser API and not + * available in our Plain Old Node main process environment. + */ +export function now(): number { + const time = process.hrtime() + return time[0] * 1000 + time[1] / 1000000 +} diff --git a/app/src/main-process/ordered-webrequest.ts b/app/src/main-process/ordered-webrequest.ts new file mode 100644 index 0000000000..bcb4494264 --- /dev/null +++ b/app/src/main-process/ordered-webrequest.ts @@ -0,0 +1,248 @@ +import { + WebRequest, + OnBeforeRequestListenerDetails, + CallbackResponse, + OnBeforeSendHeadersListenerDetails, + BeforeSendResponse, + OnCompletedListenerDetails, + OnErrorOccurredListenerDetails, + OnResponseStartedListenerDetails, + OnHeadersReceivedListenerDetails, + HeadersReceivedResponse, + OnSendHeadersListenerDetails, + OnBeforeRedirectListenerDetails, +} from 'electron/main' + +type SyncListener = (details: TDetails) => void + +type AsyncListener = ( + details: TDetails +) => Promise + +/* + * A proxy class allowing which handles subscribing to, and unsubscribing from, + * one of the synchronous events in the WebRequest class such as + * onBeforeRedirect + */ +class SyncListenerSet { + private readonly listeners = new Set>() + + public constructor( + private readonly subscribe: ( + listener: SyncListener | null + ) => void + ) {} + + public addEventListener(listener: SyncListener) { + const firstListener = this.listeners.size === 0 + this.listeners.add(listener) + + if (firstListener) { + this.subscribe(details => this.listeners.forEach(l => l(details))) + } + } + + public removeEventListener(listener: SyncListener) { + this.listeners.delete(listener) + if (this.listeners.size === 0) { + this.subscribe(null) + } + } +} + +/* + * A proxy class allowing which handles subscribing to, and unsubscribing from, + * one of the asynchronous events in the WebRequest class such as + * onBeforeRequest + */ +class AsyncListenerSet { + private readonly listeners = new Set>() + + public constructor( + private readonly subscribe: ( + listener: + | ((details: TDetails, cb: (response: TResponse) => void) => void) + | null + ) => void, + private readonly eventHandler: ( + listeners: Iterable>, + details: TDetails + ) => Promise + ) {} + + public addEventListener(listener: AsyncListener) { + const firstListener = this.listeners.size === 0 + this.listeners.add(listener) + + if (firstListener) { + this.subscribe(async (details, cb) => { + cb(await this.eventHandler([...this.listeners], details)) + }) + } + } + + public removeEventListener(listener: AsyncListener) { + this.listeners.delete(listener) + if (this.listeners.size === 0) { + this.subscribe(null) + } + } +} + +/** + * A utility class allowing consumers to apply more than one WebRequest filter + * concurrently into the main process. + * + * The WebRequest class in Electron allows us to intercept and modify web + * requests from the renderer process. Unfortunately it only allows one filter + * to be installed forcing consumers to build monolithic filters. Using + * OrderedWebRequest consumers can instead subscribe to the event they'd like + * and OrderedWebRequest will take care of calling them in order and merging the + * changes each filter applies. + * + * Note that OrderedWebRequest is not API compatible with WebRequest and relies + * on event listeners being asynchronous methods rather than providing a + * callback parameter to listeners. + * + * For documentation of the various events see the Electron WebRequest API + * documentation. + */ +export class OrderedWebRequest { + public readonly onBeforeRedirect: SyncListenerSet + + public readonly onBeforeRequest: AsyncListenerSet< + OnBeforeRequestListenerDetails, + CallbackResponse + > + + public readonly onBeforeSendHeaders: AsyncListenerSet< + OnBeforeSendHeadersListenerDetails, + BeforeSendResponse + > + + public readonly onCompleted: SyncListenerSet + public readonly onErrorOccurred: SyncListenerSet + + public readonly onHeadersReceived: AsyncListenerSet< + OnHeadersReceivedListenerDetails, + HeadersReceivedResponse + > + + public readonly onResponseStarted: SyncListenerSet + + public readonly onSendHeaders: SyncListenerSet + + public constructor(webRequest: WebRequest) { + this.onBeforeRedirect = new SyncListenerSet( + webRequest.onBeforeRedirect.bind(webRequest) + ) + + this.onBeforeRequest = new AsyncListenerSet( + webRequest.onBeforeRequest.bind(webRequest), + async (listeners, details) => { + let response: CallbackResponse = {} + + for (const listener of listeners) { + response = await listener(details) + + // If we encounter a filter which either cancels the request or + // provides a redirect url we won't process any of the following + // filters. + if (response.cancel === true || response.redirectURL !== undefined) { + break + } + } + + return response + } + ) + + this.onBeforeSendHeaders = new AsyncListenerSet( + webRequest.onBeforeSendHeaders.bind(webRequest), + async (listeners, initialDetails) => { + let details = initialDetails + let response: BeforeSendResponse = {} + + for (const listener of listeners) { + response = await listener(details) + if (response.cancel === true) { + break + } + + if (response.requestHeaders !== undefined) { + // I have no idea why there's a discrepancy of types here. + // details.requestHeaders is a Record but + // BeforeSendResponse["requestHeaders"] is a + // Record. Chances are this was done + // to make it easier for filters but it makes it trickier for us as + // we have to ensure the next filter gets headers as a + // Record + const requestHeaders = flattenHeaders(response.requestHeaders) + details = { ...details, requestHeaders } + } + } + + return details + } + ) + + this.onCompleted = new SyncListenerSet( + webRequest.onCompleted.bind(webRequest) + ) + + this.onErrorOccurred = new SyncListenerSet( + webRequest.onErrorOccurred.bind(webRequest) + ) + + this.onHeadersReceived = new AsyncListenerSet( + webRequest.onHeadersReceived.bind(webRequest), + async (listeners, initialDetails) => { + let details = initialDetails + let response: HeadersReceivedResponse = {} + + for (const listener of listeners) { + response = await listener(details) + if (response.cancel === true) { + break + } + + if (response.responseHeaders !== undefined) { + // See comment about type mismatch in onBeforeSendHeaders + const responseHeaders = unflattenHeaders(response.responseHeaders) + details = { ...details, responseHeaders } + } + + if (response.statusLine !== undefined) { + const { statusLine } = response + const statusCode = parseInt(statusLine.split(' ', 2)[1], 10) + details = { ...details, statusLine, statusCode } + } + } + + return details + } + ) + + this.onResponseStarted = new SyncListenerSet( + webRequest.onResponseStarted.bind(webRequest) + ) + + this.onSendHeaders = new SyncListenerSet( + webRequest.onSendHeaders.bind(webRequest) + ) + } +} + +// https://stackoverflow.com/a/3097052/2114 +const flattenHeaders = (headers: Record) => + Object.entries(headers).reduce>((h, [k, v]) => { + h[k] = Array.isArray(v) ? v.join(',') : v + return h + }, {}) + +// https://stackoverflow.com/a/3097052/2114 +const unflattenHeaders = (headers: Record) => + Object.entries(headers).reduce>((h, [k, v]) => { + h[k] = Array.isArray(v) ? v : v.split(',') + return h + }, {}) diff --git a/app/src/main-process/same-origin-filter.ts b/app/src/main-process/same-origin-filter.ts new file mode 100644 index 0000000000..f549177ead --- /dev/null +++ b/app/src/main-process/same-origin-filter.ts @@ -0,0 +1,77 @@ +import { OrderedWebRequest } from './ordered-webrequest' + +/** + * Installs a web request filter to prevent cross domain leaks of auth headers + * + * GitHub Desktop uses the fetch[1] web API for all of our API requests. When fetch + * is used in a browser and it encounters an http redirect to another origin + * domain CORS policies will apply to prevent submission of credentials[2]. + * + * In our case however there's no concept of same-origin (and even if there were + * it'd be problematic because we'd be making cross-origin request constantly to + * GitHub.com and GHE instances) so the `credentials: same-origin` setting won't + * help us. + * + * This is normally not a problem until http redirects get involved. When making + * an authenticated request to an API endpoint which in turn issues a redirect + * to another domain fetch will happily pass along our token to the second + * domain and there's no way for us to prevent that from happening[3] using + * the vanilla fetch API. + * + * That's the reason why this filter exists. It will look at all initiated + * requests and store their origin along with their request ID. The request id + * will be the same for any subsequent redirect requests but the urls will be + * changing. Upon each request we will check to see if we've seen the request + * id before and if so if the origin matches. If the origin doesn't match we'll + * strip some potentially dangerous headers from the redirect request. + * + * 1. https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API + * 2. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch + * 3. https://github.com/whatwg/fetch/issues/763 + * + * @param orderedWebRequest + */ +export function installSameOriginFilter(orderedWebRequest: OrderedWebRequest) { + // A map between the request ID and the _initial_ request origin + const requestOrigin = new Map() + const safeProtocols = new Set(['devtools:', 'file:', 'chrome-extension:']) + const unsafeHeaders = new Set(['authentication', 'authorization', 'cookie']) + + orderedWebRequest.onBeforeRequest.addEventListener(async details => { + const { protocol, origin } = new URL(details.url) + + // This is called once for the initial request and then once for each + // "subrequest" thereafter, i.e. a request to https://foo/bar which gets + // redirected to https://foo/baz will trigger this twice and we only + // care about capturing the initial request origin + if (!safeProtocols.has(protocol) && !requestOrigin.has(details.id)) { + requestOrigin.set(details.id, origin) + } + + return {} + }) + + orderedWebRequest.onBeforeSendHeaders.addEventListener(async details => { + const initialOrigin = requestOrigin.get(details.id) + const { origin } = new URL(details.url) + + if (initialOrigin === undefined || initialOrigin === origin) { + return { requestHeaders: details.requestHeaders } + } + + const sanitizedHeaders: Record = {} + + for (const [k, v] of Object.entries(details.requestHeaders)) { + if (!unsafeHeaders.has(k.toLowerCase())) { + sanitizedHeaders[k] = v + } + } + + log.debug(`Sanitizing cross-origin redirect to ${origin}`) + return { requestHeaders: sanitizedHeaders } + }) + + orderedWebRequest.onCompleted.addEventListener(details => + requestOrigin.delete(details.id) + ) +} diff --git a/app/src/main-process/shell.ts b/app/src/main-process/shell.ts new file mode 100644 index 0000000000..0be1088473 --- /dev/null +++ b/app/src/main-process/shell.ts @@ -0,0 +1,32 @@ +import { shell } from 'electron' + +/** + * Wraps the inbuilt shell.openItem path to address a focus issue that affects macOS. + * + * When opening a folder in Finder, the window will appear behind the application + * window, which may confuse users. As a workaround, we will fallback to using + * shell.openExternal for macOS until it can be fixed upstream. + * + * CAUTION: This method should never be used to open user-provided or derived + * paths. It's sole use is to open _directories_ that we know to be safe, no + * verification is performed to ensure that the provided path isn't actually + * an executable. + * + * @param path directory to open + */ +export function UNSAFE_openDirectory(path: string) { + // Add a trailing slash to the directory path. + // + // On Windows, if there's a file and a directory with the + // same name (e.g `C:\MyFolder\foo` and `C:\MyFolder\foo.exe`), + // when executing shell.openItem(`C:\MyFolder\foo`) then the EXE file + // will get opened. + // We can avoid this by adding a final backslash at the end of the path. + const pathname = __WIN32__ && !path.endsWith('\\') ? `${path}\\` : path + + shell.openPath(pathname).then(err => { + if (err !== '') { + log.error(`Failed to open directory (${path}): ${err}`) + } + }) +} diff --git a/app/src/main-process/show-uncaught-exception.ts b/app/src/main-process/show-uncaught-exception.ts new file mode 100644 index 0000000000..5f04ee66ff --- /dev/null +++ b/app/src/main-process/show-uncaught-exception.ts @@ -0,0 +1,52 @@ +import { app, dialog } from 'electron' +import { setCrashMenu } from './menu' +import { formatError } from '../lib/logging/format-error' +import { CrashWindow } from './crash-window' + +let hasReportedUncaughtException = false + +/** Show the uncaught exception UI. */ +export function showUncaughtException(isLaunchError: boolean, error: Error) { + log.error(formatError(error)) + + if (hasReportedUncaughtException) { + return + } + + hasReportedUncaughtException = true + + setCrashMenu() + + const window = new CrashWindow(isLaunchError ? 'launch' : 'generic', error) + + window.onDidLoad(() => { + window.show() + }) + + window.onFailedToLoad(async () => { + await dialog.showMessageBox({ + type: 'error', + title: __DARWIN__ ? `Unrecoverable Error` : 'Unrecoverable error', + message: + `GitHub Desktop has encountered an unrecoverable error and will need to restart.\n\n` + + `This has been reported to the team, but if you encounter this repeatedly please report ` + + `this issue to the GitHub Desktop issue tracker.\n\n${ + error.stack || error.message + }`, + }) + + if (!__DEV__) { + app.relaunch() + } + app.quit() + }) + + window.onClose(() => { + if (!__DEV__) { + app.relaunch() + } + app.quit() + }) + + window.load() +} diff --git a/app/src/main-process/squirrel-updater.ts b/app/src/main-process/squirrel-updater.ts new file mode 100644 index 0000000000..eb0ef380a4 --- /dev/null +++ b/app/src/main-process/squirrel-updater.ts @@ -0,0 +1,169 @@ +import * as Path from 'path' +import * as Os from 'os' + +import { mkdir, writeFile } from 'fs/promises' +import { spawn, getPathSegments, setPathSegments } from '../lib/process/win32' +import { pathExists } from '../ui/lib/path-exists' + +const appFolder = Path.resolve(process.execPath, '..') +const rootAppDir = Path.resolve(appFolder, '..') +const updateDotExe = Path.resolve(Path.join(rootAppDir, 'Update.exe')) +const exeName = Path.basename(process.execPath) + +// A lot of this code was cargo-culted from our Atom collaborators: +// https://github.com/atom/atom/blob/7c9f39e3f1d05ee423e0093e6b83f042ce11c90a/src/main-process/squirrel-update.coffee. + +/** + * Handle Squirrel.Windows app lifecycle events. + * + * Returns a promise which will resolve when the work is done. + */ +export function handleSquirrelEvent(eventName: string): Promise | null { + switch (eventName) { + case '--squirrel-install': + return handleInstalled() + + case '--squirrel-updated': + return handleUpdated() + + case '--squirrel-uninstall': + return handleUninstall() + + case '--squirrel-obsolete': + return Promise.resolve() + } + + return null +} + +async function handleInstalled(): Promise { + await createShortcut(['StartMenu', 'Desktop']) + await installCLI() +} + +async function handleUpdated(): Promise { + await updateShortcut() + await installCLI() +} + +async function installCLI(): Promise { + const binPath = getBinPath() + await mkdir(binPath, { recursive: true }) + await writeBatchScriptCLITrampoline(binPath) + await writeShellScriptCLITrampoline(binPath) + try { + const paths = getPathSegments() + if (paths.indexOf(binPath) < 0) { + await setPathSegments([...paths, binPath]) + } + } catch (e) { + log.error('Failed inserting bin path into 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. + */ +function getBinPath(): string { + return Path.resolve(process.execPath, '../../bin') +} + +function resolveVersionedPath(binPath: string, relativePath: string): string { + const appFolder = Path.resolve(process.execPath, '..') + return Path.relative(binPath, Path.join(appFolder, relativePath)) +} + +/** + * Here's the problem: our app's path contains its version number. So each time + * we update, the path to our app changes. So it's Real Hard to add our path + * directly to `Path`. We'd have to detect and remove stale entries, etc. + * + * So instead, we write a trampoline out to a fixed path, still inside our + * `AppData` directory but outside the version-specific path. That trampoline + * just launches the current version's CLI tool. Then, whenever we update, we + * rewrite the trampoline to point to the new, version-specific path. Bingo + * bango Bob's your uncle. + */ +function writeBatchScriptCLITrampoline(binPath: string): Promise { + const versionedPath = resolveVersionedPath( + binPath, + 'resources/app/static/github.bat' + ) + + const trampoline = `@echo off\n"%~dp0\\${versionedPath}" %*` + const trampolinePath = Path.join(binPath, 'github.bat') + + return writeFile(trampolinePath, trampoline) +} + +function writeShellScriptCLITrampoline(binPath: string): Promise { + // The path we get from `resolveVersionedPath` is a Win32 relative + // path (something like `..\app-2.5.0\resources\app\static\github.sh`). + // We need to make sure it's a POSIX path in order for WSL to be able + // to resolve it. See https://github.com/desktop/desktop/issues/4998 + const versionedPath = resolveVersionedPath( + binPath, + 'resources/app/static/github.sh' + ).replace(/\\/g, '/') + + const trampoline = `#!/usr/bin/env bash + DIR="$( cd "$( dirname "\$\{BASH_SOURCE[0]\}" )" && pwd )" + sh "$DIR/${versionedPath}" "$@"` + const trampolinePath = Path.join(binPath, 'github') + + return writeFile(trampolinePath, trampoline, { encoding: 'utf8', mode: 755 }) +} + +/** Spawn the Squirrel.Windows `Update.exe` with a command. */ +async function spawnSquirrelUpdate( + commands: ReadonlyArray +): Promise { + await spawn(updateDotExe, commands) +} + +type ShortcutLocations = ReadonlyArray<'StartMenu' | 'Desktop'> + +function createShortcut(locations: ShortcutLocations): Promise { + return spawnSquirrelUpdate([ + '--createShortcut', + exeName, + '-l', + locations.join(','), + ]) +} + +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) + } +} + +function removeShortcut(): Promise { + return spawnSquirrelUpdate(['--removeShortcut', exeName]) +} + +async function updateShortcut(): Promise { + const homeDirectory = Os.homedir() + if (homeDirectory) { + const desktopShortcutPath = Path.join( + homeDirectory, + 'Desktop', + 'GitHub Desktop.lnk' + ) + const exists = await pathExists(desktopShortcutPath) + const locations: ShortcutLocations = exists + ? ['StartMenu', 'Desktop'] + : ['StartMenu'] + return createShortcut(locations) + } else { + return createShortcut(['StartMenu', 'Desktop']) + } +} 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 0000000000..ae36a90e78 --- /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 0000000000..63323f9b0e --- /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 new file mode 100644 index 0000000000..95929859a2 --- /dev/null +++ b/app/src/models/account.ts @@ -0,0 +1,70 @@ +import { getDotComAPIEndpoint, IAPIEmail } from '../lib/api' + +/** + * Returns a value indicating whether two account instances + * can be considered equal. Equality is determined by comparing + * the two instances' endpoints and user id. This allows + * us to keep receiving updated Account details from the API + * while still maintaining the association between repositories + * and a particular account. + */ +export function accountEquals(x: Account, y: Account) { + return x.endpoint === y.endpoint && x.id === y.id +} + +/** + * A GitHub account, representing the user found on GitHub The Website or GitHub Enterprise. + * + * This contains a token that will be used for operations that require authentication. + */ +export class Account { + /** Create an account which can be used to perform unauthenticated API actions */ + public static anonymous(): Account { + return new Account('', getDotComAPIEndpoint(), '', [], '', -1, '', 'free') + } + + /** + * Create an instance of an account + * + * @param login The login name for this account + * @param endpoint The server for this account - GitHub or a GitHub Enterprise instance + * @param token The access token used to perform operations on behalf of this account + * @param emails The current list of email addresses associated with the account + * @param avatarURL The profile URL to render for this account + * @param id The GitHub.com or GitHub Enterprise database id for this account. + * @param name The friendly name associated with this account + */ + public constructor( + public readonly login: string, + public readonly endpoint: string, + public readonly token: string, + public readonly emails: ReadonlyArray, + public readonly avatarURL: string, + public readonly id: number, + public readonly name: string, + public readonly plan?: string + ) {} + + public withToken(token: string): Account { + return new Account( + this.login, + this.endpoint, + token, + this.emails, + this.avatarURL, + this.id, + this.name, + this.plan + ) + } + + /** + * Get a name to display + * + * This will by default return the 'name' as it is the friendly name. + * However, if not defined, we return the login + */ + public get friendlyName(): string { + return this.name !== '' ? this.name : this.login + } +} diff --git a/app/src/models/app-menu.ts b/app/src/models/app-menu.ts new file mode 100644 index 0000000000..caf2f5f030 --- /dev/null +++ b/app/src/models/app-menu.ts @@ -0,0 +1,669 @@ +import { assertNever } from '../lib/fatal-error' + +/** A type union of all possible types of menu items */ +export type MenuItem = + | IMenuItem + | ISubmenuItem + | ISeparatorMenuItem + | ICheckboxMenuItem + | IRadioMenuItem + +/** A type union of all types of menu items which can be executed */ +export type ExecutableMenuItem = IMenuItem | ICheckboxMenuItem | IRadioMenuItem + +/** + * Common properties for all item types except separator. + * Only useful for declaring the types, not for consumption + */ +interface IBaseMenuItem { + readonly id: string + readonly enabled: boolean + readonly visible: boolean + readonly label: string +} + +/** + * An interface describing the properties of a 'normal' + * menu item, i.e. a clickable item with a label but no + * other special properties. + */ +export interface IMenuItem extends IBaseMenuItem { + readonly type: 'menuItem' + readonly accelerator: string | null + readonly accessKey: string | null +} + +/** + * An interface describing the properties of a + * submenu menu item, i.e. an item which has an associated + * submenu which can be expanded to reveal more menu + * item. Not in itself executable, only a container. + */ +export interface ISubmenuItem extends IBaseMenuItem { + readonly type: 'submenuItem' + readonly menu: IMenu + readonly accessKey: string | null +} + +/** + * An interface describing the properties of a checkbox + * menu item, i.e. an item which has an associated checked + * state that can be toggled by executing it. + */ +export interface ICheckboxMenuItem extends IBaseMenuItem { + readonly type: 'checkbox' + readonly accelerator: string | null + readonly accessKey: string | null + readonly checked: boolean +} + +/** + * An interface describing the properties of a checkbox + * menu item, i.e. an item which has an associated checked + * state that is checked or unchecked based on application logic. + * + * The radio menu item is probably going to be used in a collection + * of more radio menu items where the checked item is assigned + * based on the last executed item in that group. + */ +export interface IRadioMenuItem extends IBaseMenuItem { + readonly type: 'radio' + readonly accelerator: string | null + readonly accessKey: string | null + readonly checked: boolean +} + +/** + * An interface describing the properties of a separator menu + * item, i.e. an item which sole purpose is to create separation + * between menu items. It has no other semantics and is purely + * a visual hint. + */ +export interface ISeparatorMenuItem { + readonly id: string + readonly type: 'separator' + readonly visible: boolean +} + +/** + * An interface describing a menu. + * + * Holds collection of menu items and an indication of which item (if any) + * in the menu is selected. + */ +export interface IMenu { + /** + * The id of this menu. For the root menu this will be undefined. For all + * other menus it will be the same as the id of the submenu item which + * owns this menu. + * + * +---------------------------+ + * | Root menu (id: undefined) | + * +---------------------------+ +--------------------------+ + * | File (id File) +--> File menu (id: File) | + * +---------------------------+ +--------------------------+ + * | Edit (id Edit) | | Open (id File.Open) | + * +---------------------------+ +--------------------------+ + * | Close (id File.Close) | + * +--------------------------+ + */ + readonly id?: string + + /** Type identifier, used for type narrowing */ + readonly type: 'menu' + + /** A collection of zero or more menu items */ + readonly items: ReadonlyArray + + /** The selected item in the menu or undefined if no item is selected */ + readonly selectedItem?: MenuItem +} + +/** + * Gets the accelerator for a given menu item. If the menu item doesn't + * have an explicitly defined accelerator but does have a defined role + * the default accelerator (if any) for that particular role will be + * returned. + */ +function getAccelerator(menuItem: Electron.MenuItem): string | null { + if (menuItem.accelerator) { + return menuItem.accelerator as string + } + + if (menuItem.role) { + const unsafeItem = menuItem as any + // https://github.com/electron/electron/blob/d4a8a64ba/lib/browser/api/menu-item.js#L62 + const getDefaultRoleAccelerator = unsafeItem.getDefaultRoleAccelerator + + if (typeof getDefaultRoleAccelerator === 'function') { + try { + const defaultRoleAccelerator = getDefaultRoleAccelerator.call(menuItem) + if (typeof defaultRoleAccelerator === 'string') { + return defaultRoleAccelerator + } + } catch (err) { + console.error('Could not retrieve default accelerator', err) + } + } + } + + return null +} + +/** + * Return the access key (applicable on Windows) from a menu item label. + * + * An access key is a letter or symbol preceded by an ampersand, i.e. in + * the string "Check for &updates" the access key is 'u'. Access keys are + * case insensitive and are unique per menu. + */ +function getAccessKey(text: string): string | null { + const m = text.match(/&([^&])/) + return m ? m[1] : null +} + +/** Workaround for missing type information on Electron.MenuItem.type */ +function parseMenuItem( + type: string +): 'normal' | 'separator' | 'submenu' | 'checkbox' | 'radio' { + switch (type) { + case 'normal': + case 'separator': + case 'submenu': + case 'checkbox': + case 'radio': + return type + default: + throw new Error( + `Unable to parse string ${type} to a valid menu item type` + ) + } +} + +/** + * Creates an instance of one of the types in the MenuItem type union based + * on an Electron MenuItem instance. Will recurse through all sub menus and + * convert each item. + */ +function menuItemFromElectronMenuItem(menuItem: Electron.MenuItem): MenuItem { + // Our menu items always have ids and Electron.MenuItem takes on whatever + // properties was defined on the MenuItemOptions template used to create it + // but doesn't surface those in the type declaration. + const id: string | undefined = (menuItem as any).id + if (!id) { + throw new Error(`menuItem must specify id: ${menuItem.label}`) + } + const enabled = menuItem.enabled + const visible = menuItem.visible + const label = menuItem.label + const checked = menuItem.checked + const accelerator = getAccelerator(menuItem) + const accessKey = getAccessKey(menuItem.label) + + const type = parseMenuItem(menuItem.type) + + // normal, separator, submenu, checkbox or radio. + switch (type) { + case 'normal': + return { + id, + type: 'menuItem', + label, + enabled, + visible, + accelerator, + accessKey, + } + case 'separator': + return { id, type: 'separator', visible } + case 'submenu': + const menu = menuFromElectronMenu(menuItem.submenu as Electron.Menu, id) + return { + id, + type: 'submenuItem', + label, + enabled, + visible, + menu, + accessKey, + } + case 'checkbox': + return { + id, + type: 'checkbox', + label, + enabled, + visible, + accelerator, + checked, + accessKey, + } + case 'radio': + return { + id, + type: 'radio', + label, + enabled, + visible, + accelerator, + checked, + accessKey, + } + default: + return assertNever(type, `Unknown menu item type ${type}`) + } +} +/** + * Creates a IMenu instance based on an Electron Menu instance. + * Will recurse through all sub menus and convert each item using + * menuItemFromElectronMenuItem. + * + * @param menu - The electron menu instance to convert into an + * IMenu instance + * + * @param id - The id of the menu. Menus share their id with + * their parent item. The root menu id is undefined. + */ +export function menuFromElectronMenu(menu: Electron.Menu, id?: string): IMenu { + const items = menu.items.map(menuItemFromElectronMenuItem) + + if (__DEV__) { + const seenAccessKeys = new Set() + + for (const item of items) { + if (item.visible) { + if (itemMayHaveAccessKey(item) && item.accessKey) { + if (seenAccessKeys.has(item.accessKey.toLowerCase())) { + throw new Error( + `Duplicate access key '${item.accessKey}' for item ${item.label}` + ) + } else { + seenAccessKeys.add(item.accessKey.toLowerCase()) + } + } + } + } + } + + return { id, type: 'menu', items } +} + +/** + * Creates a map between MenuItem ids and MenuItems by recursing + * through all items and all submenus. + */ +function buildIdMap( + menu: IMenu, + map = new Map() +): Map { + for (const item of menu.items) { + map.set(item.id, item) + if (item.type === 'submenuItem') { + buildIdMap(item.menu, map) + } + } + + return map +} + +/** Type guard which narrows a MenuItem to one which supports access keys */ +export function itemMayHaveAccessKey( + item: MenuItem +): item is IMenuItem | ISubmenuItem | ICheckboxMenuItem | IRadioMenuItem { + return ( + item.type === 'menuItem' || + item.type === 'submenuItem' || + item.type === 'checkbox' || + item.type === 'radio' + ) +} + +/** + * Returns a value indicating whether or not the given menu item can be + * selected. Selectable items are non-separator items which are enabled + * and visible. + */ +export function itemIsSelectable(item: MenuItem) { + return item.type !== 'separator' && item.enabled && item.visible +} + +/** + * Attempts to locate a menu item matching the provided access key in a + * given list of items. The access key comparison is case-insensitive. + * + * Note that this function does not take into account whether or not the + * item is selectable, consumers of this function need to perform that + * check themselves when applicable. + */ +export function findItemByAccessKey( + accessKey: string, + items: ReadonlyArray +): IMenuItem | ISubmenuItem | ICheckboxMenuItem | IRadioMenuItem | null { + const lowerCaseAccessKey = accessKey.toLowerCase() + + for (const item of items) { + if (itemMayHaveAccessKey(item)) { + if ( + item.accessKey && + item.accessKey.toLowerCase() === lowerCaseAccessKey + ) { + return item + } + } + } + + return null +} + +/** + * An immutable, transformable object which represents an application menu + * and its current state (which menus are open, which items are selected). + * + * The primary use case for this is for rendering a custom application menu + * on non-macOS systems. As such some interactions are explicitly made to + * conform to Windows menu interactions. This includes things like selecting + * the entire path up until the last selected item. This is necessary since, + * on Windows, the parent menu item of a menu might not be selected even + * though the submenu is. This is in order to allow for some delay when + * moving the cursor from one menu pane to another. + * + * In general, however, this object is not platform specific and much of + * the interactions are defined by the component using it. + */ +export class AppMenu { + /** + * Static constructor for the initial creation of an AppMenu instance + * from an IMenu instance. + */ + public static fromMenu(menu: IMenu): AppMenu { + const map = buildIdMap(menu) + const openMenus = [menu] + + return new AppMenu(menu, openMenus, map) + } + + /** + * Used by static constructors and transformers. + * + * @param menu The menu that this instance operates on, taken from an + * electron Menu instance and converted into an IMenu model + * by menuFromElectronMenu. + * @param openMenus A list of currently open menus with their selected items + * in the application menu. + * + * The semantics around what constitutes an open menu and how + * selection works is defined within this class class as well as + * in the individual components transforming that state. + * @param menuItemById A map between menu item ids and their corresponding MenuItem. + */ + private constructor( + private readonly menu: IMenu, + public readonly openMenus: ReadonlyArray, + private readonly menuItemById: Map + ) {} + + /** + * Retrieves a menu item by its id. + */ + public getItemById(id: string): MenuItem | undefined { + return this.menuItemById.get(id) + } + + /** + * Merges the current AppMenu state with a new menu while + * attempting to maintain selection state. + */ + public withMenu(newMenu: IMenu): AppMenu { + const newMap = buildIdMap(newMenu) + const newOpenMenus = new Array() + + // Enumerate all currently open menus and attempt to recreate + // the openMenus array with the new menu instances + for (const openMenu of this.openMenus) { + let newOpenMenu: IMenu + + // No id means it's the root menu, simple enough. + if (!openMenu.id) { + newOpenMenu = newMenu + } else { + // Menus share id with their parent item + const item = newMap.get(openMenu.id) + + if (item && item.type === 'submenuItem') { + newOpenMenu = item.menu + } else { + // This particular menu can't be found in the new menu + // structure, we have no choice but to bail here and + // not open this particular menu. + break + } + } + + let newSelectedItem: MenuItem | undefined = undefined + + if (openMenu.selectedItem) { + newSelectedItem = newMap.get(openMenu.selectedItem.id) + } + + newOpenMenus.push({ + id: newOpenMenu.id, + type: 'menu', + items: newOpenMenu.items, + selectedItem: newSelectedItem, + }) + } + + return new AppMenu(newMenu, newOpenMenus, newMap) + } + + /** + * Creates a new copy of this AppMenu instance with the given submenu open. + * + * @param submenuItem - The item which submenu should be appended + * to the list of open menus. + * + * @param selectFirstItem - A convenience item for automatically selecting + * the first item in the newly opened menu. + * + * If false the new menu is opened without a selection. + * + * Defaults to false. + */ + public withOpenedMenu( + submenuItem: ISubmenuItem, + selectFirstItem = false + ): AppMenu { + const ourMenuItem = this.menuItemById.get(submenuItem.id) + + if (!ourMenuItem) { + return this + } + + if (ourMenuItem.type !== 'submenuItem') { + throw new Error( + `Attempt to open a submenu from an item of wrong type: ${ourMenuItem.type}` + ) + } + + const parentMenuIndex = this.openMenus.findIndex( + m => m.items.indexOf(ourMenuItem) !== -1 + ) + + // The parent menu has apparently been closed in between, we could go and + // recreate it but it's probably not worth it. + if (parentMenuIndex === -1) { + return this + } + + const newOpenMenus = this.openMenus.slice(0, parentMenuIndex + 1) + + if (selectFirstItem) { + // First selectable item. + const selectedItem = ourMenuItem.menu.items.find(itemIsSelectable) + newOpenMenus.push({ ...ourMenuItem.menu, selectedItem }) + } else { + newOpenMenus.push(ourMenuItem.menu) + } + + return new AppMenu(this.menu, newOpenMenus, this.menuItemById) + } + + /** + * Creates a new copy of this AppMenu instance with the given menu removed from + * the list of open menus. + * + * @param menu - The menu which is to be closed, i.e. removed from the + * list of open menus. + */ + public withClosedMenu(menu: IMenu) { + // Root menu is always open and can't be closed + if (!menu.id) { + return this + } + + const ourMenuIndex = this.openMenus.findIndex(m => m.id === menu.id) + + if (ourMenuIndex === -1) { + return this + } + + const newOpenMenus = this.openMenus.slice(0, ourMenuIndex) + + return new AppMenu(this.menu, newOpenMenus, this.menuItemById) + } + + /** + * Creates a new copy of this AppMenu instance with the list of open menus trimmed + * to not include any menus below the given menu. + * + * @param menu - The last menu which is to remain in the list of open + * menus, all menus below this level will be pruned from + * the list of open menus. + */ + public withLastMenu(menu: IMenu) { + const ourMenuIndex = this.openMenus.findIndex(m => m.id === menu.id) + + if (ourMenuIndex === -1) { + return this + } + + const newOpenMenus = this.openMenus.slice(0, ourMenuIndex + 1) + + return new AppMenu(this.menu, newOpenMenus, this.menuItemById) + } + + /** + * Creates a new copy of this AppMenu instance in which the given menu item + * is selected. + * + * Additional semantics: + * + * All menus leading up to the given menu item will have their + * selection reset in such a fashion that the selection path + * points to the given menu item. + * + * All menus after the menu in which the given item resides + * will have their selections cleared. + * + * @param menuItem - The menu item which is to be selected. + */ + public withSelectedItem(menuItem: MenuItem) { + const ourMenuItem = this.menuItemById.get(menuItem.id) + + // The item that someone is trying to select no longer + // exists, not much we can do about that. + if (!ourMenuItem) { + return this + } + + const parentMenuIndex = this.openMenus.findIndex( + m => m.items.indexOf(ourMenuItem) !== -1 + ) + + // The menu which the selected item belongs to is no longer open, + // not much we can do about that. + if (parentMenuIndex === -1) { + return this + } + + const newOpenMenus = this.openMenus.slice() + + const parentMenu = newOpenMenus[parentMenuIndex] + + newOpenMenus[parentMenuIndex] = { ...parentMenu, selectedItem: ourMenuItem } + + // All submenus below the active menu should have their selection cleared + for (let i = parentMenuIndex + 1; i < newOpenMenus.length; i++) { + newOpenMenus[i] = { ...newOpenMenus[i], selectedItem: undefined } + } + + // Ensure that the path that lead us to the currently selected menu is + // selected. i.e. all menus above the currently active menu should have + // their selection reset to point to the currently active menu. + for (let i = parentMenuIndex - 1; i >= 0; i--) { + const menu = newOpenMenus[i] + const childMenu = newOpenMenus[i + 1] + + const selectedItem = menu.items.find( + item => item.type === 'submenuItem' && item.id === childMenu.id + ) + + newOpenMenus[i] = { ...menu, selectedItem } + } + + return new AppMenu(this.menu, newOpenMenus, this.menuItemById) + } + + /** + * Creates a new copy of this AppMenu instance in which the given menu has had + * its selection state cleared. + * + * Additional semantics: + * + * All menus leading up to the given menu item will have their + * selection reset in such a fashion that the selection path + * points to the given menu. + * + * @param menu - The menu which is to have its selection state + * cleared. + */ + public withDeselectedMenu(menu: IMenu) { + const ourMenuIndex = this.openMenus.findIndex(m => m.id === menu.id) + + // The menu that someone is trying to deselect is no longer open + // so no need to worry about selection + if (ourMenuIndex === -1) { + return this + } + + const ourMenu = this.openMenus[ourMenuIndex] + const newOpenMenus = this.openMenus.slice() + + newOpenMenus[ourMenuIndex] = { ...ourMenu, selectedItem: undefined } + + // Ensure that the path to the menu without an active selection is + // selected. i.e. all menus above should have their selection reset + // to point to the menu which no longer has an active selection. + for (let i = ourMenuIndex - 1; i >= 0; i--) { + const menu = newOpenMenus[i] + const childMenu = newOpenMenus[i + 1] + + const selectedItem = menu.items.find( + item => item.type === 'submenuItem' && item.id === childMenu.id + ) + + newOpenMenus[i] = { ...menu, selectedItem } + } + + return new AppMenu(this.menu, newOpenMenus, this.menuItemById) + } + + /** + * Creates a new copy of this AppMenu instance in which all state + * is reset. Resetting means that only the root menu is open and + * all selection state is cleared. + */ + public withReset() { + return new AppMenu(this.menu, [this.menu], this.menuItemById) + } +} diff --git a/app/src/models/author.ts b/app/src/models/author.ts new file mode 100644 index 0000000000..0ad4db2e3c --- /dev/null +++ b/app/src/models/author.ts @@ -0,0 +1,50 @@ +/** 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 + * is general purpose. + * + * For visualization purposes this object represents a + * string such as + * + * Foo Bar + * + * Additionally it includes an optional username which is + * solely for presentation purposes inside AuthorInput + */ +export type Author = KnownAuthor | UnknownAuthor + +/** 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 new file mode 100644 index 0000000000..de4ef4479e --- /dev/null +++ b/app/src/models/avatar.ts @@ -0,0 +1,81 @@ +import { Commit } from './commit' +import { CommitIdentity } from './commit-identity' +import { GitAuthor } from './git-author' +import { GitHubRepository } from './github-repository' +import { isWebFlowCommitter } from '../lib/web-flow-committer' + +/** The minimum properties we need in order to display a user's avatar. */ +export interface IAvatarUser { + /** The user's email. */ + readonly email: string + + /** The user's avatar URL. */ + readonly avatarURL: string | undefined + + /** The user's name. */ + readonly name: string + + /** + * The endpoint of the repository that this user is associated with. + * This will be https://api.github.com for GitHub.com-hosted + * repositories, something like `https://github.example.com/api/v3` + * for GitHub Enterprise and null for local repositories or + * repositories hosted on non-GitHub services. + */ + readonly endpoint: string | null +} + +export function getAvatarUserFromAuthor( + author: CommitIdentity | GitAuthor, + gitHubRepository: GitHubRepository | null +) { + return { + email: author.email, + name: author.name, + endpoint: gitHubRepository === null ? null : gitHubRepository.endpoint, + avatarURL: undefined, + } +} + +/** + * Attempt to look up avatars for all authors (and committer) + * of a particular commit. + * + * Avatars are returned ordered, starting with the author, followed + * by all co-authors and finally the committer (if different from + * author and any co-author). + * + * @param gitHubRepository + * @param gitHubUsers + * @param commit + */ +export function getAvatarUsersForCommit( + gitHubRepository: GitHubRepository | null, + commit: Commit +) { + const avatarUsers = [] + + avatarUsers.push(getAvatarUserFromAuthor(commit.author, gitHubRepository)) + avatarUsers.push( + ...commit.coAuthors.map(x => getAvatarUserFromAuthor(x, gitHubRepository)) + ) + + const coAuthoredByCommitter = commit.coAuthors.some( + x => x.name === commit.committer.name && x.email === commit.committer.email + ) + + const webFlowCommitter = + gitHubRepository !== null && isWebFlowCommitter(commit, gitHubRepository) + + if ( + !commit.authoredByCommitter && + !webFlowCommitter && + !coAuthoredByCommitter + ) { + avatarUsers.push( + getAvatarUserFromAuthor(commit.committer, gitHubRepository) + ) + } + + return avatarUsers +} diff --git a/app/src/models/banner.ts b/app/src/models/banner.ts new file mode 100644 index 0000000000..8c4488a171 --- /dev/null +++ b/app/src/models/banner.ts @@ -0,0 +1,123 @@ +import { Popup } from './popup' + +export enum BannerType { + SuccessfulMerge = 'SuccessfulMerge', + MergeConflictsFound = 'MergeConflictsFound', + SuccessfulRebase = 'SuccessfulRebase', + RebaseConflictsFound = 'RebaseConflictsFound', + BranchAlreadyUpToDate = 'BranchAlreadyUpToDate', + SuccessfulCherryPick = 'SuccessfulCherryPick', + CherryPickConflictsFound = 'CherryPickConflictsFound', + CherryPickUndone = 'CherryPickUndone', + SquashUndone = 'SquashUndone', + ReorderUndone = 'ReorderUndone', + OpenThankYouCard = 'OpenThankYouCard', + SuccessfulSquash = 'SuccessfulSquash', + SuccessfulReorder = 'SuccessfulReorder', + ConflictsFound = 'ConflictsFound', + WindowsVersionNoLongerSupported = 'WindowsVersionNoLongerSupported', +} + +export type Banner = + | { + readonly type: BannerType.SuccessfulMerge + /** name of the branch that was merged into */ + readonly ourBranch: string + /** name of the branch we merged into `ourBranch` */ + readonly theirBranch?: string + } + | { + readonly type: BannerType.MergeConflictsFound + /** name of the branch that is being merged into */ + readonly ourBranch: string + /** popup to be shown from the banner */ + readonly popup: Popup + } + | { + readonly type: BannerType.SuccessfulRebase + /** name of the branch that was used to rebase */ + readonly targetBranch: string + /** the branch that the current branch was rebased onto (if known) */ + readonly baseBranch?: string + } + | { + readonly type: BannerType.RebaseConflictsFound + /** name of the branch that was used to rebase */ + readonly targetBranch: string + /** callback to run when user clicks on link in banner text */ + readonly onOpenDialog: () => void + } + | { + readonly type: BannerType.BranchAlreadyUpToDate + /** name of the branch that was merged into */ + readonly ourBranch: string + /** name of the branch we merged into `ourBranch` */ + readonly theirBranch?: string + } + | { + readonly type: BannerType.SuccessfulCherryPick + /** name of the branch that was cherry picked to */ + readonly targetBranchName: string + /** number of commits cherry picked */ + readonly count: number + /** callback to run when user clicks undo link in banner */ + readonly onUndo: () => void + } + | { + readonly type: BannerType.CherryPickConflictsFound + /** name of the branch that the commits are being cherry picked onto */ + readonly targetBranchName: string + /** callback to run when user clicks on link in banner text */ + readonly onOpenConflictsDialog: () => void + } + | { + readonly type: BannerType.CherryPickUndone + /** name of the branch that the commits were cherry picked onto */ + readonly targetBranchName: string + /** number of commits cherry picked */ + readonly countCherryPicked: number + } + | { + readonly type: BannerType.OpenThankYouCard + readonly emoji: Map + readonly onOpenCard: () => void + readonly onThrowCardAway: () => void + } + | { + readonly type: BannerType.SuccessfulSquash + /** number of commits squashed */ + readonly count: number + /** callback to run when user clicks undo link in banner */ + readonly onUndo: () => void + } + | { + readonly type: BannerType.SquashUndone + /** number of commits squashed */ + readonly commitsCount: number + } + | { + readonly type: BannerType.SuccessfulReorder + /** number of commits reordered */ + readonly count: number + /** callback to run when user clicks undo link in banner */ + readonly onUndo: () => void + } + | { + readonly type: BannerType.ReorderUndone + /** number of commits reordered */ + readonly commitsCount: number + } + | { + readonly type: BannerType.ConflictsFound + /** + * Description of the operation to continue + * Examples: + * - rebasing target-branch-name + * - cherry-picking onto target-branch-name + * - squashing commits on target-branch-name + */ + readonly operationDescription: string | JSX.Element + /** callback to run when user clicks on link in banner text */ + readonly onOpenConflictsDialog: () => void + } + | { readonly type: BannerType.WindowsVersionNoLongerSupported } diff --git a/app/src/models/branch.ts b/app/src/models/branch.ts new file mode 100644 index 0000000000..6326933327 --- /dev/null +++ b/app/src/models/branch.ts @@ -0,0 +1,135 @@ +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 +// local and remote branches, Local should come before Remote +export enum BranchType { + Local = 0, + Remote = 1, +} + +/** The number of commits a revision range is ahead/behind. */ +export interface IAheadBehind { + readonly ahead: number + readonly behind: number +} + +/** The result of comparing two refs in a repository. */ +export interface ICompareResult extends IAheadBehind { + readonly commits: ReadonlyArray +} + +/** Basic data about a branch, and the branch it's tracking. */ +export interface ITrackingBranch { + readonly ref: string + readonly sha: string + readonly upstreamRef: string + readonly upstreamSha: string +} + +/** 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 */ +export enum StartPoint { + CurrentBranch = 'CurrentBranch', + DefaultBranch = 'DefaultBranch', + Head = 'Head', + /** Only valid for forks */ + UpstreamDefaultBranch = 'UpstreamDefaultBranch', +} + +/** A branch as loaded from Git. */ +export class Branch { + /** + * A branch as loaded from Git. + * + * @param name The short name of the branch. E.g., `main`. + * @param upstream The remote-prefixed upstream name. E.g., `origin/main`. + * @param tip Basic information (sha and author) of the latest commit on the branch. + * @param type The type of branch, e.g., local or remote. + * @param ref The canonical ref of the branch + */ + public constructor( + public readonly name: string, + public readonly upstream: string | null, + public readonly tip: IBranchTip, + public readonly type: BranchType, + public readonly ref: string + ) {} + + /** The name of the upstream's remote. */ + public get upstreamRemoteName(): string | null { + const upstream = this.upstream + if (!upstream) { + return null + } + + const pieces = upstream.match(/(.*?)\/.*/) + if (!pieces || pieces.length < 2) { + return null + } + + return pieces[1] + } + + /** The name of remote for a remote branch. If local, will return null. */ + public get remoteName(): string | null { + if (this.type === BranchType.Local) { + return null + } + + const pieces = this.ref.match(/^refs\/remotes\/(.*?)\/.*/) + if (!pieces || pieces.length !== 2) { + // This shouldn't happen, the remote ref should always be prefixed + // with refs/remotes + throw new Error(`Remote branch ref has unexpected format: ${this.ref}`) + } + return pieces[1] + } + /** + * The name of the branch's upstream without the remote prefix. + */ + public get upstreamWithoutRemote(): string | null { + if (!this.upstream) { + return null + } + + return removeRemotePrefix(this.upstream) + } + + /** + * The name of the branch without the remote prefix. If the branch is a local + * branch, this is the same as its `name`. + */ + public get nameWithoutRemote(): string { + if (this.type === BranchType.Local) { + return this.name + } else { + const withoutRemote = removeRemotePrefix(this.name) + return withoutRemote || this.name + } + } + + /** + * Gets a value indicating whether the branch is a remote branch belonging to + * one of Desktop's automatically created (and pruned) fork remotes. I.e. a + * remote branch from a branch which starts with `github-desktop-`. + * + * We hide branches from our known Desktop for remotes as these are considered + * plumbing and can add noise to everywhere in the user interface where we + * display branches as forks will likely contain duplicates of the same ref + * names + **/ + public get isDesktopForkRemoteBranch() { + return ( + this.type === BranchType.Remote && + this.name.startsWith(ForkedRemotePrefix) + ) + } +} diff --git a/app/src/models/branches-tab.ts b/app/src/models/branches-tab.ts new file mode 100644 index 0000000000..25c039d442 --- /dev/null +++ b/app/src/models/branches-tab.ts @@ -0,0 +1,5 @@ +/** The Branches foldout tabs. */ +export enum BranchesTab { + Branches = 0, + PullRequests, +} diff --git a/app/src/models/cherry-pick.ts b/app/src/models/cherry-pick.ts new file mode 100644 index 0000000000..e8ee2f72ba --- /dev/null +++ b/app/src/models/cherry-pick.ts @@ -0,0 +1,16 @@ +import { CommitOneLine } from './commit' +import { IMultiCommitOperationProgress } from './progress' + +/** Represents a snapshot of the cherry pick state from the Git repository */ +export interface ICherryPickSnapshot { + /** The sequence of commits remaining to be cherry picked */ + readonly remainingCommits: ReadonlyArray + /** The sequence of commits being cherry picked */ + readonly commits: ReadonlyArray + /** The progress of the operation */ + readonly progress: IMultiCommitOperationProgress + /** The sha of the target branch tip before cherry pick initiated. */ + readonly targetBranchUndoSha: string + /** The number of commits already cherry-picked */ + readonly cherryPickedCount: number +} diff --git a/app/src/models/clone-options.ts b/app/src/models/clone-options.ts new file mode 100644 index 0000000000..b50cccf4e8 --- /dev/null +++ b/app/src/models/clone-options.ts @@ -0,0 +1,11 @@ +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. */ + readonly defaultBranch?: string +} diff --git a/app/src/models/clone-repository-tab.ts b/app/src/models/clone-repository-tab.ts new file mode 100644 index 0000000000..d0506c019d --- /dev/null +++ b/app/src/models/clone-repository-tab.ts @@ -0,0 +1,5 @@ +export enum CloneRepositoryTab { + DotCom = 0, + Enterprise, + Generic, +} diff --git a/app/src/models/cloning-repository.ts b/app/src/models/cloning-repository.ts new file mode 100644 index 0000000000..74d67bd0a4 --- /dev/null +++ b/app/src/models/cloning-repository.ts @@ -0,0 +1,26 @@ +import * as Path from 'path' + +let CloningRepositoryID = 1 + +/** A repository which is currently being cloned. */ +export class CloningRepository { + public readonly id = CloningRepositoryID++ + + public constructor( + public readonly path: string, + public readonly url: string + ) {} + + public get name(): string { + return Path.basename(this.url, '.git') + } + + /** + * A hash of the properties of the object. + * + * Objects with the same hash are guaranteed to be structurally equal. + */ + public get hash(): string { + return `${this.id}+${this.path}+${this.url}` + } +} diff --git a/app/src/models/commit-identity.ts b/app/src/models/commit-identity.ts new file mode 100644 index 0000000000..d66d681631 --- /dev/null +++ b/app/src/models/commit-identity.ts @@ -0,0 +1,62 @@ +/** + * A tuple of name, email, and date for the author or commit + * info in a commit. + */ +export class CommitIdentity { + /** + * Parses a Git ident string (GIT_AUTHOR_IDENT or GIT_COMMITTER_IDENT) + * into a commit identity. Throws an error if identify string is invalid. + */ + public static parseIdentity(identity: string): CommitIdentity { + // See fmt_ident in ident.c: + // https://github.com/git/git/blob/3ef7618e6/ident.c#L346 + // + // Format is "NAME DATE" + // Markus Olsson 1475670580 +0200 + // + // Note that `git var` will strip any < and > from the name and email, see: + // https://github.com/git/git/blob/3ef7618e6/ident.c#L396 + // + // Note also that this expects a date formatted with the RAW option in git see: + // https://github.com/git/git/blob/35f6318d4/date.c#L191 + // + const m = identity.match(/^(.*?) <(.*?)> (\d+) (\+|-)?(\d{2})(\d{2})/) + if (!m) { + throw new Error(`Couldn't parse identity ${identity}`) + } + + const name = m[1] + const email = m[2] + // The date is specified as seconds from the epoch, + // Date() expects milliseconds since the epoch. + const date = new Date(parseInt(m[3], 10) * 1000) + + if (isNaN(date.valueOf())) { + throw new Error(`Couldn't parse identity ${identity}, invalid date`) + } + + // The RAW option never uses alphanumeric timezone identifiers and in my + // testing I've never found it to omit the leading + for a positive offset + // but the docs for strprintf seems to suggest it might on some systems so + // we're playing it safe. + const tzSign = m[4] === '-' ? '-' : '+' + const tzHH = m[5] + const tzmm = m[6] + + const tzMinutes = parseInt(tzHH, 10) * 60 + parseInt(tzmm, 10) + const tzOffset = tzMinutes * (tzSign === '-' ? -1 : 1) + + return new CommitIdentity(name, email, date, tzOffset) + } + + public constructor( + public readonly name: string, + public readonly email: string, + public readonly date: Date, + public readonly tzOffset: number = new Date().getTimezoneOffset() + ) {} + + public toString() { + return `${this.name} <${this.email}>` + } +} diff --git a/app/src/models/commit-message.ts b/app/src/models/commit-message.ts new file mode 100644 index 0000000000..90f0bcbaae --- /dev/null +++ b/app/src/models/commit-message.ts @@ -0,0 +1,10 @@ +/** A commit message summary and description. */ +export interface ICommitMessage { + readonly summary: string + readonly description: string | null +} + +export const DefaultCommitMessage: ICommitMessage = { + summary: '', + description: '', +} diff --git a/app/src/models/commit.ts b/app/src/models/commit.ts new file mode 100644 index 0000000000..44a0d8c206 --- /dev/null +++ b/app/src/models/commit.ts @@ -0,0 +1,135 @@ +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 { + /** + * The summary of the commit message (required) + */ + readonly summary: string + /** + * Additional details for the commit message (optional) + */ + readonly description: string | null + /** + * Whether or not it should amend the last commit (optional, default: false) + */ + readonly amend?: boolean + /** + * An optional array of commit trailers (for example Co-Authored-By trailers) which will be appended to the commit message in accordance with the Git trailer configuration. + */ + readonly trailers?: ReadonlyArray +} + +/** + * Extract any Co-Authored-By trailers from an array of arbitrary + * trailers. + */ +function extractCoAuthors(trailers: ReadonlyArray) { + const coAuthors = [] + + for (const trailer of trailers) { + if (isCoAuthoredByTrailer(trailer)) { + const author = GitAuthor.parse(trailer.value) + if (author) { + coAuthors.push(author) + } + } + } + + return coAuthors +} + +function trimCoAuthorsTrailers( + trailers: ReadonlyArray, + body: string +) { + let trimmedCoAuthors = body + + trailers.filter(isCoAuthoredByTrailer).forEach(({ token, value }) => { + trimmedCoAuthors = trimmedCoAuthors.replace(`${token}: ${value}`, '') + }) + + return trimmedCoAuthors +} + +/** + * A minimal shape of data to represent a commit, for situations where the + * application does not require the full commit metadata. + * + * Equivalent to the output where Git command support the + * `--oneline --no-abbrev-commit` arguments to format a commit. + */ +export type CommitOneLine = { + /** The full commit id associated with the commit */ + readonly sha: string + /** The first line of the commit message */ + readonly summary: string +} + +/** A git commit. */ +export class Commit { + /** + * A list of co-authors parsed from the commit message + * trailers. + */ + public readonly coAuthors: ReadonlyArray + + /** + * The commit body after removing coauthors + */ + public readonly bodyNoCoAuthors: string + + /** + * A value indicating whether the author and the committer + * are the same person. + */ + public readonly authoredByCommitter: boolean + + /** + * Whether or not the commit is a merge commit (i.e. has at least 2 parents) + */ + public readonly isMergeCommit: boolean + + /** + * @param sha The commit's SHA. + * @param shortSha The commit's shortSHA. + * @param summary The first line of the commit message. + * @param body The commit message without the first line and CR. + * @param author Information about the author of this commit. + * Includes name, email and date. + * @param committer Information about the committer of this commit. + * Includes name, email and date. + * @param parentSHAS The SHAs for the parents of the commit. + * @param trailers Parsed, unfolded trailers from the commit message body, + * if any, as interpreted by `git interpret-trailers` + * @param tags Tags associated with this commit. + */ + public constructor( + public readonly sha: string, + public readonly shortSha: string, + public readonly summary: string, + public readonly body: string, + public readonly author: CommitIdentity, + public readonly committer: CommitIdentity, + public readonly parentSHAs: ReadonlyArray, + public readonly trailers: ReadonlyArray, + public readonly tags: ReadonlyArray + ) { + this.coAuthors = extractCoAuthors(trailers) + + this.authoredByCommitter = + this.author.name === this.committer.name && + this.author.email === this.committer.email + + this.bodyNoCoAuthors = trimCoAuthorsTrailers(trailers, body) + + this.isMergeCommit = parentSHAs.length > 1 + } +} diff --git a/app/src/models/computed-action.ts b/app/src/models/computed-action.ts new file mode 100644 index 0000000000..5e33546f08 --- /dev/null +++ b/app/src/models/computed-action.ts @@ -0,0 +1,13 @@ +/** + * An action being computed in the background on behalf of the user + */ +export enum ComputedAction { + /** The action is being computed in the background */ + Loading = 'loading', + /** The action should complete without any additional work required by the user */ + Clean = 'clean', + /** The action requires additional work by the user to complete successfully */ + Conflicts = 'conflicts', + /** The action cannot be completed, for reasons the app should explain */ + Invalid = 'invalid', +} diff --git a/app/src/models/diff/diff-data.ts b/app/src/models/diff/diff-data.ts new file mode 100644 index 0000000000..4ebeab139b --- /dev/null +++ b/app/src/models/diff/diff-data.ts @@ -0,0 +1,128 @@ +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 + */ +export const maximumDiffStringSize = 268435441 + +export enum DiffType { + /** Changes to a text file, which may be partially selected for commit */ + Text, + /** Changes to a file with a known extension, which can be viewed in the app */ + Image, + /** Changes to an unknown file format, which Git is unable to present in a human-friendly format */ + Binary, + /** Change to a repository which is included as a submodule of this repository */ + Submodule, + /** Diff is large enough to degrade ux if rendered */ + LargeText, + /** Diff that will not be rendered */ + Unrenderable, +} + +type LineEnding = 'CR' | 'LF' | 'CRLF' + +export type LineEndingsChange = { + from: LineEnding + to: LineEnding +} + +/** Parse the line ending string into an enum value (or `null` if unknown) */ +export function parseLineEndingText(text: string): LineEnding | null { + const input = text.trim() + switch (input) { + case 'CR': + return 'CR' + case 'LF': + return 'LF' + case 'CRLF': + return 'CRLF' + default: + return null + } +} + +/** + * Data returned as part of a textual diff from Desktop + */ +interface ITextDiffData { + /** The unified text diff - including headers and context */ + readonly text: string + /** The diff contents organized by hunk - how the git CLI outputs to the caller */ + readonly hunks: ReadonlyArray + /** A warning from Git that the line endings have changed in this file and will affect the commit */ + readonly lineEndingsChange?: LineEndingsChange + /** The largest line number in the diff */ + readonly maxLineNumber: number + /** Whether or not the diff has invisible bidi characters */ + readonly hasHiddenBidiChars: boolean +} + +export interface ITextDiff extends ITextDiffData { + readonly kind: DiffType.Text +} + +/** + * Data returned as part of an image diff in Desktop + */ +export interface IImageDiff { + readonly kind: DiffType.Image + + /** + * The previous image, if the file was modified or deleted + * + * Will be undefined for an added image + */ + readonly previous?: Image + /** + * The current image, if the file was added or modified + * + * Will be undefined for a deleted image + */ + readonly current?: Image +} + +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 +} + +export interface IUnrenderableDiff { + readonly kind: DiffType.Unrenderable +} + +/** The union of diff types that can be rendered in Desktop */ +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 new file mode 100644 index 0000000000..f562094c66 --- /dev/null +++ b/app/src/models/diff/diff-line.ts @@ -0,0 +1,52 @@ +/** indicate what a line in the diff represents */ +export enum DiffLineType { + Context, + Add, + Delete, + Hunk, +} + +/** track details related to each line in the diff */ +export class DiffLine { + public constructor( + public readonly text: string, + public readonly type: DiffLineType, + // Line number in the original diff patch (before expanding it), or null if + // it was added as part of a diff expansion action. + public readonly originalLineNumber: number | null, + public readonly oldLineNumber: number | null, + public readonly newLineNumber: number | null, + public readonly noTrailingNewLine: boolean = false + ) {} + + public withNoTrailingNewLine(noTrailingNewLine: boolean): DiffLine { + return new DiffLine( + this.text, + this.type, + this.originalLineNumber, + this.oldLineNumber, + this.newLineNumber, + noTrailingNewLine + ) + } + + public isIncludeableLine() { + return this.type === DiffLineType.Add || this.type === DiffLineType.Delete + } + + /** The content of the line, i.e., without the line type marker. */ + 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 new file mode 100644 index 0000000000..3adbf4c4b2 --- /dev/null +++ b/app/src/models/diff/diff-selection.ts @@ -0,0 +1,283 @@ +import { assertNever } from '../../lib/fatal-error' + +/** + * The state of a file's diff selection + */ +export enum DiffSelectionType { + /** The entire file should be committed */ + All = 'All', + /** A subset of lines in the file have been selected for committing */ + Partial = 'Partial', + /** The file should be excluded from committing */ + None = 'None', +} + +/** + * Utility function which determines whether a boolean selection state + * matches the given DiffSelectionType. A true selection state matches + * DiffSelectionType.All, a false selection state matches + * DiffSelectionType.None and if the selection type is partial there's + * never a match. + */ +function typeMatchesSelection( + selectionType: DiffSelectionType, + selected: boolean +): boolean { + switch (selectionType) { + case DiffSelectionType.All: + return selected + case DiffSelectionType.None: + return !selected + case DiffSelectionType.Partial: + return false + default: + return assertNever( + selectionType, + `Unknown selection type ${selectionType}` + ) + } +} + +/** + * An immutable, efficient, storage object for tracking selections of indexable + * lines. While general purpose by design this is currently used exclusively for + * tracking selected lines in modified files in the working directory. + * + * This class starts out with an initial (or default) selection state, ie + * either all lines are selected by default or no lines are selected by default. + * + * The selection can then be transformed by marking a line or a range of lines + * as selected or not selected. Internally the class maintains a list of lines + * whose selection state has diverged from the default selection state. + */ +export class DiffSelection { + /** + * Initialize a new selection instance where either all lines are selected by default + * or not lines are selected by default. + */ + public static fromInitialSelection( + initialSelection: DiffSelectionType.All | DiffSelectionType.None + ): DiffSelection { + if ( + initialSelection !== DiffSelectionType.All && + initialSelection !== DiffSelectionType.None + ) { + return assertNever( + initialSelection, + 'Can only instantiate a DiffSelection with All or None as the initial selection' + ) + } + + return new DiffSelection(initialSelection, null, null) + } + + /** + * @param divergingLines Any line numbers where the selection differs from the default state. + * @param selectableLines Optional set of line numbers which can be selected. + */ + private constructor( + private readonly defaultSelectionType: + | DiffSelectionType.All + | DiffSelectionType.None, + private readonly divergingLines: Set | null = null, + private readonly selectableLines: Set | null = null + ) {} + + /** Returns a value indicating the computed overall state of the selection */ + public getSelectionType(): DiffSelectionType { + const divergingLines = this.divergingLines + const selectableLines = this.selectableLines + + // No diverging lines, happy path. Either all lines are selected or none are. + if (!divergingLines) { + return this.defaultSelectionType + } + if (divergingLines.size === 0) { + return this.defaultSelectionType + } + + // If we know which lines are selectable we need to check that + // all lines are divergent and return the inverse of default selection. + // To avoid looping through the set that often our happy path is + // if there's a size mismatch. + if (selectableLines && selectableLines.size === divergingLines.size) { + const allSelectableLinesAreDivergent = [...selectableLines].every(i => + divergingLines.has(i) + ) + + if (allSelectableLinesAreDivergent) { + return this.defaultSelectionType === DiffSelectionType.All + ? DiffSelectionType.None + : DiffSelectionType.All + } + } + + // Note that without any selectable lines we'll report partial selection + // as long as we have any diverging lines since we have no way of knowing + // if _all_ lines are divergent or not + return DiffSelectionType.Partial + } + + /** Returns a value indicating wether the given line number is selected or not */ + public isSelected(lineIndex: number): boolean { + const lineIsDivergent = + !!this.divergingLines && this.divergingLines.has(lineIndex) + + if (this.defaultSelectionType === DiffSelectionType.All) { + return !lineIsDivergent + } else if (this.defaultSelectionType === DiffSelectionType.None) { + return lineIsDivergent + } else { + return assertNever( + this.defaultSelectionType, + `Unknown base selection type ${this.defaultSelectionType}` + ) + } + } + + /** + * 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 + * line. + */ + public isSelectable(lineIndex: number): boolean { + return this.selectableLines ? this.selectableLines.has(lineIndex) : true + } + + /** + * Returns a copy of this selection instance with the provided + * line selection update. + * + * @param lineIndex The index (line number) of the line which should + * be selected or unselected. + * + * @param selected Whether the given line number should be marked + * as selected or not. + */ + public withLineSelection( + lineIndex: number, + selected: boolean + ): DiffSelection { + return this.withRangeSelection(lineIndex, 1, selected) + } + + /** + * Returns a copy of this selection instance with the provided + * line selection update. This is similar to the withLineSelection + * method except that it allows updating the selection state of + * a range of lines at once. Use this if you ever need to modify + * the selection state of more than one line at a time as it's + * more efficient. + * + * @param from The line index (inclusive) from where to start + * updating the line selection state. + * + * @param to The number of lines for which to update the + * selection state. A value of zero means no lines + * are updated and a value of 1 means only the + * line given by lineIndex will be updated. + * + * @param selected Whether the lines should be marked as selected + * or not. + */ + // Lower inclusive, upper exclusive. Same as substring + public withRangeSelection( + from: number, + length: number, + selected: boolean + ): DiffSelection { + const computedSelectionType = this.getSelectionType() + const to = from + length + + // Nothing for us to do here. This state is when all lines are already + // selected and we're being asked to select more or when no lines are + // selected and we're being asked to unselect something. + if (typeMatchesSelection(computedSelectionType, selected)) { + return this + } + + if (computedSelectionType === DiffSelectionType.Partial) { + const newDivergingLines = new Set(this.divergingLines!) + + if (typeMatchesSelection(this.defaultSelectionType, selected)) { + for (let i = from; i < to; i++) { + newDivergingLines.delete(i) + } + } else { + for (let i = from; i < to; i++) { + // Ensure it's selectable + if (this.isSelectable(i)) { + newDivergingLines.add(i) + } + } + } + + return new DiffSelection( + this.defaultSelectionType, + newDivergingLines.size === 0 ? null : newDivergingLines, + this.selectableLines + ) + } else { + const newDivergingLines = new Set() + for (let i = from; i < to; i++) { + if (this.isSelectable(i)) { + newDivergingLines.add(i) + } + } + + return new DiffSelection( + computedSelectionType, + newDivergingLines, + this.selectableLines + ) + } + } + + /** + * Returns a copy of this selection instance where the selection state + * of the specified line has been toggled (inverted). + * + * @param lineIndex The index (line number) of the line which should + * be selected or unselected. + */ + public withToggleLineSelection(lineIndex: number): DiffSelection { + return this.withLineSelection(lineIndex, !this.isSelected(lineIndex)) + } + + /** + * Returns a copy of this selection instance with all lines selected. + */ + public withSelectAll(): DiffSelection { + return new DiffSelection(DiffSelectionType.All, null, this.selectableLines) + } + + /** + * Returns a copy of this selection instance with no lines selected. + */ + public withSelectNone(): DiffSelection { + return new DiffSelection(DiffSelectionType.None, null, this.selectableLines) + } + + /** + * Returns a copy of this selection instance with a specified set of + * selectable lines. By default a DiffSelection instance allows selecting + * all lines (in fact, it has no notion of how many lines exists or what + * it is that is being selected). + * + * If the selection instance lacks a set of selectable lines it can not + * supply an accurate value from getSelectionType when the selection of + * all lines have diverged from the default state (since it doesn't know + * what all lines mean). + */ + public withSelectableLines(selectableLines: Set) { + const divergingLines = this.divergingLines + ? new Set([...this.divergingLines].filter(x => selectableLines.has(x))) + : null + + return new DiffSelection( + this.defaultSelectionType, + divergingLines, + selectableLines + ) + } +} diff --git a/app/src/models/diff/image-diff.ts b/app/src/models/diff/image-diff.ts new file mode 100644 index 0000000000..3047380b7b --- /dev/null +++ b/app/src/models/diff/image-diff.ts @@ -0,0 +1,14 @@ +/** The image diff type. */ +export enum ImageDiffType { + /** Show the old and new images side by side. */ + TwoUp, + + /** Swipe between the old and new image. */ + Swipe, + + /** Onion skin. */ + OnionSkin, + + /** Highlight differences. */ + Difference, +} diff --git a/app/src/models/diff/image.ts b/app/src/models/diff/image.ts new file mode 100644 index 0000000000..9eb4702e37 --- /dev/null +++ b/app/src/models/diff/image.ts @@ -0,0 +1,15 @@ +/** + * A container for holding an image for display in the application + */ +export class Image { + /** + * @param contents The base64 encoded contents of the image. + * @param mediaType The data URI media type, so the browser can render the image correctly. + * @param bytes Size of the file in bytes. + */ + public constructor( + public readonly contents: string, + public readonly mediaType: string, + public readonly bytes: number + ) {} +} diff --git a/app/src/models/diff/index.ts b/app/src/models/diff/index.ts new file mode 100644 index 0000000000..75f48f19ac --- /dev/null +++ b/app/src/models/diff/index.ts @@ -0,0 +1,6 @@ +export * from './diff-data' +export * from './diff-line' +export * from './diff-selection' +export * from './image' +export * from './raw-diff' +export * from './image-diff' diff --git a/app/src/models/diff/raw-diff.ts b/app/src/models/diff/raw-diff.ts new file mode 100644 index 0000000000..e38e3b1ba6 --- /dev/null +++ b/app/src/models/diff/raw-diff.ts @@ -0,0 +1,128 @@ +import { DiffLine } from './diff-line' + +export enum DiffHunkExpansionType { + /** The hunk header cannot be expanded at all. */ + None = 'None', + + /** + * The hunk header can be expanded up exclusively. Only the first hunk can be + * expanded up exclusively. + */ + Up = 'Up', + + /** + * The hunk header can be expanded down exclusively. Only the last hunk (if + * it's the dummy hunk with only one line) can be expanded down exclusively. + */ + Down = 'Down', + + /** The hunk header can be expanded both up and down. */ + Both = 'Both', + + /** + * The hunk header represents a short gap that, when expanded, will + * result in merging this hunk and the hunk above. + */ + Short = 'Short', +} + +/** each diff is made up of a number of hunks */ +export class DiffHunk { + /** + * @param header The details from the diff hunk header about the line start and patch length. + * @param lines The contents - context and changes - of the diff section. + * @param unifiedDiffStart The diff hunk's start position in the overall file diff. + * @param unifiedDiffEnd The diff hunk's end position in the overall file diff. + */ + public constructor( + public readonly header: DiffHunkHeader, + public readonly lines: ReadonlyArray, + public readonly unifiedDiffStart: number, + 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 */ +export class DiffHunkHeader { + /** + * @param oldStartLine The line in the old (or original) file where this diff hunk starts. + * @param oldLineCount The number of lines in the old (or original) file that this diff hunk covers + * @param newStartLine The line in the new file where this diff hunk starts. + * @param newLineCount The number of lines in the new file that this diff hunk covers. + */ + public constructor( + public readonly oldStartLine: number, + public readonly oldLineCount: number, + public readonly newStartLine: number, + public readonly newLineCount: number + ) {} + + 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 */ +export interface IRawDiff { + /** + * The plain text contents of the diff header. This contains + * everything from the start of the diff up until the first + * hunk header starts. Note that this does not include a trailing + * newline. + */ + readonly header: string + + /** + * The plain text contents of the diff. This contains everything + * after the diff header until the last character in the diff. + * + * Note that this does not include a trailing newline nor does + * it include diff 'no newline at end of file' comments. For + * no-newline information, consult the DiffLine noTrailingNewLine + * property. + */ + readonly contents: string + + /** + * Each hunk in the diff with information about start, and end + * positions, lines and line statuses. + */ + readonly hunks: ReadonlyArray + + /** + * Whether or not the unified diff indicates that the contents + * could not be diffed due to one of the versions being binary. + */ + readonly isBinary: boolean + + /** The largest line number in the diff */ + readonly maxLineNumber: number + + /** Whether or not the diff has invisible bidi characters */ + readonly hasHiddenBidiChars: boolean +} diff --git a/app/src/models/drag-drop.ts b/app/src/models/drag-drop.ts new file mode 100644 index 0000000000..18b75d75f4 --- /dev/null +++ b/app/src/models/drag-drop.ts @@ -0,0 +1,62 @@ +import { RowIndexPath } from '../ui/lib/list/list-row-index-path' +import { Commit } from './commit' +import { GitHubRepository } from './github-repository' + +/** + * This is a type is used in conjunction with the drag and drop manager to + * store and specify the types of data that are being dragged + * + * Thus, using a `|` here would allow us to specify multiple types of data that + * can be dragged. + */ +export type DragData = CommitDragData + +export type CommitDragData = { + type: DragType.Commit + commits: ReadonlyArray +} + +export enum DragType { + Commit, +} + +export type DragElement = { + type: DragType.Commit + commit: Commit + selectedCommits: ReadonlyArray + gitHubRepository: GitHubRepository | null +} + +export enum DropTargetType { + Branch, + Commit, + ListInsertionPoint, +} + +export enum DropTargetSelector { + Branch = '.branches-list-item', + PullRequest = '.pull-request-item', + Commit = '.commit', + ListInsertionPoint = '.list-insertion-point', +} + +export type BranchTarget = { + type: DropTargetType.Branch + branchName: string +} + +export type CommitTarget = { + type: DropTargetType.Commit +} + +export type ListInsertionPointTarget = { + type: DropTargetType.ListInsertionPoint + data: DragData + index: RowIndexPath +} + +/** + * This is a type is used in conjunction with the drag and drop manager to + * pass information about a drop target. + */ +export type DropTarget = BranchTarget | CommitTarget | ListInsertionPointTarget diff --git a/app/src/models/equality-hash.ts b/app/src/models/equality-hash.ts new file mode 100644 index 0000000000..48974a3347 --- /dev/null +++ b/app/src/models/equality-hash.ts @@ -0,0 +1,17 @@ +/** + * Types which can safely be coerced to strings without losing information. + * As an example `1234.toString()` doesn't lose any information whereas + * `({ foo: bar }).toString()` does (`[Object object]`). + */ +type HashableType = number | string | boolean | undefined | null + +/** + * Creates a string representation of the provided arguments. + * + * This is a helper function used to create a string representation of + * an object based on its properties for the purposes of simple equality + * comparisons. + */ +export function createEqualityHash(...items: HashableType[]) { + return items.join('+') +} diff --git a/app/src/models/fetch.ts b/app/src/models/fetch.ts new file mode 100644 index 0000000000..80280eb683 --- /dev/null +++ b/app/src/models/fetch.ts @@ -0,0 +1,8 @@ +/** + * Enum used by fetch to determine if + * a fetch was initiated by the backgroundFetcher + */ +export enum FetchType { + BackgroundTask, + UserInitiatedTask, +} diff --git a/app/src/models/git-account.ts b/app/src/models/git-account.ts new file mode 100644 index 0000000000..0f5e04918c --- /dev/null +++ b/app/src/models/git-account.ts @@ -0,0 +1,10 @@ +/** + * An account which can be used to potentially authenticate with a git server. + */ +export interface IGitAccount { + /** The login/username to authenticate with. */ + readonly login: string + + /** The endpoint with which the user is authenticating. */ + readonly endpoint: string +} diff --git a/app/src/models/git-author.ts b/app/src/models/git-author.ts new file mode 100644 index 0000000000..8f475a0ecf --- /dev/null +++ b/app/src/models/git-author.ts @@ -0,0 +1,15 @@ +export class GitAuthor { + public static parse(nameAddr: string): GitAuthor | null { + const m = nameAddr.match(/^(.*?)\s+<(.*?)>/) + return m === null ? null : new GitAuthor(m[1], m[2]) + } + + public constructor( + public readonly name: string, + public readonly email: string + ) {} + + public toString() { + return `${this.name} <${this.email}>` + } +} diff --git a/app/src/models/github-repository.ts b/app/src/models/github-repository.ts new file mode 100644 index 0000000000..794985684c --- /dev/null +++ b/app/src/models/github-repository.ts @@ -0,0 +1,84 @@ +import { createEqualityHash } from './equality-hash' +import { Owner } from './owner' + +export type GitHubRepositoryPermission = 'read' | 'write' | 'admin' | null + +/** A GitHub repository. */ +export class GitHubRepository { + /** + * A hash of the properties of the object. + * + * Objects with the same hash are guaranteed to be structurally equal. + */ + public readonly hash: string + + public constructor( + public readonly name: string, + public readonly owner: Owner, + /** + * The ID of the repository in the app's local database. This is no relation + * to the API ID. + */ + public readonly dbID: number, + public readonly isPrivate: boolean | null = null, + public readonly htmlURL: string | null = null, + public readonly cloneURL: string | null = null, + public readonly issuesEnabled: boolean | null = null, + public readonly isArchived: boolean | null = null, + /** The user's permissions for this github repository. `null` if unknown. */ + public readonly permissions: GitHubRepositoryPermission = null, + public readonly parent: GitHubRepository | null = null + ) { + this.hash = createEqualityHash( + this.name, + this.owner.login, + this.dbID, + this.isPrivate, + this.htmlURL, + this.cloneURL, + this.issuesEnabled, + this.isArchived, + this.permissions, + this.parent?.hash + ) + } + + public get endpoint(): string { + return this.owner.endpoint + } + + /** Get the owner/name combo. */ + public get fullName(): string { + return `${this.owner.login}/${this.name}` + } + + /** Is the repository a fork? */ + public get fork(): boolean { + return !!this.parent + } +} + +/** + * Identical to `GitHubRepository`, except it **must** have a `parent` + * (i.e it's a fork). + * + * See `isRepositoryWithForkedGitHubRepository` + */ +export type ForkedGitHubRepository = GitHubRepository & { + readonly parent: GitHubRepository + readonly fork: true +} + +/** + * Can the user push to this GitHub repository? + * + * (If their permissions are unknown, we assume they can.) + */ +export function hasWritePermission( + gitHubRepository: GitHubRepository +): boolean { + return ( + gitHubRepository.permissions === null || + gitHubRepository.permissions !== 'read' + ) +} diff --git a/app/src/models/last-thank-you.ts b/app/src/models/last-thank-you.ts new file mode 100644 index 0000000000..33cb9fd999 --- /dev/null +++ b/app/src/models/last-thank-you.ts @@ -0,0 +1,4 @@ +export interface ILastThankYou { + version: string + checkedUsers: ReadonlyArray +} diff --git a/app/src/models/manual-conflict-resolution.ts b/app/src/models/manual-conflict-resolution.ts new file mode 100644 index 0000000000..632d8bf377 --- /dev/null +++ b/app/src/models/manual-conflict-resolution.ts @@ -0,0 +1,7 @@ +// NOTE: These strings have semantic value, they're passed directly +// as `--ours` and `--theirs` to git checkout. Please be careful +// when modifying this type. +export enum ManualConflictResolution { + theirs = 'theirs', + ours = 'ours', +} diff --git a/app/src/models/menu-ids.ts b/app/src/models/menu-ids.ts new file mode 100644 index 0000000000..bcabc009a3 --- /dev/null +++ b/app/src/models/menu-ids.ts @@ -0,0 +1,40 @@ +/** A list of menu ids associated with the main menu in GitHub Desktop */ +export type MenuIDs = + | 'rename-branch' + | 'delete-branch' + | 'discard-all-changes' + | 'stash-all-changes' + | 'preferences' + | 'update-branch-with-contribution-target-branch' + | 'merge-branch' + | 'squash-and-merge-branch' + | 'rebase-branch' + | 'view-repository-on-github' + | 'compare-on-github' + | 'branch-on-github' + | 'open-in-shell' + | 'push' + | 'pull' + | 'branch' + | 'repository' + | 'go-to-commit-message' + | 'create-branch' + | 'show-changes' + | 'show-history' + | 'show-repository-list' + | 'show-branches-list' + | 'open-working-directory' + | 'show-repository-settings' + | 'open-external-editor' + | 'remove-repository' + | 'new-repository' + | 'add-local-repository' + | 'clone-repository' + | 'about' + | 'create-pull-request' + | '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 new file mode 100644 index 0000000000..1093e0c474 --- /dev/null +++ b/app/src/models/menu-labels.ts @@ -0,0 +1,63 @@ +import { Shell } from '../lib/shells' + +export type MenuLabelsEvent = { + /** + * Specify the user's selected shell to display in the menu. + * + * Specify `null` to indicate that it is not known currently, which will + * default to a placeholder based on the current platform. + */ + readonly selectedShell: Shell | null + + /** + * Specify the user's selected editor to display in the menu. + * + * Specify `null` to indicate that it is not known currently, which will + * default to a placeholder based on the current platform. + */ + readonly selectedExternalEditor: string | null + + /** + * Has the use enabled "Show confirmation dialog before force pushing"? + */ + readonly askForConfirmationOnForcePush: boolean + + /** + * Has the use enabled "Show confirmation dialog before removing repositories"? + */ + readonly askForConfirmationOnRepositoryRemoval: boolean + + /** + * 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 contributionTargetDefaultBranch?: string + + /** + * Is the current branch in a state where it can be force pushed to the remote? + */ + readonly isForcePushForCurrentRepository?: boolean + + /** + * Specify whether a pull request is associated with the current branch. + */ + readonly hasCurrentPullRequest?: boolean + + /** + * Specify whether a stashed change is accessible in the current branch. + */ + readonly isStashedChangesVisible?: boolean + + /** + * Whether or not attempting to stash working directory changes will result + * in a confirmation dialog asking the user whether they want to override + * their existing stash or not. + */ + readonly askForConfirmationWhenStashingAllChanges?: boolean +} diff --git a/app/src/models/merge.ts b/app/src/models/merge.ts new file mode 100644 index 0000000000..c4baf7a3a5 --- /dev/null +++ b/app/src/models/merge.ts @@ -0,0 +1,40 @@ +import { ComputedAction } from './computed-action' + +interface IBlobResult { + readonly mode: string + readonly sha: string + readonly path: string +} + +export interface IMergeTreeEntry { + readonly context: string + readonly base?: IBlobResult + readonly result?: IBlobResult + readonly our?: IBlobResult + readonly their?: IBlobResult + readonly diff: string + readonly hasConflicts?: boolean +} + +export type MergeTreeSuccess = { + readonly kind: ComputedAction.Clean +} + +export type MergeTreeError = { + readonly kind: ComputedAction.Conflicts + readonly conflictedFiles: number +} + +export type MergeTreeUnsupported = { + readonly kind: ComputedAction.Invalid +} + +export type MergeTreeLoading = { + readonly kind: ComputedAction.Loading +} + +export type MergeTreeResult = + | MergeTreeSuccess + | MergeTreeError + | MergeTreeUnsupported + | MergeTreeLoading diff --git a/app/src/models/multi-commit-operation.ts b/app/src/models/multi-commit-operation.ts new file mode 100644 index 0000000000..69a79f7d83 --- /dev/null +++ b/app/src/models/multi-commit-operation.ts @@ -0,0 +1,260 @@ +import { MultiCommitOperationConflictState } from '../lib/app-state' +import { Branch } from './branch' +import { Commit, CommitOneLine, ICommitContext } from './commit' +import { GitHubRepository } from './github-repository' +import { IDetachedHead, IUnbornRepository, IValidBranch } from './tip' + +/** + * Enum of types of multi commit operations + * + * Note: These are used to output the operation to the user + * and as such should be capitalized. + */ +export const enum MultiCommitOperationKind { + Rebase = 'Rebase', + CherryPick = 'Cherry-pick', + Squash = 'Squash', + Merge = 'Merge', + 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. + */ +export type MultiCommitOperationStep = + | ChooseBranchStep + | WarnForcePushStep + | ShowProgressStep + | ShowConflictsStep + | HideConflictsStep + | ConfirmAbortStep + | CreateBranchStep + +/** + * Possible kinds of steps that may happen during a multi commit operation such + * as rebase, interactive rebase, cherry-pick. + */ +export const enum MultiCommitOperationStepKind { + /** + * The step that the user picks which other branch is involved in the + * operation asides from the currently checked out branch. + * + * Examples: + * Rebase - what branch to rebase commits from + * Cherry-pick - what branch to copy commits to. + */ + ChooseBranch = 'ChooseBranch', + + /** + * Step to show dialog warning user if operation will result in needing to + * force push. + */ + WarnForcePush = 'WarnForcePush', + + /** + * Step to show a dialog that shows the operation is progressing. + */ + ShowProgress = 'ShowProgress', + + /** + * The operation has encountered conflicts that need resolved. This will be + * shown as a list of files and the conflict state. + * + * Once the conflicts are resolved, the user can continue the operation. + */ + ShowConflicts = 'ShowConflicts', + + /** + * The user may wish to leave the conflict dialog and view the files in + * the Changes tab to get a better context. In this situation, the application + * will show a banner to indicate this context and help the user return to the + * conflicted list. + */ + HideConflicts = 'HideConflicts', + + /** + * If the user attempts to abort the in-progress operation and the user has + * resolved conflicts, the application should ask the user to confirm that + * they wish to abort. + */ + ConfirmAbort = 'ConfirmAbort', + + /** + * If the user invokes creating a new branch during the operation, display + * a new branch dialog to them. + * + * Example: Cherry-picking to a new branch. + */ + CreateBranch = 'CreateBranch', +} + +export type ChooseBranchStep = { + readonly kind: MultiCommitOperationStepKind.ChooseBranch + readonly defaultBranch: Branch | null + readonly currentBranch: Branch + readonly allBranches: ReadonlyArray + readonly recentBranches: ReadonlyArray + readonly initialBranch?: Branch +} + +export type WarnForcePushStep = { + readonly kind: MultiCommitOperationStepKind.WarnForcePush + readonly baseBranch: Branch + readonly targetBranch: Branch + readonly commits: ReadonlyArray +} + +export type ShowProgressStep = { + readonly kind: MultiCommitOperationStepKind.ShowProgress +} + +export type ShowConflictsStep = { + readonly kind: MultiCommitOperationStepKind.ShowConflicts + readonly conflictState: MultiCommitOperationConflictState +} + +export type HideConflictsStep = { + readonly kind: MultiCommitOperationStepKind.HideConflicts + readonly conflictState: MultiCommitOperationConflictState +} + +export type ConfirmAbortStep = { + readonly kind: MultiCommitOperationStepKind.ConfirmAbort + readonly conflictState: MultiCommitOperationConflictState +} + +export type CreateBranchStep = { + readonly kind: MultiCommitOperationStepKind.CreateBranch + allBranches: ReadonlyArray + defaultBranch: Branch | null + upstreamDefaultBranch: Branch | null + upstreamGhRepo: GitHubRepository | null + tip: IUnbornRepository | IDetachedHead | IValidBranch + targetBranchName: string +} + +interface IBaseInteractiveRebaseDetails { + /** + * Array of commits used during the operation. + */ + readonly commits: ReadonlyArray + + /** + * This is the commit sha of the HEAD of the in-flight operation used to compare + * the state of the after an operation to a previous state. + */ + readonly currentTip: string +} + +interface IInteractiveRebaseDetails extends IBaseInteractiveRebaseDetails { + /** + * The reference to the last retained commit on the branch during an + * interactive rebase or null if rebasing to the root. + */ + readonly lastRetainedCommitRef: string | null +} + +interface ISourceBranchDetails { + /** + * The branch that are the source of the commits for the operation. + * + * Cherry-pick = the branch the user started on. + * Rebase, Merge = the branch the user picks in the choose branch dialog (thus will be null to start) + */ + readonly sourceBranch: Branch | null +} + +interface ISquashDetails extends IInteractiveRebaseDetails { + readonly kind: MultiCommitOperationKind.Squash + + /** + * A commit that the interactive rebase takes place around. + * + * Example: Squashing all the 'commits' array onto the 'targetCommit'. + */ + readonly targetCommit: Commit + + /** + * The commit context of the commit squashed. + */ + readonly commitContext: ICommitContext +} + +interface IReorderDetails extends IInteractiveRebaseDetails { + readonly kind: MultiCommitOperationKind.Reorder + + /** The commit before which the commits to reorder will be placed. */ + readonly beforeCommit: Commit | null +} + +interface ICherryPickDetails extends ISourceBranchDetails { + readonly kind: MultiCommitOperationKind.CherryPick + /** + * Whether a branch was created during operation. + * + * Example: can create a new branch to copy commits to during cherry-pick + */ + readonly branchCreated: boolean + + /** + * Array of commits used during the operation. + */ + readonly commits: ReadonlyArray +} + +interface IRebaseDetails extends ISourceBranchDetails { + readonly kind: MultiCommitOperationKind.Rebase + readonly commits: ReadonlyArray + /** + * This is the commit sha of the HEAD of the in-flight operation used to compare + * the state of the after an operation to a previous state. + */ + readonly currentTip: string +} + +interface IMergeDetails extends ISourceBranchDetails { + readonly kind: MultiCommitOperationKind.Merge + readonly isSquash: boolean +} + +export type MultiCommitOperationDetail = + | ISquashDetails + | IReorderDetails + | ICherryPickDetails + | IRebaseDetails + | IMergeDetails + +export function instanceOfIBaseRebaseDetails( + object: any +): object is IBaseInteractiveRebaseDetails { + const objectWithRequiredFields: IBaseInteractiveRebaseDetails = { + commits: [], + currentTip: '', + } + + return Object.keys(objectWithRequiredFields).every(key => key in object) +} + +export const conflictSteps = [ + MultiCommitOperationStepKind.ShowConflicts, + MultiCommitOperationStepKind.ConfirmAbort, +] diff --git a/app/src/models/owner.ts b/app/src/models/owner.ts new file mode 100644 index 0000000000..3e632dc43f --- /dev/null +++ b/app/src/models/owner.ts @@ -0,0 +1,14 @@ +import { GitHubAccountType } from '../lib/api' + +/** The owner of a GitHubRepository. */ +export class Owner { + /** + * @param id The database ID. This may be null if the object wasn't retrieved from the database. + */ + public constructor( + public readonly login: string, + public readonly endpoint: string, + public readonly id: number, + public readonly type?: GitHubAccountType + ) {} +} diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts new file mode 100644 index 0000000000..7726f0c317 --- /dev/null +++ b/app/src/models/popup.ts @@ -0,0 +1,426 @@ +import { + Repository, + RepositoryWithGitHubRepository, + RepositoryWithForkedGitHubRepository, +} from './repository' +import { PullRequest } from './pull-request' +import { Branch } from './branch' +import { ReleaseNote, ReleaseSummary } from './release-notes' +import { IRemote } from './remote' +import { RetryAction } from './retry-actions' +import { WorkingDirectoryFileChange } from './status' +import { PreferencesTab } from './preferences' +import { Commit, CommitOneLine, ICommitContext } from './commit' +import { IStashEntry } from './stash-entry' +import { Account } from '../models/account' +import { Progress } from './progress' +import { ITextDiff, DiffSelection, ImageDiffType } from './diff' +import { RepositorySettingsTab } from '../ui/repository-settings/repository-settings' +import { ICommitMessage } from './commit-message' +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 = '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', + ConfirmRepoRulesBypass = 'ConfirmRepoRulesBypass', +} + +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 + repository: Repository + branch: Branch + existsOnRemote: boolean + } + | { + type: PopupType.DeleteRemoteBranch + repository: Repository + branch: Branch + } + | { + type: PopupType.ConfirmDiscardChanges + repository: Repository + files: ReadonlyArray + showDiscardChangesSetting?: boolean + discardingAllChanges?: boolean + } + | { + type: PopupType.ConfirmDiscardSelection + repository: Repository + file: WorkingDirectoryFileChange + diff: ITextDiff + selection: DiffSelection + } + | { type: PopupType.Preferences; initialSelectedTab?: PreferencesTab } + | { + type: PopupType.RepositorySettings + repository: Repository + initialSelectedTab?: RepositorySettingsTab + } + | { type: PopupType.AddRepository; path?: string } + | { type: PopupType.CreateRepository; path?: string } + | { + type: PopupType.CloneRepository + initialURL: string | null + } + | { + type: PopupType.CreateBranch + repository: Repository + initialName?: string + targetCommit?: CommitOneLine + } + | { type: PopupType.SignIn } + | { type: PopupType.About } + | { type: PopupType.InstallGit; path: string } + | { type: PopupType.PublishRepository; repository: Repository } + | { type: PopupType.Acknowledgements } + | { + type: PopupType.UntrustedCertificate + certificate: Electron.Certificate + url: string + } + | { type: PopupType.RemoveRepository; repository: Repository } + | { type: PopupType.TermsAndConditions } + | { + type: PopupType.PushBranchCommits + repository: Repository + branch: Branch + unPushedCommits?: number + } + | { type: PopupType.CLIInstalled } + | { + type: PopupType.GenericGitAuthentication + hostname: string + retryAction: RetryAction + } + | { + type: PopupType.ExternalEditorFailed + message: string + suggestDefaultEditor?: boolean + openPreferences?: boolean + } + | { type: PopupType.OpenShellFailed; message: string } + | { type: PopupType.InitializeLFS; repositories: ReadonlyArray } + | { type: PopupType.LFSAttributeMismatch } + | { + type: PopupType.UpstreamAlreadyExists + repository: Repository + existingRemote: IRemote + } + | { + type: PopupType.ReleaseNotes + newReleases: ReadonlyArray + } + | { + type: PopupType.DeletePullRequest + repository: Repository + branch: Branch + pullRequest: PullRequest + } + | { + type: PopupType.OversizedFiles + oversizedFiles: ReadonlyArray + context: ICommitContext + repository: Repository + } + | { + type: PopupType.CommitConflictsWarning + /** files that were selected for committing that are also conflicted */ + files: ReadonlyArray + /** repository user is committing in */ + repository: Repository + /** information for completing the commit */ + context: ICommitContext + } + | { + type: PopupType.PushNeedsPull + repository: Repository + } + | { + type: PopupType.ConfirmForcePush + repository: Repository + upstreamBranch: string + } + | { + type: PopupType.StashAndSwitchBranch + repository: Repository + branchToCheckout: Branch + } + | { + type: PopupType.ConfirmOverwriteStash + repository: Repository + branchToCheckout: Branch | null + } + | { + type: PopupType.ConfirmDiscardStash + repository: Repository + stash: IStashEntry + } + | { + type: PopupType.ConfirmCheckoutCommit + repository: Repository + commit: CommitOneLine + } + | { + type: PopupType.CreateTutorialRepository + account: Account + progress?: Progress + } + | { + type: PopupType.ConfirmExitTutorial + } + | { + type: PopupType.PushRejectedDueToMissingWorkflowScope + rejectedPath: string + repository: RepositoryWithGitHubRepository + } + | { + type: PopupType.SAMLReauthRequired + organizationName: string + endpoint: string + retryAction?: RetryAction + } + | { + type: PopupType.CreateFork + repository: RepositoryWithGitHubRepository + account: Account + } + | { + type: PopupType.CreateTag + repository: Repository + targetCommitSha: string + initialName?: string + localTags: Map | null + } + | { + type: PopupType.DeleteTag + repository: Repository + tagName: string + } + | { + type: PopupType.ChooseForkSettings + repository: RepositoryWithForkedGitHubRepository + } + | { + type: PopupType.LocalChangesOverwritten + repository: Repository + retryAction: RetryAction + files: ReadonlyArray + } + | { type: PopupType.MoveToApplicationsFolder } + | { type: PopupType.ChangeRepositoryAlias; repository: Repository } + | { + type: PopupType.ThankYou + userContributions: ReadonlyArray + friendlyName: string + latestVersion: string | null + } + | { + type: PopupType.CommitMessage + coAuthors: ReadonlyArray + showCoAuthoredBy: boolean + commitMessage: ICommitMessage | null + dialogTitle: string + dialogButtonText: string + prepopulateCommitSummary: boolean + repository: Repository + onSubmitCommitMessage: (context: ICommitContext) => Promise + } + | { + type: PopupType.MultiCommitOperation + repository: Repository + } + | { + type: PopupType.WarnLocalChangesBeforeUndo + repository: Repository + commit: Commit + isWorkingDirectoryClean: boolean + } + | { + type: PopupType.WarningBeforeReset + repository: Repository + commit: Commit + } + | { + type: PopupType.InvalidatedToken + account: Account + } + | { + type: PopupType.AddSSHHost + host: string + ip: string + keyType: string + fingerprint: string + onSubmit: (addHost: boolean) => void + } + | { + type: PopupType.SSHKeyPassphrase + keyPath: string + onSubmit: ( + passphrase: string | undefined, + storePassphrase: boolean + ) => void + } + | { + type: PopupType.SSHUserPassword + username: string + onSubmit: (password: string | undefined, storePassword: boolean) => void + } + | { + type: PopupType.PullRequestChecksFailed + repository: RepositoryWithGitHubRepository + pullRequest: PullRequest + shouldChangeRepository: boolean + commitMessage: string + commitSha: string + checks: ReadonlyArray + } + | { + type: PopupType.CICheckRunRerun + checkRuns: ReadonlyArray + repository: GitHubRepository + prRef: string + failedOnly: boolean + } + | { type: PopupType.WarnForcePush; operation: string; onBegin: () => void } + | { + type: PopupType.DiscardChangesRetry + retryAction: RetryAction + } + | { + type: PopupType.PullRequestReview + repository: RepositoryWithGitHubRepository + pullRequest: PullRequest + review: ValidNotificationPullRequestReview + 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.ConfirmRepoRulesBypass + repository: GitHubRepository + branch: string + onConfirm: () => void + } + +export type Popup = IBasePopup & PopupDetail diff --git a/app/src/models/preferences.ts b/app/src/models/preferences.ts new file mode 100644 index 0000000000..8e4cabd560 --- /dev/null +++ b/app/src/models/preferences.ts @@ -0,0 +1,9 @@ +export enum PreferencesTab { + Accounts, + Integrations, + Git, + Appearance, + Notifications, + Prompts, + Advanced, +} diff --git a/app/src/models/progress.ts b/app/src/models/progress.ts new file mode 100644 index 0000000000..28802559d4 --- /dev/null +++ b/app/src/models/progress.ts @@ -0,0 +1,124 @@ +/** + * Base interface containing all the properties that progress events + * need to support. + */ +interface IProgress { + /** + * The overall progress of the operation, represented as a fraction between + * 0 and 1. + */ + readonly value: number + + /** + * An informative text for user consumption indicating the current operation + * state. This will be high level such as 'Pushing origin' or + * 'Fetching upstream' and will typically persist over a number of progress + * events. For more detailed information about the progress see + * the description field + */ + readonly title?: string + + /** + * An informative text for user consumption. In the case of git progress this + * will usually be the last raw line of output from git. + */ + readonly description?: string +} + +/** + * An object describing progression of an operation that can't be + * directly mapped or attributed to either one of the more specific + * progress events (Fetch, Checkout etc). An example of this would be + * our own refreshing of internal repository state that takes part + * after fetch, push and pull. + */ +export interface IGenericProgress extends IProgress { + kind: 'generic' +} + +/** + * An object describing the progression of a branch checkout operation + */ +export interface ICheckoutProgress extends IProgress { + kind: 'checkout' + + /** The branch or commit that's currently being checked out */ + readonly target: string + + /** + * Infotext for the user. + */ + readonly description: string +} + +/** + * An object describing the progression of a fetch operation + */ +export interface IFetchProgress extends IProgress { + kind: 'fetch' + + /** + * The remote that's being fetched + */ + readonly remote: string +} + +/** + * An object describing the progression of a pull operation + */ +export interface IPullProgress extends IProgress { + kind: 'pull' + + /** + * The remote that's being pulled from + */ + readonly remote: string +} + +/** + * An object describing the progression of a pull operation + */ +export interface IPushProgress extends IProgress { + kind: 'push' + + /** + * The remote that's being pushed to + */ + readonly remote: string + + /** + * The branch that's being pushed + */ + readonly branch: string +} + +/** + * An object describing the progression of a fetch operation + */ +export interface ICloneProgress extends IProgress { + kind: 'clone' +} + +/** An object describing the progression of a revert operation. */ +export interface IRevertProgress extends IProgress { + kind: 'revert' +} + +export interface IMultiCommitOperationProgress extends IProgress { + readonly kind: 'multiCommitOperation' + /** The summary of the commit applied */ + readonly currentCommitSummary: string + /** The number to signify which commit in a selection is being applied */ + readonly position: number + /** The total number of commits in the operation */ + readonly totalCommitCount: number +} + +export type Progress = + | IGenericProgress + | ICheckoutProgress + | IFetchProgress + | IPullProgress + | IPushProgress + | IRevertProgress + | IMultiCommitOperationProgress diff --git a/app/src/models/publish-settings.ts b/app/src/models/publish-settings.ts new file mode 100644 index 0000000000..e7e55a98ae --- /dev/null +++ b/app/src/models/publish-settings.ts @@ -0,0 +1,48 @@ +import { IAPIOrganization } from '../lib/api' + +export type RepositoryPublicationSettings = + | IEnterprisePublicationSettings + | IDotcomPublicationSettings + +export enum PublishSettingsType { + 'enterprise', + 'dotcom', +} + +export interface IEnterprisePublicationSettings { + readonly kind: PublishSettingsType.enterprise + + /** The name to use when publishing the repository. */ + readonly name: string + + /** The repository's description. */ + readonly description: string + + /** Should the repository be private? */ + readonly private: boolean + + /** + * The org to which this repository belongs. If null, the repository should be + * published as a personal repository. + */ + readonly org: IAPIOrganization | null +} + +export interface IDotcomPublicationSettings { + readonly kind: PublishSettingsType.dotcom + + /** The name to use when publishing the repository. */ + readonly name: string + + /** The repository's description. */ + readonly description: string + + /** Should the repository be private? */ + readonly private: boolean + + /** + * The org to which this repository belongs. If null, the repository should be + * published as a personal repository. + */ + readonly org: IAPIOrganization | null +} diff --git a/app/src/models/pull-request.ts b/app/src/models/pull-request.ts new file mode 100644 index 0000000000..6176da439b --- /dev/null +++ b/app/src/models/pull-request.ts @@ -0,0 +1,64 @@ +import { GitHubRepository } from './github-repository' + +/** Returns the commit ref for a given pull request number. */ +export function getPullRequestCommitRef(pullRequestNumber: number) { + return `refs/pull/${pullRequestNumber}/head` +} + +export class PullRequestRef { + /** + * @param ref The name of the ref. + * @param sha The SHA of the ref. + * @param gitHubRepository The GitHub repository in which this ref lives. It could be null if the + * repository was deleted after the PR was opened. + */ + public constructor( + public readonly ref: string, + public readonly sha: string, + public readonly gitHubRepository: GitHubRepository + ) {} +} + +export class PullRequest { + /** + * @param created The date on which the PR was created. + * @param status The status of the PR. This will be `null` if we haven't looked up its + * status yet or if an error occurred while looking it up. + * @param title The title of the PR. + * @param number The number. + * @param head The ref from which the pull request's changes are coming. + * @param base The ref which the pull request is targeting. + * @param author The author's login. + */ + public constructor( + public readonly created: Date, + public readonly title: string, + public readonly pullRequestNumber: number, + public readonly head: PullRequestRef, + public readonly base: PullRequestRef, + public readonly author: string, + public readonly draft: boolean, + 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/rebase.ts b/app/src/models/rebase.ts new file mode 100644 index 0000000000..4e511bd762 --- /dev/null +++ b/app/src/models/rebase.ts @@ -0,0 +1,62 @@ +import { IMultiCommitOperationProgress } from './progress' +import { ComputedAction } from './computed-action' +import { CommitOneLine } from './commit' + +/** + * Rebase internal state used to track how and where the rebase is applied to + * the repository. + */ +export type RebaseInternalState = { + /** The branch containing commits that should be rebased */ + readonly targetBranch: string + /** + * The commit ID of the base branch, to be used as a starting point for + * the rebase. + */ + readonly baseBranchTip: string + /** + * The commit ID of the target branch at the start of the rebase, which points + * to the original commit history. + */ + readonly originalBranchTip: string +} + +/** + * Options to pass in to rebase progress reporting + */ +export type RebaseProgressOptions = { + commits: ReadonlyArray + /** The callback to fire when rebase progress is reported */ + progressCallback: (progress: IMultiCommitOperationProgress) => void +} + +export type CleanRebase = { + readonly kind: ComputedAction.Clean + readonly commits: ReadonlyArray +} + +export type RebaseWithConflicts = { + readonly kind: ComputedAction.Conflicts +} + +export type RebaseNotSupported = { + readonly kind: ComputedAction.Invalid +} + +export type RebaseLoading = { + readonly kind: ComputedAction.Loading +} + +export type RebasePreview = + | CleanRebase + | RebaseWithConflicts + | RebaseNotSupported + | RebaseLoading + +/** Represents a snapshot of the rebase state from the Git repository */ +export type GitRebaseSnapshot = { + /** The sequence of commits that are used in the rebase */ + readonly commits: ReadonlyArray + /** The progress of the operation */ + readonly progress: IMultiCommitOperationProgress +} diff --git a/app/src/models/release-notes.ts b/app/src/models/release-notes.ts new file mode 100644 index 0000000000..267421a33e --- /dev/null +++ b/app/src/models/release-notes.ts @@ -0,0 +1,30 @@ +export type ReleaseMetadata = { + readonly name: string + readonly notes: ReadonlyArray + readonly pub_date: string + readonly version: string +} + +type ItemEntryKind = + | 'new' + | 'fixed' + | 'improved' + | 'removed' + | 'added' + | 'pretext' + | 'other' + +export type ReleaseNote = { + readonly kind: ItemEntryKind + readonly message: string +} + +export type ReleaseSummary = { + readonly latestVersion: string + readonly datePublished: string + readonly pretext: ReadonlyArray + readonly enhancements: ReadonlyArray + readonly bugfixes: ReadonlyArray + readonly other: ReadonlyArray + readonly thankYous: ReadonlyArray +} diff --git a/app/src/models/remote.ts b/app/src/models/remote.ts new file mode 100644 index 0000000000..9fe5b9f9a4 --- /dev/null +++ b/app/src/models/remote.ts @@ -0,0 +1,32 @@ +/** + * This is the magic remote name prefix + * for when we add a remote on behalf of + * the user. + */ +export const ForkedRemotePrefix = 'github-desktop-' + +export function forkPullRequestRemoteName(remoteName: string) { + return `${ForkedRemotePrefix}${remoteName}` +} + +/** A remote as defined in Git. */ +export interface IRemote { + readonly name: string + readonly url: string +} + +/** + * Gets a value indicating whether two remotes can be considered + * structurally equivalent to each other. + */ +export function remoteEquals(x: IRemote | null, y: IRemote | null) { + if (x === y) { + return true + } + + if (x === null || y === null) { + return false + } + + return x.name === y.name && x.url === y.url +} diff --git a/app/src/models/repo-rules.ts b/app/src/models/repo-rules.ts new file mode 100644 index 0000000000..9b65ee7da8 --- /dev/null +++ b/app/src/models/repo-rules.ts @@ -0,0 +1,130 @@ +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 + + 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 new file mode 100644 index 0000000000..74b9a89b9e --- /dev/null +++ b/app/src/models/repository.ts @@ -0,0 +1,227 @@ +import * as Path from 'path' + +import { GitHubRepository, ForkedGitHubRepository } from './github-repository' +import { IAheadBehind } from './branch' +import { + WorkflowPreferences, + ForkContributionTarget, +} from './workflow-preferences' +import { assertNever, fatalError } from '../lib/fatal-error' +import { createEqualityHash } from './equality-hash' + +function getBaseName(path: string): string { + const baseName = Path.basename(path) + + if (baseName.length === 0) { + // the repository is at the root of the drive + // -> show the full path here to show _something_ + return path + } + + return baseName +} + +/** Base type for a directory you can run git commands successfully */ +export type WorkingTree = { + readonly path: string +} + +/** A local repository. */ +export class Repository { + public readonly name: string + /** + * The main working tree (what we commonly + * think of as the repository's working directory) + */ + private readonly mainWorkTree: WorkingTree + + /** + * A hash of the properties of the object. + * + * Objects with the same hash are guaranteed to be structurally equal. + */ + public hash: string + + /** + * @param path The working directory of this repository + * @param missing Was the repository missing on disk last we checked? + */ + public constructor( + path: string, + public readonly id: number, + public readonly gitHubRepository: GitHubRepository | null, + public readonly missing: boolean, + public readonly alias: string | null = null, + public readonly workflowPreferences: WorkflowPreferences = {}, + /** + * True if the repository is a tutorial repository created as part of the + * onboarding flow. Tutorial repositories trigger a tutorial user experience + * which introduces new users to some core concepts of Git and GitHub. + */ + public readonly isTutorialRepository: boolean = false + ) { + this.mainWorkTree = { path } + this.name = (gitHubRepository && gitHubRepository.name) || getBaseName(path) + + this.hash = createEqualityHash( + path, + this.id, + gitHubRepository?.hash, + this.missing, + this.alias, + this.workflowPreferences.forkContributionTarget, + this.isTutorialRepository + ) + } + + public get path(): string { + return this.mainWorkTree.path + } +} + +/** A worktree linked to a main working tree (aka `Repository`) */ +export type LinkedWorkTree = WorkingTree & { + /** The sha of the head commit in this work tree */ + readonly head: string +} + +/** Identical to `Repository`, except it **must** have a `gitHubRepository` */ +export type RepositoryWithGitHubRepository = Repository & { + readonly gitHubRepository: GitHubRepository +} + +/** + * Identical to `Repository`, except it **must** have a `gitHubRepository` + * which in turn must have a parent. In other words this is a GitHub (.com + * or Enterprise) fork. + */ +export type RepositoryWithForkedGitHubRepository = Repository & { + readonly gitHubRepository: ForkedGitHubRepository +} + +/** + * Returns whether the passed repository is a GitHub repository. + * + * This function narrows down the type of the passed repository to + * RepositoryWithGitHubRepository if it returns true. + */ +export function isRepositoryWithGitHubRepository( + repository: Repository +): repository is RepositoryWithGitHubRepository { + return repository.gitHubRepository instanceof GitHubRepository +} + +/** + * Asserts that the passed repository is a GitHub repository. + */ +export function assertIsRepositoryWithGitHubRepository( + repository: Repository +): asserts repository is RepositoryWithGitHubRepository { + if (!isRepositoryWithGitHubRepository(repository)) { + return fatalError(`Repository must be GitHub repository`) + } +} + +/** + * Returns whether the passed repository is a GitHub fork. + * + * This function narrows down the type of the passed repository to + * RepositoryWithForkedGitHubRepository if it returns true. + */ +export function isRepositoryWithForkedGitHubRepository( + repository: Repository +): repository is RepositoryWithForkedGitHubRepository { + return ( + isRepositoryWithGitHubRepository(repository) && + repository.gitHubRepository.parent !== null + ) +} + +/** + * A snapshot for the local state for a given repository + */ +export interface ILocalRepositoryState { + /** + * The ahead/behind count for the current branch, or `null` if no tracking + * branch found. + */ + readonly aheadBehind: IAheadBehind | null + /** + * The number of uncommitted changes currently in the repository. + */ + readonly changedFilesCount: number +} + +/** + * Returns the owner/name alias if associated with a GitHub repository, + * otherwise the folder name that contains the repository + */ +export function nameOf(repository: Repository) { + const { gitHubRepository } = repository + + return gitHubRepository !== null ? gitHubRepository.fullName : repository.name +} + +/** + * Get the GitHub html URL for a repository, if it has one. + * Will return the parent GitHub repository's URL if it has one. + * Otherwise, returns null. + */ +export function getGitHubHtmlUrl(repository: Repository): string | null { + if (!isRepositoryWithGitHubRepository(repository)) { + return null + } + + return getNonForkGitHubRepository(repository).htmlURL +} + +/** + * Attempts to honor the Repository's workflow preference for GitHubRepository contributions. + * Falls back to returning the GitHubRepository when a non-fork repository + * is passed, returns the parent GitHubRepository otherwise. + */ +export function getNonForkGitHubRepository( + repository: RepositoryWithGitHubRepository +): GitHubRepository { + if (!isRepositoryWithForkedGitHubRepository(repository)) { + // If the repository is not a fork, we don't have to worry about anything. + return repository.gitHubRepository + } + + const forkContributionTarget = getForkContributionTarget(repository) + + switch (forkContributionTarget) { + case ForkContributionTarget.Self: + return repository.gitHubRepository + case ForkContributionTarget.Parent: + return repository.gitHubRepository.parent + default: + return assertNever( + forkContributionTarget, + 'Invalid fork contribution target' + ) + } +} + +/** + * Returns a non-undefined forkContributionTarget for the specified repository. + */ +export function getForkContributionTarget( + repository: Repository +): ForkContributionTarget { + return repository.workflowPreferences.forkContributionTarget !== undefined + ? 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/retry-actions.ts b/app/src/models/retry-actions.ts new file mode 100644 index 0000000000..09b5aca51d --- /dev/null +++ b/app/src/models/retry-actions.ts @@ -0,0 +1,87 @@ +import { Repository } from './repository' +import { CloneOptions } from './clone-options' +import { Branch } from './branch' +import { Commit, CommitOneLine, ICommitContext } from './commit' +import { WorkingDirectoryFileChange } from './status' + +/** The types of actions that can be retried. */ +export enum RetryActionType { + Push = 1, + Pull, + Fetch, + Clone, + Checkout, + Merge, + Rebase, + CherryPick, + CreateBranchForCherryPick, + Squash, + Reorder, + DiscardChanges, +} + +/** The retriable actions and their associated data. */ +export type RetryAction = + | { type: RetryActionType.Push; repository: Repository } + | { type: RetryActionType.Pull; repository: Repository } + | { type: RetryActionType.Fetch; repository: Repository } + | { + type: RetryActionType.Clone + name: string + url: string + path: string + options: CloneOptions + } + | { + type: RetryActionType.Checkout + repository: Repository + branch: Branch + } + | { + type: RetryActionType.Merge + repository: Repository + currentBranch: string + theirBranch: Branch + } + | { + type: RetryActionType.Rebase + repository: Repository + baseBranch: Branch + targetBranch: Branch + } + | { + type: RetryActionType.CherryPick + repository: Repository + targetBranch: Branch + commits: ReadonlyArray + sourceBranch: Branch | null + } + | { + type: RetryActionType.CreateBranchForCherryPick + repository: Repository + targetBranchName: string + startPoint: string | null + noTrackOption: boolean + commits: ReadonlyArray + sourceBranch: Branch | null + } + | { + type: RetryActionType.Squash + repository: Repository + toSquash: ReadonlyArray + squashOnto: Commit + lastRetainedCommitRef: string | null + commitContext: ICommitContext + } + | { + type: RetryActionType.Reorder + repository: Repository + commitsToReorder: ReadonlyArray + beforeCommit: Commit | null + lastRetainedCommitRef: string | null + } + | { + type: RetryActionType.DiscardChanges + repository: Repository + files: ReadonlyArray + } diff --git a/app/src/models/stash-entry.ts b/app/src/models/stash-entry.ts new file mode 100644 index 0000000000..bf840018b0 --- /dev/null +++ b/app/src/models/stash-entry.ts @@ -0,0 +1,44 @@ +import { CommittedFileChange } from './status' + +export interface IStashEntry { + /** The fully qualified name of the entry i.e., `refs/stash@{0}` */ + readonly name: string + + /** The name of the branch at the time the entry was created. */ + readonly branchName: string + + /** The SHA of the commit object created as a result of stashing. */ + readonly stashSha: string + + /** 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 */ +export enum StashedChangesLoadStates { + NotLoaded = 'NotLoaded', + Loading = 'Loading', + Loaded = 'Loaded', +} + +/** + * The status of stashed file changes + * + * When the status us `Loaded` all the files associated + * with the stash are made available. + */ +export type StashedFileChanges = + | { + readonly kind: + | StashedChangesLoadStates.NotLoaded + | StashedChangesLoadStates.Loading + } + | { + readonly kind: StashedChangesLoadStates.Loaded + readonly files: ReadonlyArray + } + +export type StashCallback = (stashEntry: IStashEntry) => Promise diff --git a/app/src/models/status.ts b/app/src/models/status.ts new file mode 100644 index 0000000000..1a40444d29 --- /dev/null +++ b/app/src/models/status.ts @@ -0,0 +1,394 @@ +import { DiffSelection, DiffSelectionType } from './diff' + +/** + * The status entry code as reported by Git. + */ +export enum GitStatusEntry { + Modified = 'M', + Added = 'A', + Deleted = 'D', + Renamed = 'R', + Copied = 'C', + Unchanged = '.', + Untracked = '?', + Ignored = '!', + UpdatedButUnmerged = 'U', +} + +/** The enum representation of a Git file change in GitHub Desktop. */ +export enum AppFileStatusKind { + New = 'New', + Modified = 'Modified', + Deleted = 'Deleted', + Copied = 'Copied', + Renamed = 'Renamed', + Conflicted = 'Conflicted', + Untracked = 'Untracked', +} + +/** + * Normal changes to a repository detected by GitHub Desktop + */ +export type PlainFileStatus = { + kind: + | AppFileStatusKind.New + | AppFileStatusKind.Modified + | AppFileStatusKind.Deleted + submoduleStatus?: SubmoduleStatus +} + +/** + * Copied or renamed files are change staged in the index that have a source + * as well as a destination. + * + * The `oldPath` of a copied file also exists in the working directory, but the + * `oldPath` of a renamed file will be missing from the working directory. + */ +export type CopiedOrRenamedFileStatus = { + kind: AppFileStatusKind.Copied | AppFileStatusKind.Renamed + oldPath: string + submoduleStatus?: SubmoduleStatus +} + +/** + * Details about a file marked as conflicted in the index which may have + * conflict markers to inspect. + */ +export type ConflictsWithMarkers = { + kind: AppFileStatusKind.Conflicted + entry: TextConflictEntry + conflictMarkerCount: number + submoduleStatus?: SubmoduleStatus +} + +/** + * Details about a file marked as conflicted in the index which needs to be + * resolved manually by the user. + */ +export type ManualConflict = { + kind: AppFileStatusKind.Conflicted + entry: ManualConflictEntry + submoduleStatus?: SubmoduleStatus +} + +/** Union of potential conflict scenarios the application should handle */ +export type ConflictedFileStatus = ConflictsWithMarkers | ManualConflict + +/** Custom typeguard to differentiate Conflict files from other types */ +export function isConflictedFileStatus( + appFileStatus: AppFileStatus +): appFileStatus is ConflictedFileStatus { + return appFileStatus.kind === AppFileStatusKind.Conflicted +} + +/** Custom typeguard to differentiate ConflictsWithMarkers from other Conflict types */ +export function isConflictWithMarkers( + conflictedFileStatus: ConflictedFileStatus +): conflictedFileStatus is ConflictsWithMarkers { + return conflictedFileStatus.hasOwnProperty('conflictMarkerCount') +} + +/** Custom typeguard to differentiate ManualConflict from other Conflict types */ +export function isManualConflict( + conflictedFileStatus: ConflictedFileStatus +): conflictedFileStatus is ManualConflict { + return !conflictedFileStatus.hasOwnProperty('conflictMarkerCount') +} + +/** Denotes an untracked file in the working directory) */ +export type UntrackedFileStatus = { + kind: AppFileStatusKind.Untracked + submoduleStatus?: SubmoduleStatus +} + +/** The union of potential states associated with a file change in Desktop */ +export type AppFileStatus = + | PlainFileStatus + | CopiedOrRenamedFileStatus + | 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' + /** how we should represent the file in the application */ + readonly type: 'added' | 'modified' | 'deleted' + /** the status of the index for this entry (if known) */ + 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 */ +type RenamedOrCopiedEntry = { + readonly kind: 'renamed' | 'copied' + /** the status of the index for this entry (if known) */ + 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 +} + +export enum UnmergedEntrySummary { + AddedByUs = 'added-by-us', + DeletedByUs = 'deleted-by-us', + AddedByThem = 'added-by-them', + DeletedByThem = 'deleted-by-them', + BothDeleted = 'both-deleted', + BothAdded = 'both-added', + BothModified = 'both-modified', +} + +/** + * Valid Git index states that the application should detect text conflict + * markers + */ +type TextConflictDetails = + | { + readonly action: UnmergedEntrySummary.BothAdded + readonly us: GitStatusEntry.Added + readonly them: GitStatusEntry.Added + } + | { + readonly action: UnmergedEntrySummary.BothModified + readonly us: GitStatusEntry.UpdatedButUnmerged + readonly them: GitStatusEntry.UpdatedButUnmerged + } + +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 = { + /** the submodule status for this entry */ + readonly submoduleStatus?: SubmoduleStatus +} & ( + | { + readonly action: UnmergedEntrySummary.BothAdded + readonly us: GitStatusEntry.Added + readonly them: GitStatusEntry.Added + } + | { + readonly action: UnmergedEntrySummary.BothModified + readonly us: GitStatusEntry.UpdatedButUnmerged + readonly them: GitStatusEntry.UpdatedButUnmerged + } + | { + readonly action: UnmergedEntrySummary.AddedByUs + readonly us: GitStatusEntry.Added + readonly them: GitStatusEntry.UpdatedButUnmerged + } + | { + readonly action: UnmergedEntrySummary.DeletedByThem + readonly us: GitStatusEntry.UpdatedButUnmerged + readonly them: GitStatusEntry.Deleted + } + | { + readonly action: UnmergedEntrySummary.AddedByThem + readonly us: GitStatusEntry.UpdatedButUnmerged + readonly them: GitStatusEntry.Added + } + | { + readonly action: UnmergedEntrySummary.DeletedByUs + readonly us: GitStatusEntry.Deleted + readonly them: GitStatusEntry.UpdatedButUnmerged + } + | { + readonly action: UnmergedEntrySummary.BothDeleted + 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 */ +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 */ +export type FileEntry = + | OrdinaryEntry + | RenamedOrCopiedEntry + | UnmergedEntry + | UntrackedEntry + +/** encapsulate changes to a file associated with a commit */ +export class FileChange { + /** An ID for the file change. */ + public readonly id: string + + /** + * @param path The relative path to the file in the repository. + * @param status The status of the change to the file. + */ + public constructor( + public readonly path: string, + public readonly status: AppFileStatus + ) { + if ( + status.kind === AppFileStatusKind.Renamed || + status.kind === AppFileStatusKind.Copied + ) { + this.id = `${status.kind}+${path}+${status.oldPath}` + } else { + this.id = `${status.kind}+${path}` + } + } +} + +/** encapsulate the changes to a file in the working directory */ +export class WorkingDirectoryFileChange extends FileChange { + /** + * @param path The relative path to the file in the repository. + * @param status The status of the change to the file. + * @param selection Contains the selection details for this file - all, nothing or partial. + * @param oldPath The original path in the case of a renamed file. + */ + public constructor( + path: string, + status: AppFileStatus, + public readonly selection: DiffSelection + ) { + super(path, status) + } + + /** Create a new WorkingDirectoryFileChange with the given includedness. */ + public withIncludeAll(include: boolean): WorkingDirectoryFileChange { + const newSelection = include + ? this.selection.withSelectAll() + : this.selection.withSelectNone() + + return this.withSelection(newSelection) + } + + /** Create a new WorkingDirectoryFileChange with the given diff selection. */ + public withSelection(selection: DiffSelection): WorkingDirectoryFileChange { + return new WorkingDirectoryFileChange(this.path, this.status, selection) + } +} + +/** + * An object encapsulating the changes to a committed file. + * + * @param status A commit SHA or some other identifier that ultimately + * dereferences to a commit. This is the pointer to the + * 'after' version of this change. I.e. the parent of this + * commit will contain the 'before' (or nothing, if the + * file change represents a new file). + */ +export class CommittedFileChange extends FileChange { + public constructor( + path: string, + status: AppFileStatus, + public readonly commitish: string, + public readonly parentCommitish: string + ) { + super(path, status) + + this.commitish = commitish + } +} + +/** the state of the working directory for a repository */ +export class WorkingDirectoryStatus { + /** Create a new status with the given files. */ + public static fromFiles( + files: ReadonlyArray + ): WorkingDirectoryStatus { + return new WorkingDirectoryStatus(files, getIncludeAllState(files)) + } + + private readonly fileIxById = new Map() + /** + * @param files The list of changes in the repository's working directory. + * @param includeAll Update the include checkbox state of the form. + * NOTE: we need to track this separately to the file list selection + * and perform two-way binding manually when this changes. + */ + private constructor( + public readonly files: ReadonlyArray, + public readonly includeAll: boolean | null = true + ) { + files.forEach((f, ix) => this.fileIxById.set(f.id, ix)) + } + + /** + * Update the include state of all files in the working directory + */ + public withIncludeAllFiles(includeAll: boolean): WorkingDirectoryStatus { + const newFiles = this.files.map(f => f.withIncludeAll(includeAll)) + return new WorkingDirectoryStatus(newFiles, includeAll) + } + + /** Find the file with the given ID. */ + public findFileWithID(id: string): WorkingDirectoryFileChange | null { + const ix = this.fileIxById.get(id) + return ix !== undefined ? this.files[ix] || null : null + } + + /** Find the index of the file with the given ID. Returns -1 if not found */ + public findFileIndexByID(id: string): number { + const ix = this.fileIxById.get(id) + return ix !== undefined ? ix : -1 + } +} + +function getIncludeAllState( + files: ReadonlyArray +): boolean | null { + if (!files.length) { + return true + } + + const allSelected = files.every( + f => f.selection.getSelectionType() === DiffSelectionType.All + ) + const noneSelected = files.every( + f => f.selection.getSelectionType() === DiffSelectionType.None + ) + + let includeAll: boolean | null = null + if (allSelected) { + includeAll = true + } else if (noneSelected) { + includeAll = false + } + + return includeAll +} diff --git a/app/src/models/submodule.ts b/app/src/models/submodule.ts new file mode 100644 index 0000000000..14df4d4111 --- /dev/null +++ b/app/src/models/submodule.ts @@ -0,0 +1,7 @@ +export class SubmoduleEntry { + public constructor( + public readonly sha: string, + public readonly path: string, + public readonly describe: string + ) {} +} diff --git a/app/src/models/tip.ts b/app/src/models/tip.ts new file mode 100644 index 0000000000..4ddb9c9bdd --- /dev/null +++ b/app/src/models/tip.ts @@ -0,0 +1,80 @@ +import { Branch } from './branch' +import { assertNever } from '../lib/fatal-error' + +export enum TipState { + Unknown = 'Unknown', + Unborn = 'Unborn', + Detached = 'Detached', + Valid = 'Valid', +} + +export interface IUnknownRepository { + readonly kind: TipState.Unknown +} + +export interface IUnbornRepository { + readonly kind: TipState.Unborn + + /** + * The symbolic reference that the unborn repository points to currently. + * + * Typically this will be whatever `init.defaultBranch` is set to but a user + * can create orphaned branches themselves. + */ + readonly ref: string +} + +export interface IDetachedHead { + readonly kind: TipState.Detached + /** + * The commit identifier of the current tip of the repository. + */ + readonly currentSha: string +} + +export interface IValidBranch { + readonly kind: TipState.Valid + /** + * The branch information associated with the current tip of the repository. + */ + readonly branch: Branch +} + +export type Tip = + | IUnknownRepository + | IUnbornRepository + | IDetachedHead + | IValidBranch + +/** + * Gets a value indicating whether two Tip instances refer to the + * same canonical Git state. + */ +export function tipEquals(x: Tip, y: Tip) { + if (x === y) { + return true + } + + const kind = x.kind + switch (x.kind) { + case TipState.Unknown: + return x.kind === y.kind + case TipState.Unborn: + return x.kind === y.kind && x.ref === y.ref + case TipState.Detached: + return x.kind === y.kind && x.currentSha === y.currentSha + case TipState.Valid: + return x.kind === y.kind && branchEquals(x.branch, y.branch) + default: + return assertNever(x, `Unknown tip state ${kind}`) + } +} + +function branchEquals(x: Branch, y: Branch) { + return ( + x.type === y.type && + x.tip.sha === y.tip.sha && + x.upstreamRemoteName === y.upstreamRemoteName && + x.upstream === y.upstream + ) +} diff --git a/app/src/models/tutorial-step.ts b/app/src/models/tutorial-step.ts new file mode 100644 index 0000000000..6a7c47292c --- /dev/null +++ b/app/src/models/tutorial-step.ts @@ -0,0 +1,36 @@ +export enum TutorialStep { + NotApplicable = 'NotApplicable', + PickEditor = 'PickEditor', + CreateBranch = 'CreateBranch', + EditFile = 'EditFile', + MakeCommit = 'MakeCommit', + PushBranch = 'PushBranch', + OpenPullRequest = 'OpenPullRequest', + AllDone = 'AllDone', + Paused = 'Paused', +} + +export type ValidTutorialStep = + | TutorialStep.PickEditor + | TutorialStep.CreateBranch + | TutorialStep.EditFile + | TutorialStep.MakeCommit + | TutorialStep.PushBranch + | TutorialStep.OpenPullRequest + | TutorialStep.AllDone + +export function isValidTutorialStep( + step: TutorialStep +): step is ValidTutorialStep { + return step !== TutorialStep.NotApplicable && step !== TutorialStep.Paused +} + +export const orderedTutorialSteps: ReadonlyArray = [ + TutorialStep.PickEditor, + TutorialStep.CreateBranch, + TutorialStep.EditFile, + TutorialStep.MakeCommit, + TutorialStep.PushBranch, + TutorialStep.OpenPullRequest, + TutorialStep.AllDone, +] diff --git a/app/src/models/uncommitted-changes-strategy.ts b/app/src/models/uncommitted-changes-strategy.ts new file mode 100644 index 0000000000..ccca04f10e --- /dev/null +++ b/app/src/models/uncommitted-changes-strategy.ts @@ -0,0 +1,8 @@ +export enum UncommittedChangesStrategy { + AskForConfirmation = 'AskForConfirmation', + StashOnCurrentBranch = 'StashOnCurrentBranch', + MoveToNewBranch = 'MoveToNewBranch', +} + +export const defaultUncommittedChangesStrategy: UncommittedChangesStrategy = + UncommittedChangesStrategy.AskForConfirmation diff --git a/app/src/models/workflow-preferences.ts b/app/src/models/workflow-preferences.ts new file mode 100644 index 0000000000..228ac774d5 --- /dev/null +++ b/app/src/models/workflow-preferences.ts @@ -0,0 +1,14 @@ +export enum ForkContributionTarget { + Parent = 'parent', + Self = 'self', +} + +/** + * Collection of configurable settings regarding how the user may work with a repository. + */ +export type WorkflowPreferences = { + /** + * What repo does the user want to contribute to with this fork? + */ + readonly forkContributionTarget?: ForkContributionTarget +} diff --git a/app/src/ui/about/about.tsx b/app/src/ui/about/about.tsx new file mode 100644 index 0000000000..4c3d6df132 --- /dev/null +++ b/app/src/ui/about/about.tsx @@ -0,0 +1,367 @@ +import * as React from 'react' + +import { Row } from '../lib/row' +import { Button } from '../lib/button' +import { + Dialog, + DialogError, + DialogContent, + DefaultDialogFooter, +} from '../dialog' +import { LinkButton } from '../lib/link-button' +import { updateStore, IUpdateState, UpdateStatus } from '../lib/update-store' +import { Disposable } from 'event-kit' +import { Loading } from '../lib/loading' +import { RelativeTime } from '../relative-time' +import { assertNever } from '../../lib/fatal-error' +import { ReleaseNotesUri } from '../lib/releases' +import { encodePathAsUrl } from '../../lib/path' +import { isTopMostDialog } from '../dialog/is-top-most' +import { isWindowsAndNoLongerSupportedByElectron } from '../../lib/get-os' + +const logoPath = __DARWIN__ + ? 'static/logo-64x64@2x.png' + : 'static/windows-logo-64x64@2x.png' +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. + */ + readonly onDismissed: () => void + + /** + * The name of the currently installed (and running) application + */ + readonly applicationName: string + + /** + * The currently installed (and running) version of the app. + */ + readonly applicationVersion: string + + /** + * The currently installed (and running) architecture of the app. + */ + 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 + + /** A function to call when the user wants to see Terms and Conditions. */ + readonly onShowTermsAndConditions: () => void + + /** Whether the dialog is the top most in the dialog stack */ + readonly isTopMost: boolean +} + +interface IAboutState { + readonly updateState: IUpdateState + readonly altKeyPressed: boolean +} + +/** + * A dialog that presents information about the + * running application such as name and version. + */ +export class About extends React.Component { + private updateStoreEventHandle: Disposable | null = null + private checkIsTopMostDialog = isTopMostDialog( + () => { + window.addEventListener('keydown', this.onKeyDown) + window.addEventListener('keyup', this.onKeyUp) + }, + () => { + window.removeEventListener('keydown', this.onKeyDown) + window.removeEventListener('keyup', this.onKeyUp) + } + ) + + public constructor(props: IAboutProps) { + super(props) + + this.state = { + updateState: updateStore.state, + altKeyPressed: false, + } + } + + private onUpdateStateChanged = (updateState: IUpdateState) => { + this.setState({ updateState }) + } + + public componentDidMount() { + this.updateStoreEventHandle = updateStore.onDidChange( + this.onUpdateStateChanged + ) + this.setState({ updateState: updateStore.state }) + this.checkIsTopMostDialog(this.props.isTopMost) + } + + public componentDidUpdate(): void { + this.checkIsTopMostDialog(this.props.isTopMost) + } + + public componentWillUnmount() { + if (this.updateStoreEventHandle) { + this.updateStoreEventHandle.dispose() + this.updateStoreEventHandle = null + } + this.checkIsTopMostDialog(false) + } + + private onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Alt') { + this.setState({ altKeyPressed: true }) + } + } + + private onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Alt') { + this.setState({ altKeyPressed: false }) + } + } + + private onQuitAndInstall = () => { + updateStore.quitAndInstallUpdate() + } + + private renderUpdateButton() { + if (__RELEASE_CHANNEL__ === 'development') { + return null + } + + const updateStatus = this.state.updateState.status + + switch (updateStatus) { + case UpdateStatus.UpdateReady: + return ( + + + + ) + case UpdateStatus.UpdateNotAvailable: + case UpdateStatus.CheckingForUpdates: + case UpdateStatus.UpdateAvailable: + case UpdateStatus.UpdateNotChecked: + const disabled = + ![ + UpdateStatus.UpdateNotChecked, + UpdateStatus.UpdateNotAvailable, + ].includes(updateStatus) || isWindowsAndNoLongerSupportedByElectron() + + const onClick = this.state.altKeyPressed + ? this.props.onCheckForNonStaggeredUpdates + : this.props.onCheckForUpdates + + const buttonTitle = this.state.altKeyPressed + ? 'Ensure Latest Version' + : 'Check for Updates' + + const tooltip = this.state.altKeyPressed + ? "GitHub Desktop may release updates to our user base gradually to ensure we catch any problems early. This lets you bypass the gradual rollout and jump straight to the latest version if there's one available." + : '' + + return ( + + + + ) + default: + return assertNever( + updateStatus, + `Unknown update status ${updateStatus}` + ) + } + } + + private renderCheckingForUpdate() { + return ( + + + Checking for updates… + + ) + } + + private renderUpdateAvailable() { + return ( + + + Downloading update… + + ) + } + + private renderUpdateNotAvailable() { + const lastCheckedDate = this.state.updateState.lastSuccessfulCheck + + // This case is rendered as an error + if (!lastCheckedDate) { + return null + } + + return ( +

+ You have the latest version (last checked{' '} + ) +

+ ) + } + + private renderUpdateReady() { + return ( +

+ An update has been downloaded and is ready to be installed. +

+ ) + } + + private renderUpdateDetails() { + if (__LINUX__) { + return null + } + + if (__RELEASE_CHANNEL__ === 'development') { + return ( +

+ The application is currently running in development and will not + receive any updates. +

+ ) + } + + const updateState = this.state.updateState + + switch (updateState.status) { + case UpdateStatus.CheckingForUpdates: + return this.renderCheckingForUpdate() + case UpdateStatus.UpdateAvailable: + return this.renderUpdateAvailable() + case UpdateStatus.UpdateNotAvailable: + return this.renderUpdateNotAvailable() + case UpdateStatus.UpdateReady: + return this.renderUpdateReady() + case UpdateStatus.UpdateNotChecked: + return null + default: + return assertNever( + updateState.status, + `Unknown update status ${updateState.status}` + ) + } + } + + private renderUpdateErrors() { + if (__LINUX__) { + return null + } + + if (__RELEASE_CHANNEL__ === 'development') { + return null + } + + if (isWindowsAndNoLongerSupportedByElectron()) { + return ( + + This operating system is no longer supported. Software updates have + been disabled.{' '} + + Supported operating systems + + + ) + } + + if (!this.state.updateState.lastSuccessfulCheck) { + return ( + + Couldn't determine the last time an update check was performed. You + may be running an old version. Please try manually checking for + updates and contact GitHub Support if the problem persists + + ) + } + + return null + } + + private renderBetaLink() { + if (__RELEASE_CHANNEL__ === 'beta') { + return + } + + return ( +
+

Looking for the latest features?

+

+ Check out the{' '} + + Beta Channel + +

+
+ ) + } + + public render() { + const name = this.props.applicationName + const version = this.props.applicationVersion + const releaseNotesLink = ( + release notes + ) + + const versionText = __DEV__ ? `Build ${version}` : `Version ${version}` + + return ( + + {this.renderUpdateErrors()} + + + GitHub Desktop + +

{name}

+

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

+

+ + Terms and Conditions + +

+

+ + License and Open Source Notices + +

+ {this.renderUpdateDetails()} + {this.renderUpdateButton()} + {this.renderBetaLink()} +
+ +
+ ) + } +} diff --git a/app/src/ui/about/index.ts b/app/src/ui/about/index.ts new file mode 100644 index 0000000000..31d6e96167 --- /dev/null +++ b/app/src/ui/about/index.ts @@ -0,0 +1 @@ +export * from './about' 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 0000000000..30b369ac1c --- /dev/null +++ b/app/src/ui/accessibility/aria-live-container.tsx @@ -0,0 +1,120 @@ +import { debounce } from 'lodash' +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/acknowledgements/acknowledgements.tsx b/app/src/ui/acknowledgements/acknowledgements.tsx new file mode 100644 index 0000000000..770036cade --- /dev/null +++ b/app/src/ui/acknowledgements/acknowledgements.tsx @@ -0,0 +1,149 @@ +import * as Path from 'path' +import * as Fs from 'fs' +import * as React from 'react' +import { getAppPath } from '../lib/app-proxy' +import { Loading } from '../lib/loading' +import { LinkButton } from '../lib/link-button' +import { Dialog, DialogContent, DefaultDialogFooter } from '../dialog' + +const WebsiteURL = 'https://desktop.github.com' +const RepositoryURL = 'https://github.com/desktop/desktop' + +interface IAcknowledgementsProps { + /** The function to call when the dialog should be dismissed. */ + readonly onDismissed: () => void + + /** + * The currently installed (and running) version of the app. + */ + readonly applicationVersion: string +} + +interface ILicense { + readonly repository?: string + readonly sourceText?: string + readonly license?: string +} + +type Licenses = { [key: string]: ILicense } + +interface IAcknowledgementsState { + readonly licenses: Licenses | null +} + +/** The component which displays the licenses for packages used in the app. */ +export class Acknowledgements extends React.Component< + IAcknowledgementsProps, + IAcknowledgementsState +> { + public constructor(props: IAcknowledgementsProps) { + super(props) + + this.state = { licenses: null } + } + + public async componentDidMount() { + const path = Path.join(await getAppPath(), 'static', 'licenses.json') + Fs.readFile(path, 'utf8', (err, data) => { + if (err) { + log.error('Error loading licenses', err) + return + } + + const parsed = JSON.parse(data) + if (!parsed) { + log.warn(`Couldn't parse licenses!`) + return + } + + this.setState({ licenses: parsed }) + }) + } + + private renderLicenses(licenses: Licenses) { + const elements = [] + for (const [index, key] of Object.keys(licenses).sort().entries()) { + // The first entry is Desktop itself. We don't need to thank us. + if (index === 0) { + continue + } + + const license = licenses[key] + const repository = license.repository + let nameElement + + if (repository) { + const uri = normalizedGitHubURL(repository) + nameElement = {key} + } else { + nameElement = key + } + + let licenseText + + if (license.sourceText) { + licenseText = license.sourceText + } else if (license.license) { + licenseText = `License: ${license.license}` + } else { + licenseText = 'Unknown license' + } + + const nameHeader =

{nameElement}

+ const licenseParagraph = ( +

+ {licenseText} +

+ ) + + elements.push(nameHeader, licenseParagraph) + } + + return elements + } + + public render() { + const licenses = this.state.licenses + + let desktopLicense: JSX.Element | null = null + if (licenses) { + const key = `desktop@${this.props.applicationVersion}` + const entry = licenses[key] + desktopLicense =

{entry.sourceText}

+ } + + return ( + + +

+ GitHub Desktop is an open + source project published under the MIT License. You can view the + source code and contribute to this project on{' '} + GitHub. +

+ + {desktopLicense} + +

GitHub Desktop also distributes these libraries:

+ + {licenses ? this.renderLicenses(licenses) : } +
+ + +
+ ) + } +} + +/** Normalize a package URL to a GitHub URL. */ +function normalizedGitHubURL(url: string): string { + let newURL = url + newURL = newURL.replace('git+https://github.com', 'https://github.com') + newURL = newURL.replace('git+ssh://git@github.com', 'https://github.com') + return newURL +} diff --git a/app/src/ui/acknowledgements/index.ts b/app/src/ui/acknowledgements/index.ts new file mode 100644 index 0000000000..463b5f4250 --- /dev/null +++ b/app/src/ui/acknowledgements/index.ts @@ -0,0 +1 @@ +export { Acknowledgements } from './acknowledgements' diff --git a/app/src/ui/add-repository/add-existing-repository.tsx b/app/src/ui/add-repository/add-existing-repository.tsx new file mode 100644 index 0000000000..00ef8968bd --- /dev/null +++ b/app/src/ui/add-repository/add-existing-repository.tsx @@ -0,0 +1,325 @@ +import * as React from 'react' +import * as Path from 'path' +import { Dispatcher } from '../dispatcher' +import { addSafeDirectory, getRepositoryType } from '../../lib/git' +import { Button } from '../lib/button' +import { TextBox } from '../lib/text-box' +import { Row } from '../lib/row' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +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 + readonly onDismissed: () => void + + /** An optional path to prefill the path text box with. + * Defaults to the empty string if not defined. + */ + readonly path?: string +} + +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 + * its value when the path changes until we've gotten a definitive answer from the asynchronous + * method that the path is, or isn't, a valid repository path. Separating the two means that + * we don't toggle visibility of the warning message until it's really necessary, preventing + * flickering for our users as they type in a path. + */ + readonly showNonGitRepositoryWarning: boolean + readonly isRepositoryBare: boolean + readonly isRepositoryUnsafe: boolean + readonly repositoryUnsafePath?: string + readonly isTrustingRepository: boolean +} + +/** The component for adding an existing local repository. */ +export class AddExistingRepository extends React.Component< + IAddExistingRepositoryProps, + IAddExistingRepositoryState +> { + public constructor(props: IAddExistingRepositoryProps) { + super(props) + + const path = this.props.path ? this.props.path : '' + + this.state = { + path, + isRepository: false, + showNonGitRepositoryWarning: false, + isRepositoryBare: false, + isRepositoryUnsafe: false, + isTrustingRepository: false, + } + } + + 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 + if (repositoryUnsafePath) { + await addSafeDirectory(repositoryUnsafePath) + } + await this.validatePath(path) + this.setState({ isTrustingRepository: false }) + } + + private async updatePath(path: string) { + this.setState({ path, isRepository: false }) + await this.validatePath(path) + } + + private async validatePath(path: string) { + if (path.length === 0) { + this.setState({ + isRepository: false, + isRepositoryBare: false, + showNonGitRepositoryWarning: false, + }) + return + } + + const type = await getRepositoryType(path) + + const isRepository = type.kind !== 'missing' && type.kind !== 'unsafe' + const isRepositoryUnsafe = type.kind === 'unsafe' + const isRepositoryBare = type.kind === 'bare' + const showNonGitRepositoryWarning = !isRepository || isRepositoryBare + const repositoryUnsafePath = type.kind === 'unsafe' ? type.path : undefined + + this.setState(state => + path === state.path + ? { + isRepository, + isRepositoryBare, + isRepositoryUnsafe, + showNonGitRepositoryWarning, + repositoryUnsafePath, + } + : null + ) + } + + private buildBareRepositoryError() { + if ( + !this.state.path.length || + !this.state.showNonGitRepositoryWarning || + !this.state.isRepositoryBare + ) { + return null + } + + 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 + } + + // 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 + } + + const displayedMessage = ( + <> + 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.renderErrors()} + + + + + + + ) + } + + private onPathChanged = async (path: string) => { + if (this.state.path !== path) { + this.updatePath(path) + } + } + + private showFilePicker = async () => { + const path = await showOpenDialog({ + properties: ['createDirectory', 'openDirectory'], + }) + + if (path === null) { + return + } + + this.updatePath(path) + } + + private resolvedPath(path: string): string { + return Path.resolve('/', untildify(path)) + } + + private addRepository = async () => { + this.props.onDismissed() + const { dispatcher } = this.props + + const resolvedPath = this.resolvedPath(this.state.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({ + type: PopupType.CreateRepository, + path: resolvedPath, + }) + } +} diff --git a/app/src/ui/add-repository/create-repository.tsx b/app/src/ui/add-repository/create-repository.tsx new file mode 100644 index 0000000000..e492aab332 --- /dev/null +++ b/app/src/ui/add-repository/create-repository.tsx @@ -0,0 +1,695 @@ +import * as React from 'react' +import * as Path from 'path' + +import { Dispatcher } from '../dispatcher' +import { + initGitRepository, + createCommit, + getStatus, + getAuthorIdentity, + getRepositoryType, + RepositoryType, +} from '../../lib/git' +import { sanitizedRepositoryName } from './sanitized-repository-name' +import { TextBox } from '../lib/text-box' +import { Button } from '../lib/button' +import { Row } from '../lib/row' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { writeDefaultReadme } from './write-default-readme' +import { Select } from '../lib/select' +import { writeGitDescription } from '../../lib/git/description' +import { getGitIgnoreNames, writeGitIgnore } from './gitignores' +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' +import { enableReadmeOverwriteWarning } from '../../lib/feature-flag' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +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' + +/** The sentinel value used to indicate no gitignore should be used. */ +const NoGitIgnoreValue = 'None' + +/** The sentinel value used to indicate no license should be used. */ +const NoLicenseValue: ILicense = { + name: 'None', + featured: false, + body: '', + 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 { + readonly path: string | null + readonly name: string + readonly description: string + + /** Is the given path able to be written to? */ + readonly isValidPath: boolean | null + + /** Is the given path already a repository? */ + readonly isRepository: boolean + + /** Should the repository be created with a default README? */ + readonly createWithReadme: boolean + + /** Is the repository currently in the process of being created? */ + readonly creating: boolean + + /** The names for the available gitignores. */ + readonly gitIgnoreNames: ReadonlyArray | null + + /** The gitignore to include in the repository. */ + readonly gitIgnore: string + + /** The available licenses. */ + readonly licenses: ReadonlyArray | null + + /** The license to include in the repository. */ + readonly license: string + + /** + * Whether or not a README.md file already exists in the + * directory that may be overwritten by initializing with + * a new README.md. + */ + readonly readMeExists: boolean +} + +/** The Create New Repository component. */ +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) + + const path = this.props.initialPath ? this.props.initialPath : null + + const name = this.props.initialPath + ? sanitizedRepositoryName(Path.basename(this.props.initialPath)) + : '' + + this.state = { + path, + name, + description: '', + createWithReadme: false, + creating: false, + gitIgnoreNames: null, + gitIgnore: NoGitIgnoreValue, + licenses: null, + license: NoLicenseValue.name, + isValidPath: null, + isRepository: false, + readMeExists: false, + } + + if (path === null) { + this.initializePath() + } + } + + public async componentDidMount() { + this.checkIsTopMostDialog(this.props.isTopMost) + + const gitIgnoreNames = await getGitIgnoreNames() + const licenses = await getLicenses() + + this.setState({ gitIgnoreNames, licenses }) + + const path = this.state.path ?? (await getDefaultDir()) + + this.updateIsRepository(path, this.state.name) + this.updateReadMeExists(path, this.state.name) + } + + public componentDidUpdate(): void { + this.checkIsTopMostDialog(this.props.isTopMost) + } + + public componentWillUnmount(): void { + this.checkIsTopMostDialog(false) + } + + private initializePath = async () => { + const path = await getDefaultDir() + this.setState(s => (s.path === null ? { path } : null)) + } + + private onPathChanged = async (path: string) => { + this.setState({ path, isValidPath: null, isRepository: false }) + + this.updateIsRepository(path, this.state.name) + this.updateReadMeExists(path, this.state.name) + } + + private onNameChanged = (name: string) => { + const { path } = this.state + + this.setState({ name }) + + if (path === null) { + return + } + + this.updateIsRepository(path, name) + this.updateReadMeExists(this.state.path, name) + } + + private async updateIsRepository(path: string, name: string) { + const fullPath = Path.join(path, sanitizedRepositoryName(name)) + const isRepository = await isGitRepository(fullPath) + + // 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 + ) + } + + private onDescriptionChanged = (description: string) => { + this.setState({ description }) + } + + private showFilePicker = async () => { + const path = await showOpenDialog({ + properties: ['createDirectory', 'openDirectory'], + }) + + if (path === null) { + return + } + + this.setState({ path, isRepository: false }) + this.updateIsRepository(path, this.state.name) + } + + private async updateReadMeExists(path: string | null, name: string) { + if (!enableReadmeOverwriteWarning() || path === null) { + return + } + + const fullPath = Path.join(path, sanitizedRepositoryName(name), 'README.md') + const readMeExists = await pathExists(fullPath) + + // Only update readMeExists if the path is still the same + this.setState(state => (state.path === path ? { readMeExists } : null)) + } + + private resolveRepositoryRoot = async (): Promise => { + const currentPath = this.state.path + if (currentPath === null) { + return null + } + + if (this.props.initialPath && this.props.initialPath === currentPath) { + // if the user provided an initial path and didn't change it, we should + // validate it is an existing path and use that for the repository + try { + await mkdir(currentPath, { recursive: true }) + return currentPath + } catch {} + } + + return Path.join(currentPath, sanitizedRepositoryName(this.state.name)) + } + + private createRepository = async () => { + const fullPath = await this.resolveRepositoryRoot() + + if (fullPath === null) { + // Shouldn't be able to get here with a null full path, but if you did, + // display error. + this.setState({ isValidPath: true }) + return + } + + try { + await mkdir(fullPath, { recursive: true }) + this.setState({ isValidPath: true }) + } catch (e) { + if (e.code === 'EACCES' && e.errno === -13) { + return this.setState({ isValidPath: false }) + } + + log.error( + `createRepository: the directory at ${fullPath} is not valid`, + e + ) + return this.props.dispatcher.postError(e) + } + + this.setState({ creating: true }) + + try { + await initGitRepository(fullPath) + } catch (e) { + this.setState({ creating: false }) + log.error( + `createRepository: unable to initialize a Git repository at ${fullPath}`, + e + ) + return this.props.dispatcher.postError(e) + } + + const repositories = await this.props.dispatcher.addRepositories([fullPath]) + if (repositories.length < 1) { + return + } + + const repository = repositories[0] + + if (this.state.createWithReadme) { + try { + await writeDefaultReadme( + fullPath, + this.state.name, + this.state.description + ) + } catch (e) { + log.error(`createRepository: unable to write README at ${fullPath}`, e) + this.props.dispatcher.postError(e) + } + } + + const gitIgnore = this.state.gitIgnore + if (gitIgnore !== NoGitIgnoreValue) { + try { + await writeGitIgnore(fullPath, gitIgnore) + } catch (e) { + log.error( + `createRepository: unable to write .gitignore file at ${fullPath}`, + e + ) + this.props.dispatcher.postError(e) + } + } + + const description = this.state.description + if (description) { + try { + await writeGitDescription(fullPath, description) + } catch (e) { + log.error( + `createRepository: unable to write .git/description file at ${fullPath}`, + e + ) + this.props.dispatcher.postError(e) + } + } + + const licenseName = + this.state.license === NoLicenseValue.name ? null : this.state.license + const license = (this.state.licenses || []).find( + l => l.name === licenseName + ) + + if (license) { + try { + const author = await getAuthorIdentity(repository) + + await writeLicense(fullPath, license, { + fullname: author ? author.name : '', + email: author ? author.email : '', + year: new Date().getFullYear().toString(), + description: '', + project: this.state.name, + }) + } catch (e) { + log.error(`createRepository: unable to write LICENSE at ${fullPath}`, e) + this.props.dispatcher.postError(e) + } + } + + try { + const gitAttributes = Path.join(fullPath, '.gitattributes') + const gitAttributesExists = await pathExists(gitAttributes) + if (!gitAttributesExists) { + await writeGitAttributes(fullPath) + } + } catch (e) { + log.error( + `createRepository: unable to write .gitattributes at ${fullPath}`, + e + ) + this.props.dispatcher.postError(e) + } + + const status = await getStatus(repository) + if (status === null) { + this.props.dispatcher.postError( + new Error( + `Unable to create the new repository because there are too many new files in this directory` + ) + ) + + return + } + + try { + const wd = status.workingDirectory + const files = wd.files + if (files.length > 0) { + await createCommit(repository, 'Initial commit', files) + } + } catch (e) { + log.error(`createRepository: initial commit failed at ${fullPath}`, e) + this.props.dispatcher.postError(e) + } + + this.setState({ creating: false }) + + this.updateDefaultDirectory() + + this.props.dispatcher.closeFoldout(FoldoutType.Repository) + this.props.dispatcher.selectRepository(repository) + this.props.dispatcher.recordCreateRepository() + this.props.onDismissed() + } + + private updateDefaultDirectory = () => { + // don't update the default directory as a result of creating the + // repository from an empty folder, because this value will be the + // repository path itself + if (!this.props.initialPath && this.state.path !== null) { + setDefaultDir(this.state.path) + } + } + + private onCreateWithReadmeChange = ( + event: React.FormEvent + ) => { + this.setState({ + createWithReadme: event.currentTarget.checked, + }) + } + + private renderSanitizedName() { + const sanitizedName = sanitizedRepositoryName(this.state.name) + if (this.state.name === sanitizedName) { + return null + } + + return ( + + + Will be created as {sanitizedName} + + ) + } + + private onGitIgnoreChange = (event: React.FormEvent) => { + const gitIgnore = event.currentTarget.value + this.setState({ gitIgnore }) + } + + private onLicenseChange = (event: React.FormEvent) => { + const license = event.currentTarget.value + this.setState({ license }) + } + + private renderGitIgnores() { + const gitIgnores = this.state.gitIgnoreNames || [] + const options = [NoGitIgnoreValue, ...gitIgnores] + + return ( + + + + ) + } + + private renderLicenses() { + const licenses = this.state.licenses || [] + const featuredLicenses = [ + NoLicenseValue, + ...licenses.filter(l => l.featured), + ] + const nonFeaturedLicenses = licenses.filter(l => !l.featured) + + return ( + + + + ) + } + + private renderInvalidPathError() { + const isValidPath = this.state.isValidPath + const pathSet = isValidPath !== null + + if (!pathSet || isValidPath) { + return null + } + + return ( + + Directory could not be created at this path. You may not have + permissions to create a directory here. + + ) + } + + private renderGitRepositoryError() { + const isRepo = this.state.isRepository + + if (!this.state.path || this.state.path.length === 0 || !isRepo) { + return null + } + + return ( + + + This directory appears to be a Git repository. Would you like to{' '} + + add this repository + {' '} + instead? + + + ) + } + + private renderReadmeOverwriteWarning() { + if (!enableReadmeOverwriteWarning()) { + return null + } + + if ( + this.state.createWithReadme === false || + this.state.readMeExists === false + ) { + return null + } + + return ( + + + This directory contains a README.md file already. Checking + this box will result in the existing file being overwritten. + + + ) + } + + private onAddRepositoryClicked = () => { + this.props.onDismissed() + + const { path, name } = this.state + + // Shouldn't be able to even get here if path is null. + if (path !== null) { + this.props.dispatcher.showPopup({ + type: PopupType.AddRepository, + path: Path.join(path, sanitizedRepositoryName(name)), + }) + } + } + + public render() { + const disabled = + this.state.path === null || + this.state.path.length === 0 || + this.state.name.length === 0 || + this.state.creating || + this.state.isRepository + + const readOnlyPath = !!this.props.initialPath + const loadingDefaultDir = this.state.path === null + + return ( + + {this.renderInvalidPathError()} + + + + + + + {this.renderSanitizedName()} + + + + + + + + + + + {this.renderGitRepositoryError()} + + + + + {this.renderReadmeOverwriteWarning()} + + {this.renderGitIgnores()} + {this.renderLicenses()} + + + + + + + ) + } + + private onWindowFocus = () => { + // Verify whether or not a README.md file exists at the chosen directory + // in case one has been added or removed and the warning can be displayed. + this.updateReadMeExists(this.state.path, this.state.name) + } +} diff --git a/app/src/ui/add-repository/git-attributes.ts b/app/src/ui/add-repository/git-attributes.ts new file mode 100644 index 0000000000..06f72f856a --- /dev/null +++ b/app/src/ui/add-repository/git-attributes.ts @@ -0,0 +1,12 @@ +import { writeFile } from 'fs/promises' +import { join } from 'path' + +/** + * Write the .gitAttributes file to the given repository + */ +export async function writeGitAttributes(path: string): Promise { + const fullPath = join(path, '.gitattributes') + const contents = + '# Auto detect text files and perform LF normalization\n* text=auto\n' + await writeFile(fullPath, contents) +} diff --git a/app/src/ui/add-repository/gitignores.ts b/app/src/ui/add-repository/gitignores.ts new file mode 100644 index 0000000000..1021c6ff82 --- /dev/null +++ b/app/src/ui/add-repository/gitignores.ts @@ -0,0 +1,57 @@ +import * as Path from 'path' +import { readdir, readFile, writeFile } from 'fs/promises' + +const GitIgnoreExtension = '.gitignore' + +const root = Path.join(__dirname, 'static', 'gitignore') + +let cachedGitIgnores: Map | null = null + +async function getCachedGitIgnores(): Promise> { + if (cachedGitIgnores != null) { + return cachedGitIgnores + } else { + const files = await readdir(root) + const ignoreFiles = files.filter(file => file.endsWith(GitIgnoreExtension)) + + cachedGitIgnores = new Map() + for (const file of ignoreFiles) { + cachedGitIgnores.set( + Path.basename(file, GitIgnoreExtension), + Path.join(root, file) + ) + } + + return cachedGitIgnores + } +} + +/** Get the names of the available gitignores. */ +export async function getGitIgnoreNames(): Promise> { + const gitIgnores = await getCachedGitIgnores() + return Array.from(gitIgnores.keys()) +} + +/** Get the gitignore based on a name from `getGitIgnoreNames()`. */ +async function getGitIgnoreText(name: string): Promise { + const gitIgnores = await getCachedGitIgnores() + + const path = gitIgnores.get(name) + if (!path) { + throw new Error( + `Unknown gitignore: ${name}. Only names returned from getGitIgnoreNames() can be used.` + ) + } + + return await readFile(path, 'utf8') +} + +/** Write the named gitignore to the repository. */ +export async function writeGitIgnore( + repositoryPath: string, + name: string +): Promise { + const fullPath = Path.join(repositoryPath, '.gitignore') + const text = await getGitIgnoreText(name) + await writeFile(fullPath, text) +} diff --git a/app/src/ui/add-repository/index.tsx b/app/src/ui/add-repository/index.tsx new file mode 100644 index 0000000000..6de0a2ec4e --- /dev/null +++ b/app/src/ui/add-repository/index.tsx @@ -0,0 +1,2 @@ +export * from './add-existing-repository' +export * from './create-repository' diff --git a/app/src/ui/add-repository/licenses.ts b/app/src/ui/add-repository/licenses.ts new file mode 100644 index 0000000000..1a60a0cdaa --- /dev/null +++ b/app/src/ui/add-repository/licenses.ts @@ -0,0 +1,95 @@ +import * as Path from 'path' +import { readFile, writeFile } from 'fs/promises' + +export interface ILicense { + /** The human-readable name. */ + readonly name: string + /** Is the license featured? */ + readonly featured: boolean + /** The actual text of the license. */ + readonly body: string + /** Whether to hide the license from the standard list */ + readonly hidden: boolean +} + +interface ILicenseFields { + readonly fullname: string + readonly email: string + readonly project: string + readonly description: string + readonly year: string +} + +let cachedLicenses: ReadonlyArray | null = null + +/** Get the available licenses. */ +export async function getLicenses(): Promise> { + if (cachedLicenses != null) { + return cachedLicenses + } else { + const licensesMetadataPath = Path.join( + __dirname, + 'static', + 'available-licenses.json' + ) + const json = await readFile(licensesMetadataPath, 'utf8') + const licenses: Array = JSON.parse(json) + + cachedLicenses = licenses.sort((a, b) => { + if (a.featured) { + return -1 + } + if (b.featured) { + return 1 + } + return a.name.localeCompare(b.name) + }) + + return cachedLicenses + } +} + +function replaceToken(body: string, token: string, value: string): string { + // The license templates are inconsistent :( Sometimes they use [token] and + // sometimes {token}. So we'll standardize first to {token} and then do + // replacements. + const oldPattern = new RegExp(`\\[${token}\\]`, 'g') + const newBody = body.replace(oldPattern, `{${token}}`) + + const newPattern = new RegExp(`\\{${token}\\}`, 'g') + return newBody.replace(newPattern, value) +} + +function replaceTokens( + body: string, + tokens: ReadonlyArray, + fields: ILicenseFields +): string { + let newBody = body + for (const token of tokens) { + const value = fields[token] + newBody = replaceToken(newBody, token, value) + } + + return newBody +} + +/** Write the license to the the repository at the given path. */ +export async function writeLicense( + repositoryPath: string, + license: ILicense, + fields: ILicenseFields +): Promise { + const fullPath = Path.join(repositoryPath, 'LICENSE') + + const tokens: ReadonlyArray = [ + 'fullname', + 'email', + 'project', + 'description', + 'year', + ] + + const body = replaceTokens(license.body, tokens, fields) + await writeFile(fullPath, body) +} diff --git a/app/src/ui/add-repository/sanitized-repository-name.ts b/app/src/ui/add-repository/sanitized-repository-name.ts new file mode 100644 index 0000000000..b3babf4ad9 --- /dev/null +++ b/app/src/ui/add-repository/sanitized-repository-name.ts @@ -0,0 +1,11 @@ +const ranges = [ + '\ud83c[\udf00-\udfff]', // U+1F300 to U+1F3FF + '\ud83d[\udc00-\ude4f]', // U+1F400 to U+1F64F + '\ud83d[\ude80-\udeff]', // U+1F680 to U+1F6FF +] +const emojiRegExp = new RegExp(ranges.join('|'), 'g') + +/** Sanitize a proposed repository name by replacing illegal characters. */ +export function sanitizedRepositoryName(name: string): string { + return name.replace(emojiRegExp, '-').replace(/[^\w.-]/g, '-') +} diff --git a/app/src/ui/add-repository/write-default-readme.ts b/app/src/ui/add-repository/write-default-readme.ts new file mode 100644 index 0000000000..31d3666cdb --- /dev/null +++ b/app/src/ui/add-repository/write-default-readme.ts @@ -0,0 +1,23 @@ +import { writeFile } from 'fs/promises' +import * as Path from 'path' + +const DefaultReadmeName = 'README.md' + +function defaultReadmeContents(name: string, description?: string): string { + return description !== undefined + ? `# ${name}\n ${description}\n` + : `# ${name}\n` +} + +/** + * Write the default README to the repository with the given name at the path. + */ +export async function writeDefaultReadme( + path: string, + name: string, + description?: string +): Promise { + const fullPath = Path.join(path, DefaultReadmeName) + const contents = defaultReadmeContents(name, description) + await writeFile(fullPath, contents) +} diff --git a/app/src/ui/app-error.tsx b/app/src/ui/app-error.tsx new file mode 100644 index 0000000000..427b78f3c0 --- /dev/null +++ b/app/src/ui/app-error.tsx @@ -0,0 +1,267 @@ +import * as React from 'react' + +import { + Dialog, + DialogContent, + DialogFooter, + DefaultDialogFooter, +} from './dialog' +import { dialogTransitionTimeout } from './app' +import { GitError, isAuthFailureError } from '../lib/git/core' +import { Popup, PopupType } from '../models/popup' +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' + +interface IAppErrorProps { + /** The error to be displayed */ + readonly error: Error + + /** Called to dismiss the dialog */ + readonly onDismissed: () => void + readonly onShowPopup: (popupType: Popup) => void | undefined + readonly onRetryAction: (retryAction: RetryAction) => void +} + +interface IAppErrorState { + /** The currently displayed error or null if no error is shown */ + readonly error: Error | null + + /** + * Whether or not the dialog and its buttons are disabled. + * This is used when the dialog is transitioning out of view. + */ + readonly disabled: boolean +} + +/** + * A component which renders application-wide errors as dialogs. Only one error + * is shown per dialog and if multiple errors are queued up they will be shown + * in the order they were queued. + */ +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.error, + disabled: false, + } + } + + public componentWillReceiveProps(nextProps: IAppErrorProps) { + const error = nextProps.error + + // We keep the currently shown error until it has disappeared + // from the first spot in the application error queue. + if (error !== this.state.error) { + this.setState({ error, disabled: false }) + } + } + + private showPreferencesDialog = () => { + this.props.onDismissed() + + //This is a hacky solution to resolve multiple dialog windows + //being open at the same time. + window.setTimeout(() => { + this.props.onShowPopup({ type: PopupType.Preferences }) + }, dialogTransitionTimeout.exit) + } + + private onRetryAction = (event: React.MouseEvent) => { + event.preventDefault() + this.props.onDismissed() + + const { error } = this.state + + if (error !== null && isErrorWithMetaData(error)) { + const { retryAction } = error.metadata + if (retryAction !== undefined) { + this.props.onRetryAction(retryAction) + } + } + } + + private renderErrorMessage(error: Error) { + const e = getUnderlyingError(error) + + // 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}

+ } + + private getTitle(error: Error) { + if (isCloneError(error)) { + return 'Clone failed' + } + + return 'Error' + } + + private renderContentAfterErrorMessage(error: Error) { + if (!isErrorWithMetaData(error)) { + return undefined + } + + const { retryAction } = error.metadata + + if (retryAction && retryAction.type === RetryActionType.Clone) { + return ( +

+ Would you like to retry cloning {retryAction.name}? +

+ ) + } + + return undefined + } + + private onDialogContentRef = (ref: HTMLDivElement | null) => { + this.dialogContent = ref + } + + private scrollToBottomOfGitErrorMessage() { + if (this.dialogContent === null || this.state.error === null) { + return + } + + const e = getUnderlyingError(this.state.error) + + if (isRawGitError(e)) { + this.dialogContent.scrollTop = this.dialogContent.scrollHeight + } + } + + public componentDidMount() { + this.scrollToBottomOfGitErrorMessage() + } + + public componentDidUpdate( + prevProps: IAppErrorProps, + prevState: IAppErrorState + ) { + if (prevState.error !== this.state.error) { + this.scrollToBottomOfGitErrorMessage() + } + } + + private onCloseButtonClick = (e: React.MouseEvent) => { + e.preventDefault() + this.props.onDismissed() + } + + private renderFooter(error: Error) { + if (isCloneError(error)) { + return this.renderRetryCloneFooter() + } + + const underlyingError = getUnderlyingError(error) + + if (isGitError(underlyingError)) { + const { gitError } = underlyingError.result + if (gitError !== null && isAuthFailureError(gitError)) { + return this.renderOpenPreferencesFooter() + } + } + + return this.renderDefaultFooter() + } + + private renderRetryCloneFooter() { + return ( + + + + ) + } + + private renderOpenPreferencesFooter() { + return ( + + + + ) + } + + private renderDefaultFooter() { + return + } + + public render() { + const error = this.state.error + + if (!error) { + return null + } + + return ( + + + {this.renderErrorMessage(error)} + {this.renderContentAfterErrorMessage(error)} + + {this.renderFooter(error)} + + ) + } +} + +function getUnderlyingError(error: Error): Error { + return isErrorWithMetaData(error) ? error.underlyingError : error +} + +function isErrorWithMetaData(error: Error): error is ErrorWithMetadata { + return error instanceof ErrorWithMetadata +} + +function isGitError(error: Error): error is GitError { + return error instanceof GitError +} + +function isRawGitError(error: Error | null) { + if (!error) { + return false + } + const e = getUnderlyingError(error) + return e instanceof GitError && e.isRawMessage +} + +function isCloneError(error: Error) { + if (!isErrorWithMetaData(error)) { + return false + } + const { retryAction } = error.metadata + return retryAction !== undefined && retryAction.type === RetryActionType.Clone +} diff --git a/app/src/ui/app-menu/app-menu-bar-button.tsx b/app/src/ui/app-menu/app-menu-bar-button.tsx new file mode 100644 index 0000000000..ad8f68581c --- /dev/null +++ b/app/src/ui/app-menu/app-menu-bar-button.tsx @@ -0,0 +1,286 @@ +import * as React from 'react' +import { IMenu, ISubmenuItem } from '../../models/app-menu' +import { MenuListItem } from './menu-list-item' +import { AppMenu, CloseSource } from './app-menu' +import { ToolbarDropdown } from '../toolbar' +import { Dispatcher } from '../dispatcher' + +interface IAppMenuBarButtonProps { + /** + * The top-level menu item. Currently only submenu items can be rendered as + * top-level menu bar buttons. + */ + readonly menuItem: ISubmenuItem + + /** + * A list of open menus to be rendered, each menu may have + * a selected item. An empty array signifies that the menu + * is not open and an array containing more than one element + * means that there's one or more submenus open. + */ + readonly menuState: ReadonlyArray + + /** + * Whether or not the application menu was opened with the Alt key, this + * enables access key highlighting for applicable menu items as well as + * keyboard navigation by pressing access keys. + */ + 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 + * is pressed, signifying that the item is accessible by holding Alt + * and pressing the corresponding access key. Note that this is a Windows + * convention. + * + * See the highlight prop of the AccessText component for more details. + */ + readonly highlightMenuAccessKey: boolean + + /** + * A function that's called when the menu item is closed by the user clicking + * on the button while it is expanded. This is a specialized version + * of the onDropdownStateChanged prop of the ToolbarDropdown component. + * + * @param menuItem - The top-level menu item rendered by this menu bar button. + * @param source - Whether closing the menu was caused by a keyboard or + * pointer interaction, or if it was closed due to an + * item being activated (executed). + */ + readonly onClose: ( + menuItem: ISubmenuItem, + source: 'keyboard' | 'pointer' | 'item-executed' + ) => void + + /** + * A function that's called when the menu item is opened by the user clicking + * on the button or pressing the down arrow key while it is collapsed. + * This is a specialized version of the onDropdownStateChanged prop of the + * ToolbarDropdown component. + * + * @param selectFirstItem - Whether or not to automatically select + * the first item in the newly opened menu. + * This is set when the menu is opened by the + * user pressing the down arrow key while focused + * on the button. + */ + readonly onOpen: (menuItem: ISubmenuItem, selectFirstItem?: boolean) => void + + /** + * A function that's called when the user hovers over the menu item with + * a pointer device. Note that this only fires for mouse events inside + * of the button and not when hovering content inside the foldout such + * as menu items. + */ + readonly onMouseEnter: (menuItem: ISubmenuItem) => void + + /** + * A function that's called when a key event is received from the MenuBar + * button component or any of its descendants. Note that this includes any + * component or element within the foldout when that is open like, for + * example, MenuItem components. + * + * This function is called before the menu bar button itself does any + * processing of the event so consumers should make sure to call + * event.preventDefault if they act on the event in order to make sure that + * the menu bar button component doesn't act on the same key. + */ + readonly onKeyDown: ( + menuItem: ISubmenuItem, + event: React.KeyboardEvent + ) => void + + /** + * A function that's called once the component has been mounted. This, and + * the onWillUnmount prop are essentially equivalent to the ref callback + * except these methods pass along the menuItem so that the parent component + * is able to keep track of them without having to resort to closing over id's + * in its render method which would cause the component to re-render on each + * pass. + * + * Note that this method is unreliable if the component can receive a new + * MenuItem during its lifetime. As such it's important that + * consumers on this component uses a key prop that's equal to the id of + * the menu item such that it won't be re-used. It's also important that + * consumers of this not rely on reference equality when tracking components + * and instead use the id of the menuItem. + */ + readonly onDidMount?: ( + menuItem: ISubmenuItem, + button: AppMenuBarButton + ) => void + + /** + * A function that's called directly before the component unmounts. This, and + * the onDidMount prop are essentially equivalent to the ref callback except + * these methods pass along the menuItem so that the parent component is able + * to keep track of them without having to resort to closing over id's in its + * render method which would cause the component to re-render on each pass. + * + * Note that this method is unreliable if the component can receive a new + * MenuItem during its lifetime. As such it's important that + * consumers on this component uses a key prop that's equal to the id of + * the menu item such that it won't be re-used. It's also important that + * consumers of this not rely on reference equality when tracking components + * and instead use the id of the menuItem. + */ + readonly onWillUnmount?: ( + menuItem: ISubmenuItem, + button: AppMenuBarButton + ) => void + + readonly dispatcher: Dispatcher +} + +/** + * A button used inside of a menubar which utilizes the ToolbarDropdown component + * in order to render the menu item as well as a foldout containing the item's + * submenu (if open). + */ +export class AppMenuBarButton extends React.Component< + IAppMenuBarButtonProps, + {} +> { + private innerDropDown: ToolbarDropdown | null = null + + /** + * Gets a value indicating whether or not the menu of this + * particular menu item is expanded or collapsed. + */ + private get isMenuOpen() { + return this.props.menuState.length !== 0 + } + + /** + * Programmatically move keyboard focus to the button element. + */ + public focusButton() { + if (this.innerDropDown) { + this.innerDropDown.focusButton() + } + } + + public componentDidMount() { + if (this.props.onDidMount) { + this.props.onDidMount(this.props.menuItem, this) + } + } + + public componentWillUnmount() { + if (this.props.onWillUnmount) { + this.props.onWillUnmount(this.props.menuItem, this) + } + } + + public render() { + const item = this.props.menuItem + const dropDownState = this.isMenuOpen ? 'open' : 'closed' + + return ( + + + + ) + } + + private onDropDownRef = (dropdown: ToolbarDropdown | null) => { + this.innerDropDown = dropdown + } + + private onMouseEnter = (event: React.MouseEvent) => { + this.props.onMouseEnter(this.props.menuItem) + } + + private onKeyDown = (event: React.KeyboardEvent) => { + if (event.defaultPrevented) { + return + } + + this.props.onKeyDown(this.props.menuItem, event) + + if (!this.isMenuOpen && !event.defaultPrevented) { + if (event.key === 'ArrowDown') { + this.props.onOpen(this.props.menuItem, true) + event.preventDefault() + } + } + } + + private onMenuClose = (closeSource: CloseSource) => { + // If the user closes the menu by hitting escape we explicitly move focus + // to the button so that it's highlighted and responds to Arrow keys. + if (closeSource.type === 'keyboard' && closeSource.event.key === 'Escape') { + this.focusButton() + } + + this.props.onClose(this.props.menuItem, closeSource.type) + } + + private onDropdownStateChanged = ( + state: 'closed' | 'open', + source: 'keyboard' | 'pointer' + ) => { + if (this.isMenuOpen) { + this.props.onClose(this.props.menuItem, source) + } else { + this.props.onOpen(this.props.menuItem, true) + } + } + + private dropDownContentRenderer = () => { + const menuState = this.props.menuState + + if (!this.isMenuOpen) { + return null + } + + return ( + + ) + } +} diff --git a/app/src/ui/app-menu/app-menu-bar.tsx b/app/src/ui/app-menu/app-menu-bar.tsx new file mode 100644 index 0000000000..2083d2c1bd --- /dev/null +++ b/app/src/ui/app-menu/app-menu-bar.tsx @@ -0,0 +1,485 @@ +import * as React from 'react' +import { + IMenu, + ISubmenuItem, + findItemByAccessKey, + itemIsSelectable, +} from '../../models/app-menu' +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 + + /** + * Whether or not to highlight access keys for top-level menu items. + * Note that this does not affect whether access keys are highlighted + * for menu items in submenus, that's controlled by the foldoutState + * enableAccessKeyNavigation prop and follows Windows conventions such + * that opening a menu by clicking on it and then hitting Alt does + * not highlight the access keys within. + */ + readonly highlightAppMenuAccessKeys: boolean + + /** + * The current AppMenu foldout state. If null that means that the + * app menu foldout is not currently open. + */ + readonly foldoutState: AppMenuFoldout | null + + /** + * An optional function that's called when the menubar loses focus. + * + * Note that this function will only be called once no descendant element + * of the menu bar has keyboard focus. In other words this differs + * from the traditional onBlur event. + */ + readonly onLostFocus?: () => void +} + +interface IAppMenuBarState { + /** + * A list of visible top-level menu items which have child menus of + * their own (ie submenu items). + */ + readonly menuItems: ReadonlyArray +} + +/** + * Creates menu bar 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. + * + * The state consists of a list of visible top-level menu items which have + * child menus of their own (ie submenu items). + */ +function createState(props: IAppMenuBarProps): IAppMenuBarState { + if (!props.appMenu.length) { + return { menuItems: [] } + } + + const topLevelMenu = props.appMenu[0] + const items = topLevelMenu.items + + const menuItems = new Array() + + for (const item of items) { + if (item.type === 'submenuItem' && item.visible) { + menuItems.push(item) + } + } + + return { menuItems } +} + +/** + * A Windows-style application menu bar which renders in the title + * bar section of the app and utilizes foldouts for displaying interactive + * menus. + */ +export class AppMenuBar extends React.Component< + IAppMenuBarProps, + IAppMenuBarState +> { + private menuBar: HTMLDivElement | null = null + private readonly menuButtonRefsByMenuItemId: { + [id: string]: AppMenuBarButton + } = {} + private focusOutTimeout: number | null = null + + /** + * Whether or not keyboard focus currently lies within the MenuBar component + */ + private hasFocus: boolean = false + + /** + * Whenever the MenuBar component receives focus it attempts to store the + * element which had focus prior to the component receiving it. We do so in + * order to be able to restore focus to that element when we decide to + * _programmatically_ give up our focus. + * + * A good example of this is when the user is focused on a text box and hits + * the Alt key. Focus will then move to the first menu item in the menu bar. + * If the user then hits Enter we relinquish our focus and return it back to + * the text box again. + * + * As long as we hold on to this reference we might be preventing GC from + * collecting a potentially huge subtree of the DOM so we need to make sure + * to clear it out as soon as we're done with it. + */ + private stolenFocusElement: HTMLElement | null = null + + public constructor(props: IAppMenuBarProps) { + super(props) + this.state = createState(props) + } + + public componentWillReceiveProps(nextProps: IAppMenuBarProps) { + if (nextProps.appMenu !== this.props.appMenu) { + this.setState(createState(nextProps)) + } + } + + public componentDidUpdate(prevProps: IAppMenuBarProps) { + // Was the app menu foldout just opened or closed? + if (this.props.foldoutState && !prevProps.foldoutState) { + if (this.props.appMenu.length === 1 && !this.hasFocus) { + // It was just opened, no menus are open and we don't have focus, + // that's our cue to focus the first menu item so that users + // can move move around using arrow keys. + this.focusFirstMenuItem() + } + } else if (!this.props.foldoutState && prevProps.foldoutState) { + // The foldout was just closed and we still have focus, time to + // give it back to whoever had it before or remove focus entirely. + this.restoreFocusOrBlur() + } + } + + public componentDidMount() { + if (this.props.foldoutState) { + if (this.props.appMenu.length === 1) { + this.focusFirstMenuItem() + } + } + } + + public componentWillUnmount() { + if (this.hasFocus) { + this.restoreFocusOrBlur() + } + // This is perhaps being overly cautious but just in case we're unmounted + // and someone else is still holding a reference to us we want to make sure + // that we're not preventing GC from doing its job. + this.stolenFocusElement = null + } + + public render() { + return ( + + ) + } + + private isMenuItemOpen(item: ISubmenuItem) { + const openMenu = + this.props.foldoutState && this.props.appMenu.length > 1 + ? this.props.appMenu[1] + : null + + return openMenu !== null && openMenu.id === item.id + } + + /** + * Move keyboard focus to the first menu item button in the + * menu bar. This has no effect when a menu is currently open. + */ + private focusFirstMenuItem() { + // Menu currently open? + if (this.props.appMenu.length > 1) { + return + } + + const rootItems = this.state.menuItems + + if (!rootItems.length) { + return + } + + const firstMenuItem = rootItems[0] + + if (firstMenuItem) { + this.focusMenuItem(firstMenuItem) + } + } + + private focusMenuItem(item: ISubmenuItem) { + const itemComponent = this.menuButtonRefsByMenuItemId[item.id] + + if (itemComponent) { + itemComponent.focusButton() + } + } + + private restoreFocusOrBlur() { + if (!this.hasFocus) { + return + } + + // Us having a reference to the previously focused element doesn't + // necessarily mean that that element is still in the DOM so we explicitly + // check to see if it is before we yield focus to it. + if (this.stolenFocusElement && document.contains(this.stolenFocusElement)) { + this.stolenFocusElement.focus() + } else if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + + // Don't want to hold on to this a moment longer than necessary. + this.stolenFocusElement = null + } + + private onMenuBarFocusIn = (event: Event) => { + const focusEvent = event as FocusEvent + if (!this.hasFocus) { + if ( + focusEvent.relatedTarget && + focusEvent.relatedTarget instanceof HTMLElement + ) { + this.stolenFocusElement = focusEvent.relatedTarget + } else { + this.stolenFocusElement = null + } + this.hasFocus = true + } + this.clearFocusOutTimeout() + } + + private onMenuBarFocusOut = (event: Event) => { + // When keyboard focus moves from one descendant within the + // menu bar to another we will receive one 'focusout' event + // followed quickly by a 'focusin' event. As such we + // can't tell whether we've lost focus until we're certain + // that we've only gotten the 'focusout' event. + // + // In order to achieve this we schedule our call to onLostFocusWithin + // and clear that timeout if we receive a 'focusin' event. + this.clearFocusOutTimeout() + this.focusOutTimeout = requestAnimationFrame(this.onLostFocusWithin) + } + + private clearFocusOutTimeout() { + if (this.focusOutTimeout !== null) { + cancelAnimationFrame(this.focusOutTimeout) + this.focusOutTimeout = null + } + } + + private onLostFocusWithin = () => { + this.hasFocus = false + this.focusOutTimeout = null + + if (this.props.onLostFocus) { + this.props.onLostFocus() + } + + // It's possible that the element which we are referencing here is no longer + // part of the DOM so it's important that we clear out our handle to prevent + // us from hanging on to a possibly huge DOM structure and preventing GC + // from collecting it. My kingdom for a weak reference. + this.stolenFocusElement = null + } + + private onMenuBarRef = (menuBar: HTMLDivElement | null) => { + if (this.menuBar) { + this.menuBar.removeEventListener('focusin', this.onMenuBarFocusIn) + this.menuBar.removeEventListener('focusout', this.onMenuBarFocusOut) + } + + this.menuBar = menuBar + + if (this.menuBar) { + this.menuBar.addEventListener('focusin', this.onMenuBarFocusIn) + this.menuBar.addEventListener('focusout', this.onMenuBarFocusOut) + } + } + + private onMenuClose = ( + item: ISubmenuItem, + source: 'keyboard' | 'pointer' | 'item-executed' + ) => { + if (source === 'pointer' || source === 'item-executed') { + this.restoreFocusOrBlur() + } + + this.props.dispatcher.setAppMenuState(m => m.withClosedMenu(item.menu)) + } + + private onMenuOpen = (item: ISubmenuItem, selectFirstItem?: boolean) => { + const enableAccessKeyNavigation = this.props.foldoutState + ? this.props.foldoutState.enableAccessKeyNavigation + : false + + this.props.dispatcher.showFoldout({ + type: FoldoutType.AppMenu, + enableAccessKeyNavigation, + }) + this.props.dispatcher.setAppMenuState(m => + m.withOpenedMenu(item, selectFirstItem) + ) + } + + private onMenuButtonMouseEnter = (item: ISubmenuItem) => { + if (this.hasFocus) { + this.focusMenuItem(item) + } + + if (this.props.appMenu.length > 1) { + this.props.dispatcher.setAppMenuState(m => m.withOpenedMenu(item)) + } + } + + private moveToAdjacentMenu( + direction: 'next' | 'previous', + sourceItem: ISubmenuItem + ) { + const rootItems = this.state.menuItems + const menuItemIx = rootItems.findIndex(item => item.id === sourceItem.id) + + if (menuItemIx === -1) { + return + } + + const delta = direction === 'next' ? 1 : -1 + + // http://javascript.about.com/od/problemsolving/a/modulobug.htm + const nextMenuItemIx = + (menuItemIx + delta + rootItems.length) % rootItems.length + const nextItem = rootItems[nextMenuItemIx] + + if (!nextItem) { + return + } + + const foldoutState = this.props.foldoutState + + // Determine whether a top-level application menu is currently + // open and use that if, and only if, the application menu foldout + // is active. + const openMenu = foldoutState !== null && this.props.appMenu.length > 1 + + if (openMenu) { + this.props.dispatcher.setAppMenuState(m => + m.withOpenedMenu(nextItem, true) + ) + } else { + const nextButton = this.menuButtonRefsByMenuItemId[nextItem.id] + + if (nextButton) { + nextButton.focusButton() + } + } + } + + private onMenuButtonKeyDown = ( + item: ISubmenuItem, + event: React.KeyboardEvent + ) => { + if (event.defaultPrevented) { + return + } + + const foldoutState = this.props.foldoutState + + if (!foldoutState) { + return + } + + if (event.key === 'Escape') { + if (!this.isMenuItemOpen(item)) { + this.restoreFocusOrBlur() + event.preventDefault() + } + } else if (event.key === 'ArrowLeft') { + this.moveToAdjacentMenu('previous', item) + event.preventDefault() + } else if (event.key === 'ArrowRight') { + this.moveToAdjacentMenu('next', item) + event.preventDefault() + } else if (foldoutState.enableAccessKeyNavigation) { + if (event.altKey || event.ctrlKey || event.metaKey) { + return + } + + const menuItemForAccessKey = findItemByAccessKey( + event.key, + this.state.menuItems + ) + + if (menuItemForAccessKey && itemIsSelectable(menuItemForAccessKey)) { + if (menuItemForAccessKey.type === 'submenuItem') { + this.props.dispatcher.setAppMenuState(menu => + menu + .withReset() + .withSelectedItem(menuItemForAccessKey) + .withOpenedMenu(menuItemForAccessKey, true) + ) + } else { + this.restoreFocusOrBlur() + this.props.dispatcher.executeMenuItem(menuItemForAccessKey) + } + + event.preventDefault() + } + } + } + + private onMenuButtonDidMount = ( + menuItem: ISubmenuItem, + button: AppMenuBarButton + ) => { + this.menuButtonRefsByMenuItemId[menuItem.id] = button + } + + private onMenuButtonWillUnmount = ( + menuItem: ISubmenuItem, + button: AppMenuBarButton + ) => { + delete this.menuButtonRefsByMenuItemId[menuItem.id] + } + + private renderMenuItem(item: ISubmenuItem): JSX.Element { + const foldoutState = this.props.foldoutState + + // Slice away the top menu so that each menu bar button receives + // their menu item's menu and any open submenus. + const menuState = this.isMenuItemOpen(item) + ? this.props.appMenu.slice(1) + : [] + + const openedWithAccessKey = foldoutState + ? foldoutState.openedWithAccessKey || false + : false + + const enableAccessKeyNavigation = foldoutState + ? foldoutState.enableAccessKeyNavigation + : false + + // If told to highlight access keys we will do so. If access key navigation + // is enabled and no menu is open we'll highlight as well. This matches + // the behavior of Windows menus. + const highlightMenuAccessKey = + this.props.highlightAppMenuAccessKeys || + (!this.isMenuItemOpen(item) && enableAccessKeyNavigation) + + return ( + + ) + } +} diff --git a/app/src/ui/app-menu/app-menu.tsx b/app/src/ui/app-menu/app-menu.tsx new file mode 100644 index 0000000000..7ca0ccb1a0 --- /dev/null +++ b/app/src/ui/app-menu/app-menu.tsx @@ -0,0 +1,278 @@ +import * as React from 'react' +import { MenuPane } from './menu-pane' +import { Dispatcher } from '../dispatcher' +import { IMenu, MenuItem, ISubmenuItem } from '../../models/app-menu' +import { SelectionSource, ClickSource } from '../lib/list' + +interface IAppMenuProps { + /** + * A list of open menus to be rendered, each menu may have + * a selected item. + */ + readonly state: ReadonlyArray + readonly dispatcher: Dispatcher + + /** + * A required callback for when the app menu is closed. The menu is explicitly + * closed when a menu item has been clicked (executed) or when the user + * presses Escape on the top level menu pane. + * + * @param closeSource An object describing the action that caused the menu + * to close. This can either be a keyboard event (hitting + * Escape) or the user executing one of the menu items by + * clicking on them or pressing enter. + */ + readonly onClose: (closeSource: CloseSource) => void + + /** + * Whether or not the application menu was opened with the Alt key, this + * enables access key highlighting for applicable menu items as well as + * keyboard navigation by pressing access keys. + */ + 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 { + type: 'keyboard' + event: React.KeyboardEvent +} + +export interface IItemExecutedCloseSource { + type: 'item-executed' +} + +export type CloseSource = IKeyboardCloseSource | IItemExecutedCloseSource + +const expandCollapseTimeout = 300 + +/** + * Converts a menu pane id into something that's reasonable to use as + * a classname by replacing forbidden characters and cleaning it + * up in general. + */ +function menuPaneClassNameFromId(id: string) { + const className = id + // Get rid of the leading @. for auto-generated ids + .replace(/^@\./, '') + // No accelerator key modifier necessary + .replace(/&/g, '') + // Get rid of stuff that's not safe for css class names + .replace(/[^a-z0-9_]+/gi, '-') + // Get rid of redundant underscores + .replace(/_+/, '_') + .toLowerCase() + + return className.length ? `menu-pane-${className}` : undefined +} + +export class AppMenu extends React.Component { + /** + * A numeric reference to a setTimeout timer id which is used for + * opening and closing submenus after a delay. + * + * See scheduleExpand and scheduleCollapse + */ + private expandCollapseTimer: number | null = null + + private onItemClicked = ( + depth: number, + item: MenuItem, + source: ClickSource + ) => { + this.clearExpandCollapseTimer() + + if (item.type === 'submenuItem') { + // Warning: This is a bit of a hack. When using access keys to navigate + // to a submenu item we want it not only to expand but to have its first + // child item selected by default. We do that by looking to see if the + // selection source was a keyboard press and if it wasn't one of the keys + // that we'd expect for a 'normal' click event. + const sourceIsAccessKey = + this.props.enableAccessKeyNavigation && + source.kind === 'keyboard' && + source.event.key !== 'Enter' && + source.event.key !== ' ' + + this.props.dispatcher.setAppMenuState(menu => + menu.withOpenedMenu(item, sourceIsAccessKey) + ) + } 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 + // (if any). + this.props.onClose({ type: 'item-executed' }) + this.props.dispatcher.executeMenuItem(item) + } + } + + private onPaneKeyDown = ( + depth: number, + event: React.KeyboardEvent + ) => { + const { selectedItem } = this.props.state[depth] + + if (event.key === 'ArrowLeft' || event.key === 'Escape') { + this.clearExpandCollapseTimer() + + // Only actually close the foldout when hitting escape + // on the root menu + if (depth === 0 && event.key === 'Escape') { + this.props.onClose({ type: 'keyboard', event }) + event.preventDefault() + } else if (depth > 0) { + this.props.dispatcher.setAppMenuState(menu => + menu.withClosedMenu(this.props.state[depth]) + ) + + event.preventDefault() + } + } else if (event.key === 'ArrowRight') { + this.clearExpandCollapseTimer() + + // Open the submenu and select the first item + if (selectedItem?.type === 'submenuItem') { + this.props.dispatcher.setAppMenuState(menu => + menu.withOpenedMenu(selectedItem, true) + ) + event.preventDefault() + } + } + } + + private clearExpandCollapseTimer() { + if (this.expandCollapseTimer) { + window.clearTimeout(this.expandCollapseTimer) + this.expandCollapseTimer = null + } + } + + private scheduleExpand(item: ISubmenuItem) { + this.clearExpandCollapseTimer() + this.expandCollapseTimer = window.setTimeout(() => { + this.props.dispatcher.setAppMenuState(menu => menu.withOpenedMenu(item)) + }, expandCollapseTimeout) + } + + private scheduleCollapseTo(menu: IMenu) { + this.clearExpandCollapseTimer() + this.expandCollapseTimer = window.setTimeout(() => { + this.props.dispatcher.setAppMenuState(am => am.withLastMenu(menu)) + }, expandCollapseTimeout) + } + + private onClearSelection = (depth: number) => { + this.props.dispatcher.setAppMenuState(appMenu => + appMenu.withDeselectedMenu(this.props.state[depth]) + ) + } + + private onSelectionChanged = ( + depth: number, + item: MenuItem, + source: SelectionSource + ) => { + this.clearExpandCollapseTimer() + + if (source.kind === 'keyboard') { + // Immediately close any open submenus if we're navigating by keyboard. + this.props.dispatcher.setAppMenuState(appMenu => + appMenu.withSelectedItem(item).withLastMenu(this.props.state[depth]) + ) + } else { + // If the newly selected item is a submenu we'll wait a bit and then expand + // it unless the user makes another selection in between. If it's not then + // we'll make sure to collapse any open menu below this level. + if (item.type === 'submenuItem') { + this.scheduleExpand(item) + } else { + this.scheduleCollapseTo(this.props.state[depth]) + } + + this.props.dispatcher.setAppMenuState(menu => menu.withSelectedItem(item)) + } + } + + private onPaneMouseEnter = (depth: number) => { + this.clearExpandCollapseTimer() + + const paneMenu = this.props.state[depth] + const selectedItem = paneMenu.selectedItem + + if (selectedItem) { + this.props.dispatcher.setAppMenuState(m => + m.withSelectedItem(selectedItem) + ) + } else { + // This ensures that the selection to this menu is reset. + this.props.dispatcher.setAppMenuState(m => m.withDeselectedMenu(paneMenu)) + } + } + + private onKeyDown = (event: React.KeyboardEvent) => { + if (!event.defaultPrevented && event.key === 'Escape') { + event.preventDefault() + this.props.onClose({ type: 'keyboard', event }) + } + } + + private renderMenuPane(depth: number, menu: IMenu): JSX.Element { + // 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 + + return ( + + ) + } + + public render() { + const menus = this.props.state + const panes = menus.map((m, depth) => this.renderMenuPane(depth, m)) + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ {panes} +
+ ) + } + + public componentWillUnmount() { + this.clearExpandCollapseTimer() + } +} diff --git a/app/src/ui/app-menu/index.ts b/app/src/ui/app-menu/index.ts new file mode 100644 index 0000000000..135f307c43 --- /dev/null +++ b/app/src/ui/app-menu/index.ts @@ -0,0 +1,3 @@ +export * from './app-menu' +export * from './app-menu-bar' +export * from './menu-pane' diff --git a/app/src/ui/app-menu/menu-list-item.tsx b/app/src/ui/app-menu/menu-list-item.tsx new file mode 100644 index 0000000000..b83580f888 --- /dev/null +++ b/app/src/ui/app-menu/menu-list-item.tsx @@ -0,0 +1,208 @@ +import * as React from 'react' +import classNames from 'classnames' + +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { MenuItem } from '../../models/app-menu' +import { AccessText } from '../lib/access-text' +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). + * + * See the highlight prop of AccessText component for more details. + */ + readonly highlightAccessKey: boolean + + /** + * Whether or not to render the accelerator (shortcut) next to the label. + * This can be turned off when the menu item is used as a stand-alone item + * + * Defaults to true if not specified (i.e. undefined) + */ + readonly renderAcceleratorText?: boolean + + /** + * Whether or not to render an arrow to the right of the label when the + * menu item has a submenu. This can be turned off when the menu item is + * used as a stand-alone item or when expanding the submenu doesn't follow + * the default conventions (i.e. expanding to the right). + * + * 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 +} + +/** + * Returns a platform specific human readable version of an Electron + * accelerator string. See getPlatformSpecificNameOrSymbolForModifier + * for more information. + */ +export function friendlyAcceleratorText(accelerator: string): string { + return accelerator + .split('+') + .map(getPlatformSpecificNameOrSymbolForModifier) + .join(__DARWIN__ ? '' : '+') +} + +export class MenuListItem extends React.Component { + private wrapperRef = React.createRef() + + private getIcon(item: MenuItem): JSX.Element | null { + if (item.type === 'checkbox' && item.checked) { + return + } else if (item.type === 'radio' && item.checked) { + 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 + + if (item.type === 'separator') { + return
+ } + + const arrow = + item.type === 'submenuItem' && this.props.renderSubMenuArrow !== false ? ( + + ) : null + + const accelerator = + item.type !== 'submenuItem' && + item.accelerator && + this.props.renderAcceleratorText !== false ? ( +
+ {friendlyAcceleratorText(item.accelerator)} +
+ ) : null + + 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 new file mode 100644 index 0000000000..c0dd8aab00 --- /dev/null +++ b/app/src/ui/app-menu/menu-pane.tsx @@ -0,0 +1,296 @@ +import * as React from 'react' +import classNames from 'classnames' + +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 { + /** + * An optional classname which will be appended to the 'menu-pane' class + */ + readonly className?: string + + /** + * The current Menu pane depth, starts at zero and increments by one for each + * open submenu. + */ + readonly depth: number + + /** + * All items available in the current menu. Note that this includes disabled + * menu items as well as invisible ones. This list is filtered before + * rendering. + */ + readonly items: ReadonlyArray + + /** + * The currently selected item in the menu or undefined if no item is + * selected. + */ + readonly selectedItem?: MenuItem + + /** + * A callback for when a selectable menu item was clicked by a pointer device + * or when the Enter or Space key is pressed on a selected item. The source + * parameter can be used to determine whether the click is a result of a + * pointer device or keyboard. + */ + readonly onItemClicked: ( + depth: number, + item: MenuItem, + source: ClickSource + ) => void + + /** + * 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 onKeyDown?: ( + depth: number, + event: React.KeyboardEvent + ) => void + + /** + * A callback for when the MenuPane selection changes (i.e. a new menu item is selected). + */ + readonly onSelectionChanged: ( + depth: number, + item: MenuItem, + source: SelectionSource + ) => void + + /** Callback for when the mouse enters the menu pane component */ + readonly onMouseEnter?: (depth: number) => void + + /** + * Whether or not the application menu was opened with the Alt key, this + * enables access key highlighting for applicable menu items as well as + * keyboard navigation by pressing access keys. + */ + readonly enableAccessKeyNavigation?: boolean + + /** + * 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 onClearSelection: (depth: number) => void + + /** The id of the element that serves as the menu's accessibility label */ + readonly ariaLabelledby?: string + + /** 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 + + readonly renderLabel?: (item: MenuItem) => JSX.Element | undefined +} + +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) + } + } + + 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) + } + + if (ix !== null && items[ix] !== undefined) { + this.props.onSelectionChanged(this.props.depth, items[ix], source) + return true + } + + return false + } + + 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 + + const firstChars = items.map(v => + v.type === 'separator' ? '' : v.label.trim()[0].toLowerCase() + ) + + // Check menu items after selected + let ix: number = firstChars.indexOf(char, start) + + // check menu items before selected + if (ix === -1) { + ix = firstChars.indexOf(char, 0) + } + + if (ix >= 0 && items[ix] !== undefined) { + this.props.onSelectionChanged(this.props.depth, items[ix], source) + return true + } + + return false + } + + private onKeyDown = (event: React.KeyboardEvent) => { + if (event.defaultPrevented) { + return + } + + // Modifier keys are handled elsewhere, we only care about letters and symbols + if (event.altKey || event.ctrlKey || event.metaKey) { + return + } + + const source: IKeyboardSource = { kind: 'keyboard', event } + const { selectedItem } = this.props + const { key } = event + + if (isSupportedKey(key)) { + event.preventDefault() + + 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') + } + } + + 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, + }) + } + } + + this.props.onKeyDown?.(this.props.depth, event) + } + + private onMouseEnter = (event: React.MouseEvent) => { + this.props.onMouseEnter?.(this.props.depth) + } + + 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 onRowMouseLeave = ( + item: MenuItem, + event: React.MouseEvent + ) => { + if (this.props.selectedItem === item) { + this.props.onClearSelection(this.props.depth) + } + } + + public render(): JSX.Element { + 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) => ( + + ))} +
+ ) + } +} + +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 new file mode 100644 index 0000000000..4563f1a6a5 --- /dev/null +++ b/app/src/ui/app-theme.tsx @@ -0,0 +1,78 @@ +import * as React from 'react' +import { + ApplicationTheme, + getThemeName, + getCurrentlyAppliedTheme, +} from './lib/application-theme' + +interface IAppThemeProps { + readonly theme: ApplicationTheme +} + +/** + * A pseudo-component responsible for adding the applicable CSS + * class names to the body tag in order to apply the currently + * selected theme. + * + * This component is a PureComponent, meaning that it'll only + * render when its props changes (shallow comparison). + * + * This component does not render anything into the DOM, it's + * purely (a)busing the component lifecycle to manipulate the + * body class list. + */ +export class AppTheme extends React.PureComponent { + public componentDidMount() { + this.ensureTheme() + } + + public componentDidUpdate() { + this.ensureTheme() + } + + public componentWillUnmount() { + this.clearThemes() + } + + private async ensureTheme() { + let themeToDisplay = this.props.theme + + if (this.props.theme === ApplicationTheme.System) { + themeToDisplay = await getCurrentlyAppliedTheme() + } + + const newThemeClassName = `theme-${getThemeName(themeToDisplay)}` + + if (!document.body.classList.contains(newThemeClassName)) { + this.clearThemes() + document.body.classList.add(newThemeClassName) + this.updateColorScheme() + } + } + + private updateColorScheme = () => { + const isDarkTheme = document.body.classList.contains('theme-dark') + const rootStyle = document.documentElement.style + + rootStyle.colorScheme = isDarkTheme ? 'dark' : 'light' + } + + private clearThemes() { + const body = document.body + + // body.classList is a DOMTokenList and it does not iterate all the way + // through with the for loop. (why it doesn't.. ¯\_(ツ)_/¯ - Possibly + // because we are modifying it as we loop) Hence the extra step of + // converting it to a string array. + const classList = [...body.classList] + for (const className of classList) { + if (className.startsWith('theme-')) { + body.classList.remove(className) + } + } + } + + public render() { + return null + } +} diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx new file mode 100644 index 0000000000..1033b1daa4 --- /dev/null +++ b/app/src/ui/app.tsx @@ -0,0 +1,3523 @@ +import * as React from 'react' +import * as Path from 'path' + +import { TransitionGroup, CSSTransition } from 'react-transition-group' +import { + IAppState, + RepositorySectionTab, + FoldoutType, + SelectionType, + HistoryTabMode, +} from '../lib/app-state' +import { defaultErrorHandler, 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, isWindowsAndNoLongerSupportedByElectron } from '../lib/get-os' +import { MenuEvent } from '../main-process/menu' +import { + Repository, + getGitHubHtmlUrl, + getNonForkGitHubRepository, + isRepositoryWithGitHubRepository, +} from '../models/repository' +import { Branch } from '../models/branch' +import { PreferencesTab } from '../models/preferences' +import { findItemByAccessKey, itemIsSelectable } from '../models/app-menu' +import { Account } from '../models/account' +import { TipState } from '../models/tip' +import { CloneRepositoryTab } from '../models/clone-repository-tab' +import { CloningRepository } from '../models/cloning-repository' + +import { TitleBar, ZoomInfo, FullScreenInfo } from './window' + +import { RepositoriesList } from './repositories-list' +import { RepositoryView } from './repository' +import { RenameBranch } from './rename-branch' +import { DeleteBranch, DeleteRemoteBranch } from './delete-branch' +import { CloningRepositoryView } from './cloning-repository' +import { + Toolbar, + ToolbarDropdown, + DropdownState, + PushPullButton, + BranchDropdown, + RevertProgress, +} from './toolbar' +import { iconForRepository, OcticonSymbolType } from './octicons' +import * as OcticonSymbol from './octicons/octicons.generated' +import { + showCertificateTrustDialog, + sendReady, + isInApplicationFolder, + selectAllWindowContents, +} from './main-process-proxy' +import { DiscardChanges } from './discard-changes' +import { Welcome } from './welcome' +import { AppMenuBar } from './app-menu' +import { UpdateAvailable, renderBanner } from './banners' +import { Preferences } from './preferences' +import { RepositorySettings } from './repository-settings' +import { AppError } from './app-error' +import { MissingRepository } from './missing-repository' +import { AddExistingRepository, CreateRepository } from './add-repository' +import { CloneRepository } from './clone-repository' +import { CreateBranch } from './create-branch' +import { SignIn } from './sign-in' +import { InstallGit } from './install-git' +import { EditorError } from './editor' +import { About } from './about' +import { Publish } from './publish-repository' +import { Acknowledgements } from './acknowledgements' +import { UntrustedCertificate } from './untrusted-certificate' +import { NoRepositoriesView } from './no-repositories' +import { ConfirmRemoveRepository } from './remove-repository' +import { TermsAndConditions } from './terms-and-conditions' +import { PushBranchCommits } from './branches' +import { CLIInstalled } from './cli-installed' +import { GenericGitAuthentication } from './generic-git-auth' +import { ShellError } from './shell' +import { InitializeLFS, AttributeMismatch } from './lfs' +import { UpstreamAlreadyExists } from './upstream-already-exists' +import { ReleaseNotes } from './release-notes' +import { DeletePullRequest } from './delete-branch/delete-pull-request-dialog' +import { CommitConflictsWarning } from './merge-conflicts' +import { AppTheme } from './app-theme' +import { ApplicationTheme } from './lib/application-theme' +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 { 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 { findContributionTargetDefaultBranch } from '../lib/branch' +import { + GitHubRepository, + hasWritePermission, +} from '../models/github-repository' +import { CreateTag } from './create-tag' +import { DeleteTag } from './delete-tag' +import { ChooseForkSettings } from './choose-fork-settings' +import { DiscardSelection } from './discard-changes/discard-selection-dialog' +import { LocalChangesOverwrittenDialog } from './local-changes-overwritten/local-changes-overwritten-dialog' +import memoizeOne from 'memoize-one' +import { AheadBehindStore } from '../lib/stores/ahead-behind-store' +import { getAccountForRepository } from '../lib/get-account-for-repository' +import { CommitOneLine } from '../models/commit' +import { CommitDragElement } from './drag-elements/commit-drag-element' +import classNames from 'classnames' +import { MoveToApplicationsFolder } from './move-to-applications-folder' +import { ChangeRepositoryAlias } from './change-repository-alias/change-repository-alias-dialog' +import { ThankYou } from './thank-you' +import { + getUserContributions, + hasUserAlreadyBeenCheckedOrThanked, + updateLastThankYou, +} from '../lib/thank-you' +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 { 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' +import { WarningBeforeReset } from './reset/warning-before-reset' +import { InvalidatedToken } from './invalidated-token/invalidated-token' +import { MultiCommitOperationKind } from '../models/multi-commit-operation' +import { AddSSHHost } from './ssh/add-ssh-host' +import { SSHKeyPassphrase } from './ssh/ssh-key-passphrase' +import { getMultiCommitOperationChooseBranchStep } from '../lib/multi-commit-operation' +import { ConfirmForcePush } from './rebase/confirm-force-push' +import { PullRequestChecksFailed } from './notifications/pull-request-checks-failed' +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 { 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 { uuid } from '../lib/uuid' +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/windows-version-no-longer-supported-banner' +import { offsetFromNow } from '../lib/offset-from' +import { getNumber } from '../lib/local-storage' +import { RepoRulesBypassConfirmation } from './repository-rules/repo-rules-bypass-confirmation' + +const MinuteInMilliseconds = 1000 * 60 +const HourInMilliseconds = MinuteInMilliseconds * 60 + +/** + * Check for updates every 4 hours + */ +const UpdateCheckInterval = 4 * HourInMilliseconds + +/** + * Send usage stats every 4 hours + */ +const SendStatsInterval = 4 * HourInMilliseconds + +interface IAppProps { + readonly dispatcher: Dispatcher + readonly repositoryStateManager: RepositoryStateCache + readonly appStore: AppStore + readonly issuesStore: IssuesStore + readonly gitHubUserStore: GitHubUserStore + readonly aheadBehindStore: AheadBehindStore + readonly notificationsDebugStore: NotificationsDebugStore + readonly startTime: number +} + +export const dialogTransitionTimeout = { + enter: 250, + exit: 100, +} + +export const bannerTransitionTimeout = { enter: 500, exit: 400 } + +/** + * The time to delay (in ms) from when we've loaded the initial state to showing + * the window. This is try to give Chromium enough time to flush our latest DOM + * changes. See https://github.com/desktop/desktop/issues/1398. + */ +const ReadyDelay = 100 +export class App extends React.Component { + private loading = true + + /** + * Used on non-macOS platforms to support the Alt key behavior for + * the custom application menu. See the event handlers for window + * keyup and keydown. + */ + private lastKeyPressed: string | null = null + + private updateIntervalHandle?: number + + private repositoryViewRef = React.createRef() + + /** + * Gets a value indicating whether or not we're currently showing a + * modal dialog such as the preferences, or an error dialog. + */ + private get isShowingModal() { + return this.state.currentPopup !== null + } + + /** + * Returns a memoized instance of onPopupDismissed() bound to the + * passed popupType, so it can be used in render() without creating + * multiple instances when the component gets re-rendered. + */ + private getOnPopupDismissedFn = memoizeOne((popupId: string) => { + return () => this.onPopupDismissed(popupId) + }) + + public constructor(props: IAppProps) { + super(props) + + props.dispatcher.loadInitialState().then(() => { + this.loading = false + this.forceUpdate() + + requestIdleCallback( + () => { + const now = performance.now() + sendReady(now - props.startTime) + + requestIdleCallback(() => { + this.performDeferredLaunchActions() + }) + }, + { timeout: ReadyDelay } + ) + }) + + this.state = props.appStore.getState() + props.appStore.onDidUpdate(state => { + this.setState(state) + }) + + props.appStore.onDidError(error => { + props.dispatcher.postError(error) + }) + + ipcRenderer.on('menu-event', (_, name) => this.onMenuEvent(name)) + + updateStore.onDidChange(async state => { + const status = state.status + + if ( + !(__RELEASE_CHANNEL__ === 'development') && + status === UpdateStatus.UpdateReady + ) { + this.props.dispatcher.setUpdateBannerVisibility(true) + } + + if ( + status !== UpdateStatus.UpdateReady && + (await updateStore.isUpdateShowcase()) + ) { + this.props.dispatcher.setUpdateShowCaseVisibility(true) + } + }) + + updateStore.onError(error => { + log.error(`Error checking for updates`, error) + + this.props.dispatcher.postError(error) + }) + + ipcRenderer.on('launch-timing-stats', (_, stats) => { + console.info(`App ready time: ${stats.mainReadyTime}ms`) + console.info(`Load time: ${stats.loadTime}ms`) + console.info(`Renderer ready time: ${stats.rendererReadyTime}ms`) + + this.props.dispatcher.recordLaunchStats(stats) + }) + + ipcRenderer.on('certificate-error', (_, certificate, error, url) => { + this.props.dispatcher.showPopup({ + type: PopupType.UntrustedCertificate, + certificate, + url, + }) + }) + + dragAndDropManager.onDragEnded(this.onDragEnd) + } + + public componentWillUnmount() { + window.clearInterval(this.updateIntervalHandle) + } + + private async performDeferredLaunchActions() { + // Loading emoji is super important but maybe less important that loading + // the app. So defer it until we have some breathing space. + this.props.appStore.loadEmoji() + + this.props.dispatcher.reportStats() + setInterval(() => this.props.dispatcher.reportStats(), SendStatsInterval) + + this.props.dispatcher.installGlobalLFSFilters(false) + + // We only want to automatically check for updates on beta and prod + if ( + __RELEASE_CHANNEL__ !== 'development' && + __RELEASE_CHANNEL__ !== 'test' + ) { + setInterval(() => this.checkForUpdates(true), UpdateCheckInterval) + this.checkForUpdates(true) + } else if (await updateStore.isUpdateShowcase()) { + // The only purpose of this call is so we can see the showcase on dev/test + // env. Prod and beta environment will trigger this during automatic check + // for updates. + this.props.dispatcher.setUpdateShowCaseVisibility(true) + } + + log.info(`launching: ${getVersion()} (${getOS()})`) + log.info(`execPath: '${process.execPath}'`) + + // Only show the popup in beta/production releases and mac machines + if ( + __DEV__ === false && + this.state.askToMoveToApplicationsFolderSetting && + __DARWIN__ && + (await isInApplicationFolder()) === false + ) { + this.showPopup({ type: PopupType.MoveToApplicationsFolder }) + } + + this.checkIfThankYouIsInOrder() + + if (isWindowsAndNoLongerSupportedByElectron()) { + 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.WindowsVersionNoLongerSupported }) + } + } + } + + private onMenuEvent(name: MenuEvent): any { + // Don't react to menu events when an error dialog is shown. + if (name !== 'show-app-error' && this.state.errorCount > 1) { + return + } + + switch (name) { + case 'push': + return this.push() + case 'force-push': + return this.push({ forceWithLease: true }) + case 'pull': + return this.pull() + case 'fetch': + return this.fetch() + case 'show-changes': + return this.showChanges(true) + case 'show-history': + return this.showHistory(true) + case 'choose-repository': + return this.chooseRepository() + case 'add-local-repository': + return this.showAddLocalRepo() + case 'create-branch': + return this.showCreateBranch() + case 'show-branches': + return this.showBranches() + case 'remove-repository': + return this.removeRepository(this.getRepository()) + case 'create-repository': + return this.showCreateRepository() + case 'rename-branch': + return this.renameBranch() + case 'delete-branch': + return this.deleteBranch() + case 'discard-all-changes': + return this.discardAllChanges() + case 'stash-all-changes': + return this.stashAllChanges() + case 'show-preferences': + return this.props.dispatcher.showPopup({ type: PopupType.Preferences }) + case 'open-working-directory': + return this.openCurrentRepositoryWorkingDirectory() + case 'update-branch-with-contribution-target-branch': + this.props.dispatcher.recordMenuInitiatedUpdate() + return this.updateBranchWithContributionTargetBranch() + case 'compare-to-branch': + return this.showHistory(false, true) + case 'merge-branch': + this.props.dispatcher.recordMenuInitiatedMerge() + return this.mergeBranch() + case 'squash-and-merge-branch': + this.props.dispatcher.recordMenuInitiatedMerge(true) + return this.mergeBranch(true) + case 'rebase-branch': + this.props.dispatcher.recordMenuInitiatedRebase() + return this.showRebaseDialog() + case 'show-repository-settings': + return this.showRepositorySettings() + case 'view-repository-on-github': + return this.viewRepositoryOnGitHub() + case 'compare-on-github': + return this.openBranchOnGitHub('compare') + case 'branch-on-github': + return this.openBranchOnGitHub('tree') + case 'create-issue-in-repository-on-github': + return this.openIssueCreationOnGitHub() + case 'open-in-shell': + return this.openCurrentRepositoryInShell() + case 'clone-repository': + 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 'preview-pull-request': + return this.startPullRequest() + case 'install-cli': + return this.props.dispatcher.installCLI() + 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 'show-app-error': + return this.props.dispatcher.postError( + new Error('Test Error - to use default error handler' + uuid()) + ) + 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: + 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. + */ + private async showFakeReleaseNotesPopup() { + if (__DEV__) { + this.props.dispatcher.showPopup({ + type: PopupType.ReleaseNotes, + newReleases: await generateDevReleaseSummary(), + }) + } + } + + private testShowNotification() { + if ( + __RELEASE_CHANNEL__ !== 'development' && + __RELEASE_CHANNEL__ !== 'test' + ) { + return + } + + // if current repository is not repository with github repository, return + const repository = this.getRepository() + if ( + repository == null || + repository instanceof CloningRepository || + !isRepositoryWithGitHubRepository(repository) + ) { + return + } + + this.props.dispatcher.showPopup({ + type: PopupType.TestNotifications, + repository, + }) + } + + 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() + } + + /** + * 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 resizeActiveResizable( + menuId: + | 'increase-active-resizable-width' + | 'decrease-active-resizable-width' + ) { + document.activeElement?.dispatchEvent( + new CustomEvent(menuId, { + bubbles: true, + cancelable: true, + }) + ) + } + + /** + * Handler for the 'select-all' 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 own 'select + * all' logic. + */ + private selectAll() { + const event = new CustomEvent('select-all', { + bubbles: true, + cancelable: true, + }) + + if ( + document.activeElement != null && + document.activeElement.dispatchEvent(event) + ) { + selectAllWindowContents() + } + } + + /** + * Handler for the 'find-text' menu event, dispatches + * a custom DOM event originating from the element which + * currently has keyboard focus (or the document if no element + * has focus). Components have a chance to intercept this + * event and implement their own 'find-text' logic. One + * example of this custom event is the text diff which + * will trigger a search dialog when seeing this event. + */ + private findText() { + const event = new CustomEvent('find-text', { + bubbles: true, + cancelable: true, + }) + + if (document.activeElement != null) { + document.activeElement.dispatchEvent(event) + } else { + document.dispatchEvent(event) + } + } + + private boomtown() { + setImmediate(() => { + throw new Error('Boomtown!') + }) + } + + private async goToCommitMessage() { + await this.showChanges(false) + this.props.dispatcher.setCommitMessageFocus(true) + } + + private checkForUpdates( + inBackground: boolean, + skipGuidCheck: boolean = false + ) { + if (__LINUX__ || __RELEASE_CHANNEL__ === 'development') { + return + } + + 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 + } + + updateStore.checkForUpdates(inBackground, skipGuidCheck) + } + + private getDotComAccount(): Account | null { + const dotComAccount = this.state.accounts.find( + a => a.endpoint === getDotComAPIEndpoint() + ) + return dotComAccount || null + } + + private getEnterpriseAccount(): Account | null { + const enterpriseAccount = this.state.accounts.find( + a => a.endpoint !== getDotComAPIEndpoint() + ) + return enterpriseAccount || null + } + + private updateBranchWithContributionTargetBranch() { + const { selectedState } = this.state + if ( + selectedState == null || + selectedState.type !== SelectionType.Repository + ) { + return + } + + const { state, repository } = selectedState + + const contributionTargetDefaultBranch = findContributionTargetDefaultBranch( + repository, + state.branchesState + ) + if (!contributionTargetDefaultBranch) { + return + } + + this.props.dispatcher.initializeMergeOperation( + repository, + false, + contributionTargetDefaultBranch + ) + + const { mergeStatus } = state.compareState + this.props.dispatcher.mergeBranch( + repository, + contributionTargetDefaultBranch, + mergeStatus + ) + } + + private mergeBranch(isSquash: boolean = false) { + const selectedState = this.state.selectedState + if ( + selectedState == null || + selectedState.type !== SelectionType.Repository + ) { + return + } + const { repository } = selectedState + this.props.dispatcher.startMergeBranchOperation(repository, isSquash) + } + + private openBranchOnGitHub(view: 'tree' | 'compare') { + const htmlURL = this.getCurrentRepositoryGitHubURL() + if (!htmlURL) { + return + } + + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + const branchTip = state.state.branchesState.tip + if ( + branchTip.kind !== TipState.Valid || + !branchTip.branch.upstreamWithoutRemote + ) { + return + } + + const urlEncodedBranchName = encodeURIComponent( + branchTip.branch.upstreamWithoutRemote + ) + + const url = `${htmlURL}/${view}/${urlEncodedBranchName}` + this.props.dispatcher.openInBrowser(url) + } + + private openCurrentRepositoryWorkingDirectory() { + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + this.showRepository(state.repository) + } + + private renameBranch() { + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + const tip = state.state.branchesState.tip + if (tip.kind === TipState.Valid) { + this.props.dispatcher.showPopup({ + type: PopupType.RenameBranch, + repository: state.repository, + branch: tip.branch, + }) + } + } + + private deleteBranch() { + const state = this.state.selectedState + if (state === null || state.type !== SelectionType.Repository) { + return + } + + const tip = state.state.branchesState.tip + + if (tip.kind === TipState.Valid) { + const currentPullRequest = state.state.branchesState.currentPullRequest + if (currentPullRequest !== null) { + this.props.dispatcher.showPopup({ + type: PopupType.DeletePullRequest, + repository: state.repository, + branch: tip.branch, + pullRequest: currentPullRequest, + }) + } else { + const existsOnRemote = state.state.aheadBehind !== null + + this.props.dispatcher.showPopup({ + type: PopupType.DeleteBranch, + repository: state.repository, + branch: tip.branch, + existsOnRemote: existsOnRemote, + }) + } + } + } + + private discardAllChanges() { + const state = this.state.selectedState + + if (state == null || state.type !== SelectionType.Repository) { + return + } + + const { workingDirectory } = state.state.changesState + + this.props.dispatcher.showPopup({ + type: PopupType.ConfirmDiscardChanges, + repository: state.repository, + files: workingDirectory.files, + showDiscardChangesSetting: false, + discardingAllChanges: true, + }) + } + + private stashAllChanges() { + const repository = this.getRepository() + + if (repository !== null && repository instanceof Repository) { + this.props.dispatcher.createStashForCurrentBranch(repository) + } + } + + private showAddLocalRepo = () => { + return this.props.dispatcher.showPopup({ type: PopupType.AddRepository }) + } + + private showCreateRepository = () => { + this.props.dispatcher.showPopup({ + type: PopupType.CreateRepository, + }) + } + + private showCloneRepo = (cloneUrl?: string) => { + let initialURL: string | null = null + + if (cloneUrl !== undefined) { + this.props.dispatcher.changeCloneRepositoriesTab( + CloneRepositoryTab.Generic + ) + initialURL = cloneUrl + } + + return this.props.dispatcher.showPopup({ + type: PopupType.CloneRepository, + initialURL, + }) + } + + private showCreateTutorialRepositoryPopup = () => { + const account = this.getDotComAccount() || this.getEnterpriseAccount() + + if (account === null) { + return + } + + this.props.dispatcher.showPopup({ + type: PopupType.CreateTutorialRepository, + account, + }) + } + + private onResumeTutorialRepository = () => { + const tutorialRepository = this.getSelectedTutorialRepository() + if (!tutorialRepository) { + return + } + + this.props.dispatcher.resumeTutorial(tutorialRepository) + } + + private getSelectedTutorialRepository() { + const { selectedState } = this.state + const selectedRepository = + selectedState && selectedState.type === SelectionType.Repository + ? selectedState.repository + : null + + const isTutorialRepository = + selectedRepository && selectedRepository.isTutorialRepository + + return isTutorialRepository ? selectedRepository : null + } + + private showAbout() { + this.props.dispatcher.showPopup({ type: PopupType.About }) + } + + private async showHistory( + shouldFocusHistory: boolean, + showBranchList: boolean = false + ) { + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + await this.props.dispatcher.closeCurrentFoldout() + + await this.props.dispatcher.initializeCompare(state.repository, { + kind: HistoryTabMode.History, + }) + + await this.props.dispatcher.changeRepositorySection( + state.repository, + RepositorySectionTab.History + ) + + await this.props.dispatcher.updateCompareForm(state.repository, { + filterText: '', + showBranchList, + }) + + if (shouldFocusHistory) { + this.repositoryViewRef.current?.setFocusHistoryNeeded() + } + } + + private async showChanges(shouldFocusChanges: boolean) { + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + this.props.dispatcher.closeCurrentFoldout() + + await this.props.dispatcher.changeRepositorySection( + state.repository, + RepositorySectionTab.Changes + ) + + if (shouldFocusChanges) { + this.repositoryViewRef.current?.setFocusChangesNeeded() + } + } + + private chooseRepository() { + if ( + this.state.currentFoldout && + this.state.currentFoldout.type === FoldoutType.Repository + ) { + return this.props.dispatcher.closeFoldout(FoldoutType.Repository) + } + + return this.props.dispatcher.showFoldout({ + type: FoldoutType.Repository, + }) + } + + private showBranches() { + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + if ( + this.state.currentFoldout && + this.state.currentFoldout.type === FoldoutType.Branch + ) { + return this.props.dispatcher.closeFoldout(FoldoutType.Branch) + } + + return this.props.dispatcher.showFoldout({ type: FoldoutType.Branch }) + } + + private push(options?: { forceWithLease: boolean }) { + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + if (options && options.forceWithLease) { + this.props.dispatcher.confirmOrForcePush(state.repository) + } else { + this.props.dispatcher.push(state.repository) + } + } + + private async pull() { + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + 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) { + return + } + + this.props.dispatcher.selectStashedFile(state.repository) + } + + private hideStashedChanges() { + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + this.props.dispatcher.hideStashedChanges(state.repository) + } + + public componentDidMount() { + document.ondragover = e => { + if (e.dataTransfer != null) { + if (this.isShowingModal) { + e.dataTransfer.dropEffect = 'none' + } else { + e.dataTransfer.dropEffect = 'copy' + } + } + + e.preventDefault() + } + + document.ondrop = e => { + e.preventDefault() + } + + document.body.ondrop = e => { + if (this.isShowingModal) { + return + } + if (e.dataTransfer != null) { + const files = e.dataTransfer.files + this.handleDragAndDrop(files) + } + e.preventDefault() + } + + if (shouldRenderApplicationMenu()) { + window.addEventListener('keydown', this.onWindowKeyDown) + window.addEventListener('keyup', this.onWindowKeyUp) + } + + document.addEventListener('focus', this.onDocumentFocus, { + capture: true, + }) + } + + private onDocumentFocus = (event: FocusEvent) => { + this.props.dispatcher.appFocusedElementChanged() + } + + /** + * On Windows pressing the Alt key and holding it down should + * highlight the application menu. + * + * This method in conjunction with the onWindowKeyUp sets the + * appMenuToolbarHighlight state when the Alt key (and only the + * Alt key) is pressed. + */ + private onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return + } + + if (this.isShowingModal) { + return + } + + if (shouldRenderApplicationMenu()) { + if (event.key === 'Shift' && event.altKey) { + this.props.dispatcher.setAccessKeyHighlightState(false) + } else if (event.key === 'Alt') { + if (event.shiftKey) { + return + } + // Immediately close the menu if open and the user hits Alt. This is + // a Windows convention. + if ( + this.state.currentFoldout && + this.state.currentFoldout.type === FoldoutType.AppMenu + ) { + // Only close it the menu when the key is pressed if there's an open + // menu. If there isn't we should close it when the key is released + // instead and that's taken care of in the onWindowKeyUp function. + if (this.state.appMenuState.length > 1) { + this.props.dispatcher.setAppMenuState(menu => menu.withReset()) + this.props.dispatcher.closeFoldout(FoldoutType.AppMenu) + } + } + + this.props.dispatcher.setAccessKeyHighlightState(true) + } else if (event.altKey && !event.ctrlKey && !event.metaKey) { + if (this.state.appMenuState.length) { + const candidates = this.state.appMenuState[0].items + const menuItemForAccessKey = findItemByAccessKey( + event.key, + candidates + ) + + if (menuItemForAccessKey && itemIsSelectable(menuItemForAccessKey)) { + if (menuItemForAccessKey.type === 'submenuItem') { + this.props.dispatcher.setAppMenuState(menu => + menu + .withReset() + .withSelectedItem(menuItemForAccessKey) + .withOpenedMenu(menuItemForAccessKey, true) + ) + + this.props.dispatcher.showFoldout({ + type: FoldoutType.AppMenu, + enableAccessKeyNavigation: true, + openedWithAccessKey: true, + }) + } else { + this.props.dispatcher.executeMenuItem(menuItemForAccessKey) + } + + event.preventDefault() + } + } + } else if (!event.altKey) { + this.props.dispatcher.setAccessKeyHighlightState(false) + } + } + + this.lastKeyPressed = event.key + } + + /** + * Open the application menu foldout when the Alt key is pressed. + * + * See onWindowKeyDown for more information. + */ + private onWindowKeyUp = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return + } + + if (shouldRenderApplicationMenu()) { + if (event.key === 'Alt') { + this.props.dispatcher.setAccessKeyHighlightState(false) + + if (this.lastKeyPressed === 'Alt') { + if ( + this.state.currentFoldout && + this.state.currentFoldout.type === FoldoutType.AppMenu + ) { + this.props.dispatcher.setAppMenuState(menu => menu.withReset()) + this.props.dispatcher.closeFoldout(FoldoutType.AppMenu) + } else { + this.props.dispatcher.showFoldout({ + type: FoldoutType.AppMenu, + enableAccessKeyNavigation: true, + openedWithAccessKey: false, + }) + } + } + } + } + } + + private async handleDragAndDrop(fileList: FileList) { + const paths = [...fileList].map(x => x.path) + const { dispatcher } = this.props + + // If they're bulk adding repositories then just blindly try to add them. + // But if they just dragged one, use the dialog so that they can initialize + // it if needed. + if (paths.length > 1) { + const addedRepositories = await dispatcher.addRepositories(paths) + + if (addedRepositories.length > 0) { + dispatcher.recordAddExistingRepository() + await dispatcher.selectRepository(addedRepositories[0]) + } + } else if (paths.length === 1) { + // user may accidentally provide a folder within the repository + // this ensures we use the repository root, if it is actually a repository + // otherwise we consider it an untracked repository + const path = await getRepositoryType(paths[0]) + .then(t => + t.kind === 'regular' ? t.topLevelWorkingDirectory : paths[0] + ) + .catch(e => { + log.error('Could not determine repository type', e) + return paths[0] + }) + + const { repositories } = this.state + const existingRepository = matchExistingRepository(repositories, path) + + if (existingRepository) { + await dispatcher.selectRepository(existingRepository) + } else { + await this.showPopup({ type: PopupType.AddRepository, path }) + } + } + } + + private removeRepository = ( + repository: Repository | CloningRepository | null + ) => { + if (!repository) { + return + } + + if (repository instanceof CloningRepository || repository.missing) { + this.props.dispatcher.removeRepository(repository, false) + return + } + + if (this.state.askForConfirmationOnRepositoryRemoval) { + this.props.dispatcher.showPopup({ + type: PopupType.RemoveRepository, + repository, + }) + } else { + this.props.dispatcher.removeRepository(repository, false) + } + } + + private onConfirmRepoRemoval = async ( + repository: Repository, + deleteRepoFromDisk: boolean + ) => { + await this.props.dispatcher.removeRepository(repository, deleteRepoFromDisk) + } + + private getRepository(): Repository | CloningRepository | null { + const state = this.state.selectedState + if (state == null) { + return null + } + + return state.repository + } + + private showRebaseDialog() { + const repository = this.getRepository() + + if (!repository || repository instanceof CloningRepository) { + return + } + + this.props.dispatcher.showRebaseDialog(repository) + } + + private showRepositorySettings() { + const repository = this.getRepository() + + if (!repository || repository instanceof CloningRepository) { + return + } + this.props.dispatcher.showPopup({ + type: PopupType.RepositorySettings, + repository, + }) + } + + /** + * Opens a browser to the issue creation page + * of the current GitHub repository. + */ + private openIssueCreationOnGitHub() { + const repository = this.getRepository() + // this will likely never be null since we disable the + // issue creation menu item for non-GitHub repositories + if (repository instanceof Repository) { + this.props.dispatcher.openIssueCreationPage(repository) + } + } + + private viewRepositoryOnGitHub() { + const repository = this.getRepository() + + this.viewOnGitHub(repository) + } + + /** Returns the URL to the current repository if hosted on GitHub */ + private getCurrentRepositoryGitHubURL() { + const repository = this.getRepository() + + if ( + !repository || + repository instanceof CloningRepository || + !repository.gitHubRepository + ) { + return null + } + + return repository.gitHubRepository.htmlURL + } + + private openCurrentRepositoryInShell = () => { + const repository = this.getRepository() + if (!repository) { + return + } + + this.openInShell(repository) + } + + private openCurrentRepositoryInExternalEditor() { + const repository = this.getRepository() + if (!repository) { + return + } + + this.openInExternalEditor(repository) + } + + /** + * Conditionally renders a menu bar. The menu bar is currently only rendered + * on Windows. + */ + private renderAppMenuBar() { + // We only render the app menu bar on Windows + if (!__WIN32__) { + return null + } + + // Have we received an app menu from the main process yet? + if (!this.state.appMenuState.length) { + return null + } + + // Don't render the menu bar during the welcome flow + if (this.state.showWelcomeFlow) { + return null + } + + const currentFoldout = this.state.currentFoldout + + // AppMenuBar requires us to pass a strongly typed AppMenuFoldout state or + // null if the AppMenu foldout is not currently active. + const foldoutState = + currentFoldout && currentFoldout.type === FoldoutType.AppMenu + ? currentFoldout + : null + + return ( + + ) + } + + private onMenuBarLostFocus = () => { + // Note: This event is emitted in an animation frame separate from + // that of the AppStore. See onLostFocusWithin inside of the AppMenuBar + // for more details. This means that it's possible that the current + // app state in this component's state might be out of date so take + // caution when considering app state in this method. + this.props.dispatcher.closeFoldout(FoldoutType.AppMenu) + this.props.dispatcher.setAppMenuState(menu => menu.withReset()) + } + + private renderTitlebar() { + const inFullScreen = this.state.windowState === 'full-screen' + + const menuBarActive = + 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. + if (inFullScreen) { + if (!__WIN32__ || !menuBarActive) { + return null + } + } + + const showAppIcon = __WIN32__ && !this.state.showWelcomeFlow + const inWelcomeFlow = this.state.showWelcomeFlow + const inNoRepositoriesView = this.inNoRepositoriesViewState() + + // The light title bar style should only be used while we're in + // the welcome flow as well as the no-repositories blank slate + // on macOS. The latter case has to do with the application menu + // being part of the title bar on Windows. We need to render + // the app menu in the no-repositories blank slate on Windows but + // the menu doesn't support the light style at the moment so we're + // forcing it to use the dark style. + const titleBarStyle = + inWelcomeFlow || (__DARWIN__ && inNoRepositoriesView) ? 'light' : 'dark' + + return ( + + {this.renderAppMenuBar()} + + ) + } + + private onPopupDismissed = (popupId: string) => { + return this.props.dispatcher.closePopupById(popupId) + } + + private onContinueWithUntrustedCertificate = ( + certificate: Electron.Certificate + ) => { + showCertificateTrustDialog( + certificate, + 'Could not securely connect to the server, because its certificate is not trusted. Attackers might be trying to steal your information.\n\nTo connect unsafely, which may put your data at risk, you can “Always trust” the certificate and try again.' + ) + } + + private onUpdateAvailableDismissed = () => + this.props.dispatcher.setUpdateBannerVisibility(false) + + private allPopupContent(): JSX.Element | null { + const { allPopups } = this.state + + if (allPopups.length === 0) { + return null + } + + return ( + <> + {allPopups.map(popup => { + const isTopMost = this.state.currentPopup?.id === popup.id + return ( + + {this.popupContent(popup, isTopMost)} + + ) + })} + + ) + } + + 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.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 ( + + ) + case PopupType.DeleteBranch: + return ( + + ) + case PopupType.DeleteRemoteBranch: + return ( + + ) + case PopupType.ConfirmDiscardChanges: + const showSetting = + popup.showDiscardChangesSetting === undefined + ? true + : popup.showDiscardChangesSetting + const discardingAllChanges = + popup.discardingAllChanges === undefined + ? false + : popup.discardingAllChanges + + return ( + + ) + case PopupType.ConfirmDiscardSelection: + return ( + + ) + case PopupType.Preferences: + let repository = this.getRepository() + + if (repository instanceof CloningRepository) { + repository = null + } + + return ( + + ) + case PopupType.RepositorySettings: { + const repository = popup.repository + const state = this.props.repositoryStateManager.get(repository) + const repositoryAccount = getAccountForRepository( + this.state.accounts, + repository + ) + + return ( + + ) + } + case PopupType.SignIn: + return ( + + ) + case PopupType.AddRepository: + return ( + + ) + case PopupType.CreateRepository: + return ( + + ) + case PopupType.CloneRepository: + return ( + + ) + case PopupType.CreateBranch: { + const state = this.props.repositoryStateManager.get(popup.repository) + const branchesState = state.branchesState + const repository = popup.repository + + if (branchesState.tip.kind === TipState.Unknown) { + onPopupDismissedFn() + return null + } + + let upstreamGhRepo: GitHubRepository | null = null + let upstreamDefaultBranch: Branch | null = null + + if (isRepositoryWithGitHubRepository(repository)) { + upstreamGhRepo = getNonForkGitHubRepository(repository) + upstreamDefaultBranch = branchesState.upstreamDefaultBranch + } + + return ( + + ) + } + case PopupType.InstallGit: + return ( + + ) + case PopupType.About: + const version = __DEV__ ? __SHA__.substring(0, 10) : getVersion() + + return ( + + ) + case PopupType.PublishRepository: + return ( + + ) + case PopupType.UntrustedCertificate: + return ( + + ) + case PopupType.Acknowledgements: + return ( + + ) + case PopupType.RemoveRepository: + return ( + + ) + case PopupType.TermsAndConditions: + return ( + + ) + case PopupType.PushBranchCommits: + return ( + + ) + case PopupType.CLIInstalled: + return ( + + ) + case PopupType.GenericGitAuthentication: + return ( + + ) + case PopupType.ExternalEditorFailed: + const openPreferences = popup.openPreferences + const suggestDefaultEditor = popup.suggestDefaultEditor + + return ( + + ) + case PopupType.OpenShellFailed: + return ( + + ) + case PopupType.InitializeLFS: + return ( + + ) + case PopupType.LFSAttributeMismatch: + return ( + + ) + case PopupType.UpstreamAlreadyExists: + return ( + + ) + case PopupType.ReleaseNotes: + return ( + + ) + case PopupType.DeletePullRequest: + return ( + + ) + case PopupType.OversizedFiles: + return ( + + ) + case PopupType.CommitConflictsWarning: + return ( + + ) + case PopupType.PushNeedsPull: + return ( + + ) + case PopupType.ConfirmForcePush: { + const { askForConfirmationOnForcePush } = this.state + + return ( + + ) + } + case PopupType.StashAndSwitchBranch: { + const { repository, branchToCheckout } = popup + const { branchesState, changesState } = + this.props.repositoryStateManager.get(repository) + const { tip } = branchesState + + if (tip.kind !== TipState.Valid) { + return null + } + + const currentBranch = tip.branch + const hasAssociatedStash = changesState.stashEntry !== null + + return ( + + ) + } + case PopupType.ConfirmOverwriteStash: { + const { repository, branchToCheckout: branchToCheckout } = popup + return ( + + ) + } + case PopupType.ConfirmDiscardStash: { + const { repository, stash } = popup + + return ( + + ) + } + case PopupType.ConfirmCheckoutCommit: { + const { repository, commit } = popup + + return ( + + ) + } + case PopupType.CreateTutorialRepository: { + return ( + + ) + } + case PopupType.ConfirmExitTutorial: { + return ( + + ) + } + case PopupType.PushRejectedDueToMissingWorkflowScope: + return ( + + ) + case PopupType.SAMLReauthRequired: + return ( + + ) + case PopupType.CreateFork: + return ( + + ) + case PopupType.CreateTag: { + return ( + + ) + } + case PopupType.DeleteTag: { + return ( + + ) + } + case PopupType.ChooseForkSettings: { + return ( + + ) + } + case PopupType.LocalChangesOverwritten: + const selectedState = this.state.selectedState + + const existingStash = + selectedState !== null && + selectedState.type === SelectionType.Repository + ? selectedState.state.changesState.stashEntry + : null + + return ( + + ) + case PopupType.MoveToApplicationsFolder: { + return ( + + ) + } + case PopupType.ChangeRepositoryAlias: { + return ( + + ) + } + case PopupType.ThankYou: + return ( + + ) + case PopupType.CommitMessage: + const repositoryState = this.props.repositoryStateManager.get( + popup.repository + ) + + const { tip } = repositoryState.branchesState + const currentBranchName: string | null = + tip.kind === TipState.Valid ? tip.branch.name : null + + const hasWritePermissionForRepository = + popup.repository.gitHubRepository === null || + hasWritePermission(popup.repository.gitHubRepository) + + const autocompletionProviders = buildAutocompletionProviders( + popup.repository, + this.props.dispatcher, + this.state.emoji, + this.props.issuesStore, + this.props.gitHubUserStore, + this.state.accounts + ) + + const repositoryAccount = getAccountForRepository( + this.state.accounts, + popup.repository + ) + + return ( + + ) + case PopupType.MultiCommitOperation: { + const { selectedState, emoji } = this.state + + if ( + selectedState === null || + selectedState.type !== SelectionType.Repository + ) { + return null + } + + const { changesState, multiCommitOperationState } = selectedState.state + const { workingDirectory, conflictState } = changesState + if (multiCommitOperationState === null) { + log.warn( + '[App] invalid state encountered - multi commit flow should not be active when step is null' + ) + return null + } + + return ( + + ) + } + case PopupType.WarnLocalChangesBeforeUndo: { + const { repository, commit, isWorkingDirectoryClean } = popup + return ( + + ) + } + case PopupType.WarningBeforeReset: { + const { repository, commit } = popup + return ( + + ) + } + case PopupType.InvalidatedToken: { + return ( + + ) + } + case PopupType.AddSSHHost: { + return ( + + ) + } + case PopupType.SSHKeyPassphrase: { + return ( + + ) + } + case PopupType.SSHUserPassword: { + return ( + + ) + } + case PopupType.PullRequestChecksFailed: { + return ( + + ) + } + case PopupType.CICheckRunRerun: { + return ( + + ) + } + case PopupType.WarnForcePush: { + const { askForConfirmationOnForcePush } = this.state + return ( + + ) + } + case PopupType.DiscardChangesRetry: { + return ( + + ) + } + 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.ConfirmRepoRulesBypass: { + return ( + + ) + } + default: + return assertNever(popup, `Unknown popup type: ${popup}`) + } + } + + 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 + ) { + return () => { + onBegin() + onPopupDismissedFn() + } + } + + private onExitTutorialToHomeScreen = () => { + const tutorialRepository = this.getSelectedTutorialRepository() + if (!tutorialRepository) { + return false + } + + this.props.dispatcher.pauseTutorial(tutorialRepository) + return true + } + + private onCreateTutorialRepository = (account: Account) => { + this.props.dispatcher.createTutorialRepository(account) + } + + private onUpdateExistingUpstreamRemote = (repository: Repository) => { + this.props.dispatcher.updateExistingUpstreamRemote(repository) + } + + private onIgnoreExistingUpstreamRemote = (repository: Repository) => { + this.props.dispatcher.ignoreExistingUpstreamRemote(repository) + } + + private updateExistingLFSFilters = () => { + this.props.dispatcher.installGlobalLFSFilters(true) + } + + private initializeLFS = (repositories: ReadonlyArray) => { + this.props.dispatcher.installLFSHooks(repositories) + } + + private onCloneRepositoriesTabSelected = (tab: CloneRepositoryTab) => { + this.props.dispatcher.changeCloneRepositoriesTab(tab) + } + + private onRefreshRepositories = (account: Account) => { + this.props.dispatcher.refreshApiRepositories(account) + } + + private onShowAdvancedPreferences = () => { + this.props.dispatcher.showPopup({ + type: PopupType.Preferences, + initialSelectedTab: PreferencesTab.Advanced, + }) + } + + private onBranchCreatedFromCommit = () => { + const repositoryView = this.repositoryViewRef.current + if (repositoryView !== null) { + repositoryView.scrollCompareListToTop() + } + } + + private onOpenShellIgnoreWarning = (path: string) => { + 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 }) + } + + private showTermsAndConditions = () => { + this.props.dispatcher.showPopup({ type: PopupType.TermsAndConditions }) + } + + private renderPopups() { + const popupContent = this.allPopupContent() + + return ( + + {popupContent && ( + + {popupContent} + + )} + + ) + } + + private renderDragElement() { + return
{this.renderCurrentDragElement()}
+ } + + /** + * Render the current drag element based on it's type. Used in conjunction + * with the `Draggable` component. + */ + private renderCurrentDragElement(): JSX.Element | null { + const { currentDragElement, emoji } = this.state + if (currentDragElement === null) { + return null + } + + const { gitHubRepository, commit, selectedCommits } = currentDragElement + switch (currentDragElement.type) { + case DragType.Commit: + return ( + + ) + default: + return assertNever( + currentDragElement.type, + `Unknown drag element type: ${currentDragElement}` + ) + } + } + + private renderZoomInfo() { + return + } + + private renderFullScreenInfo() { + return + } + + private onConfirmDiscardChangesChanged = (value: boolean) => { + this.props.dispatcher.setConfirmDiscardChangesSetting(value) + } + + private onConfirmDiscardChangesPermanentlyChanged = (value: boolean) => { + this.props.dispatcher.setConfirmDiscardChangesPermanentlySetting(value) + } + + private onRetryAction = (retryAction: RetryAction) => { + this.props.dispatcher.performRetry(retryAction) + } + + private showPopup = (popup: Popup) => { + 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, + }) + } + + private renderApp() { + return ( +
+ {this.renderToolbar()} + {this.renderBanner()} + {this.renderRepository()} + {this.renderPopups()} + {this.renderDragElement()} +
+ ) + } + + private renderRepositoryList = (): JSX.Element => { + const selectedRepository = this.state.selectedState + ? this.state.selectedState.repository + : null + const externalEditorLabel = this.state.selectedExternalEditor + ? this.state.selectedExternalEditor + : undefined + const shellLabel = this.state.selectedShell + const filterText = this.state.repositoryFilterText + return ( + + ) + } + + private viewOnGitHub = ( + repository: Repository | CloningRepository | null + ) => { + if (!(repository instanceof Repository)) { + return + } + + const url = getGitHubHtmlUrl(repository) + + if (url) { + this.props.dispatcher.openInBrowser(url) + } + } + + private openInShell = (repository: Repository | CloningRepository) => { + if (!(repository instanceof Repository)) { + return + } + + this.props.dispatcher.openShell(repository.path) + } + + private openFileInExternalEditor = (fullPath: string) => { + this.props.dispatcher.openInExternalEditor(fullPath) + } + + private openInExternalEditor = ( + repository: Repository | CloningRepository + ) => { + if (!(repository instanceof Repository)) { + return + } + + 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 + } + + shell.showFolderContents(repository.path) + } + + private onRepositoryDropdownStateChanged = (newState: DropdownState) => { + if (newState === 'open') { + this.props.dispatcher.showFoldout({ type: FoldoutType.Repository }) + } else { + this.props.dispatcher.closeFoldout(FoldoutType.Repository) + } + } + + private onExitTutorial = () => { + if ( + this.state.repositories.length === 1 && + isValidTutorialStep(this.state.currentOnboardingTutorialStep) + ) { + // If the only repository present is the tutorial repo, + // prompt for confirmation and exit to the BlankSlateView + this.props.dispatcher.showPopup({ + type: PopupType.ConfirmExitTutorial, + }) + } else { + // Otherwise pop open repositories panel + this.onRepositoryDropdownStateChanged('open') + } + } + + private renderRepositoryToolbarButton() { + const selection = this.state.selectedState + + const repository = selection ? selection.repository : null + + let icon: OcticonSymbolType + 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 + title = __DARWIN__ ? 'Select a Repository' : 'Select a repository' + } else { + icon = OcticonSymbol.repo + title = __DARWIN__ ? 'No Repositories' : 'No repositories' + } + + const isOpen = + this.state.currentFoldout && + this.state.currentFoldout.type === FoldoutType.Repository + + const currentState: DropdownState = isOpen ? 'open' : 'closed' + + const tooltip = repository && !isOpen ? repository.path : undefined + + const foldoutWidth = clamp(this.state.sidebarWidth) + + const foldoutStyle: React.CSSProperties = { + position: 'absolute', + marginLeft: 0, + width: foldoutWidth, + minWidth: foldoutWidth, + height: '100%', + 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 ( + + ) + } + + 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.selectedShell, + }) + + showContextualMenu(items) + } + + private renderPushPullToolbarButton() { + const selection = this.state.selectedState + if (!selection || selection.type !== SelectionType.Repository) { + return null + } + + const state = selection.state + const revertProgress = state.revertProgress + if (revertProgress) { + return + } + + let remoteName = state.remote ? state.remote.name : null + const progress = state.pushPullFetchProgress + + const { conflictState } = state.changesState + + const rebaseInProgress = + conflictState !== null && conflictState.kind === 'rebase' + + const { aheadBehind, branchesState } = state + const { pullWithRebase, tip } = branchesState + + 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 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 ( + + ) + } + + private showCreateBranch = () => { + const selection = this.state.selectedState + + // NB: This should never happen but in the case someone + // manages to delete the last repository while the drop down is + // open we'll just bail here. + if (!selection || selection.type !== SelectionType.Repository) { + return + } + + // We explicitly disable the menu item in this scenario so this + // should never happen. + if (selection.state.branchesState.tip.kind === TipState.Unknown) { + return + } + + const repository = selection.repository + + return this.props.dispatcher.showPopup({ + type: PopupType.CreateBranch, + repository, + }) + } + + private openPullRequest = () => { + const state = this.state.selectedState + + if (state == null || state.type !== SelectionType.Repository) { + return + } + + const currentPullRequest = state.state.branchesState.currentPullRequest + const dispatcher = this.props.dispatcher + + if (currentPullRequest == null) { + dispatcher.createPullRequest(state.repository) + dispatcher.recordCreatePullRequest() + } 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 + ) => { + 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 }) + } else { + this.props.dispatcher.closeFoldout(FoldoutType.Branch) + } + } + + private renderBranchToolbarButton(): JSX.Element | null { + const selection = this.state.selectedState + + if (selection == null || selection.type !== SelectionType.Repository) { + return null + } + + const currentFoldout = this.state.currentFoldout + + const isOpen = + currentFoldout !== null && currentFoldout.type === FoldoutType.Branch + + 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 ( + + ) + } + + // we currently only render one banner at a time + private renderBanner(): JSX.Element | null { + // The inset light title bar style without the toolbar + // can't support banners at the moment. So for the + // no-repositories blank slate we'll have to live without + // them. + if (this.inNoRepositoriesViewState()) { + return null + } + + let banner = null + if (this.state.currentBanner !== null) { + banner = renderBanner( + this.state.currentBanner, + this.props.dispatcher, + this.onBannerDismissed + ) + } else if ( + this.state.isUpdateAvailableBannerVisible || + this.state.isUpdateShowcaseVisible + ) { + banner = this.renderUpdateBanner() + } + return ( + + {banner && ( + + {banner} + + )} + + ) + } + + private renderUpdateBanner() { + return ( + + ) + } + + private onBannerDismissed = () => { + this.props.dispatcher.clearBanner() + } + + private renderToolbar() { + /** + * No toolbar if we're in the blank slate view. + */ + if (this.inNoRepositoriesViewState()) { + return null + } + + const width = clamp(this.state.sidebarWidth) + + return ( + +
+ {this.renderRepositoryToolbarButton()} +
+ {this.renderBranchToolbarButton()} + {this.renderPushPullToolbarButton()} +
+ ) + } + + private renderRepository() { + const state = this.state + if (this.inNoRepositoriesViewState()) { + return ( + + ) + } + + const selectedState = state.selectedState + if (!selectedState) { + return + } + + if (selectedState.type === SelectionType.Repository) { + const externalEditorLabel = state.selectedExternalEditor + ? state.selectedExternalEditor + : undefined + + return ( + + ) + } else if (selectedState.type === SelectionType.CloningRepository) { + return ( + + ) + } else if (selectedState.type === SelectionType.MissingRepository) { + return ( + + ) + } else { + return assertNever(selectedState, `Unknown state: ${selectedState}`) + } + } + + private renderWelcomeFlow() { + return ( + + ) + } + + public render() { + if (this.loading) { + return null + } + + const className = this.state.appIsFocused ? 'focused' : 'blurred' + + const currentTheme = this.state.showWelcomeFlow + ? ApplicationTheme.Light + : this.state.currentTheme + + return ( +
+ + {this.renderTitlebar()} + {this.state.showWelcomeFlow + ? this.renderWelcomeFlow() + : this.renderApp()} + {this.renderZoomInfo()} + {this.renderFullScreenInfo()} +
+ ) + } + + private onRepositoryFilterTextChanged = (text: string) => { + this.props.dispatcher.setRepositoryFilterText(text) + } + + private onSelectionChanged = (repository: Repository | CloningRepository) => { + this.props.dispatcher.selectRepository(repository) + this.props.dispatcher.closeFoldout(FoldoutType.Repository) + } + + private onViewCommitOnGitHub = async (SHA: string, filePath?: string) => { + const repository = this.getRepository() + + if ( + !repository || + repository instanceof CloningRepository || + !repository.gitHubRepository + ) { + return + } + + const commitURL = createCommitURL( + repository.gitHubRepository, + SHA, + filePath + ) + + if (commitURL === null) { + return + } + + this.props.dispatcher.openInBrowser(commitURL) + } + + private onBranchDeleted = (repository: Repository) => { + // In the event a user is in the middle of a compare + // we need to exit out of the compare state after the + // branch has been deleted. Calling executeCompare allows + // us to do just that. + this.props.dispatcher.executeCompare(repository, { + kind: HistoryTabMode.History, + }) + } + + private inNoRepositoriesViewState() { + return this.state.repositories.length === 0 || this.isTutorialPaused() + } + + private isTutorialPaused() { + return this.state.currentOnboardingTutorialStep === TutorialStep.Paused + } + + /** + * When starting cherry pick from context menu, we need to initialize the + * cherry pick state flow step with the ChooseTargetBranch as opposed + * to drag and drop which will start at the ShowProgress step. + * + * Step initialization must be done before and outside of the + * `currentPopupContent` method because it is a rendering method that is + * re-run on every update. It will just keep showing the step initialized + * there otherwise - not allowing for other flow steps. + */ + private startCherryPickWithoutBranch = ( + repository: Repository, + commits: ReadonlyArray + ) => { + const repositoryState = this.props.repositoryStateManager.get(repository) + + const { tip } = repositoryState.branchesState + let currentBranch: Branch | null = null + + if (tip.kind === TipState.Valid) { + currentBranch = tip.branch + } else { + throw new Error( + 'Tip is not in a valid state, which is required to start the cherry-pick flow' + ) + } + + this.props.dispatcher.initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.CherryPick, + sourceBranch: currentBranch, + branchCreated: false, + commits, + }, + null, + commits, + tip.branch.tip.sha + ) + + const initialStep = getMultiCommitOperationChooseBranchStep(repositoryState) + + this.props.dispatcher.setMultiCommitOperationStep(repository, initialStep) + this.props.dispatcher.recordCherryPickViaContextMenu() + + this.showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + } + + /** + * Check if the user signed into their dotCom account has been tagged in + * our release notes or if they already have received a thank you card. + * + * Notes: A user signed into a GHE account should not be contributing to + * Desktop as that account should be used for GHE repos. Tho, technically it + * is possible through commit misattribution and we are intentionally ignoring + * this scenario as it would be expected any misattributed commit would not + * be able to be detected. + */ + private async checkIfThankYouIsInOrder(): Promise { + const dotComAccount = this.getDotComAccount() + if (dotComAccount === null) { + // The user is not signed in or is a GHE user who should not have any. + return + } + + const { lastThankYou } = this.state + const { login } = dotComAccount + if (hasUserAlreadyBeenCheckedOrThanked(lastThankYou, login, getVersion())) { + return + } + + const isOnlyLastRelease = + lastThankYou !== undefined && lastThankYou.checkedUsers.includes(login) + const userContributions = await getUserContributions( + isOnlyLastRelease, + login + ) + if (userContributions === null) { + // This will prevent unnecessary release note retrieval on every time the + // app is opened for a non-contributor. + updateLastThankYou( + this.props.dispatcher, + lastThankYou, + login, + getVersion() + ) + return + } + + // If this is the first time user has seen the card, we want to thank them + // for all previous versions. Thus, only specify current version if they + // have been thanked before. + const displayVersion = isOnlyLastRelease ? getVersion() : null + const banner: Banner = { + type: BannerType.OpenThankYouCard, + // Grab emoji's by reference because we could still be loading emoji's + emoji: this.state.emoji, + onOpenCard: () => + this.openThankYouCard(userContributions, displayVersion), + onThrowCardAway: () => { + updateLastThankYou( + this.props.dispatcher, + lastThankYou, + login, + getVersion() + ) + }, + } + this.setBanner(banner) + } + + private openThankYouCard = ( + userContributions: ReadonlyArray, + latestVersion: string | null = null + ) => { + const dotComAccount = this.getDotComAccount() + + if (dotComAccount === null) { + // The user is not signed in or is a GHE user who should not have any. + return + } + const { friendlyName } = dotComAccount + + this.props.dispatcher.showPopup({ + type: PopupType.ThankYou, + userContributions, + friendlyName, + latestVersion, + }) + } + + private onDragEnd = (dropTargetSelector: DropTargetSelector | undefined) => { + this.props.dispatcher.closeFoldout(FoldoutType.Branch) + if (dropTargetSelector === undefined) { + this.props.dispatcher.recordDragStartedAndCanceled() + } + } +} + +function NoRepositorySelected() { + return
No repository selected
+} diff --git a/app/src/ui/autocompletion/autocompleting-text-input.tsx b/app/src/ui/autocompletion/autocompleting-text-input.tsx new file mode 100644 index 0000000000..cdc0970a77 --- /dev/null +++ b/app/src/ui/autocompletion/autocompleting-text-input.tsx @@ -0,0 +1,757 @@ +import * as React from 'react' +import { + List, + SelectionSource, + findNextSelectableRow, + SelectionDirection, +} from '../lib/list' +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 +} + +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 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 + + /** Indicates if input field should be required */ + 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. + */ + readonly onValueChanged?: (value: string) => void + + /** 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. + */ + readonly autocompletionProviders: ReadonlyArray> + + /** + * A method that's called when the internal input or textarea element + * is mounted or unmounted. + */ + readonly onElementRef?: (elem: ElementType | null) => void + + /** + * Optional callback to override the default edit context menu + * 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 { + readonly provider: IAutocompletionProvider + readonly items: ReadonlyArray + readonly range: IRange + readonly rangeText: string + readonly selectedItem: T | null + readonly selectedRowId: string | undefined + readonly itemListRowIdPrefix: string +} + +/** + * The height of the autocompletion result rows. + */ +const RowHeight = 29 + +/** + * The amount to offset on the Y axis so that the popup is displayed below the + * current line. + */ +const YOffset = 20 + +/** + * The default height for the popup. Note that the actual height may be + * smaller in order to fit the popup within the window. + */ +const DefaultPopupHeight = 100 + +interface IAutocompletingTextInputState { + /** + * All of the state about autocompletion. Will be null if there are no + * 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, + AutocompleteItemType extends Object +> extends React.Component< + IAutocompletingTextInputProps, + IAutocompletingTextInputState +> { + private element: ElementType | null = null + private invisibleCaretRef = React.createRef() + + /** The identifier for each autocompletion request. */ + private autocompletionRequestID = 0 + + /** + * To be implemented by subclasses. It must return the element tag name which + * should correspond to the ElementType over which it is parameterized. + */ + protected abstract getElementTagName(): 'textarea' | 'input' + + public constructor( + props: IAutocompletingTextInputProps + ) { + super(props) + + this.state = { + autocompletionState: null, + caretCoordinates: null, + } + } + + public componentWillMount() { + const elementId = 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 renderItem = (row: number): JSX.Element | null => { + const state = this.state.autocompletionState + if (!state) { + return null + } + + const item = state.items[row] + const selected = item === state.selectedItem ? 'selected' : '' + return ( +
+ {state.provider.renderItem(item)} +
+ ) + } + + private renderAutocompletions() { + const state = this.state.autocompletionState + if (!state) { + return null + } + + const items = state.items + if (!items.length) { + return null + } + + const selectedRow = state.selectedItem + ? items.indexOf(state.selectedItem) + : -1 + + // The height needed to accommodate all the matched items without overflowing + // + // Magic number warning! The autocompletion-popup container adds a border + // which we have to account for in case we want to show N number of items + // without overflowing and triggering the scrollbar. + const noOverflowItemHeight = RowHeight * items.length + + 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 + // remains the same. Additionally we need to be aware that different + // providers can use different sorting behaviors which also might affect + // rendering. + const searchText = state.rangeText + + 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 + + if (!currentAutoCompletionState) { + return + } + + const item = currentAutoCompletionState.items[row] + + if (item) { + this.insertCompletion(item, 'mouseclick') + } + } + + private onSelectedRowChanged = (row: number, source: SelectionSource) => { + const currentAutoCompletionState = this.state.autocompletionState + + if (!currentAutoCompletionState) { + return + } + + const newSelectedItem = currentAutoCompletionState.items[row] + + const newAutoCompletionState = { + ...currentAutoCompletionState, + selectedItem: newSelectedItem, + selectedRowId: newSelectedItem === null ? undefined : this.getRowId(row), + } + + this.setState({ autocompletionState: newAutoCompletionState }) + } + + private insertCompletionOnClick = (row: number): void => { + const state = this.state.autocompletionState + if (!state) { + return + } + + const items = state.items + if (!items.length) { + return + } + + const item = items[row] + + this.insertCompletion(item, 'mouseclick') + } + + private onContextMenu = (event: React.MouseEvent) => { + if (this.props.onContextMenu) { + this.props.onContextMenu(event) + } else { + event.preventDefault() + showContextualMenu([{ role: 'editMenu' }]) + } + } + + 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, + onKeyDown: this.onKeyDown, + onFocus: this.onFocus, + onBlur: this.onBlur, + onContextMenu: this.onContextMenu, + disabled: this.props.disabled, + 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>( + this.getElementTagName(), + props + ) + } + + 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) + } + } + + public focus() { + if (this.element) { + this.element.focus() + } + } + + public render() { + 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 autoCompleteItems = this.state.autocompletionState?.items ?? [] + + const suggestionsMessage = + autoCompleteItems.length === 1 + ? '1 suggestion' + : `${autoCompleteItems.length} suggestions` + + return ( +
+ {this.renderAutocompletions()} + {this.props.screenReaderLabel && ( + + )} + {this.renderTextInput()} + {this.renderInvisibleCaret()} + 0 ? suggestionsMessage : null} + trackedUserInput={this.state.autocompletionState?.rangeText} + /> +
+ ) + } + + private setCursorPosition(newCaretPosition: number) { + if (this.element == null) { + log.warn('Unable to set cursor position when element is null') + return + } + + this.element.selectionStart = newCaretPosition + this.element.selectionEnd = newCaretPosition + } + + private insertCompletion( + item: AutocompleteItemType, + source: 'mouseclick' | 'keyboard' + ) { + const element = this.element! + const autocompletionState = this.state.autocompletionState! + const originalText = element.value + const range = autocompletionState.range + const autoCompleteText = + autocompletionState.provider.getCompletionText(item) + + const textWithAutoCompleteText = + originalText.substr(0, range.start - 1) + autoCompleteText + ' ' + + const newText = + textWithAutoCompleteText + + originalText.substring(range.start + range.length) + + element.value = newText + + if (this.props.onValueChanged) { + this.props.onValueChanged(newText) + } + + const newCaretPosition = textWithAutoCompleteText.length + + if (source === 'mouseclick') { + // we only need to re-focus on the text input when the autocomplete overlay + // steals focus due to the user clicking on a selection in the autocomplete list + window.setTimeout(() => { + element.focus() + this.setCursorPosition(newCaretPosition) + }, 0) + } else { + this.setCursorPosition(newCaretPosition) + } + + this.props.onAutocompleteItemSelected?.(item) + + this.close() + if (this.props.alwaysAutocomplete) { + this.open('') + } + } + + private getMovementDirection( + event: React.KeyboardEvent + ): SelectionDirection | null { + switch (event.key) { + case 'ArrowUp': + return 'up' + case 'ArrowDown': + return 'down' + } + + return null + } + + private onKeyDown = (event: React.KeyboardEvent) => { + if (this.props.onKeyDown) { + this.props.onKeyDown(event) + } + + if (event.defaultPrevented) { + return + } + + const currentAutoCompletionState = this.state.autocompletionState + + if ( + !currentAutoCompletionState || + !currentAutoCompletionState.items.length + ) { + return + } + + const selectedRow = currentAutoCompletionState.selectedItem + ? currentAutoCompletionState.items.indexOf( + currentAutoCompletionState.selectedItem + ) + : -1 + + const direction = this.getMovementDirection(event) + if (direction) { + event.preventDefault() + const rowCount = currentAutoCompletionState.items.length + + const nextRow = findNextSelectableRow(rowCount, { + direction, + row: selectedRow, + }) + + if (nextRow !== null) { + const newSelectedItem = currentAutoCompletionState.items[nextRow] + + const newAutoCompletionState = { + ...currentAutoCompletionState, + selectedItem: newSelectedItem, + selectedRowId: + newSelectedItem === null ? undefined : this.getRowId(nextRow), + } + + this.setState({ autocompletionState: newAutoCompletionState }) + } + } else if ( + event.key === 'Enter' || + (event.key === 'Tab' && !event.shiftKey) + ) { + const item = currentAutoCompletionState.selectedItem + if (item) { + event.preventDefault() + + this.insertCompletion(item, 'keyboard') + } + } else if (event.key === 'Escape') { + this.close() + } + } + + private close() { + this.setState({ autocompletionState: null }) + } + + private async attemptAutocompletion( + str: string, + caretPosition: number + ): 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. + const regex = new RegExp(provider.getRegExp()) + if (!regex.global) { + fatalError( + `The regex (${regex}) returned from ${provider} isn't global, but it should be!` + ) + } + + let result: RegExpExecArray | null = null + while ((result = regex.exec(lowercaseStr))) { + const index = regex.lastIndex + const text = result[1] || '' + if (index === caretPosition || this.props.alwaysAutocomplete) { + const range = { start: index - text.length, length: text.length } + 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(), + } + } + } + } + + return null + } + + private buildAutocompleteListRowIdPrefix() { + return new Date().getTime().toString() + } + + private onChange = async (event: React.FormEvent) => { + const str = event.currentTarget.value + + if (this.props.onValueChanged) { + this.props.onValueChanged(str) + } + + this.updateCaretCoordinates() + + return this.open(str) + } + + private async open(str: string) { + const element = this.element + + if (element === null) { + return + } + + const caretPosition = element.selectionStart + + if (caretPosition === null) { + return + } + + const requestID = ++this.autocompletionRequestID + const autocompletionState = await this.attemptAutocompletion( + str, + caretPosition + ) + + // If another autocompletion request is in flight, then ignore these + // results. + if (requestID !== this.autocompletionRequestID) { + return + } + + this.setState({ autocompletionState }) + } +} diff --git a/app/src/ui/autocompletion/autocompletion-provider.ts b/app/src/ui/autocompletion/autocompletion-provider.ts new file mode 100644 index 0000000000..535e0677ac --- /dev/null +++ b/app/src/ui/autocompletion/autocompletion-provider.ts @@ -0,0 +1,53 @@ +import { AutocompletingTextInput } from './autocompleting-text-input' + +export class AutocompletingTextArea< + AutocompleteItemType = Object +> extends AutocompletingTextInput { + protected getElementTagName(): 'textarea' | 'input' { + return 'textarea' + } +} +export class AutocompletingInput< + AutocompleteItemType = Object +> extends AutocompletingTextInput { + protected getElementTagName(): 'textarea' | 'input' { + return 'input' + } +} + +/** An interface which defines the protocol for an autocompletion provider. */ +export interface IAutocompletionProvider { + /** + * The type of auto completion provided this instance implements. Used + * for variable width auto completion popups depending on type. + */ + kind: 'emoji' | 'user' | 'issue' + + /** + * Get the regex which it used to capture text for the provider. The text + * captured in the first group will then be passed to `getAutocompletionItems` + * to get autocompletions. + * + * The returned regex *must* be global. + */ + getRegExp(): RegExp + + /** + * Get the autocompletion results for the given text. The text is whatever was + * captured in the first group by the regex returned from `getRegExp`. + */ + getAutocompletionItems(text: string): Promise> + + /** + * Render the autocompletion item. The item will be one which the provider + * returned from `getAutocompletionItems`. + */ + renderItem(item: T): JSX.Element + + /** + * Returns a text representation of a given autocompletion results. + * This is the text that will end up going into the textbox if the + * user chooses to autocomplete a particular item. + */ + getCompletionText(item: T): string +} diff --git a/app/src/ui/autocompletion/build-autocompletion-providers.ts b/app/src/ui/autocompletion/build-autocompletion-providers.ts new file mode 100644 index 0000000000..8bc938d2c2 --- /dev/null +++ b/app/src/ui/autocompletion/build-autocompletion-providers.ts @@ -0,0 +1,60 @@ +import { + getNonForkGitHubRepository, + isRepositoryWithGitHubRepository, + Repository, +} from '../../models/repository' +import { + CoAuthorAutocompletionProvider, + EmojiAutocompletionProvider, + IAutocompletionProvider, + IssuesAutocompletionProvider, + UserAutocompletionProvider, +} from '.' +import { Dispatcher } from '../dispatcher' +import { GitHubUserStore, IssuesStore } from '../../lib/stores' +import { Account } from '../../models/account' + +export function buildAutocompletionProviders( + repository: Repository, + dispatcher: Dispatcher, + emoji: Map, + issuesStore: IssuesStore, + gitHubUserStore: GitHubUserStore, + accounts: ReadonlyArray +): IAutocompletionProvider[] { + const autocompletionProviders: IAutocompletionProvider[] = [ + new EmojiAutocompletionProvider(emoji), + ] + + // Issues autocompletion is only available for GitHub repositories. + const gitHubRepository = isRepositoryWithGitHubRepository(repository) + ? getNonForkGitHubRepository(repository) + : null + + if (gitHubRepository !== null) { + autocompletionProviders.push( + new IssuesAutocompletionProvider( + issuesStore, + gitHubRepository, + dispatcher + ) + ) + + const account = accounts.find(a => a.endpoint === gitHubRepository.endpoint) + + autocompletionProviders.push( + new UserAutocompletionProvider( + gitHubUserStore, + gitHubRepository, + account + ), + new CoAuthorAutocompletionProvider( + gitHubUserStore, + gitHubRepository, + account + ) + ) + } + + return autocompletionProviders +} diff --git a/app/src/ui/autocompletion/common.ts b/app/src/ui/autocompletion/common.ts new file mode 100644 index 0000000000..6b63593940 --- /dev/null +++ b/app/src/ui/autocompletion/common.ts @@ -0,0 +1,5 @@ +/** + * The default maximum number of hits to return from + * either of the autocompletion providers. + */ +export const DefaultMaxHits = 25 diff --git a/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx b/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx new file mode 100644 index 0000000000..aa68c87319 --- /dev/null +++ b/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx @@ -0,0 +1,122 @@ +import * as React from 'react' +import { IAutocompletionProvider } from './index' +import { compare } from '../../lib/compare' +import { DefaultMaxHits } from './common' + +/** + * Interface describing a autocomplete match for the given search + * input passed to EmojiAutocompletionProvider#getAutocompletionItems. + */ +export interface IEmojiHit { + /** A human-readable markdown representation of the emoji, ex :heart: */ + readonly emoji: string + + /** + * The offset into the emoji string where the + * match started, used for highlighting matches. + */ + readonly matchStart: number + + /** + * The length of the match or zero if the filter + * string was empty, causing the provider to return + * all possible matches. + */ + readonly matchLength: number +} + +/** Autocompletion provider for emoji. */ +export class EmojiAutocompletionProvider + implements IAutocompletionProvider +{ + public readonly kind = 'emoji' + + private readonly emoji: Map + + public constructor(emoji: Map) { + this.emoji = emoji + } + + public getRegExp(): RegExp { + return /(?:^|\n| )(?::)([a-z\d\\+-][a-z\d_]*)?/g + } + + public async getAutocompletionItems( + text: string, + maxHits = DefaultMaxHits + ): Promise> { + // This is the happy path to avoid sorting and matching + // 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 })) + .slice(0, maxHits) + } + + const results = new Array() + const needle = text.toLowerCase() + + for (const emoji of this.emoji.keys()) { + const index = emoji.indexOf(needle) + if (index !== -1) { + results.push({ emoji, matchStart: index, matchLength: needle.length }) + } + } + + // Naive emoji result sorting + // + // Matches closer to the start of the string are sorted + // before matches further into the string + // + // Longer matches relative to the emoji length is sorted + // before the same match in a longer emoji + // (:heart over :heart_eyes) + // + // If both those start and length are equal we sort + // alphabetically + return results + .sort( + (x, y) => + compare(x.matchStart, y.matchStart) || + compare(x.emoji.length, y.emoji.length) || + compare(x.emoji, y.emoji) + ) + .slice(0, maxHits) + } + + public renderItem(hit: IEmojiHit) { + const emoji = hit.emoji + + return ( +
+ {emoji} + {this.renderHighlightedTitle(hit)} +
+ ) + } + + private renderHighlightedTitle(hit: IEmojiHit) { + const emoji = hit.emoji.replaceAll(':', '') + + 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, matchStart)} + {emoji.substring(matchStart, matchStart + hit.matchLength)} + {emoji.substring(matchStart + hit.matchLength)} +
+ ) + } + + public getCompletionText(item: IEmojiHit) { + return item.emoji + } +} diff --git a/app/src/ui/autocompletion/index.ts b/app/src/ui/autocompletion/index.ts new file mode 100644 index 0000000000..2744c1fa30 --- /dev/null +++ b/app/src/ui/autocompletion/index.ts @@ -0,0 +1,5 @@ +export * from './autocompletion-provider' +export * from './emoji-autocompletion-provider' +export * from './issues-autocompletion-provider' +export * from './user-autocompletion-provider' +export * from './build-autocompletion-providers' diff --git a/app/src/ui/autocompletion/issues-autocompletion-provider.tsx b/app/src/ui/autocompletion/issues-autocompletion-provider.tsx new file mode 100644 index 0000000000..1abeea1e92 --- /dev/null +++ b/app/src/ui/autocompletion/issues-autocompletion-provider.tsx @@ -0,0 +1,65 @@ +import * as React from 'react' +import { IAutocompletionProvider } from './index' +import { IssuesStore, IIssueHit } from '../../lib/stores/issues-store' +import { Dispatcher } from '../dispatcher' +import { GitHubRepository } from '../../models/github-repository' +import { ThrottledScheduler } from '../lib/throttled-scheduler' + +/** The interval we should use to throttle the issues update. */ +const UpdateIssuesThrottleInterval = 1000 * 60 + +/** The autocompletion provider for issues in a GitHub repository. */ +export class IssuesAutocompletionProvider + implements IAutocompletionProvider +{ + public readonly kind = 'issue' + + private readonly issuesStore: IssuesStore + private readonly repository: GitHubRepository + private readonly dispatcher: Dispatcher + + /** + * The scheduler used to throttle calls to update the issues for + * autocompletion. + */ + private readonly updateIssuesScheduler = new ThrottledScheduler( + UpdateIssuesThrottleInterval + ) + + public constructor( + issuesStore: IssuesStore, + repository: GitHubRepository, + dispatcher: Dispatcher + ) { + this.issuesStore = issuesStore + this.repository = repository + this.dispatcher = dispatcher + } + + public getRegExp(): RegExp { + return /(?:^|\n| )(?:#)([a-z\d\\+-][a-z\d_]*)?/g + } + + public getAutocompletionItems( + text: string + ): Promise> { + this.updateIssuesScheduler.queue(() => { + this.dispatcher.refreshIssues(this.repository) + }) + + return this.issuesStore.getIssuesMatching(this.repository, text) + } + + public renderItem(item: IIssueHit): JSX.Element { + return ( +
+ #{item.number}  + {item.title} +
+ ) + } + + public getCompletionText(item: IIssueHit): string { + return `#${item.number}` + } +} diff --git a/app/src/ui/autocompletion/user-autocompletion-provider.tsx b/app/src/ui/autocompletion/user-autocompletion-provider.tsx new file mode 100644 index 0000000000..c604eead17 --- /dev/null +++ b/app/src/ui/autocompletion/user-autocompletion-provider.tsx @@ -0,0 +1,170 @@ +import * as React from 'react' + +import { IAutocompletionProvider } from './index' +import { GitHubUserStore } from '../../lib/stores' +import { GitHubRepository } from '../../models/github-repository' +import { Account } from '../../models/account' +import { IMentionableUser } from '../../lib/databases/index' + +/** An autocompletion hit for a user. */ +export type KnownUserHit = { + readonly kind: 'known-user' + + /** The username. */ + readonly username: string + + /** + * The user's name or null if the user + * hasn't entered a name in their profile + */ + readonly name: string | null + + /** + * The user's public email address. If the user + * hasn't selected a public email address this + * field will be an empty string. + */ + readonly email: string + + 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 +): UserHit { + return { + kind: 'known-user', + username: user.login, + name: user.name, + email: user.email, + endpoint: repository.endpoint, + } +} + +/** The autocompletion provider for user mentions in a GitHub repository. */ +export class UserAutocompletionProvider + implements IAutocompletionProvider +{ + public readonly kind = 'user' + + private readonly gitHubUserStore: GitHubUserStore + private readonly repository: GitHubRepository + private readonly account: Account | null + + public constructor( + gitHubUserStore: GitHubUserStore, + repository: GitHubRepository, + account?: Account + ) { + this.gitHubUserStore = gitHubUserStore + this.repository = repository + this.account = account || null + } + + public getRegExp(): RegExp { + return /(?:^|\n| )(?:@)([a-z\d\\+-][a-z\d_-]*)?/g + } + + protected async getUserAutocompletionItems( + text: string, + includeUnknownUser: boolean + ): Promise> { + const users = await this.gitHubUserStore.getMentionableUsersMatching( + this.repository, + text + ) + + // dotcom doesn't let you autocomplete on your own handle + const account = this.account + const filtered = account + ? users.filter(x => x.login !== account.login) + : users + + 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 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: UserHit): string { + return `@${item.username}` + } + + /** + * Retrieve a user based on the user login name, i.e their handle. + * + * If the user is already cached no additional API requests + * will be made. If the user isn't in the cache but found in + * the API it will be persisted to the database and the + * intermediate cache. + * + * @param login The login (i.e. handle) of the user + */ + public async exactMatch(login: string): Promise { + if (this.account === null) { + return null + } + + const user = await this.gitHubUserStore.getByLogin(this.account, login) + + if (!user) { + return null + } + + 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/banner.tsx b/app/src/ui/banners/banner.tsx new file mode 100644 index 0000000000..e920c7f617 --- /dev/null +++ b/app/src/ui/banners/banner.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' + +interface IBannerProps { + readonly id?: string + readonly timeout?: number + readonly dismissable?: boolean + readonly onDismissed: () => void +} + +export class Banner extends React.Component { + private timeoutId: number | null = null + + public render() { + return ( +
+
{this.props.children}
+ {this.renderCloseButton()} +
+ ) + } + + private renderCloseButton() { + const { dismissable } = this.props + if (dismissable === undefined || dismissable === false) { + return null + } + + return ( +
+ +
+ ) + } + + public componentDidMount = () => { + if (this.props.timeout !== undefined) { + this.timeoutId = window.setTimeout(() => { + this.props.onDismissed() + }, this.props.timeout) + } + } + + public componentWillUnmount = () => { + if (this.props.timeout !== undefined && this.timeoutId !== null) { + window.clearTimeout(this.timeoutId) + } + } +} 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 new file mode 100644 index 0000000000..76efa4d093 --- /dev/null +++ b/app/src/ui/banners/branch-already-up-to-date-banner.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Banner } from './banner' + +export function BranchAlreadyUpToDate({ + ourBranch, + theirBranch, + onDismissed, +}: { + readonly ourBranch: string + readonly theirBranch?: string + readonly onDismissed: () => void +}) { + const message = + theirBranch !== undefined ? ( + + {ourBranch} + {' is already up to date with '} + {theirBranch} + + ) : ( + + {ourBranch} + {' is already up to date'} + + ) + + return ( + +
+ +
+
{message}
+
+ ) +} diff --git a/app/src/ui/banners/cherry-pick-conflicts-banner.tsx b/app/src/ui/banners/cherry-pick-conflicts-banner.tsx new file mode 100644 index 0000000000..3b85f81489 --- /dev/null +++ b/app/src/ui/banners/cherry-pick-conflicts-banner.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Banner } from './banner' +import { LinkButton } from '../lib/link-button' + +interface ICherryPickConflictsBannerProps { + /** branch the user is rebasing into */ + readonly targetBranchName: string + /** callback to fire when the dialog should be reopened */ + readonly onOpenConflictsDialog: () => void + /** callback to fire to dismiss the banner */ + readonly onDismissed: () => void +} + +export class CherryPickConflictsBanner extends React.Component< + ICherryPickConflictsBannerProps, + {} +> { + private openDialog = async () => { + this.props.onDismissed() + this.props.onOpenConflictsDialog() + } + + private onDismissed = () => { + log.warn( + `[CherryPickConflictsBanner] this is not dismissable by default unless the user clicks on the link` + ) + } + + public render() { + return ( + + +
+ + Resolve conflicts to continue cherry-picking onto{' '} + {this.props.targetBranchName}. + + View conflicts +
+
+ ) + } +} diff --git a/app/src/ui/banners/cherry-pick-undone.tsx b/app/src/ui/banners/cherry-pick-undone.tsx new file mode 100644 index 0000000000..59fc6f4c70 --- /dev/null +++ b/app/src/ui/banners/cherry-pick-undone.tsx @@ -0,0 +1,25 @@ +import * as React from 'react' +import { SuccessBanner } from './success-banner' + +interface ICherryPickUndoneBannerProps { + readonly targetBranchName: string + readonly countCherryPicked: number + readonly onDismissed: () => void +} + +export class CherryPickUndone extends React.Component< + ICherryPickUndoneBannerProps, + {} +> { + public render() { + const { countCherryPicked, targetBranchName, onDismissed } = this.props + const pluralized = countCherryPicked === 1 ? 'commit' : 'commits' + return ( + + Cherry-pick undone. Successfully removed the {countCherryPicked} + {' copied '} + {pluralized} from {targetBranchName}. + + ) + } +} diff --git a/app/src/ui/banners/conflicts-found-banner.tsx b/app/src/ui/banners/conflicts-found-banner.tsx new file mode 100644 index 0000000000..c6ed8044b9 --- /dev/null +++ b/app/src/ui/banners/conflicts-found-banner.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Banner } from './banner' +import { LinkButton } from '../lib/link-button' + +interface IConflictsFoundBannerProps { + /** + * Description of the operation to continue + * Examples: + * - rebasing target-branch-name + * - cherry-picking onto target-branch-name + * - squashing commits on target-branch-name + */ + readonly operationDescription: string | JSX.Element + /** Callback to fire when the dialog should be reopened */ + readonly onOpenConflictsDialog: () => void + /** Callback to fire to dismiss the banner */ + readonly onDismissed: () => void +} + +export class ConflictsFoundBanner extends React.Component< + IConflictsFoundBannerProps, + {} +> { + private openDialog = async () => { + this.props.onDismissed() + this.props.onOpenConflictsDialog() + } + + private onDismissed = () => { + log.warn( + `[ConflictsBanner] This cannot be dismissed by default unless the user clicks on the link` + ) + } + + public render() { + return ( + + +
+ + Resolve conflicts to continue {this.props.operationDescription}. + + View conflicts +
+
+ ) + } +} diff --git a/app/src/ui/banners/index.ts b/app/src/ui/banners/index.ts new file mode 100644 index 0000000000..c2ab88f77e --- /dev/null +++ b/app/src/ui/banners/index.ts @@ -0,0 +1,3 @@ +export { Banner } from './banner' +export { UpdateAvailable } from './update-available' +export { renderBanner } from './render-banner' diff --git a/app/src/ui/banners/merge-conflicts-banner.tsx b/app/src/ui/banners/merge-conflicts-banner.tsx new file mode 100644 index 0000000000..e30358d9c8 --- /dev/null +++ b/app/src/ui/banners/merge-conflicts-banner.tsx @@ -0,0 +1,45 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Banner } from './banner' +import { Dispatcher } from '../dispatcher' +import { Popup } from '../../models/popup' +import { LinkButton } from '../lib/link-button' + +interface IMergeConflictsBannerProps { + readonly dispatcher: Dispatcher + /** branch the user is merging into */ + readonly ourBranch: string + /** merge conflicts dialog popup to be shown by this banner */ + readonly popup: Popup + readonly onDismissed: () => void +} + +export class MergeConflictsBanner extends React.Component< + IMergeConflictsBannerProps, + {} +> { + private openDialog = () => { + this.props.onDismissed() + this.props.dispatcher.showPopup(this.props.popup) + this.props.dispatcher.recordMergeConflictsDialogReopened() + } + public render() { + return ( + + +
+ + Resolve conflicts and commit to merge into{' '} + {this.props.ourBranch}. + + View conflicts +
+
+ ) + } +} diff --git a/app/src/ui/banners/open-thank-you-card.tsx b/app/src/ui/banners/open-thank-you-card.tsx new file mode 100644 index 0000000000..4d81f239fd --- /dev/null +++ b/app/src/ui/banners/open-thank-you-card.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import { LinkButton } from '../lib/link-button' +import { RichText } from '../lib/rich-text' +import { Banner } from './banner' + +interface IOpenThankYouCardProps { + readonly emoji: Map + readonly onDismissed: () => void + readonly onOpenCard: () => void + readonly onThrowCardAway: () => void +} + +/** + * A component which tells the user that there is a thank you card for them. + */ +export class OpenThankYouCard extends React.Component< + IOpenThankYouCardProps, + {} +> { + public render() { + return ( + + + The Desktop team would like to thank you for your contributions.{' '} + + Open Your Card + {' '} + + or{' '} + Throw It Away{' '} + + + + ) + } + + private onThrowCardAway = () => { + this.props.onDismissed() + this.props.onThrowCardAway() + } +} diff --git a/app/src/ui/banners/rebase-conflicts-banner.tsx b/app/src/ui/banners/rebase-conflicts-banner.tsx new file mode 100644 index 0000000000..e177ef7f2a --- /dev/null +++ b/app/src/ui/banners/rebase-conflicts-banner.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Banner } from './banner' +import { Dispatcher } from '../dispatcher' +import { LinkButton } from '../lib/link-button' + +interface IRebaseConflictsBannerProps { + readonly dispatcher: Dispatcher + /** branch the user is rebasing into */ + readonly targetBranch: string + /** callback to fire when the dialog should be reopened */ + readonly onOpenDialog: () => void + /** callback to fire to dismiss the banner */ + readonly onDismissed: () => void +} + +export class RebaseConflictsBanner extends React.Component< + IRebaseConflictsBannerProps, + {} +> { + private openDialog = async () => { + this.props.onDismissed() + this.props.onOpenDialog() + this.props.dispatcher.recordRebaseConflictsDialogReopened() + } + + private onDismissed = () => { + log.warn( + `[RebaseConflictsBanner] this is not dismissable by default unless the user clicks on the link` + ) + } + + public render() { + return ( + + +
+ + Resolve conflicts to continue rebasing{' '} + {this.props.targetBranch}. + + View conflicts +
+
+ ) + } +} diff --git a/app/src/ui/banners/render-banner.tsx b/app/src/ui/banners/render-banner.tsx new file mode 100644 index 0000000000..dd07fa7b38 --- /dev/null +++ b/app/src/ui/banners/render-banner.tsx @@ -0,0 +1,168 @@ +import * as React from 'react' + +import { assertNever } from '../../lib/fatal-error' + +import { Banner, BannerType } from '../../models/banner' + +import { Dispatcher } from '../dispatcher' +import { MergeConflictsBanner } from './merge-conflicts-banner' + +import { SuccessfulMerge } from './successful-merge' +import { RebaseConflictsBanner } from './rebase-conflicts-banner' +import { SuccessfulRebase } from './successful-rebase' +import { BranchAlreadyUpToDate } from './branch-already-up-to-date-banner' +import { SuccessfulCherryPick } from './successful-cherry-pick' +import { CherryPickConflictsBanner } from './cherry-pick-conflicts-banner' +import { CherryPickUndone } from './cherry-pick-undone' +import { OpenThankYouCard } from './open-thank-you-card' +import { SuccessfulSquash } from './successful-squash' +import { SuccessBanner } from './success-banner' +import { ConflictsFoundBanner } from './conflicts-found-banner' +import { WindowsVersionNoLongerSupportedBanner } from './windows-version-no-longer-supported-banner' + +export function renderBanner( + banner: Banner, + dispatcher: Dispatcher, + onDismissed: () => void +): JSX.Element { + switch (banner.type) { + case BannerType.SuccessfulMerge: + return ( + + ) + case BannerType.MergeConflictsFound: + return ( + + ) + case BannerType.SuccessfulRebase: + return ( + + ) + case BannerType.RebaseConflictsFound: + return ( + + ) + case BannerType.BranchAlreadyUpToDate: + return ( + + ) + case BannerType.SuccessfulCherryPick: + return ( + + ) + case BannerType.CherryPickConflictsFound: + return ( + + ) + case BannerType.CherryPickUndone: + return ( + + ) + case BannerType.OpenThankYouCard: + return ( + + ) + case BannerType.SuccessfulSquash: + return ( + + ) + case BannerType.SquashUndone: { + const pluralized = banner.commitsCount === 1 ? 'commit' : 'commits' + return ( + + Squash of {banner.commitsCount} {pluralized} undone. + + ) + } + case BannerType.SuccessfulReorder: { + const pluralized = banner.count === 1 ? 'commit' : 'commits' + + return ( + + + Successfully reordered {banner.count} {pluralized}. + + + ) + } + case BannerType.ReorderUndone: { + const pluralized = banner.commitsCount === 1 ? 'commit' : 'commits' + return ( + + Reorder of {banner.commitsCount} {pluralized} undone. + + ) + } + case BannerType.ConflictsFound: + return ( + + ) + case BannerType.WindowsVersionNoLongerSupported: + 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 new file mode 100644 index 0000000000..45a5c095c9 --- /dev/null +++ b/app/src/ui/banners/success-banner.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import { LinkButton } from '../lib/link-button' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Banner } from './banner' + +interface ISuccessBannerProps { + readonly timeout: number + readonly onDismissed: () => void + readonly onUndo?: () => void +} + +export class SuccessBanner extends React.Component { + private undo = () => { + this.props.onDismissed() + + if (this.props.onUndo === undefined) { + return + } + + this.props.onUndo() + } + + private renderUndo = () => { + if (this.props.onUndo === undefined) { + return + } + return Undo + } + + public render() { + return ( + +
+ +
+
+ {this.props.children} + {this.renderUndo()} +
+
+ ) + } +} diff --git a/app/src/ui/banners/successful-cherry-pick.tsx b/app/src/ui/banners/successful-cherry-pick.tsx new file mode 100644 index 0000000000..2175b34481 --- /dev/null +++ b/app/src/ui/banners/successful-cherry-pick.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import { SuccessBanner } from './success-banner' + +interface ISuccessfulCherryPickBannerProps { + readonly targetBranchName: string + readonly countCherryPicked: number + readonly onDismissed: () => void + readonly onUndo: () => void +} + +export class SuccessfulCherryPick extends React.Component< + ISuccessfulCherryPickBannerProps, + {} +> { + public render() { + const { countCherryPicked, onDismissed, onUndo, targetBranchName } = + this.props + + const pluralized = countCherryPicked === 1 ? 'commit' : 'commits' + + return ( + + + Successfully copied {countCherryPicked} {pluralized} to{' '} + {targetBranchName}. + + + ) + } +} diff --git a/app/src/ui/banners/successful-merge.tsx b/app/src/ui/banners/successful-merge.tsx new file mode 100644 index 0000000000..89dd44d15e --- /dev/null +++ b/app/src/ui/banners/successful-merge.tsx @@ -0,0 +1,33 @@ +import * as React from 'react' +import { SuccessBanner } from './success-banner' + +export function SuccessfulMerge({ + ourBranch, + theirBranch, + onDismissed, +}: { + readonly ourBranch: string + readonly theirBranch?: string + readonly onDismissed: () => void +}) { + const message = + theirBranch !== undefined ? ( + + {'Successfully merged '} + {theirBranch} + {' into '} + {ourBranch} + + ) : ( + + {'Successfully merged into '} + {ourBranch} + + ) + + return ( + +
{message}
+
+ ) +} diff --git a/app/src/ui/banners/successful-rebase.tsx b/app/src/ui/banners/successful-rebase.tsx new file mode 100644 index 0000000000..6ca8eb2dba --- /dev/null +++ b/app/src/ui/banners/successful-rebase.tsx @@ -0,0 +1,33 @@ +import * as React from 'react' +import { SuccessBanner } from './success-banner' + +export function SuccessfulRebase({ + baseBranch, + targetBranch, + onDismissed, +}: { + readonly baseBranch?: string + readonly targetBranch: string + readonly onDismissed: () => void +}) { + const message = + baseBranch !== undefined ? ( + + {'Successfully rebased '} + {targetBranch} + {' onto '} + {baseBranch} + + ) : ( + + {'Successfully rebased '} + {targetBranch} + + ) + + return ( + +
{message}
+
+ ) +} diff --git a/app/src/ui/banners/successful-squash.tsx b/app/src/ui/banners/successful-squash.tsx new file mode 100644 index 0000000000..9d0bc19860 --- /dev/null +++ b/app/src/ui/banners/successful-squash.tsx @@ -0,0 +1,27 @@ +import * as React from 'react' +import { SuccessBanner } from './success-banner' + +interface ISuccessfulSquashedBannerProps { + readonly count: number + readonly onDismissed: () => void + readonly onUndo: () => void +} + +export class SuccessfulSquash extends React.Component< + ISuccessfulSquashedBannerProps, + {} +> { + public render() { + const { count, onDismissed, onUndo } = this.props + + const pluralized = count === 1 ? 'commit' : 'commits' + + return ( + + + Successfully squashed {count} {pluralized}. + + + ) + } +} diff --git a/app/src/ui/banners/update-available.tsx b/app/src/ui/banners/update-available.tsx new file mode 100644 index 0000000000..4266d07bbd --- /dev/null +++ b/app/src/ui/banners/update-available.tsx @@ -0,0 +1,129 @@ +import * as React from 'react' +import { Dispatcher } from '../dispatcher/index' +import { LinkButton } from '../lib/link-button' +import { lastShowCaseVersionSeen, updateStore } from '../lib/update-store' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { PopupType } from '../../models/popup' +import { shell } from '../../lib/app-shell' + +import { ReleaseSummary } from '../../models/release-notes' +import { Banner } from './banner' +import { ReleaseNotesUri } from '../lib/releases' +import { RichText } from '../lib/rich-text' + +interface IUpdateAvailableProps { + readonly dispatcher: Dispatcher + readonly newReleases: ReadonlyArray | null + readonly isX64ToARM64ImmediateAutoUpdate: boolean + readonly isUpdateShowcaseVisible: boolean + readonly emoji: Map + readonly onDismissed: () => void +} + +/** + * 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, + {} +> { + public render() { + return ( + + {!this.props.isUpdateShowcaseVisible && ( + + )} + + {this.renderMessage()} + + ) + } + + 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 + ? ` with GitHub Desktop ${this.props.newReleases[0].latestVersion}` + : '' + + return ( + + + Exciting new features have been added{version}. See{' '} + what's new or{' '} + + dismiss + + . + + ) + } + + return ( + + An updated version of GitHub Desktop is available and will be installed + at the next launch. See{' '} + what's new or{' '} + restart GitHub Desktop + . + + ) + } + + private dismissUpdateShowCaseVisibility = () => { + // Note: under that scenario that this is being dismissed due to clicking + // what's new on a pending release and for some reason we don't have the + // releases. We will end up showing the showcase banner after restart. This + // shouldn't happen but even if it did it would just be a minor annoyance as + // user would need to dismiss it again. + const versionSeen = + this.props.newReleases === null + ? __APP_VERSION__ + : this.props.newReleases[0].latestVersion + + localStorage.setItem(lastShowCaseVersionSeen, versionSeen) + this.props.dispatcher.setUpdateShowCaseVisibility(false) + } + + private showReleaseNotes = () => { + if (this.props.newReleases == null) { + // if, for some reason we're not able to render the release notes we + // should redirect the user to the website so we do _something_ + shell.openExternal(ReleaseNotesUri) + } else { + this.props.dispatcher.showPopup({ + type: PopupType.ReleaseNotes, + newReleases: this.props.newReleases, + }) + } + + this.dismissUpdateShowCaseVisibility() + } + + private updateNow = () => { + updateStore.quitAndInstallUpdate() + } +} diff --git a/app/src/ui/banners/windows-version-no-longer-supported-banner.tsx b/app/src/ui/banners/windows-version-no-longer-supported-banner.tsx new file mode 100644 index 0000000000..d224e642bc --- /dev/null +++ b/app/src/ui/banners/windows-version-no-longer-supported-banner.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as OcticonSymbol 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 WindowsVersionNoLongerSupportedBanner 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/branches/branch-list-item-context-menu.tsx b/app/src/ui/branches/branch-list-item-context-menu.tsx new file mode 100644 index 0000000000..88334e3044 --- /dev/null +++ b/app/src/ui/branches/branch-list-item-context-menu.tsx @@ -0,0 +1,40 @@ +import { IMenuItem } from '../../lib/menu-item' +import { clipboard } from 'electron' + +interface IBranchContextMenuConfig { + name: string + isLocal: boolean + onRenameBranch?: (branchName: string) => void + onDeleteBranch?: (branchName: string) => void +} + +export function generateBranchContextMenuItems( + config: IBranchContextMenuConfig +): IMenuItem[] { + const { name, isLocal, onRenameBranch, 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), + }) + + 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 new file mode 100644 index 0000000000..3ff4efc013 --- /dev/null +++ b/app/src/ui/branches/branch-list-item.tsx @@ -0,0 +1,127 @@ +import * as React from 'react' + +import { IMatches } from '../../lib/fuzzy-find' + +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { HighlightText } from '../lib/highlight-text' +import { dragAndDropManager } from '../../lib/drag-and-drop-manager' +import { DragType, DropTargetType } from '../../models/drag-drop' +import { TooltippedContent } from '../lib/tooltipped-content' +import { RelativeTime } from '../relative-time' +import classNames from 'classnames' + +interface IBranchListItemProps { + /** The name of the branch */ + readonly name: string + + /** 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 + + /** When a drag element has landed on a branch that is not current */ + readonly onDropOntoBranch?: (branchName: string) => void + + /** When a drag element has landed on the current branch */ + readonly onDropOntoCurrentBranch?: () => void +} + +interface IBranchListItemState { + /** + * Whether or not there's currently a draggable item being dragged + * on top of the branch item. We use this in order to disable pointer + * events when dragging. + */ + readonly isDragInProgress: boolean +} + +/** The branch component. */ +export class BranchListItem extends React.Component< + IBranchListItemProps, + IBranchListItemState +> { + public constructor(props: IBranchListItemProps) { + super(props) + this.state = { isDragInProgress: false } + } + + private onMouseEnter = () => { + if (dragAndDropManager.isDragInProgress) { + this.setState({ isDragInProgress: true }) + } + + if (dragAndDropManager.isDragOfTypeInProgress(DragType.Commit)) { + dragAndDropManager.emitEnterDropTarget({ + type: DropTargetType.Branch, + branchName: this.props.name, + }) + } + } + + private onMouseLeave = () => { + this.setState({ isDragInProgress: false }) + + if (dragAndDropManager.isDragOfTypeInProgress(DragType.Commit)) { + dragAndDropManager.emitLeaveDropTarget() + } + } + + private onMouseUp = () => { + const { onDropOntoBranch, onDropOntoCurrentBranch, name, isCurrentBranch } = + this.props + + this.setState({ isDragInProgress: false }) + + if (!dragAndDropManager.isDragOfTypeInProgress(DragType.Commit)) { + return + } + + if (onDropOntoBranch !== undefined && !isCurrentBranch) { + onDropOntoBranch(name) + } + + if (onDropOntoCurrentBranch !== undefined && isCurrentBranch) { + onDropOntoCurrentBranch() + } + } + + public render() { + const { lastCommitDate, isCurrentBranch, name } = this.props + const icon = isCurrentBranch ? OcticonSymbol.check : OcticonSymbol.gitBranch + const className = classNames('branches-list-item', { + 'drop-target': this.state.isDragInProgress, + }) + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ + + + + {lastCommitDate && ( + + )} +
+ ) + } +} diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx new file mode 100644 index 0000000000..fc0bb78899 --- /dev/null +++ b/app/src/ui/branches/branch-list.tsx @@ -0,0 +1,362 @@ +import * as React from 'react' + +import { Branch, BranchType } from '../../models/branch' + +import { assertNever } from '../../lib/fatal-error' + +import { + FilterList, + IFilterListGroup, + SelectionSource, +} from '../lib/filter-list' +import { IMatches } from '../../lib/fuzzy-find' +import { Button } from '../lib/button' +import { TextBox } from '../lib/text-box' + +import { + groupBranches, + IBranchListItem, + BranchGroupIdentifier, +} 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 { enableSectionList } from '../../lib/feature-flag' +import { SectionFilterList } from '../lib/section-filter-list' + +const RowHeight = 30 + +interface IBranchListProps { + /** + * See IBranchesState.defaultBranch + */ + readonly defaultBranch: Branch | null + + /** + * The currently checked out branch or null if HEAD is detached + */ + readonly currentBranch: Branch | null + + /** + * See IBranchesState.allBranches + */ + readonly allBranches: ReadonlyArray + + /** + * See IBranchesState.recentBranches + */ + readonly recentBranches: ReadonlyArray + + /** + * The currently selected branch in the list, see the onSelectionChanged prop. + */ + readonly selectedBranch: Branch | null + + /** + * 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 + + /** Called when an item is clicked. */ + readonly onItemClick?: (item: Branch, source: ClickSource) => void + + /** + * This function will be called when the selection changes as a result of a + * user keyboard or mouse action (i.e. not when props change). This function + * will not be invoked when an already selected row is clicked on. + * + * @param selectedItem - The Branch that was just selected + * @param source - The kind of user action that provoked the change, + * either a pointer device press, or a keyboard event + * (arrow up/down) + */ + readonly onSelectionChanged?: ( + selectedItem: Branch | null, + source: SelectionSource + ) => void + + /** The current filter text to render */ + readonly filterText: string + + /** Callback to fire when the filter text is changed */ + readonly onFilterTextChanged: (filterText: string) => void + + /** Can users create a new branch? */ + readonly canCreateNewBranch: boolean + + /** + * Called when the user wants to create a new branch. It will be given a name + * to prepopulate the new branch name field. + */ + readonly onCreateNewBranch?: (name: string) => void + + readonly textbox?: TextBox + + /** + * Render function to apply to each branch in the list + */ + readonly renderBranch: ( + item: IBranchListItem, + matches: IMatches + ) => JSX.Element + + /** + * Callback to fire when the items in the filter list are updated + */ + readonly onFilterListResultsChanged?: (resultCount: number) => void + + /** If true, we do not render the filter. */ + readonly hideFilterRow?: boolean + + /** Called to render content before/above the branches filter and list. */ + readonly renderPreList?: () => JSX.Element | null + + /** Optional: No branches message */ + readonly noBranchesMessage?: string | JSX.Element + + /** Optional: Callback for if rename context menu should exist */ + readonly onRenameBranch?: (branchName: string) => void + + /** Optional: Callback for if delete context menu should exist */ + readonly onDeleteBranch?: (branchName: string) => void +} + +interface IBranchListState { + /** + * The grouped list of branches. + * + * Groups are currently defined as 'default branch', 'current branch', + * 'recent branches' and all branches. + */ + readonly groups: ReadonlyArray> + + /** 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 + ) + + 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 + } + } + } + + return { groups, selectedItem } +} + +/** The Branches list component. */ +export class BranchList extends React.Component< + IBranchListProps, + IBranchListState +> { + private branchFilterList: + | FilterList + | SectionFilterList + | null = null + + public constructor(props: IBranchListProps) { + super(props) + this.state = createState(props) + } + + public componentWillReceiveProps(nextProps: IBranchListProps) { + this.setState(createState(nextProps)) + } + + public selectNextItem(focus: boolean = false, direction: SelectionDirection) { + if (this.branchFilterList !== null) { + this.branchFilterList.selectNextItem(focus, direction) + } + } + + public render() { + return enableSectionList() ? ( + + ref={this.onBranchesFilterListRef} + className="branches-list" + rowHeight={RowHeight} + filterText={this.props.filterText} + onFilterTextChanged={this.props.onFilterTextChanged} + onFilterKeyDown={this.props.onFilterKeyDown} + selectedItem={this.state.selectedItem} + renderItem={this.renderItem} + renderGroupHeader={this.renderGroupHeader} + onItemClick={this.onItemClick} + onSelectionChanged={this.onSelectionChanged} + onEnterPressedWithoutFilteredItems={this.onCreateNewBranch} + groups={this.state.groups} + invalidationProps={this.props.allBranches} + 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} + getGroupAriaLabel={this.getGroupAriaLabel} + /> + ) : ( + + ref={this.onBranchesFilterListRef} + className="branches-list" + rowHeight={RowHeight} + filterText={this.props.filterText} + onFilterTextChanged={this.props.onFilterTextChanged} + onFilterKeyDown={this.props.onFilterKeyDown} + selectedItem={this.state.selectedItem} + renderItem={this.renderItem} + renderGroupHeader={this.renderGroupHeader} + onItemClick={this.onItemClick} + onSelectionChanged={this.onSelectionChanged} + onEnterPressedWithoutFilteredItems={this.onCreateNewBranch} + groups={this.state.groups} + invalidationProps={this.props.allBranches} + 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} + /> + ) + } + + 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 + | SectionFilterList + | null + ) => { + this.branchFilterList = filterList + } + + private renderItem = (item: IBranchListItem, matches: IMatches) => { + return this.props.renderBranch(item, matches) + } + + private parseHeader(label: string): BranchGroupIdentifier | null { + switch (label) { + case 'default': + case 'recent': + case 'other': + return label + default: + return null + } + } + + private getGroupAriaLabel = (group: number) => { + const identifier = this.state.groups[group] + .identifier as BranchGroupIdentifier + return this.getGroupLabel(identifier) + } + + private renderGroupHeader = (label: string) => { + const identifier = this.parseHeader(label) + + return identifier !== null ? ( +
+ {this.getGroupLabel(identifier)} +
+ ) : null + } + + private getGroupLabel(identifier: BranchGroupIdentifier) { + if (identifier === 'default') { + return __DARWIN__ ? 'Default Branch' : 'Default branch' + } else if (identifier === 'recent') { + return __DARWIN__ ? 'Recent Branches' : 'Recent branches' + } else if (identifier === 'other') { + return __DARWIN__ ? 'Other Branches' : 'Other branches' + } else { + return assertNever(identifier, `Unknown identifier: ${identifier}`) + } + } + + private onRenderNoItems = () => { + return ( + + ) + } + + private onRenderNewButton = () => { + return this.props.canCreateNewBranch ? ( + + ) : null + } + + private onItemClick = (item: IBranchListItem, source: ClickSource) => { + if (this.props.onItemClick) { + this.props.onItemClick(item.branch, source) + } + } + + private onSelectionChanged = ( + selectedItem: IBranchListItem | null, + source: SelectionSource + ) => { + if (this.props.onSelectionChanged) { + this.props.onSelectionChanged( + selectedItem ? selectedItem.branch : null, + source + ) + } + } + + private onCreateNewBranch = () => { + if (this.props.onCreateNewBranch) { + this.props.onCreateNewBranch(this.props.filterText) + } + } +} diff --git a/app/src/ui/branches/branch-renderer.tsx b/app/src/ui/branches/branch-renderer.tsx new file mode 100644 index 0000000000..ae85d19661 --- /dev/null +++ b/app/src/ui/branches/branch-renderer.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' + +import { Branch } from '../../models/branch' + +import { IBranchListItem } from './group-branches' +import { BranchListItem } from './branch-list-item' +import { IMatches } from '../../lib/fuzzy-find' + +export function renderDefaultBranch( + item: IBranchListItem, + matches: IMatches, + currentBranch: Branch | null, + onDropOntoBranch?: (branchName: string) => void, + onDropOntoCurrentBranch?: () => void +): JSX.Element { + const branch = item.branch + const commit = branch.tip + const currentBranchName = currentBranch ? currentBranch.name : null + return ( + + ) +} diff --git a/app/src/ui/branches/branch-select.tsx b/app/src/ui/branches/branch-select.tsx new file mode 100644 index 0000000000..19fc160374 --- /dev/null +++ b/app/src/ui/branches/branch-select.tsx @@ -0,0 +1,113 @@ +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 { renderDefaultBranch } from './branch-renderer' +import { IBranchListItem } from './group-branches' + +interface IBranchSelectProps { + /** 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) => { + return renderDefaultBranch(item, matches, this.props.currentBranch) + } + + 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 new file mode 100644 index 0000000000..9e58dcb701 --- /dev/null +++ b/app/src/ui/branches/branches-container.tsx @@ -0,0 +1,468 @@ +import * as React from 'react' + +import { PullRequest } from '../../models/pull-request' +import { + Repository, + isRepositoryWithGitHubRepository, +} from '../../models/repository' +import { Branch } from '../../models/branch' +import { BranchesTab } from '../../models/branches-tab' +import { PopupType } from '../../models/popup' + +import { Dispatcher } from '../dispatcher' +import { FoldoutType } from '../../lib/app-state' +import { assertNever } from '../../lib/fatal-error' + +import { TabBar } from '../tab-bar' + +import { Row } from '../lib/row' +import { Octicon } from '../octicons' +import * as OcticonSymbol 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 { 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 { PullRequestQuickView } from '../pull-request-quick-view' + +interface IBranchesContainerProps { + readonly dispatcher: Dispatcher + readonly repository: Repository + readonly selectedTab: BranchesTab + readonly allBranches: ReadonlyArray + readonly defaultBranch: Branch | null + 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 + + /** Are we currently loading pull requests? */ + readonly isLoadingPullRequests: boolean + + /** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */ + readonly emoji: Map +} + +interface IBranchesContainerState { + /** + * A copy of the last seen currentPullRequest property + * from props. Used in order to be able to detect when + * the selected PR in props changes in getDerivedStateFromProps + */ + readonly currentPullRequest: PullRequest | null + readonly selectedPullRequest: PullRequest | null + readonly selectedBranch: Branch | null + readonly branchFilterText: string + readonly pullRequestBeingViewed: { + pr: PullRequest + prListItemTop: number + } | null +} + +/** The unified Branches and Pull Requests component. */ +export class BranchesContainer extends React.Component< + IBranchesContainerProps, + IBranchesContainerState +> { + public static getDerivedStateFromProps( + props: IBranchesContainerProps, + state: IBranchesContainerProps + ): Partial | null { + if (state.currentPullRequest !== props.currentPullRequest) { + return { + currentPullRequest: props.currentPullRequest, + selectedPullRequest: props.currentPullRequest, + } + } + + return null + } + + private pullRequestQuickViewTimerId: number | null = null + + public constructor(props: IBranchesContainerProps) { + super(props) + + this.state = { + selectedBranch: props.currentBranch, + selectedPullRequest: props.currentPullRequest, + currentPullRequest: props.currentPullRequest, + branchFilterText: '', + pullRequestBeingViewed: null, + } + } + + public componentWillUnmount = () => { + this.clearPullRequestQuickViewTimer() + } + + public render() { + return ( +
+ {this.renderTabBar()} + {this.renderSelectedTab()} + {this.renderMergeButtonRow()} + {this.renderPullRequestQuickView()} +
+ ) + } + + private renderPullRequestQuickView = (): JSX.Element | null => { + if ( + !enablePullRequestQuickView() || + this.state.pullRequestBeingViewed === null + ) { + return null + } + + const { pr, prListItemTop } = this.state.pullRequestBeingViewed + + return ( + + ) + } + + private onMouseEnterPullRequestQuickView = () => { + this.clearPullRequestQuickViewTimer() + } + + private onMouseLeavePullRequestQuickView = () => { + this.setState({ + pullRequestBeingViewed: null, + }) + this.clearPullRequestQuickViewTimer() + } + + private renderMergeButtonRow() { + const { currentBranch } = this.props + + // This could happen if HEAD is detached, in that + // case it's better to not render anything at all. + if (currentBranch === null) { + return null + } + + return ( + + + + ) + } + + private renderOpenPullRequestsBubble() { + const pullRequests = this.props.pullRequests + + if (pullRequests.length > 0) { + return {pullRequests.length} + } + + return null + } + + private renderTabBar() { + if (!this.props.repository.gitHubRepository) { + return null + } + + return ( + + Branches + + {__DARWIN__ ? 'Pull Requests' : 'Pull requests'} + {this.renderOpenPullRequestsBubble()} + + + ) + } + + private renderBranch = (item: IBranchListItem, matches: IMatches) => { + return renderDefaultBranch( + item, + matches, + this.props.currentBranch, + this.onDropOntoBranch, + this.onDropOntoCurrentBranch + ) + } + + 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 + } + + switch (tab) { + case BranchesTab.Branches: + return ( + + ) + case BranchesTab.PullRequests: { + return this.renderPullRequests() + } + default: + return assertNever(tab, `Unknown Branches tab: ${tab}`) + } + } + + private renderPreList = () => { + if (!dragAndDropManager.isDragOfTypeInProgress(DragType.Commit)) { + return null + } + + const label = __DARWIN__ ? 'New Branch' : 'New branch' + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ +
{label}
+
+ ) + } + + private onMouseUpNewBranchDrop = async () => { + const { dragData } = dragAndDropManager + if (dragData === null || dragData.type !== DragType.Commit) { + return + } + + const { dispatcher, repository, currentBranch } = this.props + + await dispatcher.setCherryPickCreateBranchFlowStep( + repository, + '', + dragData.commits, + currentBranch + ) + + this.props.dispatcher.showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + } + + private onMouseEnterNewBranchDrop = () => { + // This is just used for displaying on windows drag ghost. + // Thus, it doesn't have to be an actual branch name. + dragAndDropManager.emitEnterDropTarget({ + type: DropTargetType.Branch, + branchName: 'a new branch', + }) + } + + private onMouseLeaveNewBranchDrop = () => { + dragAndDropManager.emitLeaveDropTarget() + } + + private renderPullRequests() { + const repository = this.props.repository + if (!isRepositoryWithGitHubRepository(repository)) { + return null + } + + const isOnDefaultBranch = + this.props.defaultBranch && + this.props.currentBranch && + this.props.defaultBranch.name === this.props.currentBranch.name + + return ( + + ) + } + + private onMouseEnterPullRequestListItem = ( + pr: PullRequest, + prListItemTop: number + ) => { + this.clearPullRequestQuickViewTimer() + this.setState({ pullRequestBeingViewed: null }) + this.pullRequestQuickViewTimerId = window.setTimeout( + () => this.setState({ pullRequestBeingViewed: { pr, prListItemTop } }), + 250 + ) + } + + private onMouseLeavePullRequestListItem = async () => { + this.clearPullRequestQuickViewTimer() + this.pullRequestQuickViewTimerId = window.setTimeout( + () => this.setState({ pullRequestBeingViewed: null }), + 500 + ) + } + + private onTabClicked = (tab: BranchesTab) => { + 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) + } + + private onBranchItemClick = (branch: Branch) => { + const { repository, dispatcher } = this.props + dispatcher.closeFoldout(FoldoutType.Branch) + + const timer = startTimer('checkout branch from list', repository) + dispatcher.checkoutBranch(repository, branch).then(() => timer.done()) + } + + private onBranchSelectionChanged = (selectedBranch: Branch | null) => { + this.setState({ selectedBranch }) + } + + private onBranchFilterTextChanged = (text: string) => { + this.setState({ branchFilterText: text }) + } + + private onCreateBranchWithName = (name: string) => { + const { repository, dispatcher } = this.props + + dispatcher.closeFoldout(FoldoutType.Branch) + dispatcher.showPopup({ + type: PopupType.CreateBranch, + repository, + initialName: name, + }) + } + + private onCreateBranch = () => { + this.onCreateBranchWithName('') + } + + private onPullRequestSelectionChanged = ( + selectedPullRequest: PullRequest | null + ) => { + this.setState({ selectedPullRequest }) + } + + /** + * Method is to handle when something is dragged and dropped onto a branch + * in the branch dropdown. + * + * Currently this is being implemented with cherry picking. But, this could be + * expanded if we ever dropped something else on a branch; in which case, + * we would likely have to check the app state to see what action is being + * performed. As this branch container is not being used anywhere except + * for the branch dropdown, we are not going to pass the repository state down + * during this implementation. + */ + private onDropOntoBranch = (branchName: string) => { + const branch = this.props.allBranches.find(b => b.name === branchName) + if (branch === undefined) { + log.warn( + '[branches-container] - Branch name of branch dropped on does not exist.' + ) + return + } + + if (dragAndDropManager.isDragOfType(DragType.Commit)) { + this.props.dispatcher.startCherryPickWithBranch( + this.props.repository, + branch + ) + } + } + + private onDropOntoCurrentBranch = () => { + if (dragAndDropManager.isDragOfType(DragType.Commit)) { + this.props.dispatcher.recordDragStartedAndCanceled() + } + } + + private clearPullRequestQuickViewTimer = () => { + if (this.pullRequestQuickViewTimerId === null) { + return + } + + window.clearTimeout(this.pullRequestQuickViewTimerId) + this.pullRequestQuickViewTimerId = null + } +} diff --git a/app/src/ui/branches/ci-status.tsx b/app/src/ui/branches/ci-status.tsx new file mode 100644 index 0000000000..994062ce33 --- /dev/null +++ b/app/src/ui/branches/ci-status.tsx @@ -0,0 +1,209 @@ +import * as React from 'react' +import { Octicon, OcticonSymbolType } from '../octicons' +import * as OcticonSymbol 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 { IAPIWorkflowJobStep } from '../../lib/api' + +interface ICIStatusProps { + /** The classname for the underlying element. */ + readonly className?: string + + readonly dispatcher: Dispatcher + + /** The GitHub repository to use when looking up commit status. */ + readonly repository: GitHubRepository + + /** The commit ref (can be a SHA or a Git ref) for which to fetch status. */ + readonly commitRef: string + + /** A callback to bubble up whether there is a check displayed */ + readonly onCheckChange?: (check: ICombinedRefCheck | null) => void +} + +interface ICIStatusState { + readonly check: ICombinedRefCheck | null +} + +/** The little CI status indicator. */ +export class CIStatus extends React.PureComponent< + ICIStatusProps, + ICIStatusState +> { + private statusSubscription: DisposableLike | null = null + + public constructor(props: ICIStatusProps) { + super(props) + const check = props.dispatcher.tryGetCommitStatus( + this.props.repository, + this.props.commitRef + ) + this.state = { + check, + } + this.props.onCheckChange?.(check) + } + + private subscribe() { + this.unsubscribe() + + this.statusSubscription = this.props.dispatcher.subscribeToCommitStatus( + this.props.repository, + this.props.commitRef, + this.onStatus + ) + } + + private unsubscribe() { + if (this.statusSubscription) { + this.statusSubscription.dispose() + this.statusSubscription = null + } + } + + public componentDidUpdate(prevProps: ICIStatusProps) { + // Re-subscribe if we're being reused to show a different status. + if ( + this.props.repository !== prevProps.repository || + this.props.commitRef !== prevProps.commitRef + ) { + this.setState({ + check: this.props.dispatcher.tryGetCommitStatus( + this.props.repository, + this.props.commitRef + ), + }) + this.subscribe() + } + } + + public componentDidMount() { + this.subscribe() + } + + public componentWillUnmount() { + this.unsubscribe() + } + + private onStatus = (check: ICombinedRefCheck | null) => { + if (this.props.onCheckChange !== undefined) { + this.props.onCheckChange(check) + } + + this.setState({ check }) + } + + public render() { + const { check } = this.state + + if (check === null || check.checks.length === 0) { + return null + } + + return ( + + ) + } +} + +export function getSymbolForCheck( + check: ICombinedRefCheck | IRefCheck | IAPIWorkflowJobStep +): OcticonSymbolType { + switch (check.conclusion) { + case 'timed_out': + return OcticonSymbol.x + case 'failure': + return OcticonSymbol.x + case 'neutral': + return OcticonSymbol.squareFill + case 'success': + return OcticonSymbol.check + case 'cancelled': + return OcticonSymbol.stop + case 'action_required': + return OcticonSymbol.alert + case 'skipped': + return OcticonSymbol.skip + case 'stale': + return OcticonSymbol.issueReopened + } + + // Pending + return OcticonSymbol.dotFill +} + +export function getClassNameForCheck( + check: ICombinedRefCheck | IRefCheck | IAPIWorkflowJobStep +): string { + switch (check.conclusion) { + case 'timed_out': + return 'timed-out' + case 'action_required': + return 'action-required' + case 'failure': + case 'neutral': + case 'success': + case 'cancelled': + case 'skipped': + case 'stale': + return check.conclusion + } + + // Pending + return 'pending' +} + +export function getSymbolForLogStep( + logStep: IAPIWorkflowJobStep +): OcticonSymbolType { + switch (logStep.conclusion) { + case 'success': + return OcticonSymbol.checkCircleFill + case 'failure': + return OcticonSymbol.xCircleFill + } + + return getSymbolForCheck(logStep) +} + +export function getClassNameForLogStep(logStep: IAPIWorkflowJobStep): string { + switch (logStep.conclusion) { + case 'failure': + return logStep.conclusion + } + + // 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/group-branches.ts b/app/src/ui/branches/group-branches.ts new file mode 100644 index 0000000000..8ed2a80afb --- /dev/null +++ b/app/src/ui/branches/group-branches.ts @@ -0,0 +1,74 @@ +import { Branch } from '../../models/branch' +import { IFilterListGroup, IFilterListItem } from '../lib/filter-list' + +export type BranchGroupIdentifier = 'default' | 'recent' | 'other' + +export interface IBranchListItem extends IFilterListItem { + readonly text: ReadonlyArray + readonly id: string + readonly branch: Branch +} + +export function groupBranches( + defaultBranch: Branch | null, + currentBranch: Branch | null, + allBranches: ReadonlyArray, + recentBranches: ReadonlyArray +): ReadonlyArray> { + const groups = new Array>() + + if (defaultBranch) { + groups.push({ + identifier: 'default', + items: [ + { + text: [defaultBranch.name], + id: defaultBranch.name, + branch: defaultBranch, + }, + ], + }) + } + + const recentBranchNames = new Set() + const defaultBranchName = defaultBranch ? defaultBranch.name : null + const recentBranchesWithoutDefault = recentBranches.filter( + b => b.name !== defaultBranchName + ) + if (recentBranchesWithoutDefault.length > 0) { + const recentBranches = new Array() + + for (const branch of recentBranchesWithoutDefault) { + recentBranches.push({ + text: [branch.name], + id: branch.name, + branch, + }) + recentBranchNames.add(branch.name) + } + + groups.push({ + identifier: 'recent', + items: recentBranches, + }) + } + + const remainingBranches = allBranches.filter( + b => + b.name !== defaultBranchName && + !recentBranchNames.has(b.name) && + !b.isDesktopForkRemoteBranch + ) + + const remainingItems = remainingBranches.map(b => ({ + text: [b.name], + id: b.name, + branch: b, + })) + groups.push({ + identifier: 'other', + items: remainingItems, + }) + + return groups +} diff --git a/app/src/ui/branches/index.ts b/app/src/ui/branches/index.ts new file mode 100644 index 0000000000..bcb647c13a --- /dev/null +++ b/app/src/ui/branches/index.ts @@ -0,0 +1,7 @@ +export { PushBranchCommits } from './push-branch-commits' +export { BranchList } from './branch-list' +export { BranchesContainer } from './branches-container' +export { PullRequestBadge } from './pull-request-badge' +export { groupBranches, IBranchListItem } from './group-branches' +export { BranchListItem } from './branch-list-item' +export { renderDefaultBranch } from './branch-renderer' diff --git a/app/src/ui/branches/no-branches.tsx b/app/src/ui/branches/no-branches.tsx new file mode 100644 index 0000000000..3e5b99f3af --- /dev/null +++ b/app/src/ui/branches/no-branches.tsx @@ -0,0 +1,59 @@ +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, + 'static/empty-no-branches.svg' +) + +interface INoBranchesProps { + /** The callback to invoke when the user wishes to create a new branch */ + 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 { + public render() { + if (this.props.canCreateNewBranch) { + return ( +
+ + +
Sorry, I can't find that branch
+ +
+ Do you want to create a new branch instead? +
+ + + +
+ ProTip! Press{' '} + {' '} + to quickly create a new branch from anywhere within the app +
+
+ ) + } + + 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 new file mode 100644 index 0000000000..ab4d870d76 --- /dev/null +++ b/app/src/ui/branches/no-pull-requests.tsx @@ -0,0 +1,91 @@ +import * as React from 'react' +import { encodePathAsUrl } from '../../lib/path' +import { Ref } from '../lib/ref' +import { LinkButton } from '../lib/link-button' + +const BlankSlateImage = encodePathAsUrl( + __dirname, + 'static/empty-no-pull-requests.svg' +) + +interface INoPullRequestsProps { + /** The name of the repository. */ + readonly repositoryName: string + + /** Is the default branch currently checked out? */ + readonly isOnDefaultBranch: boolean + + /** Is this component being rendered due to a search? */ + readonly isSearch: boolean + + /* Called when the user wants to create a new branch. */ + readonly onCreateBranch: () => void + + /** Called when the user wants to create a pull request. */ + readonly onCreatePullRequest: () => void + + /** Are we currently loading pull requests? */ + readonly isLoadingPullRequests: boolean +} + +/** The placeholder for when there are no open pull requests. */ +export class NoPullRequests extends React.Component { + public render() { + return ( +
+ + {this.renderTitle()} + {this.renderCallToAction()} +
+ ) + } + + private renderTitle() { + if (this.props.isSearch) { + return
Sorry, I can't find that pull request!
+ } else if (this.props.isLoadingPullRequests) { + return
Hang tight
+ } else { + return ( +
+
You're all set!
+
+ No open pull requests in {this.props.repositoryName} +
+
+ ) + } + } + + private renderCallToAction() { + if (this.props.isLoadingPullRequests) { + return ( +
+ Loading pull requests as fast as I can! +
+ ) + } + + if (this.props.isOnDefaultBranch) { + return ( +
+ Would you like to{' '} + + create a new branch + {' '} + and get going on your next project? +
+ ) + } else { + return ( +
+ Would you like to{' '} + + create a pull request + {' '} + from the current branch? +
+ ) + } + } +} diff --git a/app/src/ui/branches/pull-request-badge.tsx b/app/src/ui/branches/pull-request-badge.tsx new file mode 100644 index 0000000000..b8a038b460 --- /dev/null +++ b/app/src/ui/branches/pull-request-badge.tsx @@ -0,0 +1,95 @@ +import * as React from 'react' +import { CIStatus } from './ci-status' +import { GitHubRepository } from '../../models/github-repository' +import { Dispatcher } from '../dispatcher' +import { ICombinedRefCheck } from '../../lib/ci-checks/ci-checks' +import { getPullRequestCommitRef } from '../../models/pull-request' + +interface IPullRequestBadgeProps { + /** The pull request's number. */ + readonly number: number + + readonly dispatcher: Dispatcher + + /** The GitHub repository to use when looking up commit status. */ + readonly repository: GitHubRepository + + readonly onBadgeRef?: (ref: HTMLDivElement | null) => void + + /** The GitHub repository to use when looking up commit status. */ + readonly onBadgeClick?: () => void + + /** When the bottom edge of the pull request badge position changes. For + * example, on a mac, this changes when the user maximizes Desktop. */ + readonly onBadgeBottomPositionUpdate?: (bottom: number) => void +} + +interface IPullRequestBadgeState { + /** Whether or not the CI status is showing a status */ + readonly isStatusShowing: boolean +} + +/** The pull request info badge. */ +export class PullRequestBadge extends React.Component< + IPullRequestBadgeProps, + IPullRequestBadgeState +> { + private badgeRef: HTMLDivElement | null = null + private badgeBoundingBottom: number = 0 + + public constructor(props: IPullRequestBadgeProps) { + super(props) + this.state = { + isStatusShowing: false, + } + } + + public componentDidUpdate() { + if (this.badgeRef === null) { + return + } + + if ( + this.badgeRef.getBoundingClientRect().bottom !== this.badgeBoundingBottom + ) { + this.badgeBoundingBottom = this.badgeRef.getBoundingClientRect().bottom + this.props.onBadgeBottomPositionUpdate?.(this.badgeBoundingBottom) + } + } + + private onRef = (badgeRef: HTMLDivElement) => { + this.badgeRef = badgeRef + this.props.onBadgeRef?.(badgeRef) + } + + private onBadgeClick = ( + event: React.MouseEvent + ) => { + if (!this.state.isStatusShowing) { + return + } + + event.stopPropagation() + this.props.onBadgeClick?.() + } + + private onCheckChange = (check: ICombinedRefCheck | null) => { + this.setState({ isStatusShowing: check !== null }) + } + + public render() { + const ref = getPullRequestCommitRef(this.props.number) + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ #{this.props.number} + +
+ ) + } +} diff --git a/app/src/ui/branches/pull-request-list-item.tsx b/app/src/ui/branches/pull-request-list-item.tsx new file mode 100644 index 0000000000..2644bc07e7 --- /dev/null +++ b/app/src/ui/branches/pull-request-list-item.tsx @@ -0,0 +1,171 @@ +import * as React from 'react' +import classNames from 'classnames' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { CIStatus } from './ci-status' +import { HighlightText } from '../lib/highlight-text' +import { IMatches } from '../../lib/fuzzy-find' +import { GitHubRepository } from '../../models/github-repository' +import { Dispatcher } from '../dispatcher' +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' + +export interface IPullRequestListItemProps { + /** The title. */ + readonly title: string + + /** The number as received from the API. */ + readonly number: number + + /** The date on which the PR was opened. */ + readonly created: Date + + /** The author login. */ + readonly author: string + + /** Whether or not the PR is in draft mode. */ + readonly draft: boolean + + /** + * Whether or not this list item is a skeleton item + * put in place while the pull request information is + * being loaded. This adds a special 'loading' class + * to the container and prevents any text from rendering + * inside the list item. + */ + readonly loading?: boolean + + /** The characters in the PR title to highlight */ + readonly matches: IMatches + + readonly dispatcher: Dispatcher + + /** The GitHub repository to use when looking up commit status. */ + readonly repository: GitHubRepository + + /** When a drag element has landed on a pull request */ + readonly onDropOntoPullRequest: (prNumber: number) => void + + /** When mouse enters a PR */ + readonly onMouseEnter: (prNumber: number, prListItemTop: number) => void + + /** When mouse leaves a PR */ + readonly onMouseLeave: ( + event: React.MouseEvent + ) => void +} + +interface IPullRequestListItemState { + readonly isDragInProgress: boolean +} + +/** Pull requests as rendered in the Pull Requests list. */ +export class PullRequestListItem extends React.Component< + IPullRequestListItemProps, + IPullRequestListItemState +> { + public constructor(props: IPullRequestListItemProps) { + super(props) + this.state = { isDragInProgress: false } + } + + private getSubtitle() { + if (this.props.loading === true) { + return undefined + } + + const timeAgo = formatRelative(this.props.created.getTime() - Date.now()) + const subtitle = `#${this.props.number} opened ${timeAgo} by ${this.props.author}` + + return this.props.draft ? `${subtitle} • Draft` : subtitle + } + + private onMouseEnter = (e: React.MouseEvent) => { + if (dragAndDropManager.isDragInProgress) { + this.setState({ isDragInProgress: true }) + + dragAndDropManager.emitEnterDropTarget({ + type: DropTargetType.Branch, + branchName: this.props.title, + }) + } + const { top } = e.currentTarget.getBoundingClientRect() + this.props.onMouseEnter(this.props.number, top) + } + + private onMouseLeave = ( + event: React.MouseEvent + ) => { + if (dragAndDropManager.isDragInProgress) { + this.setState({ isDragInProgress: false }) + + dragAndDropManager.emitLeaveDropTarget() + } + this.props.onMouseLeave(event) + } + + private onMouseUp = () => { + if (dragAndDropManager.isDragInProgress) { + this.setState({ isDragInProgress: false }) + + this.props.onDropOntoPullRequest(this.props.number) + } + } + + public render() { + const title = this.props.loading === true ? undefined : this.props.title + const subtitle = this.getSubtitle() + const matches = this.props.matches + const className = classNames('pull-request-item', { + loading: this.props.loading === true, + open: !this.props.draft, + draft: this.props.draft, + 'drop-target': this.state.isDragInProgress, + }) + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+
+ +
+
+
+ +
+
+ +
+
+ {this.renderPullRequestStatus()} +
+ ) + } + + private renderPullRequestStatus() { + const ref = getPullRequestCommitRef(this.props.number) + return ( +
+ +
+ ) + } +} diff --git a/app/src/ui/branches/pull-request-list.tsx b/app/src/ui/branches/pull-request-list.tsx new file mode 100644 index 0000000000..0f03b816ed --- /dev/null +++ b/app/src/ui/branches/pull-request-list.tsx @@ -0,0 +1,371 @@ +import * as React from 'react' +import { + FilterList, + IFilterListGroup, + IFilterListItem, + SelectionSource, +} from '../lib/filter-list' +import { PullRequestListItem } from './pull-request-list-item' +import { PullRequest } from '../../models/pull-request' +import { NoPullRequests } from './no-pull-requests' +import { IMatches } from '../../lib/fuzzy-find' +import { Dispatcher } from '../dispatcher' +import { + RepositoryWithGitHubRepository, + getNonForkGitHubRepository, +} from '../../models/repository' +import { Button } from '../lib/button' +import { Octicon, syncClockwise } from '../octicons' +import { FoldoutType } from '../../lib/app-state' +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' + +interface IPullRequestListItem extends IFilterListItem { + readonly id: string + readonly text: ReadonlyArray + readonly pullRequest: PullRequest +} + +export const RowHeight = 47 + +interface IPullRequestListProps { + /** The pull requests to display. */ + readonly pullRequests: ReadonlyArray + + /** The currently selected pull request */ + readonly selectedPullRequest: PullRequest | null + + /** 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 + + /** Callback fired when user selects a new pull request */ + readonly onSelectionChanged: ( + pullRequest: PullRequest | null, + 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 + + /** Are we currently loading pull requests? */ + readonly isLoadingPullRequests: boolean + + /** When mouse enters a PR */ + readonly onMouseEnterPullRequest: ( + prNumber: PullRequest, + prListItemTop: number + ) => void + + /** When mouse leaves a PR */ + readonly onMouseLeavePullRequest: ( + event: React.MouseEvent + ) => void +} + +interface IPullRequestListState { + readonly filterText: string + readonly groupedItems: ReadonlyArray> + readonly selectedItem: IPullRequestListItem | null + readonly screenReaderStateMessage: string | null +} + +function resolveSelectedItem( + group: IFilterListGroup, + props: IPullRequestListProps, + currentlySelectedItem: IPullRequestListItem | null +): IPullRequestListItem | null { + let selectedItem: IPullRequestListItem | null = null + + if (props.selectedPullRequest != null) { + selectedItem = findItemForPullRequest(group, props.selectedPullRequest) + } + + if (selectedItem == null && currentlySelectedItem != null) { + selectedItem = findItemForPullRequest( + group, + currentlySelectedItem.pullRequest + ) + } + + return selectedItem +} + +/** The list of open pull requests. */ +export class PullRequestList extends React.Component< + IPullRequestListProps, + IPullRequestListState +> { + public constructor(props: IPullRequestListProps) { + super(props) + + const group = createListItems(props.pullRequests) + const selectedItem = resolveSelectedItem(group, props, null) + + this.state = { + filterText: '', + groupedItems: [group], + selectedItem, + screenReaderStateMessage: null, + } + } + + public componentWillReceiveProps(nextProps: IPullRequestListProps) { + const group = createListItems(nextProps.pullRequests) + const selectedItem = resolveSelectedItem( + group, + nextProps, + this.state.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} + /> + + + ) + } + + private renderNoItems = () => { + return ( + 0} + isLoadingPullRequests={this.props.isLoadingPullRequests} + repositoryName={this.getRepositoryName()} + isOnDefaultBranch={this.props.isOnDefaultBranch} + onCreateBranch={this.props.onCreateBranch} + onCreatePullRequest={this.onCreatePullRequest} + /> + ) + } + + private renderPullRequest = ( + item: IPullRequestListItem, + matches: IMatches + ) => { + const pr = item.pullRequest + + return ( + + ) + } + + private onMouseEnterPullRequest = ( + prNumber: number, + prListItemTop: number + ) => { + const { pullRequests } = this.props + + // If not the currently checked out pull request, find the full pull request + // object to start the cherry-pick + const pr = pullRequests.find(pr => pr.pullRequestNumber === prNumber) + if (pr === undefined) { + log.error('[onMouseEnterPullReqest] - Could not find pull request.') + return + } + + this.props.onMouseEnterPullRequest(pr, prListItemTop) + } + + private onMouseLeavePullRequest = ( + event: React.MouseEvent + ) => { + this.props.onMouseLeavePullRequest(event) + } + + private onDropOntoPullRequest = (prNumber: number) => { + const { repository, selectedPullRequest, dispatcher, pullRequests } = + this.props + + if (!dragAndDropManager.isDragOfTypeInProgress(DragType.Commit)) { + return + } + + // If dropped on currently checked out pull request, it is treated the same + // as dropping on non-pull-request. + if ( + selectedPullRequest !== null && + prNumber === selectedPullRequest.pullRequestNumber + ) { + dispatcher.endMultiCommitOperation(repository) + dispatcher.recordDragStartedAndCanceled() + return + } + + // If not the currently checked out pull request, find the full pull request + // object to start the cherry-pick + const pr = pullRequests.find(pr => pr.pullRequestNumber === prNumber) + if (pr === undefined) { + log.error('[onDropOntoPullRequest] - Could not find pull request.') + dispatcher.endMultiCommitOperation(repository) + return + } + + dispatcher.startCherryPickWithPullRequest(repository, pr) + } + + private onItemClick = ( + item: IPullRequestListItem, + source: SelectionSource + ) => { + const pullRequest = item.pullRequest + + this.props.dispatcher.closeFoldout(FoldoutType.Branch) + const timer = startTimer( + 'checkout pull request from list', + this.props.repository + ) + this.props.dispatcher + .checkoutPullRequest(this.props.repository, pullRequest) + .then(() => timer.done()) + + this.props.onSelectionChanged(pullRequest, source) + } + + private onSelectionChanged = ( + selectedItem: IPullRequestListItem | null, + source: SelectionSource + ) => { + this.props.onSelectionChanged( + selectedItem != null ? selectedItem.pullRequest : null, + source + ) + } + + private getRepositoryName(): string { + return getNonForkGitHubRepository(this.props.repository).fullName + } + + private renderListHeader = () => { + return ( +
+ Pull requests in {this.getRepositoryName()} +
+ ) + } + + private onRefreshPullRequests = () => { + this.props.dispatcher.refreshPullRequests(this.props.repository) + } + + private renderPostFilter = () => { + const tooltip = 'Refresh the list of pull requests' + + return ( + + ) + } + + private onFilterTextChanged = (text: string) => { + this.setState({ filterText: text }) + } + + private onCreatePullRequest = () => { + this.props.dispatcher.closeFoldout(FoldoutType.Branch) + this.props.dispatcher.createPullRequest(this.props.repository) + } +} + +function getSubtitle(pr: PullRequest) { + const timeAgo = formatRelative(pr.created.getTime() - Date.now()) + return `#${pr.pullRequestNumber} opened ${timeAgo} by ${pr.author}` +} + +function createListItems( + pullRequests: ReadonlyArray +): IFilterListGroup { + const items = pullRequests.map(pr => ({ + text: [pr.title, getSubtitle(pr)], + id: pr.pullRequestNumber.toString(), + pullRequest: pr, + })) + + return { + identifier: 'pull-requests', + items, + } +} + +function findItemForPullRequest( + group: IFilterListGroup, + pullRequest: PullRequest +): IPullRequestListItem | null { + return ( + group.items.find( + i => i.pullRequest.pullRequestNumber === pullRequest.pullRequestNumber + ) || null + ) +} diff --git a/app/src/ui/branches/push-branch-commits.tsx b/app/src/ui/branches/push-branch-commits.tsx new file mode 100644 index 0000000000..76b3ec95f4 --- /dev/null +++ b/app/src/ui/branches/push-branch-commits.tsx @@ -0,0 +1,175 @@ +import * as React from 'react' +import { Dispatcher } from '../dispatcher' +import { Branch } from '../../models/branch' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { Repository } from '../../models/repository' +import { Ref } from '../lib/ref' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +interface IPushBranchCommitsProps { + readonly dispatcher: Dispatcher + readonly repository: Repository + readonly branch: Branch + readonly onConfirm: (repository: Repository, branch: Branch) => void + readonly onDismissed: () => void + + /** + * Used to show the number of commits a branch is ahead by. + * If this value is undefined, component defaults to publish view. + */ + readonly unPushedCommits?: number +} + +interface IPushBranchCommitsState { + /** + * A value indicating whether we're currently working on publishing + * or pushing the branch to the remote. This value is used to tell + * the dialog to apply the loading and disabled state which adds a + * spinner and disables form controls for the duration of the operation. + */ + readonly isPushingOrPublishing: boolean +} + +/** + * Returns a string used for communicating the number of commits + * that will be pushed to the user. + * + * @param numberOfCommits The number of commits that will be pushed + * @param unit A string written in such a way that without + * modification it can be paired with the digit 1 + * such as 'commit' and which, when a 's' is appended + * to it can be paired with a zero digit or a number + * greater than one. + */ +function pluralize(numberOfCommits: number, unit: string) { + return numberOfCommits === 1 + ? `${numberOfCommits} ${unit}` + : `${numberOfCommits} ${unit}s` +} + +/** + * Simple type guard which allows us to substitute the non-obvious + * this.props.unPushedCommits === undefined checks with + * renderPublishView(this.props.unPushedCommits). + */ +function renderPublishView( + unPushedCommits: number | undefined +): unPushedCommits is undefined { + return unPushedCommits === undefined +} + +/** + * This component gets shown if the user attempts to open a PR with + * a) An un-published branch + * b) A branch that is ahead of its base branch + * + * In both cases, this asks the user if they'd like to push/publish the branch. + * If they confirm we push/publish then open the PR page on dotcom. + */ +export class PushBranchCommits extends React.Component< + IPushBranchCommitsProps, + IPushBranchCommitsState +> { + public constructor(props: IPushBranchCommitsProps) { + super(props) + + this.state = { isPushingOrPublishing: false } + } + + public render() { + return ( + + {this.renderDialogContent()} + + {this.renderButtonGroup()} + + ) + } + + private renderDialogContent() { + if (renderPublishView(this.props.unPushedCommits)) { + return ( + +

Your branch must be published before opening a pull request.

+

+ Would you like to publish {this.props.branch.name} now + and open a pull request? +

+
+ ) + } + + const localCommits = pluralize(this.props.unPushedCommits, 'local commit') + + return ( + +

+ You have {localCommits} that haven't been pushed to the remote yet. +

+

+ Would you like to push your changes to{' '} + {this.props.branch.name} before creating your pull request? +

+
+ ) + } + + private renderDialogTitle() { + if (renderPublishView(this.props.unPushedCommits)) { + return __DARWIN__ ? 'Publish Branch?' : 'Publish branch?' + } + + return __DARWIN__ ? `Push Local Changes?` : `Push local changes?` + } + + private renderButtonGroup() { + if (renderPublishView(this.props.unPushedCommits)) { + return ( + + ) + } + + return ( + + ) + } + + private onCreateWithoutPushButtonClick = ( + e: React.MouseEvent + ) => { + e.preventDefault() + this.props.onConfirm(this.props.repository, this.props.branch) + this.props.onDismissed() + } + + private onSubmit = async () => { + const { repository, branch } = this.props + + this.setState({ isPushingOrPublishing: true }) + + try { + await this.props.dispatcher.push(repository) + } finally { + this.setState({ isPushingOrPublishing: false }) + } + + this.props.onConfirm(repository, branch) + this.props.onDismissed() + } +} diff --git a/app/src/ui/change-repository-alias/change-repository-alias-dialog.tsx b/app/src/ui/change-repository-alias/change-repository-alias-dialog.tsx new file mode 100644 index 0000000000..0685ca36cf --- /dev/null +++ b/app/src/ui/change-repository-alias/change-repository-alias-dialog.tsx @@ -0,0 +1,82 @@ +import * as React from 'react' + +import { Dispatcher } from '../dispatcher' +import { nameOf, Repository } from '../../models/repository' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { TextBox } from '../lib/text-box' + +interface IChangeRepositoryAliasProps { + readonly dispatcher: Dispatcher + readonly onDismissed: () => void + readonly repository: Repository +} + +interface IChangeRepositoryAliasState { + readonly newAlias: string +} + +export class ChangeRepositoryAlias extends React.Component< + IChangeRepositoryAliasProps, + IChangeRepositoryAliasState +> { + public constructor(props: IChangeRepositoryAliasProps) { + super(props) + + this.state = { newAlias: props.repository.alias ?? props.repository.name } + } + + public render() { + const repository = this.props.repository + const verb = repository.alias === null ? 'Create' : 'Change' + + return ( + + +

+ Choose a new alias for the repository "{nameOf(repository)}".{' '} +

+

+ +

+ {repository.gitHubRepository !== null && ( +

+ This will not affect the original repository name on GitHub. +

+ )} +
+ + + + +
+ ) + } + + private onNameChanged = (newAlias: string) => { + this.setState({ newAlias }) + } + + private changeAlias = () => { + this.props.dispatcher.changeRepositoryAlias( + this.props.repository, + this.state.newAlias + ) + this.props.onDismissed() + } +} diff --git a/app/src/ui/changes/changed-file-details.tsx b/app/src/ui/changes/changed-file-details.tsx new file mode 100644 index 0000000000..cab1ce5d52 --- /dev/null +++ b/app/src/ui/changes/changed-file-details.tsx @@ -0,0 +1,95 @@ +import * as React from 'react' +import { PathLabel } from '../lib/path-label' +import { AppFileStatus } from '../../models/status' +import { IDiff, DiffType } from '../../models/diff' +import { Octicon, iconForStatus } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { mapStatus } from '../../lib/status' +import { DiffOptions } from '../diff/diff-options' + +interface IChangedFileDetailsProps { + readonly path: string + readonly status: AppFileStatus + readonly diff: IDiff | null + + /** Whether we should display side by side diffs. */ + readonly showSideBySideDiff: boolean + + /** Called when the user changes the side by side diffs setting. */ + readonly onShowSideBySideDiffChanged: (checked: boolean) => void + + /** Whether we should hide whitespace in diffs. */ + readonly hideWhitespaceInDiff: boolean + + /** Called when the user changes the hide whitespace in diffs setting. */ + readonly onHideWhitespaceInDiffChanged: (checked: boolean) => Promise + + /** Called when the user opens the diff options popover */ + readonly onDiffOptionsOpened: () => void +} + +/** Displays information about a file */ +export class ChangedFileDetails extends React.Component< + IChangedFileDetailsProps, + {} +> { + public render() { + const status = this.props.status + const fileStatus = mapStatus(status) + + return ( +
+ + {this.renderDecorator()} + + {this.renderDiffOptions()} + + +
+ ) + } + + private renderDiffOptions() { + if (this.props.diff?.kind === DiffType.Submodule) { + return null + } + + return ( + + ) + } + + private renderDecorator() { + const diff = this.props.diff + + if (diff === null) { + return null + } + + if (diff.kind === DiffType.Text && diff.lineEndingsChange) { + const message = `Warning: line endings will be changed from '${diff.lineEndingsChange.from}' to '${diff.lineEndingsChange.to}'.` + return ( + + ) + } else { + return null + } + } +} diff --git a/app/src/ui/changes/changed-file.tsx b/app/src/ui/changes/changed-file.tsx new file mode 100644 index 0000000000..131ff726bc --- /dev/null +++ b/app/src/ui/changes/changed-file.tsx @@ -0,0 +1,108 @@ +import * as React from 'react' + +import { PathLabel } from '../lib/path-label' +import { Octicon, iconForStatus } from '../octicons' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { mapStatus } from '../../lib/status' +import { WorkingDirectoryFileChange } from '../../models/status' +import { TooltipDirection } from '../lib/tooltip' +import { TooltippedContent } from '../lib/tooltipped-content' +import { AriaLiveContainer } from '../accessibility/aria-live-container' + +interface IChangedFileProps { + readonly file: WorkingDirectoryFileChange + readonly include: boolean | null + readonly availableWidth: number + readonly disableSelection: boolean + readonly checkboxTooltip?: string + readonly focused: boolean + readonly onIncludeChanged: (path: string, include: boolean) => void +} + +/** a changed file in the working directory for a given repository */ +export class ChangedFile extends React.Component { + private handleCheckboxChange = (event: React.FormEvent) => { + const include = event.currentTarget.checked + this.props.onIncludeChanged(this.props.file.path, include) + } + + private get checkboxValue(): CheckboxValue { + if (this.props.include === true) { + return CheckboxValue.On + } else if (this.props.include === false) { + return CheckboxValue.Off + } else { + return CheckboxValue.Mixed + } + } + + public render() { + const { file, availableWidth, disableSelection, checkboxTooltip, focused } = + this.props + const { status, path } = file + const fileStatus = mapStatus(status) + + const listItemPadding = 10 * 2 + const checkboxWidth = 20 + const statusWidth = 16 + const filePadding = 5 + + const availablePathWidth = + availableWidth - + listItemPadding - + checkboxWidth - + filePadding - + statusWidth + + const includedText = + this.props.include === true + ? 'included' + : this.props.include === undefined + ? 'partially included' + : 'not included' + + const pathScreenReaderMessage = `${path} ${mapStatus( + status + )} ${includedText}` + + return ( +
+ + + + + + + + + + +
+ ) + } +} diff --git a/app/src/ui/changes/changes-list.tsx b/app/src/ui/changes/changes-list.tsx new file mode 100644 index 0000000000..a6fd200a8c --- /dev/null +++ b/app/src/ui/changes/changes-list.tsx @@ -0,0 +1,1027 @@ +import * as React from 'react' +import * as Path from 'path' + +import { Dispatcher } from '../dispatcher' +import { IMenuItem } from '../../lib/menu-item' +import { revealInFileManager } from '../../lib/app-shell' +import { + WorkingDirectoryStatus, + WorkingDirectoryFileChange, + AppFileStatusKind, +} from '../../models/status' +import { DiffSelectionType } from '../../models/diff' +import { CommitIdentity } from '../../models/commit-identity' +import { ICommitMessage } from '../../models/commit-message' +import { + isRepositoryWithGitHubRepository, + Repository, +} from '../../models/repository' +import { Account } from '../../models/account' +import { Author, UnknownAuthor } from '../../models/author' +import { List, ClickSource } from '../lib/list' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { + isSafeFileExtension, + DefaultEditorLabel, + CopyFilePathLabel, + RevealInFileManagerLabel, + OpenWithDefaultProgramLabel, + CopyRelativeFilePathLabel, + CopySelectedPathsLabel, + CopySelectedRelativePathsLabel, +} from '../lib/context-menu' +import { CommitMessage } from './commit-message' +import { ChangedFile } from './changed-file' +import { IAutocompletionProvider } from '../autocompletion' +import { showContextualMenu } from '../../lib/menu-item' +import { arrayEquals } from '../../lib/equality' +import { clipboard } from 'electron' +import { basename } from 'path' +import { Commit, ICommitContext } from '../../models/commit' +import { + RebaseConflictState, + ConflictState, + Foldout, +} from '../../lib/app-state' +import { ContinueRebase } from './continue-rebase' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { IStashEntry } from '../../models/stash-entry' +import classNames from 'classnames' +import { hasWritePermission } from '../../models/github-repository' +import { hasConflictedFiles } from '../../lib/status' +import { createObservableRef } from '../lib/observable-ref' +import { TooltipDirection } from '../lib/tooltip' +import { Popup } from '../../models/popup' +import { EOL } from 'os' +import { TooltippedContent } from '../lib/tooltipped-content' +import { RepoRulesInfo } from '../../models/repo-rules' +import { IAheadBehind } from '../../models/branch' + +const RowHeight = 29 +const StashIcon: OcticonSymbol.OcticonSymbolType = { + w: 16, + h: 16, + d: + 'M10.5 1.286h-9a.214.214 0 0 0-.214.214v9a.214.214 0 0 0 .214.214h9a.214.214 0 0 0 ' + + '.214-.214v-9a.214.214 0 0 0-.214-.214zM1.5 0h9A1.5 1.5 0 0 1 12 1.5v9a1.5 1.5 0 0 1-1.5 ' + + '1.5h-9A1.5 1.5 0 0 1 0 10.5v-9A1.5 1.5 0 0 1 1.5 0zm5.712 7.212a1.714 1.714 0 1 ' + + '1-2.424-2.424 1.714 1.714 0 0 1 2.424 2.424zM2.015 12.71c.102.729.728 1.29 1.485 ' + + '1.29h9a1.5 1.5 0 0 0 1.5-1.5v-9a1.5 1.5 0 0 0-1.29-1.485v1.442a.216.216 0 0 1 ' + + '.004.043v9a.214.214 0 0 1-.214.214h-9a.216.216 0 0 1-.043-.004H2.015zm2 2c.102.729.728 ' + + '1.29 1.485 1.29h9a1.5 1.5 0 0 0 1.5-1.5v-9a1.5 1.5 0 0 0-1.29-1.485v1.442a.216.216 0 0 1 ' + + '.004.043v9a.214.214 0 0 1-.214.214h-9a.216.216 0 0 1-.043-.004H4.015z', + fr: 'evenodd', +} + +const GitIgnoreFileName = '.gitignore' + +/** Compute the 'Include All' checkbox value from the repository state */ +function getIncludeAllValue( + workingDirectory: WorkingDirectoryStatus, + rebaseConflictState: RebaseConflictState | null +) { + if (rebaseConflictState !== null) { + if (workingDirectory.files.length === 0) { + // the current commit will be skipped in the rebase + return CheckboxValue.Off + } + + // untracked files will be skipped by the rebase, so we need to ensure that + // the "Include All" checkbox matches this state + const onlyUntrackedFilesFound = workingDirectory.files.every( + f => f.status.kind === AppFileStatusKind.Untracked + ) + + if (onlyUntrackedFilesFound) { + return CheckboxValue.Off + } + + const onlyTrackedFilesFound = workingDirectory.files.every( + f => f.status.kind !== AppFileStatusKind.Untracked + ) + + // show "Mixed" if we have a mixture of tracked and untracked changes + return onlyTrackedFilesFound ? CheckboxValue.On : CheckboxValue.Mixed + } + + const { includeAll } = workingDirectory + if (includeAll === true) { + return CheckboxValue.On + } else if (includeAll === false) { + return CheckboxValue.Off + } else { + return CheckboxValue.Mixed + } +} + +interface IChangesListProps { + readonly repository: Repository + readonly repositoryAccount: Account | null + readonly workingDirectory: WorkingDirectoryStatus + readonly mostRecentLocalCommit: Commit | null + /** + * An object containing the conflicts in the working directory. + * When null it means that there are no conflicts. + */ + readonly conflictState: ConflictState | null + readonly rebaseConflictState: RebaseConflictState | null + readonly selectedFileIDs: ReadonlyArray + readonly onFileSelectionChanged: (rows: ReadonlyArray) => void + readonly onIncludeChanged: (path: string, include: boolean) => void + readonly onSelectAll: (selectAll: boolean) => void + readonly onCreateCommit: (context: ICommitContext) => Promise + readonly onDiscardChanges: (file: WorkingDirectoryFileChange) => void + readonly askForConfirmationOnDiscardChanges: boolean + readonly focusCommitMessage: boolean + readonly isShowingModal: boolean + readonly isShowingFoldout: boolean + readonly onDiscardChangesFromFiles: ( + files: ReadonlyArray, + isDiscardingAllChanges: boolean + ) => void + + /** Callback that fires on page scroll to pass the new scrollTop location */ + readonly onChangesListScrolled: (scrollTop: number) => void + + /* The scrollTop of the compareList. It is stored to allow for scroll position persistence */ + readonly changesListScrollTop?: number + + /** + * Called to open a file in its default application + * + * @param path The path of the file relative to the root of the repository + */ + readonly onOpenItem: (path: string) => void + + /** + * Called to open a file in the default external editor + * + * @param path The path of the file relative to the root of the repository + */ + readonly onOpenItemInExternalEditor: (path: string) => void + + /** + * The currently checked out branch (null if no branch is checked out). + */ + readonly branch: string | null + readonly commitAuthor: CommitIdentity | null + readonly dispatcher: Dispatcher + readonly availableWidth: number + readonly isCommitting: boolean + readonly commitToAmend: Commit | null + readonly currentBranchProtected: boolean + readonly currentRepoRulesInfo: RepoRulesInfo + readonly aheadBehind: IAheadBehind | null + + /** + * Click event handler passed directly to the onRowClick prop of List, see + * List Props for documentation. + */ + readonly onRowClick?: (row: number, source: ClickSource) => void + readonly commitMessage: ICommitMessage + + /** The autocompletion providers available to the repository. */ + readonly autocompletionProviders: ReadonlyArray> + + /** Called when the given file should be ignored. */ + readonly onIgnoreFile: (pattern: string | string[]) => void + + /** Called when the given pattern should be ignored. */ + readonly onIgnorePattern: (pattern: string | string[]) => void + + /** + * Whether or not to show a field for adding co-authors to + * a commit (currently only supported for GH/GHE repositories) + */ + readonly showCoAuthoredBy: boolean + + /** + * A list of authors (name, email pairs) which have been + * entered into the co-authors input box in the commit form + * and which _may_ be used in the subsequent commit to add + * Co-Authored-By commit message trailers depending on whether + * the user has chosen to do so. + */ + readonly coAuthors: ReadonlyArray + + /** The name of the currently selected external editor */ + readonly externalEditorLabel?: string + + readonly stashEntry: IStashEntry | null + + readonly isShowingStashEntry: boolean + + /** + * Whether we should show the onboarding tutorial nudge + * arrow pointing at the commit summary box + */ + readonly shouldNudgeToCommit: boolean + + readonly commitSpellcheckEnabled: boolean +} + +interface IChangesState { + readonly selectedRows: ReadonlyArray + readonly focusedRow: number | null +} + +function getSelectedRowsFromProps( + props: IChangesListProps +): ReadonlyArray { + const selectedFileIDs = props.selectedFileIDs + const selectedRows = [] + + for (const id of selectedFileIDs) { + const ix = props.workingDirectory.findFileIndexByID(id) + if (ix !== -1) { + selectedRows.push(ix) + } + } + + return selectedRows +} + +export class ChangesList extends React.Component< + IChangesListProps, + IChangesState +> { + private headerRef = createObservableRef() + private includeAllCheckBoxRef = React.createRef() + + public constructor(props: IChangesListProps) { + super(props) + this.state = { + selectedRows: getSelectedRowsFromProps(props), + focusedRow: null, + } + } + + public componentWillReceiveProps(nextProps: IChangesListProps) { + // No need to update state unless we haven't done it yet or the + // selected file id list has changed. + if ( + !arrayEquals(nextProps.selectedFileIDs, this.props.selectedFileIDs) || + !arrayEquals( + nextProps.workingDirectory.files, + this.props.workingDirectory.files + ) + ) { + this.setState({ selectedRows: getSelectedRowsFromProps(nextProps) }) + } + } + + private onIncludeAllChanged = (event: React.FormEvent) => { + const include = event.currentTarget.checked + this.props.onSelectAll(include) + } + + private renderRow = (row: number): JSX.Element => { + const { + workingDirectory, + rebaseConflictState, + isCommitting, + onIncludeChanged, + availableWidth, + } = this.props + + const file = workingDirectory.files[row] + const selection = file.selection.getSelectionType() + const { submoduleStatus } = file.status + + const isUncommittableSubmodule = + submoduleStatus !== undefined && + file.status.kind === AppFileStatusKind.Modified && + !submoduleStatus.commitChanged + + const isPartiallyCommittableSubmodule = + submoduleStatus !== undefined && + (submoduleStatus.commitChanged || + file.status.kind === AppFileStatusKind.New) && + (submoduleStatus.modifiedChanges || submoduleStatus.untrackedChanges) + + const includeAll = + selection === DiffSelectionType.All + ? true + : selection === DiffSelectionType.None + ? false + : null + + const include = isUncommittableSubmodule + ? false + : rebaseConflictState !== null + ? file.status.kind !== AppFileStatusKind.Untracked + : includeAll + + const disableSelection = + isCommitting || rebaseConflictState !== null || isUncommittableSubmodule + + const checkboxTooltip = isUncommittableSubmodule + ? 'This submodule change cannot be added to a commit in this repository because it contains changes that have not been committed.' + : isPartiallyCommittableSubmodule + ? 'Only changes that have been committed within the submodule will be added to this repository. You need to commit any other modified or untracked changes in the submodule before including them in this repository.' + : undefined + + return ( + + ) + } + + private onDiscardAllChanges = () => { + this.props.onDiscardChangesFromFiles( + this.props.workingDirectory.files, + true + ) + } + + private onStashChanges = () => { + this.props.dispatcher.createStashForCurrentBranch(this.props.repository) + } + + private onDiscardChanges = (files: ReadonlyArray) => { + const workingDirectory = this.props.workingDirectory + + if (files.length === 1) { + const modifiedFile = workingDirectory.files.find(f => f.path === files[0]) + + if (modifiedFile != null) { + this.props.onDiscardChanges(modifiedFile) + } + } else { + const modifiedFiles = new Array() + + files.forEach(file => { + const modifiedFile = workingDirectory.files.find(f => f.path === file) + + if (modifiedFile != null) { + modifiedFiles.push(modifiedFile) + } + }) + + if (modifiedFiles.length > 0) { + // DiscardAllChanges can also be used for discarding several selected changes. + // Therefore, we update the pop up to reflect whether or not it is "all" changes. + const discardingAllChanges = + modifiedFiles.length === workingDirectory.files.length + + this.props.onDiscardChangesFromFiles( + modifiedFiles, + discardingAllChanges + ) + } + } + } + + private getDiscardChangesMenuItemLabel = (files: ReadonlyArray) => { + const label = + files.length === 1 + ? __DARWIN__ + ? `Discard Changes` + : `Discard changes` + : __DARWIN__ + ? `Discard ${files.length} Selected Changes` + : `Discard ${files.length} selected changes` + + return this.props.askForConfirmationOnDiscardChanges ? `${label}…` : label + } + + private onContextMenu = (event: React.MouseEvent) => { + event.preventDefault() + + // need to preserve the working directory state while dealing with conflicts + if (this.props.rebaseConflictState !== null || this.props.isCommitting) { + return + } + + const hasLocalChanges = this.props.workingDirectory.files.length > 0 + const hasStash = this.props.stashEntry !== null + const hasConflicts = + this.props.conflictState !== null || + hasConflictedFiles(this.props.workingDirectory) + + const stashAllChangesLabel = __DARWIN__ + ? 'Stash All Changes' + : 'Stash all changes' + const confirmStashAllChangesLabel = __DARWIN__ + ? 'Stash All Changes…' + : 'Stash all changes…' + + const items: IMenuItem[] = [ + { + label: __DARWIN__ ? 'Discard All Changes…' : 'Discard all changes…', + action: this.onDiscardAllChanges, + enabled: hasLocalChanges, + }, + { + label: hasStash ? confirmStashAllChangesLabel : stashAllChangesLabel, + action: this.onStashChanges, + enabled: hasLocalChanges && this.props.branch !== null && !hasConflicts, + }, + ] + + showContextualMenu(items) + } + + private getDiscardChangesMenuItem = ( + paths: ReadonlyArray + ): IMenuItem => { + return { + label: this.getDiscardChangesMenuItemLabel(paths), + action: () => this.onDiscardChanges(paths), + } + } + + private getCopyPathMenuItem = ( + file: WorkingDirectoryFileChange + ): IMenuItem => { + return { + label: CopyFilePathLabel, + action: () => { + const fullPath = Path.join(this.props.repository.path, file.path) + clipboard.writeText(fullPath) + }, + } + } + + private getCopyRelativePathMenuItem = ( + file: WorkingDirectoryFileChange + ): IMenuItem => { + return { + label: CopyRelativeFilePathLabel, + action: () => clipboard.writeText(Path.normalize(file.path)), + } + } + + private getCopySelectedPathsMenuItem = ( + files: WorkingDirectoryFileChange[] + ): IMenuItem => { + return { + label: CopySelectedPathsLabel, + action: () => { + const fullPaths = files.map(file => + Path.join(this.props.repository.path, file.path) + ) + clipboard.writeText(fullPaths.join(EOL)) + }, + } + } + + private getCopySelectedRelativePathsMenuItem = ( + files: WorkingDirectoryFileChange[] + ): IMenuItem => { + return { + label: CopySelectedRelativePathsLabel, + action: () => { + const paths = files.map(file => Path.normalize(file.path)) + clipboard.writeText(paths.join(EOL)) + }, + } + } + + private getRevealInFileManagerMenuItem = ( + file: WorkingDirectoryFileChange + ): IMenuItem => { + return { + label: RevealInFileManagerLabel, + action: () => revealInFileManager(this.props.repository, file.path), + enabled: file.status.kind !== AppFileStatusKind.Deleted, + } + } + + private getOpenInExternalEditorMenuItem = ( + file: WorkingDirectoryFileChange, + enabled: boolean + ): IMenuItem => { + const { externalEditorLabel } = this.props + + const openInExternalEditor = externalEditorLabel + ? `Open in ${externalEditorLabel}` + : DefaultEditorLabel + + return { + label: openInExternalEditor, + action: () => { + this.props.onOpenItemInExternalEditor(file.path) + }, + enabled, + } + } + + private getDefaultContextMenu( + file: WorkingDirectoryFileChange + ): ReadonlyArray { + const { id, path, status } = file + + const extension = Path.extname(path) + const isSafeExtension = isSafeFileExtension(extension) + + const { workingDirectory, selectedFileIDs } = this.props + + const selectedFiles = new Array() + const paths = new Array() + const extensions = new Set() + + const addItemToArray = (fileID: string) => { + const newFile = workingDirectory.findFileWithID(fileID) + if (newFile) { + selectedFiles.push(newFile) + paths.push(newFile.path) + + const extension = Path.extname(newFile.path) + if (extension.length) { + extensions.add(extension) + } + } + } + + if (selectedFileIDs.includes(id)) { + // user has selected a file inside an existing selection + // -> context menu entries should be applied to all selected files + selectedFileIDs.forEach(addItemToArray) + } else { + // this is outside their previous selection + // -> context menu entries should be applied to just this file + addItemToArray(id) + } + + const items: IMenuItem[] = [ + this.getDiscardChangesMenuItem(paths), + { type: 'separator' }, + ] + if (paths.length === 1) { + items.push({ + label: __DARWIN__ + ? 'Ignore File (Add to .gitignore)' + : 'Ignore file (add to .gitignore)', + action: () => this.props.onIgnoreFile(path), + enabled: Path.basename(path) !== GitIgnoreFileName, + }) + } else if (paths.length > 1) { + items.push({ + label: __DARWIN__ + ? `Ignore ${paths.length} Selected Files (Add to .gitignore)` + : `Ignore ${paths.length} selected files (add to .gitignore)`, + action: () => { + // Filter out any .gitignores that happens to be selected, ignoring + // those doesn't make sense. + this.props.onIgnoreFile( + paths.filter(path => Path.basename(path) !== GitIgnoreFileName) + ) + }, + // Enable this action as long as there's something selected which isn't + // a .gitignore file. + enabled: paths.some(path => Path.basename(path) !== GitIgnoreFileName), + }) + } + // Five menu items should be enough for everyone + Array.from(extensions) + .slice(0, 5) + .forEach(extension => { + items.push({ + label: __DARWIN__ + ? `Ignore All ${extension} Files (Add to .gitignore)` + : `Ignore all ${extension} files (add to .gitignore)`, + action: () => this.props.onIgnorePattern(`*${extension}`), + }) + }) + + if (paths.length > 1) { + items.push( + { type: 'separator' }, + { + label: __DARWIN__ + ? 'Include Selected Files' + : 'Include selected files', + action: () => { + selectedFiles.map(file => + this.props.onIncludeChanged(file.path, true) + ) + }, + }, + { + label: __DARWIN__ + ? 'Exclude Selected Files' + : 'Exclude selected files', + action: () => { + selectedFiles.map(file => + this.props.onIncludeChanged(file.path, false) + ) + }, + }, + { type: 'separator' }, + this.getCopySelectedPathsMenuItem(selectedFiles), + this.getCopySelectedRelativePathsMenuItem(selectedFiles) + ) + } else { + items.push( + { type: 'separator' }, + this.getCopyPathMenuItem(file), + this.getCopyRelativePathMenuItem(file) + ) + } + + const enabled = status.kind !== AppFileStatusKind.Deleted + items.push( + { type: 'separator' }, + this.getRevealInFileManagerMenuItem(file), + this.getOpenInExternalEditorMenuItem(file, enabled), + { + label: OpenWithDefaultProgramLabel, + action: () => this.props.onOpenItem(path), + enabled: enabled && isSafeExtension, + } + ) + + return items + } + + private getRebaseContextMenu( + file: WorkingDirectoryFileChange + ): ReadonlyArray { + const { path, status } = file + + const extension = Path.extname(path) + const isSafeExtension = isSafeFileExtension(extension) + + const items = new Array() + + if (file.status.kind === AppFileStatusKind.Untracked) { + items.push(this.getDiscardChangesMenuItem([file.path]), { + type: 'separator', + }) + } + + const enabled = status.kind !== AppFileStatusKind.Deleted + + items.push( + this.getCopyPathMenuItem(file), + this.getCopyRelativePathMenuItem(file), + { type: 'separator' }, + this.getRevealInFileManagerMenuItem(file), + this.getOpenInExternalEditorMenuItem(file, enabled), + { + label: OpenWithDefaultProgramLabel, + action: () => this.props.onOpenItem(path), + enabled: enabled && isSafeExtension, + } + ) + + return items + } + + private onItemContextMenu = ( + row: number, + event: React.MouseEvent + ) => { + const { workingDirectory } = this.props + const file = workingDirectory.files[row] + + if (this.props.isCommitting) { + return + } + + event.preventDefault() + + const items = + this.props.rebaseConflictState === null + ? this.getDefaultContextMenu(file) + : this.getRebaseContextMenu(file) + + showContextualMenu(items) + } + + private getPlaceholderMessage( + files: ReadonlyArray, + prepopulateCommitSummary: boolean + ) { + if (!prepopulateCommitSummary) { + return 'Summary (required)' + } + + const firstFile = files[0] + const fileName = basename(firstFile.path) + + switch (firstFile.status.kind) { + case AppFileStatusKind.New: + case AppFileStatusKind.Untracked: + return `Create ${fileName}` + case AppFileStatusKind.Deleted: + return `Delete ${fileName}` + default: + // TODO: + // this doesn't feel like a great message for AppFileStatus.Copied or + // AppFileStatus.Renamed but without more insight (and whether this + // affects other parts of the flow) we can just default to this for now + return `Update ${fileName}` + } + } + + private onScroll = (scrollTop: number, clientHeight: number) => { + this.props.onChangesListScrolled(scrollTop) + } + + private renderCommitMessageForm = (): JSX.Element => { + const { + rebaseConflictState, + workingDirectory, + repository, + repositoryAccount, + dispatcher, + isCommitting, + commitToAmend, + currentBranchProtected, + currentRepoRulesInfo: currentRepoRulesInfo, + } = this.props + + if (rebaseConflictState !== null) { + const hasUntrackedChanges = workingDirectory.files.some( + f => f.status.kind === AppFileStatusKind.Untracked + ) + + return ( + + ) + } + + const fileCount = workingDirectory.files.length + + const includeAllValue = getIncludeAllValue( + workingDirectory, + rebaseConflictState + ) + + const anyFilesSelected = + fileCount > 0 && includeAllValue !== CheckboxValue.Off + + const filesSelected = workingDirectory.files.filter( + f => f.selection.getSelectionType() !== DiffSelectionType.None + ) + + // When a single file is selected, we use a default commit summary + // based on the file name and change status. + // However, for onboarding tutorial repositories, we don't want to do this. + // See https://github.com/desktop/desktop/issues/8354 + const prepopulateCommitSummary = + filesSelected.length === 1 && !repository.isTutorialRepository + + // if this is not a github repo, we don't want to + // restrict what the user can do at all + const hasWritePermissionForRepository = + this.props.repository.gitHubRepository === null || + hasWritePermission(this.props.repository.gitHubRepository) + + return ( + 0} + repository={repository} + repositoryAccount={repositoryAccount} + commitMessage={this.props.commitMessage} + focusCommitMessage={this.props.focusCommitMessage} + autocompletionProviders={this.props.autocompletionProviders} + isCommitting={isCommitting} + commitToAmend={commitToAmend} + showCoAuthoredBy={this.props.showCoAuthoredBy} + coAuthors={this.props.coAuthors} + placeholder={this.getPlaceholderMessage( + filesSelected, + prepopulateCommitSummary + )} + prepopulateCommitSummary={prepopulateCommitSummary} + key={repository.id} + showBranchProtected={fileCount > 0 && currentBranchProtected} + repoRulesInfo={currentRepoRulesInfo} + aheadBehind={this.props.aheadBehind} + showNoWriteAccess={fileCount > 0 && !hasWritePermissionForRepository} + shouldNudge={this.props.shouldNudgeToCommit} + commitSpellcheckEnabled={this.props.commitSpellcheckEnabled} + onCoAuthorsUpdated={this.onCoAuthorsUpdated} + onShowCoAuthoredByChanged={this.onShowCoAuthoredByChanged} + onConfirmCommitWithUnknownCoAuthors={ + this.onConfirmCommitWithUnknownCoAuthors + } + onPersistCommitMessage={this.onPersistCommitMessage} + onCommitMessageFocusSet={this.onCommitMessageFocusSet} + onRefreshAuthor={this.onRefreshAuthor} + onShowPopup={this.onShowPopup} + onShowFoldout={this.onShowFoldout} + onCommitSpellcheckEnabledChanged={this.onCommitSpellcheckEnabledChanged} + onStopAmending={this.onStopAmending} + onShowCreateForkDialog={this.onShowCreateForkDialog} + /> + ) + } + + private onCoAuthorsUpdated = (coAuthors: ReadonlyArray) => + this.props.dispatcher.setCoAuthors(this.props.repository, coAuthors) + + private onShowCoAuthoredByChanged = (showCoAuthors: boolean) => { + const { dispatcher, repository } = this.props + dispatcher.setShowCoAuthoredBy(repository, showCoAuthors) + } + + private onConfirmCommitWithUnknownCoAuthors = ( + coAuthors: ReadonlyArray, + onCommitAnyway: () => void + ) => { + const { dispatcher } = this.props + dispatcher.showUnknownAuthorsCommitWarning(coAuthors, onCommitAnyway) + } + + private onRefreshAuthor = () => + this.props.dispatcher.refreshAuthor(this.props.repository) + + private onCommitMessageFocusSet = () => + this.props.dispatcher.setCommitMessageFocus(false) + + private onPersistCommitMessage = (message: ICommitMessage) => + this.props.dispatcher.setCommitMessage(this.props.repository, message) + + private onShowPopup = (p: Popup) => this.props.dispatcher.showPopup(p) + private onShowFoldout = (f: Foldout) => this.props.dispatcher.showFoldout(f) + + private onCommitSpellcheckEnabledChanged = (enabled: boolean) => + this.props.dispatcher.setCommitSpellcheckEnabled(enabled) + + private onStopAmending = () => + this.props.dispatcher.stopAmendingRepository(this.props.repository) + + private onShowCreateForkDialog = () => { + if (isRepositoryWithGitHubRepository(this.props.repository)) { + this.props.dispatcher.showCreateForkDialog(this.props.repository) + } + } + + private onStashEntryClicked = () => { + const { isShowingStashEntry, dispatcher, repository } = this.props + + if (isShowingStashEntry) { + dispatcher.selectWorkingDirectoryFiles(repository) + + // If the button is clicked, that implies the stash was not restored or discarded + dispatcher.recordNoActionTakenOnStash() + } else { + dispatcher.selectStashedFile(repository) + dispatcher.recordStashView() + } + } + + private renderStashedChanges() { + if (this.props.stashEntry === null) { + return null + } + + const className = classNames( + 'stashed-changes-button', + this.props.isShowingStashEntry ? 'selected' : null + ) + + return ( + + ) + } + + private onRowDoubleClick = (row: number) => { + const file = this.props.workingDirectory.files[row] + + this.props.onOpenItemInExternalEditor(file.path) + } + + private onRowKeyDown = ( + _row: number, + event: React.KeyboardEvent + ) => { + // The commit is already in-flight but this check prevents the + // user from changing selection. + if ( + this.props.isCommitting && + (event.key === 'Enter' || event.key === ' ') + ) { + event.preventDefault() + } + + return + } + + public focus() { + this.includeAllCheckBoxRef.current?.focus() + } + + public render() { + const { workingDirectory, rebaseConflictState, isCommitting } = this.props + const { files } = workingDirectory + + const filesPlural = files.length === 1 ? 'file' : 'files' + const filesDescription = `${files.length} changed ${filesPlural}` + + const selectedChangeCount = files.filter( + file => file.selection.getSelectionType() !== DiffSelectionType.None + ).length + const totalFilesPlural = files.length === 1 ? 'file' : 'files' + const selectedChangesDescription = `${selectedChangeCount}/${files.length} changed ${totalFilesPlural} included` + + const includeAllValue = getIncludeAllValue( + workingDirectory, + rebaseConflictState + ) + + const disableAllCheckbox = + files.length === 0 || isCommitting || rebaseConflictState !== null + + return ( + <> +
+
+ + + +
+ {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 new file mode 100644 index 0000000000..9abf1cbe0b --- /dev/null +++ b/app/src/ui/changes/changes.tsx @@ -0,0 +1,152 @@ +import * as React from 'react' +import { ChangedFileDetails } from './changed-file-details' +import { + DiffSelection, + IDiff, + ImageDiffType, + ITextDiff, +} from '../../models/diff' +import { WorkingDirectoryFileChange } from '../../models/status' +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { SeamlessDiffSwitcher } from '../diff/seamless-diff-switcher' +import { PopupType } from '../../models/popup' + +interface IChangesProps { + readonly repository: Repository + readonly file: WorkingDirectoryFileChange + readonly diff: IDiff | null + readonly dispatcher: Dispatcher + readonly imageDiffType: ImageDiffType + + /** Whether a commit is in progress */ + readonly isCommitting: boolean + readonly hideWhitespaceInDiff: boolean + + /** + * Callback to open a selected file using the configured external editor + * + * @param fullPath The full path to the file on disk + */ + readonly onOpenInExternalEditor: (fullPath: string) => void + + /** + * Called when the user requests to open a binary file in an the + * system-assigned application for said file type. + */ + 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. + */ + readonly onChangeImageDiffType: (type: ImageDiffType) => void + + /** + * Whether we should show a confirmation dialog when the user + * discards changes + */ + readonly askForConfirmationOnDiscardChanges: boolean + + /** + * Whether we should display side by side diffs. + */ + readonly showSideBySideDiff: boolean + + /** Called when the user opens the diff options popover */ + readonly onDiffOptionsOpened: () => void +} + +export class Changes extends React.Component { + /** + * Whether or not it's currently possible to change the line selection + * of a diff. Changing selection is not possible while a commit is in + * progress or if the user has opted to hide whitespace changes. + */ + private get lineSelectionDisabled() { + return this.props.isCommitting || this.props.hideWhitespaceInDiff + } + + private onDiffLineIncludeChanged = (selection: DiffSelection) => { + if (!this.lineSelectionDisabled) { + const { repository, file } = this.props + this.props.dispatcher.changeFileLineSelection(repository, file, selection) + } + } + + private onDiscardChanges = ( + diff: ITextDiff, + diffSelection: DiffSelection + ) => { + if (this.lineSelectionDisabled) { + return + } + + if (this.props.askForConfirmationOnDiscardChanges) { + this.props.dispatcher.showPopup({ + type: PopupType.ConfirmDiscardSelection, + repository: this.props.repository, + file: this.props.file, + diff, + selection: diffSelection, + }) + } else { + this.props.dispatcher.discardChangesFromSelection( + this.props.repository, + this.props.file.path, + diff, + diffSelection + ) + } + } + + public render() { + return ( +
+ + + +
+ ) + } + + private onShowSideBySideDiffChanged = (showSideBySideDiff: boolean) => { + this.props.dispatcher.onShowSideBySideDiffChanged(showSideBySideDiff) + } + + private onHideWhitespaceInDiffChanged = (hideWhitespaceInDiff: boolean) => { + return this.props.dispatcher.onHideWhitespaceInChangesDiffChanged( + hideWhitespaceInDiff, + this.props.repository + ) + } +} diff --git a/app/src/ui/changes/commit-message-avatar.tsx b/app/src/ui/changes/commit-message-avatar.tsx new file mode 100644 index 0000000000..a5f324cbbd --- /dev/null +++ b/app/src/ui/changes/commit-message-avatar.tsx @@ -0,0 +1,460 @@ +import React from 'react' +import { Select } from '../lib/select' +import { Button } from '../lib/button' +import { Row } from '../lib/row' +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 { 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' + +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 + + /** + * 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 warningType: CommitMessageAvatarWarningType + + /** + * List of validations that failed for repo rules. Only used if + * {@link warningType} is 'disallowedEmail'. + */ + readonly emailRuleFailures?: RepoRulesMetadataFailures + + /** + * Name of the current branch + */ + readonly branch: string | null + + /** Whether or not the user's account is a GHE account. */ + readonly isEnterpriseAccount: boolean + + /** Email addresses available in the relevant GitHub (Enterprise) account. */ + readonly accountEmails: ReadonlyArray + + /** Preferred email address from the user's account. */ + readonly preferredAccountEmail: string + + /** + * The currently selected repository + */ + readonly repository: Repository + + readonly onUpdateEmail: (email: string) => void + + /** + * Called when the user has requested to see the Git Config tab in the + * 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 +} + +/** + * User avatar shown in the commit message area. It encapsulates not only the + * user avatar, but also any badge and warning we might display to the user. + */ +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.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 ? OcticonSymbol.stop : OcticonSymbol.alert + + return ( +
+ +
+ ) + } + + private openPopover = () => { + this.setState(prevState => { + if (prevState.isPopoverOpen === false) { + return { isPopoverOpen: true } + } + return null + }) + } + + private closePopover = () => { + this.setState(prevState => { + if (prevState.isPopoverOpen) { + return { isPopoverOpen: false } + } + return null + }) + } + + private onAvatarClick = (event: React.FormEvent) => { + event.preventDefault() + if (this.state.isPopoverOpen) { + this.closePopover() + } else { + this.openPopover() + } + } + + private renderGitConfigPopover() { + const { user } = this.props + const { isGitConfigLocal } = this.state + + 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 ( + <> +

{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 = ( + <> + + + + +
+ You can also choose an email local to this repository from the{' '} + + repository settings + + . +
+
+ + + + + + ) + + if (warningType === 'misattribution') { + const accountTypeSuffix = this.props.isEnterpriseAccount + ? ' Enterprise' + : '' + + const userName = + this.props.user && this.props.user.name + ? ` for ${this.props.user.name}` + : '' + + return ( + <> + +
+ {sharedHeader} doesn't match your GitHub{accountTypeSuffix}{' '} + account{userName}.{' '} + + Learn more + +
+
+ {sharedFooter} + + ) + } else if ( + warningType === 'disallowedEmail' && + emailRuleFailures && + this.props.branch && + this.props.repository.gitHubRepository + ) { + return ( + <> + + {sharedFooter} + + ) + } + + return + } + + private getCommittingAsTitle(): string | JSX.Element | undefined { + const { user } = this.props + + if (user === undefined) { + return 'Unknown user' + } + + const { name, email } = user + + if (name) { + return ( + <> + Committing as {name} + + ) + } + + return <>Committing with {email} + } + + private renderPopover() { + const { warningType } = this.props + + let header: string | JSX.Element | undefined = '' + switch (this.props.warningType) { + case 'misattribution': + header = 'This commit will be misattributed' + break + + case 'disallowedEmail': + header = 'This email address is disallowed' + break + + default: + header = this.getCommittingAsTitle() + break + } + + return ( + +

{header}

+ + {warningType !== 'none' + ? this.renderWarningPopover() + : this.renderGitConfigPopover()} +
+ ) + } + + private onRepositorySettingsClick = () => { + this.closePopover() + this.props.onOpenRepositorySettings() + } + + private onOpenGitSettings = () => { + this.closePopover() + if (this.state.isGitConfigLocal) { + this.props.onOpenRepositorySettings() + } else { + this.props.onOpenGitSettings() + } + } + + private onIgnoreClick = (event: React.MouseEvent) => { + event.preventDefault() + this.closePopover() + } + + private onUpdateEmailClick = async ( + event: React.MouseEvent + ) => { + event.preventDefault() + this.closePopover() + + if (this.props.email !== this.state.accountEmail) { + this.props.onUpdateEmail(this.state.accountEmail) + } + } + + private onSelectedGitHubEmailChange = ( + event: React.FormEvent + ) => { + const email = event.currentTarget.value + if (email) { + this.setState({ accountEmail: email }) + } + } +} diff --git a/app/src/ui/changes/commit-message.tsx b/app/src/ui/changes/commit-message.tsx new file mode 100644 index 0000000000..47d27cf8c4 --- /dev/null +++ b/app/src/ui/changes/commit-message.tsx @@ -0,0 +1,1395 @@ +import * as React from 'react' +import classNames from 'classnames' +import { + AutocompletingTextArea, + AutocompletingInput, + IAutocompletionProvider, + CoAuthorAutocompletionProvider, +} from '../autocompletion' +import { CommitIdentity } from '../../models/commit-identity' +import { ICommitMessage } from '../../models/commit-message' +import { Repository } from '../../models/repository' +import { Button } from '../lib/button' +import { Loading } from '../lib/loading' +import { AuthorInput } from '../lib/author-input/author-input' +import { FocusContainer } from '../lib/focus-container' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Author, UnknownAuthor, isKnownAuthor } from '../../models/author' +import { IMenuItem } from '../../lib/menu-item' +import { Commit, ICommitContext } from '../../models/commit' +import { startTimer } from '../lib/timing' +import { CommitWarning, CommitWarningIcon } from './commit-warning' +import { LinkButton } from '../lib/link-button' +import { Foldout, FoldoutType } from '../../lib/app-state' +import { IAvatarUser, getAvatarUserFromAuthor } from '../../models/avatar' +import { showContextualMenu } from '../../lib/menu-item' +import { Account } from '../../models/account' +import { + CommitMessageAvatar, + CommitMessageAvatarWarningType, +} from './commit-message-avatar' +import { getDotComAPIEndpoint } from '../../lib/api' +import { isAttributableEmailFor, lookupPreferredEmail } from '../../lib/email' +import { setGlobalConfigValue } from '../../lib/git/config' +import { Popup, PopupType } from '../../models/popup' +import { RepositorySettingsTab } from '../repository-settings/repository-settings' +import { IdealSummaryLength } from '../../lib/wrap-rich-text-commit-message' +import { isEmptyOrWhitespace } from '../../lib/is-empty-or-whitespace' +import { TooltipDirection } from '../lib/tooltip' +import { pick } from '../../lib/pick' +import { ToggledtippedContent } from '../lib/toggletipped-content' +import { PreferencesTab } from '../../models/preferences' +import { + RepoRuleEnforced, + RepoRulesInfo, + RepoRulesMetadataFailures, +} from '../../models/repo-rules' +import { IAheadBehind } from '../../models/branch' +import { + Popover, + PopoverAnchorPosition, + PopoverDecoration, +} from '../lib/popover' +import { RepoRulesetsForBranchLink } from '../repository-rules/repo-rulesets-for-branch-link' +import { RepoRulesMetadataFailureList } from '../repository-rules/repo-rules-failure-list' +import { Dispatcher } from '../dispatcher' +import { formatCommitMessage } from '../../lib/format-commit-message' +import { useRepoRulesLogic } from '../../lib/helpers/repo-rules' + +const addAuthorIcon = { + w: 18, + h: 13, + d: + 'M14 6V4.25a.75.75 0 0 1 1.5 0V6h1.75a.75.75 0 1 1 0 1.5H15.5v1.75a.75.75 0 0 ' + + '1-1.5 0V7.5h-1.75a.75.75 0 1 1 0-1.5H14zM8.5 4a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 ' + + '0zm.063 3.064a3.995 3.995 0 0 0 1.2-4.429A3.996 3.996 0 0 0 8.298.725a4.01 4.01 0 0 ' + + '0-6.064 1.91 3.987 3.987 0 0 0 1.2 4.43A5.988 5.988 0 0 0 0 12.2a.748.748 0 0 0 ' + + '.716.766.751.751 0 0 0 .784-.697 4.49 4.49 0 0 1 1.39-3.04 4.51 4.51 0 0 1 6.218 ' + + '0 4.49 4.49 0 0 1 1.39 3.04.748.748 0 0 0 .786.73.75.75 0 0 0 .714-.8 5.989 5.989 0 0 0-3.435-5.136z', +} + +interface ICommitMessageProps { + readonly onCreateCommit: (context: ICommitContext) => Promise + readonly branch: string | null + readonly commitAuthor: CommitIdentity | null + readonly dispatcher: Dispatcher + readonly anyFilesSelected: boolean + readonly isShowingModal: boolean + readonly isShowingFoldout: boolean + + /** + * Whether it's possible to select files for commit, affects messaging + * when commit button is disabled + */ + readonly anyFilesAvailable: boolean + readonly focusCommitMessage: boolean + readonly commitMessage: ICommitMessage | null + readonly repository: Repository + readonly repositoryAccount: Account | null + readonly autocompletionProviders: ReadonlyArray> + readonly isCommitting?: boolean + readonly commitToAmend: Commit | null + readonly placeholder: string + readonly prepopulateCommitSummary: boolean + readonly showBranchProtected: boolean + readonly repoRulesInfo: RepoRulesInfo + readonly aheadBehind: IAheadBehind | null + readonly showNoWriteAccess: boolean + + /** + * Whether or not to show a field for adding co-authors to + * a commit (currently only supported for GH/GHE repositories) + */ + readonly showCoAuthoredBy: boolean + + /** + * A list of authors (name, email pairs) which have been + * entered into the co-authors input box in the commit form + * and which _may_ be used in the subsequent commit to add + * Co-Authored-By commit message trailers depending on whether + * the user has chosen to do so. + */ + readonly coAuthors: ReadonlyArray + + /** Whether this component should show its onboarding tutorial nudge arrow */ + readonly shouldNudge?: boolean + + readonly commitSpellcheckEnabled: boolean + + /** Optional text to override default commit button text */ + readonly commitButtonText?: string + + readonly mostRecentLocalCommit: Commit | null + + /** Whether or not to remember the coauthors in the changes state */ + readonly onCoAuthorsUpdated: (coAuthors: ReadonlyArray) => void + readonly onShowCoAuthoredByChanged: (showCoAuthoredBy: boolean) => void + readonly onConfirmCommitWithUnknownCoAuthors: ( + coAuthors: ReadonlyArray, + onCommitAnyway: () => void + ) => void + + /** + * Called when the component unmounts to give callers the ability + * to persist the commit message (i.e. when switching between changes + * and history view). + */ + readonly onPersistCommitMessage?: (message: ICommitMessage) => void + + /** + * Called when the component has given the commit message focus due to + * `focusCommitMessage` being set. Used to reset the `focusCommitMessage` + * prop. + */ + readonly onCommitMessageFocusSet: () => void + + /** + * Called when the user email in Git config has been updated to refresh + * the repository state. + */ + readonly onRefreshAuthor: () => void + + readonly onShowPopup: (popup: Popup) => void + readonly onShowFoldout: (foldout: Foldout) => void + readonly onCommitSpellcheckEnabledChanged: (enabled: boolean) => void + readonly onStopAmending: () => void + readonly onShowCreateForkDialog: () => void +} + +interface ICommitMessageState { + readonly summary: string + readonly description: string | null + + readonly commitMessageAutocompletionProviders: ReadonlyArray< + IAutocompletionProvider + > + readonly coAuthorAutocompletionProvider: CoAuthorAutocompletionProvider | null + + /** + * Whether or not the description text area has more text that's + * obscured by the action bar. Note that this will always be + * false when there's no action bar. + */ + readonly descriptionObscured: boolean + + readonly isCommittingStatusMessage: string + + readonly repoRulesEnabled: boolean + + readonly isRuleFailurePopoverOpen: boolean + + readonly repoRuleCommitMessageFailures: RepoRulesMetadataFailures + readonly repoRuleCommitAuthorFailures: RepoRulesMetadataFailures + readonly repoRuleBranchNameFailures: RepoRulesMetadataFailures +} + +function findCommitMessageAutoCompleteProvider( + providers: ReadonlyArray> +): ReadonlyArray> { + return providers.filter( + provider => !(provider instanceof CoAuthorAutocompletionProvider) + ) +} + +function findCoAuthorAutoCompleteProvider( + providers: ReadonlyArray> +): CoAuthorAutocompletionProvider | null { + for (const provider of providers) { + if (provider instanceof CoAuthorAutocompletionProvider) { + return provider + } + } + + return null +} + +export class CommitMessage extends React.Component< + ICommitMessageProps, + ICommitMessageState +> { + private descriptionComponent: AutocompletingTextArea | null = null + + private summaryTextInput: HTMLInputElement | null = null + + private descriptionTextArea: HTMLTextAreaElement | null = null + private descriptionTextAreaScrollDebounceId: number | null = null + + private coAuthorInputRef = React.createRef() + + private readonly COMMIT_MSG_ERROR_BTN_ID = 'commit-message-failure-hint' + + public constructor(props: ICommitMessageProps) { + super(props) + const { commitMessage } = this.props + + this.state = { + summary: commitMessage ? commitMessage.summary : '', + description: commitMessage ? commitMessage.description : null, + commitMessageAutocompletionProviders: + findCommitMessageAutoCompleteProvider(props.autocompletionProviders), + coAuthorAutocompletionProvider: findCoAuthorAutoCompleteProvider( + props.autocompletionProviders + ), + descriptionObscured: false, + isCommittingStatusMessage: '', + repoRulesEnabled: false, + isRuleFailurePopoverOpen: false, + repoRuleCommitMessageFailures: new RepoRulesMetadataFailures(), + repoRuleCommitAuthorFailures: new RepoRulesMetadataFailures(), + repoRuleBranchNameFailures: new RepoRulesMetadataFailures(), + } + } + + // Persist our current commit message if the caller wants to + public componentWillUnmount() { + const { props, state } = this + props.onPersistCommitMessage?.(pick(state, 'summary', 'description')) + window.removeEventListener('keydown', this.onKeyDown) + } + + public async componentDidMount() { + window.addEventListener('keydown', this.onKeyDown) + await this.updateRepoRuleFailures(undefined, undefined, true) + } + + /** + * Special case for the summary/description being reset (empty) after a commit + * and the commit state changing thereafter, needing a sync with incoming props. + * We prefer the current UI state values if the user updated them manually. + * + * NOTE: although using the lifecycle method is generally an anti-pattern, we + * (and the React docs) believe it to be the right answer for this situation, see: + * https://reactjs.org/docs/react-component.html#unsafe_componentwillreceiveprops + */ + public componentWillReceiveProps(nextProps: ICommitMessageProps) { + const { commitMessage } = nextProps + + // If we switch from not amending to amending, we want to populate the + // textfields with the commit message from the commit. + if (this.props.commitToAmend === null && nextProps.commitToAmend !== null) { + this.fillWithCommitMessage({ + summary: nextProps.commitToAmend.summary, + description: nextProps.commitToAmend.body, + }) + } else if ( + this.props.commitToAmend !== null && + nextProps.commitToAmend === null && + commitMessage !== null + ) { + this.fillWithCommitMessage(commitMessage) + } + + if (!commitMessage || commitMessage === this.props.commitMessage) { + return + } + + if (this.state.summary === '' && !this.state.description) { + this.fillWithCommitMessage(commitMessage) + } + } + + private fillWithCommitMessage(commitMessage: ICommitMessage) { + this.setState({ + summary: commitMessage.summary, + description: commitMessage.description, + }) + } + + public async componentDidUpdate( + prevProps: ICommitMessageProps, + prevState: ICommitMessageState + ) { + if ( + this.props.autocompletionProviders !== prevProps.autocompletionProviders + ) { + this.setState({ + commitMessageAutocompletionProviders: + findCommitMessageAutoCompleteProvider( + this.props.autocompletionProviders + ), + coAuthorAutocompletionProvider: findCoAuthorAutoCompleteProvider( + this.props.autocompletionProviders + ), + }) + } + + if ( + this.props.focusCommitMessage && + this.props.focusCommitMessage !== prevProps.focusCommitMessage + ) { + this.focusSummary() + } else if ( + prevProps.showCoAuthoredBy === false && + this.isCoAuthorInputVisible && + // The co-author input could be also shown when switching between repos, + // but in that case we don't want to give the focus to the input. + prevProps.repository.id === this.props.repository.id + ) { + this.coAuthorInputRef.current?.focus() + } + + if ( + prevProps.isCommitting !== this.props.isCommitting && + this.props.isCommitting && + this.state.isCommittingStatusMessage === '' + ) { + this.setState({ isCommittingStatusMessage: this.getButtonTitle() }) + } + + if ( + prevProps.mostRecentLocalCommit?.sha !== + this.props.mostRecentLocalCommit?.sha && + this.props.mostRecentLocalCommit !== null + ) { + this.setState({ + isCommittingStatusMessage: `Committed Just now - ${this.props.mostRecentLocalCommit.summary} (Sha: ${this.props.mostRecentLocalCommit.shortSha})`, + }) + } + + await this.updateRepoRuleFailures(prevProps, prevState) + } + + private async updateRepoRuleFailures( + prevProps?: ICommitMessageProps, + prevState?: ICommitMessageState, + forceUpdate: boolean = false + ) { + let repoRulesEnabled = this.state.repoRulesEnabled + if ( + forceUpdate || + prevProps?.repository !== this.props.repository || + prevProps?.repositoryAccount !== this.props.repositoryAccount + ) { + repoRulesEnabled = useRepoRulesLogic( + this.props.repositoryAccount, + this.props.repository + ) + this.setState({ repoRulesEnabled }) + } + + if (!repoRulesEnabled) { + return + } + + await this.updateRepoRulesCommitMessageFailures( + prevProps, + prevState, + forceUpdate + ) + this.updateRepoRulesCommitAuthorFailures(prevProps, forceUpdate) + this.updateRepoRulesBranchNameFailures(prevProps, forceUpdate) + } + + private async updateRepoRulesCommitMessageFailures( + prevProps?: ICommitMessageProps, + prevState?: ICommitMessageState, + forceUpdate?: boolean + ) { + if ( + forceUpdate || + prevState?.summary !== this.state.summary || + prevState?.description !== this.state.description || + prevProps?.coAuthors !== this.props.coAuthors || + prevProps?.commitToAmend !== this.props.commitToAmend || + prevProps?.repository !== this.props.repository || + prevProps?.repoRulesInfo.commitMessagePatterns !== + this.props.repoRulesInfo.commitMessagePatterns + ) { + let summary = this.state.summary + if (!summary && !this.state.description) { + summary = this.summaryOrPlaceholder + } + + const context: ICommitContext = { + summary, + description: this.state.description, + trailers: this.getCoAuthorTrailers(), + amend: this.props.commitToAmend !== null, + } + + const msg = await formatCommitMessage(this.props.repository, context) + const failures = + this.props.repoRulesInfo.commitMessagePatterns.getFailedRules(msg) + + this.setState({ repoRuleCommitMessageFailures: failures }) + } + } + + private updateRepoRulesCommitAuthorFailures( + prevProps?: ICommitMessageProps, + forceUpdate?: boolean + ) { + if ( + forceUpdate || + prevProps?.commitAuthor?.email !== this.props.commitAuthor?.email || + prevProps?.repoRulesInfo.commitAuthorEmailPatterns !== + this.props.repoRulesInfo.commitAuthorEmailPatterns + ) { + const email = this.props.commitAuthor?.email + let failures: RepoRulesMetadataFailures + + if (!email) { + failures = new RepoRulesMetadataFailures() + } else { + failures = + this.props.repoRulesInfo.commitAuthorEmailPatterns.getFailedRules( + email + ) + } + + this.setState({ repoRuleCommitAuthorFailures: failures }) + } + } + + private updateRepoRulesBranchNameFailures( + prevProps?: ICommitMessageProps, + forceUpdate?: boolean + ) { + if ( + forceUpdate || + prevProps?.branch !== this.props.branch || + prevProps?.repoRulesInfo.branchNamePatterns !== + this.props.repoRulesInfo.branchNamePatterns + ) { + const branch = this.props.branch + let failures: RepoRulesMetadataFailures + + if (!branch) { + failures = new RepoRulesMetadataFailures() + } else { + failures = + this.props.repoRulesInfo.branchNamePatterns.getFailedRules(branch) + } + + this.setState({ repoRuleBranchNameFailures: failures }) + } + } + + private clearCommitMessage() { + this.setState({ summary: '', description: null }) + } + + private focusSummary() { + if (this.summaryTextInput !== null) { + this.summaryTextInput.focus() + this.props.onCommitMessageFocusSet() + } + } + + private onSummaryChanged = (summary: string) => { + this.setState({ summary }) + } + + private onDescriptionChanged = (description: string) => { + this.setState({ description }) + } + + private onSubmit = () => { + if ( + this.shouldWarnForRepoRuleBypass() && + this.props.repository.gitHubRepository && + this.props.branch + ) { + this.props.dispatcher.showRepoRulesCommitBypassWarning( + this.props.repository.gitHubRepository, + this.props.branch, + () => this.createCommit() + ) + } else { + this.createCommit() + } + } + + private getCoAuthorTrailers() { + const { coAuthors } = this.props + const token = 'Co-Authored-By' + return this.isCoAuthorInputEnabled + ? coAuthors + .filter(isKnownAuthor) + .map(a => ({ token, value: `${a.name} <${a.email}>` })) + : [] + } + + private get summaryOrPlaceholder() { + return this.props.prepopulateCommitSummary && !this.state.summary + ? this.props.placeholder + : this.state.summary + } + + private forceCreateCommit = async () => { + return this.createCommit(false) + } + + private async createCommit(warnUnknownAuthors: boolean = true) { + const { description } = this.state + + if (!this.canCommit() && !this.canAmend()) { + return + } + + if (warnUnknownAuthors) { + const unknownAuthors = this.props.coAuthors.filter( + (author): author is UnknownAuthor => !isKnownAuthor(author) + ) + + if (unknownAuthors.length > 0) { + this.props.onConfirmCommitWithUnknownCoAuthors( + unknownAuthors, + this.forceCreateCommit + ) + return + } + } + + const trailers = this.getCoAuthorTrailers() + + const commitContext = { + summary: this.summaryOrPlaceholder, + description, + trailers, + amend: this.props.commitToAmend !== null, + } + + const timer = startTimer('create commit', this.props.repository) + const commitCreated = await this.props.onCreateCommit(commitContext) + timer.done() + + if (commitCreated) { + this.clearCommitMessage() + } + } + + private canCommit(): boolean { + return ( + ((this.props.anyFilesSelected === true && + this.state.summary.length > 0) || + this.props.prepopulateCommitSummary) && + !this.hasRepoRuleFailure() + ) + } + + private canAmend(): boolean { + return ( + this.props.commitToAmend !== null && + (this.state.summary.length > 0 || this.props.prepopulateCommitSummary) && + !this.hasRepoRuleFailure() + ) + } + + /** + * Whether the user will be prevented from pushing this commit due to a repo rule failure. + */ + private hasRepoRuleFailure(): boolean { + const { aheadBehind, repoRulesInfo } = this.props + + if (!this.state.repoRulesEnabled) { + return false + } + + return ( + repoRulesInfo.basicCommitWarning === true || + repoRulesInfo.pullRequestRequired === true || + this.state.repoRuleCommitMessageFailures.status === 'fail' || + this.state.repoRuleCommitAuthorFailures.status === 'fail' || + (aheadBehind === null && + (repoRulesInfo.creationRestricted === true || + this.state.repoRuleBranchNameFailures.status === 'fail')) + ) + } + + /** + * If true, then rules exist for the branch but the user is bypassing all of them. + * Used to display a confirmation prompt. + */ + private shouldWarnForRepoRuleBypass(): boolean { + const { aheadBehind, branch, repoRulesInfo } = this.props + + if (!this.state.repoRulesEnabled) { + return false + } + + // if all rules pass, then nothing to warn about. if at least one rule fails, then the user won't hit this + // in the first place because the button will be disabled. therefore, only need to check if any single + // value is 'bypass'. + + if ( + repoRulesInfo.basicCommitWarning === 'bypass' || + repoRulesInfo.pullRequestRequired === 'bypass' + ) { + return true + } + + if ( + this.state.repoRuleCommitMessageFailures.status === 'bypass' || + this.state.repoRuleCommitAuthorFailures.status === 'bypass' + ) { + return true + } + + return ( + aheadBehind === null && + branch !== null && + (repoRulesInfo.creationRestricted === 'bypass' || + this.state.repoRuleBranchNameFailures.status === 'bypass') + ) + } + + private canExcecuteCommitShortcut(): boolean { + return !this.props.isShowingFoldout && !this.props.isShowingModal + } + + private onKeyDown = (event: React.KeyboardEvent | KeyboardEvent) => { + if (event.defaultPrevented) { + return + } + + const isShortcutKey = __DARWIN__ ? event.metaKey : event.ctrlKey + if ( + isShortcutKey && + event.key === 'Enter' && + (this.canCommit() || this.canAmend()) && + this.canExcecuteCommitShortcut() + ) { + this.createCommit() + event.preventDefault() + } + } + + private renderAvatar() { + const { commitAuthor, repository } = this.props + const { gitHubRepository } = repository + const avatarUser: IAvatarUser | undefined = + commitAuthor !== null + ? getAvatarUserFromAuthor(commitAuthor, gitHubRepository) + : undefined + + const repositoryAccount = this.props.repositoryAccount + const accountEmails = repositoryAccount?.emails.map(e => e.email) ?? [] + const email = commitAuthor?.email + + let warningType: CommitMessageAvatarWarningType = 'none' + if (email !== undefined) { + if ( + this.state.repoRulesEnabled && + this.state.repoRuleCommitAuthorFailures.status !== 'pass' + ) { + warningType = 'disallowedEmail' + } else if ( + repositoryAccount !== null && + repositoryAccount !== undefined && + isAttributableEmailFor(repositoryAccount, email) === false + ) { + warningType = 'misattribution' + } + } + + return ( + + ) + } + + private onUpdateUserEmail = async (email: string) => { + await setGlobalConfigValue('user.email', email) + this.props.onRefreshAuthor() + } + + private onOpenRepositorySettings = () => { + this.props.onShowPopup({ + type: PopupType.RepositorySettings, + repository: this.props.repository, + initialSelectedTab: RepositorySettingsTab.GitConfig, + }) + } + + private onOpenGitSettings = () => { + this.props.onShowPopup({ + type: PopupType.Preferences, + initialSelectedTab: PreferencesTab.Git, + }) + } + + private get isCoAuthorInputEnabled() { + return this.props.repository.gitHubRepository !== null + } + + private get isCoAuthorInputVisible() { + return this.props.showCoAuthoredBy && this.isCoAuthorInputEnabled + } + + private onCoAuthorsUpdated = (coAuthors: ReadonlyArray) => + this.props.onCoAuthorsUpdated(coAuthors) + + private renderCoAuthorInput() { + if (!this.isCoAuthorInputVisible) { + return null + } + + const autocompletionProvider = this.state.coAuthorAutocompletionProvider + + if (!autocompletionProvider) { + return null + } + + return ( + + ) + } + + private onToggleCoAuthors = () => { + this.props.onShowCoAuthoredByChanged(!this.props.showCoAuthoredBy) + } + + private get toggleCoAuthorsText(): string { + return this.props.showCoAuthoredBy + ? __DARWIN__ + ? 'Remove Co-Authors' + : 'Remove co-authors' + : __DARWIN__ + ? 'Add Co-Authors' + : 'Add co-authors' + } + + private getAddRemoveCoAuthorsMenuItem(): IMenuItem { + return { + label: this.toggleCoAuthorsText, + action: this.onToggleCoAuthors, + enabled: + this.props.repository.gitHubRepository !== null && + this.props.isCommitting !== true, + } + } + + private onContextMenu = (event: React.MouseEvent) => { + if ( + event.target instanceof HTMLTextAreaElement || + event.target instanceof HTMLInputElement + ) { + return + } + + showContextualMenu([this.getAddRemoveCoAuthorsMenuItem()]) + } + + private onAutocompletingInputContextMenu = () => { + const items: IMenuItem[] = [ + this.getAddRemoveCoAuthorsMenuItem(), + { type: 'separator' }, + { role: 'editMenu' }, + { type: 'separator' }, + ] + + items.push( + this.getCommitSpellcheckEnabilityMenuItem( + this.props.commitSpellcheckEnabled + ) + ) + + showContextualMenu(items, true) + } + + private getCommitSpellcheckEnabilityMenuItem(isEnabled: boolean): IMenuItem { + const enableLabel = __DARWIN__ + ? 'Enable Commit Spellcheck' + : 'Enable commit spellcheck' + const disableLabel = __DARWIN__ + ? 'Disable Commit Spellcheck' + : 'Disable commit spellcheck' + return { + label: isEnabled ? disableLabel : enableLabel, + action: () => this.props.onCommitSpellcheckEnabledChanged(!isEnabled), + } + } + + private onCoAuthorToggleButtonClick = ( + e: React.MouseEvent + ) => { + e.preventDefault() + this.onToggleCoAuthors() + } + + private renderCoAuthorToggleButton() { + if (this.props.repository.gitHubRepository === null) { + return null + } + + return ( + + ) + } + + private onDescriptionFieldRef = ( + component: AutocompletingTextArea | null + ) => { + this.descriptionComponent = component + } + + private onDescriptionTextAreaScroll = () => { + this.descriptionTextAreaScrollDebounceId = null + + const elem = this.descriptionTextArea + const descriptionObscured = + elem !== null && elem.scrollTop + elem.offsetHeight < elem.scrollHeight + + if (this.state.descriptionObscured !== descriptionObscured) { + this.setState({ descriptionObscured }) + } + } + + private onDescriptionTextAreaRef = (elem: HTMLTextAreaElement | null) => { + if (elem) { + const checkDescriptionScrollState = () => { + if (this.descriptionTextAreaScrollDebounceId !== null) { + cancelAnimationFrame(this.descriptionTextAreaScrollDebounceId) + this.descriptionTextAreaScrollDebounceId = null + } + this.descriptionTextAreaScrollDebounceId = requestAnimationFrame( + this.onDescriptionTextAreaScroll + ) + } + elem.addEventListener('input', checkDescriptionScrollState) + elem.addEventListener('scroll', checkDescriptionScrollState) + } + + this.descriptionTextArea = elem + } + + private onSummaryInputRef = (elem: HTMLInputElement | null) => { + this.summaryTextInput = elem + } + + private onFocusContainerClick = (event: React.MouseEvent) => { + if (this.descriptionComponent) { + this.descriptionComponent.focus() + } + } + + /** + * Whether or not there's anything to render in the action bar + */ + private get isActionBarEnabled() { + return this.isCoAuthorInputEnabled + } + + private renderActionBar() { + if (!this.isCoAuthorInputEnabled) { + return null + } + + const className = classNames('action-bar', { + disabled: this.props.isCommitting === true, + }) + + return
{this.renderCoAuthorToggleButton()}
+ } + + private renderAmendCommitNotice() { + const { commitToAmend } = this.props + + if (commitToAmend !== null) { + return ( + + Your changes will modify your most recent commit.{' '} + + Stop amending + {' '} + to make these changes as a new commit. + + ) + } else { + return null + } + } + + private renderBranchProtectionsRepoRulesCommitWarning() { + const { + showNoWriteAccess, + showBranchProtected, + repoRulesInfo, + aheadBehind, + repository, + branch, + } = this.props + + const { repoRuleBranchNameFailures, repoRulesEnabled } = this.state + + // if one of these is not bypassable, then a failure message needs to be shown rather than just displaying + // the first one in the if statement. + let repoRuleWarningToDisplay: 'publish' | 'basic' | null = null + + if (repoRulesEnabled) { + let publishStatus: RepoRuleEnforced = false + const basicStatus = repoRulesInfo.basicCommitWarning + + if (aheadBehind === null && branch !== null) { + if ( + repoRulesInfo.creationRestricted === true || + repoRuleBranchNameFailures.status === 'fail' + ) { + publishStatus = true + } else if ( + repoRulesInfo.creationRestricted === 'bypass' || + repoRuleBranchNameFailures.status === 'bypass' + ) { + publishStatus = 'bypass' + } else { + publishStatus = false + } + } + + if (publishStatus === true && basicStatus) { + repoRuleWarningToDisplay = 'publish' + } else if (basicStatus === true) { + repoRuleWarningToDisplay = 'basic' + } else if (publishStatus) { + repoRuleWarningToDisplay = 'publish' + } else if (basicStatus) { + repoRuleWarningToDisplay = 'basic' + } + } + + if (showNoWriteAccess) { + return ( + + You don't have write access to {repository.name}. + Want to{' '} + + create a fork + + ? + + ) + } else if (showBranchProtected) { + if (branch === null) { + // If the branch is null that means we haven't loaded the tip yet or + // we're on a detached head. We shouldn't ever end up here with + // showBranchProtected being true without a branch but who knows + // what fun and exciting edge cases the future might hold + return null + } + + return ( + + {branch} is a protected branch. Want to{' '} + switch branches + ? + + ) + } else if (repoRuleWarningToDisplay === 'publish') { + const canBypass = !( + repoRulesInfo.creationRestricted === true || + this.state.repoRuleBranchNameFailures.status === 'fail' + ) + + return ( + + The branch name {branch} fails{' '} + + one or more rules + {' '} + that {canBypass ? 'would' : 'will'} prevent it from being published + {canBypass && ', but you can bypass them. Proceed with caution!'} + {!canBypass && ( + <> + . Want to{' '} + + switch branches + + ? + + )} + + ) + } else if (repoRuleWarningToDisplay === 'basic') { + const canBypass = repoRulesInfo.basicCommitWarning === 'bypass' + + return ( + + + One or more rules + {' '} + apply to the branch {branch} that{' '} + {canBypass ? 'would' : 'will'} prevent pushing + {canBypass && ', but you can bypass them. Proceed with caution!'} + {!canBypass && ( + <> + . Want to{' '} + + switch branches + + ? + + )} + + ) + } else { + return null + } + } + + private renderRuleFailurePopover() { + const { branch, repository } = this.props + + // the failure status is checked here separately from whether the popover is open. if the + // user has it open but rules pass as they're typing, then keep the popover logic open + // but just don't render it. as they keep typing, if the message fails again, then the + // popover will open back up. + if ( + !branch || + !repository.gitHubRepository || + !this.state.repoRulesEnabled || + this.state.repoRuleCommitMessageFailures.status === 'pass' + ) { + return + } + + const header = __DARWIN__ + ? 'Commit Message Rule Failures' + : 'Commit message rule failures' + return ( + +

{header}

+ + +
+ ) + } + + private toggleRuleFailurePopover = () => { + this.setState({ + isRuleFailurePopoverOpen: !this.state.isRuleFailurePopoverOpen, + }) + } + + public closeRuleFailurePopover = () => { + this.setState({ isRuleFailurePopoverOpen: false }) + } + + private onSwitchBranch = () => { + this.props.onShowFoldout({ type: FoldoutType.Branch }) + } + + private getButtonVerb() { + const { isCommitting, commitToAmend } = this.props + + const amendVerb = isCommitting ? 'Amending' : 'Amend' + const commitVerb = isCommitting ? 'Committing' : 'Commit' + const isAmending = commitToAmend !== null + + return isAmending ? amendVerb : commitVerb + } + + private getCommittingButtonText() { + const { branch } = this.props + const verb = this.getButtonVerb() + + if (branch === null) { + return verb + } + + return ( + <> + {verb} to {branch} + + ) + } + + private getCommittingButtonTitle() { + const { branch } = this.props + const verb = this.getButtonVerb() + + if (branch === null) { + return verb + } + + return `${verb} to ${branch}` + } + + private getButtonText() { + const { commitToAmend, commitButtonText } = this.props + + if (commitButtonText) { + return commitButtonText + } + + const isAmending = commitToAmend !== null + return isAmending ? this.getButtonTitle() : this.getCommittingButtonText() + } + + private getButtonTitle(): string { + const { commitToAmend, commitButtonText } = this.props + + if (commitButtonText) { + return commitButtonText + } + + const isAmending = commitToAmend !== null + return isAmending + ? `${this.getButtonVerb()} last commit` + : this.getCommittingButtonTitle() + } + + private getButtonTooltip(buttonEnabled: boolean) { + if (buttonEnabled) { + return this.getButtonTitle() + } + + const isSummaryBlank = isEmptyOrWhitespace(this.summaryOrPlaceholder) + if (isSummaryBlank) { + return `A commit summary is required to commit` + } else if (!this.props.anyFilesSelected && this.props.anyFilesAvailable) { + return `Select one or more files to commit` + } else if (this.props.isCommitting) { + return `Committing changes…` + } + + return undefined + } + + private renderSubmitButton() { + const { isCommitting } = this.props + const isSummaryBlank = isEmptyOrWhitespace(this.summaryOrPlaceholder) + const buttonEnabled = + (this.canCommit() || this.canAmend()) && !isCommitting && !isSummaryBlank + const loading = isCommitting ? : undefined + const tooltip = this.getButtonTooltip(buttonEnabled) + const commitButton = this.getButtonText() + + return ( + + ) + } + + private renderSummaryLengthHint(): JSX.Element | null { + return ( + +
+ Great commit summaries contain fewer than 50 characters +
+
+ Place extra information in the description field. +
+ + } + ariaLiveMessage={ + 'Great commit summaries contain fewer than 50 characters. Place extra information in the description field.' + } + direction={TooltipDirection.NORTH} + className="length-hint" + tooltipClassName="length-hint-tooltip" + ariaLabel="Open Summary Length Info" + > + +
+ ) + } + + private renderRepoRuleCommitMessageFailureHint(): JSX.Element | null { + // enableRepoRules FF is checked before this method + + if (this.state.repoRuleCommitMessageFailures.status === 'pass') { + return null + } + + const canBypass = + this.state.repoRuleCommitMessageFailures.status === 'bypass' + + let ariaLabelPrefix: string + let bypassMessage = '' + if (canBypass) { + ariaLabelPrefix = 'Warning' + bypassMessage = ', but you can bypass them' + } else { + ariaLabelPrefix = 'Error' + } + + return ( + + ) + } + + public render() { + const className = classNames('commit-message-component', { + 'with-action-bar': this.isActionBarEnabled, + 'with-co-authors': this.isCoAuthorInputVisible, + }) + + const descriptionClassName = classNames('description-field', { + 'with-overflow': this.state.descriptionObscured, + }) + + const showRepoRuleCommitMessageFailureHint = + this.state.repoRulesEnabled && + this.state.repoRuleCommitMessageFailures.status !== 'pass' + + const showSummaryLengthHint = + !showRepoRuleCommitMessageFailureHint && + this.state.summary.length > IdealSummaryLength + + const summaryClassName = classNames('summary', { + 'with-trailing-icon': + showRepoRuleCommitMessageFailureHint || showSummaryLengthHint, + }) + const summaryInputClassName = classNames('summary-field', 'nudge-arrow', { + 'nudge-arrow-left': this.props.shouldNudge === true, + }) + + const ariaDescribedBy = showRepoRuleCommitMessageFailureHint + ? this.COMMIT_MSG_ERROR_BTN_ID + : undefined + + const { placeholder, isCommitting, commitSpellcheckEnabled } = this.props + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
+
+ {this.renderAvatar()} + + + {showRepoRuleCommitMessageFailureHint && + this.renderRepoRuleCommitMessageFailureHint()} + {showSummaryLengthHint && this.renderSummaryLengthHint()} +
+ + {this.state.isRuleFailurePopoverOpen && this.renderRuleFailurePopover()} + + + + {this.renderActionBar()} + + + {this.renderCoAuthorInput()} + + {this.renderAmendCommitNotice()} + {this.renderBranchProtectionsRepoRulesCommitWarning()} + + {this.renderSubmitButton()} + + {this.state.isCommittingStatusMessage} + +
+ ) + } +} diff --git a/app/src/ui/changes/commit-warning.tsx b/app/src/ui/changes/commit-warning.tsx new file mode 100644 index 0000000000..51e8087f7f --- /dev/null +++ b/app/src/ui/changes/commit-warning.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import { assertNever } from '../../lib/fatal-error' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' + +export enum CommitWarningIcon { + Warning, + Information, + Error, +} + +const renderIcon = (icon: CommitWarningIcon) => { + let className = '' + let symbol = OcticonSymbol.alert + + switch (icon) { + case CommitWarningIcon.Warning: + className = 'warning-icon' + symbol = OcticonSymbol.alert + break + case CommitWarningIcon.Information: + className = 'information-icon' + symbol = OcticonSymbol.info + break + case CommitWarningIcon.Error: + className = 'error-icon' + symbol = OcticonSymbol.stop + break + default: + assertNever(icon, `Unexpected icon value ${icon}`) + } + + return +} + +/** A warning displayed above the commit button + */ +export const CommitWarning: React.FunctionComponent<{ + readonly icon: CommitWarningIcon +}> = props => { + return ( +
+
{renderIcon(props.icon)}
+
{props.children}
+
+ ) +} + +const ignoreContextMenu = (event: React.MouseEvent) => { + // this prevents the context menu for the root element of CommitMessage from + // firing - it shows 'Add Co-Authors' or 'Remove Co-Authors' based on the + // form state, and for now I'm going to leave that behaviour as-is + + // feel free to remove this if that behaviour is revisited + event.preventDefault() +} diff --git a/app/src/ui/changes/continue-rebase.tsx b/app/src/ui/changes/continue-rebase.tsx new file mode 100644 index 0000000000..d3e62ab5a0 --- /dev/null +++ b/app/src/ui/changes/continue-rebase.tsx @@ -0,0 +1,75 @@ +import * as React from 'react' +import { Button } from '../lib/button' +import { Loading } from '../lib/loading' +import { RebaseConflictState } from '../../lib/app-state' +import { Dispatcher } from '../dispatcher' +import { Repository } from '../../models/repository' +import { WorkingDirectoryStatus } from '../../models/status' +import { getConflictedFiles } from '../../lib/status' +import { MultiCommitOperationKind } from '../../models/multi-commit-operation' + +interface IContinueRebaseProps { + readonly dispatcher: Dispatcher + readonly repository: Repository + readonly workingDirectory: WorkingDirectoryStatus + readonly rebaseConflictState: RebaseConflictState + readonly isCommitting: boolean + readonly hasUntrackedChanges: boolean +} + +export class ContinueRebase extends React.Component { + private onSubmit = async () => { + const { rebaseConflictState } = this.props + + await this.props.dispatcher.continueRebase( + MultiCommitOperationKind.Rebase, + this.props.repository, + this.props.workingDirectory, + rebaseConflictState + ) + } + + public render() { + const { manualResolutions } = this.props.rebaseConflictState + + let canCommit = true + let tooltip = 'Continue rebase' + + const conflictedFilesCount = getConflictedFiles( + this.props.workingDirectory, + manualResolutions + ).length + + if (conflictedFilesCount > 0) { + tooltip = 'Resolve all conflicts before continuing' + canCommit = false + } + + const buttonEnabled = canCommit && !this.props.isCommitting + + const loading = this.props.isCommitting ? : undefined + + const warnAboutUntrackedFiles = this.props.hasUntrackedChanges ? ( +
+ Untracked files will be excluded +
+ ) : undefined + + return ( +
+ + + {warnAboutUntrackedFiles} +
+ ) + } +} diff --git a/app/src/ui/changes/files-changed-badge.tsx b/app/src/ui/changes/files-changed-badge.tsx new file mode 100644 index 0000000000..cb84fdd59a --- /dev/null +++ b/app/src/ui/changes/files-changed-badge.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' + +interface IFilesChangedBadgeProps { + readonly filesChangedCount: number +} + +/** The number that can be displayed as a specific value */ +const MaximumChangesCount = 300 + +/** Displays number of files that have changed */ +export class FilesChangedBadge extends React.Component< + IFilesChangedBadgeProps, + {} +> { + public render() { + const filesChangedCount = this.props.filesChangedCount + const badgeCount = + filesChangedCount > MaximumChangesCount + ? `${MaximumChangesCount}+` + : filesChangedCount + + return {badgeCount} + } +} diff --git a/app/src/ui/changes/index.ts b/app/src/ui/changes/index.ts new file mode 100644 index 0000000000..90db3cf8e7 --- /dev/null +++ b/app/src/ui/changes/index.ts @@ -0,0 +1,2 @@ +export { ChangesSidebar } from './sidebar' +export { Changes } from './changes' diff --git a/app/src/ui/changes/multiple-selection.tsx b/app/src/ui/changes/multiple-selection.tsx new file mode 100644 index 0000000000..aae914d624 --- /dev/null +++ b/app/src/ui/changes/multiple-selection.tsx @@ -0,0 +1,26 @@ +import * as React from 'react' +import { encodePathAsUrl } from '../../lib/path' + +const BlankSlateImage = encodePathAsUrl( + __dirname, + 'static/multiple-files-selected.svg' +) + +interface IMultipleSelectionProps { + /** Called when the user chooses to open the repository. */ + readonly count: number +} +/** The component to display when there are no local changes. */ +export class MultipleSelection extends React.Component< + IMultipleSelectionProps, + {} +> { + public render() { + return ( +
+ +
{this.props.count} files selected
+
+ ) + } +} diff --git a/app/src/ui/changes/no-changes.tsx b/app/src/ui/changes/no-changes.tsx new file mode 100644 index 0000000000..135d44baa1 --- /dev/null +++ b/app/src/ui/changes/no-changes.tsx @@ -0,0 +1,782 @@ +import * as React from 'react' + +import { encodePathAsUrl } from '../../lib/path' +import { Repository } from '../../models/repository' +import { LinkButton } from '../lib/link-button' +import { MenuIDs } from '../../models/menu-ids' +import { IMenu, MenuItem } from '../../models/app-menu' +import memoizeOne from 'memoize-one' +import { getPlatformSpecificNameOrSymbolForModifier } from '../../lib/menu-item' +import { MenuBackedSuggestedAction } from '../suggested-actions' +import { IRepositoryState } from '../../lib/app-state' +import { TipState, IValidBranch } from '../../models/tip' +import { Ref } from '../lib/ref' +import { IAheadBehind } from '../../models/branch' +import { IRemote } from '../../models/remote' +import { + ForcePushBranchState, + getCurrentBranchForcePushState, +} from '../../lib/rebase' +import { StashedChangesLoadStates } from '../../models/stash-entry' +import { Dispatcher } from '../dispatcher' +import { SuggestedActionGroup } from '../suggested-actions' +import { PreferencesTab } from '../../models/preferences' +import { PopupType } from '../../models/popup' +import { + DropdownSuggestedAction, + IDropdownSuggestedActionOption, +} from '../suggested-actions/dropdown-suggested-action' +import { + PullRequestSuggestedNextAction, + isIdPullRequestSuggestedNextAction, +} from '../../models/pull-request' +import { KeyboardShortcut } from '../keyboard-shortcut/keyboard-shortcut' + +function formatMenuItemLabel(text: string) { + if (__WIN32__ || __LINUX__) { + // Ampersand has a special meaning on Windows where it denotes + // the access key (usually rendered as an underline on the following) + // character. A literal ampersand is escaped by putting another ampersand + // in front of it (&&). Here we strip single ampersands and unescape + // double ampersands. Example: "&Push && Pull" becomes "Push & Pull". + return text.replace(/&?&/g, m => (m.length > 1 ? '&' : '')) + } + + return text +} + +function formatParentMenuLabel(menuItem: IMenuItemInfo) { + const parentMenusText = menuItem.parentMenuLabels.join(' -> ') + return formatMenuItemLabel(parentMenusText) +} + +const PaperStackImage = encodePathAsUrl(__dirname, 'static/paper-stack.svg') + +interface INoChangesProps { + readonly dispatcher: Dispatcher + + /** + * The currently selected repository + */ + readonly repository: Repository + + /** + * The top-level application menu item. + */ + readonly appMenu: IMenu | undefined + + /** + * An object describing the current state of + * the selected repository. Used to determine + * whether to render push, pull, publish, or + * 'open pr' actions. + */ + readonly repositoryState: IRepositoryState + + /** + * Whether or not the user has a configured (explicitly, + * or automatically) external editor. Used to + * determine whether or not to render the action for + * opening the repository in an external editor. + */ + readonly isExternalEditorAvailable: boolean + + /** The user's preference of pull request suggested next action to use **/ + readonly pullRequestSuggestedNextAction?: PullRequestSuggestedNextAction +} + +/** + * Helper projection interface used to hold + * computed information about a particular menu item. + * Used internally in the NoChanges component to + * trace whether a menu item is enabled, what its + * keyboard shortcut is and so forth. + */ +interface IMenuItemInfo { + /** + * The textual representation of the menu item, + * this is what's shown in the application menu + */ + readonly label: string + + /** + * Any accelerator keys (i.e. keyboard shortcut) + * for the menu item. A menu item which can be + * triggered using Command+Shift+K would be + * represented here as three elements in the + * array. Used to format and display the keyboard + * shortcut for activating an action. + */ + readonly acceleratorKeys: ReadonlyArray + + /** + * An ordered list of the labels for parent menus + * of a particular menu item. Used to provide + * a textual representation of where to locate + * a particular action in the menu system. + */ + readonly parentMenuLabels: ReadonlyArray + + /** + * Whether or not the menu item is currently + * enabled. + */ + readonly enabled: boolean +} + +interface INoChangesState { + /** + * Whether or not to enable the slide in and + * slide out transitions for the remote actions. + * + * Disabled initially and enabled 500ms after + * component mounting in order to provide instant + * loading of the remote action when the view is + * initially appearing. + */ + readonly enableTransitions: boolean +} + +function getItemAcceleratorKeys(item: MenuItem) { + if (item.type === 'separator' || item.type === 'submenuItem') { + return [] + } + + if (item.accelerator === null) { + return [] + } + + return item.accelerator + .split('+') + .map(getPlatformSpecificNameOrSymbolForModifier) +} + +function buildMenuItemInfoMap( + menu: IMenu, + map = new Map(), + parent?: IMenuItemInfo +): ReadonlyMap { + for (const item of menu.items) { + if (item.type === 'separator') { + continue + } + + const infoItem: IMenuItemInfo = { + label: item.label as string, + acceleratorKeys: getItemAcceleratorKeys(item), + parentMenuLabels: + parent === undefined ? [] : [parent.label, ...parent.parentMenuLabels], + enabled: item.enabled, + } + + map.set(item.id, infoItem) + + if (item.type === 'submenuItem') { + buildMenuItemInfoMap(item.menu, map, infoItem) + } + } + + return map +} + +/** The component to display when there are no local changes. */ +export class NoChanges extends React.Component< + INoChangesProps, + INoChangesState +> { + private getMenuInfoMap = memoizeOne((menu: IMenu | undefined) => + menu === undefined + ? new Map() + : buildMenuItemInfoMap(menu) + ) + + /** + * ID for the timer that's activated when the component + * mounts. See componentDidMount/componentWillUnmount. + */ + private transitionTimer: number | null = null + + public constructor(props: INoChangesProps) { + super(props) + this.state = { + enableTransitions: false, + } + } + + private getMenuItemInfo(menuItemId: MenuIDs): IMenuItemInfo | undefined { + return this.getMenuInfoMap(this.props.appMenu).get(menuItemId) + } + + private getPlatformFileManagerName() { + if (__DARWIN__) { + return 'Finder' + } else if (__WIN32__) { + return 'Explorer' + } + return 'your File Manager' + } + + private renderDiscoverabilityElements(menuItem: IMenuItemInfo) { + const parentMenusText = formatParentMenuLabel(menuItem) + + return ( + <> + {parentMenusText} menu or{' '} + {this.renderDiscoverabilityKeyboardShortcut(menuItem)} + + ) + } + + private renderDiscoverabilityKeyboardShortcut(menuItemInfo: IMenuItemInfo) { + return ( + + ) + } + + private renderMenuBackedAction( + itemId: MenuIDs, + title: string, + description?: string | JSX.Element, + onClick?: (e: React.MouseEvent) => void + ) { + const menuItem = this.getMenuItemInfo(itemId) + + if (menuItem === undefined) { + log.error(`Could not find matching menu item for ${itemId}`) + return null + } + + return ( + + ) + } + + private renderShowInFileManager() { + const fileManager = this.getPlatformFileManagerName() + + return this.renderMenuBackedAction( + 'open-working-directory', + `View the files of your repository in ${fileManager}`, + undefined, + this.onShowInFileManagerClicked + ) + } + + private onShowInFileManagerClicked = () => + this.props.dispatcher.recordSuggestedStepOpenWorkingDirectory() + + private renderViewOnGitHub() { + const isGitHub = this.props.repository.gitHubRepository !== null + + if (!isGitHub) { + return null + } + + return this.renderMenuBackedAction( + 'view-repository-on-github', + `Open the repository page on GitHub in your browser`, + undefined, + this.onViewOnGitHubClicked + ) + } + + private onViewOnGitHubClicked = () => + this.props.dispatcher.recordSuggestedStepViewOnGitHub() + + private openIntegrationPreferences = () => { + this.props.dispatcher.showPopup({ + type: PopupType.Preferences, + initialSelectedTab: PreferencesTab.Integrations, + }) + } + + private renderOpenInExternalEditor() { + if (!this.props.isExternalEditorAvailable) { + return null + } + + const itemId: MenuIDs = 'open-external-editor' + const menuItem = this.getMenuItemInfo(itemId) + + if (menuItem === undefined) { + log.error(`Could not find matching menu item for ${itemId}`) + return null + } + + const preferencesMenuItem = this.getMenuItemInfo('preferences') + + if (preferencesMenuItem === undefined) { + log.error(`Could not find matching menu item for ${itemId}`) + return null + } + + const title = `Open the repository in your external editor` + + const description = ( + <> + Select your editor in{' '} + + {__DARWIN__ ? 'Preferences' : 'Options'} + + + ) + + return this.renderMenuBackedAction( + itemId, + title, + description, + this.onOpenInExternalEditorClicked + ) + } + + private onOpenInExternalEditorClicked = () => + this.props.dispatcher.recordSuggestedStepOpenInExternalEditor() + + private renderRemoteAction() { + const { remote, aheadBehind, branchesState, tagsToPush } = + this.props.repositoryState + const { tip, defaultBranch, currentPullRequest } = branchesState + + if (tip.kind !== TipState.Valid) { + return null + } + + if (remote === null) { + return this.renderPublishRepositoryAction() + } + + // Branch not published + if (aheadBehind === null) { + return this.renderPublishBranchAction(tip) + } + + const isForcePush = + getCurrentBranchForcePushState(branchesState, aheadBehind) === + ForcePushBranchState.Recommended + if (isForcePush) { + // do not render an action currently after the rebase has completed, as + // the default behaviour is currently to pull in changes from the tracking + // branch which will could potentially lead to a more confusing history + return null + } + + if (aheadBehind.behind > 0) { + return this.renderPullBranchAction(tip, remote, aheadBehind) + } + + if ( + aheadBehind.ahead > 0 || + (tagsToPush !== null && tagsToPush.length > 0) + ) { + return this.renderPushBranchAction(tip, remote, aheadBehind, tagsToPush) + } + + const isGitHub = this.props.repository.gitHubRepository !== null + const hasOpenPullRequest = currentPullRequest !== null + const isDefaultBranch = + defaultBranch !== null && tip.branch.name === defaultBranch.name + + if (isGitHub && !hasOpenPullRequest && !isDefaultBranch) { + return this.renderCreatePullRequestAction(tip) + } + + return null + } + + private renderViewStashAction() { + const { changesState, branchesState } = this.props.repositoryState + + const { tip } = branchesState + if (tip.kind !== TipState.Valid) { + return null + } + + const { stashEntry } = changesState + if (stashEntry === null) { + return null + } + + if (stashEntry.files.kind !== StashedChangesLoadStates.Loaded) { + return null + } + + const numChanges = stashEntry.files.files.length + const description = ( + <> + You have {numChanges} {numChanges === 1 ? 'change' : 'changes'} in + progress that you have not yet committed. + + ) + const discoverabilityContent = ( + <> + When a stash exists, access it at the bottom of the Changes tab to the + left. + + ) + const itemId: MenuIDs = 'toggle-stashed-changes' + const menuItem = this.getMenuItemInfo(itemId) + if (menuItem === undefined) { + log.error(`Could not find matching menu item for ${itemId}`) + return null + } + + return ( + + ) + } + + private onViewStashClicked = () => + this.props.dispatcher.recordSuggestedStepViewStash() + + private renderPublishRepositoryAction() { + // This is a bit confusing, there's no dedicated + // publish menu item, the 'Push' menu item will initiate + // a publish if the repository doesn't have a remote. We'll + // use it here for the keyboard shortcut only. + const itemId: MenuIDs = 'push' + const menuItem = this.getMenuItemInfo(itemId) + + if (menuItem === undefined) { + log.error(`Could not find matching menu item for ${itemId}`) + return null + } + + const discoverabilityContent = ( + <> + Always available in the toolbar for local repositories or{' '} + {this.renderDiscoverabilityKeyboardShortcut(menuItem)} + + ) + + return ( + + ) + } + + private onPublishRepositoryClicked = () => + this.props.dispatcher.recordSuggestedStepPublishRepository() + + private renderPublishBranchAction(tip: IValidBranch) { + // This is a bit confusing, there's no dedicated + // publish branch menu item, the 'Push' menu item will initiate + // a publish if the branch doesn't have a remote tracking branch. + // We'll use it here for the keyboard shortcut only. + const itemId: MenuIDs = 'push' + const menuItem = this.getMenuItemInfo(itemId) + + if (menuItem === undefined) { + log.error(`Could not find matching menu item for ${itemId}`) + return null + } + + const isGitHub = this.props.repository.gitHubRepository !== null + + const description = ( + <> + The current branch ({tip.branch.name}) hasn't been published + to the remote yet. By publishing it {isGitHub ? 'to GitHub' : ''} you + can share it, {isGitHub ? 'open a pull request, ' : ''} + and collaborate with others. + + ) + + const discoverabilityContent = ( + <> + Always available in the toolbar or{' '} + {this.renderDiscoverabilityKeyboardShortcut(menuItem)} + + ) + + return ( + + ) + } + + private onPublishBranchClicked = () => + this.props.dispatcher.recordSuggestedStepPublishBranch() + + private renderPullBranchAction( + tip: IValidBranch, + remote: IRemote, + aheadBehind: IAheadBehind + ) { + const itemId: MenuIDs = 'pull' + const menuItem = this.getMenuItemInfo(itemId) + + if (menuItem === undefined) { + log.error(`Could not find matching menu item for ${itemId}`) + return null + } + + const isGitHub = this.props.repository.gitHubRepository !== null + + const description = ( + <> + The current branch ({tip.branch.name}) has{' '} + {aheadBehind.behind === 1 ? 'a commit' : 'commits'} on{' '} + {isGitHub ? 'GitHub' : 'the remote'} that{' '} + {aheadBehind.behind === 1 ? 'does not' : 'do not'} exist on your + machine. + + ) + + const discoverabilityContent = ( + <> + Always available in the toolbar when there are remote changes or{' '} + {this.renderDiscoverabilityKeyboardShortcut(menuItem)} + + ) + + const title = `Pull ${aheadBehind.behind} ${ + aheadBehind.behind === 1 ? 'commit' : 'commits' + } from the ${remote.name} remote` + + const buttonText = `Pull ${remote.name}` + + return ( + + ) + } + + private renderPushBranchAction( + tip: IValidBranch, + remote: IRemote, + aheadBehind: IAheadBehind, + tagsToPush: ReadonlyArray | null + ) { + const itemId: MenuIDs = 'push' + const menuItem = this.getMenuItemInfo(itemId) + + if (menuItem === undefined) { + log.error(`Could not find matching menu item for ${itemId}`) + return null + } + + const isGitHub = this.props.repository.gitHubRepository !== null + + const itemsToPushTypes = [] + const itemsToPushDescriptions = [] + + if (aheadBehind.ahead > 0) { + itemsToPushTypes.push('commits') + itemsToPushDescriptions.push( + aheadBehind.ahead === 1 + ? '1 local commit' + : `${aheadBehind.ahead} local commits` + ) + } + + if (tagsToPush !== null && tagsToPush.length > 0) { + itemsToPushTypes.push('tags') + itemsToPushDescriptions.push( + tagsToPush.length === 1 ? '1 tag' : `${tagsToPush.length} tags` + ) + } + + const description = `You have ${itemsToPushDescriptions.join( + ' and ' + )} waiting to be pushed to ${isGitHub ? 'GitHub' : 'the remote'}.` + + const discoverabilityContent = ( + <> + Always available in the toolbar when there are local commits waiting to + be pushed or {this.renderDiscoverabilityKeyboardShortcut(menuItem)} + + ) + + const title = `Push ${itemsToPushTypes.join(' and ')} to the ${ + remote.name + } remote` + + const buttonText = `Push ${remote.name}` + + return ( + + ) + } + + private onPullRequestSuggestedActionChanged = (action: string) => { + if (isIdPullRequestSuggestedNextAction(action)) { + this.props.dispatcher.setPullRequestSuggestedNextAction(action) + } + } + + private renderCreatePullRequestAction(tip: IValidBranch) { + const createMenuItem = this.getMenuItemInfo('create-pull-request') + if (createMenuItem === undefined) { + log.error(`Could not find matching menu item for 'create-pull-request'`) + return null + } + + const description = ( + <> + The current branch ({tip.branch.name}) is already published + to GitHub. Create a pull request to propose and collaborate on your + changes. + + ) + + const title = `Create a Pull Request from your current branch` + const buttonText = `Create Pull Request` + + const previewPullMenuItem = this.getMenuItemInfo('preview-pull-request') + + if (previewPullMenuItem === undefined) { + log.error(`Could not find matching menu item for 'preview-pull-request'`) + return null + } + + const createPullRequestAction: IDropdownSuggestedActionOption = { + title, + label: buttonText, + description, + id: PullRequestSuggestedNextAction.CreatePullRequest, + menuItemId: 'create-pull-request', + discoverabilityContent: + this.renderDiscoverabilityElements(createMenuItem), + disabled: !createMenuItem.enabled, + onClick: this.onCreatePullRequestClicked, + } + + const previewPullRequestAction: IDropdownSuggestedActionOption = { + title: `Preview the Pull Request from your current branch`, + label: 'Preview Pull Request', + description: ( + <> + The current branch ({tip.branch.name}) is already published + to GitHub. Preview the changes this pull request will have before + proposing your changes. + + ), + id: PullRequestSuggestedNextAction.PreviewPullRequest, + menuItemId: 'preview-pull-request', + discoverabilityContent: + this.renderDiscoverabilityElements(previewPullMenuItem), + disabled: !previewPullMenuItem.enabled, + } + + return ( + + ) + } + + private onCreatePullRequestClicked = () => + this.props.dispatcher.recordSuggestedStepCreatePullRequest() + + private renderActions() { + return ( + <> + + {this.renderViewStashAction() || this.renderRemoteAction()} + + + {this.renderOpenInExternalEditor()} + {this.renderShowInFileManager()} + {this.renderViewOnGitHub()} + + + ) + } + + public componentDidMount() { + this.transitionTimer = window.setTimeout(() => { + this.setState({ enableTransitions: true }) + this.transitionTimer = null + }, 500) + } + + public componentWillUnmount() { + if (this.transitionTimer !== null) { + clearTimeout(this.transitionTimer) + } + } + + public render() { + return ( +
+
+
+
+

No local changes

+

+ There are no uncommitted changes in this repository. Here are + some friendly suggestions for what to do next. +

+
+ +
+ {this.renderActions()} +
+
+ ) + } +} diff --git a/app/src/ui/changes/oversized-files-warning.tsx b/app/src/ui/changes/oversized-files-warning.tsx new file mode 100644 index 0000000000..bae9acf2b8 --- /dev/null +++ b/app/src/ui/changes/oversized-files-warning.tsx @@ -0,0 +1,90 @@ +import * as React from 'react' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { LinkButton } from '../lib/link-button' +import { PathText } from '../lib/path-text' +import { Dispatcher } from '../dispatcher' +import { Repository } from '../../models/repository' +import { ICommitContext } from '../../models/commit' +import { DefaultCommitMessage } from '../../models/commit-message' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +const GitLFSWebsiteURL = + 'https://help.github.com/articles/versioning-large-files/' + +interface IOversizedFilesProps { + readonly oversizedFiles: ReadonlyArray + readonly onDismissed: () => void + readonly dispatcher: Dispatcher + readonly context: ICommitContext + readonly repository: Repository +} + +/** A dialog to display a list of files that are too large to commit. */ +export class OversizedFiles extends React.Component { + public constructor(props: IOversizedFilesProps) { + super(props) + } + + public render() { + return ( + + +

+ The following files are over 100MB.{' '} + + If you commit these files, you will no longer be able to push this + repository to GitHub.com. + +

+ {this.renderFileList()} +

+ We recommend you avoid committing these files or use{' '} + Git LFS to store + large files on GitHub. +

+
+ + + + +
+ ) + } + + private renderFileList() { + return ( +
+
    + {this.props.oversizedFiles.map(fileName => ( +
  • + +
  • + ))} +
+
+ ) + } + + private onSubmit = async () => { + this.props.onDismissed() + + await this.props.dispatcher.commitIncludedChanges( + this.props.repository, + this.props.context + ) + + this.props.dispatcher.setCommitMessage( + this.props.repository, + DefaultCommitMessage + ) + } +} diff --git a/app/src/ui/changes/sidebar.tsx b/app/src/ui/changes/sidebar.tsx new file mode 100644 index 0000000000..03c2687fd9 --- /dev/null +++ b/app/src/ui/changes/sidebar.tsx @@ -0,0 +1,442 @@ +import * as Path from 'path' +import * as React from 'react' + +import { ChangesList } from './changes-list' +import { DiffSelectionType } from '../../models/diff' +import { + IChangesState, + RebaseConflictState, + isRebaseConflictState, + ChangesSelectionKind, +} from '../../lib/app-state' +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { IssuesStore, GitHubUserStore } from '../../lib/stores' +import { CommitIdentity } from '../../models/commit-identity' +import { Commit, ICommitContext } from '../../models/commit' +import { UndoCommit } from './undo-commit' +import { + buildAutocompletionProviders, + IAutocompletionProvider, +} from '../autocompletion' +import { ClickSource } from '../lib/list' +import { WorkingDirectoryFileChange } from '../../models/status' +import { TransitionGroup, CSSTransition } from 'react-transition-group' +import { openFile } from '../lib/open-file' +import { Account } from '../../models/account' +import { PopupType } from '../../models/popup' +import { filesNotTrackedByLFS } from '../../lib/git/lfs' +import { getLargeFilePaths } from '../../lib/large-files' +import { isConflictedFile, hasUnresolvedConflicts } from '../../lib/status' +import { getAccountForRepository } from '../../lib/get-account-for-repository' +import { IAheadBehind } from '../../models/branch' + +/** + * The timeout for the animation of the enter/leave animation for Undo. + * + * Note that this *must* match the duration specified for the `undo` transitions + * in `_changes-list.scss`. + */ +const UndoCommitAnimationTimeout = 500 + +interface IChangesSidebarProps { + readonly repository: Repository + readonly changes: IChangesState + readonly aheadBehind: IAheadBehind | null + readonly dispatcher: Dispatcher + readonly commitAuthor: CommitIdentity | null + readonly branch: string | null + readonly emoji: Map + readonly mostRecentLocalCommit: Commit | null + readonly issuesStore: IssuesStore + readonly availableWidth: number + readonly isCommitting: boolean + readonly commitToAmend: Commit | null + readonly isPushPullFetchInProgress: boolean + readonly gitHubUserStore: GitHubUserStore + readonly focusCommitMessage: boolean + readonly askForConfirmationOnDiscardChanges: boolean + readonly accounts: ReadonlyArray + readonly isShowingModal: boolean + readonly isShowingFoldout: boolean + /** The name of the currently selected external editor */ + readonly externalEditorLabel?: string + + /** + * Callback to open a selected file using the configured external editor + * + * @param fullPath The full path to the file on disk + */ + readonly onOpenInExternalEditor: (fullPath: string) => void + readonly onChangesListScrolled: (scrollTop: number) => void + readonly changesListScrollTop?: number + + /** + * Whether we should show the onboarding tutorial nudge + * arrow pointing at the commit summary box + */ + readonly shouldNudgeToCommit: boolean + + readonly commitSpellcheckEnabled: boolean +} + +export class ChangesSidebar extends React.Component { + private autocompletionProviders: ReadonlyArray< + IAutocompletionProvider + > | null = null + private changesListRef = React.createRef() + + public constructor(props: IChangesSidebarProps) { + super(props) + + this.receiveProps(props) + } + + public componentWillReceiveProps(nextProps: IChangesSidebarProps) { + this.receiveProps(nextProps) + } + + private receiveProps(props: IChangesSidebarProps) { + if ( + this.autocompletionProviders === null || + this.props.emoji.size === 0 || + props.repository.hash !== this.props.repository.hash || + props.accounts !== this.props.accounts + ) { + this.autocompletionProviders = buildAutocompletionProviders( + props.repository, + props.dispatcher, + props.emoji, + props.issuesStore, + props.gitHubUserStore, + props.accounts + ) + } + } + + private onCreateCommit = async ( + context: ICommitContext + ): Promise => { + const { workingDirectory } = this.props.changes + + const overSizedFiles = await getLargeFilePaths( + this.props.repository, + workingDirectory + ) + + const filesIgnoredByLFS = await filesNotTrackedByLFS( + this.props.repository, + overSizedFiles + ) + + if (filesIgnoredByLFS.length !== 0) { + this.props.dispatcher.showPopup({ + type: PopupType.OversizedFiles, + oversizedFiles: filesIgnoredByLFS, + context: context, + repository: this.props.repository, + }) + + return false + } + + // are any conflicted files left? + const conflictedFilesLeft = workingDirectory.files.filter( + f => + isConflictedFile(f.status) && + f.selection.getSelectionType() === DiffSelectionType.None + ) + + if (conflictedFilesLeft.length === 0) { + this.props.dispatcher.clearBanner() + this.props.dispatcher.recordUnguidedConflictedMergeCompletion() + } + + // which of the files selected for committing are conflicted (with markers)? + const conflictedFilesSelected = workingDirectory.files.filter( + f => + isConflictedFile(f.status) && + hasUnresolvedConflicts(f.status) && + f.selection.getSelectionType() !== DiffSelectionType.None + ) + + if (conflictedFilesSelected.length > 0) { + this.props.dispatcher.showPopup({ + type: PopupType.CommitConflictsWarning, + files: conflictedFilesSelected, + repository: this.props.repository, + context, + }) + return false + } + + return this.props.dispatcher.commitIncludedChanges( + this.props.repository, + context + ) + } + + private onFileSelectionChanged = (rows: ReadonlyArray) => { + const files = rows.map(i => this.props.changes.workingDirectory.files[i]) + this.props.dispatcher.selectWorkingDirectoryFiles( + this.props.repository, + files + ) + } + + private onIncludeChanged = (path: string, include: boolean) => { + const workingDirectory = this.props.changes.workingDirectory + const file = workingDirectory.files.find(f => f.path === path) + if (!file) { + console.error( + 'unable to find working directory file to apply included change: ' + + path + ) + return + } + + this.props.dispatcher.changeFileIncluded( + this.props.repository, + file, + include + ) + } + + private onSelectAll = (selectAll: boolean) => { + this.props.dispatcher.changeIncludeAllFiles( + this.props.repository, + selectAll + ) + } + + private onDiscardChanges = (file: WorkingDirectoryFileChange) => { + if (!this.props.askForConfirmationOnDiscardChanges) { + this.props.dispatcher.discardChanges(this.props.repository, [file]) + } else { + this.props.dispatcher.showPopup({ + type: PopupType.ConfirmDiscardChanges, + repository: this.props.repository, + files: [file], + }) + } + } + + private onDiscardChangesFromFiles = ( + files: ReadonlyArray, + isDiscardingAllChanges: boolean + ) => { + this.props.dispatcher.showPopup({ + type: PopupType.ConfirmDiscardChanges, + repository: this.props.repository, + showDiscardChangesSetting: false, + discardingAllChanges: isDiscardingAllChanges, + files, + }) + } + + private onIgnoreFile = (file: string | string[]) => { + this.props.dispatcher.appendIgnoreFile(this.props.repository, file) + } + + private onIgnorePattern = (pattern: string | string[]) => { + this.props.dispatcher.appendIgnoreRule(this.props.repository, pattern) + } + + /** + * Open file with default application. + * + * @param path The path of the file relative to the root of the repository + */ + private onOpenItem = (path: string) => { + const fullPath = Path.join(this.props.repository.path, path) + openFile(fullPath, this.props.dispatcher) + } + /** + * Called to open a file in the default external editor + * + * @param path The path of the file relative to the root of the repository + */ + private onOpenItemInExternalEditor = (path: string) => { + this.props.onOpenInExternalEditor(path) + } + + /** + * Toggles the selection of a given working directory file. + * If the file is partially selected it the selection is cleared + * in order to match the behavior of clicking on an indeterminate + * checkbox. + */ + private onToggleInclude(row: number) { + const workingDirectory = this.props.changes.workingDirectory + const file = workingDirectory.files[row] + + if (!file) { + console.error('keyboard selection toggle despite no file - what?') + return + } + + const currentSelection = file.selection.getSelectionType() + + this.props.dispatcher.changeFileIncluded( + this.props.repository, + file, + currentSelection === DiffSelectionType.None + ) + } + + /** + * Handles click events from the List item container, note that this is + * Not the same thing as the element returned by the row renderer in ChangesList + */ + private onChangedItemClick = ( + rows: number | number[], + source: ClickSource + ) => { + // Toggle selection when user presses the spacebar or enter while focused + // on a list item or on the list's container + if (source.kind === 'keyboard') { + if (rows instanceof Array) { + rows.forEach(row => this.onToggleInclude(row)) + } else { + this.onToggleInclude(rows) + } + } + } + + private onUndo = () => { + const commit = this.props.mostRecentLocalCommit + + if (commit && commit.tags.length === 0) { + this.props.dispatcher.undoCommit(this.props.repository, commit) + } + } + + private renderMostRecentLocalCommit() { + const commit = this.props.mostRecentLocalCommit + let child: JSX.Element | null = null + + // We don't allow undoing commits that have tags associated to them, since then + // the commit won't be completely deleted because the tag will still point to it. + // Also, don't allow undoing commits while the user is amending the last one. + if ( + commit && + commit.tags.length === 0 && + this.props.commitToAmend === null + ) { + child = ( + + + + ) + } + + return {child} + } + + private renderUndoCommit = ( + rebaseConflictState: RebaseConflictState | null + ): JSX.Element | null => { + if (rebaseConflictState !== null) { + return null + } + + return this.renderMostRecentLocalCommit() + } + + public focus() { + this.changesListRef.current?.focus() + } + + public render() { + const { + workingDirectory, + commitMessage, + showCoAuthoredBy, + coAuthors, + conflictState, + selection, + currentBranchProtected, + currentRepoRulesInfo, + } = this.props.changes + let rebaseConflictState: RebaseConflictState | null = null + if (conflictState !== null) { + rebaseConflictState = isRebaseConflictState(conflictState) + ? conflictState + : null + } + + const selectedFileIDs = + selection.kind === ChangesSelectionKind.WorkingDirectory + ? selection.selectedFileIDs + : [] + + const isShowingStashEntry = selection.kind === ChangesSelectionKind.Stash + const repositoryAccount = getAccountForRepository( + this.props.accounts, + this.props.repository + ) + + return ( +
+ + {this.renderUndoCommit(rebaseConflictState)} +
+ ) + } +} diff --git a/app/src/ui/changes/undo-commit.tsx b/app/src/ui/changes/undo-commit.tsx new file mode 100644 index 0000000000..8f276737ab --- /dev/null +++ b/app/src/ui/changes/undo-commit.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' + +import { Commit } from '../../models/commit' +import { RichText } from '../lib/rich-text' +import { RelativeTime } from '../relative-time' +import { Button } from '../lib/button' + +interface IUndoCommitProps { + /** The function to call when the Undo button is clicked. */ + readonly onUndo: () => void + + /** The commit to undo. */ + readonly commit: Commit + + /** The emoji cache to use when rendering the commit message */ + readonly emoji: Map + + /** whether a push, pull or fetch is in progress */ + readonly isPushPullFetchInProgress: boolean + + /** whether a committing is in progress */ + readonly isCommitting: boolean +} + +/** The Undo Commit component. */ +export class UndoCommit extends React.Component { + public render() { + const disabled = + this.props.isPushPullFetchInProgress || this.props.isCommitting + const title = disabled + ? 'Undo is disabled while the repository is being updated' + : undefined + + const authorDate = this.props.commit.author.date + return ( +
+
+
+ Committed +
+ +
+
+ +
+
+ ) + } +} diff --git a/app/src/ui/check-runs/ci-check-re-run-button.tsx b/app/src/ui/check-runs/ci-check-re-run-button.tsx new file mode 100644 index 0000000000..2931e06980 --- /dev/null +++ b/app/src/ui/check-runs/ci-check-re-run-button.tsx @@ -0,0 +1,58 @@ +import * as React from 'react' +import { APICheckConclusion } from '../../lib/api' +import { IRefCheck } from '../../lib/ci-checks/ci-checks' +import { IMenuItem, showContextualMenu } from '../../lib/menu-item' +import { Button } from '../lib/button' +import { Octicon, syncClockwise } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' + +interface ICICheckReRunButtonProps { + readonly disabled: boolean + readonly checkRuns: ReadonlyArray + readonly canReRunFailed: boolean + readonly onRerunChecks: (failedOnly: boolean) => void +} + +export class CICheckReRunButton extends React.PureComponent { + private get failedChecksExist() { + return this.props.checkRuns.some( + cr => cr.conclusion === APICheckConclusion.Failure + ) + } + + private onRerunChecks = () => { + if (!this.props.canReRunFailed || !this.failedChecksExist) { + this.props.onRerunChecks(false) + return + } + + const items: IMenuItem[] = [ + { + label: __DARWIN__ ? 'Re-run Failed Checks' : 'Re-run failed checks', + action: () => this.props.onRerunChecks(true), + }, + { + label: __DARWIN__ ? 'Re-run All Checks' : 'Re-run all checks', + action: () => this.props.onRerunChecks(false), + }, + ] + + showContextualMenu(items) + } + + public render() { + const text = + this.props.canReRunFailed && this.failedChecksExist ? ( + <> + Re-run + + ) : ( + 'Re-run Checks' + ) + return ( + + ) + } +} diff --git a/app/src/ui/check-runs/ci-check-run-actions-job-step-item.tsx b/app/src/ui/check-runs/ci-check-run-actions-job-step-item.tsx new file mode 100644 index 0000000000..403eb5e783 --- /dev/null +++ b/app/src/ui/check-runs/ci-check-run-actions-job-step-item.tsx @@ -0,0 +1,73 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import * as React from 'react' +import { Octicon } from '../octicons' +import classNames from 'classnames' +import { + getClassNameForCheck, + getSymbolForLogStep, +} from '../branches/ci-status' +import { IAPIWorkflowJobStep } from '../../lib/api' +import { getFormattedCheckRunDuration } from '../../lib/ci-checks/ci-checks' +import { TooltippedContent } from '../lib/tooltipped-content' +import { TooltipDirection } from '../lib/tooltip' + +interface ICICheckRunActionsJobStepListItemProps { + readonly step: IAPIWorkflowJobStep + readonly firstFailedStep: IAPIWorkflowJobStep | undefined + + /** Callback to open a job steps link on dotcom*/ + readonly onViewJobStepExternally: (step: IAPIWorkflowJobStep) => void +} + +export class CICheckRunActionsJobStepListItem extends React.PureComponent { + private onViewJobStepExternally = () => { + this.props.onViewJobStepExternally(this.props.step) + } + + private onStepHeaderRef = (step: IAPIWorkflowJobStep) => { + return (stepHeaderRef: HTMLDivElement | null) => { + if ( + this.props.firstFailedStep !== undefined && + step.number === this.props.firstFailedStep.number && + stepHeaderRef !== null + ) { + stepHeaderRef.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + } + + public render() { + const { step } = this.props + return ( +
+
+ +
+ + + {step.name} + + +
+ {getFormattedCheckRunDuration(step)} +
+
+ ) + } +} diff --git a/app/src/ui/check-runs/ci-check-run-actions-job-step-list.tsx b/app/src/ui/check-runs/ci-check-run-actions-job-step-list.tsx new file mode 100644 index 0000000000..060b6ea59e --- /dev/null +++ b/app/src/ui/check-runs/ci-check-run-actions-job-step-list.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' +import { IAPIWorkflowJobStep } from '../../lib/api' +import { isFailure } from '../../lib/ci-checks/ci-checks' +import { CICheckRunActionsJobStepListItem } from './ci-check-run-actions-job-step-item' + +interface ICICheckRunActionsJobStepListsProps { + /** The action jobs steps of a check run **/ + readonly steps: ReadonlyArray + + /** Callback to open a job steps link on dotcom*/ + readonly onViewJobStep: (step: IAPIWorkflowJobStep) => void +} + +interface ICICheckRunActionsJobStepListsState { + readonly firstFailedStep: IAPIWorkflowJobStep | undefined +} + +/** The CI check list item. */ +export class CICheckRunActionsJobStepList extends React.PureComponent< + ICICheckRunActionsJobStepListsProps, + ICICheckRunActionsJobStepListsState +> { + public constructor(props: ICICheckRunActionsJobStepListsProps) { + super(props) + + this.state = { + firstFailedStep: this.props.steps.find(isFailure), + } + } + + public componentDidUpdate() { + this.setState({ + firstFailedStep: this.props.steps.find(isFailure), + }) + } + + public render() { + const { steps } = this.props + + const jobSteps = steps.map((step, i) => { + return ( + + ) + }) + + return
{jobSteps}
+ } +} diff --git a/app/src/ui/check-runs/ci-check-run-list-item.tsx b/app/src/ui/check-runs/ci-check-run-list-item.tsx new file mode 100644 index 0000000000..84511328eb --- /dev/null +++ b/app/src/ui/check-runs/ci-check-run-list-item.tsx @@ -0,0 +1,231 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import * as React from 'react' +import { IRefCheck } from '../../lib/ci-checks/ci-checks' +import { Octicon } from '../octicons' +import { getClassNameForCheck, getSymbolForCheck } from '../branches/ci-status' +import classNames from 'classnames' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { TooltippedContent } from '../lib/tooltipped-content' +import { CICheckRunActionsJobStepList } from './ci-check-run-actions-job-step-list' +import { IAPIWorkflowJobStep } from '../../lib/api' +import { TooltipDirection } from '../lib/tooltip' + +interface ICICheckRunListItemProps { + /** The check run to display **/ + readonly checkRun: IRefCheck + + /** Whether call for actions logs is pending */ + readonly loadingActionLogs: boolean + + /** Whether tcall for actions workflows is pending */ + readonly loadingActionWorkflows: boolean + + /** Whether to show the logs for this check run */ + readonly isCheckRunExpanded: boolean + + /** Whether the list item can be selected */ + readonly selectable: boolean + + /** Whether the list item is selected */ + readonly selected: boolean + + /** Whether check runs can be expanded. Default: false */ + readonly notExpandable?: boolean + + /** Showing a condensed view */ + readonly isCondensedView?: boolean + + /** Callback for when a check run is clicked */ + readonly onCheckRunExpansionToggleClick: (checkRun: IRefCheck) => void + + /** Callback to opens check runs target url (maybe GitHub, maybe third party) */ + readonly onViewCheckExternally?: (checkRun: IRefCheck) => void + + /** Callback to open a job steps link on dotcom*/ + readonly onViewJobStep?: ( + checkRun: IRefCheck, + step: IAPIWorkflowJobStep + ) => void + + /** Callback to rerun a job*/ + readonly onRerunJob?: (checkRun: IRefCheck) => void +} + +interface ICICheckRunListItemState { + readonly isMouseOver: boolean +} + +/** The CI check list item. */ +export class CICheckRunListItem extends React.PureComponent< + ICICheckRunListItemProps, + ICICheckRunListItemState +> { + public constructor(props: ICICheckRunListItemProps) { + super(props) + this.state = { isMouseOver: false } + } + + private toggleCheckRunExpansion = () => { + this.props.onCheckRunExpansionToggleClick(this.props.checkRun) + } + + private onViewCheckExternally = () => { + this.props.onViewCheckExternally?.(this.props.checkRun) + } + + private onViewJobStep = (step: IAPIWorkflowJobStep) => { + this.props.onViewJobStep?.(this.props.checkRun, step) + } + + private rerunJob = (e: React.MouseEvent) => { + e.stopPropagation() + if (this.props.checkRun.actionJobSteps === undefined) { + return + } + + this.props.onRerunJob?.(this.props.checkRun) + } + + private onMouseEnter = () => { + if (!this.state.isMouseOver) { + this.setState({ isMouseOver: true }) + } + } + + private onMouseLeave = (e: React.MouseEvent) => { + this.setState({ isMouseOver: false }) + } + + private renderCheckStatusSymbol = (): JSX.Element => { + const { checkRun } = this.props + + return ( +
+ +
+ ) + } + + private renderCheckJobStepToggle = (): JSX.Element | null => { + const { checkRun, isCheckRunExpanded, selectable, notExpandable } = + this.props + + if ( + checkRun.actionJobSteps === undefined || + selectable || + notExpandable === true + ) { + return null + } + + return ( +
+ +
+ ) + } + + private renderCheckRunName = (): JSX.Element => { + const { checkRun, isCondensedView, onViewCheckExternally } = this.props + const { name, description } = checkRun + return ( +
+ + + {name} + + + + {isCondensedView ? null : ( +
{description}
+ )} +
+ ) + } + + private renderJobRerun = (): JSX.Element | null => { + const { checkRun, onRerunJob } = this.props + const { isMouseOver } = this.state + + if (!isMouseOver || onRerunJob === undefined) { + return null + } + + const classes = classNames('job-rerun', { + 'not-action-job': checkRun.actionJobSteps === undefined, + }) + + const tooltip = + checkRun.actionJobSteps !== undefined + ? 'Re-run this check' + : 'This check cannot be re-run individually.' + + return ( +
+ + + +
+ ) + } + + public render() { + const { checkRun, isCheckRunExpanded, selected, isCondensedView } = + this.props + + const classes = classNames('ci-check-list-item', 'list-item', { + sticky: isCheckRunExpanded, + selected, + condensed: isCondensedView, + }) + return ( +
+
+ {this.renderCheckStatusSymbol()} + {this.renderCheckRunName()} + {this.renderJobRerun()} + {this.renderCheckJobStepToggle()} +
+ {isCheckRunExpanded && checkRun.actionJobSteps !== undefined ? ( + + ) : null} +
+ ) + } +} diff --git a/app/src/ui/check-runs/ci-check-run-list.tsx b/app/src/ui/check-runs/ci-check-run-list.tsx new file mode 100644 index 0000000000..953c274452 --- /dev/null +++ b/app/src/ui/check-runs/ci-check-run-list.tsx @@ -0,0 +1,215 @@ +import * as React from 'react' +import { IAPIWorkflowJobStep } from '../../lib/api' +import { + getCheckRunGroupNames, + getCheckRunsGroupedByActionWorkflowNameAndEvent, + IRefCheck, + isFailure, +} from '../../lib/ci-checks/ci-checks' +import { CICheckRunListItem } from './ci-check-run-list-item' +import { FocusContainer } from '../lib/focus-container' +import classNames from 'classnames' + +interface ICICheckRunListProps { + /** List of check runs to display */ + readonly checkRuns: ReadonlyArray + + /** Whether loading action logs */ + readonly loadingActionLogs: boolean + + /** Whether loading workflow */ + readonly loadingActionWorkflows: boolean + + /** Whether check runs can be selected. Default: false */ + readonly selectable?: boolean + + /** Whether check runs can be expanded. Default: false */ + readonly notExpandable?: boolean + + /** Showing a condensed view */ + readonly isCondensedView?: boolean + + /** Callback to opens check runs target url (maybe GitHub, maybe third party) */ + readonly onViewCheckDetails?: (checkRun: IRefCheck) => void + + /** Callback when a check run is clicked */ + readonly onCheckRunClick?: (checkRun: IRefCheck) => void + + /** Callback to open a job steps link on dotcom*/ + readonly onViewJobStep?: ( + checkRun: IRefCheck, + step: IAPIWorkflowJobStep + ) => void + + /** Callback to rerun a job*/ + readonly onRerunJob?: (checkRun: IRefCheck) => void +} + +interface ICICheckRunListState { + readonly checkRunGroups: Map> + readonly checkRunExpanded: string | null + readonly hasUserToggledCheckRun: boolean +} + +/** The CI Check list. */ +export class CICheckRunList extends React.PureComponent< + ICICheckRunListProps, + ICICheckRunListState +> { + public constructor(props: ICICheckRunListProps) { + super(props) + this.state = this.setupStateAfterCheckRunPropChange(this.props, null) + } + + public componentDidUpdate(prevProps: ICICheckRunListProps) { + const { checkRunExpanded, hasUserToggledCheckRun } = + this.setupStateAfterCheckRunPropChange(this.props, this.state) + + let foundDiffStatus = false + for (const prevCR of prevProps.checkRuns) { + const diffStatus = this.props.checkRuns.find( + cr => cr.id === prevCR.id && cr.status !== prevCR.status + ) + if (diffStatus !== undefined) { + foundDiffStatus = true + break + } + } + + if (foundDiffStatus) { + this.setState({ + checkRunExpanded, + hasUserToggledCheckRun, + checkRunGroups: getCheckRunsGroupedByActionWorkflowNameAndEvent( + this.props.checkRuns + ), + }) + } else { + this.setState({ checkRunExpanded, hasUserToggledCheckRun }) + } + } + + private setupStateAfterCheckRunPropChange( + props: ICICheckRunListProps, + currentState: ICICheckRunListState | null + ): ICICheckRunListState { + // If the user has expanded something and then a load occurs, we don't want + // to reset their position. + if (currentState?.hasUserToggledCheckRun === true) { + return currentState + } + + const checkRunGroups = getCheckRunsGroupedByActionWorkflowNameAndEvent( + props.checkRuns + ) + let checkRunExpanded = null + + if (this.props.notExpandable !== true) { + // If there is a failure, we want the first check run with a failure, to + // be opened so the user doesn't have to click through to find it. + for (const group of checkRunGroups.values()) { + const firstFailure = group.find( + cr => isFailure(cr) && cr.actionJobSteps !== undefined + ) + if (firstFailure !== undefined) { + checkRunExpanded = firstFailure.id.toString() + break + } + } + } + + return { + checkRunGroups, + checkRunExpanded, + hasUserToggledCheckRun: currentState?.hasUserToggledCheckRun || false, + } + } + + private onCheckRunClick = (checkRun: IRefCheck): void => { + if (this.props.notExpandable === true) { + return + } + + // If the list is selectable, we don't want to toggle when the selected + // item is clicked again. + const checkRunExpanded = + this.state.checkRunExpanded === checkRun.id.toString() && + !this.props.selectable + ? null + : checkRun.id.toString() + + this.setState({ + checkRunExpanded, + hasUserToggledCheckRun: true, + }) + + this.props.onCheckRunClick?.(checkRun) + } + + private renderListItems = ( + checkRuns: ReadonlyArray | undefined + ) => { + if (checkRuns === undefined) { + // This shouldn't happen as the selection is based off the keys of the map + return null + } + + const list = checkRuns.map((c, i) => { + const checkRunExpanded = this.state.checkRunExpanded === c.id.toString() + const selectable = this.props.selectable === true + + return ( + + ) + }) + + return ( + {list} + ) + } + + private renderList = (): JSX.Element | null => { + const { checkRunGroups } = this.state + const checkRunGroupNames = getCheckRunGroupNames(checkRunGroups) + if ( + checkRunGroupNames.length === 1 && + (checkRunGroupNames[0] === 'Other' || this.props.isCondensedView) + ) { + return this.renderListItems(this.props.checkRuns) + } + + const groupHeaderClasses = classNames('ci-check-run-list-group-header', { + condensed: this.props.isCondensedView, + }) + + const groups = checkRunGroupNames.map((groupName, i) => { + return ( +
+
{groupName}
+ {this.renderListItems(checkRunGroups.get(groupName))} +
+ ) + }) + + return <>{groups} + } + + public render() { + return
{this.renderList()}
+ } +} diff --git a/app/src/ui/check-runs/ci-check-run-popover.tsx b/app/src/ui/check-runs/ci-check-run-popover.tsx new file mode 100644 index 0000000000..a3b8bdc59b --- /dev/null +++ b/app/src/ui/check-runs/ci-check-run-popover.tsx @@ -0,0 +1,408 @@ +import * as React from 'react' +import { GitHubRepository } from '../../models/github-repository' +import { DisposableLike } from 'event-kit' +import { Dispatcher } from '../dispatcher' +import { + getCheckRunConclusionAdjective, + ICombinedRefCheck, + IRefCheck, + getCheckRunStepURL, + getCheckStatusCountMap, + FailingCheckConclusions, +} from '../../lib/ci-checks/ci-checks' +import { Octicon, syncClockwise } from '../octicons' +import { APICheckConclusion, IAPIWorkflowJobStep } from '../../lib/api' +import { + Popover, + PopoverAnchorPosition, + PopoverDecoration, +} from '../lib/popover' +import { CICheckRunList } from './ci-check-run-list' +import { encodePathAsUrl } from '../../lib/path' +import { PopupType } from '../../models/popup' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Donut } from '../donut' +import { + supportsRerunningChecks, + supportsRerunningIndividualOrFailedChecks, +} from '../../lib/endpoint-capabilities' +import { getPullRequestCommitRef } from '../../models/pull-request' +import { CICheckReRunButton } from './ci-check-re-run-button' + +const BlankSlateImage = encodePathAsUrl( + __dirname, + 'static/empty-no-pull-requests.svg' +) + +interface ICICheckRunPopoverProps { + readonly dispatcher: Dispatcher + + /** The GitHub repository to use when looking up commit status. */ + readonly repository: GitHubRepository + + /** The current branch name. */ + readonly branchName: string + + /** The pull request's number. */ + readonly prNumber: number + + readonly anchor: HTMLElement | null + + /** Callback for when popover closes */ + readonly closePopover: (event?: MouseEvent) => void +} + +interface ICICheckRunPopoverState { + readonly checkRuns: ReadonlyArray + readonly checkRunSummary: string + readonly loadingActionLogs: boolean + readonly loadingActionWorkflows: boolean +} + +/** The CI Check Runs Popover. */ +export class CICheckRunPopover extends React.PureComponent< + ICICheckRunPopoverProps, + ICICheckRunPopoverState +> { + private statusSubscription: DisposableLike | null = null + + public constructor(props: ICICheckRunPopoverProps) { + super(props) + + const cachedStatus = this.props.dispatcher.tryGetCommitStatus( + this.props.repository, + getPullRequestCommitRef(this.props.prNumber) + ) + + this.state = { + checkRuns: cachedStatus?.checks ?? [], + checkRunSummary: this.getCombinedCheckSummary(cachedStatus), + loadingActionLogs: true, + loadingActionWorkflows: true, + } + } + + public componentDidMount() { + const combinedCheck = this.props.dispatcher.tryGetCommitStatus( + this.props.repository, + getPullRequestCommitRef(this.props.prNumber), + this.props.branchName + ) + + this.onStatus(combinedCheck) + this.subscribe() + } + + public componentWillUnmount() { + this.unsubscribe() + } + + private subscribe() { + this.unsubscribe() + + this.statusSubscription = this.props.dispatcher.subscribeToCommitStatus( + this.props.repository, + getPullRequestCommitRef(this.props.prNumber), + this.onStatus, + this.props.branchName + ) + } + + private unsubscribe() { + if (this.statusSubscription) { + this.statusSubscription.dispose() + this.statusSubscription = null + } + } + + private onStatus = async (check: ICombinedRefCheck | null) => { + if (check === null) { + // Either this is on load -> we just want to continue to show loader + // status/cached header or while user has it open and we ant to continue + // to show last cache value to user closes popover + return + } + + this.setState({ + checkRuns: [...check.checks], + checkRunSummary: this.getCombinedCheckSummary(check), + loadingActionWorkflows: false, + loadingActionLogs: false, + }) + } + + private onViewCheckDetails = (checkRun: IRefCheck): void => { + if (checkRun.htmlUrl === null && this.props.repository.htmlURL === null) { + // A check run may not have a url depending on how it is setup. + // However, the repository should have one; Thus, we shouldn't hit this + return + } + + // Some checks do not provide htmlURLS like ones for the legacy status + // object as they do not have a view in the checks screen. In that case we + // will just open the PR and they can navigate from there... a little + // dissatisfying tho more of an edgecase anyways. + const url = + checkRun.htmlUrl ?? + `${this.props.repository.htmlURL}/pull/${this.props.prNumber}` + + this.props.dispatcher.openInBrowser(url) + this.props.dispatcher.recordCheckViewedOnline() + } + + private onViewJobStep = ( + checkRun: IRefCheck, + step: IAPIWorkflowJobStep + ): void => { + const { repository, prNumber, dispatcher } = this.props + + const url = getCheckRunStepURL(checkRun, step, repository, prNumber) + + if (url !== null) { + dispatcher.openInBrowser(url) + this.props.dispatcher.recordCheckJobStepViewedOnline() + } + } + + private getCombinedCheckSummary( + combinedCheck: ICombinedRefCheck | null + ): string { + if (combinedCheck === null || combinedCheck.checks.length === 0) { + return '' + } + + const { checks } = combinedCheck + const conclusionMap = new Map() + for (const check of checks) { + const adj = getCheckRunConclusionAdjective( + check.conclusion + ).toLocaleLowerCase() + conclusionMap.set(adj, (conclusionMap.get(adj) ?? 0) + 1) + } + + const summaryArray = [] + for (const [conclusion, count] of conclusionMap.entries()) { + summaryArray.push({ count, conclusion }) + } + + if (summaryArray.length > 1) { + const output = summaryArray.map( + ({ count, conclusion }) => `${count} ${conclusion}` + ) + return `${output.slice(0, -1).join(', ')}, and ${output.slice(-1)} checks` + } + + const pluralize = summaryArray[0].count > 1 ? 'checks' : 'check' + return `${summaryArray[0].count} ${summaryArray[0].conclusion} ${pluralize}` + } + + private rerunChecks = ( + failedOnly: boolean, + checkRuns?: ReadonlyArray + ) => { + this.props.dispatcher.showPopup({ + type: PopupType.CICheckRunRerun, + checkRuns: checkRuns ?? this.state.checkRuns, + repository: this.props.repository, + prRef: getPullRequestCommitRef(this.props.prNumber), + failedOnly, + }) + } + + private renderRerunButton = () => { + const { checkRuns } = this.state + if (!supportsRerunningChecks(this.props.repository.endpoint)) { + return null + } + + return ( + + ) + } + + private renderCheckRunLoadings(): JSX.Element { + return ( +
+ +
Stand By
+
Check runs incoming!
+
+ ) + } + + private renderCompletenessIndicator( + allSuccess: boolean, + allFailure: boolean, + loading: boolean, + checkRuns: ReadonlyArray + ): JSX.Element { + if (loading) { + return + } + + switch (true) { + case allSuccess: + return ( + + ) + case allFailure: { + return ( + + ) + } + } + + return + } + + private getTitle( + allSuccess: boolean, + allFailure: boolean, + somePendingNoFailures: boolean, + loading: boolean + ): JSX.Element { + switch (true) { + case loading: + return <>Checks Summary + case somePendingNoFailures: + return ( + Some checks haven't completed yet + ) + case allFailure: + return All checks have failed + case allSuccess: + return <>All checks have passed + } + + return Some checks were not successful + } + + private renderHeader = (): JSX.Element => { + const { loadingActionWorkflows, checkRuns, checkRunSummary } = this.state + // Only show loading header status, if there are no cached check runs to display. + const loading = loadingActionWorkflows && checkRuns.length === 0 + + const somePendingNoFailures = + !loading && + checkRuns.some(v => v.conclusion === null) && + !checkRuns.some( + v => + v.conclusion !== null && + FailingCheckConclusions.includes(v.conclusion) + ) + + const successfulishConclusions = [ + APICheckConclusion.Success, + APICheckConclusion.Neutral, + APICheckConclusion.Skipped, + ] + const allSuccessIsh = + !loading && // quick return: if loading, no list + !somePendingNoFailures && // quick return: if some pending, can't all be success + !checkRuns.some( + v => + v.conclusion !== null && + !successfulishConclusions.includes(v.conclusion) + ) + + const allFailure = + !loading && // quick return if loading, no list + !somePendingNoFailures && // quick return: if some failing, can't all be failure + !checkRuns.some( + v => + v.conclusion === null || + !FailingCheckConclusions.includes(v.conclusion) + ) + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex +
+
+ {this.renderCompletenessIndicator( + allSuccessIsh, + allFailure, + loading, + checkRuns + )} +
+
+
+ {this.getTitle( + allSuccessIsh, + allFailure, + somePendingNoFailures, + loading + )} +
+
{checkRunSummary}
+
+ {this.renderRerunButton()} +
+ ) + } + + private onRerunJob = (check: IRefCheck) => { + this.rerunChecks(false, [check]) + } + + public renderList = (): JSX.Element => { + const { checkRuns, loadingActionLogs, loadingActionWorkflows } = this.state + if (loadingActionWorkflows) { + return this.renderCheckRunLoadings() + } + + return ( +
+ +
+ ) + } + + public render() { + return ( +
+ +
+ {this.renderHeader()} + {this.renderList()} +
+
+
+ ) + } +} diff --git a/app/src/ui/check-runs/ci-check-run-rerun-dialog.tsx b/app/src/ui/check-runs/ci-check-run-rerun-dialog.tsx new file mode 100644 index 0000000000..66a2307cac --- /dev/null +++ b/app/src/ui/check-runs/ci-check-run-rerun-dialog.tsx @@ -0,0 +1,260 @@ +import * as React from 'react' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { IRefCheck } from '../../lib/ci-checks/ci-checks' +import { CICheckRunList } from './ci-check-run-list' +import { GitHubRepository } from '../../models/github-repository' +import { Dispatcher } from '../dispatcher' +import { + APICheckConclusion, + APICheckStatus, + IAPICheckSuite, +} from '../../lib/api' +import { Octicon } from '../octicons' +import * as OcticonSymbol from './../octicons/octicons.generated' +import { encodePathAsUrl } from '../../lib/path' +import { offsetFromNow } from '../../lib/offset-from' + +const BlankSlateImage = encodePathAsUrl( + __dirname, + 'static/empty-no-pull-requests.svg' +) + +interface ICICheckRunRerunDialogProps { + readonly dispatcher: Dispatcher + readonly repository: GitHubRepository + + /** List of all the check runs (some of which are not rerunnable) */ + readonly checkRuns: ReadonlyArray + + /** The git reference of the pr */ + readonly prRef: string + + /** Whether to only rerun failed checks */ + readonly failedOnly: boolean + + readonly onDismissed: () => void +} + +interface ICICheckRunRerunDialogState { + readonly loadingCheckSuites: boolean + readonly loadingRerun: boolean + readonly rerunnable: ReadonlyArray + readonly nonRerunnable: ReadonlyArray +} + +/** + * Dialog that informs the user of which jobs will be rerun + */ +export class CICheckRunRerunDialog extends React.Component< + ICICheckRunRerunDialogProps, + ICICheckRunRerunDialogState +> { + public constructor(props: ICICheckRunRerunDialogProps) { + super(props) + this.state = { + loadingCheckSuites: true, + loadingRerun: false, + rerunnable: [], + nonRerunnable: [], + } + this.determineRerunnability() + } + + private onSubmit = async () => { + const { dispatcher, repository, prRef } = this.props + this.setState({ loadingRerun: true }) + await dispatcher.rerequestCheckSuites( + repository, + this.state.rerunnable, + this.props.failedOnly + ) + await dispatcher.manualRefreshSubscription( + repository, + prRef, + this.state.rerunnable + ) + dispatcher.recordRerunChecks() + this.props.onDismissed() + } + + private determineRerunnability = async () => { + const checkRunsToConsider = this.props.failedOnly + ? this.props.checkRuns.filter( + cr => cr.conclusion === APICheckConclusion.Failure + ) + : this.props.checkRuns + + // Get unique set of check suite ids + const checkSuiteIds = new Set( + checkRunsToConsider.map(cr => cr.checkSuiteId) + ) + + const checkSuitesPromises = new Array>() + + for (const id of checkSuiteIds) { + if (id === null) { + continue + } + checkSuitesPromises.push( + this.props.dispatcher.fetchCheckSuite(this.props.repository, id) + ) + } + + const rerequestableCheckSuiteIds: number[] = [] + for (const cs of await Promise.all(checkSuitesPromises)) { + if (cs === null) { + continue + } + + const createdAt = Date.parse(cs.created_at) + if ( + cs.rerequestable && + createdAt > offsetFromNow(-30, 'days') && // Must be less than a month old + cs.status === APICheckStatus.Completed // Must be completed + ) { + rerequestableCheckSuiteIds.push(cs.id) + } + } + + const rerunnable = checkRunsToConsider.filter( + cr => + cr.checkSuiteId !== null && + rerequestableCheckSuiteIds.includes(cr.checkSuiteId) + ) + const nonRerunnable = checkRunsToConsider.filter( + cr => + cr.checkSuiteId === null || + !rerequestableCheckSuiteIds.includes(cr.checkSuiteId) + ) + + this.setState({ loadingCheckSuites: false, rerunnable, nonRerunnable }) + } + + private renderRerunnableJobsList = () => { + if (this.state.rerunnable.length === 0) { + return null + } + + return ( +
+ +
+ ) + } + + private renderRerunDependentsMessage = () => { + if (this.state.rerunnable.length === 0) { + return null + } + + const name = + this.props.checkRuns.length === 1 ? ( + {this.props.checkRuns[0].name} + ) : ( + 'these workflows' + ) + const dependentAdj = this.props.checkRuns.length === 1 ? 'its' : 'their' + + return ( +
+ A new attempt of {name} will be started, including all of {dependentAdj}{' '} + dependents: +
+ ) + } + + private renderRerunWarning = () => { + if ( + this.state.loadingCheckSuites || + this.state.nonRerunnable.length === 0 + ) { + return null + } + + const pluralize = `check${this.state.nonRerunnable.length !== 1 ? 's' : ''}` + const verb = this.state.nonRerunnable.length !== 1 ? 'are' : 'is' + const warningPrefix = + this.state.rerunnable.length === 0 + ? `There are no ${ + this.props.failedOnly ? 'failed ' : '' + }checks that can be re-run` + : `There ${verb} ${this.state.nonRerunnable.length} ${ + this.props.failedOnly ? 'failed ' : '' + }${pluralize} that cannot be re-run` + return ( +
+ + + {`${warningPrefix}. A check run cannot be re-run if the check is more than one month old, + the check or its dependent has not completed, or the check is not configured to be + re-run.`} +
+ ) + } + + public getTitle = (showDescriptor: boolean = true) => { + const { checkRuns, failedOnly } = this.props + const s = checkRuns.length === 1 ? '' : 's' + const c = __DARWIN__ ? 'C' : 'c' + + let descriptor = '' + if (showDescriptor && checkRuns.length === 1) { + descriptor = __DARWIN__ ? 'Single ' : 'single ' + } + + if (showDescriptor && failedOnly) { + descriptor = __DARWIN__ ? 'Failed ' : 'failed ' + } + + return `Re-run ${descriptor}${c}heck${s}` + } + + private renderDialogContent = () => { + if (this.state.loadingCheckSuites && this.props.checkRuns.length > 1) { + return ( +
+ +
Please wait
+
+ Determining which checks can be re-run. +
+
+ ) + } + + return ( + <> + {this.renderRerunDependentsMessage()} + {this.renderRerunnableJobsList()} + {this.renderRerunWarning()} + + ) + } + + public render() { + return ( + + {this.renderDialogContent()} + + + + + ) + } +} diff --git a/app/src/ui/checkout/confirm-checkout-commit.tsx b/app/src/ui/checkout/confirm-checkout-commit.tsx new file mode 100644 index 0000000000..ac97a8da35 --- /dev/null +++ b/app/src/ui/checkout/confirm-checkout-commit.tsx @@ -0,0 +1,106 @@ +import * as React from 'react' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { Row } from '../lib/row' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { CommitOneLine } from '../../models/commit' + +interface IConfirmCheckoutCommitProps { + readonly dispatcher: Dispatcher + readonly repository: Repository + readonly commit: CommitOneLine + readonly askForConfirmationOnCheckoutCommit: boolean + readonly onDismissed: () => void +} + +interface IConfirmCheckoutCommitState { + readonly isCheckingOut: boolean + readonly confirmCheckoutCommit: boolean +} +/** + * Dialog to confirm checking out a commit + */ +export class ConfirmCheckoutCommitDialog extends React.Component< + IConfirmCheckoutCommitProps, + IConfirmCheckoutCommitState +> { + public constructor(props: IConfirmCheckoutCommitProps) { + super(props) + + this.state = { + isCheckingOut: false, + confirmCheckoutCommit: props.askForConfirmationOnCheckoutCommit, + } + } + + public render() { + const title = __DARWIN__ ? 'Checkout Commit?' : 'Checkout commit?' + + return ( + + + + Checking out a commit will create a detached HEAD, and you will no + longer be on any branch. Are you sure you want to checkout this + commit? + + + + + + + + + + ) + } + + private onaskForConfirmationOnCheckoutCommitChanged = ( + event: React.FormEvent + ) => { + const value = !event.currentTarget.checked + + this.setState({ confirmCheckoutCommit: value }) + } + + private onSubmit = async () => { + const { dispatcher, repository, commit, onDismissed } = this.props + + this.setState({ + isCheckingOut: true, + }) + + try { + dispatcher.setConfirmCheckoutCommitSetting( + this.state.confirmCheckoutCommit + ) + await dispatcher.checkoutCommit(repository, commit) + } finally { + this.setState({ + isCheckingOut: false, + }) + } + + onDismissed() + } +} diff --git a/app/src/ui/choose-fork-settings/choose-fork-settings-dialog.tsx b/app/src/ui/choose-fork-settings/choose-fork-settings-dialog.tsx new file mode 100644 index 0000000000..5533dc9c82 --- /dev/null +++ b/app/src/ui/choose-fork-settings/choose-fork-settings-dialog.tsx @@ -0,0 +1,122 @@ +import * as React from 'react' + +import { + RepositoryWithForkedGitHubRepository, + getForkContributionTarget, +} from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { Row } from '../lib/row' +import { Dialog, DialogContent, DialogFooter } from '../dialog' + +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { ForkContributionTarget } from '../../models/workflow-preferences' +import { VerticalSegmentedControl } from '../lib/vertical-segmented-control' +import { ForkSettingsDescription } from '../repository-settings/fork-contribution-target-description' + +interface IChooseForkSettingsProps { + readonly dispatcher: Dispatcher + /** + * The current repository. + * It needs to be a forked GitHub-based repository + */ + readonly repository: RepositoryWithForkedGitHubRepository + /** + * Event triggered when the dialog is dismissed by the user. + * This happens both when the user clicks on "Continue" to + * save their changes or when they click on "Cancel". + */ + readonly onDismissed: () => void +} + +interface IChooseForkSettingsState { + /** The currently selected ForkContributionTarget value */ + readonly forkContributionTarget: ForkContributionTarget +} + +export class ChooseForkSettings extends React.Component< + IChooseForkSettingsProps, + IChooseForkSettingsState +> { + public constructor(props: IChooseForkSettingsProps) { + super(props) + + this.state = { + forkContributionTarget: getForkContributionTarget(props.repository), + } + } + + public render() { + const items = [ + { + title: 'To contribute to the parent project', + description: ( + <> + We will help you contribute to the{' '} + + {this.props.repository.gitHubRepository.parent.fullName} + {' '} + repository + + ), + key: ForkContributionTarget.Parent, + }, + { + title: 'For my own purposes', + description: ( + <> + We will help you contribute to the{' '} + {this.props.repository.gitHubRepository.fullName}{' '} + repository + + ), + key: ForkContributionTarget.Self, + }, + ] + + return ( + + + + + + + + + + + + + + + ) + } + + private onSelectionChanged = (value: ForkContributionTarget) => { + this.setState({ + forkContributionTarget: value, + }) + } + + private onSubmit = async () => { + this.props.dispatcher.updateRepositoryWorkflowPreferences( + this.props.repository, + { + forkContributionTarget: this.state.forkContributionTarget, + } + ) + this.props.onDismissed() + } +} diff --git a/app/src/ui/choose-fork-settings/index.ts b/app/src/ui/choose-fork-settings/index.ts new file mode 100644 index 0000000000..23813f45e1 --- /dev/null +++ b/app/src/ui/choose-fork-settings/index.ts @@ -0,0 +1 @@ +export { ChooseForkSettings } from './choose-fork-settings-dialog' diff --git a/app/src/ui/cli-installed/cli-installed.tsx b/app/src/ui/cli-installed/cli-installed.tsx new file mode 100644 index 0000000000..bb76afa056 --- /dev/null +++ b/app/src/ui/cli-installed/cli-installed.tsx @@ -0,0 +1,33 @@ +import * as React from 'react' +import { Dialog, DialogContent, DefaultDialogFooter } from '../dialog' +import { InstalledCLIPath } from '../lib/install-cli' + +interface ICLIInstalledProps { + /** Called when the popup should be dismissed. */ + readonly onDismissed: () => void +} + +/** Tell the user the CLI tool was successfully installed. */ +export class CLIInstalled extends React.Component { + public render() { + return ( + + +
+ The command line tool has been installed at{' '} + {InstalledCLIPath}. +
+
+ +
+ ) + } +} diff --git a/app/src/ui/cli-installed/index.ts b/app/src/ui/cli-installed/index.ts new file mode 100644 index 0000000000..3c3c1d5508 --- /dev/null +++ b/app/src/ui/cli-installed/index.ts @@ -0,0 +1 @@ +export { CLIInstalled } from './cli-installed' diff --git a/app/src/ui/clone-repository/clone-generic-repository.tsx b/app/src/ui/clone-repository/clone-generic-repository.tsx new file mode 100644 index 0000000000..678da4bbec --- /dev/null +++ b/app/src/ui/clone-repository/clone-generic-repository.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' +import { TextBox } from '../lib/text-box' +import { Button } from '../lib/button' +import { Row } from '../lib/row' +import { DialogContent } from '../dialog' +import { Ref } from '../lib/ref' + +interface ICloneGenericRepositoryProps { + /** The URL to clone. */ + readonly url: string + + /** The path to which the repository should be cloned. */ + readonly path: string + + /** Called when the destination path changes. */ + readonly onPathChanged: (path: string) => void + + /** Called when the URL to clone changes. */ + readonly onUrlChanged: (url: string) => void + + /** + * Called when the user should be prompted to choose a directory to clone to. + */ + readonly onChooseDirectory: () => Promise +} + +/** The component for cloning a repository. */ +export class CloneGenericRepository extends React.Component< + ICloneGenericRepositoryProps, + {} +> { + public render() { + return ( + + + + Repository URL or GitHub username and repository +
(hubot/cool-repo) + + } + /> +
+ + + + + +
+ ) + } + + private onUrlChanged = (url: string) => { + this.props.onUrlChanged(url) + } +} diff --git a/app/src/ui/clone-repository/clone-github-repository.tsx b/app/src/ui/clone-repository/clone-github-repository.tsx new file mode 100644 index 0000000000..2ccc661132 --- /dev/null +++ b/app/src/ui/clone-repository/clone-github-repository.tsx @@ -0,0 +1,113 @@ +import * as React from 'react' + +import { Account } from '../../models/account' +import { DialogContent } from '../dialog' +import { TextBox } from '../lib/text-box' +import { Row } from '../lib/row' +import { Button } from '../lib/button' +import { IAPIRepository } from '../../lib/api' +import { CloneableRepositoryFilterList } from './cloneable-repository-filter-list' +import { ClickSource } from '../lib/list' + +interface ICloneGithubRepositoryProps { + /** The account to clone from. */ + readonly account: Account + + /** The path to clone to. */ + readonly path: string + + /** Called when the destination path changes. */ + readonly onPathChanged: (path: string) => void + + /** + * Called when the user should be prompted to choose a destination directory. + */ + readonly onChooseDirectory: () => Promise + + /** + * The currently selected repository, or null if no repository + * is selected. + */ + readonly selectedItem: IAPIRepository | null + + /** Called when a repository is selected. */ + readonly onSelectionChanged: (selectedItem: IAPIRepository | null) => void + + /** + * The list of repositories that the account has explicit permissions + * to access, or null if no repositories has been loaded yet. + */ + readonly repositories: ReadonlyArray | null + + /** + * Whether or not the list of repositories is currently being loaded + * by the API Repositories Store. This determines whether the loading + * indicator is shown or not. + */ + readonly loading: boolean + + /** + * The contents of the filter text box used to filter the list of + * repositories. + */ + readonly filterText: string + + /** + * Called when the filter text is changed by the user entering a new + * value in the filter text box. + */ + readonly onFilterTextChanged: (filterText: string) => void + + /** + * Called when the user requests a refresh of the repositories + * available for cloning. + */ + readonly onRefreshRepositories: (account: Account) => void + + /** + * This function will be called when a pointer device is pressed and then + * released on a selectable row. Note that this follows the conventions + * of button elements such that pressing Enter or Space on a keyboard + * while focused on a particular row will also trigger this event. Consumers + * can differentiate between the two using the source parameter. + * + * Consumers of this event do _not_ have to call event.preventDefault, + * when this event is subscribed to the list will automatically call it. + */ + readonly onItemClicked: ( + repository: IAPIRepository, + source: ClickSource + ) => void +} + +export class CloneGithubRepository extends React.PureComponent { + public render() { + return ( + + + + + + + + + + + ) + } +} diff --git a/app/src/ui/clone-repository/clone-repository.tsx b/app/src/ui/clone-repository/clone-repository.tsx new file mode 100644 index 0000000000..dc70a90970 --- /dev/null +++ b/app/src/ui/clone-repository/clone-repository.tsx @@ -0,0 +1,771 @@ +import * as Path from 'path' +import * as React from 'react' +import { Dispatcher } from '../dispatcher' +import { getDefaultDir, setDefaultDir } from '../lib/default-dir' +import { Account } from '../../models/account' +import { FoldoutType } from '../../lib/app-state' +import { + IRepositoryIdentifier, + parseRepositoryIdentifier, + parseRemote, +} from '../../lib/remote-parsing' +import { findAccountForRemoteURL } from '../../lib/find-account' +import { API, IAPIRepository, IAPIRepositoryCloneInfo } from '../../lib/api' +import { Dialog, DialogError, DialogFooter, DialogContent } from '../dialog' +import { TabBar } from '../tab-bar' +import { CloneRepositoryTab } from '../../models/clone-repository-tab' +import { CloneGenericRepository } from './clone-generic-repository' +import { CloneGithubRepository } from './clone-github-repository' +import { assertNever } from '../../lib/fatal-error' +import { CallToAction } from '../lib/call-to-action' +import { IAccountRepositories } from '../../lib/stores/api-repositories-store' +import { merge } from '../../lib/merge' +import { ClickSource } from '../lib/list' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { showOpenDialog, showSaveDialog } from '../main-process-proxy' +import { readdir } from 'fs/promises' +import { isTopMostDialog } from '../dialog/is-top-most' + +interface ICloneRepositoryProps { + readonly dispatcher: Dispatcher + readonly onDismissed: () => void + + /** The logged in accounts. */ + readonly dotComAccount: Account | null + + /** The logged in Enterprise account. */ + readonly enterpriseAccount: Account | null + + /** The initial URL or `owner/name` shortcut to use. */ + readonly initialURL: string | null + + /** The currently select tab. */ + readonly selectedTab: CloneRepositoryTab + + /** Called when the user selects a tab. */ + readonly onTabSelected: (tab: CloneRepositoryTab) => void + + /** + * A map keyed on a user account (GitHub.com or GitHub Enterprise) + * containing an object with repositories that the authenticated + * user has explicit permission (:read, :write, or :admin) to access + * as well as information about whether the list of repositories + * is currently being loaded or not. + * + * If a currently signed in account is missing from the map that + * means that the list of accessible repositories has not yet been + * loaded. An entry for an account with an empty list of repositories + * means that no accessible repositories was found for the account. + * + * See the ApiRepositoriesStore for more details on loading repositories + */ + readonly apiRepositories: ReadonlyMap + + /** + * Called when the user requests a refresh of the repositories + * available for cloning. + */ + readonly onRefreshRepositories: (account: Account) => void + + /** Whether the dialog is the top most in the dialog stack */ + readonly isTopMost: boolean +} + +interface ICloneRepositoryState { + /** A copy of the path state field which is set when the component initializes. + * + * This value, as opposed to the path state variable, doesn't change for the + * lifetime of the component. Used to keep track of whether the user has + * modified the path state field which influences whether we show a + * warning about the directory already existing or not. + * + * See the onWindowFocus method for more information. + */ + readonly initialPath: string | null + + /** Are we currently trying to load the entered repository? */ + readonly loading: boolean + + /** + * The persisted state of the CloneGitHubRepository component for + * the GitHub.com account. + */ + readonly dotComTabState: IGitHubTabState + + /** + * The persisted state of the CloneGitHubRepository component for + * the GitHub Enterprise account. + */ + readonly enterpriseTabState: IGitHubTabState + + /** + * The persisted state of the CloneGenericRepository component. + */ + readonly urlTabState: IUrlTabState +} + +/** + * Common persisted state for the CloneGitHubRepository and + * CloneGenericRepository components. + */ +interface IBaseTabState { + /** The current error if one occurred. */ + readonly error: Error | null + + /** + * The repository identifier that was last parsed from the user-entered URL. + */ + readonly lastParsedIdentifier: IRepositoryIdentifier | null + + /** The local path to clone to. */ + readonly path: string | null + + /** The user-entered URL or `owner/name` shortcut. */ + readonly url: string +} + +interface IUrlTabState extends IBaseTabState { + readonly kind: 'urlTabState' +} + +/** + * Persisted state for the CloneGitHubRepository component. + */ +interface IGitHubTabState extends IBaseTabState { + readonly kind: 'dotComTabState' | 'enterpriseTabState' + + /** + * The contents of the filter text box used to filter the list of + * repositories. + */ + readonly filterText: string + + /** + * The currently selected repository, or null if no repository + * is selected. + */ + readonly selectedItem: IAPIRepository | null +} + +/** The component for cloning a repository. */ +export class CloneRepository extends React.Component< + ICloneRepositoryProps, + ICloneRepositoryState +> { + private checkIsTopMostDialog = isTopMostDialog( + () => { + this.validatePath() + window.addEventListener('focus', this.onWindowFocus) + }, + () => { + window.removeEventListener('focus', this.onWindowFocus) + } + ) + + public constructor(props: ICloneRepositoryProps) { + super(props) + + const defaultDirectory = null + + const initialBaseTabState: IBaseTabState = { + error: null, + lastParsedIdentifier: null, + path: defaultDirectory, + url: this.props.initialURL || '', + } + + this.state = { + initialPath: defaultDirectory, + loading: false, + dotComTabState: { + kind: 'dotComTabState', + filterText: '', + selectedItem: null, + ...initialBaseTabState, + }, + enterpriseTabState: { + kind: 'enterpriseTabState', + filterText: '', + selectedItem: null, + ...initialBaseTabState, + }, + urlTabState: { + kind: 'urlTabState', + ...initialBaseTabState, + }, + } + + this.initializePath() + } + + public componentDidUpdate(prevProps: ICloneRepositoryProps) { + if (prevProps.selectedTab !== this.props.selectedTab) { + this.validatePath() + } + + if (prevProps.initialURL !== this.props.initialURL) { + this.updateUrl(this.props.initialURL || '') + } + + this.checkIsTopMostDialog(this.props.isTopMost) + } + + public componentDidMount() { + const initialURL = this.props.initialURL + if (initialURL) { + this.updateUrl(initialURL) + } + + this.checkIsTopMostDialog(this.props.isTopMost) + } + + public componentWillUnmount(): void { + this.checkIsTopMostDialog(false) + } + + private initializePath = async () => { + const initialPath = await getDefaultDir() + const dotComTabState = { ...this.state.dotComTabState, path: initialPath } + const enterpriseTabState = { + ...this.state.enterpriseTabState, + path: initialPath, + } + const urlTabState = { ...this.state.urlTabState, path: initialPath } + this.setState({ + initialPath, + dotComTabState, + enterpriseTabState, + urlTabState, + }) + + // Update the local path based on the current url now that we have an + // initial path + const selectedTabState = this.getSelectedTabState() + this.updateUrl(selectedTabState.url) + } + + public render() { + const { error } = this.getSelectedTabState() + return ( + + + GitHub.com + GitHub Enterprise + URL + + + {error ? {error.message} : null} + + {this.renderActiveTab()} + + {this.renderFooter()} + + ) + } + + private checkIfCloningDisabled = () => { + const tabState = this.getSelectedTabState() + const { error, url, path } = tabState + const { loading } = this.state + + const disabled = + url.length === 0 || + path == null || + path.length === 0 || + loading || + error !== null + + return disabled + } + + private renderFooter() { + const selectedTab = this.props.selectedTab + if ( + selectedTab !== CloneRepositoryTab.Generic && + !this.getAccountForTab(selectedTab) + ) { + return null + } + + const disabled = this.checkIfCloningDisabled() + + return ( + + + + ) + } + + private onTabClicked = (tab: CloneRepositoryTab) => { + this.props.onTabSelected(tab) + } + + private onPathChanged = (path: string) => { + this.setSelectedTabState({ path }, this.validatePath) + } + + private renderActiveTab() { + const tab = this.props.selectedTab + + switch (tab) { + case CloneRepositoryTab.Generic: + const tabState = this.state.urlTabState + return ( + + ) + + case CloneRepositoryTab.DotCom: + case CloneRepositoryTab.Enterprise: { + const account = this.getAccountForTab(tab) + if (!account) { + return {this.renderSignIn(tab)} + } else { + const accountState = this.props.apiRepositories.get(account) + const repositories = + accountState === undefined ? null : accountState.repositories + const loading = + accountState === undefined ? false : accountState.loading + const tabState = this.getGitHubTabState(tab) + + return ( + + ) + } + } + default: + return assertNever(tab, `Unknown tab: ${tab}`) + } + } + + private getAccountForTab(tab: CloneRepositoryTab): Account | null { + switch (tab) { + case CloneRepositoryTab.DotCom: + return this.props.dotComAccount + case CloneRepositoryTab.Enterprise: + return this.props.enterpriseAccount + default: + return null + } + } + + private getGitHubTabState( + tab: CloneRepositoryTab.DotCom | CloneRepositoryTab.Enterprise + ): IGitHubTabState { + if (tab === CloneRepositoryTab.DotCom) { + return this.state.dotComTabState + } else if (tab === CloneRepositoryTab.Enterprise) { + return this.state.enterpriseTabState + } else { + return assertNever(tab, `Unknown tab: ${tab}`) + } + } + + private getTabState(tab: CloneRepositoryTab): IBaseTabState { + if (tab === CloneRepositoryTab.DotCom) { + return this.state.dotComTabState + } else if (tab === CloneRepositoryTab.Enterprise) { + return this.state.enterpriseTabState + } else if (tab === CloneRepositoryTab.Generic) { + return this.state.urlTabState + } else { + return assertNever(tab, `Unknown tab: ${tab}`) + } + } + + private getSelectedTabState(): IBaseTabState { + return this.getTabState(this.props.selectedTab) + } + + /** + * Update the state for the currently selected tab. Note that + * since the selected tab can be using either IGitHubTabState + * or IUrlTabState this method can only accept subset state + * shared between the two types. + */ + private setSelectedTabState( + state: Pick, + callback?: () => void + ) { + this.setTabState(state, this.props.selectedTab, callback) + } + + /** + * Merge the current state with the provided subset of state + * for the provided tab. + */ + private setTabState( + state: Pick, + tab: CloneRepositoryTab, + callback?: () => void + ): void { + if (tab === CloneRepositoryTab.DotCom) { + this.setState( + prevState => ({ + dotComTabState: { + ...prevState.dotComTabState, + ...state, + }, + }), + callback + ) + } else if (tab === CloneRepositoryTab.Enterprise) { + this.setState( + prevState => ({ + enterpriseTabState: { + ...prevState.enterpriseTabState, + ...state, + }, + }), + callback + ) + } else if (tab === CloneRepositoryTab.Generic) { + this.setState( + prevState => ({ + urlTabState: { ...prevState.urlTabState, ...state }, + }), + callback + ) + } else { + return assertNever(tab, `Unknown tab: ${tab}`) + } + } + + private setGitHubTabState( + tabState: Pick, + tab: CloneRepositoryTab.DotCom | CloneRepositoryTab.Enterprise + ): void { + if (tab === CloneRepositoryTab.DotCom) { + this.setState(prevState => ({ + dotComTabState: merge(prevState.dotComTabState, tabState), + })) + } else if (tab === CloneRepositoryTab.Enterprise) { + this.setState(prevState => ({ + enterpriseTabState: merge(prevState.enterpriseTabState, tabState), + })) + } else { + return assertNever(tab, `Unknown tab: ${tab}`) + } + } + + private renderSignIn(tab: CloneRepositoryTab) { + const signInTitle = __DARWIN__ ? 'Sign In' : 'Sign in' + switch (tab) { + case CloneRepositoryTab.DotCom: + return ( + +
+ Sign in to your GitHub.com account to access your repositories. +
+
+ ) + case CloneRepositoryTab.Enterprise: + return ( + +
+ If you have a GitHub Enterprise or AE account at work, sign in to + it to get access to your repositories. +
+
+ ) + case CloneRepositoryTab.Generic: + return null + default: + return assertNever(tab, `Unknown sign in tab: ${tab}`) + } + } + + private signInDotCom = () => { + this.props.dispatcher.showDotComSignInDialog() + } + + private signInEnterprise = () => { + this.props.dispatcher.showEnterpriseSignInDialog() + } + + private onFilterTextChanged = (filterText: string) => { + if (this.props.selectedTab !== CloneRepositoryTab.Generic) { + this.setGitHubTabState({ filterText }, this.props.selectedTab) + } + } + + private onSelectionChanged = (selectedItem: IAPIRepository | null) => { + if (this.props.selectedTab !== CloneRepositoryTab.Generic) { + this.setGitHubTabState({ selectedItem }, this.props.selectedTab) + this.updateUrl(selectedItem === null ? '' : selectedItem.clone_url) + } + } + + private validatePath = async () => { + const tabState = this.getSelectedTabState() + const { path, url, error } = tabState + const { initialPath } = this.state + const isDefaultPath = initialPath === path + const isURLNotEntered = url === '' + + if (isDefaultPath && isURLNotEntered) { + if (error) { + this.setSelectedTabState({ error: null }) + } + } else { + const pathValidation = await this.validateEmptyFolder(path) + + // We only care about the result if the path hasn't + // changed since we went async + const newTabState = this.getSelectedTabState() + if (newTabState.path === path) { + this.setSelectedTabState({ error: pathValidation, path }) + } + } + } + + private onChooseDirectory = async () => { + // We received feedback (#12812) that using the save dialog is confusing on + // windows due to appearing to require a file selection. This is not the case + // on mac where it more clearly shows directory creation. + if (__DARWIN__) { + return this.onChooseWithSaveDialog() + } + + return this.onChooseWithOpenDialog() + } + + private onChooseWithOpenDialog = async (): Promise => { + const path = await showOpenDialog({ + properties: ['createDirectory', 'openDirectory'], + }) + + if (path === null) { + return + } + + const tabState = this.getSelectedTabState() + const lastParsedIdentifier = tabState.lastParsedIdentifier + const directory = lastParsedIdentifier + ? Path.join(path, lastParsedIdentifier.name) + : path + + this.setSelectedTabState( + { path: directory, error: null }, + this.validatePath + ) + + return directory + } + + private onChooseWithSaveDialog = async (): Promise => { + const tabState = this.getSelectedTabState() + + const path = await showSaveDialog({ + buttonLabel: 'Select', + nameFieldLabel: 'Clone As:', + showsTagField: false, + defaultPath: tabState.path ?? '', + properties: ['createDirectory'], + }) + + if (path == null) { + return + } + + this.setSelectedTabState({ path, error: null }, this.validatePath) + + return path + } + + private updateUrl = async (url: string) => { + const parsed = parseRepositoryIdentifier(url) + const tabState = this.getSelectedTabState() + const lastParsedIdentifier = tabState.lastParsedIdentifier + + // If there is no path yet, just update the url + if (tabState.path === null) { + this.setSelectedTabState({ url }, this.validatePath) + return + } + + let newPath: string + + const dirPath = tabState.path + if (lastParsedIdentifier) { + if (parsed) { + newPath = Path.join(Path.dirname(dirPath), parsed.name) + } else { + newPath = Path.dirname(dirPath) + } + } else if (parsed) { + newPath = Path.join(dirPath, parsed.name) + } else { + newPath = dirPath + } + + this.setSelectedTabState( + { + url, + lastParsedIdentifier: parsed, + path: newPath, + }, + this.validatePath + ) + } + + private async validateEmptyFolder( + path: string | null + ): Promise { + if (path === null) { + return new Error( + 'Unable to read path on disk. Please check the path and try again.' + ) + } + + try { + const directoryFiles = await readdir(path) + + if (directoryFiles.length === 0) { + return null + } else { + return new Error( + 'This folder contains files. Git can only clone to empty folders.' + ) + } + } catch (error) { + if (error.code === 'ENOTDIR') { + // path refers to a file or other file system entry + return new Error( + 'There is already a file with this name. Git can only clone to a folder.' + ) + } + + if (error.code === 'ENOENT') { + // Folder does not exist + return null + } + + log.error( + 'CloneRepository: Path validation failed. Error: ' + error.message + ) + return new Error( + 'Unable to read path on disk. Please check the path and try again.' + ) + } + } + + /** + * Lookup the account associated with the clone (if applicable) and resolve + * the repository alias to the clone URL and the repository default branch, + * if possible. + */ + private async resolveCloneInfo(): Promise { + const { url, lastParsedIdentifier } = this.getSelectedTabState() + + const accounts = new Array() + if (this.props.dotComAccount) { + accounts.push(this.props.dotComAccount) + } + + if (this.props.enterpriseAccount) { + accounts.push(this.props.enterpriseAccount) + } + + const account = await findAccountForRemoteURL(url, accounts) + if (lastParsedIdentifier !== null && account !== null) { + const api = API.fromAccount(account) + const { owner, name } = lastParsedIdentifier + // Respect the user's preference if they provided an SSH URL + const protocol = parseRemote(url)?.protocol + + return api.fetchRepositoryCloneInfo(owner, name, protocol).catch(err => { + log.error(`Failed to look up repository clone info for '${url}'`, err) + return { url } + }) + } + + return { url } + } + + private onItemClicked = (repository: IAPIRepository, source: ClickSource) => { + if (source.kind === 'keyboard' && source.event.key === 'Enter') { + if (this.checkIfCloningDisabled() === false) { + this.clone() + } + } + } + + private clone = async () => { + this.setState({ loading: true }) + + const cloneInfo = await this.resolveCloneInfo() + const { path } = this.getSelectedTabState() + + if (path == null) { + const error = new Error(`Directory could not be created at this path.`) + this.setState({ loading: false }) + this.setSelectedTabState({ error }) + return + } + + if (!cloneInfo) { + const error = new Error( + `We couldn't find that repository. Check that you are logged in, the network is accessible, and the URL or repository alias are spelled correctly.` + ) + this.setState({ loading: false }) + this.setSelectedTabState({ error }) + return + } + + const { url, defaultBranch } = cloneInfo + + this.props.dispatcher.closeFoldout(FoldoutType.Repository) + try { + this.cloneImpl(url.trim(), path, defaultBranch) + } catch (e) { + log.error(`CloneRepository: clone failed to complete to ${path}`, e) + this.setState({ loading: false }) + this.setSelectedTabState({ error: e }) + } + } + + private cloneImpl(url: string, path: string, defaultBranch?: string) { + this.props.dispatcher.clone(url, path, { defaultBranch }) + this.props.onDismissed() + + setDefaultDir(Path.resolve(path, '..')) + } + + private onWindowFocus = () => { + // Verify the path after focus has been regained in + // case the directory or directory contents has been + // created/removed/altered while the user wasn't in-app. + this.validatePath() + } +} diff --git a/app/src/ui/clone-repository/cloneable-repository-filter-list.tsx b/app/src/ui/clone-repository/cloneable-repository-filter-list.tsx new file mode 100644 index 0000000000..6b77eafb40 --- /dev/null +++ b/app/src/ui/clone-repository/cloneable-repository-filter-list.tsx @@ -0,0 +1,305 @@ +import * as React from 'react' +import { Account } from '../../models/account' +import { FilterList, IFilterListGroup } from '../lib/filter-list' +import { IAPIRepository, getDotComAPIEndpoint, getHTMLURL } from '../../lib/api' +import { + ICloneableRepositoryListItem, + groupRepositories, + YourRepositoriesIdentifier, +} from './group-repositories' +import memoizeOne from 'memoize-one' +import { Button } from '../lib/button' +import { IMatches } from '../../lib/fuzzy-find' +import { Octicon, syncClockwise } from '../octicons' +import { HighlightText } from '../lib/highlight-text' +import { ClickSource } from '../lib/list' +import { LinkButton } from '../lib/link-button' +import { Ref } from '../lib/ref' +import { enableSectionList } from '../../lib/feature-flag' +import { SectionFilterList } from '../lib/section-filter-list' + +interface ICloneableRepositoryFilterListProps { + /** The account to clone from. */ + readonly account: Account + + /** + * The currently selected repository, or null if no repository + * is selected. + */ + readonly selectedItem: IAPIRepository | null + + /** Called when a repository is selected. */ + readonly onSelectionChanged: (selectedItem: IAPIRepository | null) => void + + /** + * The list of repositories that the account has explicit permissions + * to access, or null if no repositories has been loaded yet. + */ + readonly repositories: ReadonlyArray | null + + /** + * Whether or not the list of repositories is currently being loaded + * by the API Repositories Store. This determines whether the loading + * indicator is shown or not. + */ + readonly loading: boolean + + /** + * The contents of the filter text box used to filter the list of + * repositories. + */ + readonly filterText: string + + /** + * Called when the filter text is changed by the user entering a new + * value in the filter text box. + */ + readonly onFilterTextChanged: (filterText: string) => void + + /** + * Called when the user requests a refresh of the repositories + * available for cloning. + */ + readonly onRefreshRepositories: (account: Account) => void + + /** + * This function will be called when a pointer device is pressed and then + * released on a selectable row. Note that this follows the conventions + * of button elements such that pressing Enter or Space on a keyboard + * while focused on a particular row will also trigger this event. Consumers + * can differentiate between the two using the source parameter. + * + * Consumers of this event do _not_ have to call event.preventDefault, + * when this event is subscribed to the list will automatically call it. + */ + readonly onItemClicked?: ( + repository: IAPIRepository, + source: ClickSource + ) => void +} + +const RowHeight = 31 + +/** + * Iterate over all groups until a list item is found that matches + * the clone url of the provided repository. + */ +function findMatchingListItem( + groups: ReadonlyArray>, + selectedRepository: IAPIRepository | null +) { + if (selectedRepository !== null) { + for (const group of groups) { + for (const item of group.items) { + if (item.url === selectedRepository.clone_url) { + return item + } + } + } + } + + return null +} + +/** + * Attempt to locate the source IAPIRepository instance given + * an ICloneableRepositoryList item using clone_url for the + * equality comparison. + */ +function findRepositoryForListItem( + repositories: ReadonlyArray, + listItem: ICloneableRepositoryListItem +) { + return repositories.find(r => r.clone_url === listItem.url) || null +} + +export class CloneableRepositoryFilterList extends React.PureComponent { + /** + * A memoized function for grouping repositories for display + * in the FilterList. The group will not be recomputed as long + * as the provided list of repositories is equal to the last + * time the method was called (reference equality). + */ + private getRepositoryGroups = memoizeOne( + (repositories: ReadonlyArray | null, login: string) => + repositories === null ? [] : groupRepositories(repositories, login) + ) + + /** + * A memoized function for finding the selected list item based + * on an IAPIRepository instance. The selected item will not be + * recomputed as long as the provided list of repositories and + * the selected data object is equal to the last time the method + * was called (reference equality). + * + * See findMatchingListItem for more details. + */ + private getSelectedListItem = memoizeOne(findMatchingListItem) + + public componentDidMount() { + if (this.props.repositories === null) { + this.refreshRepositories() + } + } + + public componentDidUpdate(prevProps: ICloneableRepositoryFilterListProps) { + if ( + prevProps.repositories !== this.props.repositories && + this.props.repositories === null + ) { + this.refreshRepositories() + } + } + + private refreshRepositories = () => { + this.props.onRefreshRepositories(this.props.account) + } + + public render() { + const { repositories, account, selectedItem } = this.props + + const groups = this.getRepositoryGroups(repositories, account.login) + const getGroupAriaLabel = (group: number) => { + const groupIdentifier = groups[group].identifier + return groupIdentifier === YourRepositoriesIdentifier + ? this.getYourRepositoriesLabel() + : groupIdentifier + } + + const selectedListItem = this.getSelectedListItem(groups, selectedItem) + const ListComponent = enableSectionList() ? SectionFilterList : FilterList + const filterListProps: typeof ListComponent['prototype']['props'] = { + className: 'clone-github-repo', + rowHeight: RowHeight, + selectedItem: selectedListItem, + renderItem: this.renderItem, + renderGroupHeader: this.renderGroupHeader, + onSelectionChanged: this.onSelectionChanged, + invalidationProps: groups, + groups: groups, + filterText: this.props.filterText, + onFilterTextChanged: this.props.onFilterTextChanged, + renderNoItems: this.renderNoItems, + renderPostFilter: this.renderPostFilter, + onItemClick: this.props.onItemClicked ? this.onItemClick : undefined, + placeholderText: 'Filter your repositories', + getGroupAriaLabel, + } + + return + } + + private onItemClick = ( + item: ICloneableRepositoryListItem, + source: ClickSource + ) => { + const { onItemClicked, repositories } = this.props + + if (onItemClicked === undefined || repositories === null) { + return + } + + const selectedItem = findRepositoryForListItem(repositories, item) + + if (selectedItem !== null) { + onItemClicked(selectedItem, source) + } + } + + private onSelectionChanged = (item: ICloneableRepositoryListItem | null) => { + if (item === null || this.props.repositories === null) { + this.props.onSelectionChanged(null) + } else { + this.props.onSelectionChanged( + findRepositoryForListItem(this.props.repositories, item) + ) + } + } + + private getYourRepositoriesLabel = () => { + return __DARWIN__ ? 'Your Repositories' : 'Your repositories' + } + + private renderGroupHeader = (identifier: string) => { + let header = identifier + if (identifier === YourRepositoriesIdentifier) { + header = this.getYourRepositoriesLabel() + } + return ( +
+ {header} +
+ ) + } + + private renderItem = ( + item: ICloneableRepositoryListItem, + matches: IMatches + ) => { + return ( +
+ +
+ +
+ {item.archived &&
Archived
} +
+ ) + } + + private renderPostFilter = () => { + const tooltip = 'Refresh the list of repositories' + + return ( + + ) + } + + private renderNoItems = () => { + const { loading, repositories } = this.props + const endpointName = + this.props.account.endpoint === getDotComAPIEndpoint() + ? 'GitHub.com' + : getHTMLURL(this.props.account.endpoint) + + if (loading && (repositories === null || repositories.length === 0)) { + return ( +
{`Loading repositories from ${endpointName}…`}
+ ) + } + + if (this.props.filterText.length !== 0) { + return ( +
+
+ Sorry, I can't find any repository matching{' '} + {this.props.filterText} +
+
+ ) + } + + return ( +
+
+ Looks like there are no repositories for{' '} + {this.props.account.login} on {endpointName}.{' '} + + Refresh this list + {' '} + if you've created a repository recently. +
+
+ ) + } +} diff --git a/app/src/ui/clone-repository/group-repositories.ts b/app/src/ui/clone-repository/group-repositories.ts new file mode 100644 index 0000000000..160da063e1 --- /dev/null +++ b/app/src/ui/clone-repository/group-repositories.ts @@ -0,0 +1,75 @@ +import { IAPIRepository } from '../../lib/api' +import { IFilterListGroup, IFilterListItem } from '../lib/filter-list' +import { OcticonSymbolType } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { entries, groupBy } from 'lodash' +import { caseInsensitiveEquals, compare } from '../../lib/compare' + +/** The identifier for the "Your Repositories" grouping. */ +export const YourRepositoriesIdentifier = 'your-repositories' + +export interface ICloneableRepositoryListItem extends IFilterListItem { + /** The identifier for the item. */ + readonly id: string + + /** The search text. */ + readonly text: ReadonlyArray + + /** The name of the repository. */ + readonly name: string + + /** The icon for the repo. */ + readonly icon: OcticonSymbolType + + /** The clone URL. */ + readonly url: string + + /** Whether or not the repository is archived */ + readonly archived?: boolean +} + +function getIcon(gitHubRepo: IAPIRepository): OcticonSymbolType { + if (gitHubRepo.private) { + return OcticonSymbol.lock + } + if (gitHubRepo.fork) { + return OcticonSymbol.repoForked + } + + return OcticonSymbol.repo +} + +const toListItems = (repositories: ReadonlyArray) => + repositories + .map(repo => ({ + id: repo.html_url, + text: [`${repo.owner.login}/${repo.name}`], + url: repo.clone_url, + name: repo.name, + icon: getIcon(repo), + archived: repo.archived, + })) + .sort((x, y) => compare(x.name, y.name)) + +export function groupRepositories( + repositories: ReadonlyArray, + login: string +): ReadonlyArray> { + const groups = groupBy(repositories, x => + caseInsensitiveEquals(x.owner.login, login) + ? YourRepositoriesIdentifier + : x.owner.login + ) + + return entries(groups) + .map(([identifier, repos]) => ({ identifier, items: toListItems(repos) })) + .sort((x, y) => { + if (x.identifier === YourRepositoriesIdentifier) { + return -1 + } else if (y.identifier === YourRepositoriesIdentifier) { + return 1 + } else { + return compare(x.identifier, y.identifier) + } + }) +} diff --git a/app/src/ui/clone-repository/index.tsx b/app/src/ui/clone-repository/index.tsx new file mode 100644 index 0000000000..94e071adb8 --- /dev/null +++ b/app/src/ui/clone-repository/index.tsx @@ -0,0 +1 @@ +export * from './clone-repository' diff --git a/app/src/ui/cloning-repository.tsx b/app/src/ui/cloning-repository.tsx new file mode 100644 index 0000000000..e6352b657a --- /dev/null +++ b/app/src/ui/cloning-repository.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' + +import { CloningRepository } from '../models/cloning-repository' +import { ICloneProgress } from '../models/progress' +import { Octicon } from './octicons' +import * as OcticonSymbol from './octicons/octicons.generated' +import { UiView } from './ui-view' + +interface ICloningRepositoryProps { + readonly repository: CloningRepository + readonly progress: ICloneProgress +} + +/** The component for displaying a cloning repository's progress. */ +export class CloningRepositoryView extends React.Component< + ICloningRepositoryProps, + {} +> { + public render() { + /* The progress element won't take null for an answer. + * Only way to get it to be indeterminate is by using undefined */ + const progressValue = this.props.progress.value || undefined + + return ( + +
+ +
Cloning {this.props.repository.name}
+
+ +
+ {this.props.progress.description} +
+
+ ) + } +} diff --git a/app/src/ui/commit-message/commit-message-dialog.tsx b/app/src/ui/commit-message/commit-message-dialog.tsx new file mode 100644 index 0000000000..ee9b77431d --- /dev/null +++ b/app/src/ui/commit-message/commit-message-dialog.tsx @@ -0,0 +1,198 @@ +import * as React from 'react' +import { Dispatcher } from '../dispatcher' +import { + isRepositoryWithGitHubRepository, + Repository, +} from '../../models/repository' +import { Dialog, DialogContent } from '../dialog' +import { ICommitContext } from '../../models/commit' +import { CommitIdentity } from '../../models/commit-identity' +import { ICommitMessage } from '../../models/commit-message' +import { IAutocompletionProvider } from '../autocompletion' +import { Author, UnknownAuthor } from '../../models/author' +import { CommitMessage } from '../changes/commit-message' +import { noop } from 'lodash' +import { Popup } from '../../models/popup' +import { Foldout } from '../../lib/app-state' +import { Account } from '../../models/account' +import { pick } from '../../lib/pick' +import { RepoRulesInfo } from '../../models/repo-rules' +import { IAheadBehind } from '../../models/branch' + +interface ICommitMessageDialogProps { + /** + * A list of autocompletion providers that should be enabled for this input. + */ + readonly autocompletionProviders: ReadonlyArray> + + /** + * The branch that will be modified by the commit + */ + readonly branch: string | null + + /** + * A list of authors (name, email pairs) which have been entered into the + * co-authors input box in the commit form and which _may_ be used in the + * subsequent commit to add Co-Authored-By commit message trailers depending + * on whether the user has chosen to do so. + */ + readonly coAuthors: ReadonlyArray + + /** + * The name and email that will be used for the author info when committing + * barring any race where user.name/user.email is updated between us reading + * it and a commit being made (ie we don't currently use this value explicitly + * when committing) + */ + readonly commitAuthor: CommitIdentity | null + + /** The commit message for a work-in-progress commit. */ + readonly commitMessage: ICommitMessage | null + + /** + * Whether or not the app should use spell check on commit summary and description + */ + readonly commitSpellcheckEnabled: boolean + + /** Text for the ok button */ + readonly dialogButtonText: string + + /** The title to be displayed in the dialog */ + readonly dialogTitle: string + + /** The application dispatcher */ + readonly dispatcher: Dispatcher + + /** Whether to prepopulate the commit summary with the placeholder or summary*/ + readonly prepopulateCommitSummary: boolean + + /** The current repository to commit against. */ + readonly repository: Repository + + /** Whether to warn the user that they are on a protected branch. */ + readonly showBranchProtected: boolean + + /** Repository rules that apply to the branch. */ + readonly repoRulesInfo: RepoRulesInfo + + readonly aheadBehind: IAheadBehind | null + + /** + * Whether or not to show a field for adding co-authors to a commit + * (currently only supported for GH/GHE repositories) + */ + readonly showCoAuthoredBy: boolean + + /** Whether to warn the user that they don't have write access */ + readonly showNoWriteAccess: boolean + + /** Method to run when dialog is dismissed */ + readonly onDismissed: () => void + + /** Method to run when dialog is submitted */ + readonly onSubmitCommitMessage: (context: ICommitContext) => Promise + + readonly repositoryAccount: Account | null +} + +interface ICommitMessageDialogState { + readonly showCoAuthoredBy: boolean + readonly coAuthors: ReadonlyArray +} + +export class CommitMessageDialog extends React.Component< + ICommitMessageDialogProps, + ICommitMessageDialogState +> { + public constructor(props: ICommitMessageDialogProps) { + super(props) + this.state = pick(props, 'showCoAuthoredBy', 'coAuthors') + } + + public render() { + return ( + + + + + + ) + } + + private onCoAuthorsUpdated = (coAuthors: ReadonlyArray) => + this.setState({ coAuthors }) + + private onShowCoAuthorsChanged = (showCoAuthoredBy: boolean) => + this.setState({ showCoAuthoredBy }) + + private onConfirmCommitWithUnknownCoAuthors = ( + coAuthors: ReadonlyArray, + onCommitAnyway: () => void + ) => { + const { dispatcher } = this.props + dispatcher.showUnknownAuthorsCommitWarning(coAuthors, onCommitAnyway) + } + + private onRefreshAuthor = () => + this.props.dispatcher.refreshAuthor(this.props.repository) + + private onShowPopup = (p: Popup) => this.props.dispatcher.showPopup(p) + private onShowFoldout = (f: Foldout) => this.props.dispatcher.showFoldout(f) + + private onCommitSpellcheckEnabledChanged = (enabled: boolean) => + this.props.dispatcher.setCommitSpellcheckEnabled(enabled) + + private onStopAmending = () => + this.props.dispatcher.stopAmendingRepository(this.props.repository) + + private onShowCreateForkDialog = () => { + if (isRepositoryWithGitHubRepository(this.props.repository)) { + this.props.dispatcher.showCreateForkDialog(this.props.repository) + } + } +} diff --git a/app/src/ui/create-branch/create-branch-dialog.tsx b/app/src/ui/create-branch/create-branch-dialog.tsx new file mode 100644 index 0000000000..f6a280da4d --- /dev/null +++ b/app/src/ui/create-branch/create-branch-dialog.tsx @@ -0,0 +1,685 @@ +import * as React from 'react' + +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { Branch, StartPoint } from '../../models/branch' +import { Row } from '../lib/row' +import { Ref } from '../lib/ref' +import { LinkButton } from '../lib/link-button' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { + VerticalSegmentedControl, + ISegmentedItem, +} from '../lib/vertical-segmented-control' +import { + TipState, + IUnbornRepository, + IDetachedHead, + IValidBranch, +} from '../../models/tip' +import { assertNever } from '../../lib/fatal-error' +import { renderBranchNameExistsOnRemoteWarning } from '../lib/branch-name-warnings' +import { getStartPoint } from '../../lib/create-branch' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { startTimer } from '../lib/timing' +import { GitHubRepository } from '../../models/github-repository' +import { RefNameTextBox } from '../lib/ref-name-text-box' +import { CommitOneLine } from '../../models/commit' +import { PopupType } from '../../models/popup' +import { RepositorySettingsTab } from '../repository-settings/repository-settings' +import { isRepositoryWithForkedGitHubRepository } from '../../models/repository' +import { API, APIRepoRuleType, IAPIRepoRuleset } from '../../lib/api' +import { Account } from '../../models/account' +import { getAccountForRepository } from '../../lib/get-account-for-repository' +import { InputError } from '../lib/input-description/input-error' +import { InputWarning } from '../lib/input-description/input-warning' +import { useRepoRulesLogic } from '../../lib/helpers/repo-rules' + +interface ICreateBranchProps { + readonly repository: Repository + readonly targetCommit?: CommitOneLine + readonly upstreamGitHubRepository: GitHubRepository | null + readonly accounts: ReadonlyArray + readonly cachedRepoRulesets: ReadonlyMap + readonly dispatcher: Dispatcher + readonly onBranchCreatedFromCommit?: () => void + readonly onDismissed: () => void + /** + * If provided, the branch creation is handled by the given method. + * + * It is also responsible for dismissing the popup. + */ + readonly createBranch?: ( + name: string, + startPoint: string | null, + noTrack: boolean + ) => void + readonly tip: IUnbornRepository | IDetachedHead | IValidBranch + readonly defaultBranch: Branch | null + readonly upstreamDefaultBranch: Branch | null + readonly allBranches: ReadonlyArray + readonly initialName: string + /** + * If provided, use as the okButtonText + */ + readonly okButtonText?: string + + /** + * If provided, use as the header + */ + readonly headerText?: string +} + +interface ICreateBranchState { + readonly currentError: { error: Error; isWarning: boolean } | null + readonly branchName: string + readonly startPoint: StartPoint + + /** + * Whether or not the dialog is currently creating a branch. This affects + * the dialog loading state as well as the rendering of the branch selector. + * + * When the dialog is creating a branch we take the tip and defaultBranch + * as they were in props at the time of creation and stick them in state + * so that we can maintain the layout of the branch selection parts even + * as the Tip changes during creation. + * + * Note: once branch creation has been initiated this value stays at true + * and will never revert to being false. If the branch creation operation + * fails this dialog will still be dismissed and an error dialog will be + * shown in its place. + */ + readonly isCreatingBranch: boolean + + /** + * The tip of the current repository, captured from props at the start + * of the create branch operation. + */ + readonly tipAtCreateStart: IUnbornRepository | IDetachedHead | IValidBranch + + /** + * The default branch of the current repository, captured from props at the + * start of the create branch operation. + */ + readonly defaultBranchAtCreateStart: Branch | null +} + +/** The Create Branch component. */ +export class CreateBranch extends React.Component< + ICreateBranchProps, + ICreateBranchState +> { + private branchRulesDebounceId: number | null = null + + private readonly ERRORS_ID = 'branch-name-errors' + + public constructor(props: ICreateBranchProps) { + super(props) + + const startPoint = getStartPoint(props, StartPoint.UpstreamDefaultBranch) + + this.state = { + currentError: null, + branchName: props.initialName, + startPoint, + isCreatingBranch: false, + tipAtCreateStart: props.tip, + defaultBranchAtCreateStart: getBranchForStartPoint(startPoint, props), + } + } + + public componentWillReceiveProps(nextProps: ICreateBranchProps) { + this.setState({ + startPoint: getStartPoint(nextProps, this.state.startPoint), + }) + + if (!this.state.isCreatingBranch) { + const defaultStartPoint = getStartPoint( + nextProps, + StartPoint.UpstreamDefaultBranch + ) + + this.setState({ + tipAtCreateStart: nextProps.tip, + defaultBranchAtCreateStart: getBranchForStartPoint( + defaultStartPoint, + nextProps + ), + }) + } + } + + public componentWillUnmount() { + if (this.branchRulesDebounceId !== null) { + window.clearTimeout(this.branchRulesDebounceId) + } + } + + private renderBranchSelection() { + const tip = this.state.isCreatingBranch + ? this.state.tipAtCreateStart + : this.props.tip + + const tipKind = tip.kind + const targetCommit = this.props.targetCommit + + if (targetCommit !== undefined) { + return ( +

+ Your new branch will be based on the commit '{targetCommit.summary}' ( + {targetCommit.sha.substring(0, 7)}) from your repository. +

+ ) + } else if (tip.kind === TipState.Detached) { + return ( +

+ You do not currently have any branch checked out (your HEAD reference + is detached). As such your new branch will be based on your currently + checked out commit ({tip.currentSha.substring(0, 7)} + ). +

+ ) + } else if (tip.kind === TipState.Unborn) { + return ( +

+ Your current branch is unborn (does not contain any commits). Creating + a new branch will rename the current branch. +

+ ) + } else if (tip.kind === TipState.Valid) { + if ( + this.props.upstreamGitHubRepository !== null && + this.props.upstreamDefaultBranch !== null + ) { + return this.renderForkBranchSelection( + tip.branch.name, + this.props.upstreamDefaultBranch, + this.props.upstreamGitHubRepository.fullName + ) + } + + const defaultBranch = this.state.isCreatingBranch + ? this.props.defaultBranch + : this.state.defaultBranchAtCreateStart + + return this.renderRegularBranchSelection(tip.branch.name, defaultBranch) + } else { + return assertNever(tip, `Unknown tip kind ${tipKind}`) + } + } + + private renderBranchNameErrors() { + const { currentError } = this.state + if (!currentError) { + return null + } + + if (currentError.isWarning) { + return ( + + + {currentError.error.message} + + + ) + } else { + return ( + + + {currentError.error.message} + + + ) + } + } + + private onBaseBranchChanged = (startPoint: StartPoint) => { + this.setState({ + startPoint, + }) + } + + public render() { + const disabled = + this.state.branchName.length <= 0 || + (!!this.state.currentError && !this.state.currentError.isWarning) || + /^\s*$/.test(this.state.branchName) + const hasError = !!this.state.currentError + + return ( + + + + + {this.renderBranchNameErrors()} + + {renderBranchNameExistsOnRemoteWarning( + this.state.branchName, + this.props.allBranches + )} + + {this.renderBranchSelection()} + + + + + + + ) + } + + private getHeaderText = (): string => { + if (this.props.headerText !== undefined) { + return this.props.headerText + } + + return __DARWIN__ ? 'Create a Branch' : 'Create a branch' + } + + private getOkButtonText = (): string => { + if (this.props.okButtonText !== undefined) { + return this.props.okButtonText + } + + return __DARWIN__ ? 'Create Branch' : 'Create branch' + } + + private onBranchNameChange = (name: string) => { + this.updateBranchName(name) + } + + private async updateBranchName(branchName: string) { + this.setState({ branchName }) + + const alreadyExists = + this.props.allBranches.findIndex(b => b.name === branchName) > -1 + + const currentError = alreadyExists + ? { + error: new Error(`A branch named ${branchName} already exists.`), + isWarning: false, + } + : null + + if (!currentError) { + if (this.branchRulesDebounceId !== null) { + window.clearTimeout(this.branchRulesDebounceId) + } + + this.branchRulesDebounceId = window.setTimeout( + this.checkBranchRules, + 500, + branchName + ) + } + + this.setState({ + branchName, + currentError, + }) + } + + /** + * Checks repo rules to see if the provided branch name is valid for the + * current user and repository. The "get all rules for a branch" endpoint + * is called first, and if a "creation" or "branch name" rule is found, + * then those rulesets are checked to see if the current user can bypass + * them. + */ + private checkBranchRules = async (branchName: string) => { + if ( + this.state.branchName !== branchName || + this.props.accounts.length === 0 || + this.props.upstreamGitHubRepository === null || + branchName === '' || + this.state.currentError !== null + ) { + return + } + + const account = getAccountForRepository( + this.props.accounts, + this.props.repository + ) + + if ( + account === null || + !useRepoRulesLogic(account, this.props.repository) + ) { + return + } + + const api = API.fromAccount(account) + const branchRules = await api.fetchRepoRulesForBranch( + this.props.upstreamGitHubRepository.owner.login, + this.props.upstreamGitHubRepository.name, + branchName + ) + + // filter the rules to only the relevant ones and get their IDs. use a Set to dedupe. + const toCheckForBypass = new Set( + branchRules + .filter( + r => + r.type === APIRepoRuleType.Creation || + r.type === APIRepoRuleType.BranchNamePattern + ) + .map(r => r.ruleset_id) + ) + + // there are no relevant rules for this branch name, so return + if (toCheckForBypass.size === 0) { + return + } + + // check cached rulesets to see which ones the user can bypass + let cannotBypass = false + for (const id of toCheckForBypass) { + const rs = this.props.cachedRepoRulesets.get(id) + + if (rs?.current_user_can_bypass !== 'always') { + // the user cannot bypass, so stop checking + cannotBypass = true + break + } + } + + if (this.state.branchName !== branchName) { + return + } + + if (cannotBypass) { + this.setState({ + currentError: { + error: new Error( + `Branch name '${branchName}' is restricted by repo rules.` + ), + isWarning: false, + }, + }) + } else { + this.setState({ + currentError: { + error: new Error( + `Branch name '${branchName}' is restricted by repo rules, but you can bypass them. Proceed with caution!` + ), + isWarning: true, + }, + }) + } + } + + private createBranch = async () => { + const name = this.state.branchName + + let startPoint: string | null = null + let noTrack = false + + const { defaultBranch, upstreamDefaultBranch, repository } = this.props + + if (this.props.targetCommit !== undefined) { + startPoint = this.props.targetCommit.sha + } else if (this.state.startPoint === StartPoint.DefaultBranch) { + // This really shouldn't happen, we take all kinds of precautions + // to make sure the startPoint state is valid given the current props. + if (!defaultBranch) { + this.setState({ + currentError: { + error: new Error('Could not determine the default branch.'), + isWarning: false, + }, + }) + return + } + + startPoint = defaultBranch.name + } else if (this.state.startPoint === StartPoint.UpstreamDefaultBranch) { + // This really shouldn't happen, we take all kinds of precautions + // to make sure the startPoint state is valid given the current props. + if (!upstreamDefaultBranch) { + this.setState({ + currentError: { + error: new Error('Could not determine the default branch.'), + isWarning: false, + }, + }) + return + } + + startPoint = upstreamDefaultBranch.name + noTrack = true + } + + if (name.length > 0) { + this.setState({ isCreatingBranch: true }) + + // If createBranch is provided, use it instead of dispatcher + if (this.props.createBranch !== undefined) { + this.props.createBranch(name, startPoint, noTrack) + return + } + + const timer = startTimer('create branch', repository) + const branch = await this.props.dispatcher.createBranch( + repository, + name, + startPoint, + noTrack + ) + timer.done() + this.props.onDismissed() + + // If the operation was successful and the branch was created from a + // commit, invoke the callback. + if ( + branch !== undefined && + this.props.targetCommit !== undefined && + this.props.onBranchCreatedFromCommit !== undefined + ) { + this.props.onBranchCreatedFromCommit() + } + } + } + + /** + * Render options for a non-fork repository + * + * Gives user the option to make a new branch from + * the default branch. + */ + private renderRegularBranchSelection( + currentBranchName: string, + defaultBranch: Branch | null + ) { + if (defaultBranch === null || defaultBranch.name === currentBranchName) { + return ( +
+ Your new branch will be based on your currently checked out branch ( + {currentBranchName}){this.renderForkLinkSuffix()}.{' '} + {defaultBranch?.name === currentBranchName && ( + <> + {currentBranchName} is the {defaultBranchLink} for your + repository. + + )} +
+ ) + } else { + const items = [ + { + title: defaultBranch.name, + description: + "The default branch in your repository. Pick this to start on something new that's not dependent on your current branch.", + key: StartPoint.DefaultBranch, + }, + { + title: currentBranchName, + description: + 'The currently checked out branch. Pick this if you need to build on work done on this branch.', + key: StartPoint.CurrentBranch, + }, + ] + + const selectedValue = + this.state.startPoint === StartPoint.DefaultBranch + ? this.state.startPoint + : StartPoint.CurrentBranch + + return ( +
+ {this.renderOptions(items, selectedValue)} + {this.renderForkLink()} +
+ ) + } + } + + /** + * Render options if we're in a fork + * + * Gives user the option to make a new branch from + * the upstream default branch. + */ + private renderForkBranchSelection( + currentBranchName: string, + upstreamDefaultBranch: Branch, + upstreamRepositoryFullName: string + ) { + // we assume here that the upstream and this + // fork will have the same default branch name + if (currentBranchName === upstreamDefaultBranch.nameWithoutRemote) { + return ( +
+ Your new branch will be based on{' '} + {upstreamRepositoryFullName} + 's {defaultBranchLink} ( + {upstreamDefaultBranch.nameWithoutRemote}) + {this.renderForkLinkSuffix()}. +
+ ) + } else { + const items = [ + { + title: upstreamDefaultBranch.name, + description: + "The default branch of the upstream repository. Pick this to start on something new that's not dependent on your current branch.", + key: StartPoint.UpstreamDefaultBranch, + }, + { + title: currentBranchName, + description: + 'The currently checked out branch. Pick this if you need to build on work done on this branch.', + key: StartPoint.CurrentBranch, + }, + ] + + const selectedValue = + this.state.startPoint === StartPoint.UpstreamDefaultBranch + ? this.state.startPoint + : StartPoint.CurrentBranch + return ( +
+ {this.renderOptions(items, selectedValue)} + {this.renderForkLink()} +
+ ) + } + } + + private renderForkLink = () => { + if (isRepositoryWithForkedGitHubRepository(this.props.repository)) { + return ( +
+ Your default branch source is determined by your{' '} + + fork behavior settings + + . +
+ ) + } else { + return + } + } + + private renderForkLinkSuffix = () => { + if (isRepositoryWithForkedGitHubRepository(this.props.repository)) { + return ( + +  as determined by your{' '} + + fork behavior settings + + + ) + } else { + return + } + } + + /** Shared method for rendering two choices in this component */ + private renderOptions = ( + items: ReadonlyArray>, + selectedValue: StartPoint + ) => ( + + + + ) + + private onForkSettingsClick = () => { + this.props.dispatcher.showPopup({ + type: PopupType.RepositorySettings, + repository: this.props.repository, + initialSelectedTab: RepositorySettingsTab.ForkSettings, + }) + } +} + +/** Reusable snippet */ +const defaultBranchLink = ( + + default branch + +) + +/** Given some branches and a start point, return the proper branch */ +function getBranchForStartPoint( + startPoint: StartPoint, + branchInfo: { + readonly defaultBranch: Branch | null + readonly upstreamDefaultBranch: Branch | null + } +) { + return startPoint === StartPoint.UpstreamDefaultBranch + ? branchInfo.upstreamDefaultBranch + : startPoint === StartPoint.DefaultBranch + ? branchInfo.defaultBranch + : null +} diff --git a/app/src/ui/create-branch/index.ts b/app/src/ui/create-branch/index.ts new file mode 100644 index 0000000000..6d18ab1ddf --- /dev/null +++ b/app/src/ui/create-branch/index.ts @@ -0,0 +1 @@ +export { CreateBranch } from './create-branch-dialog' diff --git a/app/src/ui/create-tag/create-tag-dialog.tsx b/app/src/ui/create-tag/create-tag-dialog.tsx new file mode 100644 index 0000000000..0af910341b --- /dev/null +++ b/app/src/ui/create-tag/create-tag-dialog.tsx @@ -0,0 +1,169 @@ +import * as React from 'react' + +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { Dialog, DialogError, DialogContent, DialogFooter } from '../dialog' + +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { startTimer } from '../lib/timing' +import { Ref } from '../lib/ref' +import { RefNameTextBox } from '../lib/ref-name-text-box' +import { enablePreviousTagSuggestions } from '../../lib/feature-flag' + +interface ICreateTagProps { + readonly repository: Repository + readonly dispatcher: Dispatcher + readonly onDismissed: () => void + readonly targetCommitSha: string + readonly initialName?: string + readonly localTags: Map | null +} + +interface ICreateTagState { + readonly tagName: string + + /** + * Note: once tag creation has been initiated this value stays at true + * and will never revert to being false. If the tag creation operation + * fails this dialog will still be dismissed and an error dialog will be + * shown in its place. + */ + readonly isCreatingTag: boolean + readonly previousTags: Array | null +} + +const MaxTagNameLength = 245 + +/** The Create Tag component. */ +export class CreateTag extends React.Component< + ICreateTagProps, + ICreateTagState +> { + public constructor(props: ICreateTagProps) { + super(props) + + this.state = { + tagName: props.initialName || '', + isCreatingTag: false, + previousTags: this.getExistingTagsFiltered(), + } + } + + public render() { + const error = this.getCurrentError() + const disabled = error !== null || this.state.tagName.length === 0 + + return ( + + {error && {error}} + + + + + {this.renderPreviousTags()} + + + + + + + ) + } + + private renderPreviousTags() { + if (!enablePreviousTagSuggestions()) { + return null + } + + const { localTags } = this.props + const { previousTags, tagName } = this.state + + if (previousTags === null || localTags === null || localTags.size === 0) { + return null + } + + const title = __DARWIN__ ? 'Previous Tags' : 'Previous tags' + const lastThreeTags = previousTags.slice(-3) + + return ( + <> +

{title}

+ {lastThreeTags.length === 0 ? ( +

{`No matches found for '${tagName}'`}

+ ) : ( + lastThreeTags.map((item: string, index: number) => ( + {item} + )) + )} + + ) + } + + private getCurrentError(): JSX.Element | null { + if (this.state.tagName.length > MaxTagNameLength) { + return ( + <>The tag name cannot be longer than {MaxTagNameLength} characters + ) + } + + const alreadyExists = + this.props.localTags && this.props.localTags.has(this.state.tagName) + if (alreadyExists) { + return ( + <> + A tag named {this.state.tagName} already exists + + ) + } + + return null + } + + private getExistingTagsFiltered(filter: string = ''): Array | null { + if (this.props.localTags === null) { + return null + } + const previousTags = Array.from(this.props.localTags.keys()) + return previousTags.filter(item => item.includes(filter)) + } + + private updateTagName = (tagName: string) => { + this.setState({ + tagName, + previousTags: this.getExistingTagsFiltered(tagName), + }) + } + + private createTag = async () => { + const name = this.state.tagName + const repository = this.props.repository + + if (name.length > 0) { + this.setState({ isCreatingTag: true }) + + const timer = startTimer('create tag', repository) + await this.props.dispatcher.createTag( + repository, + name, + this.props.targetCommitSha + ) + timer.done() + + this.props.onDismissed() + } + } +} diff --git a/app/src/ui/create-tag/index.ts b/app/src/ui/create-tag/index.ts new file mode 100644 index 0000000000..2f7c8f9e40 --- /dev/null +++ b/app/src/ui/create-tag/index.ts @@ -0,0 +1 @@ +export { CreateTag } from './create-tag-dialog' diff --git a/app/src/ui/delete-branch/delete-branch-dialog.tsx b/app/src/ui/delete-branch/delete-branch-dialog.tsx new file mode 100644 index 0000000000..9b5785b4e6 --- /dev/null +++ b/app/src/ui/delete-branch/delete-branch-dialog.tsx @@ -0,0 +1,114 @@ +import * as React from 'react' + +import { Dispatcher } from '../dispatcher' +import { Repository } from '../../models/repository' +import { Branch } from '../../models/branch' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { Ref } from '../lib/ref' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +interface IDeleteBranchProps { + readonly dispatcher: Dispatcher + readonly repository: Repository + readonly branch: Branch + readonly existsOnRemote: boolean + readonly onDismissed: () => void + readonly onDeleted: (repository: Repository) => void +} + +interface IDeleteBranchState { + readonly includeRemoteBranch: boolean + readonly isDeleting: boolean +} + +export class DeleteBranch extends React.Component< + IDeleteBranchProps, + IDeleteBranchState +> { + public constructor(props: IDeleteBranchProps) { + super(props) + + this.state = { + includeRemoteBranch: false, + isDeleting: false, + } + } + + public render() { + return ( + + +

+ Delete branch {this.props.branch.name}?
+ This action cannot be undone. +

+ + {this.renderDeleteOnRemote()} +
+ + + +
+ ) + } + + private renderDeleteOnRemote() { + if (this.props.branch.upstreamRemoteName && this.props.existsOnRemote) { + return ( +
+

+ + The branch also exists on the remote, do you wish to delete it + there as well? + +

+ +
+ ) + } + + return null + } + + private onIncludeRemoteChanged = ( + event: React.FormEvent + ) => { + const value = event.currentTarget.checked + + this.setState({ includeRemoteBranch: value }) + } + + private deleteBranch = async () => { + const { dispatcher, repository, branch } = this.props + + this.setState({ isDeleting: true }) + + await dispatcher.deleteLocalBranch( + repository, + branch, + this.state.includeRemoteBranch + ) + this.props.onDeleted(repository) + + this.props.onDismissed() + } +} diff --git a/app/src/ui/delete-branch/delete-pull-request-dialog.tsx b/app/src/ui/delete-branch/delete-pull-request-dialog.tsx new file mode 100644 index 0000000000..9aac1d59b7 --- /dev/null +++ b/app/src/ui/delete-branch/delete-pull-request-dialog.tsx @@ -0,0 +1,61 @@ +import * as React from 'react' + +import { Dispatcher } from '../dispatcher' + +import { Repository } from '../../models/repository' +import { Branch } from '../../models/branch' +import { PullRequest } from '../../models/pull-request' + +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { LinkButton } from '../lib/link-button' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +interface IDeleteBranchProps { + readonly dispatcher: Dispatcher + readonly repository: Repository + readonly branch: Branch + readonly pullRequest: PullRequest + readonly onDismissed: () => void +} + +export class DeletePullRequest extends React.Component { + public render() { + return ( + + +

This branch may have an open pull request associated with it.

+

+ If{' '} + + #{this.props.pullRequest.pullRequestNumber} + {' '} + has been merged, you can also go to GitHub to delete the remote + branch. +

+
+ + + +
+ ) + } + + private openPullRequest = () => { + this.props.dispatcher.showPullRequest(this.props.repository) + } + + private deleteBranch = () => { + this.props.dispatcher.deleteLocalBranch( + this.props.repository, + this.props.branch + ) + + return this.props.onDismissed() + } +} diff --git a/app/src/ui/delete-branch/delete-remote-branch-dialog.tsx b/app/src/ui/delete-branch/delete-remote-branch-dialog.tsx new file mode 100644 index 0000000000..b44f5a4d8a --- /dev/null +++ b/app/src/ui/delete-branch/delete-remote-branch-dialog.tsx @@ -0,0 +1,75 @@ +import * as React from 'react' + +import { Dispatcher } from '../dispatcher' +import { Repository } from '../../models/repository' +import { Branch } from '../../models/branch' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { Ref } from '../lib/ref' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +interface IDeleteRemoteBranchProps { + readonly dispatcher: Dispatcher + readonly repository: Repository + readonly branch: Branch + readonly onDismissed: () => void + readonly onDeleted: (repository: Repository) => void +} +interface IDeleteRemoteBranchState { + readonly isDeleting: boolean +} +export class DeleteRemoteBranch extends React.Component< + IDeleteRemoteBranchProps, + IDeleteRemoteBranchState +> { + public constructor(props: IDeleteRemoteBranchProps) { + super(props) + + this.state = { + isDeleting: false, + } + } + + public render() { + return ( + + +
+

+ Delete remote branch {this.props.branch.name}?
+ This action cannot be undone. +

+ +

+ This branch does not exist locally. Deleting it may impact others + collaborating on this branch. +

+
+
+ + + +
+ ) + } + + private deleteBranch = async () => { + const { dispatcher, repository, branch } = this.props + + this.setState({ isDeleting: true }) + + await dispatcher.deleteRemoteBranch(repository, branch) + this.props.onDeleted(repository) + + this.props.onDismissed() + } +} diff --git a/app/src/ui/delete-branch/index.ts b/app/src/ui/delete-branch/index.ts new file mode 100644 index 0000000000..7153b95b4b --- /dev/null +++ b/app/src/ui/delete-branch/index.ts @@ -0,0 +1,2 @@ +export { DeleteBranch } from './delete-branch-dialog' +export { DeleteRemoteBranch } from './delete-remote-branch-dialog' diff --git a/app/src/ui/delete-tag/delete-tag-dialog.tsx b/app/src/ui/delete-tag/delete-tag-dialog.tsx new file mode 100644 index 0000000000..2db5dbdb13 --- /dev/null +++ b/app/src/ui/delete-tag/delete-tag-dialog.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' + +import { Dispatcher } from '../dispatcher' +import { Repository } from '../../models/repository' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { Ref } from '../lib/ref' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +interface IDeleteTagProps { + readonly dispatcher: Dispatcher + readonly repository: Repository + readonly tagName: string + readonly onDismissed: () => void +} + +interface IDeleteTagState { + readonly isDeleting: boolean +} + +export class DeleteTag extends React.Component< + IDeleteTagProps, + IDeleteTagState +> { + public constructor(props: IDeleteTagProps) { + super(props) + + this.state = { + isDeleting: false, + } + } + + public render() { + return ( + + +

+ Are you sure you want to delete the tag{' '} + {this.props.tagName}? +

+
+ + + +
+ ) + } + + private DeleteTag = async () => { + const { dispatcher, repository, tagName } = this.props + + this.setState({ isDeleting: true }) + + await dispatcher.deleteTag(repository, tagName) + this.props.onDismissed() + } +} diff --git a/app/src/ui/delete-tag/index.ts b/app/src/ui/delete-tag/index.ts new file mode 100644 index 0000000000..d5b611a327 --- /dev/null +++ b/app/src/ui/delete-tag/index.ts @@ -0,0 +1 @@ +export { DeleteTag } from './delete-tag-dialog' diff --git a/app/src/ui/dialog/content.tsx b/app/src/ui/dialog/content.tsx new file mode 100644 index 0000000000..1285bc80c6 --- /dev/null +++ b/app/src/ui/dialog/content.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' +import classNames from 'classnames' + +interface IDialogContentProps { + /** + * An optional className to be applied to the rendered div element. + */ + readonly className?: string + + /** + * An optional function that will be passed a reference do the + * div container element of the DialogContents component (or null if + * unmounted). + */ + readonly onRef?: (element: HTMLDivElement | null) => void +} + +/** + * A container component for content (ie non-header, non-footer) in a Dialog. + * This component should only be used once in any given dialog. + * + * If a dialog implements a tabbed interface where each tab is a child component + * the child components _should_ render the DialogContent component themselves + * to avoid excessive nesting and to ensure that styles applying to phrasing + * content in the dialog get applied consistently. + */ +export class DialogContent extends React.Component { + public render() { + const className = classNames('dialog-content', this.props.className) + + return ( +
+ {this.props.children} +
+ ) + } +} diff --git a/app/src/ui/dialog/default-dialog-footer.tsx b/app/src/ui/dialog/default-dialog-footer.tsx new file mode 100644 index 0000000000..573386f074 --- /dev/null +++ b/app/src/ui/dialog/default-dialog-footer.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import { OkCancelButtonGroup } from './ok-cancel-button-group' +import { DialogFooter } from './footer' + +interface IDefaultDialogFooterProps { + /** An optional text/label for the submit button, defaults to "Close" */ + readonly buttonText?: string + + /** + * An optional event handler for when the submit button is clicked (either + * explicitly or as the result of a form keyboard submission). If specified + * the consumer is responsible for preventing the default behavior which + * is to submit the form (and thereby triggering the Dialog's submit event) + */ + readonly onButtonClick?: (event: React.MouseEvent) => void + + /** An optional title (i.e. tooltip) for the submit button, defaults to none */ + readonly buttonTitle?: string + + /** Whether the submit button will be disabled or not, defaults to false */ + readonly disabled?: boolean +} + +/** + * A component which renders a default footer in a Dialog. + * + * A default footer consists of a single submit button inside + * of a button group which triggers the onSubmit event on the + * dialog when clicked. + */ +export class DefaultDialogFooter extends React.Component< + IDefaultDialogFooterProps, + {} +> { + public render() { + return ( + + + + ) + } +} diff --git a/app/src/ui/dialog/dialog.tsx b/app/src/ui/dialog/dialog.tsx new file mode 100644 index 0000000000..ac18cf1577 --- /dev/null +++ b/app/src/ui/dialog/dialog.tsx @@ -0,0 +1,810 @@ +import * as React from 'react' +import classNames from 'classnames' +import { DialogHeader } from './header' +import { createUniqueId, releaseUniqueId } from '../lib/id-pool' +import { getTitleBarHeight } from '../window/title-bar' +import { isTopMostDialog } from './is-top-most' +import { isMacOSVentura } from '../../lib/get-os' + +export interface IDialogStackContext { + /** Whether or not this dialog is the top most one in the stack to be + * interacted with by the user. This will also determine if event listeners + * will be active or not. */ + isTopMost: boolean +} + +/** + * The DialogStackContext is used to communicate between the `Dialog` and the + * `App` information that is mostly unique to the `Dialog` component such as + * whether it is at the top of the popup stack. Some, but not the vast majority, + * custom popup components in between may also utilize this to enable and + * disable event listeners in response to changes in whether it is the top most + * popup. + * + * NB *** React.Context is not the preferred method of passing data to child + * components for this code base. We are choosing to use it here as implementing + * prop drilling would be extremely tedious and would lead to adding `Dialog` + * props on 60+ components that would not otherwise use them. *** + * + */ +export const DialogStackContext = React.createContext({ + isTopMost: false, +}) + +/** + * The time (in milliseconds) from when the dialog is mounted + * until it can be dismissed. See the isAppearing property in + * IDialogState for more information. + */ +const dismissGracePeriodMs = 250 + +/** + * The time (in milliseconds) that we should wait after focusing before we + * re-enable click dismissal. + */ +const DisableClickDismissalDelay = 500 + +/** + * Title bar height in pixels + */ +const titleBarHeight = getTitleBarHeight() + +interface IDialogProps { + /** + * An optional dialog title. Most, if not all dialogs should have + * this. When present the Dialog renders a DialogHeader element + * containing an icon (if the type prop warrants it), the title itself + * and a close button (if the dialog is dismissable). + * + * By omitting this consumers may use their own custom DialogHeader + * for when the default component doesn't cut it. + */ + readonly title?: string | JSX.Element + + /** + * Whether or not the dialog should be dismissable. A dismissable dialog + * can be dismissed either by clicking on the backdrop or by clicking + * the close button in the header (if a header was specified). Dismissal + * will trigger the onDismissed event which callers must handle and pass + * on to the dispatcher in order to close the dialog. + * + * A non-dismissable dialog can only be closed by means of the component + * implementing a dialog. An example would be a critical error or warning + * that requires explicit user action by for example clicking on a button. + * + * Defaults to true if omitted. + */ + readonly dismissable?: boolean + + /** + * Event triggered when the dialog is dismissed by the user in the + * ways described in the dismissable prop. + */ + readonly onDismissed?: () => void + + /** + * An optional id for the rendered dialog element. + */ + readonly id?: string + + /** + * An optional dialog type. A warning or error dialog type triggers custom + * styling of the dialog, see _dialog.scss for more detail. + * + * Defaults to 'normal' if omitted + */ + readonly type?: 'normal' | 'warning' | 'error' + + /** + * An event triggered when the dialog form is submitted. All dialogs contain + * a top-level form element which can be triggered through a submit button. + * + * Consumers should handle this rather than subscribing to the onClick event + * on the button itself since there may be other ways of submitting a specific + * form (such as Ctrl+Enter). + */ + readonly onSubmit?: () => void + + /** + * An optional className to be applied to the rendered dialog element. + */ + readonly className?: string + + /** + * Whether or not the dialog should be disabled. All dialogs wrap their + * content in a
element which, when disabled, causes all descendant + * form elements and buttons to also become disabled. This is useful for + * consumers implementing a typical save dialog where the save action isn't + * instantaneous (such as a sign in dialog) and they need to ensure that the + * user doesn't continue mutating the form state or click buttons while the + * save/submit action is in progress. Note that this does not prevent the + * dialog from being dismissed. + */ + readonly disabled?: boolean + + /** + * Whether or not the dialog contents are currently involved in processing + * data, executing an asynchronous operation or by other means working. + * Setting this value will render a spinning progress icon in the dialog + * header (if the dialog has a header). Note that the spinning icon + * will temporarily replace the dialog icon (if present) for the duration + * of the loading operation. + */ + readonly loading?: boolean + + /** Whether or not to override focus of first element with close button */ + readonly focusCloseButtonOnOpen?: boolean +} + +/** + * If role is alertdialog, ariaDescribedBy is required. + */ +interface IAlertDialogProps extends IDialogProps { + /** This is used to point to an element containing content pertinent to the + * users workflow. This should be provided for dialogs that are alerts or + * confirmations so that that the information that is interrupting the user's + * workflow is screen reader announced and acquire a response */ + readonly ariaDescribedBy: string + + /** By default, a dialog has role of "dialog" and requires the use of an + * "aria-label" or "aria-labelledby" to accessibily announce the title or + * purpose of the header. This is typically accomplished by providing the + * `title` prop and the dialog component will take care of adding the + * `aria-labelledby` attribute. + * + * However, if the dialog is an alert or confirmation dialog we should use the + * role of `alertdialog` AND the `ariaDescribedBy` prop should be provided + * containing the id of the element with the information required by the user + * to proceed or be made aware of to ensure it is also read by screen readers. + * + * + * https://www.w3.org/TR/wai-aria-1.1/#alertdialog + * "An alert dialog is a modal dialog that interrupts the user's workflow to + * communicate an important message and acquire a response. Examples include + * action confirmation prompts and error message confirmations. The + * alertdialog role enables assistive technologies and browsers to distinguish + * alert dialogs from other dialogs so they have the option of giving alert + * dialogs special treatment, such as playing a system alert sound." + * */ + readonly role: 'alertdialog' +} + +/** + * If role is undefined or dialog, ariaDescribedBy is optional. + */ +interface IDescribedByDialogProps extends IDialogProps { + /** This is used to point to an element containing content pertinent to the + * users workflow. This should be provided for dialogs that are alerts or + * confirmations so that that the information that is interrupting the user's + * workflow is screen reader announced and acquire a response */ + readonly ariaDescribedBy?: string + + /** By default, a dialog has role of "dialog". This is only required for a + * role of 'alertdialog' in which case `ariaDescribedBy` must also be + * provided */ + readonly role?: 'dialog' +} + +/** Interface union to force usage of `ariaDescribedBy` if role of `alertdialog` + * is used */ +type DialogProps = IAlertDialogProps | IDescribedByDialogProps + +interface IDialogState { + /** + * When a dialog is shown we wait for a few hundred milliseconds before + * acknowledging a dismissal in order to avoid people accidentally dismissing + * dialogs that appear as they're doing other things. Since the entire + * backdrop of a dialog can be clicked to dismiss all it takes is one rogue + * click and the dialog is gone. This is less than ideal if we're in the + * middle of displaying an important error message. + * + * This state boolean is used to keep track of whether we're still in that + * grace period or not. + */ + readonly isAppearing: boolean + + /** + * An optional id for the h1 element that contains the title of this + * dialog. Used to aid in accessibility by allowing the h1 to be referenced + * in an aria-labeledby/aria-describedby attributed. Undefined if the dialog + * does not have a title or the component has not yet been mounted. + */ + readonly titleId?: string +} + +/** + * A general purpose, versatile, dialog component which utilizes the new + * element. See https://demo.agektmr.com/dialog/ + * + * A dialog is opened as a modal that prevents keyboard or pointer access to + * underlying elements. It's not possible to use the tab key to move focus + * out of the dialog without first dismissing it. + */ +export class Dialog extends React.Component { + public static contextType = DialogStackContext + public declare context: React.ContextType + + private checkIsTopMostDialog = isTopMostDialog( + () => { + this.onDialogIsTopMost() + }, + () => { + this.onDialogIsNotTopMost() + } + ) + + private dialogElement: HTMLDialogElement | null = null + private dismissGraceTimeoutId?: number + + private disableClickDismissalTimeoutId: number | null = null + private disableClickDismissal = false + + /** + * Resize observer used for tracking width changes and + * refreshing the internal codemirror instance when + * they occur + */ + private readonly resizeObserver: ResizeObserver + private resizeDebounceId: number | null = null + + public constructor(props: DialogProps) { + super(props) + this.state = { isAppearing: true } + + // Observe size changes and let codemirror know + // when it needs to refresh. + this.resizeObserver = new ResizeObserver(this.scheduleResizeEvent) + } + + private scheduleResizeEvent = () => { + if (this.resizeDebounceId !== null) { + cancelAnimationFrame(this.resizeDebounceId) + this.resizeDebounceId = null + } + this.resizeDebounceId = requestAnimationFrame(this.onResized) + } + + /** + * Attempt to ensure that the entire dialog is always visible. Chromium + * takes care of positioning the dialog when we initially show it but + * subsequent resizes of either the dialog (such as when switching tabs + * in the preferences dialog) or the Window doesn't affect positioning. + */ + private onResized = () => { + if (!this.dialogElement) { + return + } + + const { offsetTop, offsetHeight } = this.dialogElement + + // Not much we can do if the dialog is bigger than the window + if (offsetHeight > window.innerHeight - titleBarHeight) { + return + } + + const padding = 10 + const overflow = offsetTop + offsetHeight + padding - window.innerHeight + + if (overflow > 0) { + const top = Math.max(titleBarHeight, offsetTop - overflow) + this.dialogElement.style.top = `${top}px` + } + } + + private clearDismissGraceTimeout() { + if (this.dismissGraceTimeoutId !== undefined) { + window.clearTimeout(this.dismissGraceTimeoutId) + this.dismissGraceTimeoutId = undefined + } + } + + private scheduleDismissGraceTimeout() { + this.clearDismissGraceTimeout() + + this.dismissGraceTimeoutId = window.setTimeout( + this.onDismissGraceTimer, + dismissGracePeriodMs + ) + } + + private onDismissGraceTimer = () => { + this.setState({ isAppearing: false }) + + this.dialogElement?.dispatchEvent( + new CustomEvent('dialog-appeared', { + bubbles: true, + cancelable: false, + }) + ) + } + + private isDismissable() { + return this.props.dismissable === undefined || this.props.dismissable + } + + private updateTitleId() { + if (this.state.titleId) { + releaseUniqueId(this.state.titleId) + this.setState({ titleId: undefined }) + } + + if (this.props.title) { + // createUniqueId handles static strings fine, so in the case of receiving + // a JSX element for the title we can just pass in a fixed value rather + // than trying to generate a string from an arbitrary element + const id = typeof this.props.title === 'string' ? this.props.title : '???' + this.setState({ + titleId: createUniqueId(`Dialog_${this.props.id}_${id}`), + }) + } + } + + public componentWillMount() { + this.updateTitleId() + } + + public componentDidMount() { + this.checkIsTopMostDialog(this.context.isTopMost) + } + + protected onDialogIsTopMost() { + if (this.dialogElement == null) { + return + } + + if (!this.dialogElement.open) { + this.dialogElement.showModal() + } + + // Provide an event that components can subscribe to in order to perform + // tasks such as re-layout after the dialog is visible + this.dialogElement.dispatchEvent( + new CustomEvent('dialog-show', { + bubbles: true, + cancelable: false, + }) + ) + + this.setState({ isAppearing: true }) + this.scheduleDismissGraceTimeout() + + this.focusFirstSuitableChild() + + window.addEventListener('focus', this.onWindowFocus) + + this.resizeObserver.observe(this.dialogElement) + window.addEventListener('resize', this.scheduleResizeEvent) + } + + protected onDialogIsNotTopMost() { + if (this.dialogElement !== null && this.dialogElement.open) { + this.dialogElement?.close() + } + + this.clearDismissGraceTimeout() + + window.removeEventListener('focus', this.onWindowFocus) + document.removeEventListener('mouseup', this.onDocumentMouseUp) + + this.resizeObserver.disconnect() + window.removeEventListener('resize', this.scheduleResizeEvent) + } + + /** + * Attempts to move keyboard focus to the first _suitable_ child of the + * dialog. + * + * The original motivation for this function is that while the order of the + * Ok, and Cancel buttons differ between platforms (see OkCancelButtonGroup) + * we don't want to accidentally put keyboard focus on the destructive + * button (like the Ok button in the discard changes dialog) but rather + * on the non-destructive action. This logic originates from the macOS + * human interface guidelines + * + * From https://developer.apple.com/design/human-interface-guidelines/macos/windows-and-views/dialogs/: + * + * "Users sometimes press Return merely to dismiss a dialog, without + * reading its content, so it’s crucial that a default button initiate + * a harmless action. [...] when a dialog may result in a destructive + * action, Cancel can be set as the default button." + * + * The same guidelines also has this to say about focus: + * + * "Set the initial focus to the first location that accepts user input. + * Doing so lets the user begin entering data immediately, without needing + * to click a specific item like a text field or list." + * + * In attempting to follow the guidelines outlined above we follow a priority + * order in determining the first suitable child. + * + * 1. The element with the lowest positive tabIndex + * This might sound counterintuitive but imagine the following pseudo + * dialog this would be button D as button D would be the first button + * to get focused when hitting Tab. + * + * + * + * + * + * + * + * + * 2. The first element which is either implicitly keyboard focusable (like a + * text input field) or explicitly focusable through tabIndex=0 (like a TabBar + * tab) + * + * 3. The first submit button. We use this as a proxy for what macOS HIG calls + * "default button". It's not the same thing but for our purposes it's close + * enough. + * + * 4. Any remaining button + * + * 5. The dialog close button + * + */ + public focusFirstSuitableChild() { + const dialog = this.dialogElement + + if (dialog === null) { + return + } + + const selector = [ + 'input:not([type=hidden]):not(:disabled):not([tabindex="-1"])', + 'textarea:not(:disabled):not([tabindex="-1"])', + 'button:not(:disabled):not([tabindex="-1"])', + '[tabindex]:not(:disabled):not([tabindex="-1"])', + ].join(', ') + + // The element which has the lowest explicit tab index (i.e. greater than 0) + let firstExplicit: { 0: number; 1: HTMLElement | null } = [Infinity, null] + + // First submit button + let firstSubmitButton: HTMLElement | null = null + + // The first button-like element (input, submit, reset etc) + let firstButton: HTMLElement | null = null + + // The first element which is either implicitly keyboard focusable (like a + // text input field) or explicitly focusable through tabIndex=0 (like an + // anchor tag masquerading as a button) + let firstTabbable: HTMLElement | null = null + + const closeButton = dialog.querySelector( + ':scope > div.dialog-header button.close' + ) + + if ( + closeButton instanceof HTMLElement && + this.props.focusCloseButtonOnOpen + ) { + closeButton.focus() + return + } + + const excludedInputTypes = [ + ':not([type=button])', + ':not([type=submit])', + ':not([type=reset])', + ':not([type=hidden])', + ':not([type=radio])', + ] + + const inputSelector = `input${excludedInputTypes.join('')}, textarea` + const buttonSelector = + 'input[type=button], input[type=submit] input[type=reset], button' + + const submitSelector = 'input[type=submit], button[type=submit]' + + for (const candidate of dialog.querySelectorAll(selector)) { + if (!(candidate instanceof HTMLElement)) { + continue + } + + const tabIndex = parseInt(candidate.getAttribute('tabindex') || '', 10) + + if (tabIndex > 0 && tabIndex < firstExplicit[0]) { + firstExplicit = [tabIndex, candidate] + } else if ( + firstTabbable === null && + (tabIndex === 0 || candidate.matches(inputSelector)) + ) { + firstTabbable = candidate + } else if ( + firstSubmitButton === null && + candidate.matches(submitSelector) + ) { + firstSubmitButton = candidate + } else if ( + firstButton === null && + candidate.matches(buttonSelector) && + candidate !== closeButton + ) { + firstButton = candidate + } + } + + const focusCandidates = [ + firstExplicit[1], + firstTabbable, + firstSubmitButton, + firstButton, + closeButton, + ] + + for (const focusCandidate of focusCandidates) { + if (focusCandidate instanceof HTMLElement) { + focusCandidate.focus() + break + } + } + } + + private onWindowFocus = () => { + // On Windows and Linux, a click which focuses the window will also get + // passed down into the DOM. But we don't want to dismiss the dialog based + // on that click. See https://github.com/desktop/desktop/issues/2486. + // macOS normally automatically disables "click-through" behavior but + // we've intentionally turned that off so we need to apply the same + // behavior regardless of platform. + // See https://github.com/desktop/desktop/pull/3843. + this.clearClickDismissalTimer() + + this.disableClickDismissal = true + + this.disableClickDismissalTimeoutId = window.setTimeout(() => { + this.disableClickDismissal = false + this.disableClickDismissalTimeoutId = null + }, DisableClickDismissalDelay) + } + + private clearClickDismissalTimer() { + if (this.disableClickDismissalTimeoutId) { + window.clearTimeout(this.disableClickDismissalTimeoutId) + this.disableClickDismissalTimeoutId = null + } + } + + public componentWillUnmount() { + if (this.state.titleId) { + releaseUniqueId(this.state.titleId) + } + + this.checkIsTopMostDialog(false) + } + + public componentDidUpdate(prevProps: DialogProps) { + if (!this.props.title && this.state.titleId) { + this.updateTitleId() + } + + this.checkIsTopMostDialog(this.context.isTopMost) + } + + private onDialogCancel = (e: Event | React.SyntheticEvent) => { + e.preventDefault() + this.onDismiss() + } + + private onDialogMouseDown = (e: React.MouseEvent) => { + if (e.defaultPrevented) { + return + } + + if (this.isDismissable() === false) { + return + } + + // This event handler catches the onClick event of buttons in the + // dialog. Ie, if someone hits enter inside the dialog form an onClick + // event will be raised on the the submit button which isn't what we + // want so we'll make sure that the original target for the event is + // our own dialog element. + if (e.target !== this.dialogElement) { + return + } + + // Ignore the first click right after the window's been focused. It could + // be the click that focused the window, in which case we don't wanna + // dismiss the dialog. + if (this.disableClickDismissal) { + this.disableClickDismissal = false + this.clearClickDismissalTimer() + return + } + + if (!this.mouseEventIsInsideDialog(e)) { + // The user has pressed down on their pointer device outside of the + // dialog (i.e. on the backdrop). Now we subscribe to the global + // mouse up event where we can make sure that they release the pointer + // device on the backdrop as well. + document.addEventListener('mouseup', this.onDocumentMouseUp, { + once: true, + }) + } + } + + private mouseEventIsInsideDialog( + e: React.MouseEvent | MouseEvent + ) { + // it's possible that we've been unmounted + if (this.dialogElement === null) { + return false + } + + const isInTitleBar = e.clientY <= titleBarHeight + + if (isInTitleBar) { + return false + } + + // Figure out if the user clicked on the backdrop or in the dialog itself. + const rect = this.dialogElement.getBoundingClientRect() + + // http://stackoverflow.com/a/26984690/2114 + const isInDialog = + rect.top <= e.clientY && + e.clientY <= rect.top + rect.height && + rect.left <= e.clientX && + e.clientX <= rect.left + rect.width + + return isInDialog + } + + /** + * Subscribed to from the onDialogMouseDown when the user + * presses down on the backdrop, ensures that we only dismiss + * the dialog if they release their pointer device over the + * backdrop as well (as opposed to over the dialog itself). + */ + private onDocumentMouseUp = (e: MouseEvent) => { + if (!e.defaultPrevented && !this.mouseEventIsInsideDialog(e)) { + e.preventDefault() + this.onDismiss() + } + } + + private onDialogRef = (e: HTMLDialogElement | null) => { + // We need to explicitly subscribe to and unsubscribe from the dialog + // element as react doesn't yet understand the element and which events + // it has. + if (!e) { + if (this.dialogElement) { + this.dialogElement.removeEventListener('cancel', this.onDialogCancel) + } + } else { + e.addEventListener('cancel', this.onDialogCancel) + } + + this.dialogElement = e + } + + private onKeyDown = (event: React.KeyboardEvent) => { + if (event.defaultPrevented) { + return + } + const shortcutKey = __DARWIN__ ? event.metaKey : event.ctrlKey + if ((shortcutKey && event.key === 'w') || event.key === 'Escape') { + this.onDialogCancel(event) + } + } + + private onDismiss = () => { + if (this.isDismissable() && !this.state.isAppearing) { + if (this.props.onDismissed) { + this.props.onDismissed() + } + } + } + + private onSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (this.props.onSubmit) { + this.props.onSubmit() + } else { + this.onDismiss() + } + } + + private renderHeader() { + if (!this.props.title) { + return null + } + + return ( + + ) + } + + /** + * Gets the aria-labelledby and aria-describedby attributes for the dialog + * element. + * + * The correct semantics are that the dialog element should have the + * aria-labelledby and the aria-describedby is optional unless the dialog has + * a role of alertdialog, in which case both are required. + * + * However, macOs Ventura introduced a regression in that: + * + * For role of 'dialog' (default), the aria-labelledby is not announced and + * if provided prevents the aria-describedby from being announced. Thus, + * this method will add the aria-labelledby to the aria-describedby in this + * case. + * + * For role of 'alertdialog', the aria-labelledby is announced but not the + * aria-describedby. Thus, this method will add both to the + * aria-labelledby. + * + * Neither of the above is semantically correct tho, hopefully, macOs will be + * fixed in a future release. The issue is known for macOS versions 13.0 to + * the current version of 13.5 as of 2023-07-31. + * + * A known macOS behavior is that if two ids are provided to the + * aria-describedby only the first one is announced with a note about the + * second one existing. This currently does not impact us as we only provide + * one id for non-alert dialogs and the alert dialogs are handled with the + * `aria-labelledby` where both ids are announced. + * + */ + private getAriaAttributes() { + if (!isMacOSVentura()) { + // correct semantics for all other os + return { + 'aria-labelledby': this.state.titleId, + 'aria-describedby': this.props.ariaDescribedBy, + } + } + + if (this.props.role === 'alertdialog') { + return { + 'aria-labelledby': `${this.state.titleId} ${this.props.ariaDescribedBy}`, + } + } + + return { + 'aria-describedby': `${this.state.titleId} ${ + this.props.ariaDescribedBy ?? '' + }`, + } + } + + public render() { + const className = classNames( + { + error: this.props.type === 'error', + warning: this.props.type === 'warning', + }, + this.props.className, + 'tooltip-host' + ) + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions + + {this.renderHeader()} + +
+
+ {this.props.children} +
+
+
+ ) + } +} diff --git a/app/src/ui/dialog/error.tsx b/app/src/ui/dialog/error.tsx new file mode 100644 index 0000000000..63d6fee094 --- /dev/null +++ b/app/src/ui/dialog/error.tsx @@ -0,0 +1,25 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' + +/** + * A component used for displaying short error messages inline + * in a dialog. These error messages (there can be more than one) + * should be rendered as the first child of the component + * and support arbitrary content. + * + * The content (error message) is paired with a stop icon and receive + * special styling. + * + * Provide `children` to display content inside the error dialog. + */ +export class DialogError extends React.Component { + public render() { + return ( +
+ +
{this.props.children}
+
+ ) + } +} diff --git a/app/src/ui/dialog/footer.tsx b/app/src/ui/dialog/footer.tsx new file mode 100644 index 0000000000..5248fde1bb --- /dev/null +++ b/app/src/ui/dialog/footer.tsx @@ -0,0 +1,14 @@ +import * as React from 'react' + +/** + * A container component for footer content in a Dialog. + * This component should only be used at most once in any given dialog and it + * should be rendered as the last child of that dialog. + * + * Provide `children` to display content inside the dialog footer. + */ +export class DialogFooter extends React.Component<{}, {}> { + public render() { + return
{this.props.children}
+ } +} diff --git a/app/src/ui/dialog/header.tsx b/app/src/ui/dialog/header.tsx new file mode 100644 index 0000000000..4e96f71667 --- /dev/null +++ b/app/src/ui/dialog/header.tsx @@ -0,0 +1,93 @@ +import * as React from 'react' +import { Octicon, syncClockwise } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' + +interface IDialogHeaderProps { + /** + * The dialog title text. Will be rendered top and center in a dialog. + * You can also pass JSX for custom styling + */ + readonly title: string | JSX.Element + + /** + * An optional id for the h1 element that contains the title of this + * dialog. Used to aid in accessibility by allowing the h1 to be referenced + * in an aria-labeledby/aria-describedby attributed + */ + readonly titleId?: string + + /** + * Whether or not the implementing dialog is dismissable. This controls + * whether or not the dialog header renders a close button or not. + */ + readonly dismissable: boolean + + /** + * Event triggered when the dialog is dismissed by the user in the + * ways described in the dismissable prop. + */ + readonly onDismissed?: () => void + + /** + * Whether or not the dialog contents are currently involved in processing + * data, executing an asynchronous operation or by other means working. + * Setting this value will render a spinning progress icon in the header. + * Note that the spinning icon will temporarily replace the dialog icon + * (if present) for the duration of the loading operation. + */ + readonly loading?: boolean +} + +/** + * A high-level component for Dialog headers. + * + * This component should typically not be used by consumers as the title prop + * of the Dialog component should suffice. There are, however, cases where + * custom content needs to be rendered in a dialog and in that scenario it + * might be necessary to use this component directly. + */ +export class DialogHeader extends React.Component { + private onCloseButtonClick = (e: React.MouseEvent) => { + /** This prevent default is a preventative measure since the dialog is akin + * to a big Form element. We wouldn't any surprise form handling. */ + e.preventDefault() + if (this.props.onDismissed) { + this.props.onDismissed() + } + } + + private renderCloseButton() { + if (!this.props.dismissable) { + return null + } + + return ( + + ) + } + + private renderTitle() { + return

{this.props.title}

+ } + + public render() { + const spinner = this.props.loading ? ( + + ) : null + + return ( +
+ {this.renderTitle()} + {spinner} + {this.renderCloseButton()} + {this.props.children} +
+ ) + } +} diff --git a/app/src/ui/dialog/index.ts b/app/src/ui/dialog/index.ts new file mode 100644 index 0000000000..fb3d3cc73c --- /dev/null +++ b/app/src/ui/dialog/index.ts @@ -0,0 +1,6 @@ +export * from './content' +export * from './dialog' +export * from './error' +export * from './footer' +export * from './ok-cancel-button-group' +export * from './default-dialog-footer' diff --git a/app/src/ui/dialog/is-top-most.tsx b/app/src/ui/dialog/is-top-most.tsx new file mode 100644 index 0000000000..c6247d817a --- /dev/null +++ b/app/src/ui/dialog/is-top-most.tsx @@ -0,0 +1,17 @@ +import memoizeOne from 'memoize-one' + +/** This method is a memoizedOne for a consistent means of handling when the + * isTopMost property of the `DialogStackContext` changes in the various popups + * that consume it. */ +export function isTopMostDialog( + onDialogIsTopMost: () => void, + onDialogIsNotTopMost: () => void +) { + return memoizeOne((isTopMost: boolean) => { + if (isTopMost) { + onDialogIsTopMost() + } else { + onDialogIsNotTopMost() + } + }) +} diff --git a/app/src/ui/dialog/ok-cancel-button-group.tsx b/app/src/ui/dialog/ok-cancel-button-group.tsx new file mode 100644 index 0000000000..7ab5cdc059 --- /dev/null +++ b/app/src/ui/dialog/ok-cancel-button-group.tsx @@ -0,0 +1,206 @@ +import * as React from 'react' +import classNames from 'classnames' +import { Button } from '../lib/button' + +interface IOkCancelButtonGroupProps { + /** + * An optional className to be applied to the rendered div element. + */ + readonly className?: string + + /** + * Does the affirmative (Ok) button perform a destructive action? This controls + * whether the Ok button, or the Cancel button will be the default button, + * defaults to false. + */ + readonly destructive?: boolean + + /** An optional text/label for the Ok button, defaults to "Ok" */ + readonly okButtonText?: string | JSX.Element + + /** An optional title (i.e. tooltip) for the Ok button, defaults to none */ + readonly okButtonTitle?: string + + /** Aria description of the ok button */ + readonly okButtonAriaDescribedBy?: string + + /** + * An optional event handler for when the Ok button is clicked (either + * explicitly or as the result of a form keyboard submission). If specified + * the consumer is responsible for preventing the default behavior which + * is to submit the form (and thereby triggering the Dialog's submit event) + */ + readonly onOkButtonClick?: ( + event: React.MouseEvent + ) => void + + /** Whether the Ok button will be disabled or not, defaults to false */ + readonly okButtonDisabled?: boolean + + /** An optional text/label for the Cancel button, defaults to "Cancel" */ + readonly cancelButtonText?: string | JSX.Element + + /** An optional title (i.e. tooltip) for the Cancel button, defaults to none */ + readonly cancelButtonTitle?: string + + /** + * Whether or not the cancel button should be rendered. The intention + * behind this property is to enable the DefaultDialogFooter component + * to reuse the layout of the OkCancelButtonGroup. This property was + * not intended to be used directly by generic consumers of this component. + * Note that use of this renders the destructive prop inoperable. + * + * Defaults to true + */ + readonly cancelButtonVisible?: boolean + + /** + * An optional event handler for when the Cancel button is clicked (either + * explicitly or as the result of a form keyboard submission). If specified + * the consumer is responsible for preventing the default behavior which + * is to reset the form (and thereby triggering the Dialog's cancel event) + */ + readonly onCancelButtonClick?: ( + event: React.MouseEvent + ) => void + + /** Whether the Cancel button will be disabled or not, defaults to false */ + readonly cancelButtonDisabled?: boolean +} + +/** + * A component for rendering Ok and Cancel buttons in + * a dialog in the platform specific order. + * + * Ie, on Windows we expect the button order to be Ok, Cancel + * whereas on Mac we expect it to be Cancel, Ok. + * + * See https://www.nngroup.com/articles/ok-cancel-or-cancel-ok/ + * + * For the purposes of this component Ok and Cancel are + * abstract concepts indicating an affirmative answer to a + * question posed by a dialog or a dismissal of the dialog. + * The actual labels for the buttons can be customized to + * fit the dialog contents. + * + * This component also takes care of selecting the appropriate + * default button depending on whether an affirmative answer + * from the user would result in a destructive action or not. + */ +export class OkCancelButtonGroup extends React.Component< + IOkCancelButtonGroupProps, + {} +> { + private onOkButtonClick = (event: React.MouseEvent) => { + if (this.props.onOkButtonClick !== undefined) { + this.props.onOkButtonClick(event) + } + + if (event.defaultPrevented) { + return + } + + // If the button group is destructive the Ok button will be regular + // button as opposed to a submit button and the cancel button will + // be a submit button. The reason for this is that we want the default + // button to be the safest choice and we want that safe button to be + // what gets clicked if the user submits the form using the keyboard. + // + // The dialog component, however, will always treat a form submission + // as the "affirmative"/Ok action and a form reset as the cancel action + // so we flip the event we actually send to the dialog here. + if (this.props.destructive === true) { + event.preventDefault() + if (event.currentTarget.form) { + // https://stackoverflow.com/a/12820780/2114 + event.currentTarget.form.dispatchEvent(new Event('submit')) + } + } + } + + private onCancelButtonClick = ( + event: React.MouseEvent + ) => { + if (this.props.onCancelButtonClick !== undefined) { + this.props.onCancelButtonClick(event) + } + + if (event.defaultPrevented) { + return + } + + // If the button group is destructive the Cancel button will the submit + // button and the Ok button will be a regular button. See the + // explanation for this in onOkButtonClick + if (this.props.destructive === true) { + event.preventDefault() + if (event.currentTarget.form) { + // https://stackoverflow.com/a/12820780/2114 + event.currentTarget.form.dispatchEvent(new Event('reset')) + } + } + } + + private renderOkButton() { + return ( + + ) + } + + private renderCancelButton() { + if (this.props.cancelButtonVisible === false) { + return null + } + + return ( + + ) + } + + private renderButtons() { + // See https://www.nngroup.com/articles/ok-cancel-or-cancel-ok/ + if (__DARWIN__) { + return ( + <> + {this.renderCancelButton()} + {this.renderOkButton()} + + ) + } else { + return ( + <> + {this.renderOkButton()} + {this.renderCancelButton()} + + ) + } + } + + public render() { + const className = classNames('button-group', this.props.className, { + destructive: this.props.destructive === true, + }) + + return ( +
+ {this.renderButtons()} + {this.props.children} +
+ ) + } +} diff --git a/app/src/ui/diff/binary-file.tsx b/app/src/ui/diff/binary-file.tsx new file mode 100644 index 0000000000..0e593a2bd9 --- /dev/null +++ b/app/src/ui/diff/binary-file.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' +import * as Path from 'path' + +import { Repository } from '../../models/repository' + +import { LinkButton } from '../lib/link-button' + +interface IBinaryFileProps { + readonly repository: Repository + readonly path: string + /** + * Called when the user requests to open a binary file in an the + * system-assigned application for said file type. + */ + readonly onOpenBinaryFile: (fullPath: string) => void +} + +/** represents the default view for a file that we cannot render a diff for */ +export class BinaryFile extends React.Component { + private open = () => { + const fullPath = Path.join(this.props.repository.path, this.props.path) + this.props.onOpenBinaryFile(fullPath) + } + + public render() { + return ( +
+
This binary file has changed.
+
+ + Open file in external program. + +
+
+ ) + } +} diff --git a/app/src/ui/diff/changed-range.ts b/app/src/ui/diff/changed-range.ts new file mode 100644 index 0000000000..22cd61bb34 --- /dev/null +++ b/app/src/ui/diff/changed-range.ts @@ -0,0 +1,62 @@ +export interface IRange { + /** The starting location for the range. */ + readonly location: number + + /** The length of the range. */ + readonly length: number +} + +/** Get the maximum position in the range. */ +function rangeMax(range: IRange): number { + return range.location + range.length +} + +/** Get the length of the common substring between the two strings. */ +function commonLength( + stringA: string, + rangeA: IRange, + stringB: string, + rangeB: IRange, + reverse: boolean +): number { + const max = Math.min(rangeA.length, rangeB.length) + const startA = reverse ? rangeMax(rangeA) - 1 : rangeA.location + const startB = reverse ? rangeMax(rangeB) - 1 : rangeB.location + const stride = reverse ? -1 : 1 + + let length = 0 + while (Math.abs(length) < max) { + if (stringA[startA + length] !== stringB[startB + length]) { + break + } + + length += stride + } + + return Math.abs(length) +} + +/** Get the changed ranges in the strings, relative to each other. */ +export function relativeChanges( + stringA: string, + stringB: string +): { stringARange: IRange; stringBRange: IRange } { + let bRange = { location: 0, length: stringB.length } + let aRange = { location: 0, length: stringA.length } + + const prefixLength = commonLength(stringB, bRange, stringA, aRange, false) + bRange = { + location: bRange.location + prefixLength, + length: bRange.length - prefixLength, + } + aRange = { + location: aRange.location + prefixLength, + length: aRange.length - prefixLength, + } + + const suffixLength = commonLength(stringB, bRange, stringA, aRange, true) + bRange.length -= suffixLength + aRange.length -= suffixLength + + return { stringARange: aRange, stringBRange: bRange } +} diff --git a/app/src/ui/diff/code-mirror-host.tsx b/app/src/ui/diff/code-mirror-host.tsx new file mode 100644 index 0000000000..80d7e04566 --- /dev/null +++ b/app/src/ui/diff/code-mirror-host.tsx @@ -0,0 +1,272 @@ +import * as React from 'react' +import CodeMirror, { + Doc, + Editor, + EditorChange, + EditorConfiguration, + LineHandle, +} from 'codemirror' + +// Required for us to be able to customize the foreground color of selected text +import 'codemirror/addon/selection/mark-selection' + +// Autocompletion plugin +import 'codemirror/addon/hint/show-hint' + +import 'codemirror/addon/scroll/simplescrollbars' + +import 'codemirror/addon/search/search' + +interface ICodeMirrorHostProps { + /** + * An optional class name for the wrapper element around the + * CodeMirror component + */ + readonly className?: string + + /** The text contents for the editor */ + readonly value: string | Doc + + /** Any CodeMirror specific settings */ + readonly options?: EditorConfiguration + + /** Callback for diff to control whether selection is enabled */ + readonly isSelectionEnabled?: () => boolean + + /** Callback for when CodeMirror renders (or re-renders) a line */ + readonly onRenderLine?: ( + cm: Editor, + line: LineHandle, + elem: HTMLElement + ) => void + + /** Callback for when CodeMirror has completed a batch of changes to the editor */ + readonly onChanges?: (cm: Editor, change: EditorChange[]) => void + + /** Callback for when the viewport changes due to scrolling or other updates */ + readonly onViewportChange?: (cm: Editor, from: number, to: number) => void + + /** Callback for when the editor document is swapped out for a new one */ + readonly onSwapDoc?: (cm: Editor, oldDoc: Doc) => void + + /** + * Called after the document has been swapped, meaning that consumers of this + * event have access to the updated viewport (as opposed to onSwapDoc) + */ + readonly onAfterSwapDoc?: (cm: Editor, oldDoc: Doc, newDoc: Doc) => void + + /** + * Called when user want to open context menu. + */ + readonly onContextMenu?: (cm: Editor, event: Event) => void + + /** + * Called when content has been copied. The default behavior may be prevented + * by calling `preventDefault` on the event. + */ + readonly onCopy?: (editor: Editor, event: Event) => void +} + +/** + * Attempts to cancel an active mouse selection in the + * given editor by accessing undocumented APIs. This is likely + * to break in the future. + */ +function cancelActiveSelection(cm: Editor) { + if (cm.state && cm.state.selectingText instanceof Function) { + try { + // Simulate a mouseup event which will cause CodeMirror + // to abort its currently active selection. If no selection + // is active the selectingText property will not be a function + // so we won't end up here. + cm.state.selectingText(new CustomEvent('fake-event')) + } catch (err) { + // If we end up here it's likely because CodeMirror has changed + // its internal API. + // See https://github.com/codemirror/CodeMirror/issues/5821 + log.info('Unable to cancel CodeMirror selection', err) + } + } +} + +/** + * A component hosting a CodeMirror instance + */ +export class CodeMirrorHost extends React.Component { + private static updateDoc(cm: Editor, value: string | Doc) { + if (typeof value === 'string') { + cm.setValue(value) + } else { + cancelActiveSelection(cm) + cm.swapDoc(value) + } + } + + private wrapper: HTMLDivElement | null = null + private codeMirror: Editor | null = null + + /** + * Resize observer used for tracking width changes and + * refreshing the internal codemirror instance when + * they occur + */ + private readonly resizeObserver: ResizeObserver + private resizeDebounceId: number | null = null + private lastKnownWidth: number | null = null + + public constructor(props: ICodeMirrorHostProps) { + super(props) + + // Observe size changes and let codemirror know + // when it needs to refresh. + this.resizeObserver = new ResizeObserver(entries => { + if (entries.length === 1 && this.codeMirror) { + const newWidth = entries[0].contentRect.width + + // We don't care about the first resize, let's just + // store what we've got. Codemirror already does a good + // job of height changes through monitoring window resize, + // we just need to care about when the width changes and + // do a re-layout + if (this.lastKnownWidth === null) { + this.lastKnownWidth = newWidth + } else if (this.lastKnownWidth !== newWidth) { + this.lastKnownWidth = newWidth + + if (this.resizeDebounceId !== null) { + cancelAnimationFrame(this.resizeDebounceId) + this.resizeDebounceId = null + } + this.resizeDebounceId = requestAnimationFrame(this.onResized) + } + } + }) + } + + /** + * Gets the internal CodeMirror instance or null if CodeMirror hasn't + * been initialized yet (happens when component mounts) + */ + public getEditor(): Editor | null { + return this.codeMirror + } + + public componentDidMount() { + this.codeMirror = CodeMirror(this.wrapper!, this.props.options) + + this.codeMirror.on('renderLine', this.onRenderLine) + this.codeMirror.on('changes', this.onChanges) + this.codeMirror.on('viewportChange', this.onViewportChange) + this.codeMirror.on('beforeSelectionChange', this.beforeSelectionChanged) + this.codeMirror.on('copy', this.onCopy) + this.codeMirror.on('contextmenu', this.onContextMenu) + this.codeMirror.on('swapDoc', this.onSwapDoc as any) + + CodeMirrorHost.updateDoc(this.codeMirror, this.props.value) + this.resizeObserver.observe(this.codeMirror.getWrapperElement()) + + if (this.wrapper !== null && this.wrapper.closest('dialog') !== null) { + document.addEventListener('dialog-appeared', this.onDialogAppeared) + } + } + + private onDialogAppeared = () => { + requestAnimationFrame(this.onResized) + } + + private onSwapDoc = (cm: Editor, oldDoc: Doc) => { + if (this.props.onSwapDoc) { + this.props.onSwapDoc(cm, oldDoc) + } + } + + private onContextMenu = (instance: Editor, event: Event) => { + if (this.props.onContextMenu) { + this.props.onContextMenu(instance, event) + } + } + + private onCopy = (instance: Editor, event: Event) => { + if (this.props.onCopy) { + this.props.onCopy(instance, event) + } + } + + public componentWillUnmount() { + const cm = this.codeMirror + + if (cm) { + cm.off('changes', this.onChanges) + cm.off('viewportChange', this.onViewportChange) + cm.off('renderLine', this.onRenderLine) + cm.off('beforeSelectionChange', this.beforeSelectionChanged) + cm.off('copy', this.onCopy) + cm.off('swapDoc', this.onSwapDoc as any) + + this.codeMirror = null + } + + this.resizeObserver.disconnect() + document.removeEventListener('dialog-show', this.onDialogAppeared) + } + + public componentDidUpdate(prevProps: ICodeMirrorHostProps) { + if (this.codeMirror && this.props.value !== prevProps.value) { + const oldDoc = this.codeMirror.getDoc() + CodeMirrorHost.updateDoc(this.codeMirror, this.props.value) + const newDoc = this.codeMirror.getDoc() + + if (this.props.onAfterSwapDoc) { + this.props.onAfterSwapDoc(this.codeMirror, oldDoc, newDoc) + } + } + } + + private beforeSelectionChanged = (cm: Editor, changeObj: any) => { + if (this.props.isSelectionEnabled) { + if (!this.props.isSelectionEnabled()) { + // ignore whatever the user has currently selected, pass in a + // "nothing selected" value + // NOTE: + // - `head` is the part of the selection that is moving + // - `anchor` is the other end + changeObj.update([ + { head: { line: 0, ch: 0 }, anchor: { line: 0, ch: 0 } }, + ]) + } + } + } + + private onChanges = (cm: Editor, changes: EditorChange[]) => { + if (this.props.onChanges) { + this.props.onChanges(cm, changes) + } + } + + private onViewportChange = (cm: Editor, from: number, to: number) => { + if (this.props.onViewportChange) { + this.props.onViewportChange(cm, from, to) + } + } + + private onRenderLine = (cm: Editor, line: LineHandle, elem: HTMLElement) => { + if (this.props.onRenderLine) { + this.props.onRenderLine(cm, line, elem) + } + } + + private onResized = () => { + this.resizeDebounceId = null + if (this.codeMirror) { + this.codeMirror.refresh() + } + } + + private onRef = (ref: HTMLDivElement | null) => { + this.wrapper = ref + } + + public render() { + return
+ } +} diff --git a/app/src/ui/diff/diff-explorer.ts b/app/src/ui/diff/diff-explorer.ts new file mode 100644 index 0000000000..507cc41aae --- /dev/null +++ b/app/src/ui/diff/diff-explorer.ts @@ -0,0 +1,237 @@ +import { DiffLine, DiffHunk, DiffLineType } from '../../models/diff' + +/** + * Indicate the type of changes that are included in the current range. + */ +export enum DiffRangeType { + /** Only contains added lines. */ + Additions, + /** Only contains deleted lines. */ + Deletions, + /** Contains both added and removed lines. */ + Mixed, +} + +/** + * Helper object that represents a range of lines in a diff. + * Its type represents the type of interactive (added or deleted) + * lines that it contains, being null when it has no interactive lines. + */ +interface IDiffRange { + readonly from: number + readonly to: number + readonly type: DiffRangeType | null +} + +interface IDiffLineInfo { + readonly line: DiffLine + readonly hunk: DiffHunk +} + +/** + * Locate the diff hunk for the given (absolute) line number in the diff. + */ +export function diffHunkForIndex( + hunks: ReadonlyArray, + index: number +): DiffHunk | null { + const hunk = hunks.find(h => { + return index >= h.unifiedDiffStart && index <= h.unifiedDiffEnd + }) + return hunk || null +} + +/** + * Locate the diff line and hunk for the given (absolute) line number in the diff. + */ +export function diffLineInfoForIndex( + hunks: ReadonlyArray, + index: number +): IDiffLineInfo | null { + const hunk = diffHunkForIndex(hunks, index) + if (!hunk) { + return null + } + + const line = hunk.lines[index - hunk.unifiedDiffStart] + if (!line) { + return null + } + + return { hunk, line } +} + +/** + * Locate the diff line for the given (absolute) line number in the diff. + */ +export function diffLineForIndex( + hunks: ReadonlyArray, + index: number +): DiffLine | null { + const diffLineInfo = diffLineInfoForIndex(hunks, index) + if (diffLineInfo === null) { + return null + } + + return diffLineInfo.line +} + +/** Get the line number as represented in the diff text itself. */ +export function lineNumberForDiffLine( + diffLine: DiffLine, + hunks: ReadonlyArray +): number { + let lineOffset = 0 + for (const hunk of hunks) { + const index = hunk.lines.indexOf(diffLine) + if (index > -1) { + return index + lineOffset + } else { + lineOffset += hunk.lines.length + } + } + + return -1 +} + +/** + * For the given row in the diff, determine the range of elements that + * should be displayed as interactive, as a hunk is not granular enough. + * The values in the returned range are mapped to lines in the original diff, + * in case the current diff has been partially expanded. + */ +export function findInteractiveOriginalDiffRange( + hunks: ReadonlyArray, + index: number +): IDiffRange | null { + const range = findInteractiveDiffRange(hunks, index) + + if (range === null) { + return null + } + + const from = getLineInOriginalDiff(hunks, range.from) + const to = getLineInOriginalDiff(hunks, range.to) + + if (from === null || to === null) { + return null + } + + return { + ...range, + from, + to, + } +} + +/** + * Utility function to get the line number in the original line from a given + * line number in the current text diff (which might be expanded). + */ +export function getLineInOriginalDiff( + hunks: ReadonlyArray, + index: number +) { + const diffLine = diffLineForIndex(hunks, index) + if (diffLine === null) { + return null + } + + return diffLine.originalLineNumber +} + +/** + * For the given row in the diff, determine the range of elements that + * should be displayed as interactive, as a hunk is not granular enough + */ +export function findInteractiveDiffRange( + hunks: ReadonlyArray, + index: number +): IDiffRange | null { + const hunk = diffHunkForIndex(hunks, index) + if (!hunk) { + return null + } + + const relativeIndex = index - hunk.unifiedDiffStart + + let rangeType: DiffRangeType | null = getNextRangeType( + null, + hunk.lines[relativeIndex] + ) + let contextLineBeforeIndex: number | null = null + + for (let i = relativeIndex - 1; i >= 0; i--) { + const line = hunk.lines[i] + if (!line.isIncludeableLine()) { + const startIndex = i + 1 + contextLineBeforeIndex = hunk.unifiedDiffStart + startIndex + break + } + + rangeType = getNextRangeType(rangeType, line) + } + + const from = + contextLineBeforeIndex !== null + ? contextLineBeforeIndex + : hunk.unifiedDiffStart + 1 + + let contextLineAfterIndex: number | null = null + + for (let i = relativeIndex + 1; i < hunk.lines.length; i++) { + const line = hunk.lines[i] + if (!line.isIncludeableLine()) { + const endIndex = i - 1 + contextLineAfterIndex = hunk.unifiedDiffStart + endIndex + break + } + + rangeType = getNextRangeType(rangeType, line) + } + + const to = + contextLineAfterIndex !== null ? contextLineAfterIndex : hunk.unifiedDiffEnd + + return { from, to, type: rangeType } +} + +function getNextRangeType( + currentRangeType: DiffRangeType | null, + currentLine: DiffLine +): DiffRangeType | null { + if ( + currentLine.type !== DiffLineType.Add && + currentLine.type !== DiffLineType.Delete + ) { + // If the current line is not interactive, ignore it. + return currentRangeType + } + + if (currentRangeType === null) { + // If the current range type hasn't been set yet, we set it + // temporarily to the current line type. + return currentLine.type === DiffLineType.Add + ? DiffRangeType.Additions + : DiffRangeType.Deletions + } + + if (currentRangeType === DiffRangeType.Mixed) { + // If the current range type is Mixed we don't need to change it + // (it can't go back to Additions or Deletions). + return currentRangeType + } + + if ( + (currentLine.type === DiffLineType.Add && + currentRangeType !== DiffRangeType.Additions) || + (currentLine.type === DiffLineType.Delete && + currentRangeType !== DiffRangeType.Deletions) + ) { + // If the current line has a different type than the current range type, + // we automatically set the range type to mixed. + return DiffRangeType.Mixed + } + + return currentRangeType +} diff --git a/app/src/ui/diff/diff-helpers.tsx b/app/src/ui/diff/diff-helpers.tsx new file mode 100644 index 0000000000..c181b1a28e --- /dev/null +++ b/app/src/ui/diff/diff-helpers.tsx @@ -0,0 +1,492 @@ +import * as React from 'react' + +import { ILineTokens } from '../../lib/highlighter/types' +import classNames from 'classnames' +import { relativeChanges } from './changed-range' +import { mapKeysEqual } from '../../lib/equality' +import { + WorkingDirectoryFileChange, + CommittedFileChange, +} from '../../models/status' +import { DiffHunk, DiffHunkExpansionType } from '../../models/diff/raw-diff' +import { DiffLineType, ILargeTextDiff, ITextDiff } from '../../models/diff' +import { DiffSyntaxToken } from './diff-syntax-mode' + +/** + * DiffRowType defines the different types of + * rows that a diff visualization can have. + * + * It contains similar values than DiffLineType + * with the addition of `Modified`, which + * corresponds to a line that has both deleted and + * added content. + */ +export enum DiffRowType { + Context = 'Context', + Hunk = 'Hunk', + Added = 'Added', + Deleted = 'Deleted', + Modified = 'Modified', +} + +export enum DiffColumn { + Before = 'before', + After = 'after', +} + +export type SimplifiedDiffRowData = Omit + +export interface IDiffRowData { + /** + * The actual contents of the diff line. + */ + readonly content: string + + /** + * The line number on the source file. + */ + readonly lineNumber: number + + /** + * The line number on the original diff (without expansion). + * This is used for discarding lines and for partial committing lines. + */ + readonly diffLineNumber: number | null + + /** + * Flag to display that this diff line lacks a new line. + * This is used to display when a newline is + * added or removed to the last line of a file. + */ + readonly noNewLineIndicator: boolean + + /** + * Whether the diff line has been selected for partial committing. + */ + readonly isSelected: boolean + + /** + * Array of tokens to do syntax highlighting on the diff line. + */ + readonly tokens: ReadonlyArray +} + +/** + * IDiffRowAdded represents a row that displays an added line. + */ +interface IDiffRowAdded { + readonly type: DiffRowType.Added + + /** + * The data object contains information about that added line in the diff. + */ + readonly data: T + + /** + * The start line of the hunk where this line belongs in the diff. + * + * In this context, a hunk is not exactly equivalent to a diff hunk, but + * instead marks a group of consecutive added/deleted lines (see hoveredHunk + * comment in the `` component). + */ + readonly hunkStartLine: number +} + +/** + * IDiffRowDeleted represents a row that displays a deleted line. + */ +interface IDiffRowDeleted { + readonly type: DiffRowType.Deleted + + /** + * The data object contains information about that deleted line in the diff. + */ + readonly data: T + + /** + * The start line of the hunk where this line belongs in the diff. + * + * In this context, a hunk is not exactly equivalent to a diff hunk, but + * instead marks a group of consecutive added/deleted lines (see hoveredHunk + * comment in the `` component). + */ + readonly hunkStartLine: number +} + +/** + * IDiffRowModified represents a row that displays both a deleted line inline + * with an added line. + */ +interface IDiffRowModified { + readonly type: DiffRowType.Modified + + /** + * The beforeData object contains information about the deleted line in the diff. + */ + readonly beforeData: T + + /** + * The beforeData object contains information about the added line in the diff. + */ + readonly afterData: T + + /** + * The start line of the hunk where this line belongs in the diff. + * + * In this context, a hunk is not exactly equivalent to a diff hunk, but + * instead marks a group of consecutive added/deleted lines (see hoveredHunk + * comment in the `` component). + */ + readonly hunkStartLine: number +} + +/** + * IDiffRowContext represents a row that contains non-modified + * contextual lines around additions/deletions in a diff. + */ +interface IDiffRowContext { + readonly type: DiffRowType.Context + + /** + * The actual contents of the contextual line. + */ + readonly content: string + + /** + * The line number of this row in the previous state source file. + */ + readonly beforeLineNumber: number + + /** + * The line number of this row in the next state source file. + */ + readonly afterLineNumber: number + + /** + * Tokens to use to syntax highlight the contents of the before version of the line. + */ + readonly beforeTokens: ReadonlyArray + + /** + * Tokens to use to syntax highlight the contents of the after version of the line. + */ + readonly afterTokens: ReadonlyArray +} + +/** + * IDiffRowContext represents a row that contains the header + * of a diff hunk. + */ +interface IDiffRowHunk { + readonly type: DiffRowType.Hunk + /** + * The actual contents of the line. + */ + readonly content: string + + /** How the hunk can be expanded. */ + readonly expansionType: DiffHunkExpansionType + + /** Index of the hunk in the diff. */ + readonly hunkIndex: number +} + +export type DiffRow = + | IDiffRowAdded + | IDiffRowDeleted + | IDiffRowModified + | IDiffRowContext + | IDiffRowHunk + +export type SimplifiedDiffRow = + | IDiffRowAdded + | IDiffRowDeleted + | IDiffRowModified + | IDiffRowContext + | IDiffRowHunk + +export type ChangedFile = WorkingDirectoryFileChange | CommittedFileChange + +/** + * Returns an object with two ILineTokens objects that can be used to highlight + * the added and removed characters between two lines. + * + * The `before` object contains the tokens to be used against the `lineBefore` string + * while the `after` object contains the tokens to use with the `lineAfter` string. + * + * This method can be used in conjunction with the `syntaxHighlightLine()` method to + * get the difference between two lines highlighted: + * + * syntaxHighlightLine( + * lineBefore, + * getDiffTokens(lineBefore, lineAfter).before + * ) + * + * @param lineBefore The first version of the line to compare. + * @param lineAfter The second version of the line to compare. + */ +export function getDiffTokens( + lineBefore: string, + lineAfter: string +): { before: ILineTokens; after: ILineTokens } { + const changeRanges = relativeChanges(lineBefore, lineAfter) + + return { + before: { + [changeRanges.stringARange.location]: { + token: 'diff-delete-inner', + length: changeRanges.stringARange.length, + }, + }, + after: { + [changeRanges.stringBRange.location]: { + token: 'diff-add-inner', + length: changeRanges.stringBRange.length, + }, + }, + } +} + +/** + * Returns an JSX element with syntax highlighting of the passed line using both + * the syntaxTokens and diffTokens. + * + * @param line The line to syntax highlight. + * @param tokensArray An array of ILineTokens objects that is used for syntax highlighting. + */ +export function syntaxHighlightLine( + line: string, + tokensArray: ReadonlyArray +): JSX.Element { + const elements = [] + let currentElement = { + content: '', + tokens: new Map(), + } + + for (let i = 0; i < line.length; i++) { + const char = line[i] + const newTokens = new Map() + + for (const [token, endPosition] of currentElement.tokens) { + if (endPosition > i) { + newTokens.set(token, endPosition) + } + } + + for (const tokens of tokensArray) { + if (tokens[i] !== undefined && tokens[i].length > 0) { + // ILineTokens can contain multiple tokens separated by spaces. + // We split them to avoid creating unneeded HTML elements when + // these tokens do not maintain the same order. + const tokenNames = tokens[i].token.split(' ') + const position = i + tokens[i].length + + for (const name of tokenNames) { + const existingTokenPosition = newTokens.get(name) + + // While it's rare, it's theoretically possible that the same + // token exists for the same start position with different end + // positions. If this happens, we choose the longest one. + if ( + existingTokenPosition === undefined || + position > existingTokenPosition + ) { + newTokens.set(name, position) + } + } + } + } + + // If the calculated tokens for the character + // are the same as the ones for the current element, + // we can just append the character on that element contents. + // Otherwise, we need to create a new element with the tokens + // and "archive" the current element. + if (mapKeysEqual(currentElement.tokens, newTokens)) { + currentElement.content += char + currentElement.tokens = newTokens + } else { + elements.push({ + tokens: currentElement.tokens, + content: currentElement.content, + }) + + currentElement = { + content: char, + tokens: newTokens, + } + } + } + + // Add the remaining current element to the list of elements. + elements.push({ + tokens: currentElement.tokens, + content: currentElement.content, + }) + + return ( + <> + {elements.map((element, i) => { + if (element.tokens.size === 0) { + // If the element does not contain any token + // we can skip creating a span. + return element.content + } + return ( + `cm-${name}`) + )} + > + {element.content} + + ) + })} + + ) +} + +/** Utility function for checking whether a file supports selection */ +export function canSelect( + file: ChangedFile +): file is WorkingDirectoryFileChange { + return file instanceof WorkingDirectoryFileChange +} + +/** Gets the width in pixels of the diff line number gutter based on the number of digits in the number */ +export function getLineWidthFromDigitCount(digitAmount: number): number { + return Math.max(digitAmount, 3) * 10 + 5 +} + +/** Utility function for getting the digit count of the largest line number in an array of diff hunks */ +export function getLargestLineNumber(hunks: DiffHunk[]): number { + if (hunks.length === 0) { + return 0 + } + + for (let i = hunks.length - 1; i >= 0; i--) { + const hunk = hunks[i] + + for (let j = hunk.lines.length - 1; j >= 0; j--) { + const line = hunk.lines[j] + + if (line.type === DiffLineType.Hunk) { + continue + } + + const newLineNumber = line.newLineNumber ?? 0 + const oldLineNumber = line.oldLineNumber ?? 0 + return newLineNumber > oldLineNumber ? newLineNumber : oldLineNumber + } + } + + return 0 +} + +export function getNumberOfDigits(val: number): number { + return (Math.log(val) * Math.LOG10E + 1) | 0 +} + +/** + * The longest line for which we'd try to calculate a line diff, this matches + * GitHub.com's behavior. + **/ +export const MaxIntraLineDiffStringLength = 1024 + +/** + * Used to obtain classes applied to style the row as first or last of a group + * of added or deleted rows in the side-by-side diff. + **/ +export function getFirstAndLastClassesSideBySide( + row: SimplifiedDiffRow, + previousRow: SimplifiedDiffRow | undefined, + nextRow: SimplifiedDiffRow | undefined, + addedOrDeleted: DiffRowType.Added | DiffRowType.Deleted +): ReadonlyArray { + const classes = new Array() + const typesToCheck = [addedOrDeleted, DiffRowType.Modified] + + // Is the row of the type we are checking? No. Then can't be first or last. + if (!typesToCheck.includes(row.type)) { + return [] + } + + // Is the previous row exist or is of the type we are checking? + // No. Then this row must be the first of this type. + if (previousRow === undefined || !typesToCheck.includes(previousRow.type)) { + classes.push('is-first') + } + + // Is the next row exist or is of the type we are checking? + // No. Then this row must be last of this type. + if (nextRow === undefined || !typesToCheck.includes(nextRow.type)) { + classes.push('is-last') + } + + return classes +} + +/** + * Used to obtain classes applied to style the row if it is the first or last of + * a group of added, deleted, or modified rows in the unified diff. + **/ +export function getFirstAndLastClassesUnified( + token: DiffSyntaxToken, + prevToken: DiffSyntaxToken | undefined, + nextToken: DiffSyntaxToken | undefined +): string[] { + const addedOrDeletedTokens = [DiffSyntaxToken.Add, DiffSyntaxToken.Delete] + if (!addedOrDeletedTokens.includes(token)) { + return [] + } + + const classNames = [] + + if (prevToken !== token) { + classNames.push('is-first') + } + + if (nextToken !== token) { + classNames.push('is-last') + } + + return classNames +} + +/** + * Compares two text diffs for structural equality. + * + * Components needing to know whether a re-render is necessary after receiving + * a diff is the intended use case. + */ +export function textDiffEquals( + x: ITextDiff | ILargeTextDiff, + y: ITextDiff | ILargeTextDiff +) { + if (x === y) { + return true + } + + if ( + x.text === y.text && + x.kind === y.kind && + x.hasHiddenBidiChars === y.hasHiddenBidiChars && + x.lineEndingsChange === y.lineEndingsChange && + x.hunks.length === y.hunks.length + ) { + // This is a performance optimization which lets us avoid iterating over all + // lines (deep equality on all hunks). We're already comparing the diff text + // above so the only thing that can change with the diff text staying the + // same is whether or not the last line is followed by a trailing newline. + // That information is encodeded in the noTrailingNewLine property which + // exists on all lines but is only ever set on lines in the last hunk + return ( + x.hunks.length === 0 || + x.hunks[x.hunks.length - 1].equals(y.hunks[y.hunks.length - 1]) + ) + } + + return false +} diff --git a/app/src/ui/diff/diff-options.tsx b/app/src/ui/diff/diff-options.tsx new file mode 100644 index 0000000000..2556855d3b --- /dev/null +++ b/app/src/ui/diff/diff-options.tsx @@ -0,0 +1,183 @@ +import * as React from 'react' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { RadioButton } from '../lib/radio-button' +import { + Popover, + PopoverAnchorPosition, + PopoverDecoration, +} from '../lib/popover' +import { Tooltip, TooltipDirection } from '../lib/tooltip' +import { createObservableRef } from '../lib/observable-ref' + +interface IDiffOptionsProps { + readonly isInteractiveDiff: boolean + readonly hideWhitespaceChanges: boolean + readonly onHideWhitespaceChangesChanged: ( + hideWhitespaceChanges: boolean + ) => void + + readonly showSideBySideDiff: boolean + readonly onShowSideBySideDiffChanged: (showSideBySideDiff: boolean) => void + + /** Called when the user opens the diff options popover */ + readonly onDiffOptionsOpened: () => void +} + +interface IDiffOptionsState { + readonly isPopoverOpen: boolean +} + +export class DiffOptions extends React.Component< + IDiffOptionsProps, + IDiffOptionsState +> { + private innerButtonRef = createObservableRef() + private diffOptionsRef = React.createRef() + private gearIconRef = React.createRef() + + public constructor(props: IDiffOptionsProps) { + super(props) + this.state = { + isPopoverOpen: false, + } + } + + private onButtonClick = (event: React.FormEvent) => { + event.preventDefault() + if (this.state.isPopoverOpen) { + this.closePopover() + } else { + this.openPopover() + } + } + + private openPopover = () => { + this.setState(prevState => { + if (!prevState.isPopoverOpen) { + this.props.onDiffOptionsOpened() + return { isPopoverOpen: true } + } + return null + }) + } + + private closePopover = () => { + this.setState(prevState => { + if (prevState.isPopoverOpen) { + return { isPopoverOpen: false } + } + + return null + }) + } + + private onHideWhitespaceChangesChanged = ( + event: React.FormEvent + ) => { + return this.props.onHideWhitespaceChangesChanged( + event.currentTarget.checked + ) + } + + public render() { + const buttonLabel = `Diff ${__DARWIN__ ? 'Settings' : 'Options'}` + return ( +
+ + {this.state.isPopoverOpen && this.renderPopover()} +
+ ) + } + + private renderPopover() { + return ( + +

+ Diff {__DARWIN__ ? 'Settings' : 'Options'} +

+ {this.renderHideWhitespaceChanges()} + {this.renderShowSideBySide()} +
+ ) + } + + private onUnifiedSelected = () => { + this.props.onShowSideBySideDiffChanged(false) + } + private onSideBySideSelected = () => { + this.props.onShowSideBySideDiffChanged(true) + } + + private renderShowSideBySide() { + return ( +
+ Diff display + + +
Split
+ + } + onSelected={this.onSideBySideSelected} + /> +
+ ) + } + + private renderHideWhitespaceChanges() { + return ( +
+ Whitespace + + {this.props.isInteractiveDiff && ( +

+ Interacting with individual lines or hunks will be disabled while + hiding whitespace. +

+ )} +
+ ) + } +} diff --git a/app/src/ui/diff/diff-search-input.tsx b/app/src/ui/diff/diff-search-input.tsx new file mode 100644 index 0000000000..12712a491e --- /dev/null +++ b/app/src/ui/diff/diff-search-input.tsx @@ -0,0 +1,64 @@ +import * as React from 'react' +import { TextBox } from '../lib/text-box' + +interface IDiffSearchInputProps { + /** + * Called when the user indicated that they either want to initiate a search + * or want to advance to the next hit (typically done by hitting `Enter`). + */ + readonly onSearch: (query: string, direction: 'next' | 'previous') => void + + /** + * Called when the user indicates that they want to abort the search, + * either by clicking outside of the component or by hitting `Escape`. + */ + readonly onClose: () => void +} + +interface IDiffSearchInputState { + readonly value: string +} + +export class DiffSearchInput extends React.Component< + IDiffSearchInputProps, + IDiffSearchInputState +> { + public constructor(props: IDiffSearchInputProps) { + super(props) + this.state = { value: '' } + } + + public render() { + return ( +
+ +
+ ) + } + + private onChange = (value: string) => { + this.setState({ value }) + } + + private onBlur = () => { + this.props.onClose() + } + + private onKeyDown = (evt: React.KeyboardEvent) => { + if (evt.key === 'Escape' && !evt.defaultPrevented) { + evt.preventDefault() + this.props.onClose() + } else if (evt.key === 'Enter' && !evt.defaultPrevented) { + evt.preventDefault() + this.props.onSearch(this.state.value, evt.shiftKey ? 'previous' : 'next') + } + } +} diff --git a/app/src/ui/diff/diff-syntax-mode.ts b/app/src/ui/diff/diff-syntax-mode.ts new file mode 100644 index 0000000000..ac2f61dcf4 --- /dev/null +++ b/app/src/ui/diff/diff-syntax-mode.ts @@ -0,0 +1,299 @@ +import { DiffHunk, DiffLine, DiffLineType } from '../../models/diff' +import * as CodeMirror from 'codemirror' +import { diffLineForIndex } from './diff-explorer' +import { ITokens } from '../../lib/highlighter/types' + +import 'codemirror/mode/javascript/javascript' +import { DefaultDiffExpansionStep } from './text-diff-expansion' +import { getFirstAndLastClassesUnified } from './diff-helpers' + +export interface IDiffSyntaxModeOptions { + /** + * The unified diff representing the change + */ + readonly hunks: ReadonlyArray + + /** + * Tokens returned from the highlighter for the 'before' + * version of the change + */ + readonly oldTokens: ITokens + + /** + * Tokens returned from the highlighter for the 'after' + * version of the change + */ + readonly newTokens: ITokens +} + +export interface IDiffSyntaxModeSpec extends IDiffSyntaxModeOptions { + readonly name: 'github-diff-syntax' +} + +export enum DiffSyntaxToken { + Add = 'diff-add', + Delete = 'diff-delete', + Hunk = 'diff-hunk', + Context = 'diff-context', +} + +const TokenNames: { [key: string]: DiffSyntaxToken | undefined } = { + '+': DiffSyntaxToken.Add, + '-': DiffSyntaxToken.Delete, + '@': DiffSyntaxToken.Hunk, + ' ': DiffSyntaxToken.Context, +} + +interface IState { + diffLineIndex: number + previousHunkOldEndLine: number | null + prevLineToken: DiffSyntaxToken | undefined +} + +function skipLine(stream: CodeMirror.StringStream, state: IState) { + stream.skipToEnd() + state.diffLineIndex++ + return null +} + +function getBaseDiffLineStyle( + token: DiffSyntaxToken, + customBackgroundClassNames: ReadonlyArray = [] +) { + const customBackgroundStyles = customBackgroundClassNames + .map(c => `line-background-${c}`) + .join(' ') + + return `line-${token} line-background-${token} ${customBackgroundStyles}` +} + +/** + * Attempt to get tokens for a particular diff line. This will attempt + * to look up tokens in both the old tokens and the new which is + * important because for context lines we might only have tokens in + * one version and we need to be resilient about that. + */ +export function getTokensForDiffLine( + diffLine: DiffLine, + oldTokens: ITokens | undefined, + newTokens: ITokens | undefined +) { + const oldTokensResult = getTokens(diffLine.oldLineNumber, oldTokens) + + if (oldTokensResult !== null) { + return oldTokensResult + } + + return getTokens(diffLine.newLineNumber, newTokens) +} + +/** + * Attempt to get tokens for a particular diff line. This will attempt + * to look up tokens in both the old tokens and the new which is + * important because for context lines we might only have tokens in + * one version and we need to be resilient about that. + */ +export function getTokens( + lineNumber: number | null, + tokens: ITokens | undefined +) { + // Note: Diff lines numbers start at one so we adjust this in order + // to get the line _index_ in the before or after file contents. + if ( + tokens !== undefined && + lineNumber !== null && + tokens[lineNumber - 1] !== undefined + ) { + return tokens[lineNumber - 1] + } + + return null +} + +export class DiffSyntaxMode { + public static readonly ModeName = 'github-diff-syntax' + + private readonly hunks?: ReadonlyArray + private readonly oldTokens?: ITokens + private readonly newTokens?: ITokens + + public constructor( + hunks?: ReadonlyArray, + oldTokens?: ITokens, + newTokens?: ITokens + ) { + this.hunks = hunks + this.oldTokens = oldTokens + this.newTokens = newTokens + } + + public startState(): IState { + return { + diffLineIndex: 0, + previousHunkOldEndLine: null, + prevLineToken: undefined, + } + } + + public blankLine(state: IState) { + // If we run into a blank line and we don't have hunks yet, and given we + // should never get blank diffs, let's assume we're in the last line of a + // diff that was just loaded, but for which we haven't run the highlighter + // yet. If we don't do this, that last line will be formatted wrongly. + if (this.hunks === undefined) { + return getBaseDiffLineStyle(DiffSyntaxToken.Hunk) + } + + // A line might be empty in a non-blank diff for the only line of the + // dummy hunk we put at the bottom of the diff to allow users to expand + // the visible contents. + if (this.hunks.length > 0) { + const diffLine = diffLineForIndex(this.hunks, state.diffLineIndex) + if (diffLine?.type === DiffLineType.Hunk) { + return getBaseDiffLineStyle(DiffSyntaxToken.Hunk) + } + } + + // Should never happen except for blank diffs but + // let's play along + state.diffLineIndex++ + return undefined + } + + public token = ( + stream: CodeMirror.StringStream, + state: IState + ): string | null => { + // The first character of a line in a diff is always going to + // be the diff line marker so we always take care of that first. + if (stream.sol()) { + const tokenKey = stream.next() + + if (stream.eol()) { + state.diffLineIndex++ + } + + if (tokenKey === null) { + return null + } + + const token = TokenNames[tokenKey] + + if (token === undefined) { + return null + } + + const nextLine = stream.lookAhead(1) + const nextLineToken = + typeof nextLine === 'string' ? TokenNames[nextLine[0]] : undefined + + const lineBackgroundClassNames = getFirstAndLastClassesUnified( + token, + state.prevLineToken, + nextLineToken + ) + state.prevLineToken = token + + let result = getBaseDiffLineStyle(token, lineBackgroundClassNames) + + // If it's a hunk header line, we want to make a few extra checks + // depending on the distance to the previous hunk. + if (token === DiffSyntaxToken.Hunk) { + // First we grab the numbers in the hunk header + const matches = stream.match(/\@ -(\d+),(\d+) \+\d+,\d+ \@\@/) + if (matches !== null) { + const oldStartLine = parseInt(matches[1]) + const oldLineCount = parseInt(matches[2]) + + // If there is a hunk above and the distance with this one is bigger + // than the expansion "step", return an additional class name that + // will be used to make that line taller to fit the expansion buttons. + if ( + state.previousHunkOldEndLine !== null && + oldStartLine - state.previousHunkOldEndLine > + DefaultDiffExpansionStep + ) { + result += ` line-${token}-expandable-both` + } + + // Finally we update the state with the index of the last line of the + // current hunk. + state.previousHunkOldEndLine = oldStartLine + oldLineCount + } + + // Check again if we reached the EOL after matching the regex + if (stream.eol()) { + state.diffLineIndex++ + } + } + + return result + } + + // This happens when the mode is running without tokens, in this + // case there's really nothing more for us to do than what we've + // already done above to deal with the diff line marker. + if (this.hunks == null) { + return skipLine(stream, state) + } + + const diffLine = diffLineForIndex(this.hunks, state.diffLineIndex) + + if (!diffLine) { + return skipLine(stream, state) + } + + const lineTokens = getTokensForDiffLine( + diffLine, + this.oldTokens, + this.newTokens + ) + + if (!lineTokens) { + return skipLine(stream, state) + } + + // -1 because the diff line that we're looking at is always prefixed + // by +, -, @ or space depending on the type of diff line. Those markers + // are obviously not present in the before/after version. + const token = lineTokens[stream.pos - stream.lineStart - 1] + + if (!token) { + // There's no token at the current position so let's skip ahead + // until we find one or we hit the end of the line. Note that we + // don't have to worry about already being at the end of the line + // as it's a requirement for modes to always advance the stream. In + // other words, CodeMirror will never give us a stream already at + // the end of a line. + do { + stream.pos++ + } while (!stream.eol() && !lineTokens[stream.pos - stream.lineStart - 1]) + } else { + stream.pos += token.length + } + + if (stream.eol()) { + state.diffLineIndex++ + } + + return token ? token.token : null + } +} + +CodeMirror.defineMode( + DiffSyntaxMode.ModeName, + function ( + config: CodeMirror.EditorConfiguration, + modeOptions?: IDiffSyntaxModeOptions + ) { + if (!modeOptions) { + throw new Error('I needs me some options') + } + + return new DiffSyntaxMode( + modeOptions.hunks, + modeOptions.oldTokens, + modeOptions.newTokens + ) + } +) diff --git a/app/src/ui/diff/hidden-bidi-chars-warning.tsx b/app/src/ui/diff/hidden-bidi-chars-warning.tsx new file mode 100644 index 0000000000..53c46ca274 --- /dev/null +++ b/app/src/ui/diff/hidden-bidi-chars-warning.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { LinkButton } from '../lib/link-button' + +export class HiddenBidiCharsWarning extends React.Component { + public render() { + return ( +
+ + This diff contains bidirectional Unicode text that may be interpreted or + compiled differently than what appears below. To review, open the file + in an editor that reveals hidden Unicode characters.{' '} + + Learn more about bidirectional Unicode characters + +
+ ) + } +} diff --git a/app/src/ui/diff/image-diffs/deleted-image-diff.tsx b/app/src/ui/diff/image-diffs/deleted-image-diff.tsx new file mode 100644 index 0000000000..7c53bfbb33 --- /dev/null +++ b/app/src/ui/diff/image-diffs/deleted-image-diff.tsx @@ -0,0 +1,25 @@ +import * as React from 'react' + +import { Image } from '../../../models/diff' +import { ImageContainer } from './image-container' + +interface IDeletedImageDiffProps { + readonly previous: Image +} + +/** A component to render when the file has been deleted from the repository */ +export class DeletedImageDiff extends React.Component< + IDeletedImageDiffProps, + {} +> { + public render() { + return ( +
+
+
Deleted
+ +
+
+ ) + } +} diff --git a/app/src/ui/diff/image-diffs/difference-blend.tsx b/app/src/ui/diff/image-diffs/difference-blend.tsx new file mode 100644 index 0000000000..a5bf0078cc --- /dev/null +++ b/app/src/ui/diff/image-diffs/difference-blend.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' +import { ImageContainer } from './image-container' +import { ICommonImageDiffProperties } from './modified-image-diff' + +export class DifferenceBlend extends React.Component< + ICommonImageDiffProperties, + {} +> { + public render() { + const style: React.CSSProperties = { + height: this.props.maxSize.height, + width: this.props.maxSize.width, + } + + const maxSize: React.CSSProperties = { + maxHeight: this.props.maxSize.height, + maxWidth: this.props.maxSize.width, + } + + return ( +
+
+
+
+ +
+ +
+ +
+
+
+
+ ) + } +} diff --git a/app/src/ui/diff/image-diffs/image-container.tsx b/app/src/ui/diff/image-diffs/image-container.tsx new file mode 100644 index 0000000000..2832d8d04f --- /dev/null +++ b/app/src/ui/diff/image-diffs/image-container.tsx @@ -0,0 +1,38 @@ +import * as React from 'react' + +import { Image } from '../../../models/diff' + +interface IImageProps { + /** The image contents to render */ + readonly image: Image + + /** Optional styles to apply to the image container */ + readonly style?: React.CSSProperties + + /** callback to fire after the image has been loaded */ + readonly onElementLoad?: (img: HTMLImageElement) => void +} + +export class ImageContainer extends React.Component { + public render() { + const image = this.props.image + const imageSource = `data:${image.mediaType};base64,${image.contents}` + + return ( +
+ +
+ ) + } + + private onLoad = (e: React.SyntheticEvent) => { + if (this.props.onElementLoad) { + this.props.onElementLoad(e.currentTarget) + } + } +} diff --git a/app/src/ui/diff/image-diffs/index.ts b/app/src/ui/diff/image-diffs/index.ts new file mode 100644 index 0000000000..019fef25e6 --- /dev/null +++ b/app/src/ui/diff/image-diffs/index.ts @@ -0,0 +1,3 @@ +export { ModifiedImageDiff } from './modified-image-diff' +export { NewImageDiff } from './new-image-diff' +export { DeletedImageDiff } from './deleted-image-diff' diff --git a/app/src/ui/diff/image-diffs/modified-image-diff.tsx b/app/src/ui/diff/image-diffs/modified-image-diff.tsx new file mode 100644 index 0000000000..c774a232f7 --- /dev/null +++ b/app/src/ui/diff/image-diffs/modified-image-diff.tsx @@ -0,0 +1,204 @@ +import * as React from 'react' + +import { Image, ImageDiffType } from '../../../models/diff' +import { TabBar, TabBarType } from '../../tab-bar' +import { TwoUp } from './two-up' +import { DifferenceBlend } from './difference-blend' +import { OnionSkin } from './onion-skin' +import { Swipe } from './swipe' +import { assertNever } from '../../../lib/fatal-error' +import { ISize, getMaxFitSize } from './sizing' + +interface IModifiedImageDiffProps { + readonly previous: Image + readonly current: Image + readonly diffType: ImageDiffType + /** + * Called when the user is viewing an image diff and requests + * to change the diff presentation mode. + */ + readonly onChangeDiffType: (type: ImageDiffType) => void +} + +export interface ICommonImageDiffProperties { + /** The biggest size to fit both the previous and current images. */ + readonly maxSize: ISize + + /** The previous image. */ + readonly previous: Image + + /** The current image. */ + readonly current: Image + + /** A function to call when the previous image has loaded. */ + readonly onPreviousImageLoad: (img: HTMLImageElement) => void + + /** A function to call when the current image has loaded. */ + readonly onCurrentImageLoad: (img: HTMLImageElement) => void + + /** + * A function to call which provides the element that will contain the + * images. This container element is used to measure the available space for + * the images, which is then used to calculate the aspect fit size. + */ + readonly onContainerRef: (e: HTMLElement | null) => void +} + +interface IModifiedImageDiffState { + /** The size of the previous image. */ + readonly previousImageSize: ISize | null + + /** The size of the current image. */ + readonly currentImageSize: ISize | null + + /** The size of the container element. */ + readonly containerSize: ISize | null +} + +/** A component which renders the changes to an image in the repository */ +export class ModifiedImageDiff extends React.Component< + IModifiedImageDiffProps, + IModifiedImageDiffState +> { + private container: HTMLElement | null = null + + private readonly resizeObserver: ResizeObserver + private resizedTimeoutID: NodeJS.Immediate | null = null + + public constructor(props: IModifiedImageDiffProps) { + super(props) + + this.resizeObserver = new ResizeObserver(entries => { + for (const { target, contentRect } of entries) { + if (target === this.container && target instanceof HTMLElement) { + // We might end up causing a recursive update by updating the state + // when we're reacting to a resize so we'll defer it until after + // react is done with this frame. + if (this.resizedTimeoutID !== null) { + clearImmediate(this.resizedTimeoutID) + } + + this.resizedTimeoutID = setImmediate( + this.onResized, + target, + contentRect + ) + } + } + }) + + this.state = { + previousImageSize: null, + currentImageSize: null, + containerSize: null, + } + } + + private onPreviousImageLoad = (img: HTMLImageElement) => { + const size = { width: img.naturalWidth, height: img.naturalHeight } + this.setState({ previousImageSize: size }) + } + + private onCurrentImageLoad = (img: HTMLImageElement) => { + const size = { width: img.naturalWidth, height: img.naturalHeight } + this.setState({ currentImageSize: size }) + } + + private onResized = (target: HTMLElement, contentRect: ClientRect) => { + this.resizedTimeoutID = null + + const containerSize = { + width: target.offsetWidth, + height: target.offsetHeight, + } + this.setState({ containerSize }) + } + + private getMaxSize(): ISize { + const zeroSize = { width: 0, height: 0, containerWidth: 0 } + const containerSize = this.state.containerSize + if (!containerSize) { + return zeroSize + } + + const { previousImageSize, currentImageSize } = this.state + if (!previousImageSize || !currentImageSize) { + return zeroSize + } + + const maxFitSize = getMaxFitSize( + previousImageSize, + currentImageSize, + containerSize + ) + + return maxFitSize + } + + private onContainerRef = (c: HTMLElement | null) => { + this.container = c + + this.resizeObserver.disconnect() + + if (c) { + this.resizeObserver.observe(c) + } + } + + public render() { + return ( +
+ {this.renderCurrentDiffType()} + + + 2-up + Swipe + Onion Skin + Difference + +
+ ) + } + + private renderCurrentDiffType() { + const maxSize = this.getMaxSize() + const type = this.props.diffType + switch (type) { + case ImageDiffType.TwoUp: + return ( + + ) + + case ImageDiffType.Swipe: + return + + case ImageDiffType.OnionSkin: + return + + case ImageDiffType.Difference: + return + + default: + return assertNever(type, `Unknown diff type: ${type}`) + } + } + + private getCommonProps(maxSize: ISize): ICommonImageDiffProperties { + return { + maxSize, + previous: this.props.previous, + current: this.props.current, + onPreviousImageLoad: this.onPreviousImageLoad, + onCurrentImageLoad: this.onCurrentImageLoad, + onContainerRef: this.onContainerRef, + } + } +} diff --git a/app/src/ui/diff/image-diffs/new-image-diff.tsx b/app/src/ui/diff/image-diffs/new-image-diff.tsx new file mode 100644 index 0000000000..72df75e401 --- /dev/null +++ b/app/src/ui/diff/image-diffs/new-image-diff.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' + +import { Image } from '../../../models/diff' +import { ImageContainer } from './image-container' + +interface INewImageDiffProps { + readonly current: Image +} + +/** A component to render when a new image has been added to the repository */ +export class NewImageDiff extends React.Component { + public render() { + return ( +
+
+
Added
+ +
+
+ ) + } +} diff --git a/app/src/ui/diff/image-diffs/onion-skin.tsx b/app/src/ui/diff/image-diffs/onion-skin.tsx new file mode 100644 index 0000000000..4e23bc6c3f --- /dev/null +++ b/app/src/ui/diff/image-diffs/onion-skin.tsx @@ -0,0 +1,77 @@ +import * as React from 'react' +import { ICommonImageDiffProperties } from './modified-image-diff' +import { ImageContainer } from './image-container' + +interface IOnionSkinState { + readonly crossfade: number +} + +export class OnionSkin extends React.Component< + ICommonImageDiffProperties, + IOnionSkinState +> { + public constructor(props: ICommonImageDiffProperties) { + super(props) + + this.state = { crossfade: 1 } + } + + public render() { + const style: React.CSSProperties = { + height: this.props.maxSize.height, + width: this.props.maxSize.width, + } + + const maxSize: React.CSSProperties = { + maxHeight: this.props.maxSize.height, + maxWidth: this.props.maxSize.width, + } + + return ( +
+
+
+
+ +
+ +
+ +
+
+
+ + +
+ ) + } + + private onValueChange = (e: React.ChangeEvent) => { + this.setState({ crossfade: e.currentTarget.valueAsNumber }) + } +} diff --git a/app/src/ui/diff/image-diffs/sizing.ts b/app/src/ui/diff/image-diffs/sizing.ts new file mode 100644 index 0000000000..46396ea8db --- /dev/null +++ b/app/src/ui/diff/image-diffs/sizing.ts @@ -0,0 +1,49 @@ +export interface ISize { + readonly width: number + readonly height: number +} + +/** + * Get the size which fits in the container without scaling and maintaining + * aspect ratio. + */ +export function getAspectFitSize( + imageSize: ISize, + containerSize: ISize +): ISize { + const heightRatio = + containerSize.height < imageSize.height + ? imageSize.height / containerSize.height + : 1 + const widthRatio = + containerSize.width < imageSize.width + ? imageSize.width / containerSize.width + : 1 + + let ratio = Math.max(1, widthRatio) + if (widthRatio < heightRatio) { + ratio = Math.max(1, heightRatio) + } + + return { + width: imageSize.width / ratio, + height: imageSize.height / ratio, + } +} + +/** + * Get the size which will fit the bigger of the two images while maintaining + * aspect ratio. + */ +export function getMaxFitSize( + previousImageSize: ISize, + currentImageSize: ISize, + containerSize: ISize +): ISize { + const previousSize = getAspectFitSize(previousImageSize, containerSize) + const currentSize = getAspectFitSize(currentImageSize, containerSize) + + const width = Math.max(previousSize.width, currentSize.width) + const height = Math.max(previousSize.height, currentSize.height) + return { width, height } +} diff --git a/app/src/ui/diff/image-diffs/swipe.tsx b/app/src/ui/diff/image-diffs/swipe.tsx new file mode 100644 index 0000000000..b1c5e027e0 --- /dev/null +++ b/app/src/ui/diff/image-diffs/swipe.tsx @@ -0,0 +1,89 @@ +import * as React from 'react' +import { ICommonImageDiffProperties } from './modified-image-diff' +import { ImageContainer } from './image-container' + +/** How much bigger the slider should be than the images. */ +const SliderOverflow = 14 + +interface ISwipeState { + readonly percentage: number +} + +export class Swipe extends React.Component< + ICommonImageDiffProperties, + ISwipeState +> { + public constructor(props: ICommonImageDiffProperties) { + super(props) + + this.state = { percentage: 0 } + } + + public render() { + const style: React.CSSProperties = { + height: this.props.maxSize.height, + width: this.props.maxSize.width, + } + + const swiperWidth = this.props.maxSize.width * (1 - this.state.percentage) + + const currentStyle: React.CSSProperties = { + height: this.props.maxSize.height, + width: this.props.maxSize.width, + left: -(this.props.maxSize.width - swiperWidth), + } + + const maxSize: React.CSSProperties = { + maxHeight: this.props.maxSize.height, + maxWidth: this.props.maxSize.width, + } + + return ( +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+ ) + } + + private onValueChange = (e: React.ChangeEvent) => { + const percentage = e.currentTarget.valueAsNumber + this.setState({ percentage }) + } +} diff --git a/app/src/ui/diff/image-diffs/two-up.tsx b/app/src/ui/diff/image-diffs/two-up.tsx new file mode 100644 index 0000000000..8006ec84c4 --- /dev/null +++ b/app/src/ui/diff/image-diffs/two-up.tsx @@ -0,0 +1,86 @@ +import * as React from 'react' +import { ImageContainer } from './image-container' +import { ICommonImageDiffProperties } from './modified-image-diff' +import { ISize } from './sizing' +import { formatBytes } from '../../lib/bytes' +import classNames from 'classnames' + +function percentDiff(previous: number, current: number) { + return `${Math.abs(Math.round((current / previous) * 100))}%` +} + +interface ITwoUpProps extends ICommonImageDiffProperties { + readonly previousImageSize: ISize | null + readonly currentImageSize: ISize | null +} + +export class TwoUp extends React.Component { + public render() { + const zeroSize = { width: 0, height: 0 } + const previousImageSize = this.props.previousImageSize || zeroSize + const currentImageSize = this.props.currentImageSize || zeroSize + + const { current, previous } = this.props + + const diffPercent = percentDiff(previous.bytes, current.bytes) + const diffBytes = current.bytes - previous.bytes + const diffBytesSign = diffBytes >= 0 ? '+' : '' + + const style: React.CSSProperties = { + maxWidth: this.props.maxSize.width, + } + + return ( +
+
+
+
Deleted
+ + +
+ W: {previousImageSize.width} + px | H: {previousImageSize.height} + px | Size:{' '} + {formatBytes(previous.bytes, 2, false)} +
+
+ +
+
Added
+ + +
+ W: {currentImageSize.width} + px | H: {currentImageSize.height} + px | Size:{' '} + {formatBytes(current.bytes, 2, false)} +
+
+
+
+ Diff:{' '} + 0, + removed: diffBytes < 0, + })} + > + {diffBytes !== 0 + ? `${diffBytesSign}${formatBytes( + diffBytes, + 2, + false + )} (${diffPercent})` + : 'No size difference'} + +
+
+ ) + } +} diff --git a/app/src/ui/diff/index.tsx b/app/src/ui/diff/index.tsx new file mode 100644 index 0000000000..d4c4c9d2c2 --- /dev/null +++ b/app/src/ui/diff/index.tsx @@ -0,0 +1,323 @@ +import * as React from 'react' + +import { assertNever } from '../../lib/fatal-error' +import { encodePathAsUrl } from '../../lib/path' + +import { Repository } from '../../models/repository' +import { + CommittedFileChange, + WorkingDirectoryFileChange, + AppFileStatusKind, + isManualConflict, + isConflictedFileStatus, +} from '../../models/status' +import { + DiffSelection, + DiffType, + IDiff, + IImageDiff, + ITextDiff, + ILargeTextDiff, + ImageDiffType, + ISubmoduleDiff, +} from '../../models/diff' +import { Button } from '../lib/button' +import { + NewImageDiff, + ModifiedImageDiff, + DeletedImageDiff, +} from './image-diffs' +import { BinaryFile } from './binary-file' +import { TextDiff } from './text-diff' +import { SideBySideDiff } from './side-by-side-diff' +import { enableExperimentalDiffViewer } from '../../lib/feature-flag' +import { IFileContents } from './syntax-highlighting' +import { SubmoduleDiff } from './submodule-diff' + +// image used when no diff is displayed +const NoDiffImage = encodePathAsUrl(__dirname, 'static/ufo-alert.svg') + +type ChangedFile = WorkingDirectoryFileChange | CommittedFileChange + +/** The props for the Diff component. */ +interface IDiffProps { + readonly repository: Repository + + /** + * Whether the diff is readonly, e.g., displaying a historical diff, or the + * diff's lines can be selected, e.g., displaying a change in the working + * directory. + */ + readonly readOnly: boolean + + /** The file whose diff should be displayed. */ + readonly file: ChangedFile + + /** Called when the includedness of lines or a range of lines has changed. */ + readonly onIncludeChanged?: (diffSelection: DiffSelection) => void + + /** The diff that should be rendered */ + readonly diff: IDiff + + /** + * Contents of the old and new files related to the current text diff. + */ + readonly fileContents: IFileContents | null + + /** The type of image diff to display. */ + readonly imageDiffType: ImageDiffType + + /** Hiding whitespace in diff. */ + readonly hideWhitespaceInDiff: boolean + + /** Whether we should display side by side diffs. */ + readonly showSideBySideDiff: boolean + + /** Whether we should show a confirmation dialog when the user discards changes */ + readonly askForConfirmationOnDiscardChanges?: boolean + + /** + * Called when the user requests to open a binary file in an the + * system-assigned application for said file type. + */ + readonly onOpenBinaryFile: (fullPath: string) => void + + /** + * Callback to open a selected file using the configured external editor + * + * @param fullPath The full path to the file on disk + */ + readonly onOpenInExternalEditor?: (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. + */ + readonly onChangeImageDiffType: (type: ImageDiffType) => void + + /* + * 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 + + /** Called when the user changes the hide whitespace in diffs setting. */ + readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void +} + +interface IDiffState { + readonly forceShowLargeDiff: boolean +} + +/** A component which renders a diff for a file. */ +export class Diff extends React.Component { + public constructor(props: IDiffProps) { + super(props) + + this.state = { + forceShowLargeDiff: false, + } + } + + public render() { + const diff = this.props.diff + + switch (diff.kind) { + case DiffType.Text: + return this.renderText(diff) + case DiffType.Binary: + return this.renderBinaryFile() + case DiffType.Submodule: + return this.renderSubmoduleDiff(diff) + case DiffType.Image: + return this.renderImage(diff) + case DiffType.LargeText: { + return this.state.forceShowLargeDiff + ? this.renderLargeText(diff) + : this.renderLargeTextDiff() + } + case DiffType.Unrenderable: + return this.renderUnrenderableDiff() + default: + return assertNever(diff, `Unsupported diff type: ${diff}`) + } + } + + private renderImage(imageDiff: IImageDiff) { + if (imageDiff.current && imageDiff.previous) { + return ( + + ) + } + + if ( + imageDiff.current && + (this.props.file.status.kind === AppFileStatusKind.New || + this.props.file.status.kind === AppFileStatusKind.Untracked) + ) { + return + } + + if ( + imageDiff.previous && + this.props.file.status.kind === AppFileStatusKind.Deleted + ) { + return + } + + return null + } + + private renderLargeTextDiff() { + return ( +
+ +

+ The diff is too large to be displayed by default. +
+ You can try to show it anyway, but performance may be negatively + impacted. +

+ +
+ ) + } + + private renderUnrenderableDiff() { + return ( +
+ +

The diff is too large to be displayed.

+
+ ) + } + + private renderLargeText(diff: ILargeTextDiff) { + // guaranteed to be set since this function won't be called if text or hunks are null + const textDiff: ITextDiff = { + text: diff.text, + hunks: diff.hunks, + kind: DiffType.Text, + lineEndingsChange: diff.lineEndingsChange, + maxLineNumber: diff.maxLineNumber, + hasHiddenBidiChars: diff.hasHiddenBidiChars, + } + + return this.renderTextDiff(textDiff) + } + + private renderText(diff: ITextDiff) { + if (diff.hunks.length === 0) { + if ( + this.props.file.status.kind === AppFileStatusKind.New || + this.props.file.status.kind === AppFileStatusKind.Untracked + ) { + return
The file is empty
+ } + + if (this.props.file.status.kind === AppFileStatusKind.Renamed) { + return ( +
+ The file was renamed but not changed +
+ ) + } + + if ( + isConflictedFileStatus(this.props.file.status) && + isManualConflict(this.props.file.status) + ) { + return ( +
+ The file is in conflict and must be resolved via the command line. +
+ ) + } + + if (this.props.hideWhitespaceInDiff) { + return
Only whitespace changes found
+ } + + return
No content changes found
+ } + + return this.renderTextDiff(diff) + } + + private renderSubmoduleDiff(diff: ISubmoduleDiff) { + return ( + + ) + } + + private renderBinaryFile() { + return ( + + ) + } + + private renderTextDiff(diff: ITextDiff) { + if (enableExperimentalDiffViewer() || this.props.showSideBySideDiff) { + return ( + + ) + } + + return ( + + ) + } + + private showLargeDiff = () => { + this.setState({ forceShowLargeDiff: true }) + } +} diff --git a/app/src/ui/diff/seamless-diff-switcher.tsx b/app/src/ui/diff/seamless-diff-switcher.tsx new file mode 100644 index 0000000000..ec05acd9a0 --- /dev/null +++ b/app/src/ui/diff/seamless-diff-switcher.tsx @@ -0,0 +1,362 @@ +import * as React from 'react' +import classNames from 'classnames' + +import { Repository } from '../../models/repository' + +import { Diff } from './index' +import { + WorkingDirectoryFileChange, + CommittedFileChange, +} from '../../models/status' +import { + DiffSelection, + DiffType, + IDiff, + ImageDiffType, + ITextDiff, + ILargeTextDiff, +} from '../../models/diff' +import { Loading } from '../lib/loading' +import { getFileContents, IFileContents } from './syntax-highlighting' +import { getTextDiffWithBottomDummyHunk } from './text-diff-expansion' +import { textDiffEquals } from './diff-helpers' + +/** + * The time (in milliseconds) we allow when loading a diff before + * treating the diff load as slow. + */ +const SlowDiffLoadingThreshold = 150 + +type ChangedFile = WorkingDirectoryFileChange | CommittedFileChange + +interface ISeamlessDiffSwitcherProps { + readonly repository: Repository + + /** + * Whether the diff is readonly, e.g., displaying a historical diff, or the + * diff's lines can be selected, e.g., displaying a change in the working + * directory. + */ + readonly readOnly: boolean + + /** The file whose diff should be displayed. */ + readonly file: ChangedFile + + /** Called when the includedness of lines or a range of lines has changed. */ + readonly onIncludeChanged?: (diffSelection: DiffSelection) => void + + /** The diff that should be rendered */ + readonly diff: IDiff | null + + /** The type of image diff to display. */ + readonly imageDiffType: ImageDiffType + + /** Hiding whitespace in diff. */ + readonly hideWhitespaceInDiff: boolean + + /** Whether we should display side by side diffs. */ + readonly showSideBySideDiff: boolean + + /** Whether we should show a confirmation dialog when the user discards changes */ + readonly askForConfirmationOnDiscardChanges?: boolean + + /** + * Called when the user requests to open a binary file in an the + * system-assigned application for said file type. + */ + 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. + */ + readonly onChangeImageDiffType: (type: ImageDiffType) => void + + /* + * 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 + + /** Called when the user changes the hide whitespace in diffs setting. */ + readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void +} + +interface ISeamlessDiffSwitcherState { + /** + * Whether or not the application is currently loading the next + * diff that should be displayed. + */ + readonly isLoadingDiff: boolean + + /** + * Whether or not the application has taken more than + * `SlowDiffLoadingThreshold` milliseconds trying to load the + * diff + */ + readonly isLoadingSlow: boolean + + /** + * The current props for the SeamlessDiffSwitcher or a snapshot + * of props from the last time we had a Diff to show if the + * `isLoadingDiff` prop is true. + */ + readonly propSnapshot: ISeamlessDiffSwitcherProps + + /** The diff that should be rendered */ + readonly diff: IDiff | null + + /** Contents of the old and new files related to the current text diff. */ + readonly fileContents: IFileContents | null +} + +/** I'm super useful */ +function noop() {} + +function isSameFile(prevFile: ChangedFile, newFile: ChangedFile) { + return prevFile === newFile || prevFile.id === newFile.id +} + +function isSameDiff(prevDiff: IDiff, newDiff: IDiff) { + return ( + prevDiff === newDiff || + (isTextDiff(prevDiff) && + isTextDiff(newDiff) && + textDiffEquals(prevDiff, newDiff)) + ) +} + +function isTextDiff(diff: IDiff): diff is ITextDiff | ILargeTextDiff { + return diff.kind === DiffType.Text || diff.kind === DiffType.LargeText +} + +/** + * A component which attempts to minimize the need for unmounting + * and remounting text diff components with the ultimate goal of + * avoiding flickering when rapidly switching between files. + */ +export class SeamlessDiffSwitcher extends React.Component< + ISeamlessDiffSwitcherProps, + ISeamlessDiffSwitcherState +> { + public static getDerivedStateFromProps( + props: ISeamlessDiffSwitcherProps, + state: ISeamlessDiffSwitcherState + ): Partial { + const sameFile = + state.fileContents !== null && + isSameFile(state.fileContents.file, props.file) + const fileContents = sameFile ? state.fileContents : null + // If it's a text diff, we'll consider it loaded once the contents of the old + // and new files have been loaded. + const isLoadingDiff = + props.diff === null || (isTextDiff(props.diff) && fileContents === null) + const beganOrFinishedLoadingDiff = isLoadingDiff !== state.isLoadingDiff + // If the props diff is not a text diff, just pass it along to the state. + const diff = + props.diff !== null && !isTextDiff(props.diff) ? props.diff : state.diff + + return { + isLoadingDiff, + ...(!isLoadingDiff ? { propSnapshot: props } : undefined), + // If we've just begun loading the diff or just finished loading it we + // can't say that it's slow in all other cases we leave the + // isLoadingSlow state as-is + ...(beganOrFinishedLoadingDiff ? { isLoadingSlow: false } : undefined), + diff, + fileContents, + } + } + + private slowLoadingTimeoutId: number | null = null + + /** File whose (old & new files) contents are being loaded. */ + private loadingState: { file: ChangedFile; diff: IDiff } | null = null + + public constructor(props: ISeamlessDiffSwitcherProps) { + super(props) + + // It's loading the diff if (1) there is no diff or (2) we have a diff but + // it's a text diff. In that case we need to load the contents of the old + // and new files before considering it loaded. + const isLoadingDiff = props.diff === null || isTextDiff(props.diff) + + this.state = { + isLoadingDiff, + isLoadingSlow: false, + propSnapshot: props, + diff: props.diff, + fileContents: null, + } + } + + public componentDidMount() { + if (this.state.isLoadingDiff) { + this.scheduleSlowLoadingTimeout() + } + this.loadFileContentsIfNeeded(null) + } + + public componentWillUnmount() { + this.clearSlowLoadingTimeout() + } + + public componentDidUpdate( + prevProps: ISeamlessDiffSwitcherProps, + prevState: ISeamlessDiffSwitcherState + ) { + // Have we transitioned from loading to not loading or vice versa? + if (this.state.isLoadingDiff !== prevState.isLoadingDiff) { + if (this.state.isLoadingDiff) { + // If we've just begun loading the diff, start the timer + this.scheduleSlowLoadingTimeout() + } else { + // If we're no longer loading the diff make sure that we're not + // still counting down + this.clearSlowLoadingTimeout() + } + } + + this.loadFileContentsIfNeeded(prevProps.diff) + } + + private async loadFileContentsIfNeeded(prevDiff: IDiff | null) { + const { diff, file: fileToLoad } = this.props + + if (diff === null || !isTextDiff(diff)) { + return + } + + // Have we already loaded file contents for this file and is the diff + // still the same, if so there's no need to do it again. + const currentFileContents = this.state.fileContents + if ( + currentFileContents !== null && + isSameFile(currentFileContents.file, fileToLoad) && + prevDiff !== null && + isSameDiff(prevDiff, diff) + ) { + return + } + + // Are we currently loading file contents for this file and is the diff + // still the same? If so we can wait for that to load + if ( + this.loadingState !== null && + isSameFile(this.loadingState.file, fileToLoad) && + isSameDiff(this.loadingState.diff, diff) + ) { + return + } + + this.loadingState = { file: fileToLoad, diff } + + const fileContents = await getFileContents( + this.props.repository, + fileToLoad + ) + + this.loadingState = null + + // Has the file changed while we've been reading it? + if (!isSameFile(fileToLoad, this.props.file)) { + return + } + + const newDiff = + fileContents.canBeExpanded && diff.kind === DiffType.Text + ? getTextDiffWithBottomDummyHunk( + diff, + diff.hunks, + fileContents.oldContents.length, + fileContents.newContents.length + ) + : null + + this.setState({ diff: newDiff ?? diff, fileContents }) + } + + private onSlowLoadingTimeout = () => { + this.setState({ isLoadingSlow: true }) + } + + private scheduleSlowLoadingTimeout() { + this.clearSlowLoadingTimeout() + this.slowLoadingTimeoutId = window.setTimeout( + this.onSlowLoadingTimeout, + SlowDiffLoadingThreshold + ) + } + + private clearSlowLoadingTimeout() { + if (this.slowLoadingTimeoutId !== null) { + window.clearTimeout(this.slowLoadingTimeoutId) + this.slowLoadingTimeoutId = null + } + } + + public render() { + const { isLoadingDiff, isLoadingSlow, fileContents, diff } = this.state + const { + repository, + imageDiffType, + readOnly, + hideWhitespaceInDiff, + showSideBySideDiff, + onIncludeChanged, + onDiscardChanges, + file, + onOpenBinaryFile, + onOpenSubmodule, + onChangeImageDiffType, + onHideWhitespaceInDiffChanged, + } = this.state.propSnapshot + + const className = classNames('seamless-diff-switcher', { + loading: isLoadingDiff, + slow: isLoadingDiff && isLoadingSlow, + 'has-diff': diff !== null, + }) + + const loadingIndicator = isLoadingDiff ? ( +
+ +
+ ) : null + + return ( +
+ {diff !== null ? ( + + ) : null} + {loadingIndicator} +
+ ) + } +} diff --git a/app/src/ui/diff/side-by-side-diff-row.tsx b/app/src/ui/diff/side-by-side-diff-row.tsx new file mode 100644 index 0000000000..81973e221b --- /dev/null +++ b/app/src/ui/diff/side-by-side-diff-row.tsx @@ -0,0 +1,741 @@ +import * as React from 'react' + +import { + syntaxHighlightLine, + DiffRow, + DiffRowType, + IDiffRowData, + DiffColumn, +} from './diff-helpers' +import { ILineTokens } from '../../lib/highlighter/types' +import classNames from 'classnames' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { narrowNoNewlineSymbol } from './text-diff' +import { shallowEquals, structuralEquals } from '../../lib/equality' +import { DiffHunkExpansionType } from '../../models/diff' +import { PopoverAnchorPosition } from '../lib/popover' +import { WhitespaceHintPopover } from './whitespace-hint-popover' +import { TooltipDirection } from '../lib/tooltip' +import { Button } from '../lib/button' + +interface ISideBySideDiffRowProps { + /** + * The row data. This contains most of the information used to render the row. + */ + readonly row: DiffRow + + /** + * Whether the diff is selectable or read-only. + */ + readonly isDiffSelectable: boolean + + /** + * Whether the row belongs to a hunk that is hovered. + */ + readonly isHunkHovered: boolean + + /** + * Whether to display the rows side by side. + */ + readonly showSideBySideDiff: boolean + + /** Whether or not whitespace changes are hidden. */ + readonly hideWhitespaceInDiff: boolean + + /** + * The width (in pixels) of the diff gutter. + */ + readonly lineNumberWidth: number + + /** + * The index of the row in the displayed diff. + */ + readonly numRow: number + + /** + * Called when a line selection is started. Called with the + * row and column of the selected line and a flag to indicate + * if the user is selecting or unselecting lines. + * (only relevant when isDiffSelectable is true) + */ + readonly onStartSelection: ( + row: number, + column: DiffColumn, + select: boolean + ) => void + + /** + * Called when a line selection is updated. Called with the + * row and column of the hovered line. + * (only relevant when isDiffSelectable is true) + */ + readonly onUpdateSelection: (row: number, column: DiffColumn) => void + + /** + * Called when the user hovers the hunk handle. Called with the start + * line of the hunk. + * (only relevant when isDiffSelectable is true) + */ + readonly onMouseEnterHunk: (hunkStartLine: number) => void + + /** + * Called when the user unhovers the hunk handle. Called with the start + * line of the hunk. + * (only relevant when isDiffSelectable is true) + */ + readonly onMouseLeaveHunk: (hunkStartLine: number) => void + + readonly onExpandHunk: ( + hunkIndex: number, + kind: DiffHunkExpansionType + ) => void + + /** + * Called when the user clicks on the hunk handle. Called with the start + * line of the hunk and a flag indicating whether to select or unselect + * the hunk. + * (only relevant when isDiffSelectable is true) + */ + readonly onClickHunk: (hunkStartLine: number, select: boolean) => void + + /** + * Called when the user right-clicks a line number. Called with the + * clicked diff line number. + * (only relevant when isDiffSelectable is true) + */ + readonly onContextMenuLine: (diffLineNumber: number) => void + + /** + * Called when the user right-clicks a hunk handle. Called with the start + * line of the hunk. + * (only relevant when isDiffSelectable is true) + */ + readonly onContextMenuHunk: (hunkStartLine: number) => void + + /** + * Called when the user right-clicks a hunk expansion handle. + */ + readonly onContextMenuExpandHunk: () => void + + /** + * Called when the user right-clicks text on the diff. + */ + readonly onContextMenuText: () => void + + /** + * Array of classes applied to the after section of a row + */ + readonly afterClassNames: ReadonlyArray + + /** + * Array of classes applied to the before section of a row + */ + readonly beforeClassNames: ReadonlyArray + + /** Called when the user changes the hide whitespace in diffs setting. */ + readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void + + /* This tracks the last expanded hunk index so that we can refocus the expander after rerender */ + readonly lastExpandedHunk: { + index: number + expansionType: DiffHunkExpansionType + } | null +} + +interface ISideBySideDiffRowState { + readonly showWhitespaceHint: DiffColumn | undefined +} + +export class SideBySideDiffRow extends React.Component< + ISideBySideDiffRowProps, + ISideBySideDiffRowState +> { + public constructor(props: ISideBySideDiffRowProps) { + super(props) + this.state = { showWhitespaceHint: undefined } + } + public render() { + const { row, showSideBySideDiff, beforeClassNames, afterClassNames } = + this.props + + const beforeClasses = classNames('before', ...beforeClassNames) + const afterClasses = classNames('after', ...afterClassNames) + switch (row.type) { + case DiffRowType.Hunk: { + const className = ['row', 'hunk-info'] + if (row.expansionType === DiffHunkExpansionType.Both) { + className.push('expandable-both') + } + + return ( +
+ {this.renderHunkHeaderGutter(row.hunkIndex, row.expansionType)} + {this.renderContentFromString(row.content)} +
+ ) + } + case DiffRowType.Context: + const { beforeLineNumber, afterLineNumber } = row + if (!showSideBySideDiff) { + return ( +
+
+ {this.renderLineNumbers( + [beforeLineNumber, afterLineNumber], + undefined + )} + {this.renderContentFromString(row.content, row.beforeTokens)} +
+
+ ) + } + + return ( +
+
+ {this.renderLineNumber(beforeLineNumber, DiffColumn.Before)} + {this.renderContentFromString(row.content, row.beforeTokens)} +
+
+ {this.renderLineNumber(afterLineNumber, DiffColumn.After)} + {this.renderContentFromString(row.content, row.afterTokens)} +
+
+ ) + + case DiffRowType.Added: { + const { lineNumber, isSelected } = row.data + if (!showSideBySideDiff) { + return ( +
+
+ {this.renderLineNumbers( + [undefined, lineNumber], + DiffColumn.After, + isSelected + )} + {this.renderHunkHandle()} + {this.renderContent(row.data)} + {this.renderWhitespaceHintPopover(DiffColumn.After)} +
+
+ ) + } + + return ( +
+
+ {this.renderLineNumber(undefined, DiffColumn.Before)} + {this.renderContentFromString('')} + {this.renderWhitespaceHintPopover(DiffColumn.Before)} +
+
+ {this.renderLineNumber(lineNumber, DiffColumn.After, isSelected)} + {this.renderContent(row.data)} + {this.renderWhitespaceHintPopover(DiffColumn.After)} +
+ {this.renderHunkHandle()} +
+ ) + } + case DiffRowType.Deleted: { + const { lineNumber, isSelected } = row.data + if (!showSideBySideDiff) { + return ( +
+
+ {this.renderLineNumbers( + [lineNumber, undefined], + DiffColumn.Before, + isSelected + )} + {this.renderHunkHandle()} + {this.renderContent(row.data)} + {this.renderWhitespaceHintPopover(DiffColumn.Before)} +
+
+ ) + } + + return ( +
+
+ {this.renderLineNumber(lineNumber, DiffColumn.Before, isSelected)} + {this.renderContent(row.data)} + {this.renderWhitespaceHintPopover(DiffColumn.Before)} +
+
+ {this.renderLineNumber(undefined, DiffColumn.After)} + {this.renderContentFromString('')} + {this.renderWhitespaceHintPopover(DiffColumn.After)} +
+ {this.renderHunkHandle()} +
+ ) + } + case DiffRowType.Modified: { + const { beforeData: before, afterData: after } = row + return ( +
+
+ {this.renderLineNumber( + before.lineNumber, + DiffColumn.Before, + before.isSelected + )} + {this.renderContent(before)} + {this.renderWhitespaceHintPopover(DiffColumn.Before)} +
+
+ {this.renderLineNumber( + after.lineNumber, + DiffColumn.After, + after.isSelected + )} + {this.renderContent(after)} + {this.renderWhitespaceHintPopover(DiffColumn.After)} +
+ {this.renderHunkHandle()} +
+ ) + } + } + } + + public shouldComponentUpdate( + nextProps: ISideBySideDiffRowProps, + nextState: ISideBySideDiffRowState + ) { + if (!shallowEquals(this.state, nextState)) { + return true + } + + const { row: prevRow, ...restPrevProps } = this.props + const { row: nextRow, ...restNextProps } = nextProps + + if (!structuralEquals(prevRow, nextRow)) { + return true + } + + return !shallowEquals(restPrevProps, restNextProps) + } + + private renderContentFromString( + content: string, + tokens: ReadonlyArray = [] + ) { + return this.renderContent({ content, tokens, noNewLineIndicator: false }) + } + + private renderContent( + data: Pick + ) { + return ( +
+ {syntaxHighlightLine(data.content, data.tokens)} + {data.noNewLineIndicator && ( + + )} +
+ ) + } + + private getHunkExpansionElementInfo( + hunkIndex: number, + expansionType: DiffHunkExpansionType + ) { + switch (expansionType) { + // This can only be the first hunk + case DiffHunkExpansionType.Up: + return { + icon: OcticonSymbol.foldUp, + title: 'Expand Up', + handler: this.onExpandHunk(hunkIndex, expansionType), + } + // This can only be the last dummy hunk. In this case, we expand the + // second to last hunk down. + case DiffHunkExpansionType.Down: + return { + icon: OcticonSymbol.foldDown, + title: 'Expand Down', + handler: this.onExpandHunk(hunkIndex - 1, expansionType), + } + case DiffHunkExpansionType.Short: + return { + icon: OcticonSymbol.fold, + title: 'Expand All', + handler: this.onExpandHunk(hunkIndex, expansionType), + } + } + + throw new Error(`Unexpected expansion type ${expansionType}`) + } + + /** + * This method returns the width of a line gutter in pixels. For unified diffs + * the gutter contains the line number of both before and after sides, whereas + * for side-by-side diffs the gutter contains the line number of only one side. + */ + private get lineGutterWidth() { + const { showSideBySideDiff, lineNumberWidth } = this.props + return showSideBySideDiff ? lineNumberWidth : lineNumberWidth * 2 + } + + private renderHunkExpansionHandle( + hunkIndex: number, + expansionType: DiffHunkExpansionType + ) { + if (expansionType === DiffHunkExpansionType.None) { + return ( +
+ +
+ ) + } + + const elementInfo = this.getHunkExpansionElementInfo( + hunkIndex, + expansionType + ) + + /** + * For accessibility, when a button is focused, it should maintain focus. + * This sets the autofocus of the button if the last expanded button at the + * position was the same type. The +1 is to handle the last hunk index which + * is one off, and if there are two hunks with the same expansion types on + * after each other we just want the first one and autofocus will go to the + * first one automatically. + * + * Other notes: the expand up buttons already worked. This is + * for expand all and expand down buttons. + */ + const { lastExpandedHunk } = this.props + const focusButton = + lastExpandedHunk !== null && + expansionType === lastExpandedHunk.expansionType + ? hunkIndex === lastExpandedHunk.index || + hunkIndex === lastExpandedHunk.index + 1 + : false + + return ( +
+ +
+ ) + } + + private renderHunkHeaderGutter( + hunkIndex: number, + expansionType: DiffHunkExpansionType + ) { + if (expansionType === DiffHunkExpansionType.Both) { + return ( +
+ {this.renderHunkExpansionHandle( + hunkIndex, + DiffHunkExpansionType.Down + )} + {this.renderHunkExpansionHandle(hunkIndex, DiffHunkExpansionType.Up)} +
+ ) + } + + return this.renderHunkExpansionHandle(hunkIndex, expansionType) + } + + private renderHunkHandle() { + if (!this.props.isDiffSelectable) { + return null + } + + // In unified mode, the hunk handle left position depends on the line gutter + // width. + const style: React.CSSProperties = this.props.showSideBySideDiff + ? {} + : { left: this.lineGutterWidth } + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ ) + } + + 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 + ) { + const wrapperID = + column === undefined ? undefined : this.getLineNumbersContainerID(column) + if (!this.props.isDiffSelectable || isSelected === undefined) { + return ( +
+ {lineNumbers.map((lineNumber, index) => ( + {lineNumber} + ))} +
+ ) + } + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ {lineNumbers.map((lineNumber, index) => ( + {lineNumber} + ))} +
+ ) + } + + 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 anchorPosition = + column === DiffColumn.Before + ? PopoverAnchorPosition.LeftTop + : PopoverAnchorPosition.RightTop + + return ( + + ) + } + + private onWhitespaceHintClose = () => { + this.setState({ showWhitespaceHint: undefined }) + } + + /** + * 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 | undefined, + column: DiffColumn, + isSelected?: boolean + ) { + return this.renderLineNumbers([lineNumber], column, isSelected) + } + + private getDiffColumn(targetElement?: Element): DiffColumn | null { + const { row } = this.props + + switch (row.type) { + case DiffRowType.Added: + return DiffColumn.After + case DiffRowType.Deleted: + return DiffColumn.Before + case DiffRowType.Modified: + return targetElement?.closest('.after') + ? DiffColumn.After + : DiffColumn.Before + } + + return null + } + + /** + * Returns the data object for the current row if the current row is + * added, deleted or modified, null otherwise. + * + * On modified rows it normally returns the data corresponding to the + * previous state. In this situation an optional targetElement param can + * be passed which will be used to infer either the previous or the next + * state data (based on which column the target element belongs). + * + * @param targetElement Optional element to pass to infer which data to use + * on modified rows. + */ + private getDiffData(targetElement?: Element): IDiffRowData | null { + const { row } = this.props + + switch (row.type) { + case DiffRowType.Added: + case DiffRowType.Deleted: + return row.data + case DiffRowType.Modified: + return targetElement?.closest('.after') ? row.afterData : row.beforeData + } + + return null + } + + private onMouseDownLineNumber = (evt: React.MouseEvent) => { + if (evt.buttons === 2) { + return + } + + 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) + } + } + + private onMouseEnterLineNumber = (evt: React.MouseEvent) => { + if (this.props.hideWhitespaceInDiff) { + 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) + } + } + + private onMouseEnterHunk = () => { + if ('hunkStartLine' in this.props.row) { + this.props.onMouseEnterHunk(this.props.row.hunkStartLine) + } + } + + private onMouseLeaveHunk = () => { + 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 + // Prefer left hand side popovers when clicking hunk except for when + // the left hand side doesn't have a gutter + const column = + row.type === DiffRowType.Added ? DiffColumn.After : DiffColumn.Before + + this.setState({ showWhitespaceHint: column }) + return + } + + // Since the hunk handler lies between the previous and the next columns, + // when clicking on it on modified lines we cannot know if we should + // use the state of the previous or the next line to know whether we should + // select or unselect the hunk. + // To workaround this, we're relying on the logic of `getDiffData()` to have + // a consistent behaviour (which will use the previous column state in this case). + const data = this.getDiffData() + + if (data !== null && 'hunkStartLine' in this.props.row) { + this.props.onClickHunk(this.props.row.hunkStartLine, !data.isSelected) + } + } + + private onContextMenuLineNumber = (evt: React.MouseEvent) => { + if (this.props.hideWhitespaceInDiff) { + return + } + + const data = this.getDiffData(evt.currentTarget) + if (data !== null && data.diffLineNumber !== null) { + this.props.onContextMenuLine(data.diffLineNumber) + } + } + + private onContextMenuHunk = () => { + if (this.props.hideWhitespaceInDiff) { + return + } + + if ('hunkStartLine' in this.props.row) { + this.props.onContextMenuHunk(this.props.row.hunkStartLine) + } + } +} diff --git a/app/src/ui/diff/side-by-side-diff.tsx b/app/src/ui/diff/side-by-side-diff.tsx new file mode 100644 index 0000000000..15fb25c4e3 --- /dev/null +++ b/app/src/ui/diff/side-by-side-diff.tsx @@ -0,0 +1,1654 @@ +import * as React from 'react' + +import { Repository } from '../../models/repository' +import { + ITextDiff, + DiffLineType, + DiffHunk, + DiffLine, + DiffSelection, + DiffHunkExpansionType, +} from '../../models/diff' +import { + getLineFilters, + highlightContents, + IFileContents, +} from './syntax-highlighting' +import { ITokens, ILineTokens, IToken } from '../../lib/highlighter/types' +import { + assertNever, + assertNonNullable, + forceUnwrap, +} from '../../lib/fatal-error' +import classNames from 'classnames' +import { + List, + AutoSizer, + CellMeasurerCache, + CellMeasurer, + ListRowProps, + OverscanIndicesGetterParams, + defaultOverscanIndicesGetter, +} from 'react-virtualized' +import { SideBySideDiffRow } from './side-by-side-diff-row' +import memoize from 'memoize-one' +import { + findInteractiveOriginalDiffRange, + DiffRangeType, +} from './diff-explorer' +import { + ChangedFile, + DiffRow, + DiffRowType, + canSelect, + getDiffTokens, + SimplifiedDiffRowData, + SimplifiedDiffRow, + IDiffRowData, + DiffColumn, + getLineWidthFromDigitCount, + getNumberOfDigits, + MaxIntraLineDiffStringLength, + getFirstAndLastClassesSideBySide, + textDiffEquals, +} from './diff-helpers' +import { showContextualMenu } from '../../lib/menu-item' +import { getTokens } from './diff-syntax-mode' +import { DiffSearchInput } from './diff-search-input' +import { + expandTextDiffHunk, + DiffExpansionKind, + expandWholeTextDiff, +} from './text-diff-expansion' +import { IMenuItem } from '../../lib/menu-item' +import { HiddenBidiCharsWarning } from './hidden-bidi-chars-warning' +import { escapeRegExp } from 'lodash' + +const DefaultRowHeight = 20 + +export interface ISelectionPoint { + readonly column: DiffColumn + readonly row: number +} + +export interface ISelection { + readonly from: ISelectionPoint + readonly to: ISelectionPoint + readonly isSelected: boolean +} + +type ModifiedLine = { line: DiffLine; diffLineNumber: number } + +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 { + readonly repository: Repository + + /** The file whose diff should be displayed. */ + readonly file: ChangedFile + + /** The initial diff */ + readonly diff: ITextDiff + + /** + * Contents of the old and new files related to the current text diff. + */ + readonly fileContents: IFileContents | null + + /** + * Called when the includedness of lines or a range of lines has changed. + * Only applicable when readOnly is false. + */ + readonly onIncludeChanged?: (diffSelection: DiffSelection) => void + + /** + * 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 or not whitespace changes are hidden. */ + readonly hideWhitespaceInDiff: boolean + + /** + * Whether we'll show a confirmation dialog when the user + * discards changes. + */ + readonly askForConfirmationOnDiscardChanges?: boolean + + /** + * Whether we'll show the diff in a side-by-side layout. + */ + readonly showSideBySideDiff: boolean + + /** Called when the user changes the hide whitespace in diffs setting. */ + readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void +} + +interface ISideBySideDiffState { + /** The diff that should be rendered */ + readonly diff: ITextDiff + + /** + * The list of syntax highlighting tokens corresponding to + * the previous contents of the file. + */ + readonly beforeTokens?: ITokens + /** + * The list of syntax highlighting tokens corresponding to + * the next contents of the file. + */ + readonly afterTokens?: ITokens + + /** + * Indicates whether the user is doing a text selection and in which + * column is doing it. This allows us to limit text selection to that + * specific column via CSS. + */ + readonly selectingTextInRow: 'before' | 'after' + + /** + * The current diff selection. This is used while + * dragging the mouse over different lines to know where the user started + * dragging and whether the selection is to add or remove lines from the + * selection. + **/ + readonly temporarySelection?: ISelection + + /** + * Indicates the hunk that the user is currently hovering via the gutter. + * + * In this context, a hunk is not exactly equivalent to a diff hunk, but + * instead marks a group of consecutive added/deleted lines. + * + * As an example, the following diff will contain a single diff hunk + * (marked by the line starting with @@) but in this context we'll have two + * hunks: + * + * | @@ -1,4 +1,4 @@ + * | line 1 + * | -line 2 + * | +line 2a + * | line 3 + * | -line 4 + * | +line 4a + * + * This differentiation makes selecting multiple lines by clicking on the + * gutter more user friendly, since only consecutive modified lines get selected. + */ + readonly hoveredHunk?: number + + readonly isSearching: boolean + + readonly searchQuery?: string + + readonly searchResults?: SearchResults + + readonly selectedSearchResult: number | undefined + + /** This tracks the last expanded hunk index so that we can refocus the expander after rerender */ + readonly lastExpandedHunk: { + index: number + expansionType: DiffHunkExpansionType + } | null +} + +const listRowsHeightCache = new CellMeasurerCache({ + defaultHeight: DefaultRowHeight, + fixedWidth: true, +}) + +export class SideBySideDiff extends React.Component< + ISideBySideDiffProps, + 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 + + public constructor(props: ISideBySideDiffProps) { + super(props) + + this.state = { + diff: props.diff, + isSearching: false, + selectedSearchResult: undefined, + selectingTextInRow: 'before', + lastExpandedHunk: null, + } + } + + public componentDidMount() { + this.initDiffSyntaxMode() + + window.addEventListener('keydown', this.onWindowKeyDown) + + // 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) + } + + private onCutOrCopy = (ev: ClipboardEvent) => { + if (ev.defaultPrevented || !this.isEntireDiffSelected()) { + return + } + + const lineTypes = this.props.showSideBySideDiff + ? this.state.selectingTextInRow === 'before' + ? [DiffLineType.Delete, DiffLineType.Context] + : [DiffLineType.Add, DiffLineType.Context] + : [DiffLineType.Add, DiffLineType.Delete, DiffLineType.Context] + + const contents = this.state.diff.hunks + .flatMap(h => + h.lines + .filter(line => lineTypes.includes(line.type)) + .map(line => line.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 + ) + } + + public componentDidUpdate( + prevProps: ISideBySideDiffProps, + prevState: ISideBySideDiffState + ) { + if ( + !highlightParametersEqual(this.props, prevProps, this.state, prevState) + ) { + this.initDiffSyntaxMode() + this.clearListRowsHeightCache() + } + + if (!textDiffEquals(this.props.diff, prevProps.diff)) { + this.diffToRestore = null + this.setState({ diff: this.props.diff, lastExpandedHunk: null }) + } + + // Scroll to top if we switched to a new file + if ( + this.virtualListRef.current !== null && + 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() + } + } + } + } + + private canExpandDiff() { + const contents = this.props.fileContents + return ( + contents !== null && + contents.canBeExpanded && + contents.newContents.length > 0 + ) + } + + 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 + } + + public render() { + const { diff } = this.state + + const rows = getDiffRows( + diff, + this.props.showSideBySideDiff, + this.canExpandDiff() + ) + const containerClassName = classNames('side-by-side-diff-container', { + 'unified-diff': !this.props.showSideBySideDiff, + [`selecting-${this.state.selectingTextInRow}`]: + this.props.showSideBySideDiff && + this.state.selectingTextInRow !== undefined, + editable: canSelect(this.props.file), + }) + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ {diff.hasHiddenBidiChars && } + {this.state.isSearching && ( + + )} +
+ + {({ height, width }) => ( + + )} + +
+
+ ) + } + + 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 }) + } + + private renderRow = ({ index, parent, style, key }: ListRowProps) => { + const { diff } = this.state + const rows = getDiffRows( + diff, + this.props.showSideBySideDiff, + this.canExpandDiff() + ) + + const row = rows[index] + if (row === undefined) { + return null + } + + const prev = rows[index - 1] + const next = rows[index + 1] + + const beforeClassNames = getFirstAndLastClassesSideBySide( + row, + prev, + next, + DiffRowType.Deleted + ) + const afterClassNames = getFirstAndLastClassesSideBySide( + row, + prev, + next, + DiffRowType.Added + ) + + const lineNumberWidth = getLineWidthFromDigitCount( + getNumberOfDigits(diff.maxLineNumber) + ) + + const rowWithTokens = this.createFullRow(row, index) + + const isHunkHovered = + 'hunkStartLine' in row && this.state.hoveredHunk === row.hunkStartLine + + return ( + +
+ +
+
+ ) + } + + private getRowHeight = (row: { index: number }) => { + return listRowsHeightCache.rowHeight(row) ?? DefaultRowHeight + } + + private clearListRowsHeightCache = () => { + listRowsHeightCache.clearAll() + } + + private async initDiffSyntaxMode() { + const contents = this.props.fileContents + + if (contents === null) { + return + } + + const { diff: currentDiff } = this.state + + // Store the current props and state so 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 tabSize = 4 + + const tokens = await highlightContents(contents, tabSize, lineFilters) + + if ( + !highlightParametersEqual( + this.props, + propsSnapshot, + this.state, + stateSnapshot + ) + ) { + return + } + + this.setState({ + beforeTokens: tokens.oldTokens, + afterTokens: tokens.newTokens, + }) + } + + private getSelection(): DiffSelection | undefined { + return canSelect(this.props.file) ? this.props.file.selection : undefined + } + + private createFullRow(row: SimplifiedDiffRow, numRow: number): DiffRow { + if (row.type === DiffRowType.Added) { + return { + ...row, + data: this.getRowDataPopulated( + row.data, + numRow, + this.props.showSideBySideDiff ? DiffColumn.After : DiffColumn.Before, + this.state.afterTokens + ), + } + } + + if (row.type === DiffRowType.Deleted) { + return { + ...row, + data: this.getRowDataPopulated( + row.data, + numRow, + DiffColumn.Before, + this.state.beforeTokens + ), + } + } + + if (row.type === DiffRowType.Modified) { + return { + ...row, + beforeData: this.getRowDataPopulated( + row.beforeData, + numRow, + DiffColumn.Before, + this.state.beforeTokens + ), + afterData: this.getRowDataPopulated( + row.afterData, + numRow, + DiffColumn.After, + this.state.afterTokens + ), + } + } + + if (row.type === DiffRowType.Context) { + const lineTokens = + getTokens(row.beforeLineNumber, this.state.beforeTokens) ?? + getTokens(row.afterLineNumber, this.state.afterTokens) + + const beforeTokens = [...row.beforeTokens] + const afterTokens = [...row.afterTokens] + + if (lineTokens !== null) { + beforeTokens.push(lineTokens) + afterTokens.push(lineTokens) + } + + const beforeSearchTokens = this.getSearchTokens(numRow, DiffColumn.Before) + if (beforeSearchTokens !== undefined) { + beforeSearchTokens.forEach(x => beforeTokens.push(x)) + } + + const afterSearchTokens = this.getSearchTokens(numRow, DiffColumn.After) + if (afterSearchTokens !== undefined) { + afterSearchTokens.forEach(x => afterTokens.push(x)) + } + + return { ...row, beforeTokens, afterTokens } + } + + return row + } + + private getRowDataPopulated( + data: SimplifiedDiffRowData, + row: number, + column: DiffColumn, + tokens: ITokens | undefined + ): IDiffRowData { + const searchTokens = this.getSearchTokens(row, column) + const lineTokens = getTokens(data.lineNumber, tokens) + const finalTokens = [...data.tokens] + + if (searchTokens !== undefined) { + searchTokens.forEach(x => finalTokens.push(x)) + } + if (lineTokens !== null) { + finalTokens.push(lineTokens) + } + + return { + ...data, + tokens: finalTokens, + isSelected: + data.diffLineNumber !== null && + isInSelection( + data.diffLineNumber, + row, + column, + this.getSelection(), + this.state.temporarySelection + ), + } + } + + private getSearchTokens(row: number, column: DiffColumn) { + const { searchResults: searchTokens, selectedSearchResult } = this.state + + if (searchTokens === undefined) { + return undefined + } + + const lineTokens = searchTokens.getLineTokens(row, column) + + if (lineTokens === undefined) { + return undefined + } + + if (lineTokens !== undefined && selectedSearchResult !== undefined) { + const selected = searchTokens.get(selectedSearchResult) + + if (row === selected?.row && column === selected.column) { + if (lineTokens[selected.offset] !== undefined) { + const selectedToken = { + [selected.offset]: { length: selected.length, token: 'selected' }, + } + + return [lineTokens, selectedToken] + } + } + } + + return [lineTokens] + } + + private getDiffLineNumber( + rowNumber: number, + column: DiffColumn + ): number | null { + const { diff } = this.state + const rows = getDiffRows( + diff, + this.props.showSideBySideDiff, + this.canExpandDiff() + ) + const row = rows[rowNumber] + + if (row === undefined) { + return null + } + + if (row.type === DiffRowType.Added || row.type === DiffRowType.Deleted) { + return row.data.diffLineNumber + } + + if (row.type === DiffRowType.Modified) { + return column === DiffColumn.After + ? row.afterData.diffLineNumber + : row.beforeData.diffLineNumber + } + + return null + } + + /** + * This handler is used to limit text selection to a single column. + * To do so, we store the last column where the user clicked and use + * that information to add a CSS class on the container div + * (e.g `selecting-before`). + * + * Then, via CSS we can disable text selection on the column that is + * not being selected. + */ + private onMouseDown = (event: React.MouseEvent) => { + if (!this.props.showSideBySideDiff) { + return + } + + if (!(event.target instanceof HTMLElement)) { + return + } + + // We need to use the event target since the current target will + // always point to the container. + const isSelectingBeforeText = event.target.closest('.before') + const isSelectingAfterText = event.target.closest('.after') + + if (isSelectingBeforeText !== null) { + this.setState({ selectingTextInRow: 'before' }) + } else if (isSelectingAfterText !== null) { + this.setState({ selectingTextInRow: 'after' }) + } + } + + 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 } + this.setState({ temporarySelection }) + + document.addEventListener('mouseup', this.onEndSelection, { once: true }) + } + + private onUpdateSelection = (row: number, column: DiffColumn) => { + const { temporarySelection } = this.state + if (temporarySelection === undefined) { + return + } + + const to = { row, column } + this.setState({ temporarySelection: { ...temporarySelection, to } }) + } + + private onEndSelection = () => { + let selection = this.getSelection() + const { temporarySelection } = this.state + + if (selection === undefined || temporarySelection === undefined) { + return + } + + 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) + } + + if (lineAfter !== null) { + selection = selection.withLineSelection(lineAfter, isSelected) + } + } + + this.props.onIncludeChanged?.(selection) + this.setState({ temporarySelection: undefined }) + } + + private onMouseEnterHunk = (hunkStartLine: number) => { + if (this.state.temporarySelection === undefined) { + this.setState({ hoveredHunk: hunkStartLine }) + } + } + + private onMouseLeaveHunk = () => { + this.setState({ hoveredHunk: undefined }) + } + + private onExpandHunk = ( + hunkIndex: number, + expansionType: DiffHunkExpansionType + ) => { + const { diff } = this.state + + if (hunkIndex === -1 || hunkIndex >= diff.hunks.length) { + return + } + + this.setState({ lastExpandedHunk: { index: hunkIndex, expansionType } }) + + const kind = expansionType === DiffHunkExpansionType.Down ? 'down' : 'up' + + this.expandHunk(diff.hunks[hunkIndex], kind) + } + + private onClickHunk = (hunkStartLine: number, select: boolean) => { + if (this.props.onIncludeChanged === undefined) { + return + } + + const { diff } = this.state + const selection = this.getSelection() + + if (selection !== undefined) { + const range = findInteractiveOriginalDiffRange(diff.hunks, hunkStartLine) + if (range !== null) { + const { from, to } = range + const sel = selection.withRangeSelection(from, to - from + 1, select) + this.props.onIncludeChanged(sel) + } + } + } + + /** + * Handler to show a context menu when the user right-clicks on the diff text. + */ + private onContextMenuText = () => { + const selectionLength = window.getSelection()?.toString().length ?? 0 + + const items: IMenuItem[] = [ + { + label: 'Copy', + // When using role="copy", the enabled attribute is not taken into account. + role: selectionLength > 0 ? 'copy' : undefined, + enabled: selectionLength > 0, + }, + { + label: __DARWIN__ ? 'Select All' : 'Select all', + action: () => this.onSelectAll(), + }, + ] + + const expandMenuItem = this.buildExpandMenuItem() + if (expandMenuItem !== null) { + items.push({ type: 'separator' }, expandMenuItem) + } + + showContextualMenu(items) + } + + /** + * Handler to show a context menu when the user right-clicks on a line number. + * + * @param diffLineNumber the line number the diff where the user clicked + */ + private onContextMenuLine = (diffLineNumber: number) => { + const { file, hideWhitespaceInDiff } = this.props + const { diff } = this.state + + if (!canSelect(file)) { + return + } + + if (hideWhitespaceInDiff) { + return + } + + if (this.props.onDiscardChanges === undefined) { + return + } + + const range = findInteractiveOriginalDiffRange(diff.hunks, diffLineNumber) + if (range === null || range.type === null) { + return + } + + showContextualMenu([ + { + label: this.getDiscardLabel(range.type, 1), + action: () => this.onDiscardChanges(diffLineNumber), + }, + ]) + } + + private buildExpandMenuItem(): IMenuItem | null { + const { diff } = this.state + if (!this.canExpandDiff()) { + return null + } + + 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 onExpandWholeFile = () => { + const contents = this.props.fileContents + const { diff } = this.state + + if (contents === null || !this.canExpandDiff()) { + return + } + + const updatedDiff = expandWholeTextDiff(diff, contents.newContents) + + if (updatedDiff === undefined) { + return + } + + this.diffToRestore = diff + + this.setState({ diff: updatedDiff }) + } + + private onCollapseExpandedLines = () => { + if (this.diffToRestore === null) { + return + } + + this.setState({ diff: this.diffToRestore }) + + this.diffToRestore = null + } + + /** + * Handler to show a context menu when the user right-clicks on the gutter hunk handler. + * + * @param hunkStartLine The start line of the hunk where the user clicked. + */ + private onContextMenuHunk = (hunkStartLine: number) => { + if (!canSelect(this.props.file)) { + return + } + + if (this.props.onDiscardChanges === undefined) { + return + } + + const range = findInteractiveOriginalDiffRange( + this.state.diff.hunks, + hunkStartLine + ) + if (range === null || range.type === null) { + return + } + + showContextualMenu([ + { + label: this.getDiscardLabel(range.type, range.to - range.from + 1), + action: () => this.onDiscardChanges(range.from, range.to), + }, + ]) + } + + private onContextMenuExpandHunk = () => { + const expandMenuItem = this.buildExpandMenuItem() + if (expandMenuItem === null) { + return + } + + showContextualMenu([expandMenuItem]) + } + + 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 onDiscardChanges(startLine: number, endLine: number = startLine) { + const selection = this.getSelection() + if (selection === undefined) { + return + } + + if (this.props.onDiscardChanges === undefined) { + return + } + + const newSelection = 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, newSelection) + } + + private onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return + } + + const isCmdOrCtrl = __DARWIN__ + ? event.metaKey && !event.ctrlKey + : event.ctrlKey + + if (isCmdOrCtrl && !event.shiftKey && !event.altKey && event.key === 'f') { + event.preventDefault() + this.showSearch() + } + } + + private showSearch = () => { + if (!this.state.isSearching) { + this.resetSearch(true) + } + } + + private onSearch = (searchQuery: string, direction: 'next' | 'previous') => { + let { selectedSearchResult, searchResults: searchResults } = this.state + const { showSideBySideDiff } = this.props + const { diff } = 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 + } + } else { + searchResults = calcSearchTokens( + diff, + showSideBySideDiff, + searchQuery, + this.canExpandDiff() + ) + selectedSearchResult = 0 + + if (searchResults === undefined || searchResults.length === 0) { + this.resetSearch(true) + return + } + } + + const scrollToRow = searchResults.get(selectedSearchResult)?.row + + if (scrollToRow !== undefined) { + this.virtualListRef.current?.scrollToRow(scrollToRow) + } + + this.setState({ searchQuery, searchResults, selectedSearchResult }) + } + + private onSearchCancel = () => { + this.resetSearch(false) + } + + private resetSearch(isSearching: boolean) { + this.setState({ + selectedSearchResult: undefined, + searchQuery: undefined, + searchResults: undefined, + isSearching, + }) + } + + /** Expand a selected hunk. */ + private expandHunk(hunk: DiffHunk, kind: DiffExpansionKind) { + const contents = this.props.fileContents + const { diff } = this.state + + if (contents === null || !this.canExpandDiff()) { + return + } + + const updatedDiff = expandTextDiffHunk( + diff, + hunk, + kind, + contents.newContents + ) + + if (updatedDiff === undefined) { + return + } + + this.setState({ diff: updatedDiff }) + } +} + +/** + * 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: ISideBySideDiffProps, + prevProps: ISideBySideDiffProps, + newState: ISideBySideDiffState, + prevState: ISideBySideDiffState +) { + return ( + (newProps === prevProps || + (newProps.file.id === prevProps.file.id && + newProps.showSideBySideDiff === prevProps.showSideBySideDiff)) && + newState.diff.text === prevState.diff.text && + prevProps.fileContents?.file.id === newProps.fileContents?.file.id + ) +} + +/** + * Memoized function to calculate the actual rows to display side by side + * as a diff. + * + * @param diff The diff to use to calculate the rows. + * @param showSideBySideDiff Whether or not show the diff in side by side mode. + */ +const getDiffRows = memoize(function ( + diff: ITextDiff, + showSideBySideDiff: boolean, + enableDiffExpansion: boolean +): ReadonlyArray { + const outputRows = new Array() + + diff.hunks.forEach((hunk, index) => { + for (const row of getDiffRowsFromHunk( + index, + hunk, + showSideBySideDiff, + enableDiffExpansion + )) { + outputRows.push(row) + } + }) + + return outputRows +}) + +/** + * Returns an array of rows with the needed data to render a side-by-side diff + * with them. + * + * In some situations it will merge a deleted an added row into a single + * modified row, in order to display them side by side (This happens when there + * are consecutive added and deleted rows). + * + * @param hunk The hunk to use to extract the rows data + * @param showSideBySideDiff Whether or not show the diff in side by side mode. + */ +function getDiffRowsFromHunk( + hunkIndex: number, + hunk: DiffHunk, + showSideBySideDiff: boolean, + enableDiffExpansion: boolean +): ReadonlyArray { + const rows = new Array() + + /** + * Array containing multiple consecutive added/deleted lines. This + * is used to be able to merge them into modified rows. + */ + let modifiedLines = new Array() + + for (const [num, line] of hunk.lines.entries()) { + const diffLineNumber = hunk.unifiedDiffStart + num + + if (line.type === DiffLineType.Delete || line.type === DiffLineType.Add) { + modifiedLines.push({ line, diffLineNumber }) + continue + } + + if (modifiedLines.length > 0) { + // If the current line is not added/deleted and we have any added/deleted + // line stored, we need to process them. + for (const row of getModifiedRows(modifiedLines, showSideBySideDiff)) { + rows.push(row) + } + modifiedLines = [] + } + + if (line.type === DiffLineType.Hunk) { + rows.push({ + type: DiffRowType.Hunk, + content: line.text, + expansionType: enableDiffExpansion + ? hunk.expansionType + : DiffHunkExpansionType.None, + hunkIndex, + }) + continue + } + + if (line.type === DiffLineType.Context) { + assertNonNullable( + line.oldLineNumber, + `No oldLineNumber for ${diffLineNumber}` + ) + assertNonNullable( + line.newLineNumber, + `No newLineNumber for ${diffLineNumber}` + ) + + rows.push({ + type: DiffRowType.Context, + content: line.content, + beforeLineNumber: line.oldLineNumber, + afterLineNumber: line.newLineNumber, + beforeTokens: [], + afterTokens: [], + }) + continue + } + + assertNever(line.type, `Invalid line type: ${line.type}`) + } + + // Do one more pass to process the remaining list of modified lines. + if (modifiedLines.length > 0) { + for (const row of getModifiedRows(modifiedLines, showSideBySideDiff)) { + rows.push(row) + } + } + + return rows +} + +function getModifiedRows( + addedOrDeletedLines: ReadonlyArray, + showSideBySideDiff: boolean +): ReadonlyArray { + if (addedOrDeletedLines.length === 0) { + return [] + } + const hunkStartLine = addedOrDeletedLines[0].diffLineNumber + const addedLines = new Array() + const deletedLines = new Array() + + for (const line of addedOrDeletedLines) { + if (line.line.type === DiffLineType.Add) { + addedLines.push(line) + } else if (line.line.type === DiffLineType.Delete) { + deletedLines.push(line) + } + } + + const output = new Array() + + const diffTokensBefore = new Array() + const diffTokensAfter = new Array() + + // To match the behavior of github.com, we only highlight differences between + // lines on hunks that have the same number of added and deleted lines. + const shouldDisplayDiffInChunk = addedLines.length === deletedLines.length + + if (shouldDisplayDiffInChunk) { + for (let i = 0; i < deletedLines.length; i++) { + const addedLine = addedLines[i] + const deletedLine = deletedLines[i] + + if ( + addedLine.line.content.length < MaxIntraLineDiffStringLength && + deletedLine.line.content.length < MaxIntraLineDiffStringLength + ) { + const { before, after } = getDiffTokens( + deletedLine.line.content, + addedLine.line.content + ) + diffTokensBefore[i] = before + diffTokensAfter[i] = after + } + } + } + + let indexModifiedRow = 0 + + while ( + showSideBySideDiff && + indexModifiedRow < addedLines.length && + indexModifiedRow < deletedLines.length + ) { + const addedLine = forceUnwrap( + 'Unexpected null line', + addedLines[indexModifiedRow] + ) + const deletedLine = forceUnwrap( + 'Unexpected null line', + deletedLines[indexModifiedRow] + ) + + // Modified lines + output.push({ + type: DiffRowType.Modified, + beforeData: getDataFromLine( + deletedLine, + 'oldLineNumber', + diffTokensBefore.shift() + ), + afterData: getDataFromLine( + addedLine, + 'newLineNumber', + diffTokensAfter.shift() + ), + hunkStartLine, + }) + + indexModifiedRow++ + } + + for (let i = indexModifiedRow; i < deletedLines.length; i++) { + const line = forceUnwrap('Unexpected null line', deletedLines[i]) + + output.push({ + type: DiffRowType.Deleted, + data: getDataFromLine(line, 'oldLineNumber', diffTokensBefore.shift()), + hunkStartLine, + }) + } + + for (let i = indexModifiedRow; i < addedLines.length; i++) { + const line = forceUnwrap('Unexpected null line', addedLines[i]) + + // Added line + output.push({ + type: DiffRowType.Added, + data: getDataFromLine(line, 'newLineNumber', diffTokensAfter.shift()), + hunkStartLine, + }) + } + + return output +} + +function getDataFromLine( + { line, diffLineNumber }: { line: DiffLine; diffLineNumber: number }, + lineToUse: 'oldLineNumber' | 'newLineNumber', + diffTokens: ILineTokens | undefined +): SimplifiedDiffRowData { + const lineNumber = forceUnwrap( + `Expecting ${lineToUse} value for ${line}`, + line[lineToUse] + ) + + const tokens = new Array() + + if (diffTokens !== undefined) { + tokens.push(diffTokens) + } + + return { + content: line.content, + lineNumber, + diffLineNumber: line.originalLineNumber, + noNewLineIndicator: line.noTrailingNewLine, + tokens, + } +} + +/** + * Helper class that lets us index search results both by their row + * and column for fast lookup durig the render phase but also by their + * relative order (index) allowing us to efficiently perform backwards search. + */ +class SearchResults { + private readonly lookup = new Map() + private readonly hits = new Array<[number, DiffColumn, number, number]>() + + private getKey(row: number, column: DiffColumn) { + return `${row}.${column}` + } + + public add(row: number, column: DiffColumn, offset: number, length: number) { + const key = this.getKey(row, column) + const existing = this.lookup.get(key) + const token: IToken = { length, token: 'search-result' } + + if (existing !== undefined) { + existing[offset] = token + } else { + this.lookup.set(key, { [offset]: token }) + } + + this.hits.push([row, column, offset, length]) + } + + public get length() { + return this.hits.length + } + + public get(index: number) { + const hit = this.hits[index] + return hit === undefined + ? undefined + : { row: hit[0], column: hit[1], offset: hit[2], length: hit[3] } + } + + public getLineTokens(row: number, column: DiffColumn) { + return this.lookup.get(this.getKey(row, column)) + } +} + +function calcSearchTokens( + diff: ITextDiff, + showSideBySideDiffs: boolean, + searchQuery: string, + enableDiffExpansion: boolean +): SearchResults | undefined { + if (searchQuery.length === 0) { + return undefined + } + + const hits = new SearchResults() + const searchRe = new RegExp(escapeRegExp(searchQuery), 'gi') + const rows = getDiffRows(diff, showSideBySideDiffs, enableDiffExpansion) + + for (const [rowNumber, row] of rows.entries()) { + if (row.type === DiffRowType.Hunk) { + continue + } + + for (const column of enumerateColumnContents(row, showSideBySideDiffs)) { + for (const match of column.content.matchAll(searchRe)) { + if (match.index !== undefined) { + hits.add(rowNumber, column.type, match.index, match[0].length) + } + } + } + } + + return hits +} + +function* enumerateColumnContents( + row: SimplifiedDiffRow, + showSideBySideDiffs: boolean +): IterableIterator<{ type: DiffColumn; content: string }> { + if (row.type === DiffRowType.Hunk) { + yield { type: DiffColumn.Before, content: row.content } + } else if (row.type === DiffRowType.Added) { + const type = showSideBySideDiffs ? DiffColumn.After : DiffColumn.Before + yield { type, content: row.data.content } + } else if (row.type === DiffRowType.Deleted) { + yield { type: DiffColumn.Before, content: row.data.content } + } else if (row.type === DiffRowType.Context) { + yield { type: DiffColumn.Before, content: row.content } + if (showSideBySideDiffs) { + yield { type: DiffColumn.After, content: row.content } + } + } else if (row.type === DiffRowType.Modified) { + yield { type: DiffColumn.Before, content: row.beforeData.content } + yield { type: DiffColumn.After, content: row.afterData.content } + } else { + assertNever(row, `Unknown row type ${row}`) + } +} + +function isInSelection( + diffLineNumber: number, + row: number, + column: DiffColumn, + selection: DiffSelection | undefined, + temporarySelection: ISelection | undefined +) { + const isInStoredSelection = selection?.isSelected(diffLineNumber) ?? false + + if (temporarySelection === undefined) { + return isInStoredSelection + } + + const isInTemporary = isInTemporarySelection(row, column, temporarySelection) + + if (temporarySelection.isSelected) { + return isInStoredSelection || isInTemporary + } else { + return isInStoredSelection && !isInTemporary + } +} + +export function isInTemporarySelection( + row: number, + column: DiffColumn, + selection: ISelection | undefined +): selection is ISelection { + if (selection === undefined) { + return false + } + + 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) + ) { + return true + } + + return false +} diff --git a/app/src/ui/diff/submodule-diff.tsx b/app/src/ui/diff/submodule-diff.tsx new file mode 100644 index 0000000000..7640cfe99c --- /dev/null +++ b/app/src/ui/diff/submodule-diff.tsx @@ -0,0 +1,200 @@ +import React from 'react' +import { parseRepositoryIdentifier } from '../../lib/remote-parsing' +import { ISubmoduleDiff } from '../../models/diff' +import { LinkButton } from '../lib/link-button' +import { TooltippedCommitSHA } from '../lib/tooltipped-commit-sha' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { SuggestedAction } from '../suggested-actions' + +type SubmoduleItemIcon = + | { + readonly octicon: typeof OcticonSymbol.info + readonly className: 'info-icon' + } + | { + readonly octicon: typeof OcticonSymbol.diffModified + readonly className: 'modified-icon' + } + | { + readonly octicon: typeof OcticonSymbol.diffAdded + readonly className: 'added-icon' + } + | { + readonly octicon: typeof OcticonSymbol.diffRemoved + readonly className: 'removed-icon' + } + | { + readonly octicon: typeof OcticonSymbol.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: OcticonSymbol.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: OcticonSymbol.diffModified, className: 'modified-icon' }, + <> + This submodule changed its commit from{' '} + {this.renderTooltippedCommitSHA(oldSHA)} to{' '} + {this.renderTooltippedCommitSHA(newSHA)}.{suffix} + + ) + } else if (oldSHA === null && newSHA !== null) { + return this.renderSubmoduleDiffItem( + { octicon: OcticonSymbol.diffAdded, className: 'added-icon' }, + <> + This submodule {verb} added pointing at commit{' '} + {this.renderTooltippedCommitSHA(newSHA)}.{suffix} + + ) + } else if (oldSHA !== null && newSHA === null) { + return this.renderSubmoduleDiffItem( + { octicon: OcticonSymbol.diffRemoved, className: 'removed-icon' }, + <> + This submodule {verb} removed while it was pointing at commit{' '} + {this.renderTooltippedCommitSHA(oldSHA)}.{suffix} + + ) + } + + return null + } + + private renderTooltippedCommitSHA(sha: string) { + return + } + + 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: OcticonSymbol.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 new file mode 100644 index 0000000000..0f76c20a34 --- /dev/null +++ b/app/src/ui/diff/syntax-highlighting/index.ts @@ -0,0 +1,219 @@ +import * as Path from 'path' + +import { assertNever } from '../../../lib/fatal-error' + +import { getPartialBlobContents } from '../../../lib/git/show' +import { readPartialFile } from '../../../lib/file-system' +import { highlight } from '../../../lib/highlighter/worker' +import { ITokens } from '../../../lib/highlighter/types' + +import { + CommittedFileChange, + WorkingDirectoryFileChange, + AppFileStatusKind, +} from '../../../models/status' +import { Repository } from '../../../models/repository' +import { DiffHunk, DiffLineType, DiffLine } from '../../../models/diff' +import { getOldPathOrDefault } from '../../../lib/get-old-path' + +/** The maximum number of bytes we'll process for highlighting. */ +const MaxHighlightContentLength = 256 * 1024 + +// There is no good way to get the actual length of the old/new contents, +// since we're directly truncating the git output to up to MaxHighlightContentLength +// characters. Therefore, when we try to limit diff expansion, we can't know if +// a file is exactly MaxHighlightContentLength characters long or longer, so +// we'll look for exactly that amount of characters minus 1. +const MaxDiffExpansionNewContentLength = MaxHighlightContentLength - 1 + +type ChangedFile = WorkingDirectoryFileChange | CommittedFileChange + +interface ILineFilters { + readonly oldLineFilter: Array + readonly newLineFilter: Array +} + +export interface IFileContents { + readonly file: ChangedFile + readonly oldContents: ReadonlyArray + readonly newContents: ReadonlyArray + readonly canBeExpanded: boolean +} + +interface IFileTokens { + readonly oldTokens: ITokens + readonly newTokens: ITokens +} + +async function getOldFileContent( + repository: Repository, + file: ChangedFile +): Promise { + if ( + file.status.kind === AppFileStatusKind.New || + file.status.kind === AppFileStatusKind.Untracked + ) { + return null + } + + let commitish + + if (file instanceof WorkingDirectoryFileChange) { + // If we pass an empty string here we get the contents + // that are in the index. But since we call diff with + // --no-index (see diff.ts) we need to look at what's + // actually committed to get the appropriate content. + commitish = 'HEAD' + } else if (file instanceof CommittedFileChange) { + commitish = file.parentCommitish + } else { + return assertNever(file, 'Unknown file change type') + } + + return getPartialBlobContents( + repository, + commitish, + getOldPathOrDefault(file), + MaxHighlightContentLength + ) +} + +async function getNewFileContent( + repository: Repository, + file: ChangedFile +): Promise { + if (file.status.kind === AppFileStatusKind.Deleted) { + return null + } + + if (file instanceof WorkingDirectoryFileChange) { + return readPartialFile( + Path.join(repository.path, file.path), + 0, + MaxHighlightContentLength - 1 + ) + } else if (file instanceof CommittedFileChange) { + return getPartialBlobContents( + repository, + file.commitish, + file.path, + MaxHighlightContentLength + ) + } + + return assertNever(file, 'Unknown file change type') +} + +export async function getFileContents( + repo: Repository, + file: ChangedFile +): Promise { + const [oldContents, newContents] = await Promise.all([ + getOldFileContent(repo, file).catch(e => { + log.error('Could not load old contents for syntax highlighting', e) + return null + }), + getNewFileContent(repo, file).catch(e => { + log.error('Could not load new contents for syntax highlighting', e) + return null + }), + ]) + + return { + file, + oldContents: oldContents?.toString('utf8').split(/\r?\n/) ?? [], + newContents: newContents?.toString('utf8').split(/\r?\n/) ?? [], + canBeExpanded: + newContents !== null && + newContents.length <= MaxDiffExpansionNewContentLength, + } +} + +/** + * Figure out which lines we need to have tokenized in + * both the old and new version of the file. + */ +export function getLineFilters(hunks: ReadonlyArray): ILineFilters { + const oldLineFilter = new Array() + const newLineFilter = new Array() + + const diffLines = new Array() + + let anyAdded = false + let anyDeleted = false + + for (const hunk of hunks) { + for (const line of hunk.lines) { + anyAdded = anyAdded || line.type === DiffLineType.Add + anyDeleted = anyDeleted || line.type === DiffLineType.Delete + diffLines.push(line) + } + } + + for (const line of diffLines) { + // So this might need a little explaining. What we're trying + // to achieve here is if the diff contains only additions or + // only deletions we'll source all the highlighted lines from + // either the before or after file. That way we can completely + // disregard highlighting, the other version. + if (line.oldLineNumber !== null && line.newLineNumber !== null) { + if (anyAdded && !anyDeleted) { + newLineFilter.push(line.newLineNumber - 1) + } else { + oldLineFilter.push(line.oldLineNumber - 1) + } + } else { + // If there's a mix (meaning we'll have to read from both + // anyway) we'll prioritize the old version since + // that's immutable and less likely to be the subject of a + // race condition when someone rapidly modifies the file on + // disk. + if (line.oldLineNumber !== null) { + oldLineFilter.push(line.oldLineNumber - 1) + } else if (line.newLineNumber !== null) { + newLineFilter.push(line.newLineNumber - 1) + } + } + } + + return { oldLineFilter, newLineFilter } +} + +export async function highlightContents( + contents: IFileContents, + tabSize: number, + lineFilters: ILineFilters +): Promise { + const { file, oldContents, newContents } = contents + + const oldPath = getOldPathOrDefault(file) + + const [oldTokens, newTokens] = await Promise.all([ + oldContents === null + ? {} + : highlight( + oldContents, + Path.basename(oldPath), + Path.extname(oldPath), + tabSize, + lineFilters.oldLineFilter + ).catch(e => { + log.error('Highlighter worked failed for old contents', e) + return {} + }), + newContents === null + ? {} + : highlight( + newContents, + Path.basename(file.path), + Path.extname(file.path), + tabSize, + lineFilters.newLineFilter + ).catch(e => { + log.error('Highlighter worked failed for new contents', e) + return {} + }), + ]) + + return { oldTokens, newTokens } +} diff --git a/app/src/ui/diff/text-diff-expansion.ts b/app/src/ui/diff/text-diff-expansion.ts new file mode 100644 index 0000000000..092426a1a9 --- /dev/null +++ b/app/src/ui/diff/text-diff-expansion.ts @@ -0,0 +1,459 @@ +import { + DiffHunk, + DiffHunkExpansionType, + DiffHunkHeader, + DiffLine, + DiffLineType, + ITextDiff, +} from '../../models/diff' +import { getLargestLineNumber } from './diff-helpers' +import { HiddenBidiCharsRegex } from '../../lib/diff-parser' + +/** How many new lines will be added to a diff hunk by default. */ +export const DefaultDiffExpansionStep = 20 + +/** Type of expansion: could be up or down. */ +export type DiffExpansionKind = 'up' | 'down' + +/** Builds the diff text string given a list of hunks. */ +function getDiffTextFromHunks(hunks: ReadonlyArray) { + // Grab all hunk lines and rebuild the diff text from it + const newDiffLines = hunks.reduce>( + (result, hunk) => result.concat(hunk.lines), + [] + ) + + return newDiffLines.map(diffLine => diffLine.text).join('\n') +} + +/** Merges two consecutive hunks into one. */ +function mergeDiffHunks(hunk1: DiffHunk, hunk2: DiffHunk): DiffHunk { + // Remove the first line in both hunks, because those are hunk header lines + // that will be replaced by a new one for the resulting hunk. + const allHunk1LinesButFirst = hunk1.lines.slice(1) + const allHunk2LinesButFirst = hunk2.lines.slice(1) + + const newHunkHeader = new DiffHunkHeader( + hunk1.header.oldStartLine, + hunk1.header.oldLineCount + hunk2.header.oldLineCount, + hunk1.header.newStartLine, + hunk1.header.newLineCount + hunk2.header.newLineCount + ) + + // Create a new hunk header line for the resulting hunk + const newFirstHunkLine = new DiffLine( + newHunkHeader.toDiffLineRepresentation(), + DiffLineType.Hunk, + null, + null, + null, + false + ) + + const newHunkLines = [ + newFirstHunkLine, + ...allHunk1LinesButFirst, + ...allHunk2LinesButFirst, + ] + + return new DiffHunk( + newHunkHeader, + newHunkLines, + hunk1.unifiedDiffStart, + hunk1.unifiedDiffStart + newHunkLines.length - 1, + // The expansion type of the resulting hunk will match the expansion type + // of the first hunk: + // - If the first hunk can be expanded up, it means it's the very first + // hunk, so the resulting hunk will be the first too. + // - If the first hunk can be expanded but short, that doesn't change after + // merging it with the second one. + // - If it can be expanded up and down (meaning it's a long gap), that + // doesn't change after merging it with the second one. + // - It can never be expanded down exclusively, because only the last dummy + // hunk can do that, and that will never be the first hunk in a merge. + hunk1.expansionType + ) +} + +/** + * Calculates whether or not a hunk header can be expanded up, down, both, or if + * the space represented by the hunk header is short and expansion there would + * mean merging with the hunk above. + * + * @param hunkIndex Index of the hunk to evaluate within the whole diff. + * @param hunkHeader Header of the hunk to evaluate. + * @param previousHunk Hunk previous to the one to evaluate. Null if the + * evaluated hunk is the first one. + */ +export function getHunkHeaderExpansionType( + hunkIndex: number, + hunkHeader: DiffHunkHeader, + previousHunk: DiffHunk | null +): DiffHunkExpansionType { + const distanceToPrevious = + previousHunk === null + ? Infinity + : hunkHeader.oldStartLine - + previousHunk.header.oldStartLine - + previousHunk.header.oldLineCount + + // In order to simplify the whole logic around expansion, only the hunk at the + // top can be expanded up exclusively, and only the hunk at the bottom (the + // dummy one, see getTextDiffWithBottomDummyHunk) can be expanded down + // exclusively. + // The rest of the hunks can be expanded both ways, except those which are too + // short and therefore the direction of expansion doesn't matter. + if (hunkIndex === 0) { + // The top hunk can only be expanded if there is content above it + if (hunkHeader.oldStartLine > 1 && hunkHeader.newStartLine > 1) { + return DiffHunkExpansionType.Up + } else { + return DiffHunkExpansionType.None + } + } else if (distanceToPrevious <= DefaultDiffExpansionStep) { + return DiffHunkExpansionType.Short + } else { + return DiffHunkExpansionType.Both + } +} + +/** + * Expands a text diff completely. + * + * @param diff Original text diff to expand. + * @param newContentLines Array with all the lines of the new content. + */ +export function expandWholeTextDiff( + diff: ITextDiff, + newContentLines: ReadonlyArray +): ITextDiff | undefined { + let result = diff + + // The logic is to keep expanding the first hunk until it's the only one. + // First expand the first hunk up, and then down as many times as possible. + // If there is only one hunk, just expand it once up. + while ( + result.hunks.length > 1 || + (result.hunks.length === 1 && + result.hunks[0].expansionType === DiffHunkExpansionType.Up) + ) { + const firstHunk = result.hunks[0] + + const partialResult = expandTextDiffHunk( + result, + firstHunk, + firstHunk.expansionType === DiffHunkExpansionType.Up ? 'up' : 'down', + newContentLines, + newContentLines.length + ) + + if (partialResult === undefined) { + return + } + + result = partialResult + } + + return result +} + +/** + * Expands a hunk in a text diff. Returns the new diff with the expanded hunk, + * or undefined if anything went wrong. + * + * @param diff Original text diff to expand. + * @param hunk Specific hunk in the original diff to expand. + * @param kind Kind of expansion (up or down). + * @param newContentLines Array with all the lines of the new content. + * @param step How many lines it will be expanded. + */ +export function expandTextDiffHunk( + diff: ITextDiff, + hunk: DiffHunk, + kind: DiffExpansionKind, + newContentLines: ReadonlyArray, + step: number = DefaultDiffExpansionStep +): ITextDiff | undefined { + const hunkIndex = diff.hunks.indexOf(hunk) + if (hunkIndex === -1) { + return + } + + const isExpandingUp = kind === 'up' + const adjacentHunkIndex = + isExpandingUp && hunkIndex > 0 + ? hunkIndex - 1 + : !isExpandingUp && hunkIndex < diff.hunks.length - 1 + ? hunkIndex + 1 + : null + const adjacentHunk = + adjacentHunkIndex !== null ? diff.hunks[adjacentHunkIndex] : null + + // The adjacent hunk can only be the dummy hunk at the bottom if: + // - We're expanding down. + // - It only has one line. + // - That line is of type "Hunk". + // - The adjacent hunk is the last one. + const isAdjacentDummyHunk = + adjacentHunk !== null && + isExpandingUp === false && + adjacentHunk.lines.length === 1 && + adjacentHunk.lines[0].type === DiffLineType.Hunk && + adjacentHunkIndex === diff.hunks.length - 1 + + const newLineNumber = hunk.header.newStartLine + const oldLineNumber = hunk.header.oldStartLine + + // Calculate the range of new lines to add to the diff. We could use new or + // old line number indistinctly, so I chose the new lines. + let [from, to] = isExpandingUp + ? [newLineNumber - step, newLineNumber] + : [ + newLineNumber + hunk.header.newLineCount, + newLineNumber + hunk.header.newLineCount + step, + ] + + // We will merge the current hunk with the adjacent only if the expansion + // ends where the adjacent hunk begins (depending on the expansion direction). + // In any case, never let the expanded hunk to overlap the adjacent hunk. + let shouldMergeWithAdjacent = false + + if (adjacentHunk !== null) { + if (isExpandingUp) { + const upLimit = + adjacentHunk.header.newStartLine + adjacentHunk.header.newLineCount + from = Math.max(from, upLimit) + shouldMergeWithAdjacent = from === upLimit + } else { + // Make sure we're not comparing against the dummy hunk at the bottom, + // which is effectively taking all the undiscovered file contents and + // would prevent us from expanding down the diff. + if (isAdjacentDummyHunk === false) { + const downLimit = adjacentHunk.header.newStartLine + to = Math.min(to, downLimit) + shouldMergeWithAdjacent = to === downLimit + } + } + } + + const newLines = newContentLines.slice( + Math.max(from - 1, 0), + Math.min(to - 1, newContentLines.length) + ) + const numberOfLinesToAdd = newLines.length + + // Nothing to do here + if (numberOfLinesToAdd === 0) { + return + } + + // Create the DiffLine instances using the right line numbers. + const newLineDiffs = newLines.map((line, index) => { + const newNewLineNumber = isExpandingUp + ? newLineNumber - (numberOfLinesToAdd - index) + : newLineNumber + hunk.header.newLineCount + index + const newOldLineNumber = isExpandingUp + ? oldLineNumber - (numberOfLinesToAdd - index) + : oldLineNumber + hunk.header.oldLineCount + index + + // We need to prepend a space before the line text to match the diff + // output. + return new DiffLine( + ' ' + line, + DiffLineType.Context, + // This null means this line doesn't exist in the original line + null, + newOldLineNumber, + newNewLineNumber, + false + ) + }) + + // Look for hidden bidi chars in the new lines, if the diff didn't have any already + const hasHiddenBidiChars = + diff.hasHiddenBidiChars || + newLines.some(line => HiddenBidiCharsRegex.test(line)) + + // Update the resulting hunk header with the new line count + const newHunkHeader = new DiffHunkHeader( + isExpandingUp + ? hunk.header.oldStartLine - numberOfLinesToAdd + : hunk.header.oldStartLine, + hunk.header.oldLineCount + numberOfLinesToAdd, + isExpandingUp + ? hunk.header.newStartLine - numberOfLinesToAdd + : hunk.header.newStartLine, + hunk.header.newLineCount + numberOfLinesToAdd + ) + + // Grab the header line of the hunk to expand + const firstHunkLine = hunk.lines[0] + + // Create a new Hunk header line + const newDiffHunkLine = new DiffLine( + newHunkHeader.toDiffLineRepresentation(), + DiffLineType.Hunk, + null, + firstHunkLine.oldLineNumber, + firstHunkLine.newLineNumber, + firstHunkLine.noTrailingNewLine + ) + + const allHunkLinesButFirst = hunk.lines.slice(1) + + // Update the diff lines of the hunk with the new lines + const updatedHunkLines = isExpandingUp + ? [newDiffHunkLine, ...newLineDiffs, ...allHunkLinesButFirst] + : [newDiffHunkLine, ...allHunkLinesButFirst, ...newLineDiffs] + + let numberOfNewDiffLines = updatedHunkLines.length - hunk.lines.length + + const previousHunk = hunkIndex === 0 ? null : diff.hunks[hunkIndex - 1] + const expansionType = getHunkHeaderExpansionType( + hunkIndex, + newHunkHeader, + previousHunk + ) + + // Update the hunk with all the new info (header, lines, start/end...) + let updatedHunk = new DiffHunk( + newHunkHeader, + updatedHunkLines, + hunk.unifiedDiffStart, + hunk.unifiedDiffEnd + numberOfNewDiffLines, + expansionType + ) + + let previousHunksEndIndex = 0 // Exclusive + let followingHunksStartIndex = 0 // Inclusive + + // Merge hunks if needed. Depending on whether we need to merge the current + // hunk and the adjacent, we will strip (or not) the adjacent from the list + // of hunks, and replace the current one with the merged version. + if (shouldMergeWithAdjacent && adjacentHunk !== null) { + if (isExpandingUp) { + updatedHunk = mergeDiffHunks(adjacentHunk, updatedHunk) + previousHunksEndIndex = hunkIndex - 1 + followingHunksStartIndex = hunkIndex + 1 + } else { + previousHunksEndIndex = hunkIndex + followingHunksStartIndex = hunkIndex + 2 + updatedHunk = mergeDiffHunks(updatedHunk, adjacentHunk) + } + + // After merging, there is one line less (the Hunk header line from one + // of the merged hunks). + numberOfNewDiffLines = numberOfNewDiffLines - 1 + } else { + previousHunksEndIndex = hunkIndex + followingHunksStartIndex = hunkIndex + 1 + } + + const previousHunks = diff.hunks.slice(0, previousHunksEndIndex) + + // Grab the hunks after the current one, and update their start/end, but only + // if the currently expanded hunk didn't reach the bottom of the file. + const newHunkLastLine = + newHunkHeader.newStartLine + newHunkHeader.newLineCount - 1 + const followingHunks = + newHunkLastLine >= newContentLines.length + ? [] + : diff.hunks.slice(followingHunksStartIndex).map((hunk, hunkIndex) => { + const isLastDummyHunk = + hunkIndex + followingHunksStartIndex === diff.hunks.length - 1 && + hunk.lines.length === 1 && + hunk.lines[0].type === DiffLineType.Hunk + + // Only compute the new expansion type if the hunk is the first one + // (of the remaining hunks) and it's not the last dummy hunk. + const shouldComputeNewExpansionType = + hunkIndex === 0 && !isLastDummyHunk + + return new DiffHunk( + hunk.header, + hunk.lines, + hunk.unifiedDiffStart + numberOfNewDiffLines, + hunk.unifiedDiffEnd + numberOfNewDiffLines, + // If it's the first hunk after the one we expanded, recalculate + // its expansion type. + shouldComputeNewExpansionType + ? getHunkHeaderExpansionType( + followingHunksStartIndex, + hunk.header, + updatedHunk + ) + : hunk.expansionType + ) + }) + + // Create the new list of hunks of the diff, and the new diff text + const newHunks = [...previousHunks, updatedHunk, ...followingHunks] + const newDiffText = getDiffTextFromHunks(newHunks) + + return { + ...diff, + text: newDiffText, + hunks: newHunks, + maxLineNumber: getLargestLineNumber(newHunks), + hasHiddenBidiChars, + } +} + +/** + * Calculates a new text diff, if needed, with a dummy hunk at the end to allow + * expansion of the diff at the bottom. + * If such dummy hunk at the bottom is not needed, returns null. + * + * @param diff Original diff + * @param hunks Hunks from the original diff + * @param numberOfOldLines Number of lines in the old content + * @param numberOfNewLines Number of lines in the new content + */ +export function getTextDiffWithBottomDummyHunk( + diff: ITextDiff, + hunks: ReadonlyArray, + numberOfOldLines: number, + numberOfNewLines: number +): ITextDiff | null { + const lastHunk = hunks.at(-1) + + if (lastHunk === undefined) { + return null + } + + // If the last hunk doesn't reach the end of the file, create a dummy hunk + // at the end to allow expanding the diff down. + const lastHunkNewLine = + lastHunk.header.newStartLine + lastHunk.header.newLineCount + + if (lastHunkNewLine >= numberOfNewLines) { + return null + } + const dummyOldStartLine = + lastHunk.header.oldStartLine + lastHunk.header.oldLineCount + const dummyNewStartLine = + lastHunk.header.newStartLine + lastHunk.header.newLineCount + const dummyHeader = new DiffHunkHeader( + dummyOldStartLine, + numberOfOldLines - dummyOldStartLine + 1, + dummyNewStartLine, + numberOfNewLines - dummyNewStartLine + 1 + ) + // Use an empty line for this dummy hunk to keep the diff clean + const dummyLine = new DiffLine('', DiffLineType.Hunk, null, null, null, false) + const dummyHunk = new DiffHunk( + dummyHeader, + [dummyLine], + lastHunk.unifiedDiffEnd + 1, + lastHunk.unifiedDiffEnd + 1, + DiffHunkExpansionType.Down + ) + + const newHunks = [...hunks, dummyHunk] + + return { + ...diff, + text: getDiffTextFromHunks(newHunks), + hunks: newHunks, + } +} diff --git a/app/src/ui/diff/text-diff.tsx b/app/src/ui/diff/text-diff.tsx new file mode 100644 index 0000000000..568c043c53 --- /dev/null +++ b/app/src/ui/diff/text-diff.tsx @@ -0,0 +1,1581 @@ +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, + textDiffEquals, +} 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 { PopoverAnchorPosition } 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), + enabled: !this.props.hideWhitespaceInDiff, + }, + ] + } + + // 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 && + !this.props.hideWhitespaceInDiff, + }, + ] + } + + 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(lineInfo.line, 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 getGutterLineID(index: number) { + return `diff-line-gutter-${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('button') + hunkExpandUpHandle.classList.add('hunk-expander', '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('button') + hunkExpandDownHandle.classList.add( + 'hunk-expander', + '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('button') + hunkExpandWholeHandle.classList.add( + 'hunk-expander', + '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(index, 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( + index: number, + 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) + } + } + + marker.id = this.getGutterLineID(index) + + 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 lineY = cm.heightAtLine(index, 'local') + + // Offset down by 10px to align the popover arrow. + container.style.top = `${lineY - 10}px` + + 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() + } + + const isSameDiff = textDiffEquals(this.props.diff, prevProps.diff) + + 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 (isSameDiff) { + this.updateViewport() + } + } + } + + if (!isSameDiff) { + 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 new file mode 100644 index 0000000000..220ad3b004 --- /dev/null +++ b/app/src/ui/diff/whitespace-hint-popover.tsx @@ -0,0 +1,58 @@ +import * as React from 'react' +import { + Popover, + PopoverAnchorPosition, + PopoverAppearEffect, + PopoverDecoration, +} from '../lib/popover' +import { OkCancelButtonGroup } from '../dialog' + +interface IWhitespaceHintPopoverProps { + 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 +} + +export class WhitespaceHintPopover extends React.Component { + public render() { + return ( + +

Show whitespace changes?

+

+ Selecting lines is disabled when hiding whitespace changes. +

+
+ +
+
+ ) + } + + private onShowWhitespaceChanges = ( + event: React.MouseEvent + ) => { + event.preventDefault() + this.props.onHideWhitespaceInDiffChanged(false) + this.props.onDismissed() + } + + private onDismissed = (event?: React.MouseEvent | MouseEvent) => { + event?.preventDefault() + this.props.onDismissed() + } +} diff --git a/app/src/ui/discard-changes/discard-changes-dialog.tsx b/app/src/ui/discard-changes/discard-changes-dialog.tsx new file mode 100644 index 0000000000..f174255bc5 --- /dev/null +++ b/app/src/ui/discard-changes/discard-changes-dialog.tsx @@ -0,0 +1,173 @@ +import * as React from 'react' + +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { WorkingDirectoryFileChange } from '../../models/status' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { PathText } from '../lib/path-text' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { TrashNameLabel } from '../lib/context-menu' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +interface IDiscardChangesProps { + readonly repository: Repository + readonly dispatcher: Dispatcher + readonly files: ReadonlyArray + readonly confirmDiscardChanges: boolean + /** + * Determines whether to show the option + * to ask for confirmation when discarding + * changes + */ + readonly discardingAllChanges: boolean + readonly showDiscardChangesSetting: boolean + readonly onDismissed: () => void + readonly onConfirmDiscardChangesChanged: (optOut: boolean) => void +} + +interface IDiscardChangesState { + /** + * Whether or not we're currently in the process of discarding + * changes. This is used to display a loading state + */ + readonly isDiscardingChanges: boolean + + readonly confirmDiscardChanges: boolean +} + +/** + * If we're discarding any more than this number, we won't bother listing them + * all. + */ +const MaxFilesToList = 10 + +/** A component to confirm and then discard changes. */ +export class DiscardChanges extends React.Component< + IDiscardChangesProps, + IDiscardChangesState +> { + public constructor(props: IDiscardChangesProps) { + super(props) + + this.state = { + isDiscardingChanges: false, + confirmDiscardChanges: this.props.confirmDiscardChanges, + } + } + + private getOkButtonLabel() { + if (this.props.discardingAllChanges) { + return __DARWIN__ ? 'Discard All Changes' : 'Discard all changes' + } + return __DARWIN__ ? 'Discard Changes' : 'Discard changes' + } + + private getDialogTitle() { + if (this.props.discardingAllChanges) { + return __DARWIN__ + ? 'Confirm Discard All Changes' + : 'Confirm discard all changes' + } + return __DARWIN__ ? 'Confirm Discard Changes' : 'Confirm discard changes' + } + + public render() { + const isDiscardingChanges = this.state.isDiscardingChanges + + return ( + + + {this.renderFileList()} +

+ Changes can be restored by retrieving them from the {TrashNameLabel} + . +

+ {this.renderConfirmDiscardChanges()} +
+ + + + +
+ ) + } + + private renderConfirmDiscardChanges() { + if (this.props.showDiscardChangesSetting) { + return ( + + ) + } else { + // since we ignore the users option to not show + // confirmation, we don't want to show a checkbox + // that will have no effect + return null + } + } + + private renderFileList() { + if (this.props.files.length > MaxFilesToList) { + return ( +

+ Are you sure you want to discard all {this.props.files.length} changed + files? +

+ ) + } else { + return ( +
+

Are you sure you want to discard all changes to:

+
+
    + {this.props.files.map(p => ( +
  • + +
  • + ))} +
+
+
+ ) + } + } + + private discard = async () => { + this.setState({ isDiscardingChanges: true }) + + await this.props.dispatcher.discardChanges( + this.props.repository, + this.props.files + ) + + this.props.onConfirmDiscardChangesChanged(this.state.confirmDiscardChanges) + this.props.onDismissed() + } + + private onConfirmDiscardChangesChanged = ( + event: React.FormEvent + ) => { + const value = !event.currentTarget.checked + + this.setState({ confirmDiscardChanges: value }) + } +} diff --git a/app/src/ui/discard-changes/discard-changes-retry-dialog.tsx b/app/src/ui/discard-changes/discard-changes-retry-dialog.tsx new file mode 100644 index 0000000000..875832c94e --- /dev/null +++ b/app/src/ui/discard-changes/discard-changes-retry-dialog.tsx @@ -0,0 +1,111 @@ +import * as React from 'react' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { Dispatcher } from '../dispatcher' +import { TrashNameLabel } from '../lib/context-menu' +import { RetryAction } from '../../models/retry-actions' +import { Checkbox, CheckboxValue } from '../lib/checkbox' + +interface IDiscardChangesRetryDialogProps { + readonly dispatcher: Dispatcher + readonly retryAction: RetryAction + readonly onDismissed: () => void + readonly onConfirmDiscardChangesChanged: (optOut: boolean) => void +} + +interface IDiscardChangesRetryDialogState { + readonly retrying: boolean + readonly confirmDiscardChanges: boolean +} + +export class DiscardChangesRetryDialog extends React.Component< + IDiscardChangesRetryDialogProps, + IDiscardChangesRetryDialogState +> { + public constructor(props: IDiscardChangesRetryDialogProps) { + super(props) + this.state = { retrying: false, confirmDiscardChanges: true } + } + + public render() { + const { retrying } = this.state + + return ( + + +

Failed to discard changes to {TrashNameLabel}.

+
+ Common reasons are: +
    +
  • + The {TrashNameLabel} is configured to delete items immediately. +
  • +
  • Restricted access to move the file(s).
  • +
+
+

These changes will be unrecoverable from the {TrashNameLabel}.

+ {this.renderConfirmDiscardChanges()} +
+ {this.renderFooter()} +
+ ) + } + + private renderConfirmDiscardChanges() { + return ( + + ) + } + + private renderFooter() { + return ( + + + + ) + } + + private onConfirmDiscardChangesChanged = ( + event: React.FormEvent + ) => { + const value = !event.currentTarget.checked + + this.setState({ confirmDiscardChanges: value }) + } + + private onSubmit = async () => { + const { dispatcher, retryAction } = this.props + + this.setState({ retrying: true }) + + await dispatcher.performRetry(retryAction) + + this.props.onConfirmDiscardChangesChanged(this.state.confirmDiscardChanges) + this.props.onDismissed() + } +} diff --git a/app/src/ui/discard-changes/discard-selection-dialog.tsx b/app/src/ui/discard-changes/discard-selection-dialog.tsx new file mode 100644 index 0000000000..67f3e16323 --- /dev/null +++ b/app/src/ui/discard-changes/discard-selection-dialog.tsx @@ -0,0 +1,133 @@ +import * as React from 'react' + +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { WorkingDirectoryFileChange } from '../../models/status' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { PathText } from '../lib/path-text' +import { Checkbox, CheckboxValue } from '../lib/checkbox' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { ITextDiff, DiffSelection } from '../../models/diff' + +interface IDiscardSelectionProps { + readonly repository: Repository + readonly dispatcher: Dispatcher + /** + * The file where the selection of changes to discard should be applied. + */ + readonly file: WorkingDirectoryFileChange + /** + * The current diff with the local changes for that file. + */ + readonly diff: ITextDiff + /** + * The selection (based on the passed diff) of changes to discard. + */ + readonly selection: DiffSelection + /** + * Function called when the user either dismisses the dialog or + * the discard operation finishes. + */ + readonly onDismissed: () => void +} + +interface IDiscardSelectionState { + /** + * Whether or not we're currently in the process of discarding + * changes. This is used to display a loading state + */ + readonly isDiscardingSelection: boolean + /** + * Whether or not the "do not show this message again" checkbox + * is checked. + */ + readonly confirmDiscardSelection: boolean +} + +/** A component to confirm and then discard changes from a selection. */ +export class DiscardSelection extends React.Component< + IDiscardSelectionProps, + IDiscardSelectionState +> { + public constructor(props: IDiscardSelectionProps) { + super(props) + + this.state = { + isDiscardingSelection: false, + confirmDiscardSelection: true, + } + } + + private getOkButtonLabel() { + return __DARWIN__ ? 'Discard Changes' : 'Discard changes' + } + + public render() { + const isDiscardingChanges = this.state.isDiscardingSelection + + return ( + + +

Are you sure you want to discard the selected changes to:

+ +
    +
  • + +
  • +
+ + +
+ + + + +
+ ) + } + + private discard = async () => { + this.setState({ isDiscardingSelection: true }) + + await this.props.dispatcher.discardChangesFromSelection( + this.props.repository, + this.props.file.path, + this.props.diff, + this.props.selection + ) + this.props.dispatcher.setConfirmDiscardChangesSetting( + this.state.confirmDiscardSelection + ) + this.props.onDismissed() + } + + private onConfirmDiscardSelectionChanged = ( + event: React.FormEvent + ) => { + const value = !event.currentTarget.checked + + this.setState({ confirmDiscardSelection: value }) + } +} diff --git a/app/src/ui/discard-changes/index.ts b/app/src/ui/discard-changes/index.ts new file mode 100644 index 0000000000..e9f4bc2a04 --- /dev/null +++ b/app/src/ui/discard-changes/index.ts @@ -0,0 +1 @@ +export { DiscardChanges } from './discard-changes-dialog' diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts new file mode 100644 index 0000000000..2f45d34f8f --- /dev/null +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -0,0 +1,4097 @@ +import { Disposable, DisposableLike } from 'event-kit' + +import { + IAPIOrganization, + IAPIPullRequest, + IAPIFullRepository, + IAPICheckSuite, + IAPIRepoRuleset, +} from '../../lib/api' +import { shell } from '../../lib/app-shell' +import { + CompareAction, + Foldout, + FoldoutType, + ICompareFormUpdate, + RepositorySectionTab, + RebaseConflictState, + isRebaseConflictState, + isCherryPickConflictState, + CherryPickConflictState, + MultiCommitOperationConflictState, + IMultiCommitOperationState, +} from '../../lib/app-state' +import { assertNever, fatalError } from '../../lib/fatal-error' +import { + setGenericPassword, + setGenericUsername, +} from '../../lib/generic-git-auth' +import { + RebaseResult, + PushOptions, + getCommitsBetweenCommits, + getBranches, + getRebaseSnapshot, + getRepositoryType, +} from '../../lib/git' +import { isGitOnPath } from '../../lib/is-git-on-path' +import { + rejectOAuthRequest, + requestAuthenticatedUser, + resolveOAuthRequest, +} from '../../lib/oauth' +import { + IOpenRepositoryFromURLAction, + IUnknownAction, + URLActionType, +} from '../../lib/parse-app-url' +import { + matchExistingRepository, + urlsMatch, +} from '../../lib/repository-matching' +import { Shell } from '../../lib/shells' +import { ILaunchStats, StatsStore } from '../../lib/stats' +import { AppStore } from '../../lib/stores/app-store' +import { RepositoryStateCache } from '../../lib/stores/repository-state-cache' +import { getTipSha } from '../../lib/tip' + +import { Account } from '../../models/account' +import { AppMenu, ExecutableMenuItem } from '../../models/app-menu' +import { Author, UnknownAuthor } from '../../models/author' +import { Branch, 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 { ICommitMessage } from '../../models/commit-message' +import { DiffSelection, ImageDiffType, ITextDiff } from '../../models/diff' +import { FetchType } from '../../models/fetch' +import { GitHubRepository } from '../../models/github-repository' +import { ManualConflictResolution } from '../../models/manual-conflict-resolution' +import { Popup, PopupType } from '../../models/popup' +import { + PullRequest, + PullRequestSuggestedNextAction, +} from '../../models/pull-request' +import { + Repository, + RepositoryWithGitHubRepository, + isRepositoryWithGitHubRepository, + getGitHubHtmlUrl, + isRepositoryWithForkedGitHubRepository, + getNonForkGitHubRepository, +} from '../../models/repository' +import { RetryAction, RetryActionType } from '../../models/retry-actions' +import { + CommittedFileChange, + WorkingDirectoryFileChange, + WorkingDirectoryStatus, +} from '../../models/status' +import { TipState, IValidBranch } from '../../models/tip' +import { Banner, BannerType } from '../../models/banner' + +import { ApplicationTheme } from '../lib/application-theme' +import { installCLI } from '../lib/install-cli' +import { + executeMenuItem, + moveToApplicationsFolder, + isWindowFocused, + showOpenDialog, +} from '../main-process-proxy' +import { + CommitStatusStore, + StatusCallBack, +} from '../../lib/stores/commit-status-store' +import { MergeTreeResult } from '../../models/merge' +import { UncommittedChangesStrategy } from '../../models/uncommitted-changes-strategy' +import { IStashEntry } from '../../models/stash-entry' +import { WorkflowPreferences } from '../../models/workflow-preferences' +import { resolveWithin } from '../../lib/path' +import { CherryPickResult } from '../../lib/git/cherry-pick' +import { sleep } from '../../lib/promise' +import { DragElement, DragType } from '../../models/drag-drop' +import { ILastThankYou } from '../../models/last-thank-you' +import { dragAndDropManager } from '../../lib/drag-and-drop-manager' +import { + CreateBranchStep, + MultiCommitOperationDetail, + MultiCommitOperationKind, + MultiCommitOperationStep, + MultiCommitOperationStepKind, +} from '../../models/multi-commit-operation' +import { getMultiCommitOperationChooseBranchStep } from '../../lib/multi-commit-operation' +import { ICombinedRefCheck, IRefCheck } from '../../lib/ci-checks/ci-checks' +import { ValidNotificationPullRequestReviewState } from '../../lib/valid-notification-pull-request-review' +import { UnreachableCommitsTab } from '../history/unreachable-commits-dialog' + +/** + * An error handler function. + * + * If the returned {Promise} returns an error, it will be passed to the next + * error handler. If it returns null, error propagation is halted. + */ +export type ErrorHandler = ( + error: Error, + dispatcher: Dispatcher +) => Promise + +/** + * The Dispatcher acts as the hub for state. The StateHub if you will. It + * decouples the consumer of state from where/how it is stored. + */ +export class Dispatcher { + private readonly errorHandlers = new Array() + + public constructor( + private readonly appStore: AppStore, + private readonly repositoryStateManager: RepositoryStateCache, + private readonly statsStore: StatsStore, + private readonly commitStatusStore: CommitStatusStore + ) {} + + /** Load the initial state for the app. */ + public loadInitialState(): Promise { + return this.appStore.loadInitialState() + } + + /** + * Add the repositories at the given paths. If a path isn't a repository, then + * this will post an error to that affect. + */ + public addRepositories( + paths: ReadonlyArray + ): Promise> { + return this.appStore._addRepositories(paths) + } + + /** + * Add a tutorial repository. + * + * This method differs from the `addRepositories` method in that it + * requires that the repository has been created on the remote and + * set up to track it. Given that tutorial repositories are created + * from the no-repositories blank slate it shouldn't be possible for + * another repository with the same path to exist but in case that + * changes in the future this method will set the tutorial flag on + * the existing repository at the given path. + */ + public addTutorialRepository( + path: string, + endpoint: string, + apiRepository: IAPIFullRepository + ) { + return this.appStore._addTutorialRepository(path, endpoint, apiRepository) + } + + /** Resume an already started onboarding tutorial */ + public resumeTutorial(repository: Repository) { + return this.appStore._resumeTutorial(repository) + } + + /** Suspend the onboarding tutorial and go to the no repositories blank slate view */ + public pauseTutorial(repository: Repository) { + return this.appStore._pauseTutorial(repository) + } + + /** + * Remove the repositories represented by the given IDs from local storage. + * + * When `moveToTrash` is enabled, only the repositories that were successfully + * deleted on disk are removed from the app. If some failed due to files being + * open elsewhere, an error is thrown. + */ + public async removeRepository( + repository: Repository | CloningRepository, + moveToTrash: boolean + ): Promise { + return this.appStore._removeRepository(repository, moveToTrash) + } + + /** Update the repository's `missing` flag. */ + public async updateRepositoryMissing( + repository: Repository, + missing: boolean + ): Promise { + return this.appStore._updateRepositoryMissing(repository, missing) + } + + /** Load the next batch of history for the repository. */ + public loadNextCommitBatch(repository: Repository): Promise { + return this.appStore._loadNextCommitBatch(repository) + } + + /** Load the changed files for the current history selection. */ + public loadChangedFilesForCurrentSelection( + repository: Repository + ): Promise { + return this.appStore._loadChangedFilesForCurrentSelection(repository) + } + + /** + * Change the selected commit in the history view. + * + * @param repository The currently active repository instance + * + * @param sha The object id of one of the commits currently + * the history list, represented as a SHA-1 hash + * digest. This should match exactly that of Commit.Sha + */ + public changeCommitSelection( + repository: Repository, + shas: ReadonlyArray, + isContiguous: boolean + ): void { + return this.appStore._changeCommitSelection(repository, shas, isContiguous) + } + + /** Update the shas that should be highlighted */ + public updateShasToHighlight( + repository: Repository, + shasToHighlight: ReadonlyArray + ) { + this.appStore._updateShasToHighlight(repository, shasToHighlight) + } + + /** + * Change the selected changed file in the history view. + * + * @param repository The currently active repository instance + * + * @param file A FileChange instance among those available in + * IHistoryState.changedFiles + */ + public changeFileSelection( + repository: Repository, + file: CommittedFileChange + ): Promise { + return this.appStore._changeFileSelection(repository, file) + } + + /** Set the repository filter text. */ + public setRepositoryFilterText(text: string): Promise { + return this.appStore._setRepositoryFilterText(text) + } + + /** Select the repository. */ + public selectRepository( + repository: Repository | CloningRepository + ): Promise { + return this.appStore._selectRepository(repository) + } + + /** Change the selected section in the repository. */ + public changeRepositorySection( + repository: Repository, + section: RepositorySectionTab + ): Promise { + return this.appStore._changeRepositorySection(repository, section) + } + + /** + * Changes the selection in the changes view to the working directory and + * optionally selects one or more files from the working directory. + * + * @param files An array of files to select when showing the working directory. + * If undefined this method will preserve the previously selected + * files or pick the first changed file if no selection exists. + */ + public selectWorkingDirectoryFiles( + repository: Repository, + selectedFiles?: WorkingDirectoryFileChange[] + ): Promise { + return this.appStore._selectWorkingDirectoryFiles(repository, selectedFiles) + } + + /** + * Changes the selection in the changes view to the stash entry view and + * optionally selects a particular file from the current stash entry. + * + * @param file A file to select when showing the stash entry. + * If undefined this method will preserve the previously selected + * file or pick the first changed file if no selection exists. + */ + public selectStashedFile( + repository: Repository, + file?: CommittedFileChange | null + ): Promise { + return this.appStore._selectStashedFile(repository, file) + } + + /** + * Commit the changes which were marked for inclusion, using the given commit + * summary and description and optionally any number of commit message trailers + * which will be merged into the final commit message. + */ + public async commitIncludedChanges( + repository: Repository, + context: ICommitContext + ): Promise { + return this.appStore._commitIncludedChanges(repository, context) + } + + /** Change the file's includedness. */ + public changeFileIncluded( + repository: Repository, + file: WorkingDirectoryFileChange, + include: boolean + ): Promise { + return this.appStore._changeFileIncluded(repository, file, include) + } + + /** Change the file's line selection state. */ + public changeFileLineSelection( + repository: Repository, + file: WorkingDirectoryFileChange, + diffSelection: DiffSelection + ): Promise { + return this.appStore._changeFileLineSelection( + repository, + file, + diffSelection + ) + } + + /** Change the Include All state. */ + public changeIncludeAllFiles( + repository: Repository, + includeAll: boolean + ): Promise { + return this.appStore._changeIncludeAllFiles(repository, includeAll) + } + + /** + * Refresh the repository. This would be used, e.g., when the app gains focus. + */ + public refreshRepository(repository: Repository): Promise { + return this.appStore._refreshOrRecoverRepository(repository) + } + + /** + * Refresh the commit author of a repository. Required after changing git's + * user name or email address. + */ + public async refreshAuthor(repository: Repository): Promise { + return this.appStore._refreshAuthor(repository) + } + + /** Show the popup. This will close any current popup. */ + public showPopup(popup: Popup): Promise { + return this.appStore._showPopup(popup) + } + + /** + * Close the current popup, if found + * + * @param popupType only close the popup if it matches this `PopupType` + */ + public closePopup(popupType?: PopupType) { + return this.appStore._closePopup(popupType) + } + + /** + * Close the popup with given id. + */ + public closePopupById(popupId: string) { + return this.appStore._closePopupById(popupId) + } + + /** Show the foldout. This will close any current popup. */ + public showFoldout(foldout: Foldout): Promise { + return this.appStore._showFoldout(foldout) + } + + /** Close the current foldout. If opening a new foldout use closeFoldout instead. */ + public closeCurrentFoldout(): Promise { + return this.appStore._closeCurrentFoldout() + } + + /** Close the specified foldout */ + public closeFoldout(foldout: FoldoutType): Promise { + return this.appStore._closeFoldout(foldout) + } + + /** + * Check for remote commits that could affect an rebase operation. + * + * @param targetBranch The branch where the rebase takes place. + * @param oldestCommitRef Ref of the oldest commit involved in the interactive + * rebase, or tip of the base branch in a regular + * rebase. If it's null, the root of the branch will be + * considered. + */ + private async warnAboutRemoteCommits( + repository: Repository, + targetBranch: Branch, + oldestCommitRef: string | null + ): Promise { + if (targetBranch.upstream === null) { + return false + } + + // if the branch is tracking a remote branch + const upstreamBranchesMatching = await getBranches( + repository, + `refs/remotes/${targetBranch.upstream}` + ) + + if (upstreamBranchesMatching.length === 0) { + return false + } + + // At this point, the target branch has an upstream. Therefore, if the + // rebase goes up to the root commit of the branch, remote commits that will + // require a force push after the rebase do exist. + if (oldestCommitRef === null) { + return true + } + + // and the remote branch has commits that don't exist on the base branch + const remoteCommits = await getCommitsBetweenCommits( + repository, + oldestCommitRef, + targetBranch.upstream + ) + + return remoteCommits !== null && remoteCommits.length > 0 + } + + /** Initialize rebase flow to choose branch step **/ + public async showRebaseDialog( + repository: Repository, + initialBranch?: Branch | null + ) { + const repositoryState = this.repositoryStateManager.get(repository) + const initialStep = getMultiCommitOperationChooseBranchStep( + repositoryState, + initialBranch + ) + + const { tip } = repositoryState.branchesState + let currentBranch: Branch | null = null + + if (tip.kind === TipState.Valid) { + currentBranch = tip.branch + } else { + throw new Error( + 'Tip is not in a valid state, which is required to start the rebase flow' + ) + } + + this.initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.Rebase, + sourceBranch: null, + commits: [], + currentTip: tip.branch.tip.sha, + }, + currentBranch, + [], + currentBranch.tip.sha + ) + + this.setMultiCommitOperationStep(repository, initialStep) + + this.showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + } + + /** Initialize and start the rebase operation */ + public async startRebase( + repository: Repository, + baseBranch: Branch, + targetBranch: Branch, + commits: ReadonlyArray, + options?: { continueWithForcePush: boolean } + ): Promise { + const { askForConfirmationOnForcePush } = this.appStore.getState() + + const hasOverriddenForcePushCheck = + options !== undefined && options.continueWithForcePush + + const { branchesState } = this.repositoryStateManager.get(repository) + const originalBranchTip = getTipSha(branchesState.tip) + + this.appStore._initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.Rebase, + commits, + currentTip: baseBranch.tip.sha, + sourceBranch: baseBranch, + }, + targetBranch, + commits, + originalBranchTip + ) + + if (askForConfirmationOnForcePush && !hasOverriddenForcePushCheck) { + const showWarning = await this.warnAboutRemoteCommits( + repository, + baseBranch, + targetBranch.tip.sha + ) + + if (showWarning) { + this.setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.WarnForcePush, + targetBranch, + baseBranch, + commits, + }) + return + } + } + + await this.rebase(repository, baseBranch, targetBranch) + } + + /** + * Initialize and launch the rebase flow for a conflicted repository + */ + public async launchRebaseOperation( + repository: Repository, + targetBranch: string + ) { + await this.appStore._loadStatus(repository) + + const repositoryState = this.repositoryStateManager.get(repository) + const { conflictState } = repositoryState.changesState + + if (conflictState === null || !isRebaseConflictState(conflictState)) { + return + } + + const updatedConflictState = { + ...conflictState, + targetBranch, + } + + this.repositoryStateManager.updateChangesState(repository, () => ({ + conflictState: updatedConflictState, + })) + + const snapshot = await getRebaseSnapshot(repository) + if (snapshot === null) { + return + } + + const { progress, commits } = snapshot + this.initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.Rebase, + sourceBranch: null, + commits, + currentTip: '', + }, + null, + commits, + targetBranch + ) + + this.repositoryStateManager.updateMultiCommitOperationState( + repository, + () => ({ + progress, + }) + ) + + const { manualResolutions } = conflictState + this.setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.ShowConflicts, + conflictState: { + kind: 'multiCommitOperation', + manualResolutions, + ourBranch: targetBranch, + theirBranch: undefined, + }, + }) + + this.showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + } + + /** + * Create a new branch from the given starting point and check it out. + * + * If the startPoint argument is omitted the new branch will be created based + * off of the current state of HEAD. + */ + public createBranch( + repository: Repository, + name: string, + startPoint: string | null, + noTrackOption: boolean = false + ): Promise { + return this.appStore._createBranch( + repository, + name, + startPoint, + noTrackOption + ) + } + + /** + * Create a new tag on the given target commit. + */ + public createTag( + repository: Repository, + name: string, + targetCommitSha: string + ): Promise { + return this.appStore._createTag(repository, name, targetCommitSha) + } + + /** + * Deletes the passed tag. + */ + public deleteTag(repository: Repository, name: string): Promise { + return this.appStore._deleteTag(repository, name) + } + + /** + * Show the tag creation dialog. + */ + public showCreateTagDialog( + repository: Repository, + targetCommitSha: string, + localTags: Map | null, + initialName?: string + ): Promise { + return this.showPopup({ + type: PopupType.CreateTag, + repository, + targetCommitSha, + initialName, + localTags, + }) + } + + /** + * Show the confirmation dialog to delete a tag. + */ + public showDeleteTagDialog( + repository: Repository, + tagName: string + ): Promise { + return this.showPopup({ + type: PopupType.DeleteTag, + repository, + tagName, + }) + } + + /** Check out the given branch. */ + public checkoutBranch( + repository: Repository, + branch: Branch, + strategy?: UncommittedChangesStrategy + ): Promise { + return this.appStore._checkoutBranch(repository, branch, strategy) + } + + /** Check out the given commit. */ + public checkoutCommit( + repository: Repository, + commit: CommitOneLine + ): Promise { + return this.appStore._checkoutCommit(repository, commit) + } + + /** Push the current branch. */ + public push(repository: Repository): Promise { + return this.appStore._push(repository) + } + + private pushWithOptions(repository: Repository, options?: PushOptions) { + if (options !== undefined && options.forceWithLease) { + this.dropCurrentBranchFromForcePushList(repository) + } + + return this.appStore._push(repository, options) + } + + /** Pull the current branch. */ + public pull(repository: Repository): Promise { + return this.appStore._pull(repository) + } + + /** Fetch a specific refspec for the repository. */ + public fetchRefspec( + repository: Repository, + fetchspec: string + ): Promise { + return this.appStore._fetchRefspec(repository, fetchspec) + } + + /** Fetch all refs for the repository */ + public fetch(repository: Repository, fetchType: FetchType): Promise { + return this.appStore._fetch(repository, fetchType) + } + + /** Publish the repository to GitHub with the given properties. */ + public publishRepository( + repository: Repository, + name: string, + description: string, + private_: boolean, + account: Account, + org: IAPIOrganization | null + ): Promise { + return this.appStore._publishRepository( + repository, + name, + description, + private_, + account, + org + ) + } + + /** + * Post the given error. This will send the error through the standard error + * handler machinery. + */ + public async postError(error: Error): Promise { + let currentError: Error | null = error + for (let i = this.errorHandlers.length - 1; i >= 0; i--) { + const handler = this.errorHandlers[i] + currentError = await handler(currentError, this) + + if (!currentError) { + break + } + } + + if (currentError) { + fatalError( + `Unhandled error ${currentError}. This shouldn't happen! All errors should be handled, even if it's just by the default handler.` + ) + } + } + + /** + * Post the given error. Note that this bypasses the standard error handler + * machinery. You probably don't want that. See `Dispatcher.postError` + * instead. + */ + public presentError(error: Error): Promise { + return this.appStore._pushError(error) + } + + /** + * Clone a missing repository to the previous path, and update it's + * state in the repository list if the clone completes without error. + */ + public cloneAgain(url: string, path: string): Promise { + return this.appStore._cloneAgain(url, path) + } + + /** Clone the repository to the path. */ + public async clone( + url: string, + path: string, + options?: { branch?: string; defaultBranch?: string } + ): Promise { + return this.appStore._completeOpenInDesktop(async () => { + const { promise, repository } = this.appStore._clone(url, path, options) + await this.selectRepository(repository) + const success = await promise + // TODO: this exit condition is not great, bob + if (!success) { + return null + } + + const addedRepositories = await this.addRepositories([path]) + + if (addedRepositories.length < 1) { + return null + } + + const addedRepository = addedRepositories[0] + await this.selectRepository(addedRepository) + + if (isRepositoryWithForkedGitHubRepository(addedRepository)) { + this.showPopup({ + type: PopupType.ChooseForkSettings, + repository: addedRepository, + }) + } + + return addedRepository + }) + } + + /** Changes the repository alias to a new name. */ + public changeRepositoryAlias( + repository: Repository, + newAlias: string | null + ): Promise { + return this.appStore._changeRepositoryAlias(repository, newAlias) + } + + /** Rename the branch to a new name. */ + public renameBranch( + repository: Repository, + branch: Branch, + newName: string + ): Promise { + return this.appStore._renameBranch(repository, branch, newName) + } + + /** + * Delete the branch. This will delete both the local branch and the remote + * branch if includeUpstream is true, and then check out the default branch. + */ + public deleteLocalBranch( + repository: Repository, + branch: Branch, + includeUpstream?: boolean + ): Promise { + return this.appStore._deleteBranch(repository, branch, includeUpstream) + } + + /** + * Delete the remote branch. + */ + public deleteRemoteBranch( + repository: Repository, + branch: Branch + ): Promise { + return this.appStore._deleteBranch(repository, branch) + } + + /** Discard the changes to the given files. */ + public discardChanges( + repository: Repository, + files: ReadonlyArray, + moveToTrash: boolean = true + ): Promise { + return this.appStore._discardChanges(repository, files, moveToTrash) + } + + /** Discard the changes from the given diff selection. */ + public discardChangesFromSelection( + repository: Repository, + filePath: string, + diff: ITextDiff, + selection: DiffSelection + ): Promise { + return this.appStore._discardChangesFromSelection( + repository, + filePath, + diff, + selection + ) + } + + /** Start amending the most recent commit. */ + public async startAmendingRepository( + repository: Repository, + commit: Commit, + isLocalCommit: boolean, + continueWithForcePush: boolean = false + ) { + const repositoryState = this.repositoryStateManager.get(repository) + const { tip } = repositoryState.branchesState + const { askForConfirmationOnForcePush } = this.appStore.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) + + this.appStore._setRepositoryCommitToAmend(repository, commit) + + this.statsStore.recordAmendCommitStarted() + } + + /** Stop amending the most recent commit. */ + public async stopAmendingRepository(repository: Repository) { + this.appStore._setRepositoryCommitToAmend(repository, null) + } + + /** Undo the given commit. */ + public undoCommit( + repository: Repository, + commit: Commit, + showConfirmationDialog: boolean = true + ): Promise { + return this.appStore._undoCommit(repository, commit, showConfirmationDialog) + } + + /** Reset to a given commit. */ + public resetToCommit( + repository: Repository, + commit: Commit, + showConfirmationDialog: boolean = true + ): Promise { + this.statsStore.recordResetToCommitCount() + return this.appStore._resetToCommit( + repository, + commit, + showConfirmationDialog + ) + } + + /** Revert the commit with the given SHA */ + public revertCommit(repository: Repository, commit: Commit): Promise { + return this.appStore._revertCommit(repository, commit) + } + + /** + * Set the width of the repository sidebar to the given + * value. This affects the changes and history sidebar + * as well as the first toolbar section which contains + * repo selection on all platforms and repo selection and + * app menu on Windows. + */ + public setSidebarWidth(width: number): Promise { + return this.appStore._setSidebarWidth(width) + } + + /** + * Set the update banner's visibility + */ + public setUpdateBannerVisibility(isVisible: boolean) { + return this.appStore._setUpdateBannerVisibility(isVisible) + } + + /** + * Set the update show case visibility + */ + public setUpdateShowCaseVisibility(isVisible: boolean) { + return this.appStore._setUpdateShowCaseVisibility(isVisible) + } + + /** + * Set the banner state for the application + */ + public setBanner(state: Banner) { + return this.appStore._setBanner(state) + } + + /** + * Close the current banner, if found. + * + * @param bannerType only close the banner if it matches this `BannerType` + */ + public clearBanner(bannerType?: BannerType) { + return this.appStore._clearBanner(bannerType) + } + + /** + * Reset the width of the repository sidebar to its default + * value. This affects the changes and history sidebar + * as well as the first toolbar section which contains + * repo selection on all platforms and repo selection and + * app menu on Windows. + */ + public resetSidebarWidth(): Promise { + return this.appStore._resetSidebarWidth() + } + + /** + * Set the width of the commit summary column in the + * history view to the given value. + */ + public setCommitSummaryWidth(width: number): Promise { + return this.appStore._setCommitSummaryWidth(width) + } + + /** + * Reset the width of the commit summary column in the + * history view to its default value. + */ + public resetCommitSummaryWidth(): Promise { + return this.appStore._resetCommitSummaryWidth() + } + + /** Update the repository's issues from GitHub. */ + public refreshIssues(repository: GitHubRepository): Promise { + return this.appStore._refreshIssues(repository) + } + + /** End the Welcome flow. */ + public endWelcomeFlow(): Promise { + return this.appStore._endWelcomeFlow() + } + + /** Set the commit message input's focus. */ + public setCommitMessageFocus(focus: boolean) { + this.appStore._setCommitMessageFocus(focus) + } + + /** + * Set the commit summary and description for a work-in-progress + * commit in the changes view for a particular repository. + */ + public setCommitMessage( + repository: Repository, + message: ICommitMessage + ): Promise { + return this.appStore._setCommitMessage(repository, message) + } + + /** Remove the given account from the app. */ + public removeAccount(account: Account): Promise { + return this.appStore._removeAccount(account) + } + + /** + * Ask the dispatcher to apply a transformation function to the current + * state of the application menu. + * + * Since the dispatcher is asynchronous it's possible for components + * utilizing the menu state to have an out-of-date view of the state + * of the app menu which is why they're not allowed to transform it + * directly. + * + * To work around potential race conditions consumers instead pass a + * delegate which receives the updated application menu and allows + * them to perform the necessary state transitions. The AppMenu instance + * is itself immutable but does offer transformation methods and in + * order for the state to be properly updated the delegate _must_ return + * the latest transformed instance of the AppMenu. + */ + public setAppMenuState(update: (appMenu: AppMenu) => AppMenu): Promise { + return this.appStore._setAppMenuState(update) + } + + /** + * Tell the main process to execute (i.e. simulate a click of) the given menu item. + */ + public executeMenuItem(item: ExecutableMenuItem): Promise { + executeMenuItem(item) + return Promise.resolve() + } + + /** + * Set whether or not to to add a highlight class to the app menu toolbar icon. + * Used to highlight the button when the Alt key is pressed. + * + * Only applicable on non-macOS platforms. + */ + public setAccessKeyHighlightState(highlight: boolean): Promise { + return this.appStore._setAccessKeyHighlightState(highlight) + } + + /** Merge the named branch into the current branch. */ + public mergeBranch( + repository: Repository, + branch: Branch, + mergeStatus: MergeTreeResult | null, + isSquash: boolean = false + ): Promise { + return this.appStore._mergeBranch(repository, branch, mergeStatus, isSquash) + } + + /** + * Update the per-repository list of branches that can be force-pushed + * after a rebase or amend is completed. + */ + private addBranchToForcePushList = ( + repository: Repository, + tipWithBranch: IValidBranch, + beforeChangeSha: string + ) => { + this.appStore._addBranchToForcePushList( + repository, + tipWithBranch, + beforeChangeSha + ) + } + + private dropCurrentBranchFromForcePushList = (repository: Repository) => { + const currentState = this.repositoryStateManager.get(repository) + const { forcePushBranches: rebasedBranches, tip } = + currentState.branchesState + + if (tip.kind !== TipState.Valid) { + return + } + + const updatedMap = new Map(rebasedBranches) + updatedMap.delete(tip.branch.nameWithoutRemote) + + this.repositoryStateManager.updateBranchesState(repository, () => ({ + forcePushBranches: updatedMap, + })) + } + + /** + * Update the rebase state to indicate the user has resolved conflicts in the + * current repository. + */ + public setConflictsResolved(repository: Repository) { + return this.appStore._setConflictsResolved(repository) + } + + /** Starts a rebase for the given base and target branch */ + public async rebase( + repository: Repository, + baseBranch: Branch, + targetBranch: Branch + ): Promise { + const { branchesState, multiCommitOperationState } = + this.repositoryStateManager.get(repository) + + if ( + multiCommitOperationState == null || + multiCommitOperationState.operationDetail.kind !== + MultiCommitOperationKind.Rebase + ) { + return + } + const { commits } = multiCommitOperationState.operationDetail + + const beforeSha = getTipSha(branchesState.tip) + + log.info( + `[rebase] starting rebase for ${targetBranch.name} at ${beforeSha}` + ) + log.info( + `[rebase] to restore the previous state if this completed rebase is unsatisfactory:` + ) + log.info(`[rebase] - git checkout ${targetBranch.name}`) + log.info(`[rebase] - git reset ${beforeSha} --hard`) + + const result = await this.appStore._rebase( + repository, + baseBranch, + targetBranch + ) + + await this.appStore._loadStatus(repository) + + const stateAfter = this.repositoryStateManager.get(repository) + const { tip } = stateAfter.branchesState + const afterSha = getTipSha(tip) + + log.info( + `[rebase] completed rebase - got ${result} and on tip ${afterSha} - kind ${tip.kind}` + ) + + if (result === RebaseResult.ConflictsEncountered) { + const { conflictState } = stateAfter.changesState + if (conflictState === null) { + log.warn( + `[rebase] conflict state after rebase is null - unable to continue` + ) + return + } + + if (!isRebaseConflictState(conflictState)) { + log.warn( + `[rebase] conflict state after rebase is not rebase conflicts - unable to continue` + ) + return + } + + return this.startMultiCommitOperationConflictFlow( + MultiCommitOperationKind.Rebase, + repository, + baseBranch.name, + targetBranch.name + ) + } else if (result === RebaseResult.CompletedWithoutError) { + if (tip.kind !== TipState.Valid) { + log.warn( + `[rebase] tip after completing rebase is ${tip.kind} but this should be a valid tip if the rebase completed without error` + ) + return + } + + this.statsStore.recordRebaseSuccessWithoutConflicts() + await this.completeMultiCommitOperation(repository, commits.length) + } else if (result === RebaseResult.Error) { + // we were unable to successfully start the rebase, and an error should + // be shown through the default error handling infrastructure, so we can + // just abandon the rebase for now + this.endMultiCommitOperation(repository) + } + } + + /** Abort the current rebase and refreshes the repository status */ + public async abortRebase(repository: Repository) { + await this.appStore._abortRebase(repository) + await this.appStore._loadStatus(repository) + await this.refreshRepository(repository) + } + + /** + * Continue with the rebase after the user has resolved all conflicts with + * tracked files in the working directory. + */ + public async continueRebase( + kind: MultiCommitOperationKind, + repository: Repository, + workingDirectory: WorkingDirectoryStatus, + conflictsState: RebaseConflictState + ): Promise { + const stateBefore = this.repositoryStateManager.get(repository) + const { manualResolutions } = conflictsState + + const beforeSha = getTipSha(stateBefore.branchesState.tip) + + log.info(`[continueRebase] continuing rebase for ${beforeSha}`) + + const result = await this.appStore._continueRebase( + repository, + workingDirectory, + manualResolutions + ) + + if (result === RebaseResult.CompletedWithoutError) { + this.statsStore.recordOperationSuccessfulWithConflicts(kind) + } + + await this.appStore._loadStatus(repository) + + const stateAfter = this.repositoryStateManager.get(repository) + const { tip } = stateAfter.branchesState + const afterSha = getTipSha(tip) + + log.info( + `[continueRebase] completed rebase - got ${result} and on tip ${afterSha} - kind ${tip.kind}` + ) + + return result + } + + /** aborts an in-flight merge and refreshes the repository's status */ + public async abortMerge(repository: Repository) { + await this.appStore._abortMerge(repository) + await this.appStore._loadStatus(repository) + } + + /** aborts an in-flight merge and refreshes the repository's status */ + public async abortSquashMerge(repository: Repository) { + await this.appStore._abortSquashMerge(repository) + return this.appStore._refreshRepository(repository) + } + + /** + * commits an in-flight merge and shows a banner if successful + * + * @param repository + * @param workingDirectory + * @param successfulMergeBannerState information for banner to be displayed if merge is successful + */ + public async finishConflictedMerge( + repository: Repository, + workingDirectory: WorkingDirectoryStatus, + successfulMergeBanner: Banner, + isSquash: boolean + ) { + // get manual resolutions in case there are manual conflicts + const repositoryState = this.repositoryStateManager.get(repository) + const { conflictState } = repositoryState.changesState + if (conflictState === null) { + // if this doesn't exist, something is very wrong and we shouldn't proceed 😢 + log.error( + 'Conflict state missing during finishConflictedMerge. No merge will be committed.' + ) + return + } + const result = await this.appStore._finishConflictedMerge( + repository, + workingDirectory, + conflictState.manualResolutions + ) + if (result !== undefined) { + this.setBanner(successfulMergeBanner) + if (isSquash) { + // Squash merge will not hit the normal recording of successful merge in + // app-store._mergeBranch because it only records there when there are + // no conflicts. Thus, recordSquashMergeSuccessful is done here in order + // to capture all successful squash merges under this metric. + this.statsStore.recordSquashMergeSuccessful() + this.statsStore.recordSquashMergeSuccessfulWithConflicts() + } + } + } + + /** Record the given launch stats. */ + public recordLaunchStats(stats: ILaunchStats): Promise { + return this.appStore._recordLaunchStats(stats) + } + + /** Report any stats if needed. */ + public reportStats(): Promise { + return this.appStore._reportStats() + } + + /** Changes the URL for the remote that matches the given name */ + public setRemoteURL( + repository: Repository, + name: string, + url: string + ): Promise { + return this.appStore._setRemoteURL(repository, name, url) + } + + /** Open the URL in a browser */ + public openInBrowser(url: string): Promise { + return this.appStore._openInBrowser(url) + } + + /** Add the pattern to the repository's gitignore. */ + public appendIgnoreRule( + repository: Repository, + pattern: string | string[] + ): Promise { + return this.appStore._appendIgnoreRule(repository, pattern) + } + + /** + * Convenience method to add the given file path(s) to the repository's gitignore. + * + * The file path will be escaped before adding. + */ + public appendIgnoreFile( + repository: Repository, + filePath: string | string[] + ): Promise { + return this.appStore._appendIgnoreFile(repository, filePath) + } + + /** Opens a Git-enabled terminal setting the working directory to the repository path */ + public async openShell( + path: string, + ignoreWarning: boolean = false + ): Promise { + const gitFound = await isGitOnPath() + if (gitFound || ignoreWarning) { + this.appStore._openShell(path) + } else { + this.appStore._showPopup({ + type: PopupType.InstallGit, + path, + }) + } + } + + /** + * Opens a path in the external editor selected by the user. + */ + public async openInExternalEditor(fullPath: string): Promise { + return this.appStore._openInExternalEditor(fullPath) + } + + /** + * Persist the given content to the repository's root .gitignore. + * + * If the repository root doesn't contain a .gitignore file one + * will be created, otherwise the current file will be overwritten. + */ + public saveGitIgnore(repository: Repository, text: string): Promise { + return this.appStore._saveGitIgnore(repository, text) + } + + /** Set whether the user has opted out of stats reporting. */ + public setStatsOptOut( + optOut: boolean, + userViewedPrompt: boolean + ): Promise { + return this.appStore.setStatsOptOut(optOut, userViewedPrompt) + } + + /** Moves the app to the /Applications folder on macOS. */ + public moveToApplicationsFolder() { + return moveToApplicationsFolder() + } + + /** + * Clear any in-flight sign in state and return to the + * initial (no sign-in) state. + */ + public resetSignInState(): Promise { + return this.appStore._resetSignInState() + } + + /** + * Initiate a sign in flow for github.com. This will put the store + * in the Authentication step ready to receive user credentials. + */ + public beginDotComSignIn(): Promise { + return this.appStore._beginDotComSignIn() + } + + /** + * Initiate a sign in flow for a GitHub Enterprise instance. This will + * put the store in the EndpointEntry step ready to receive the url + * to the enterprise instance. + */ + public beginEnterpriseSignIn(): Promise { + return this.appStore._beginEnterpriseSignIn() + } + + /** + * Attempt to advance from the EndpointEntry step with the given endpoint + * url. This method must only be called when the store is in the authentication + * step or an error will be thrown. + * + * The provided endpoint url will be validated for syntactic correctness as + * well as connectivity before the promise resolves. If the endpoint url is + * invalid or the host can't be reached the promise will be rejected and the + * sign in state updated with an error to be presented to the user. + * + * If validation is successful the store will advance to the authentication + * step. + */ + public setSignInEndpoint(url: string): Promise { + return this.appStore._setSignInEndpoint(url) + } + + /** + * 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. + */ + public setSignInCredentials( + username: string, + password: string + ): Promise { + return this.appStore._setSignInCredentials(username, password) + } + + /** + * 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 requestBrowserAuthentication(): Promise { + return this.appStore._requestBrowserAuthentication() + } + + /** + * Initiate an OAuth sign in using the system configured browser to GitHub.com. + * + * 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 requestBrowserAuthenticationToDotcom(): Promise { + await this.beginDotComSignIn() + return this.requestBrowserAuthentication() + } + + /** + * 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 setSignInOTP(otp: string): Promise { + return this.appStore._setSignInOTP(otp) + } + + /** + * Launch a sign in dialog for authenticating a user with + * GitHub.com. + */ + public async showDotComSignInDialog(): Promise { + await this.appStore._beginDotComSignIn() + await this.appStore._showPopup({ type: PopupType.SignIn }) + } + + /** + * Launch a sign in dialog for authenticating a user with + * a GitHub Enterprise instance. + * Optionally, you can provide an endpoint URL. + */ + public async showEnterpriseSignInDialog(endpoint?: string): Promise { + await this.appStore._beginEnterpriseSignIn() + + if (endpoint !== undefined) { + await this.appStore._setSignInEndpoint(endpoint) + } + + await this.appStore._showPopup({ type: PopupType.SignIn }) + } + + /** + * Show a dialog that helps the user create a fork of + * their local repo. + */ + public async showCreateForkDialog( + repository: RepositoryWithGitHubRepository + ): Promise { + await this.appStore._showCreateForkDialog(repository) + } + + public async showUnknownAuthorsCommitWarning( + authors: ReadonlyArray, + onCommitAnyway: () => void + ) { + return this.appStore._showPopup({ + type: PopupType.UnknownAuthors, + authors, + onCommit: onCommitAnyway, + }) + } + + public async showRepoRulesCommitBypassWarning( + repository: GitHubRepository, + branch: string, + onConfirm: () => void + ) { + return this.appStore._showPopup({ + type: PopupType.ConfirmRepoRulesBypass, + repository, + branch, + onConfirm, + }) + } + + /** + * Register a new error handler. + * + * Error handlers are called in order starting with the most recently + * registered handler. The error which the returned {Promise} resolves to is + * passed to the next handler, etc. If the handler's {Promise} resolves to + * null, error propagation is halted. + */ + public registerErrorHandler(handler: ErrorHandler): Disposable { + this.errorHandlers.push(handler) + + return new Disposable(() => { + const i = this.errorHandlers.indexOf(handler) + if (i >= 0) { + this.errorHandlers.splice(i, 1) + } + }) + } + + /** + * Update the location of an existing repository and clear the missing flag. + */ + public async relocateRepository(repository: Repository): Promise { + const path = await showOpenDialog({ + properties: ['openDirectory'], + }) + + if (path !== null) { + await this.updateRepositoryPath(repository, path) + } + } + + /** + * Change the workflow preferences for the specified repository. + * + * @param repository The repository to update. + * @param workflowPreferences The object with the workflow settings to use. + */ + public async updateRepositoryWorkflowPreferences( + repository: Repository, + workflowPreferences: WorkflowPreferences + ) { + await this.appStore._updateRepositoryWorkflowPreferences( + repository, + workflowPreferences + ) + } + + /** Update the repository's path. */ + private async updateRepositoryPath( + repository: Repository, + path: string + ): Promise { + await this.appStore._updateRepositoryPath(repository, path) + } + + public async setAppFocusState(isFocused: boolean): Promise { + await this.appStore._setAppFocusState(isFocused) + + if (isFocused) { + this.commitStatusStore.startBackgroundRefresh() + } else { + this.commitStatusStore.stopBackgroundRefresh() + } + } + + public async initializeAppFocusState(): Promise { + const isFocused = await isWindowFocused() + this.setAppFocusState(isFocused) + } + + /** + * Find an existing repository that can be used for checking out + * the passed pull request. + * + * This method will try to find an opened repository that matches the + * HEAD repository of the PR first and if not found it will try to + * find an opened repository that matches the BASE repository of the PR. + * Matching in this context means that either the origin remote or the + * upstream remote url are equal to the PR ref repository URL. + * + * With this logic we try to select the best suited repository to open + * a PR when triggering a "Open PR from Desktop" action from a browser. + * + * @param pullRequest the pull request object received from the API. + */ + private getRepositoryFromPullRequest( + pullRequest: IAPIPullRequest + ): RepositoryWithGitHubRepository | null { + const state = this.appStore.getState() + const repositories = state.repositories + const headUrl = pullRequest.head.repo?.clone_url + const baseUrl = pullRequest.base.repo?.clone_url + + // This likely means that the base repository has been deleted + // and we don't support checking out from refs/pulls/NNN/head + // yet so we'll bail for now. + if (headUrl === undefined || baseUrl === undefined) { + return null + } + + for (const repository of repositories) { + if (this.doesRepositoryMatchUrl(repository, headUrl)) { + return repository + } + } + + for (const repository of repositories) { + if (this.doesRepositoryMatchUrl(repository, baseUrl)) { + return repository + } + } + + return null + } + + private doesRepositoryMatchUrl( + repo: Repository | CloningRepository, + url: string + ): repo is RepositoryWithGitHubRepository { + if (repo instanceof Repository && isRepositoryWithGitHubRepository(repo)) { + const originRepoUrl = repo.gitHubRepository.htmlURL + const upstreamRepoUrl = repo.gitHubRepository.parent?.htmlURL ?? null + + if (originRepoUrl !== null && urlsMatch(originRepoUrl, url)) { + return true + } + + if (upstreamRepoUrl !== null && urlsMatch(upstreamRepoUrl, url)) { + return true + } + } + + return false + } + + private async openRepositoryFromUrl(action: IOpenRepositoryFromURLAction) { + const { url, pr, branch, filepath } = action + + let repository: Repository | null + + if (pr !== null) { + repository = await this.openPullRequestFromUrl(url, pr) + } else if (branch !== null) { + repository = await this.openBranchNameFromUrl(url, branch) + } else { + repository = await this.openOrCloneRepository(url) + } + + if (repository === null) { + return + } + + if (filepath !== null) { + const resolved = await resolveWithin(repository.path, filepath) + + if (resolved !== null) { + shell.showItemInFolder(resolved) + } else { + log.error( + `Prevented attempt to open path outside of the repository root: ${filepath}` + ) + } + } + } + + private async openBranchNameFromUrl( + url: string, + branchName: string + ): Promise { + const repository = await this.openOrCloneRepository(url) + + if (repository === null) { + return null + } + + // ensure a fresh clone repository has it's in-memory state + // up-to-date before performing the "Clone in Desktop" steps + await this.appStore._refreshRepository(repository) + + // if the repo has a remote, fetch before switching branches to ensure + // the checkout will be successful. This operation could be a no-op. + await this.appStore._fetch(repository, FetchType.UserInitiatedTask) + + await this.checkoutLocalBranch(repository, branchName) + + return repository + } + + private async openPullRequestFromUrl( + url: string, + pr: string + ): Promise { + const pullRequest = await this.appStore.fetchPullRequest(url, pr) + + if (pullRequest === null) { + return null + } + + // Find the repository where the PR is created in Desktop. + let repository: Repository | null = + this.getRepositoryFromPullRequest(pullRequest) + + if (repository !== null) { + await this.selectRepository(repository) + } else { + repository = await this.openOrCloneRepository(url) + } + + if (repository === null) { + log.warn( + `Open Repository from URL failed, did not find or clone repository: ${url}` + ) + return null + } + if (!isRepositoryWithGitHubRepository(repository)) { + log.warn( + `Received a non-GitHub repository when opening repository from URL: ${url}` + ) + return null + } + + // ensure a fresh clone repository has it's in-memory state + // up-to-date before performing the "Clone in Desktop" steps + await this.appStore._refreshRepository(repository) + + if (pullRequest.head.repo === null) { + return null + } + + await this.appStore._checkoutPullRequest( + repository, + pullRequest.number, + pullRequest.head.repo.owner.login, + pullRequest.head.repo.clone_url, + pullRequest.head.ref + ) + + return repository + } + + public async dispatchURLAction(action: URLActionType): Promise { + switch (action.name) { + case 'oauth': + try { + log.info(`[Dispatcher] requesting authenticated user`) + const user = await requestAuthenticatedUser(action.code, action.state) + if (user) { + resolveOAuthRequest(user) + } else if (user === null) { + rejectOAuthRequest(new Error('Unable to fetch authenticated user.')) + } + } catch (e) { + rejectOAuthRequest(e) + } + + if (__DARWIN__) { + // workaround for user reports that the application doesn't receive focus + // after completing the OAuth signin in the browser + const isFocused = await isWindowFocused() + if (!isFocused) { + log.info( + `refocusing the main window after the OAuth flow is completed` + ) + window.focus() + } + } + break + + case 'open-repository-from-url': + this.openRepositoryFromUrl(action) + break + + case 'open-repository-from-path': + // user may accidentally provide a folder within the repository + // this ensures we use the repository root, if it is actually a repository + // otherwise we consider it an untracked repository + const path = await getRepositoryType(action.path) + .then(t => + t.kind === 'regular' ? t.topLevelWorkingDirectory : action.path + ) + .catch(e => { + log.error('Could not determine repository type', e) + return action.path + }) + + const { repositories } = this.appStore.getState() + const existingRepository = matchExistingRepository(repositories, path) + + if (existingRepository) { + await this.selectRepository(existingRepository) + this.statsStore.recordAddExistingRepository() + } else { + await this.showPopup({ type: PopupType.AddRepository, path }) + } + break + + default: + const unknownAction: IUnknownAction = action + log.warn( + `Unknown URL action: ${ + unknownAction.name + } - payload: ${JSON.stringify(unknownAction)}` + ) + } + } + + /** + * Sets the user's preference so that moving the app to /Applications is not asked + */ + public setAskToMoveToApplicationsFolderSetting( + value: boolean + ): Promise { + return this.appStore._setAskToMoveToApplicationsFolderSetting(value) + } + + /** + * Sets the user's preference so that confirmation to remove repo is not asked + */ + public setConfirmRepoRemovalSetting(value: boolean): Promise { + return this.appStore._setConfirmRepositoryRemovalSetting(value) + } + + /** + * Sets the user's preference so that confirmation to discard changes is not asked + */ + public setConfirmDiscardChangesSetting(value: boolean): Promise { + return this.appStore._setConfirmDiscardChangesSetting(value) + } + + /** + * Sets the user's preference so that confirmation to retry discard changes + * after failure is not asked + */ + public setConfirmDiscardChangesPermanentlySetting( + value: boolean + ): Promise { + return this.appStore._setConfirmDiscardChangesPermanentlySetting(value) + } + + /** + * Sets the user's preference for handling uncommitted changes when switching branches + */ + public setUncommittedChangesStrategySetting( + value: UncommittedChangesStrategy + ): Promise { + return this.appStore._setUncommittedChangesStrategySetting(value) + } + + /** + * Sets the user's preference for an external program to open repositories in. + */ + public setExternalEditor(editor: string): Promise { + return this.appStore._setExternalEditor(editor) + } + + /** + * Sets the user's preferred shell. + */ + public setShell(shell: Shell): Promise { + return this.appStore._setShell(shell) + } + + private async checkoutLocalBranch(repository: Repository, branch: string) { + let shouldCheckoutBranch = true + + const state = this.repositoryStateManager.get(repository) + const branches = state.branchesState.allBranches + + const { tip } = state.branchesState + + if (tip.kind === TipState.Valid) { + shouldCheckoutBranch = tip.branch.nameWithoutRemote !== branch + } + + const localBranch = branches.find(b => b.nameWithoutRemote === branch) + + // N.B: This looks weird, and it is. _checkoutBranch used + // to behave this way (silently ignoring checkout) when given + // a branch name string that does not correspond to a local branch + // in the git store. When rewriting _checkoutBranch + // to remove the support for string branch names the behavior + // was moved up to this method to not alter the current behavior. + // + // https://youtu.be/IjmtVKOAHPM + if (shouldCheckoutBranch && localBranch !== undefined) { + await this.checkoutBranch(repository, localBranch) + } + } + + private async openOrCloneRepository(url: string): Promise { + const state = this.appStore.getState() + const repositories = state.repositories + const existingRepository = repositories.find(r => + this.doesRepositoryMatchUrl(r, url) + ) + + if (existingRepository) { + return await this.selectRepository(existingRepository) + } + + return this.appStore._startOpenInDesktop(() => { + this.changeCloneRepositoriesTab(CloneRepositoryTab.Generic) + this.showPopup({ + type: PopupType.CloneRepository, + initialURL: url, + }) + }) + } + + public async openOrAddRepository(path: string): Promise { + const state = this.appStore.getState() + const repositories = state.repositories + const existingRepository = repositories.find(r => r.path === path) + + if (existingRepository) { + return await this.selectRepository(existingRepository) + } + + return this.appStore._startOpenInDesktop(() => { + this.showPopup({ + type: PopupType.AddRepository, + path, + }) + }) + } + + /** + * Install the CLI tool. + * + * This is used only on macOS. + */ + public async installCLI() { + try { + await installCLI() + + this.showPopup({ type: PopupType.CLIInstalled }) + } catch (e) { + log.error('Error installing CLI', e) + + this.postError(e) + } + } + + /** Prompt the user to authenticate for a generic git server. */ + public promptForGenericGitAuthentication( + repository: Repository | CloningRepository, + retry: RetryAction + ): Promise { + return this.appStore.promptForGenericGitAuthentication(repository, retry) + } + + /** Save the generic git credentials. */ + public async saveGenericGitCredentials( + hostname: string, + username: string, + password: string + ): Promise { + log.info(`storing generic credentials for '${hostname}' and '${username}'`) + setGenericUsername(hostname, username) + + try { + await setGenericPassword(hostname, username, password) + } catch (e) { + log.error( + `Error saving generic git credentials: ${username}@${hostname}`, + e + ) + + this.postError(e) + } + } + + /** Perform the given retry action. */ + public async performRetry(retryAction: RetryAction): Promise { + switch (retryAction.type) { + case RetryActionType.Push: + return this.push(retryAction.repository) + + case RetryActionType.Pull: + return this.pull(retryAction.repository) + + case RetryActionType.Fetch: + return this.fetch(retryAction.repository, FetchType.UserInitiatedTask) + + case RetryActionType.Clone: + await this.clone(retryAction.url, retryAction.path, retryAction.options) + break + + case RetryActionType.Checkout: + await this.checkoutBranch(retryAction.repository, retryAction.branch) + break + + case RetryActionType.Merge: + return this.mergeBranch( + retryAction.repository, + retryAction.theirBranch, + null + ) + + case RetryActionType.Rebase: + return this.rebase( + retryAction.repository, + retryAction.baseBranch, + retryAction.targetBranch + ) + case RetryActionType.CherryPick: + return this.cherryPick( + retryAction.repository, + retryAction.targetBranch, + retryAction.commits, + retryAction.sourceBranch + ) + case RetryActionType.CreateBranchForCherryPick: + return this.startCherryPickWithBranchName( + retryAction.repository, + retryAction.targetBranchName, + retryAction.startPoint, + retryAction.noTrackOption, + retryAction.commits, + retryAction.sourceBranch + ) + case RetryActionType.Squash: + return this.squash( + retryAction.repository, + retryAction.toSquash, + retryAction.squashOnto, + retryAction.lastRetainedCommitRef, + retryAction.commitContext + ) + case RetryActionType.Reorder: + return this.reorderCommits( + retryAction.repository, + retryAction.commitsToReorder, + retryAction.beforeCommit, + retryAction.lastRetainedCommitRef + ) + case RetryActionType.DiscardChanges: + return this.discardChanges( + retryAction.repository, + retryAction.files, + false + ) + default: + return assertNever(retryAction, `Unknown retry action: ${retryAction}`) + } + } + + /** Change the selected image diff type. */ + public changeImageDiffType(type: ImageDiffType): Promise { + return this.appStore._changeImageDiffType(type) + } + + /** Change the hide whitespace in changes diff setting */ + public onHideWhitespaceInChangesDiffChanged( + hideWhitespaceInDiff: boolean, + repository: Repository + ): Promise { + return this.appStore._setHideWhitespaceInChangesDiff( + hideWhitespaceInDiff, + repository + ) + } + + /** Change the hide whitespace in history diff setting */ + public onHideWhitespaceInHistoryDiffChanged( + hideWhitespaceInDiff: boolean, + repository: Repository, + file: CommittedFileChange | null = null + ): Promise { + return this.appStore._setHideWhitespaceInHistoryDiff( + hideWhitespaceInDiff, + repository, + file + ) + } + + /** Change the hide whitespace in pull request diff setting */ + public onHideWhitespaceInPullRequestDiffChanged( + hideWhitespaceInDiff: boolean, + repository: Repository, + file: CommittedFileChange | null = null + ) { + this.appStore._setHideWhitespaceInPullRequestDiff( + hideWhitespaceInDiff, + repository, + file + ) + } + + /** Change the side by side diff setting */ + public onShowSideBySideDiffChanged(showSideBySideDiff: boolean) { + return this.appStore._setShowSideBySideDiff(showSideBySideDiff) + } + + /** Install the global Git LFS filters. */ + public installGlobalLFSFilters(force: boolean): Promise { + return this.appStore._installGlobalLFSFilters(force) + } + + /** Install the LFS filters */ + public installLFSHooks( + repositories: ReadonlyArray + ): Promise { + return this.appStore._installLFSHooks(repositories) + } + + /** Change the selected Clone Repository tab. */ + public changeCloneRepositoriesTab(tab: CloneRepositoryTab): Promise { + return this.appStore._changeCloneRepositoriesTab(tab) + } + + /** + * Request a refresh of the list of repositories that + * the provided account has explicit permissions to access. + * See ApiRepositoriesStore for more details. + */ + public refreshApiRepositories(account: Account) { + return this.appStore._refreshApiRepositories(account) + } + + /** Change the selected Branches foldout tab. */ + public changeBranchesTab(tab: BranchesTab): Promise { + return this.appStore._changeBranchesTab(tab) + } + + /** + * Open the Explore page at the GitHub instance of this repository + */ + public showGitHubExplore(repository: Repository): Promise { + return this.appStore._showGitHubExplore(repository) + } + + /** + * Open the Create Pull Request page on GitHub after verifying ahead/behind. + * + * Note that this method will present the user with a dialog in case the + * current branch in the repository is ahead or behind the remote. + * The dialog lets the user choose whether get in sync with the remote + * or open the PR anyway. This is distinct from the + * openCreatePullRequestInBrowser method which immediately opens the + * create pull request page without showing a dialog. + */ + public createPullRequest( + repository: Repository, + baseBranch?: Branch + ): Promise { + return this.appStore._createPullRequest(repository, baseBranch) + } + + /** + * Show the current pull request on github.com + */ + public showPullRequest(repository: Repository): Promise { + return this.appStore._showPullRequest(repository) + } + + /** + * Open a browser and navigate to the provided pull request + */ + public async showPullRequestByPR(pr: PullRequest): Promise { + return this.appStore._showPullRequestByPR(pr) + } + + /** + * Immediately open the Create Pull Request page on GitHub. + * + * See the createPullRequest method for more details. + */ + public openCreatePullRequestInBrowser( + repository: Repository, + branch: Branch + ): Promise { + return this.appStore._openCreatePullRequestInBrowser(repository, branch) + } + + /** + * Update the existing `upstream` remote to point to the repository's parent. + */ + public updateExistingUpstreamRemote(repository: Repository): Promise { + return this.appStore._updateExistingUpstreamRemote(repository) + } + + /** Ignore the existing `upstream` remote. */ + public ignoreExistingUpstreamRemote(repository: Repository): Promise { + return this.appStore._ignoreExistingUpstreamRemote(repository) + } + + /** Checks out a PR whose ref exists locally or in a forked repo. */ + public async checkoutPullRequest( + repository: RepositoryWithGitHubRepository, + pullRequest: PullRequest + ): Promise { + if (pullRequest.head.gitHubRepository.cloneURL === null) { + return + } + + return this.appStore._checkoutPullRequest( + repository, + pullRequest.pullRequestNumber, + pullRequest.head.gitHubRepository.owner.login, + pullRequest.head.gitHubRepository.cloneURL, + pullRequest.head.ref + ) + } + + /** + * Set whether the user has chosen to hide or show the + * co-authors field in the commit message component + * + * @param repository Co-author settings are per-repository + */ + public setShowCoAuthoredBy( + repository: Repository, + showCoAuthoredBy: boolean + ) { + return this.appStore._setShowCoAuthoredBy(repository, showCoAuthoredBy) + } + + /** + * Update the per-repository co-authors list + * + * @param repository Co-author settings are per-repository + * @param coAuthors Zero or more authors + */ + public setCoAuthors( + repository: Repository, + coAuthors: ReadonlyArray + ) { + return this.appStore._setCoAuthors(repository, coAuthors) + } + + /** + * Initialize the compare state for the current repository. + */ + public initializeCompare( + repository: Repository, + initialAction?: CompareAction + ) { + return this.appStore._initializeCompare(repository, initialAction) + } + + /** + * Update the compare state for the current repository + */ + public executeCompare(repository: Repository, action: CompareAction) { + return this.appStore._executeCompare(repository, action) + } + + /** Update the compare form state for the current repository */ + public updateCompareForm( + repository: Repository, + newState: Pick + ) { + return this.appStore._updateCompareForm(repository, newState) + } + + /** + * update the manual resolution method for a file + */ + public updateManualConflictResolution( + repository: Repository, + path: string, + manualResolution: ManualConflictResolution | null + ) { + return this.appStore._updateManualConflictResolution( + repository, + path, + manualResolution + ) + } + + public async confirmOrForcePush(repository: Repository) { + const { askForConfirmationOnForcePush } = this.appStore.getState() + + const { branchesState } = this.repositoryStateManager.get(repository) + const { tip } = branchesState + + if (tip.kind !== TipState.Valid) { + log.warn(`Could not find a branch to perform force push`) + return + } + + const { upstream } = tip.branch + + if (upstream === null) { + log.warn(`Could not find an upstream branch which will be pushed`) + return + } + + if (askForConfirmationOnForcePush) { + this.showPopup({ + type: PopupType.ConfirmForcePush, + repository, + upstreamBranch: upstream, + }) + } else { + await this.performForcePush(repository) + } + } + + public async performForcePush(repository: Repository) { + await this.pushWithOptions(repository, { + forceWithLease: true, + }) + + await this.appStore._loadStatus(repository) + } + + public setConfirmDiscardStashSetting(value: boolean) { + return this.appStore._setConfirmDiscardStashSetting(value) + } + + public setConfirmCheckoutCommitSetting(value: boolean) { + return this.appStore._setConfirmCheckoutCommitSetting(value) + } + + public setConfirmForcePushSetting(value: boolean) { + return this.appStore._setConfirmForcePushSetting(value) + } + + public setConfirmUndoCommitSetting(value: boolean) { + return this.appStore._setConfirmUndoCommitSetting(value) + } + + /** + * Converts a local repository to use the given fork + * as its default remote and associated `GitHubRepository`. + */ + public async convertRepositoryToFork( + repository: RepositoryWithGitHubRepository, + fork: IAPIFullRepository + ): Promise { + return this.appStore._convertRepositoryToFork(repository, fork) + } + + /** + * Updates the application state to indicate a conflict is in-progress + * as a result of a pull and increments the relevant metric. + */ + public mergeConflictDetectedFromPull() { + return this.statsStore.recordMergeConflictFromPull() + } + + /** + * Updates the application state to indicate a conflict is in-progress + * as a result of a merge and increments the relevant metric. + */ + public mergeConflictDetectedFromExplicitMerge() { + return this.statsStore.recordMergeConflictFromExplicitMerge() + } + + /** Increments the `openSubmoduleFromDiffCount` metric */ + public recordOpenSubmoduleFromDiffCount() { + return this.statsStore.recordOpenSubmoduleFromDiffCount() + } + + /** + * Increments the `mergeIntoCurrentBranchMenuCount` metric + */ + public recordMenuInitiatedMerge(isSquash: boolean = true) { + return this.statsStore.recordMenuInitiatedMerge(isSquash) + } + + /** + * Increments the `rebaseIntoCurrentBranchMenuCount` metric + */ + public recordMenuInitiatedRebase() { + return this.statsStore.recordMenuInitiatedRebase() + } + + /** + * Increments the `updateFromDefaultBranchMenuCount` metric + */ + public recordMenuInitiatedUpdate() { + return this.statsStore.recordMenuInitiatedUpdate() + } + + /** + * Increments the `mergesInitiatedFromComparison` metric + */ + public recordCompareInitiatedMerge() { + return this.statsStore.recordCompareInitiatedMerge() + } + + /** + * Set the application-wide theme + */ + public setSelectedTheme(theme: ApplicationTheme) { + return this.appStore._setSelectedTheme(theme) + } + + /** + * Increments either the `repoWithIndicatorClicked` or + * the `repoWithoutIndicatorClicked` metric + */ + public recordRepoClicked(repoHasIndicator: boolean) { + return this.statsStore.recordRepoClicked(repoHasIndicator) + } + + /** + * Increments the `createPullRequestCount` metric + */ + public recordCreatePullRequest() { + return this.statsStore.recordCreatePullRequest() + } + + public recordCreatePullRequestFromPreview() { + return this.statsStore.recordCreatePullRequestFromPreview() + } + + public recordWelcomeWizardInitiated() { + return this.statsStore.recordWelcomeWizardInitiated() + } + + public recordCreateRepository() { + this.statsStore.recordCreateRepository() + } + + public recordAddExistingRepository() { + this.statsStore.recordAddExistingRepository() + } + + /** + * Increments the `mergeConflictsDialogDismissalCount` metric + */ + public recordMergeConflictsDialogDismissal() { + this.statsStore.recordMergeConflictsDialogDismissal() + } + + /** + * Increments the `mergeConflictsDialogReopenedCount` metric + */ + public recordMergeConflictsDialogReopened() { + this.statsStore.recordMergeConflictsDialogReopened() + } + + /** + * Increments the `anyConflictsLeftOnMergeConflictsDialogDismissalCount` metric + */ + public recordAnyConflictsLeftOnMergeConflictsDialogDismissal() { + this.statsStore.recordAnyConflictsLeftOnMergeConflictsDialogDismissal() + } + + /** + * Increments the `guidedConflictedMergeCompletionCount` metric + */ + public recordGuidedConflictedMergeCompletion() { + this.statsStore.recordGuidedConflictedMergeCompletion() + } + + /** + * Increments the `unguidedConflictedMergeCompletionCount` metric + */ + public recordUnguidedConflictedMergeCompletion() { + this.statsStore.recordUnguidedConflictedMergeCompletion() + } + + // TODO: more rebase-related actions + + /** + * Increments the `rebaseConflictsDialogDismissalCount` metric + */ + public recordRebaseConflictsDialogDismissal() { + this.statsStore.recordRebaseConflictsDialogDismissal() + } + + /** + * Increments the `rebaseConflictsDialogReopenedCount` metric + */ + public recordRebaseConflictsDialogReopened() { + this.statsStore.recordRebaseConflictsDialogReopened() + } + + /** Increments the `errorWhenSwitchingBranchesWithUncommmittedChanges` metric */ + public recordErrorWhenSwitchingBranchesWithUncommmittedChanges() { + return this.statsStore.recordErrorWhenSwitchingBranchesWithUncommmittedChanges() + } + + /** + * Refresh the list of open pull requests for the given repository. + */ + public refreshPullRequests(repository: Repository): Promise { + return this.appStore._refreshPullRequests(repository) + } + + /** + * Attempt to retrieve a commit status for a particular + * ref. If the ref doesn't exist in the cache this function returns null. + * + * Useful for component who wish to have a value for the initial render + * instead of waiting for the subscription to produce an event. + */ + public tryGetCommitStatus( + repository: GitHubRepository, + ref: string, + branchName?: string + ): ICombinedRefCheck | null { + return this.commitStatusStore.tryGetStatus(repository, ref, branchName) + } + + /** + * Subscribe to commit status updates for a particular ref. + * + * @param repository The GitHub repository to use when looking up commit status. + * @param ref The commit ref (can be a SHA or a Git ref) for which to + * fetch status. + * @param callback A callback which will be invoked whenever the + * store updates a commit status for the given ref. + * @param branchName If we want to retrieve action workflow checks with the + * sub, we provide the branch name for it. + */ + public subscribeToCommitStatus( + repository: GitHubRepository, + ref: string, + callback: StatusCallBack, + branchName?: string + ): DisposableLike { + return this.commitStatusStore.subscribe( + repository, + ref, + callback, + branchName + ) + } + + /** + * Invoke a manual refresh of the status for a particular ref + */ + public manualRefreshSubscription( + repository: GitHubRepository, + ref: string, + pendingChecks: ReadonlyArray + ): Promise { + return this.commitStatusStore.manualRefreshSubscription( + repository, + ref, + pendingChecks + ) + } + + /** + * Triggers GitHub to rerequest a list of check suites, without pushing new + * code to a repository. + */ + public async rerequestCheckSuites( + repository: GitHubRepository, + checkRuns: ReadonlyArray, + failedOnly: boolean + ): Promise> { + const promises = new Array>() + + // If it is one and in actions check, we can rerun it individually. + if (checkRuns.length === 1 && checkRuns[0].actionsWorkflow !== undefined) { + promises.push( + this.commitStatusStore.rerunJob(repository, checkRuns[0].id) + ) + return Promise.all(promises) + } + + const checkSuiteIds = new Set() + const workflowRunIds = new Set() + for (const cr of checkRuns) { + if (failedOnly && cr.actionsWorkflow !== undefined) { + workflowRunIds.add(cr.actionsWorkflow.id) + continue + } + + // There could still be failed ones that are not action and only way to + // rerun them is to rerun their whole check suite + if (cr.checkSuiteId !== null) { + checkSuiteIds.add(cr.checkSuiteId) + } + } + + for (const id of workflowRunIds) { + promises.push(this.commitStatusStore.rerunFailedJobs(repository, id)) + } + + for (const id of checkSuiteIds) { + promises.push(this.commitStatusStore.rerequestCheckSuite(repository, id)) + } + + return Promise.all(promises) + } + + /** + * Gets a single check suite using its id + */ + public async fetchCheckSuite( + repository: GitHubRepository, + checkSuiteId: number + ): Promise { + return this.commitStatusStore.fetchCheckSuite(repository, checkSuiteId) + } + + /** + * Creates a stash for the current branch. Note that this will + * override any stash that already exists for the current branch. + * + * @param repository + * @param showConfirmationDialog Whether to show a confirmation dialog if an + * existing stash exists (defaults to true). + */ + public createStashForCurrentBranch( + repository: Repository, + showConfirmationDialog: boolean = true + ) { + return this.appStore._createStashForCurrentBranch( + repository, + showConfirmationDialog + ) + } + + /** Drops the given stash in the given repository */ + public dropStash(repository: Repository, stashEntry: IStashEntry) { + return this.appStore._dropStashEntry(repository, stashEntry) + } + + /** Pop the given stash in the given repository */ + public popStash(repository: Repository, stashEntry: IStashEntry) { + return this.appStore._popStashEntry(repository, stashEntry) + } + + /** + * Set the width of the commit summary column in the + * history view to the given value. + */ + public setStashedFilesWidth = (width: number): Promise => { + return this.appStore._setStashedFilesWidth(width) + } + + /** + * Reset the width of the commit summary column in the + * history view to its default value. + */ + public resetStashedFilesWidth = (): Promise => { + return this.appStore._resetStashedFilesWidth() + } + + /** Hide the diff for stashed changes */ + public hideStashedChanges(repository: Repository) { + return this.appStore._hideStashedChanges(repository) + } + + /** + * Increment the number of times the user has opened their external editor + * from the suggested next steps view + */ + public recordSuggestedStepOpenInExternalEditor(): Promise { + return this.statsStore.recordSuggestedStepOpenInExternalEditor() + } + + /** + * 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.statsStore.recordSuggestedStepOpenWorkingDirectory() + } + + /** + * Increment the number of times the user has opened their repository on + * GitHub from the suggested next steps view + */ + public recordSuggestedStepViewOnGitHub(): Promise { + return this.statsStore.recordSuggestedStepViewOnGitHub() + } + + /** + * Increment the number of times the user has used the publish repository + * action from the suggested next steps view + */ + public recordSuggestedStepPublishRepository(): Promise { + return this.statsStore.recordSuggestedStepPublishRepository() + } + + /** + * 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.statsStore.recordSuggestedStepPublishBranch() + } + + /** + * Increment the number of times the user has used the Create PR suggestion + * in the suggested next steps view. + */ + public recordSuggestedStepCreatePullRequest(): Promise { + return this.statsStore.recordSuggestedStepCreatePullRequest() + } + + /** + * Increment the number of times the user has used the View Stash suggestion + * in the suggested next steps view. + */ + public recordSuggestedStepViewStash(): Promise { + return this.statsStore.recordSuggestedStepViewStash() + } + + /** Record when the user takes no action on the stash entry */ + public recordNoActionTakenOnStash(): Promise { + return this.statsStore.recordNoActionTakenOnStash() + } + + /** Record when the user views the stash entry */ + public recordStashView(): Promise { + return this.statsStore.recordStashView() + } + + /** Call when the user opts to skip the pick editor step of the onboarding tutorial */ + public skipPickEditorTutorialStep(repository: Repository) { + return this.appStore._skipPickEditorTutorialStep(repository) + } + + /** + * Call when the user has either created a pull request or opts to + * skip the create pull request step of the onboarding tutorial + */ + public markPullRequestTutorialStepAsComplete(repository: Repository) { + return this.appStore._markPullRequestTutorialStepAsComplete(repository) + } + + /** + * Increments the `forksCreated ` metric` indicating that the user has + * elected to create a fork when presented with a dialog informing + * them that they don't have write access to the current repository. + */ + public recordForkCreated() { + return this.statsStore.recordForkCreated() + } + + /** + * Create a tutorial repository using the given account. The account + * determines which host (i.e. GitHub.com or a GHES instance) that + * the tutorial repository should be created on. + * + * @param account The account (and thereby the GitHub host) under + * which the repository is to be created created + */ + public createTutorialRepository(account: Account) { + return this.appStore._createTutorialRepository(account) + } + + /** Open the issue creation page for a GitHub repository in a browser */ + public async openIssueCreationPage(repository: Repository): Promise { + // Default to creating issue on parent repo + // See https://github.com/desktop/desktop/issues/9232 for rationale + const url = getGitHubHtmlUrl(repository) + if (url !== null) { + this.statsStore.recordIssueCreationWebpageOpened() + return this.appStore._openInBrowser(`${url}/issues/new/choose`) + } else { + return false + } + } + + public setRepositoryIndicatorsEnabled(repositoryIndicatorsEnabled: boolean) { + this.appStore._setRepositoryIndicatorsEnabled(repositoryIndicatorsEnabled) + } + + public setCommitSpellcheckEnabled(commitSpellcheckEnabled: boolean) { + this.appStore._setCommitSpellcheckEnabled(commitSpellcheckEnabled) + } + + public setUseWindowsOpenSSH(useWindowsOpenSSH: boolean) { + this.appStore._setUseWindowsOpenSSH(useWindowsOpenSSH) + } + + public setNotificationsEnabled(notificationsEnabled: boolean) { + this.appStore._setNotificationsEnabled(notificationsEnabled) + } + + public recordDiffOptionsViewed() { + return this.statsStore.recordDiffOptionsViewed() + } + + private logHowToRevertCherryPick( + targetBranchName: string, + beforeSha: string | null + ) { + log.info( + `[cherryPick] starting cherry-pick for ${targetBranchName} at ${beforeSha}` + ) + log.info( + `[cherryPick] to restore the previous state if this completed cherry-pick is unsatisfactory:` + ) + log.info(`[cherryPick] - git checkout ${targetBranchName}`) + log.info(`[cherryPick] - git reset ${beforeSha} --hard`) + } + + /** Initializes multi commit operation state for cherry pick if it is null */ + public initializeMultiCommitOperationStateCherryPick( + repository: Repository, + targetBranch: Branch, + commits: ReadonlyArray, + sourceBranch: Branch | null + ): void { + if ( + this.repositoryStateManager.get(repository).multiCommitOperationState !== + null + ) { + return + } + + this.initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.CherryPick, + sourceBranch, + branchCreated: false, + commits, + }, + targetBranch, + commits, + sourceBranch?.tip.sha ?? null + ) + } + + /** Starts a cherry pick of the given commits onto the target branch */ + public async cherryPick( + repository: Repository, + targetBranch: Branch, + commits: ReadonlyArray, + sourceBranch: Branch | null + ): Promise { + // If uncommitted changes are stashed, we had to clear the multi commit + // operation in case user hit cancel. (This method only sets it, if it null) + this.initializeMultiCommitOperationStateCherryPick( + repository, + targetBranch, + commits, + sourceBranch + ) + + this.appStore._initializeCherryPickProgress(repository, commits) + this.switchMultiCommitOperationToShowProgress(repository) + + const retry: RetryAction = { + type: RetryActionType.CherryPick, + repository, + targetBranch, + commits, + sourceBranch, + } + + if (this.appStore._checkForUncommittedChanges(repository, retry)) { + this.endMultiCommitOperation(repository) + return + } + + const { tip } = targetBranch + this.repositoryStateManager.updateMultiCommitOperationUndoState( + repository, + () => ({ + undoSha: tip.sha, + branchName: targetBranch.name, + }) + ) + + if (commits.length > 1) { + this.statsStore.recordCherryPickMultipleCommits() + } + + const nameAfterCheckout = await this.appStore._checkoutBranchReturnName( + repository, + targetBranch + ) + + if (nameAfterCheckout === undefined) { + log.error('[cherryPick] - Failed to check out the target branch.') + this.endMultiCommitOperation(repository) + return + } + + const result = await this.appStore._cherryPick(repository, commits) + + if (result !== CherryPickResult.UnableToStart) { + this.logHowToRevertCherryPick(nameAfterCheckout, tip.sha) + } + + this.processCherryPickResult( + repository, + result, + nameAfterCheckout, + commits, + sourceBranch + ) + } + + public async startCherryPickWithBranchName( + repository: Repository, + targetBranchName: string, + startPoint: string | null, + noTrackOption: boolean = false, + commits: ReadonlyArray, + sourceBranch: Branch | null + ): Promise { + const retry: RetryAction = { + type: RetryActionType.CreateBranchForCherryPick, + repository, + targetBranchName, + startPoint, + noTrackOption, + commits, + sourceBranch, + } + + if (this.appStore._checkForUncommittedChanges(repository, retry)) { + this.endMultiCommitOperation(repository) + return + } + + const targetBranch = await this.appStore._createBranch( + repository, + targetBranchName, + startPoint, + noTrackOption, + false + ) + + if (targetBranch === undefined) { + log.error( + '[startCherryPickWithBranchName] - Unable to create branch for cherry-pick operation' + ) + this.endMultiCommitOperation(repository) + return + } + + // If uncommitted changes are stashed, we had to clear the multi commit + // operation in case user hit cancel. (This method only sets it, if it null) + this.initializeMultiCommitOperationStateCherryPick( + repository, + targetBranch, + commits, + sourceBranch + ) + this.appStore._setMultiCommitOperationTargetBranch(repository, targetBranch) + this.appStore._setCherryPickBranchCreated(repository, true) + this.statsStore.recordCherryPickBranchCreatedCount() + return this.cherryPick(repository, targetBranch, commits, sourceBranch) + } + + /** + * This method starts a cherry pick after drag and dropping on a branch. + * It needs to: + * - get the current branch, + * - get the commits dragged from cherry picking state + * - invoke popup + * - invoke cherry pick + */ + public async startCherryPickWithBranch( + repository: Repository, + targetBranch: Branch + ): Promise { + const { branchesState } = this.repositoryStateManager.get(repository) + + const { dragData } = dragAndDropManager + if (dragData == null || dragData.type !== DragType.Commit) { + log.error( + '[cherryPick] Invalid Cherry-picking State: Could not determine selected commits.' + ) + this.endMultiCommitOperation(repository) + return + } + + const { tip } = branchesState + if (tip.kind !== TipState.Valid) { + this.endMultiCommitOperation(repository) + throw new Error( + 'Tip is not in a valid state, which is required to start the cherry-pick flow.' + ) + } + const sourceBranch = tip.branch + const { commits } = dragData + + this.initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.CherryPick, + sourceBranch, + branchCreated: false, + commits, + }, + targetBranch, + commits, + tip.branch.tip.sha + ) + + this.showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + + this.statsStore.recordCherryPickViaDragAndDrop() + this.setCherryPickBranchCreated(repository, false) + this.cherryPick(repository, targetBranch, commits, sourceBranch) + } + + /** + * Method to start a cherry-pick after drag and dropping onto a pull request. + */ + public async startCherryPickWithPullRequest( + repository: RepositoryWithGitHubRepository, + pullRequest: PullRequest + ) { + const { pullRequestNumber, head } = pullRequest + const { ref, gitHubRepository } = head + const { + cloneURL, + owner: { login }, + } = gitHubRepository + + let targetBranch + if (cloneURL !== null) { + targetBranch = await this.appStore._findPullRequestBranch( + repository, + pullRequestNumber, + login, + cloneURL, + ref + ) + } + + if (targetBranch === undefined) { + log.error( + '[cherryPick] Could not determine target branch for cherry-pick operation - aborting cherry-pick.' + ) + this.endMultiCommitOperation(repository) + return + } + + return this.startCherryPickWithBranch(repository, targetBranch) + } + + /** + * Continue with the cherryPick after the user has resolved all conflicts with + * tracked files in the working directory. + */ + public async continueCherryPick( + repository: Repository, + files: ReadonlyArray, + conflictsState: CherryPickConflictState, + commits: ReadonlyArray, + sourceBranch: Branch | null + ): Promise { + await this.switchMultiCommitOperationToShowProgress(repository) + + const result = await this.appStore._continueCherryPick( + repository, + files, + conflictsState.manualResolutions + ) + + if (result === CherryPickResult.CompletedWithoutError) { + this.statsStore.recordCherryPickSuccessfulWithConflicts() + } + + this.processCherryPickResult( + repository, + result, + conflictsState.targetBranchName, + commits, + sourceBranch + ) + } + + /** + * Obtains the current app conflict state and switches cherry pick flow to + * show conflicts step + */ + private startConflictCherryPickFlow(repository: Repository): void { + const { changesState, multiCommitOperationState } = + this.repositoryStateManager.get(repository) + const { conflictState } = changesState + + if ( + conflictState === null || + !isCherryPickConflictState(conflictState) || + multiCommitOperationState == null || + multiCommitOperationState.operationDetail.kind !== + MultiCommitOperationKind.CherryPick + ) { + log.error( + '[cherryPick] - conflict state was null or not in a cherry-pick conflict state - unable to continue' + ) + this.endMultiCommitOperation(repository) + return + } + + const { sourceBranch } = multiCommitOperationState.operationDetail + + this.setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.ShowConflicts, + conflictState: { + kind: 'multiCommitOperation', + manualResolutions: conflictState.manualResolutions, + ourBranch: conflictState.targetBranchName, + theirBranch: sourceBranch !== null ? sourceBranch.name : undefined, + }, + }) + + this.statsStore.recordCherryPickConflictsEncountered() + } + + /** Aborts an ongoing cherry pick and switches back to the source branch. */ + public async abortCherryPick( + repository: Repository, + sourceBranch: Branch | null + ) { + await this.appStore._abortCherryPick(repository, sourceBranch) + await this.appStore._loadStatus(repository) + this.endMultiCommitOperation(repository) + await this.refreshRepository(repository) + } + + /** + * Moves multi commit operation step to progress and defers to allow user to + * see the progress dialog instead of suddenly appearing + * and disappearing again. + */ + public async switchMultiCommitOperationToShowProgress( + repository: Repository + ) { + this.setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.ShowProgress, + }) + await sleep(500) + } + + /** + * Processes the cherry pick result. + * 1. Completes the cherry pick with banner if successful. + * 2. Moves cherry pick flow if conflicts. + * 3. Handles errors. + */ + private async processCherryPickResult( + repository: Repository, + cherryPickResult: CherryPickResult, + targetBranchName: string, + commits: ReadonlyArray, + sourceBranch: Branch | null + ): Promise { + // This will update the conflict state of the app. This is needed to start + // conflict flow if cherry pick results in conflict. + await this.appStore._loadStatus(repository) + + switch (cherryPickResult) { + case CherryPickResult.CompletedWithoutError: + await this.changeCommitSelection(repository, [commits[0].sha], true) + await this.completeMultiCommitOperation(repository, commits.length) + break + case CherryPickResult.ConflictsEncountered: + this.startConflictCherryPickFlow(repository) + break + case CherryPickResult.UnableToStart: + // This is an expected error such as not being able to checkout the + // target branch which means the cherry pick operation never started or + // was cleanly aborted. + this.endMultiCommitOperation(repository) + break + default: + // If the user closes error dialog and tries to cherry pick again, it + // will fail again due to ongoing cherry pick. Thus, if we get to an + // unhandled error state, we want to abort any ongoing cherry pick. + // A known error is if a user attempts to cherry pick a merge commit. + this.appStore._clearCherryPickingHead(repository, sourceBranch) + this.endMultiCommitOperation(repository) + this.appStore._closePopup() + } + } + + /** + * Update the cherry pick progress in application state by querying the Git + * repository state. + */ + public setCherryPickProgressFromState(repository: Repository) { + return this.appStore._setCherryPickProgressFromState(repository) + } + + /** Method to record cherry pick initiated via the context menu. */ + public recordCherryPickViaContextMenu() { + this.statsStore.recordCherryPickViaContextMenu() + } + + /** Method to record an operation started via drag and drop and canceled. */ + public recordDragStartedAndCanceled() { + this.statsStore.recordDragStartedAndCanceled() + } + + /** Method to set the drag element */ + public setDragElement(dragElement: DragElement): void { + this.appStore._setDragElement(dragElement) + } + + /** Method to clear the drag element */ + public clearDragElement(): void { + this.appStore._setDragElement(null) + } + + /** Set Cherry Pick Flow Step For Create Branch */ + public async setCherryPickCreateBranchFlowStep( + repository: Repository, + targetBranchName: string, + commits: ReadonlyArray, + sourceBranch: Branch | null + ): Promise { + const { branchesState } = this.repositoryStateManager.get(repository) + const { defaultBranch, upstreamDefaultBranch, allBranches, tip } = + branchesState + + if (tip.kind !== TipState.Valid) { + this.appStore._clearCherryPickingHead(repository, null) + this.endMultiCommitOperation(repository) + log.error('Tip is in unknown state. Cherry-pick aborted.') + return + } + + const isGHRepo = isRepositoryWithGitHubRepository(repository) + const upstreamGhRepo = isGHRepo + ? getNonForkGitHubRepository(repository as RepositoryWithGitHubRepository) + : null + + this.initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.CherryPick, + sourceBranch, + branchCreated: true, + commits, + }, + null, + commits, + tip.branch.tip.sha + ) + + const step: CreateBranchStep = { + kind: MultiCommitOperationStepKind.CreateBranch, + allBranches, + defaultBranch, + upstreamDefaultBranch, + upstreamGhRepo, + tip, + targetBranchName, + } + + return this.appStore._setMultiCommitOperationStep(repository, step) + } + + /** Set the multi commit operation target branch */ + public setMultiCommitOperationTargetBranch( + repository: Repository, + targetBranch: Branch + ): void { + this.repositoryStateManager.updateMultiCommitOperationState( + repository, + () => ({ + targetBranch, + }) + ) + } + + /** Set cherry-pick branch created state */ + public setCherryPickBranchCreated( + repository: Repository, + branchCreated: boolean + ): void { + this.appStore._setCherryPickBranchCreated(repository, branchCreated) + } + + /** Gets a branches ahead behind remote or null if doesn't exist on remote */ + public async getBranchAheadBehind( + repository: Repository, + branch: Branch + ): Promise { + return this.appStore._getBranchAheadBehind(repository, branch) + } + + /** Set whether thank you is in order for external contributions */ + public setLastThankYou(lastThankYou: ILastThankYou) { + this.appStore._setLastThankYou(lastThankYou) + } + + public async reorderCommits( + repository: Repository, + commitsToReorder: ReadonlyArray, + beforeCommit: Commit | null, + lastRetainedCommitRef: string | null, + continueWithForcePush: boolean = false + ) { + const retry: RetryAction = { + type: RetryActionType.Reorder, + repository, + commitsToReorder, + beforeCommit, + lastRetainedCommitRef, + } + + if (this.appStore._checkForUncommittedChanges(repository, retry)) { + return + } + + const stateBefore = this.repositoryStateManager.get(repository) + const { tip } = stateBefore.branchesState + + if (tip.kind !== TipState.Valid) { + log.info(`[reorder] - invalid tip state - could not perform reorder.`) + return + } + + this.statsStore.recordReorderStarted() + + if (commitsToReorder.length > 1) { + this.statsStore.recordReorderMultipleCommits() + } + + this.appStore._initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.Reorder, + lastRetainedCommitRef, + beforeCommit, + commits: commitsToReorder, + currentTip: tip.branch.tip.sha, + }, + tip.branch, + commitsToReorder, + tip.branch.tip.sha + ) + + this.showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + + this.appStore._setMultiCommitOperationUndoState(repository, tip) + + const { askForConfirmationOnForcePush } = this.appStore.getState() + + if (askForConfirmationOnForcePush && !continueWithForcePush) { + const showWarning = await this.warnAboutRemoteCommits( + repository, + tip.branch, + lastRetainedCommitRef + ) + + if (showWarning) { + this.setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.WarnForcePush, + targetBranch: tip.branch, + baseBranch: tip.branch, + commits: commitsToReorder, + }) + return + } + } + + const result = await this.appStore._reorderCommits( + repository, + commitsToReorder, + beforeCommit, + lastRetainedCommitRef + ) + + this.logHowToRevertMultiCommitOperation( + MultiCommitOperationKind.Reorder, + tip + ) + + return this.processMultiCommitOperationRebaseResult( + MultiCommitOperationKind.Reorder, + repository, + result, + commitsToReorder.length, + tip.branch.name, + `${MultiCommitOperationKind.Reorder.toLowerCase()} commit` + ) + } + + /** + * Starts a squash + * + * @param toSquash - commits to squash onto another commit + * @param squashOnto - commit to squash the `toSquash` commits onto + * @param lastRetainedCommitRef - commit ref of commit before commits in + * squash or null if a commit to squash is root (first in history) of the + * branch + * @param commitContext - to build the commit message from + */ + public async squash( + repository: Repository, + toSquash: ReadonlyArray, + squashOnto: Commit, + lastRetainedCommitRef: string | null, + commitContext: ICommitContext, + continueWithForcePush: boolean = false + ): Promise { + const retry: RetryAction = { + type: RetryActionType.Squash, + repository, + toSquash, + squashOnto, + lastRetainedCommitRef, + commitContext, + } + + if (this.appStore._checkForUncommittedChanges(repository, retry)) { + return + } + + const stateBefore = this.repositoryStateManager.get(repository) + const { tip } = stateBefore.branchesState + + if (tip.kind !== TipState.Valid) { + log.info(`[squash] - invalid tip state - could not perform squash.`) + return + } + + if (toSquash.length > 1) { + this.statsStore.recordSquashMultipleCommitsInvoked() + } + + this.initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.Squash, + lastRetainedCommitRef, + commitContext, + targetCommit: squashOnto, + commits: toSquash, + currentTip: tip.branch.tip.sha, + }, + tip.branch, + toSquash, + tip.branch.tip.sha + ) + + this.closePopup(PopupType.CommitMessage) + this.showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + + this.appStore._setMultiCommitOperationUndoState(repository, tip) + + const { askForConfirmationOnForcePush } = this.appStore.getState() + + if (askForConfirmationOnForcePush && !continueWithForcePush) { + const showWarning = await this.warnAboutRemoteCommits( + repository, + tip.branch, + lastRetainedCommitRef + ) + + if (showWarning) { + this.setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.WarnForcePush, + targetBranch: tip.branch, + baseBranch: tip.branch, + commits: toSquash, + }) + return + } + } + + const result = await this.appStore._squash( + repository, + toSquash, + squashOnto, + lastRetainedCommitRef, + commitContext + ) + + this.logHowToRevertMultiCommitOperation( + MultiCommitOperationKind.Squash, + tip + ) + + return this.processMultiCommitOperationRebaseResult( + MultiCommitOperationKind.Squash, + repository, + result, + toSquash.length + 1, + tip.branch.name, + `${MultiCommitOperationKind.Squash.toLowerCase()} commit` + ) + } + + public initializeMultiCommitOperation( + repository: Repository, + operationDetail: MultiCommitOperationDetail, + targetBranch: Branch | null, + commits: ReadonlyArray, + originalBranchTip: string | null + ) { + this.appStore._initializeMultiCommitOperation( + repository, + operationDetail, + targetBranch, + commits, + originalBranchTip + ) + } + + private logHowToRevertMultiCommitOperation( + kind: MultiCommitOperationKind, + tip: IValidBranch + ) { + const operation = kind.toLocaleLowerCase() + const beforeSha = getTipSha(tip) + log.info( + `[${operation}] starting rebase for ${tip.branch.name} at ${beforeSha}` + ) + log.info( + `[${operation}] to restore the previous state if this completed rebase is unsatisfactory:` + ) + log.info(`[${operation}] - git checkout ${tip.branch.name}`) + log.info(`[${operation}] - git reset ${beforeSha} --hard`) + } + + /** + * Processes the multi commit operation result + * 1. Completes the operation with banner if successful. + * 2. Moves operation flow to conflicts handler. + * 3. Handles errors. + * + * @param totalNumberOfCommits Total number of commits involved in the + * operation. For example, if you squash one + * commit onto another, there are 2 commits + * involved. + */ + public async processMultiCommitOperationRebaseResult( + kind: MultiCommitOperationKind, + repository: Repository, + result: RebaseResult, + totalNumberOfCommits: number, + ourBranch: string, + theirBranch: string + ): Promise { + // This will update the conflict state of the app. This is needed to start + // conflict flow if squash results in conflict. + const status = await this.appStore._loadStatus(repository) + switch (result) { + case RebaseResult.CompletedWithoutError: + if (status !== null && status.currentTip !== undefined) { + // This sets the history to the current tip + // TODO: Look at history back to last retained commit and search for + // squashed commit based on new commit message ... if there is more + // than one, just take the most recent. (not likely?) + await this.changeCommitSelection( + repository, + [status.currentTip], + true + ) + } + + await this.completeMultiCommitOperation( + repository, + totalNumberOfCommits + ) + break + case RebaseResult.ConflictsEncountered: + await this.refreshRepository(repository) + this.startMultiCommitOperationConflictFlow( + kind, + repository, + ourBranch, + theirBranch + ) + break + default: + // TODO: clear state + this.appStore._closePopup() + } + } + + /** + * Obtains the current app conflict state and switches multi commit operation + * to show conflicts step + */ + private startMultiCommitOperationConflictFlow( + kind: MultiCommitOperationKind, + repository: Repository, + ourBranch: string, + theirBranch: string + ): void { + const { + changesState: { conflictState }, + } = this.repositoryStateManager.get(repository) + + if (conflictState === null) { + log.error( + '[startMultiCommitOperationConflictFlow] - conflict state was null - unable to continue' + ) + this.endMultiCommitOperation(repository) + return + } + + const { manualResolutions } = conflictState + this.setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.ShowConflicts, + conflictState: { + kind: 'multiCommitOperation', + manualResolutions, + ourBranch, + theirBranch, + }, + }) + + this.statsStore.recordOperationConflictsEncounteredCount(kind) + + this.showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + } + + /** + * Wrap multi commit operation actions + * - closes popups + * - refreshes repo (so changes appear in history) + * - sets success banner + * - end operation state + */ + private async completeMultiCommitOperation( + repository: Repository, + count: number + ): Promise { + this.closePopup() + + const { + branchesState: { tip }, + multiCommitOperationState: mcos, + } = this.repositoryStateManager.get(repository) + + if (mcos === null) { + log.error( + '[completeMultiCommitOperation] - No multi commit operation to complete.' + ) + return + } + + const { operationDetail, originalBranchTip } = mcos + const { kind } = operationDetail + const banner = this.getMultiCommitOperationSuccessBanner( + repository, + count, + mcos + ) + + this.setBanner(banner) + + if ( + tip.kind === TipState.Valid && + originalBranchTip !== null && + kind !== MultiCommitOperationKind.CherryPick + ) { + this.addBranchToForcePushList(repository, tip, originalBranchTip) + } + + this.statsStore.recordOperationSuccessful(kind) + + this.endMultiCommitOperation(repository) + await this.refreshRepository(repository) + } + + private getMultiCommitOperationSuccessBanner( + repository: Repository, + count: number, + mcos: IMultiCommitOperationState + ): Banner { + const { operationDetail, targetBranch } = mcos + const { kind } = operationDetail + + const bannerBase = { + count, + onUndo: () => { + this.undoMultiCommitOperation(mcos, repository, count) + }, + } + + let banner: Banner + switch (kind) { + case MultiCommitOperationKind.Squash: + banner = { ...bannerBase, type: BannerType.SuccessfulSquash } + break + case MultiCommitOperationKind.Reorder: + banner = { ...bannerBase, type: BannerType.SuccessfulReorder } + break + case MultiCommitOperationKind.CherryPick: + banner = { + ...bannerBase, + type: BannerType.SuccessfulCherryPick, + targetBranchName: targetBranch !== null ? targetBranch.name : '', + } + break + case MultiCommitOperationKind.Rebase: + const sourceBranch = + operationDetail.kind === MultiCommitOperationKind.Rebase + ? operationDetail.sourceBranch + : null + banner = { + type: BannerType.SuccessfulRebase, + targetBranch: targetBranch !== null ? targetBranch.name : '', + baseBranch: sourceBranch !== null ? sourceBranch.name : undefined, + } + break + case MultiCommitOperationKind.Merge: + throw new Error(`Unexpected multi commit operation kind ${kind}`) + default: + assertNever(kind, `Unsupported multi operation kind ${kind}`) + } + + return banner + } + + /** + * This method will perform a hard reset back to the tip of the branch before + * the multi commit operation happened. + */ + private async undoMultiCommitOperation( + mcos: IMultiCommitOperationState, + repository: Repository, + commitsCount: number + ): Promise { + const result = await this.appStore._undoMultiCommitOperation( + mcos, + repository, + commitsCount + ) + + if (result) { + this.statsStore.recordOperationUndone(mcos.operationDetail.kind) + } + + return result + } + + public handleConflictsDetectedOnError( + repository: Repository, + currentBranch: string, + theirBranch: string + ) { + return this.appStore._handleConflictsDetectedOnError( + repository, + currentBranch, + theirBranch + ) + } + + /** + * This method is to update the multi operation state to move it along in + * steps. + */ + public setMultiCommitOperationStep( + repository: Repository, + step: MultiCommitOperationStep + ): Promise { + return this.appStore._setMultiCommitOperationStep(repository, step) + } + + /** Method to clear multi commit operation state. */ + public endMultiCommitOperation(repository: Repository) { + this.appStore._endMultiCommitOperation(repository) + } + + /** Opens conflicts found banner for part of multi commit operation */ + public onConflictsFoundBanner = ( + repository: Repository, + operationDescription: string | JSX.Element, + multiCommitOperationConflictState: MultiCommitOperationConflictState + ) => { + this.setBanner({ + type: BannerType.ConflictsFound, + operationDescription, + onOpenConflictsDialog: async () => { + const { changesState, multiCommitOperationState } = + this.repositoryStateManager.get(repository) + const { conflictState } = changesState + + if (conflictState == null) { + log.error( + '[onConflictsFoundBanner] App is in invalid state to so conflicts dialog.' + ) + return + } + + if ( + multiCommitOperationState !== null && + multiCommitOperationState.operationDetail.kind === + MultiCommitOperationKind.CherryPick + ) { + // TODO: expanded to other types - not functionally necessary; makes + // progress dialog more accurate; likely only regular rebase has the + // state data to also do this; need to evaluate it's importance + await this.setCherryPickProgressFromState(repository) + } + + const { manualResolutions } = conflictState + + this.setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.ShowConflicts, + conflictState: { + ...multiCommitOperationConflictState, + manualResolutions, + }, + }) + + this.showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + }, + }) + } + + public startMergeBranchOperation( + repository: Repository, + isSquash: boolean = false, + initialBranch?: Branch | null + ) { + const { branchesState } = this.repositoryStateManager.get(repository) + const { defaultBranch, allBranches, recentBranches, tip } = branchesState + let currentBranch: Branch | null = null + + if (tip.kind === TipState.Valid) { + currentBranch = tip.branch + } else { + throw new Error( + 'Tip is not in a valid state, which is required to start the merge operation' + ) + } + + this.initializeMergeOperation(repository, isSquash, null) + + this.setMultiCommitOperationStep(repository, { + kind: MultiCommitOperationStepKind.ChooseBranch, + defaultBranch, + currentBranch, + allBranches, + recentBranches, + initialBranch: initialBranch !== null ? initialBranch : undefined, + }) + + this.showPopup({ + type: PopupType.MultiCommitOperation, + repository, + }) + } + + /** Records the squash that a squash has been invoked by either drag and drop or context menu */ + public recordSquashInvoked(isInvokedByContextMenu: boolean): void { + if (isInvokedByContextMenu) { + this.statsStore.recordSquashViaContextMenuInvoked() + } else { + this.statsStore.recordSquashViaDragAndDropInvokedCount() + } + } + + public initializeMergeOperation( + repository: Repository, + isSquash: boolean, + sourceBranch: Branch | null + ) { + const { + branchesState: { tip }, + } = this.repositoryStateManager.get(repository) + + let currentBranch: Branch | null = null + + if (tip.kind === TipState.Valid) { + currentBranch = tip.branch + } else { + throw new Error( + 'Tip is not in a valid state, which is required to initialize the merge operation' + ) + } + + this.initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.Merge, + isSquash, + sourceBranch, + }, + currentBranch, + [], + currentBranch.tip.sha + ) + } + + public setShowCIStatusPopover(showCIStatusPopover: boolean) { + this.appStore._setShowCIStatusPopover(showCIStatusPopover) + if (showCIStatusPopover) { + this.statsStore.recordCheckRunsPopoverOpened() + } + } + + public _toggleCIStatusPopover() { + this.appStore._toggleCIStatusPopover() + } + + public recordCheckViewedOnline() { + this.statsStore.recordCheckViewedOnline() + } + + public recordCheckJobStepViewedOnline() { + this.statsStore.recordCheckJobStepViewedOnline() + } + + public recordRerunChecks() { + this.statsStore.recordRerunChecks() + } + + public recordChecksFailedDialogSwitchToPullRequest() { + this.statsStore.recordChecksFailedDialogSwitchToPullRequest() + } + + public recordChecksFailedDialogRerunChecks() { + this.statsStore.recordChecksFailedDialogRerunChecks() + } + + public recordPullRequestReviewDialogSwitchToPullRequest( + reviewType: ValidNotificationPullRequestReviewState + ) { + this.statsStore.recordPullRequestReviewDialogSwitchToPullRequest(reviewType) + } + + public recordPullRequestCommentDialogSwitchToPullRequest() { + this.statsStore.recordPullRequestCommentDialogSwitchToPullRequest() + } + + public showUnreachableCommits(selectedTab: UnreachableCommitsTab) { + this.statsStore.recordMultiCommitDiffUnreachableCommitsDialogOpenedCount() + + this.showPopup({ + type: PopupType.UnreachableCommits, + selectedTab, + }) + } + + public startPullRequest(repository: Repository) { + this.appStore._startPullRequest(repository) + } + + /** + * Change the selected changed file of the current pull request state. + */ + public changePullRequestFileSelection( + repository: Repository, + file: CommittedFileChange + ): Promise { + return this.appStore._changePullRequestFileSelection(repository, file) + } + + /** + * Set the width of the file list column in the pull request files changed + */ + public setPullRequestFileListWidth(width: number): Promise { + return this.appStore._setPullRequestFileListWidth(width) + } + + /** + * Reset the width of the file list column in the pull request files changed + */ + public resetPullRequestFileListWidth(): Promise { + return this.appStore._resetPullRequestFileListWidth() + } + + public updatePullRequestBaseBranch(repository: Repository, branch: Branch) { + this.appStore._updatePullRequestBaseBranch(repository, branch) + } + + /** + * Attempts to quit the app if it's not updating, unless requested to quit + * even if it is updating. + * + * @param evenIfUpdating Whether to quit even if the app is updating. + */ + public quitApp(evenIfUpdating: boolean) { + this.appStore._quitApp(evenIfUpdating) + } + + /** + * Cancels quitting the app. This could be needed if, on macOS, the user tries + * to quit the app while an update is in progress, but then after being + * informed about the issues that could cause they decided to not close the + * app yet. + */ + public cancelQuittingApp() { + this.appStore._cancelQuittingApp() + } + + /** + * Sets the user's preference for which pull request suggested next action to + * use + */ + public setPullRequestSuggestedNextAction( + value: PullRequestSuggestedNextAction + ) { + return this.appStore._setPullRequestSuggestedNextAction(value) + } + + public appFocusedElementChanged() { + this.appStore._appFocusedElementChanged() + } + + public updateCachedRepoRulesets(rulesets: Array) { + this.appStore._updateCachedRepoRulesets(rulesets) + } +} diff --git a/app/src/ui/dispatcher/error-handlers.ts b/app/src/ui/dispatcher/error-handlers.ts new file mode 100644 index 0000000000..34d670ae4f --- /dev/null +++ b/app/src/ui/dispatcher/error-handlers.ts @@ -0,0 +1,663 @@ +import { + GitError as DugiteError, + RepositoryDoesNotExistErrorCode, +} from 'dugite' + +import { Dispatcher } from '.' +import { ExternalEditorError } from '../../lib/editors/shared' +import { + DiscardChangesError, + ErrorWithMetadata, +} from '../../lib/error-with-metadata' +import { AuthenticationErrors } from '../../lib/git/authentication' +import { GitError, isAuthFailureError } from '../../lib/git/core' +import { ShellError } from '../../lib/shells' +import { UpstreamAlreadyExistsError } from '../../lib/stores/upstream-already-exists-error' + +import { PopupType } from '../../models/popup' +import { + Repository, + isRepositoryWithGitHubRepository, +} from '../../models/repository' +import { hasWritePermission } from '../../models/github-repository' +import { RetryActionType } from '../../models/retry-actions' +import { parseFilesToBeOverwritten } from '../lib/parse-files-to-be-overwritten' + +/** An error which also has a code property. */ +interface IErrorWithCode extends Error { + readonly code: string +} + +/** + * A type-guard method which determines whether the given object is an + * Error instance with a `code` string property. This type of error + * is commonly returned by NodeJS process- and file system libraries + * as well as Dugite. + * + * See https://nodejs.org/api/util.html#util_util_getsystemerrorname_err + */ +function isErrorWithCode(error: any): error is IErrorWithCode { + return error instanceof Error && typeof (error as any).code === 'string' +} + +/** + * Cast the error to an error containing a code if it has a code. Otherwise + * return null. + */ +function asErrorWithCode(error: Error): IErrorWithCode | null { + return isErrorWithCode(error) ? error : null +} + +/** + * Cast the error to an error with metadata if possible. Otherwise return null. + */ +function asErrorWithMetadata(error: Error): ErrorWithMetadata | null { + if (error instanceof ErrorWithMetadata) { + return error + } else { + return null + } +} + +/** Cast the error to a `GitError` if possible. Otherwise return null. */ +function asGitError(error: Error): GitError | null { + if (error instanceof GitError) { + return error + } else { + return null + } +} + +function asEditorError(error: Error): ExternalEditorError | null { + if (error instanceof ExternalEditorError) { + return error + } + return null +} + +/** Handle errors by presenting them. */ +export async function defaultErrorHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + const e = asErrorWithMetadata(error) || error + await dispatcher.presentError(e) + + return null +} + +/** Handler for when a repository disappears 😱. */ +export async function missingRepositoryHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + const e = asErrorWithMetadata(error) + if (!e) { + return error + } + + const repository = e.metadata.repository + if (!repository || !(repository instanceof Repository)) { + return error + } + + if (repository.missing) { + return null + } + + const errorWithCode = asErrorWithCode(e.underlyingError) + const gitError = asGitError(e.underlyingError) + const missing = + (gitError && gitError.result.gitError === DugiteError.NotAGitRepository) || + (errorWithCode && errorWithCode.code === RepositoryDoesNotExistErrorCode) + + if (missing) { + await dispatcher.updateRepositoryMissing(repository, true) + return null + } + + return error +} + +/** Handle errors that happen as a result of a background task. */ +export async function backgroundTaskHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + const e = asErrorWithMetadata(error) + if (!e) { + return error + } + + const metadata = e.metadata + // Ignore errors from background tasks. We might want more nuance here in the + // future, but this'll do for now. + if (metadata.backgroundTask) { + return null + } else { + return error + } +} + +/** Handle git authentication errors in a manner that seems Right And Good. */ +export async function gitAuthenticationErrorHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + const e = asErrorWithMetadata(error) + if (!e) { + return error + } + + const gitError = asGitError(e.underlyingError) + if (!gitError) { + return error + } + + const dugiteError = gitError.result.gitError + if (!dugiteError) { + return error + } + + if (!AuthenticationErrors.has(dugiteError)) { + return error + } + + const repository = e.metadata.repository + if (!repository) { + return error + } + + // If it's a GitHub repository then it's not some generic git server + // authentication problem, but more likely a legit permission problem. So let + // the error continue to bubble up. + if (repository instanceof Repository && repository.gitHubRepository) { + return error + } + + const retry = e.metadata.retryAction + if (!retry) { + log.error(`No retry action provided for a git authentication error.`, e) + return error + } + + await dispatcher.promptForGenericGitAuthentication(repository, retry) + + return null +} + +export async function externalEditorErrorHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + const e = asEditorError(error) + if (!e) { + return error + } + + const { suggestDefaultEditor, openPreferences } = e.metadata + + await dispatcher.showPopup({ + type: PopupType.ExternalEditorFailed, + message: e.message, + suggestDefaultEditor, + openPreferences, + }) + + return null +} + +export async function openShellErrorHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + if (!(error instanceof ShellError)) { + return error + } + + await dispatcher.showPopup({ + type: PopupType.OpenShellFailed, + message: error.message, + }) + + return null +} + +/** Handle errors where they need to pull before pushing. */ +export async function pushNeedsPullHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + const e = asErrorWithMetadata(error) + if (!e) { + return error + } + + const gitError = asGitError(e.underlyingError) + if (!gitError) { + return error + } + + const dugiteError = gitError.result.gitError + if (!dugiteError) { + return error + } + + if (dugiteError !== DugiteError.PushNotFastForward) { + return error + } + + const repository = e.metadata.repository + if (!repository) { + return error + } + + if (!(repository instanceof Repository)) { + return error + } + + dispatcher.showPopup({ type: PopupType.PushNeedsPull, repository }) + + return null +} + +/** + * Handler for detecting when a merge conflict is reported to direct the user + * to a different dialog than the generic Git error dialog. + */ +export async function mergeConflictHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + const e = asErrorWithMetadata(error) + if (!e) { + return error + } + + const gitError = asGitError(e.underlyingError) + if (!gitError) { + return error + } + + const dugiteError = gitError.result.gitError + if (!dugiteError) { + return error + } + + if (dugiteError !== DugiteError.MergeConflicts) { + return error + } + + const { repository, gitContext } = e.metadata + if (repository == null) { + return error + } + + if (!(repository instanceof Repository)) { + return error + } + + if (gitContext == null) { + return error + } + + if (!(gitContext.kind === 'merge' || gitContext.kind === 'pull')) { + return error + } + + switch (gitContext.kind) { + case 'pull': + dispatcher.mergeConflictDetectedFromPull() + break + case 'merge': + dispatcher.mergeConflictDetectedFromExplicitMerge() + break + } + + const { currentBranch, theirBranch } = gitContext + + dispatcher.handleConflictsDetectedOnError( + repository, + currentBranch, + theirBranch + ) + + return null +} + +/** + * Handler for when we attempt to install the global LFS filters and LFS throws + * an error. + */ +export async function lfsAttributeMismatchHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + const gitError = asGitError(error) + if (!gitError) { + return error + } + + const dugiteError = gitError.result.gitError + if (!dugiteError) { + return error + } + + if (dugiteError !== DugiteError.LFSAttributeDoesNotMatch) { + return error + } + + dispatcher.showPopup({ type: PopupType.LFSAttributeMismatch }) + + return null +} + +/** + * Handler for when an upstream remote already exists but doesn't actually match + * the upstream repository. + */ +export async function upstreamAlreadyExistsHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + if (!(error instanceof UpstreamAlreadyExistsError)) { + return error + } + + dispatcher.showPopup({ + type: PopupType.UpstreamAlreadyExists, + repository: error.repository, + existingRemote: error.existingRemote, + }) + + return null +} + +/* + * Handler for detecting when a merge conflict is reported to direct the user + * to a different dialog than the generic Git error dialog. + */ +export async function rebaseConflictsHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + const e = asErrorWithMetadata(error) + if (!e) { + return error + } + + const gitError = asGitError(e.underlyingError) + if (!gitError) { + return error + } + + const dugiteError = gitError.result.gitError + if (!dugiteError) { + return error + } + + if (dugiteError !== DugiteError.RebaseConflicts) { + return error + } + + const { repository, gitContext } = e.metadata + if (repository == null) { + return error + } + + if (!(repository instanceof Repository)) { + return error + } + + if (gitContext == null) { + return error + } + + if (gitContext.kind !== 'merge' && gitContext.kind !== 'pull') { + return error + } + + const { currentBranch } = gitContext + + dispatcher.launchRebaseOperation(repository, currentBranch) + + return null +} + +const rejectedPathRe = + /^ ! \[remote rejected\] .*? -> .*? \(refusing to allow an OAuth App to create or update workflow `(.*?)` without `workflow` scope\)/m + +/** + * Attempts to detect whether an error is the result of a failed push + * due to insufficient OAuth permissions (missing workflow scope) + */ +export async function refusedWorkflowUpdate( + error: Error, + dispatcher: Dispatcher +) { + const e = asErrorWithMetadata(error) + if (!e) { + return error + } + + const gitError = asGitError(e.underlyingError) + if (!gitError) { + return error + } + + const { repository } = e.metadata + + if (!(repository instanceof Repository)) { + return error + } + + if (!isRepositoryWithGitHubRepository(repository)) { + return error + } + + const match = rejectedPathRe.exec(error.message) + + if (!match) { + return error + } + + dispatcher.showPopup({ + type: PopupType.PushRejectedDueToMissingWorkflowScope, + rejectedPath: match[1], + repository, + }) + + return null +} + +const samlReauthErrorMessageRe = + /`([^']+)' organization has enabled or enforced SAML SSO.*?you must re-authorize/s + +/** + * Attempts to detect whether an error is the result of a failed push + * due to insufficient OAuth permissions (missing workflow scope) + */ +export async function samlReauthRequired(error: Error, dispatcher: Dispatcher) { + const e = asErrorWithMetadata(error) + if (!e) { + return error + } + + const gitError = asGitError(e.underlyingError) + if (!gitError || gitError.result.gitError === null) { + return error + } + + if (!isAuthFailureError(gitError.result.gitError)) { + return error + } + + const { repository } = e.metadata + + if (!(repository instanceof Repository)) { + return error + } + + if (repository.gitHubRepository === null) { + return error + } + + const remoteMessage = getRemoteMessage(gitError.result.stderr) + const match = samlReauthErrorMessageRe.exec(remoteMessage) + + if (!match) { + return error + } + + const organizationName = match[1] + const endpoint = repository.gitHubRepository.endpoint + + dispatcher.showPopup({ + type: PopupType.SAMLReauthRequired, + organizationName, + endpoint, + retryAction: e.metadata.retryAction, + }) + + return null +} + +/** + * Attempts to detect whether an error is the result of a failed push + * due to insufficient GitHub permissions. (No `write` access.) + */ +export async function insufficientGitHubRepoPermissions( + error: Error, + dispatcher: Dispatcher +) { + const e = asErrorWithMetadata(error) + if (!e) { + return error + } + + const gitError = asGitError(e.underlyingError) + if (!gitError || gitError.result.gitError === null) { + return error + } + + if (!isAuthFailureError(gitError.result.gitError)) { + return error + } + + const { repository, retryAction } = e.metadata + + if ( + !(repository instanceof Repository) || + !isRepositoryWithGitHubRepository(repository) + ) { + return error + } + + if (retryAction === undefined || retryAction.type !== RetryActionType.Push) { + return error + } + + if (hasWritePermission(repository.gitHubRepository)) { + return error + } + + dispatcher.showCreateForkDialog(repository) + + return null +} + +/** + * Handler for when an action the user attempts cannot be done because there are local + * changes that would get overwritten. + */ +export async function localChangesOverwrittenHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + const e = asErrorWithMetadata(error) + if (e === null) { + return error + } + + const gitError = asGitError(e.underlyingError) + if (gitError === null) { + return error + } + + const dugiteError = gitError.result.gitError + + if ( + dugiteError !== DugiteError.LocalChangesOverwritten && + dugiteError !== DugiteError.MergeWithLocalChanges && + dugiteError !== DugiteError.RebaseWithLocalChanges + ) { + return error + } + + const { repository, retryAction } = e.metadata + + if (!(repository instanceof Repository)) { + return error + } + + if (retryAction === undefined) { + return error + } + + if (e.metadata.gitContext?.kind === 'checkout') { + dispatcher.recordErrorWhenSwitchingBranchesWithUncommmittedChanges() + } + + const files = parseFilesToBeOverwritten(gitError.result.stderr) + + dispatcher.showPopup({ + type: PopupType.LocalChangesOverwritten, + repository, + retryAction, + files, + }) + + return null +} + +/** + * Handler for when an action the user attempts to discard changes and they + * cannot be moved to trash/recycle bin + */ +export async function discardChangesHandler( + error: Error, + dispatcher: Dispatcher +): Promise { + if (!(error instanceof DiscardChangesError)) { + return error + } + + const { retryAction } = error.metadata + + if (retryAction === undefined) { + return error + } + + dispatcher.showPopup({ + type: PopupType.DiscardChangesRetry, + retryAction, + }) + + return null +} + +/** + * Extract lines from Git's stderr output starting with the + * prefix `remote: `. Useful to extract server-specific + * error messages from network operations (fetch, push, pull, + * etc). + */ +function getRemoteMessage(stderr: string) { + const needle = 'remote: ' + + return stderr + .split(/\r?\n/) + .filter(x => x.startsWith(needle)) + .map(x => x.substring(needle.length)) + .join('\n') +} diff --git a/app/src/ui/dispatcher/index.ts b/app/src/ui/dispatcher/index.ts new file mode 100644 index 0000000000..d7d92b2281 --- /dev/null +++ b/app/src/ui/dispatcher/index.ts @@ -0,0 +1,2 @@ +export * from './dispatcher' +export * from './error-handlers' diff --git a/app/src/ui/donut.tsx b/app/src/ui/donut.tsx new file mode 100644 index 0000000000..482f680de2 --- /dev/null +++ b/app/src/ui/donut.tsx @@ -0,0 +1,122 @@ +import * as React from 'react' + +const OUTER_RADIUS = 15 +const INNER_RADIUS = 9 + +interface IPath { + readonly name: string + readonly path: string +} + +interface IDonut { + paths: ReadonlyArray + height: number + width: number +} + +interface IDonutProps { + /** + * A map of counts where the key is a string to be used as a classname for + * that segment of the donut in svg such that it can be styled. + * + * Example: + * failure => 5 + * success => 7 + * You could now have a classes defeind of "failure" and "success" to color + * the segments green and red. + */ + readonly valueMap: Map +} + +/** + * A component for displaying a donut type pie chart svg + * + * Usage: `([['failure', 5], ['success', 7]])} />` + */ +export class Donut extends React.Component { + public render() { + const { valueMap } = this.props + const { paths, height, width } = buildDonutSVGData(valueMap) + const viewBox = `0 0 ${width} ${height}` + + const svgPaths = paths.map((p, i) => { + return + }) + + return ( + + {svgPaths} + + ) + } +} + +function buildDonutSVGData( + donutValuesMap: Map, + outerRadius: number = OUTER_RADIUS, + innerRadius: number = INNER_RADIUS +): IDonut { + const diameter = outerRadius * 2 + const sum = [...donutValuesMap.values()].reduce((sum, v) => sum + v) + + const cx = diameter / 2 + const cy = cx + + const paths: IPath[] = [] + let cumulative = 0 + + for (const [name, value] of [...donutValuesMap.entries()]) { + if (value === 0) { + continue + } + + const portion = value / sum + + if (portion === 1) { + const x2 = cx - 0.01 + const y1 = cy - outerRadius + const y2 = cy - innerRadius + const d = ['M', cx, y1] + d.push('A', outerRadius, outerRadius, 0, 1, 1, x2, y1) + d.push('L', x2, y2) + d.push('A', innerRadius, innerRadius, 0, 1, 0, cx, y2) + paths.push({ name, path: d.join(' ') }) + continue + } + + const cumulative_plus_value = cumulative + value + + const d = ['M', ...scale(cumulative, outerRadius, cx, sum)] + d.push('A', outerRadius, outerRadius, 0) + d.push(portion > 0.5 ? 1 : 0) + d.push(1) + d.push(...scale(cumulative_plus_value, outerRadius, cx, sum)) + d.push('L') + + d.push(...scale(cumulative_plus_value, innerRadius, cx, sum)) + d.push('A', innerRadius, innerRadius, 0) + d.push(portion > 0.5 ? 1 : 0) + d.push(0) + d.push(...scale(cumulative, innerRadius, cx, sum)) + + cumulative += value + + paths.push({ name, path: d.join(' ') }) + } + + return { + paths, + height: diameter, + width: diameter, + } +} + +function scale( + value: number, + radius: number, + cxy: number, + sum: number +): ReadonlyArray { + const radians = (value / sum) * Math.PI * 2 - Math.PI / 2 + return [radius * Math.cos(radians) + cxy, radius * Math.sin(radians) + cxy] +} diff --git a/app/src/ui/drag-elements/commit-drag-element.tsx b/app/src/ui/drag-elements/commit-drag-element.tsx new file mode 100644 index 0000000000..c83b5d55ef --- /dev/null +++ b/app/src/ui/drag-elements/commit-drag-element.tsx @@ -0,0 +1,188 @@ +import classNames from 'classnames' +import { Disposable } from 'event-kit' +import * as React from 'react' +import { dragAndDropManager } from '../../lib/drag-and-drop-manager' +import { assertNever } from '../../lib/fatal-error' +import { Commit } from '../../models/commit' +import { DragType, DropTarget, DropTargetType } from '../../models/drag-drop' +import { GitHubRepository } from '../../models/github-repository' +import { CommitListItem } from '../history/commit-list-item' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' + +interface ICommitDragElementProps { + readonly commit: Commit + readonly selectedCommits: ReadonlyArray + readonly gitHubRepository: GitHubRepository | null + readonly emoji: Map +} + +interface ICommitDragElementState { + readonly showTooltip: boolean + readonly currentDropTarget: DropTarget | null +} + +export class CommitDragElement extends React.Component< + ICommitDragElementProps, + ICommitDragElementState +> { + private timeoutId: number | null = null + private onEnterDropTarget: Disposable | null = null + private onLeaveDropTargetDisposable: Disposable | null = null + + public constructor(props: ICommitDragElementProps) { + super(props) + this.state = { + showTooltip: false, + currentDropTarget: null, + } + } + + private clearTimeout() { + if (this.timeoutId !== null) { + window.clearTimeout(this.timeoutId) + } + } + + private setToolTipTimer(time: number) { + if (__DARWIN__) { + // For macOs, we styled the copy to message to look like a native tool tip + // that appears when hovering over a element with the title attribute. We + // also are implementing this timeout to have similar hover-to-see feel. + this.setState({ showTooltip: false }) + + this.clearTimeout() + + this.timeoutId = window.setTimeout( + () => this.setState({ showTooltip: true }), + time + ) + } else { + this.setState({ showTooltip: true }) + } + } + + private renderDragToolTip() { + const { showTooltip, currentDropTarget } = this.state + if (!showTooltip || currentDropTarget === null) { + return + } + + let toolTipContents + switch (currentDropTarget.type) { + case DropTargetType.Branch: + const copyToPlus = __DARWIN__ ? null : ( + + ) + toolTipContents = ( + <> + {copyToPlus} + + Copy to + + {currentDropTarget.branchName} + + + + ) + break + case DropTargetType.Commit: + // Selected commits (being dragged) + the one commit it is squashed (dropped) on. + const commitsBeingSquashedCount = this.props.selectedCommits.length + 1 + toolTipContents = ( + <> + Squash {commitsBeingSquashedCount} commits + + ) + break + case DropTargetType.ListInsertionPoint: + if (currentDropTarget.data.type !== DragType.Commit) { + toolTipContents = ( + <> + 'Insert here' + + ) + break + } + + const pluralized = + currentDropTarget.data.commits.length === 1 ? 'commit' : 'commits' + toolTipContents = ( + <> + {`Move ${pluralized} here`} + + ) + break + default: + assertNever( + currentDropTarget, + `Unknown drop target type: ${currentDropTarget}` + ) + } + + return ( +
+
{toolTipContents}
+
+ ) + } + + public componentDidMount() { + this.onEnterDropTarget = dragAndDropManager.onEnterDropTarget( + dropTarget => { + this.setState({ currentDropTarget: dropTarget }) + switch (dropTarget.type) { + case DropTargetType.Branch: + case DropTargetType.Commit: + case DropTargetType.ListInsertionPoint: + this.setToolTipTimer(1500) + break + default: + assertNever(dropTarget, `Unknown drop target type: ${dropTarget}`) + } + } + ) + + this.onLeaveDropTargetDisposable = dragAndDropManager.onLeaveDropTarget( + () => { + this.setState({ currentDropTarget: null, showTooltip: false }) + } + ) + } + + public componentWillUnmount() { + this.clearTimeout() + + if (this.onEnterDropTarget !== null) { + this.onEnterDropTarget.dispose() + this.onEnterDropTarget = null + } + + if (this.onLeaveDropTargetDisposable !== null) { + this.onLeaveDropTargetDisposable.dispose() + this.onLeaveDropTargetDisposable = null + } + } + + public render() { + const { commit, gitHubRepository, selectedCommits, emoji } = this.props + const count = selectedCommits.length + + const className = classNames({ 'multiple-selected': count > 1 }) + return ( +
+
+
{count}
+ +
+ {this.renderDragToolTip()} +
+ ) + } +} diff --git a/app/src/ui/dropdown-select-button.tsx b/app/src/ui/dropdown-select-button.tsx new file mode 100644 index 0000000000..c17f6bea6b --- /dev/null +++ b/app/src/ui/dropdown-select-button.tsx @@ -0,0 +1,389 @@ +import classNames from 'classnames' +import React from 'react' +import { Button } from './lib/button' +import { Octicon } from './octicons' +import * as OcticonSymbol from './octicons/octicons.generated' +import { MenuPane } from './app-menu' +import { ICheckboxMenuItem, MenuItem } from '../models/app-menu' +import { ClickSource, SelectionSource } from './lib/list' + +export interface IDropdownSelectButtonOption { + /** The select option header label. */ + readonly label: string + + /** The select option description */ + readonly description?: string | JSX.Element + + /** The select option's value */ + readonly id: string +} + +interface IDropdownSelectButtonProps { + /** The selection button options */ + readonly options: ReadonlyArray + + /** The selection option value */ + readonly checkedOption?: string + + /** Whether or not the button is enabled */ + readonly disabled?: boolean + + /** tooltip for the button */ + readonly tooltip?: string + + /** aria label for the button */ + readonly dropdownAriaLabel: string + + /** Callback for when the button selection changes*/ + readonly onCheckedOptionChange?: ( + selectedOption: IDropdownSelectButtonOption + ) => void + + /** Callback for when button is selected option button is clicked */ + readonly onSubmit?: ( + event: React.MouseEvent, + selectedOption: IDropdownSelectButtonOption + ) => void +} + +interface IDropdownSelectButtonState { + /** Whether the options are rendered */ + readonly showButtonOptions: boolean + + /** The currently checked option (not necessarily highlighted, but is the only option checked) */ + readonly checkedOption: IDropdownSelectButtonOption | null + + /** The currently selected option -> The option highlighted that if clicked or hit enter on would become checked */ + readonly selectedOption: IDropdownSelectButtonOption | null + + /** + * The adjusting position of the options popover. This is calculated based + * on if there is enough room to show the options below the dropdown button. + */ + readonly optionsPositionBottom?: string +} + +export class DropdownSelectButton extends React.Component< + IDropdownSelectButtonProps, + IDropdownSelectButtonState +> { + private invokeButtonRef: HTMLButtonElement | null = null + private dropdownButtonRef: HTMLButtonElement | null = null + private optionsContainerRef: HTMLDivElement | null = null + private dropdownSelectContainerRef = React.createRef() + + public constructor(props: IDropdownSelectButtonProps) { + super(props) + + this.state = { + showButtonOptions: false, + checkedOption: this.getCheckedOption(props.checkedOption), + selectedOption: this.getCheckedOption(props.checkedOption), + } + } + + public componentDidMount(): void { + if (this.dropdownSelectContainerRef.current) { + this.dropdownSelectContainerRef.current.addEventListener( + 'focusout', + this.onFocusOut + ) + } + } + + public componentWillUnmount(): void { + if (this.dropdownSelectContainerRef.current) { + this.dropdownSelectContainerRef.current.removeEventListener( + 'focusout', + this.onFocusOut + ) + } + } + + private onFocusOut = (event: FocusEvent) => { + if ( + this.state.showButtonOptions && + event.relatedTarget && + !this.dropdownSelectContainerRef.current?.contains( + event.relatedTarget as Node + ) + ) { + this.setState({ showButtonOptions: false }) + } + } + + public componentDidUpdate() { + if (this.invokeButtonRef === null || this.optionsContainerRef === null) { + return + } + + const windowHeight = window.innerHeight + const bottomOfButton = this.invokeButtonRef.getBoundingClientRect().bottom + const invokeButtonHeight = + this.invokeButtonRef.getBoundingClientRect().height + // 15 pixels is just to give some padding room below it + const calcMaxHeight = windowHeight - bottomOfButton - 15 + const heightOfOptions = this.optionsContainerRef.clientHeight + const optionsPositionBottom = + calcMaxHeight < heightOfOptions ? `${invokeButtonHeight}px` : undefined + if (optionsPositionBottom !== this.state.optionsPositionBottom) { + this.setState({ optionsPositionBottom }) + } + } + + private getCheckedOption( + selectedValue: string | undefined + ): IDropdownSelectButtonOption | null { + const { options } = this.props + if (options.length === 0) { + return null + } + + const selectedOption = options.find(o => o.id === selectedValue) + return selectedOption ?? options[0] + } + + private onItemClick = ( + depth: number, + item: MenuItem, + source: ClickSource + ) => { + const selectedOption = this.props.options.find(o => o.id === item.id) + + if (!selectedOption) { + return + } + + this.setState({ checkedOption: selectedOption, showButtonOptions: false }) + this.dropdownButtonRef?.focus() + this.props.onCheckedOptionChange?.(selectedOption) + } + + private onClearSelection = () => { + this.setState({ selectedOption: null }) + } + + private onPaneKeyDown = ( + depth: number | undefined, + event: React.KeyboardEvent + ) => { + if (event.key !== 'Escape' && event.key !== 'Esc') { + return + } + + event.preventDefault() + event.stopPropagation() + this.dropdownButtonRef?.focus() + this.setState({ showButtonOptions: false }) + } + + private onSelectionChanged = ( + depth: number, + item: MenuItem, + source: SelectionSource + ) => { + const selectedOption = this.props.options.find(o => o.id === item.id) + + if (!selectedOption) { + return + } + + this.setState({ selectedOption }) + } + + private openSplitButtonDropdown = () => { + this.setState({ showButtonOptions: !this.state.showButtonOptions }) + } + + private onDropdownButtonKeyDown = ( + event: React.KeyboardEvent + ) => { + const { key } = event + let flag = false + + switch (key) { + case ' ': + case 'Enter': + case 'ArrowDown': + case 'Down': + this.setState({ + selectedOption: this.props.options.at(0) ?? null, + showButtonOptions: true, + }) + flag = true + break + + case 'Esc': + case 'Escape': + this.dropdownButtonRef?.focus() + this.setState({ showButtonOptions: false }) + flag = true + break + + case 'Up': + case 'ArrowUp': + this.setState({ + selectedOption: this.props.options.at(-1) ?? null, + showButtonOptions: true, + }) + flag = true + break + + default: + break + } + + if (flag) { + event.stopPropagation() + event.preventDefault() + } + } + + private onInvokeButtonRef = (buttonRef: HTMLButtonElement | null) => { + this.invokeButtonRef = buttonRef + } + + private onDropdownButtonRef = (buttonRef: HTMLButtonElement | null) => { + this.dropdownButtonRef = buttonRef + } + + private onOptionsContainerRef = (ref: HTMLDivElement | null) => { + this.optionsContainerRef = ref + } + + private renderOption = (item: MenuItem) => { + const option = this.props.options.find(o => o.id === item.id) + if (!option) { + return + } + + return ( + <> +
{option.label}
+
{option.description}
+ + ) + } + + private getMenuItems( + options: ReadonlyArray, + checkedOptionId: string | undefined + ): ReadonlyArray { + const defaultCheckBoxMenuItem: ICheckboxMenuItem = { + type: 'checkbox', + id: '', + label: '', + checked: false, + enabled: true, + visible: true, + accelerator: null, + accessKey: null, + } + + return options.map(({ id, label }) => { + const checked = checkedOptionId === id + return { ...defaultCheckBoxMenuItem, ...{ id, label, checked } } + }) + } + + private renderSplitButtonOptions() { + const { + showButtonOptions, + checkedOption, + selectedOption, + optionsPositionBottom: bottom, + } = this.state + + if (!showButtonOptions) { + return + } + + const { options } = this.props + + const items = this.getMenuItems(options, checkedOption?.id) + const selectedItem = items.find(i => i.id === selectedOption?.id) + const openClass = bottom !== undefined ? 'open-top' : 'open-bottom' + const classes = classNames('dropdown-select-button-options', openClass) + + return ( +
+ +
+ ) + } + + private onSubmit = (event: React.MouseEvent) => { + if ( + this.props.onSubmit !== undefined && + this.state.checkedOption !== null + ) { + this.props.onSubmit(event, this.state.checkedOption) + } + } + + public render() { + const { options, disabled, dropdownAriaLabel } = this.props + const { + checkedOption: selectedOption, + optionsPositionBottom, + showButtonOptions, + } = this.state + if (options.length === 0 || selectedOption === null) { + return + } + + const openClass = + optionsPositionBottom !== undefined ? 'open-top' : 'open-bottom' + const containerClasses = classNames( + 'dropdown-select-button', + showButtonOptions ? openClass : null + ) + + const dropdownClasses = classNames('dropdown-button', { disabled }) + // The button is type of submit so that it will trigger a form's onSubmit + // method. + return ( +
+
+ + +
+ {this.renderSplitButtonOptions()} +
+ ) + } +} diff --git a/app/src/ui/editor/editor-error.tsx b/app/src/ui/editor/editor-error.tsx new file mode 100644 index 0000000000..75ef0ac071 --- /dev/null +++ b/app/src/ui/editor/editor-error.tsx @@ -0,0 +1,106 @@ +import * as React from 'react' + +import { + Dialog, + DialogContent, + DialogFooter, + DefaultDialogFooter, + OkCancelButtonGroup, +} from '../dialog' +import { shell } from '../../lib/app-shell' +import { suggestedExternalEditor } from '../../lib/editors/shared' + +interface IEditorErrorProps { + /** + * Event triggered when the dialog is dismissed by the user in the + * ways described in the Dialog component's dismissable prop. + */ + readonly onDismissed: () => void + + /** + * Event to trigger if the user navigates to the Preferences dialog + */ + readonly showPreferencesDialog: () => void + + /** + * The text to display to the user relating to this error. + */ + readonly message: string + + /** Render the "Install ${Default}" link as the default action */ + readonly suggestDefaultEditor?: boolean + + /** Render the "Open Preferences" link as the default action */ + readonly viewPreferences?: boolean +} + +/** + * A dialog indicating something went wrong with launching an external editor, + * with guidance to get the user back to a happy places + */ +export class EditorError extends React.Component { + public constructor(props: IEditorErrorProps) { + super(props) + } + + private onExternalLink = () => { + shell.openExternal(suggestedExternalEditor.url) + } + + private onShowPreferencesDialog = ( + e: React.MouseEvent + ) => { + e.preventDefault() + this.props.onDismissed() + this.props.showPreferencesDialog() + } + + private renderFooter() { + const { viewPreferences, suggestDefaultEditor } = this.props + + if (viewPreferences) { + return ( + + + + ) + } else if (suggestDefaultEditor) { + return ( + + + + ) + } + + return + } + + public render() { + const title = __DARWIN__ + ? 'Unable to Open External Editor' + : 'Unable to open external editor' + + return ( + + +

{this.props.message}

+
+ {this.renderFooter()} +
+ ) + } +} diff --git a/app/src/ui/editor/index.ts b/app/src/ui/editor/index.ts new file mode 100644 index 0000000000..b142e8cbcd --- /dev/null +++ b/app/src/ui/editor/index.ts @@ -0,0 +1 @@ +export * from './editor-error' diff --git a/app/src/ui/forks/create-fork-dialog.tsx b/app/src/ui/forks/create-fork-dialog.tsx new file mode 100644 index 0000000000..30490ffba9 --- /dev/null +++ b/app/src/ui/forks/create-fork-dialog.tsx @@ -0,0 +1,176 @@ +import * as React from 'react' +import { + Dialog, + DialogContent, + DialogFooter, + DefaultDialogFooter, +} from '../dialog' +import { Dispatcher } from '../dispatcher' +import { + RepositoryWithGitHubRepository, + isRepositoryWithForkedGitHubRepository, +} from '../../models/repository' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { sendNonFatalException } from '../../lib/helpers/non-fatal-exception' +import { Account } from '../../models/account' +import { API } from '../../lib/api' +import { LinkButton } from '../lib/link-button' +import { PopupType } from '../../models/popup' + +interface ICreateForkDialogProps { + readonly dispatcher: Dispatcher + readonly repository: RepositoryWithGitHubRepository + readonly account: Account + readonly onDismissed: () => void +} + +interface ICreateForkDialogState { + readonly loading: boolean + readonly error?: Error +} + +/** + * Dialog offering to create a fork of the given repository + */ +export class CreateForkDialog extends React.Component< + ICreateForkDialogProps, + ICreateForkDialogState +> { + public constructor(props: ICreateForkDialogProps) { + super(props) + this.state = { loading: false } + } + /** + * Starts fork process on GitHub! + */ + private onSubmit = async () => { + this.setState({ loading: true }) + const { gitHubRepository } = this.props.repository + const api = API.fromAccount(this.props.account) + try { + const fork = await api.forkRepository( + gitHubRepository.owner.login, + gitHubRepository.name + ) + this.props.dispatcher.recordForkCreated() + const updatedRepository = + await this.props.dispatcher.convertRepositoryToFork( + this.props.repository, + fork + ) + this.setState({ loading: false }) + this.props.onDismissed() + + if (isRepositoryWithForkedGitHubRepository(updatedRepository)) { + this.props.dispatcher.showPopup({ + type: PopupType.ChooseForkSettings, + repository: updatedRepository, + }) + } + } catch (e) { + log.error(`Fork creation through API failed (${e})`) + sendNonFatalException('forkCreation', e) + this.setState({ error: e, loading: false }) + } + } + + public render() { + return ( + + {this.state.error !== undefined + ? renderCreateForkDialogError( + this.props.repository, + this.props.account, + this.state.error + ) + : renderCreateForkDialogContent( + this.props.repository, + this.props.account, + this.state.loading + )} + + ) + } +} + +/** Standard (non-error) message and buttons for `CreateForkDialog` */ +function renderCreateForkDialogContent( + repository: RepositoryWithGitHubRepository, + account: Account, + loading: boolean +) { + return ( + <> + +

+ {`It looks like you don’t have write access to `} + {repository.gitHubRepository.fullName} + {`. If you should, please check with a repository administrator.`} +

+

+ {` Do you want to create a fork of this repository at `} + + {`${account.login}/${repository.gitHubRepository.name}`} + + {` to continue?`} +

+
+ + + + + ) +} + +/** Error state message (and buttons) for `CreateForkDialog` */ +function renderCreateForkDialogError( + repository: RepositoryWithGitHubRepository, + account: Account, + error: Error +) { + const suggestion = + repository.gitHubRepository.htmlURL !== null ? ( + <> + {`You can try `} + + creating the fork manually on GitHub + + . + + ) : undefined + return ( + <> + +
+ {`Creating your fork `} + + {`${account.login}/${repository.gitHubRepository.name}`} + + {` failed. `} + {suggestion} +
+
+ Error details +
{error.message}
+
+
+ + + ) +} diff --git a/app/src/ui/generic-git-auth/generic-git-auth.tsx b/app/src/ui/generic-git-auth/generic-git-auth.tsx new file mode 100644 index 0000000000..f26ebf8ac4 --- /dev/null +++ b/app/src/ui/generic-git-auth/generic-git-auth.tsx @@ -0,0 +1,120 @@ +import * as React from 'react' + +import { TextBox } from '../lib/text-box' +import { Row } from '../lib/row' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { RetryAction } from '../../models/retry-actions' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { Ref } from '../lib/ref' +import { LinkButton } from '../lib/link-button' +import { PasswordTextBox } from '../lib/password-text-box' + +interface IGenericGitAuthenticationProps { + /** The hostname with which the user tried to authenticate. */ + readonly hostname: string + + /** The function to call when the user saves their credentials. */ + readonly onSave: ( + hostname: string, + username: string, + password: string, + retryAction: RetryAction + ) => void + + /** The function to call when the user dismisses the dialog. */ + readonly onDismiss: () => void + + /** The action to retry after getting credentials. */ + readonly retryAction: RetryAction +} + +interface IGenericGitAuthenticationState { + readonly username: string + readonly password: string +} + +/** Shown to enter the credentials to authenticate to a generic git server. */ +export class GenericGitAuthentication extends React.Component< + IGenericGitAuthenticationProps, + IGenericGitAuthenticationState +> { + public constructor(props: IGenericGitAuthenticationProps) { + super(props) + + this.state = { username: '', password: '' } + } + + public render() { + const disabled = !this.state.password.length || !this.state.username.length + return ( + + +

+ We were unable to authenticate with {this.props.hostname} + . Please enter your username and password to try again. +

+ + + + + + + + + + +
+ Depending on your repository's hosting service, you might need to + use a Personal Access Token (PAT) as your password. Learn more + about creating a PAT in our{' '} + + integration docs + + . +
+
+
+ + + + +
+ ) + } + + private onUsernameChange = (value: string) => { + this.setState({ username: value }) + } + + private onPasswordChange = (value: string) => { + this.setState({ password: value }) + } + + private save = () => { + this.props.onDismiss() + + this.props.onSave( + this.props.hostname, + this.state.username, + this.state.password, + this.props.retryAction + ) + } +} diff --git a/app/src/ui/generic-git-auth/index.ts b/app/src/ui/generic-git-auth/index.ts new file mode 100644 index 0000000000..7421a21b0f --- /dev/null +++ b/app/src/ui/generic-git-auth/index.ts @@ -0,0 +1 @@ +export { GenericGitAuthentication } from './generic-git-auth' diff --git a/app/src/ui/history/commit-list-item.tsx b/app/src/ui/history/commit-list-item.tsx new file mode 100644 index 0000000000..ab2cfe069c --- /dev/null +++ b/app/src/ui/history/commit-list-item.tsx @@ -0,0 +1,248 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import * as React from 'react' +import { Commit } from '../../models/commit' +import { GitHubRepository } from '../../models/github-repository' +import { IAvatarUser, getAvatarUsersForCommit } from '../../models/avatar' +import { RichText } from '../lib/rich-text' +import { RelativeTime } from '../relative-time' +import { CommitAttribution } from '../lib/commit-attribution' +import { AvatarStack } from '../lib/avatar-stack' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Draggable } from '../lib/draggable' +import { dragAndDropManager } from '../../lib/drag-and-drop-manager' +import { + DragType, + DropTargetSelector, + DropTargetType, +} from '../../models/drag-drop' +import classNames from 'classnames' + +interface ICommitProps { + readonly gitHubRepository: GitHubRepository | null + readonly commit: Commit + readonly selectedCommits: ReadonlyArray + readonly emoji: Map + readonly onRenderCommitDragElement?: (commit: Commit) => void + readonly onRemoveDragElement?: () => void + readonly onSquash?: ( + toSquash: ReadonlyArray, + squashOnto: Commit, + isInvokedByContextMenu: boolean + ) => void + /** + * Whether or not the commit can be dragged for certain operations like squash, + * cherry-pick, reorder, etc. Defaults to false. + */ + readonly isDraggable?: boolean + readonly showUnpushedIndicator: boolean + readonly unpushedIndicatorTitle?: string + readonly disableSquashing?: boolean + readonly isMultiCommitOperationInProgress?: boolean +} + +interface ICommitListItemState { + readonly avatarUsers: ReadonlyArray +} + +/** A component which displays a single commit in a commit list. */ +export class CommitListItem extends React.PureComponent< + ICommitProps, + ICommitListItemState +> { + public constructor(props: ICommitProps) { + super(props) + + this.state = { + avatarUsers: getAvatarUsersForCommit( + props.gitHubRepository, + props.commit + ), + } + } + + public componentWillReceiveProps(nextProps: ICommitProps) { + if (nextProps.commit !== this.props.commit) { + this.setState({ + avatarUsers: getAvatarUsersForCommit( + nextProps.gitHubRepository, + nextProps.commit + ), + }) + } + } + + private onMouseUp = () => { + const { onSquash, selectedCommits, commit, disableSquashing } = this.props + if ( + disableSquashing !== true && + dragAndDropManager.isDragOfTypeInProgress(DragType.Commit) && + onSquash !== undefined && + // don't squash if dragging one commit and dropping onto itself + selectedCommits.filter(c => c.sha !== commit.sha).length > 0 + ) { + onSquash(selectedCommits, commit, false) + } + } + + private onMouseEnter = () => { + const { selectedCommits, commit, disableSquashing } = this.props + const isSelected = + selectedCommits.find(c => c.sha === commit.sha) !== undefined + if ( + disableSquashing !== true && + dragAndDropManager.isDragOfTypeInProgress(DragType.Commit) && + !isSelected + ) { + dragAndDropManager.emitEnterDropTarget({ + type: DropTargetType.Commit, + }) + } + } + + private onMouseLeave = () => { + if (dragAndDropManager.isDragOfTypeInProgress(DragType.Commit)) { + dragAndDropManager.emitLeaveDropTarget() + } + } + + public render() { + const { commit } = this.props + const { + author: { date }, + } = commit + + const isDraggable = this.props.isDraggable || false + const hasEmptySummary = commit.summary.length === 0 + const commitSummary = hasEmptySummary + ? 'Empty commit message' + : commit.summary + + const summaryClassNames = classNames('summary', { + 'empty-summary': hasEmptySummary, + }) + + return ( + +
+
+ +
+ +
+ + {renderRelativeTime(date)} +
+
+
+ {this.renderCommitIndicators()} +
+
+ ) + } + + private renderCommitIndicators() { + const tagIndicator = renderCommitListItemTags(this.props.commit.tags) + const unpushedIndicator = this.renderUnpushedIndicator() + + if (tagIndicator || unpushedIndicator) { + return ( +
+ {tagIndicator} + {unpushedIndicator} +
+ ) + } + + return null + } + + private renderUnpushedIndicator() { + if (!this.props.showUnpushedIndicator) { + return null + } + + return ( +
+ +
+ ) + } + + private onDragStart = () => { + // Removes active status from commit selection so they do not appear + // highlighted in commit list. + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + dragAndDropManager.setDragData({ + type: DragType.Commit, + commits: this.props.selectedCommits, + }) + } + + private onRenderCommitDragElement = () => { + if (this.props.onRenderCommitDragElement !== undefined) { + this.props.onRenderCommitDragElement(this.props.commit) + } + } + + private onRemoveDragElement = () => { + if (this.props.onRemoveDragElement !== undefined) { + this.props.onRemoveDragElement() + } + } +} + +function renderRelativeTime(date: Date) { + return ( + <> + {` • `} + + + ) +} + +function renderCommitListItemTags(tags: ReadonlyArray) { + if (tags.length === 0) { + return null + } + const [firstTag] = tags + return ( + + + {firstTag} + + {tags.length > 1 && ( + + )} + + ) +} diff --git a/app/src/ui/history/commit-list.tsx b/app/src/ui/history/commit-list.tsx new file mode 100644 index 0000000000..5c3db3b6d1 --- /dev/null +++ b/app/src/ui/history/commit-list.tsx @@ -0,0 +1,763 @@ +import * as React from 'react' +import memoize from 'memoize-one' +import { GitHubRepository } from '../../models/github-repository' +import { Commit, CommitOneLine } from '../../models/commit' +import { CommitListItem } from './commit-list-item' +import { List } from '../lib/list' +import { arrayEquals } from '../../lib/equality' +import { DragData, DragType } from '../../models/drag-drop' +import classNames from 'classnames' +import memoizeOne from 'memoize-one' +import { IMenuItem, showContextualMenu } from '../../lib/menu-item' +import { + enableCheckoutCommit, + enableResetToCommit, +} from '../../lib/feature-flag' +import { getDotComAPIEndpoint } from '../../lib/api' +import { clipboard } from 'electron' + +const RowHeight = 50 + +interface ICommitListProps { + /** The GitHub repository associated with this commit (if found) */ + readonly gitHubRepository: GitHubRepository | null + + /** The list of commits SHAs to display, in order. */ + readonly commitSHAs: ReadonlyArray + + /** The commits loaded, keyed by their full SHA. */ + readonly commitLookup: Map + + /** The SHAs of the selected commits */ + readonly selectedSHAs: ReadonlyArray + + /** Whether or not commits in this list can be undone. */ + readonly canUndoCommits?: boolean + + /** Whether or not commits in this list can be amended. */ + readonly canAmendCommits?: boolean + + /** Whether or the user can reset to commits in this list. */ + readonly canResetToCommits?: boolean + + /** The emoji lookup to render images inline */ + readonly emoji: Map + + /** The list of known local commits for the current branch */ + readonly localCommitSHAs: ReadonlyArray + + /** The message to display inside the list when no results are displayed */ + readonly emptyListMessage?: JSX.Element | string + + /** Callback which fires when a commit has been selected in the list */ + readonly onCommitsSelected?: ( + commits: ReadonlyArray, + isContiguous: boolean + ) => void + + /** Callback that fires when a scroll event has occurred */ + readonly onScroll?: (start: number, end: number) => void + + /** Callback to fire to undo a given commit in the current repository */ + readonly onUndoCommit?: (commit: Commit) => void + + /** Callback to fire to reset to a given commit in the current repository */ + readonly onResetToCommit?: (commit: Commit) => void + + /** Callback to fire to revert a given commit in the current repository */ + readonly onRevertCommit?: (commit: Commit) => void + + readonly onAmendCommit?: (commit: Commit, isLocalCommit: boolean) => void + + /** Callback to fire to open a given commit on GitHub */ + readonly onViewCommitOnGitHub?: (sha: string) => void + + /** + * Callback to fire to create a branch from a given commit in the current + * repository + */ + readonly onCreateBranch?: (commit: CommitOneLine) => void + + /** + * Callback to fire to checkout the selected commit in the current + * repository + */ + readonly onCheckoutCommit?: (commit: CommitOneLine) => void + + /** Callback to fire to open the dialog to create a new tag on the given commit */ + readonly onCreateTag?: (targetCommitSha: string) => void + + /** Callback to fire to delete an unpushed tag */ + readonly onDeleteTag?: (tagName: string) => void + + /** + * A handler called whenever the user drops commits on the list to be inserted. + * + * @param baseCommit - The commit before the selected commits will be inserted. + * This will be null when commits must be inserted at the + * end of the list. + * @param commitsToInsert - The commits dropped by the user. + */ + readonly onDropCommitInsertion?: ( + baseCommit: Commit | null, + commitsToInsert: ReadonlyArray, + lastRetainedCommitRef: string | null + ) => void + + /** Callback to fire to cherry picking the commit */ + readonly onCherryPick?: (commits: ReadonlyArray) => void + + /** Callback to fire to squashing commits */ + readonly onSquash?: ( + toSquash: ReadonlyArray, + squashOnto: Commit, + lastRetainedCommitRef: string | null, + isInvokedByContextMenu: boolean + ) => void + + /** + * Optional callback that fires on page scroll in order to allow passing + * a new scrollTop value up to the parent component for storing. + */ + readonly onCompareListScrolled?: (scrollTop: number) => void + + /* The scrollTop of the compareList. It is stored to allow for scroll position persistence */ + readonly compareListScrollTop?: number + + /* Whether the repository is local (it has no remotes) */ + readonly isLocalRepository: boolean + + /* Tags that haven't been pushed yet. This is used to show the unpushed indicator */ + readonly tagsToPush?: ReadonlyArray + + /** Whether or not commits in this list can be reordered. */ + readonly reorderingEnabled?: boolean + + /** Whether a multi commit operation is in progress (in particular the + * conflicts resolution step allows interaction with history) */ + readonly isMultiCommitOperationInProgress?: boolean + + /** Callback to render commit drag element */ + readonly onRenderCommitDragElement?: ( + commit: Commit, + selectedCommits: ReadonlyArray + ) => void + + /** Callback to remove commit drag element */ + readonly onRemoveCommitDragElement?: () => void + + /** Whether squashing should be enabled on the commit list */ + readonly disableSquashing?: boolean + + /** Shas that should be highlighted */ + readonly shasToHighlight?: ReadonlyArray +} + +/** A component which displays the list of commits. */ +export class CommitList extends React.Component { + private commitsHash = memoize(makeCommitsHash, arrayEquals) + private commitIndexBySha = memoizeOne( + (commitSHAs: ReadonlyArray) => + new Map(commitSHAs.map((sha, index) => [sha, index])) + ) + + private listRef = React.createRef() + + private getVisibleCommits(): ReadonlyArray { + const commits = new Array() + for (const sha of this.props.commitSHAs) { + const commitMaybe = this.props.commitLookup.get(sha) + // this should never be undefined, but just in case + if (commitMaybe !== undefined) { + commits.push(commitMaybe) + } + } + return commits + } + + private isLocalCommit = (sha: string) => + this.props.localCommitSHAs.includes(sha) + + private renderCommit = (row: number) => { + const sha = this.props.commitSHAs[row] + const commit = this.props.commitLookup.get(sha) + + if (commit == null) { + if (__DEV__) { + log.warn( + `[CommitList]: the commit '${sha}' does not exist in the cache` + ) + } + return null + } + + const isLocal = this.isLocalCommit(commit.sha) + const unpushedTags = this.getUnpushedTags(commit) + + const showUnpushedIndicator = + (isLocal || unpushedTags.length > 0) && + this.props.isLocalRepository === false + + return ( + + ) + } + + private getLastRetainedCommitRef(indexes: ReadonlyArray) { + const maxIndex = Math.max(...indexes) + const lastIndex = this.props.commitSHAs.length - 1 + /* If the commit is the first commit in the branch, you cannot reference it + using the sha */ + const lastRetainedCommitRef = + maxIndex !== lastIndex ? `${this.props.commitSHAs[maxIndex]}^` : null + return lastRetainedCommitRef + } + + private onSquash = ( + toSquash: ReadonlyArray, + squashOnto: Commit, + isInvokedByContextMenu: boolean + ) => { + const indexes = [...toSquash, squashOnto].map(v => + this.props.commitSHAs.findIndex(sha => sha === v.sha) + ) + this.props.onSquash?.( + toSquash, + squashOnto, + this.getLastRetainedCommitRef(indexes), + isInvokedByContextMenu + ) + } + + private onRenderCommitDragElement = (commit: Commit) => { + this.props.onRenderCommitDragElement?.(commit, this.selectedCommits) + } + + private getUnpushedIndicatorTitle( + isLocalCommit: boolean, + numUnpushedTags: number + ) { + if (isLocalCommit) { + return 'This commit has not been pushed to the remote repository' + } + + if (numUnpushedTags > 0) { + return `This commit has ${numUnpushedTags} tag${ + numUnpushedTags > 1 ? 's' : '' + } to push` + } + + return undefined + } + + private get selectedCommits() { + return this.lookupCommits(this.props.selectedSHAs) + } + + private getUnpushedTags(commit: Commit) { + const tagsToPushSet = new Set(this.props.tagsToPush ?? []) + return commit.tags.filter(tagName => tagsToPushSet.has(tagName)) + } + + private onSelectionChanged = (rows: ReadonlyArray) => { + const selectedShas = rows.map(r => this.props.commitSHAs[r]) + const selectedCommits = this.lookupCommits(selectedShas) + this.props.onCommitsSelected?.(selectedCommits, this.isContiguous(rows)) + } + + /** + * Accepts a sorted array of numbers in descending order. If the numbers ar + * contiguous order, 4, 3, 2 not 5, 3, 1, returns true. + * + * Defined an array of 0 and 1 are considered contiguous. + */ + private isContiguous(indexes: ReadonlyArray) { + if (indexes.length <= 1) { + return true + } + + const sorted = [...indexes].sort((a, b) => b - a) + + for (let i = 0; i < sorted.length; i++) { + const current = sorted[i] + if (i + 1 === sorted.length) { + continue + } + + if (current - 1 !== sorted[i + 1]) { + return false + } + } + + return true + } + + // This is required along with onSelectedRangeChanged in the case of a user + // paging up/down or using arrow keys up/down. + private onSelectedRowChanged = (row: number) => { + const sha = this.props.commitSHAs[row] + const commit = this.props.commitLookup.get(sha) + if (commit) { + this.props.onCommitsSelected?.([commit], true) + } + } + + private lookupCommits( + commitSHAs: ReadonlyArray + ): ReadonlyArray { + const commits: Commit[] = [] + commitSHAs.forEach(sha => { + const commit = this.props.commitLookup.get(sha) + if (commit === undefined) { + log.warn( + '[Commit List] - Unable to lookup commit from sha - This should not happen.' + ) + return + } + commits.push(commit) + }) + return commits + } + + private onScroll = (scrollTop: number, clientHeight: number) => { + const numberOfRows = Math.ceil(clientHeight / RowHeight) + const top = Math.floor(scrollTop / RowHeight) + const bottom = top + numberOfRows + this.props.onScroll?.(top, bottom) + + // Pass new scroll value so the scroll position will be remembered (if the callback has been supplied). + this.props.onCompareListScrolled?.(scrollTop) + } + + private rowForSHA(sha: string) { + return this.commitIndexBySha(this.props.commitSHAs).get(sha) ?? -1 + } + + private getRowCustomClassMap = () => { + const { commitSHAs, shasToHighlight } = this.props + if (shasToHighlight === undefined || shasToHighlight.length === 0) { + return undefined + } + + const rowsForShasNotInDiff = commitSHAs + .filter(sha => shasToHighlight.includes(sha)) + .map(sha => this.rowForSHA(sha)) + + if (rowsForShasNotInDiff.length === 0) { + return undefined + } + + const rowClassMap = new Map>() + rowClassMap.set('highlighted', rowsForShasNotInDiff) + return rowClassMap + } + + public focus() { + this.listRef.current?.focus() + } + + public render() { + const { + commitSHAs, + selectedSHAs, + shasToHighlight, + emptyListMessage, + reorderingEnabled, + isMultiCommitOperationInProgress, + } = this.props + if (commitSHAs.length === 0) { + return ( +
+ {emptyListMessage ?? 'No commits to list'} +
+ ) + } + + const classes = classNames({ + 'has-highlighted-commits': + shasToHighlight !== undefined && shasToHighlight.length > 0, + }) + + const selectedRows = selectedSHAs + .map(sha => this.rowForSHA(sha)) + .filter(r => r !== -1) + + return ( +
+ +
+ ) + } + + private onRowContextMenu = ( + row: number, + event: React.MouseEvent + ) => { + event.preventDefault() + + const sha = this.props.commitSHAs[row] + const commit = this.props.commitLookup.get(sha) + if (commit === undefined) { + if (__DEV__) { + log.warn( + `[CommitList]: the commit '${sha}' does not exist in the cache` + ) + } + return + } + + let items: IMenuItem[] = [] + if (this.props.selectedSHAs.length > 1) { + items = this.getContextMenuMultipleCommits(commit) + } else { + items = this.getContextMenuForSingleCommit(row, commit) + } + + showContextualMenu(items) + } + + private getContextMenuForSingleCommit( + row: number, + commit: Commit + ): IMenuItem[] { + const isLocal = this.isLocalCommit(commit.sha) + + const canBeUndone = + this.props.canUndoCommits === true && isLocal && row === 0 + const canBeAmended = this.props.canAmendCommits === true && row === 0 + // The user can reset to any commit up to the first non-local one (included). + // They cannot reset to the most recent commit... because they're already + // in it. + const isResettableCommit = + row > 0 && row <= this.props.localCommitSHAs.length + const canBeResetTo = + this.props.canResetToCommits === true && isResettableCommit + const canBeCheckedOut = row > 0 //Cannot checkout the current commit + + let viewOnGitHubLabel = 'View on GitHub' + const gitHubRepository = this.props.gitHubRepository + + if ( + gitHubRepository && + gitHubRepository.endpoint !== getDotComAPIEndpoint() + ) { + viewOnGitHubLabel = 'View on GitHub Enterprise' + } + + const items: IMenuItem[] = [] + + if (canBeAmended) { + items.push({ + label: __DARWIN__ ? 'Amend Commit…' : 'Amend commit…', + action: () => this.props.onAmendCommit?.(commit, isLocal), + }) + } + + if (canBeUndone) { + items.push({ + label: __DARWIN__ ? 'Undo Commit…' : 'Undo commit…', + action: () => { + if (this.props.onUndoCommit) { + this.props.onUndoCommit(commit) + } + }, + enabled: this.props.onUndoCommit !== undefined, + }) + } + + if (enableResetToCommit()) { + items.push({ + label: __DARWIN__ ? 'Reset to Commit…' : 'Reset to commit…', + action: () => { + if (this.props.onResetToCommit) { + this.props.onResetToCommit(commit) + } + }, + enabled: canBeResetTo && this.props.onResetToCommit !== undefined, + }) + } + + if (enableCheckoutCommit()) { + items.push({ + label: __DARWIN__ ? 'Checkout Commit' : 'Checkout commit', + action: () => { + this.props.onCheckoutCommit?.(commit) + }, + enabled: canBeCheckedOut && this.props.onCheckoutCommit !== undefined, + }) + } + + items.push( + { + label: __DARWIN__ + ? 'Revert Changes in Commit' + : 'Revert changes in commit', + action: () => { + if (this.props.onRevertCommit) { + this.props.onRevertCommit(commit) + } + }, + enabled: this.props.onRevertCommit !== undefined, + }, + { type: 'separator' }, + { + label: __DARWIN__ + ? 'Create Branch from Commit' + : 'Create branch from commit', + action: () => { + if (this.props.onCreateBranch) { + this.props.onCreateBranch(commit) + } + }, + }, + { + label: 'Create Tag…', + action: () => this.props.onCreateTag?.(commit.sha), + enabled: this.props.onCreateTag !== undefined, + } + ) + + const deleteTagsMenuItem = this.getDeleteTagsMenuItem(commit) + + if (deleteTagsMenuItem !== null) { + items.push( + { + type: 'separator', + }, + deleteTagsMenuItem + ) + } + const darwinTagsLabel = commit.tags.length > 1 ? 'Copy Tags' : 'Copy Tag' + const windowTagsLabel = commit.tags.length > 1 ? 'Copy tags' : 'Copy tag' + items.push( + { + label: __DARWIN__ ? 'Cherry-pick Commit…' : 'Cherry-pick commit…', + action: () => this.props.onCherryPick?.(this.selectedCommits), + enabled: this.canCherryPick(), + }, + { type: 'separator' }, + { + label: 'Copy SHA', + action: () => clipboard.writeText(commit.sha), + }, + { + label: __DARWIN__ ? darwinTagsLabel : windowTagsLabel, + action: () => clipboard.writeText(commit.tags.join(' ')), + enabled: commit.tags.length > 0, + }, + { + label: viewOnGitHubLabel, + action: () => this.props.onViewCommitOnGitHub?.(commit.sha), + enabled: !isLocal && !!gitHubRepository, + } + ) + + return items + } + + private canCherryPick(): boolean { + const { onCherryPick, isMultiCommitOperationInProgress } = this.props + return ( + onCherryPick !== undefined && isMultiCommitOperationInProgress === false + ) + } + + private canSquash(): boolean { + const { onSquash, disableSquashing, isMultiCommitOperationInProgress } = + this.props + return ( + onSquash !== undefined && + disableSquashing === false && + isMultiCommitOperationInProgress === false + ) + } + + private getDeleteTagsMenuItem(commit: Commit): IMenuItem | null { + const { onDeleteTag } = this.props + const unpushedTags = this.getUnpushedTags(commit) + + if ( + onDeleteTag === undefined || + unpushedTags === undefined || + commit.tags.length === 0 + ) { + return null + } + + if (commit.tags.length === 1) { + const tagName = commit.tags[0] + + return { + label: `Delete tag ${tagName}`, + action: () => onDeleteTag(tagName), + enabled: unpushedTags.includes(tagName), + } + } + + // Convert tags to a Set to avoid O(n^2) + const unpushedTagsSet = new Set(unpushedTags) + + return { + label: 'Delete tag…', + submenu: commit.tags.map(tagName => { + return { + label: tagName, + action: () => onDeleteTag(tagName), + enabled: unpushedTagsSet.has(tagName), + } + }), + } + } + + private getContextMenuMultipleCommits(commit: Commit): IMenuItem[] { + const count = this.props.selectedSHAs.length + + return [ + { + label: __DARWIN__ + ? `Cherry-pick ${count} Commits…` + : `Cherry-pick ${count} commits…`, + action: () => this.props.onCherryPick?.(this.selectedCommits), + enabled: this.canCherryPick(), + }, + { + label: __DARWIN__ + ? `Squash ${count} Commits…` + : `Squash ${count} commits…`, + action: () => this.onSquash(this.selectedCommits, commit, true), + enabled: this.canSquash(), + }, + ] + } + + private onDropDataInsertion = (row: number, data: DragData) => { + if ( + this.props.onDropCommitInsertion === undefined || + data.type !== DragType.Commit + ) { + return + } + + // The base commit index will be in row - 1, because row is the position + // where the new item should be inserted, and commits have a reverse order + // (newer commits are in lower row values) in the list. + const baseCommitIndex = row === 0 ? null : row - 1 + + if ( + this.props.commitSHAs.length === 0 || + (baseCommitIndex !== null && + baseCommitIndex > this.props.commitSHAs.length) + ) { + return + } + + const baseCommitSHA = + baseCommitIndex === null ? null : this.props.commitSHAs[baseCommitIndex] + const baseCommit = + baseCommitSHA !== null ? this.props.commitLookup.get(baseCommitSHA) : null + + const commitIndexes = data.commits + .filter((v): v is Commit => v !== null && v !== undefined) + .map(v => this.props.commitSHAs.findIndex(sha => sha === v.sha)) + .sort() // Required to check if they're contiguous + + // Check if values in commit indexes are contiguous + const commitsAreContiguous = commitIndexes.every((value, i, array) => { + return i === array.length - 1 || value === array[i + 1] - 1 + }) + + // If commits are contiguous and they are dropped in a position contained + // among those indexes, ignore the drop. + if (commitsAreContiguous) { + const firstDroppedCommitIndex = commitIndexes[0] + + // Commits are dropped right above themselves if + // 1. The base commit index is null (meaning, it was dropped at the top + // of the commit list) and the index of the first dropped commit is 0. + // 2. The base commit index is the index right above the first dropped. + const commitsDroppedRightAboveThemselves = + (baseCommitIndex === null && firstDroppedCommitIndex === 0) || + baseCommitIndex === firstDroppedCommitIndex - 1 + + // Commits are dropped within themselves if there is a base commit index + // and it's in the list of commit indexes. + const commitsDroppedWithinThemselves = + baseCommitIndex !== null && + commitIndexes.indexOf(baseCommitIndex) !== -1 + + if ( + commitsDroppedRightAboveThemselves || + commitsDroppedWithinThemselves + ) { + return + } + } + + const allIndexes = commitIndexes.concat( + baseCommitIndex !== null ? [baseCommitIndex] : [] + ) + + this.props.onDropCommitInsertion( + baseCommit ?? null, + data.commits, + this.getLastRetainedCommitRef(allIndexes) + ) + } +} + +/** + * Makes a hash of the commit's data that will be shown in a CommitListItem + */ +function commitListItemHash(commit: Commit): string { + return `${commit.sha} ${commit.tags}` +} + +function makeCommitsHash(commits: ReadonlyArray): string { + return commits.map(commitListItemHash).join(' ') +} diff --git a/app/src/ui/history/commit-summary.tsx b/app/src/ui/history/commit-summary.tsx new file mode 100644 index 0000000000..e331a72046 --- /dev/null +++ b/app/src/ui/history/commit-summary.tsx @@ -0,0 +1,667 @@ +import * as React from 'react' +import classNames from 'classnames' + +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { RichText } from '../lib/rich-text' +import { Repository } from '../../models/repository' +import { Commit } from '../../models/commit' +import { getAvatarUsersForCommit, IAvatarUser } from '../../models/avatar' +import { AvatarStack } from '../lib/avatar-stack' +import { CommitAttribution } from '../lib/commit-attribution' +import { Tokenizer, TokenResult } from '../../lib/text-token-parser' +import { wrapRichTextCommitMessage } from '../../lib/wrap-rich-text-commit-message' +import { DiffOptions } from '../diff/diff-options' +import { IChangesetData } from '../../lib/git' +import { TooltippedContent } from '../lib/tooltipped-content' +import { AppFileStatusKind } from '../../models/status' +import _ from 'lodash' +import { LinkButton } from '../lib/link-button' +import { UnreachableCommitsTab } from './unreachable-commits-dialog' +import { TooltippedCommitSHA } from '../lib/tooltipped-commit-sha' +import memoizeOne from 'memoize-one' + +interface ICommitSummaryProps { + readonly repository: Repository + readonly selectedCommits: ReadonlyArray + readonly shasInDiff: ReadonlyArray + readonly changesetData: IChangesetData + readonly emoji: Map + + /** + * Whether or not the commit body container should + * be rendered expanded or not. In expanded mode the + * commit body container takes over the diff view + * allowing for full height, scrollable reading of + * the commit message body. + */ + readonly isExpanded: boolean + + readonly onExpandChanged: (isExpanded: boolean) => void + + readonly onDescriptionBottomChanged: (descriptionBottom: number) => void + + readonly hideDescriptionBorder: boolean + + readonly hideWhitespaceInDiff: boolean + + /** Whether we should display side by side diffs. */ + readonly showSideBySideDiff: boolean + readonly onHideWhitespaceInDiffChanged: (checked: boolean) => Promise + + /** Called when the user changes the side by side diffs setting. */ + readonly onShowSideBySideDiffChanged: (checked: boolean) => void + + /** Called when the user opens the diff options popover */ + readonly onDiffOptionsOpened: () => void + + /** Called to highlight certain shas in the history */ + readonly onHighlightShas: (shasToHighlight: ReadonlyArray) => void + + /** Called to show unreachable commits dialog */ + readonly showUnreachableCommits: (tab: UnreachableCommitsTab) => void +} + +interface ICommitSummaryState { + /** + * The commit message summary, i.e. the first line in the commit message. + * Note that this may differ from the body property in the commit object + * passed through props, see the createState method for more details. + */ + readonly summary: ReadonlyArray + + /** + * Whether the commit summary was empty. + */ + readonly hasEmptySummary: boolean + + /** + * The commit message body, i.e. anything after the first line of text in the + * commit message. Note that this may differ from the body property in the + * commit object passed through props, see the createState method for more + * details. + */ + readonly body: ReadonlyArray + + /** + * Whether or not the commit body text overflows its container. Used in + * conjunction with the isExpanded prop. + */ + readonly isOverflowed: boolean + + /** + * The avatars associated with this commit. Used when rendering + * the avatar stack and calculated whenever the commit prop changes. + */ + readonly avatarUsers: ReadonlyArray +} + +/** + * Creates the state object for the CommitSummary component. + * + * Ensures that the commit summary never exceeds 72 characters and wraps it + * into the commit body if it does. + * + * @param isOverflowed Whether or not the component should render the commit + * body in expanded mode, see the documentation for the + * isOverflowed state property. + * + * @param props The current commit summary prop object. + */ +function createState( + isOverflowed: boolean, + props: ICommitSummaryProps +): ICommitSummaryState { + const { emoji, repository, selectedCommits } = props + const tokenizer = new Tokenizer(emoji, repository) + + const { summary, body } = wrapRichTextCommitMessage( + getCommitSummary(selectedCommits), + selectedCommits[0].body, + tokenizer + ) + + const hasEmptySummary = + selectedCommits.length === 1 && selectedCommits[0].summary.length === 0 + + const allAvatarUsers = selectedCommits.flatMap(c => + getAvatarUsersForCommit(repository.gitHubRepository, c) + ) + + const avatarUsers = _.uniqWith( + allAvatarUsers, + (a, b) => a.email === b.email && a.name === b.name + ) + + return { isOverflowed, summary, body, avatarUsers, hasEmptySummary } +} + +function getCommitSummary(selectedCommits: ReadonlyArray) { + return selectedCommits[0].summary.length === 0 + ? 'Empty commit message' + : selectedCommits[0].summary +} + +/** + * Helper function which determines if two commit objects + * have the same commit summary and body. + */ +function messageEquals(x: Commit, y: Commit) { + return x.summary === y.summary && x.body === y.body +} + +export class CommitSummary extends React.Component< + ICommitSummaryProps, + ICommitSummaryState +> { + private descriptionScrollViewRef: HTMLDivElement | null = null + private readonly resizeObserver: ResizeObserver | null = null + private updateOverflowTimeoutId: NodeJS.Immediate | null = null + private descriptionRef: HTMLDivElement | null = null + + private getCountCommitsNotInDiff = memoizeOne( + ( + selectedCommits: ReadonlyArray, + shasInDiff: ReadonlyArray + ) => { + if (selectedCommits.length === 1) { + return 0 + } else { + const shas = new Set(shasInDiff) + return selectedCommits.reduce( + (acc, c) => acc + (shas.has(c.sha) ? 0 : 1), + 0 + ) + } + } + ) + + public constructor(props: ICommitSummaryProps) { + super(props) + + this.state = createState(false, props) + + const ResizeObserverClass: typeof ResizeObserver = (window as any) + .ResizeObserver + + if (ResizeObserverClass || false) { + this.resizeObserver = new ResizeObserverClass(entries => { + for (const entry of entries) { + if (entry.target === this.descriptionScrollViewRef) { + // We might end up causing a recursive update by updating the state + // when we're reacting to a resize so we'll defer it until after + // react is done with this frame. + if (this.updateOverflowTimeoutId !== null) { + clearImmediate(this.updateOverflowTimeoutId) + } + + this.updateOverflowTimeoutId = setImmediate(this.onResized) + } + } + }) + } + } + + private onResized = () => { + if (this.descriptionRef) { + const descriptionBottom = + this.descriptionRef.getBoundingClientRect().bottom + this.props.onDescriptionBottomChanged(descriptionBottom) + } + + if (this.props.isExpanded) { + return + } + + this.updateOverflow() + } + + private onDescriptionScrollViewRef = (ref: HTMLDivElement | null) => { + this.descriptionScrollViewRef = ref + + if (this.resizeObserver) { + this.resizeObserver.disconnect() + + if (ref) { + this.resizeObserver.observe(ref) + } else { + this.setState({ isOverflowed: false }) + } + } + } + + private onDescriptionRef = (ref: HTMLDivElement | null) => { + this.descriptionRef = ref + } + + private renderExpander() { + if ( + !this.state.body.length || + (!this.props.isExpanded && !this.state.isOverflowed) + ) { + return null + } + + const expanded = this.props.isExpanded + const onClick = expanded ? this.onCollapse : this.onExpand + const icon = expanded ? OcticonSymbol.fold : OcticonSymbol.unfold + + return ( + + ) + } + + private onExpand = () => { + this.props.onExpandChanged(true) + } + + private onCollapse = () => { + if (this.descriptionScrollViewRef) { + this.descriptionScrollViewRef.scrollTop = 0 + } + + this.props.onExpandChanged(false) + } + + private updateOverflow() { + const scrollView = this.descriptionScrollViewRef + if (scrollView) { + this.setState({ + isOverflowed: scrollView.scrollHeight > scrollView.offsetHeight, + }) + } else { + if (this.state.isOverflowed) { + this.setState({ isOverflowed: false }) + } + } + } + + public componentDidMount() { + // No need to check if it overflows if we're expanded + if (!this.props.isExpanded) { + this.updateOverflow() + } + } + + public componentWillUpdate(nextProps: ICommitSummaryProps) { + if ( + nextProps.selectedCommits.length !== this.props.selectedCommits.length || + !nextProps.selectedCommits.every((nextCommit, i) => + messageEquals(nextCommit, this.props.selectedCommits[i]) + ) + ) { + this.setState(createState(false, nextProps)) + } + } + + public componentDidUpdate( + prevProps: ICommitSummaryProps, + prevState: ICommitSummaryState + ) { + // No need to check if it overflows if we're expanded + if (!this.props.isExpanded) { + // If the body has changed or we've just toggled the expanded + // state we'll recalculate whether we overflow or not. + if (prevState.body !== this.state.body || prevProps.isExpanded) { + this.updateOverflow() + } + } else { + // Clear overflow state if we're expanded, we don't need it. + if (this.state.isOverflowed) { + this.setState({ isOverflowed: false }) + } + } + } + + private renderDescription() { + if (this.state.body.length === 0) { + return null + } + + return ( +
+
+ +
+ + {this.renderExpander()} +
+ ) + } + + private onHighlightShasInDiff = () => { + this.props.onHighlightShas(this.props.shasInDiff) + } + + private onHighlightShasNotInDiff = () => { + const { onHighlightShas, selectedCommits, shasInDiff } = this.props + onHighlightShas( + selectedCommits.filter(c => !shasInDiff.includes(c.sha)).map(c => c.sha) + ) + } + + private onRemoveHighlightOfShas = () => { + this.props.onHighlightShas([]) + } + + private showUnreachableCommits = () => { + this.props.showUnreachableCommits(UnreachableCommitsTab.Unreachable) + } + + private showReachableCommits = () => { + this.props.showUnreachableCommits(UnreachableCommitsTab.Reachable) + } + + private renderCommitsNotReachable = () => { + const { selectedCommits, shasInDiff } = this.props + if (selectedCommits.length === 1) { + return + } + + const excludedCommitsCount = this.getCountCommitsNotInDiff( + selectedCommits, + shasInDiff + ) + + if (excludedCommitsCount === 0) { + return + } + + const commitsPluralized = excludedCommitsCount > 1 ? 'commits' : 'commit' + + return ( + // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events +
+ + + {excludedCommitsCount} unreachable {commitsPluralized} + {' '} + not included. +
+ ) + } + + private renderAuthors = () => { + const { selectedCommits, repository } = this.props + const { avatarUsers } = this.state + if (selectedCommits.length > 1) { + return + } + + return ( +
  • + + +
  • + ) + } + + private renderCommitRef = () => { + const { selectedCommits } = this.props + if (selectedCommits.length > 1) { + return + } + + return ( +
  • + + +
  • + ) + } + + private renderSummary = () => { + const { selectedCommits, shasInDiff } = this.props + const { summary, hasEmptySummary } = this.state + const summaryClassNames = classNames('commit-summary-title', { + 'empty-summary': hasEmptySummary, + }) + + if (selectedCommits.length === 1) { + return ( + + ) + } + + const commitsNotInDiff = this.getCountCommitsNotInDiff( + selectedCommits, + shasInDiff + ) + const numInDiff = selectedCommits.length - commitsNotInDiff + const commitsPluralized = numInDiff > 1 ? 'commits' : 'commit' + return ( +
    + Showing changes from{' '} + {commitsNotInDiff > 0 ? ( + + {numInDiff} {commitsPluralized} + + ) : ( + <> + {' '} + {numInDiff} {commitsPluralized} + + )} +
    + ) + } + + public render() { + const className = classNames({ + expanded: this.props.isExpanded, + collapsed: !this.props.isExpanded, + 'has-expander': this.props.isExpanded || this.state.isOverflowed, + 'hide-description-border': this.props.hideDescriptionBorder, + }) + + return ( +
    +
    + {this.renderSummary()} +
      + {this.renderAuthors()} + {this.renderCommitRef()} + {this.renderChangedFilesDescription()} + {this.renderLinesChanged()} + {this.renderTags()} + +
    • + +
    • +
    +
    + + {this.renderDescription()} + {this.renderCommitsNotReachable()} +
    + ) + } + + private renderChangedFilesDescription = () => { + const fileCount = this.props.changesetData.files.length + const filesPlural = fileCount === 1 ? 'file' : 'files' + const filesShortDescription = `${fileCount} changed ${filesPlural}` + + let filesAdded = 0 + let filesModified = 0 + let filesRemoved = 0 + let filesRenamed = 0 + for (const file of this.props.changesetData.files) { + switch (file.status.kind) { + case AppFileStatusKind.New: + filesAdded += 1 + break + case AppFileStatusKind.Modified: + filesModified += 1 + break + case AppFileStatusKind.Deleted: + filesRemoved += 1 + break + case AppFileStatusKind.Renamed: + filesRenamed += 1 + } + } + + const hasFileDescription = + filesAdded + filesModified + filesRemoved + filesRenamed > 0 + + const filesLongDescription = ( + <> + {filesAdded > 0 ? ( + + + {filesAdded} added + + ) : null} + {filesModified > 0 ? ( + + + {filesModified} modified + + ) : null} + {filesRemoved > 0 ? ( + + + {filesRemoved} deleted + + ) : null} + {filesRenamed > 0 ? ( + + + {filesRenamed} renamed + + ) : null} + + ) + + return ( + 0 && hasFileDescription ? filesLongDescription : undefined + } + > + + {filesShortDescription} + + ) + } + + private renderLinesChanged() { + const linesAdded = this.props.changesetData.linesAdded + const linesDeleted = this.props.changesetData.linesDeleted + if (linesAdded + linesDeleted === 0) { + return null + } + + const linesAddedPlural = linesAdded === 1 ? 'line' : 'lines' + const linesDeletedPlural = linesDeleted === 1 ? 'line' : 'lines' + const linesAddedTitle = `${linesAdded} ${linesAddedPlural} added` + const linesDeletedTitle = `${linesDeleted} ${linesDeletedPlural} deleted` + + return ( + <> + + +{linesAdded} + + + -{linesDeleted} + + + ) + } + + private renderTags() { + const { selectedCommits } = this.props + if (selectedCommits.length > 1) { + return + } + + const tags = selectedCommits[0].tags + + if (tags.length === 0) { + return + } + + return ( +
  • + + + + + {tags.join(', ')} +
  • + ) + } +} diff --git a/app/src/ui/history/committed-file-item.tsx b/app/src/ui/history/committed-file-item.tsx new file mode 100644 index 0000000000..5144db2224 --- /dev/null +++ b/app/src/ui/history/committed-file-item.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' + +import { CommittedFileChange } from '../../models/status' +import { mapStatus } from '../../lib/status' +import { PathLabel } from '../lib/path-label' +import { Octicon, iconForStatus } from '../octicons' +import { TooltippedContent } from '../lib/tooltipped-content' +import { TooltipDirection } from '../lib/tooltip' + +interface ICommittedFileItemProps { + readonly availableWidth: number + readonly file: CommittedFileChange + readonly focused: boolean +} + +export class CommittedFileItem extends React.Component { + public render() { + const { file, focused } = this.props + const { status } = file + const fileStatus = mapStatus(status) + + const listItemPadding = 10 * 2 + const statusWidth = 16 + const filePathPadding = 5 + const availablePathWidth = + this.props.availableWidth - + listItemPadding - + filePathPadding - + statusWidth + + return ( +
    + + + + +
    + ) + } +} diff --git a/app/src/ui/history/compare-branch-list-item.tsx b/app/src/ui/history/compare-branch-list-item.tsx new file mode 100644 index 0000000000..4adb6949f5 --- /dev/null +++ b/app/src/ui/history/compare-branch-list-item.tsx @@ -0,0 +1,139 @@ +import * as React from 'react' + +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { HighlightText } from '../lib/highlight-text' +import { Branch, IAheadBehind } from '../../models/branch' +import { IMatches } from '../../lib/fuzzy-find' +import { AheadBehindStore } from '../../lib/stores/ahead-behind-store' +import { Repository } from '../../models/repository' +import { DisposableLike } from 'event-kit' + +interface ICompareBranchListItemProps { + readonly branch: Branch + readonly currentBranch: Branch | null + readonly repository: Repository + + /** The characters in the branch name to highlight */ + readonly matches: IMatches + + readonly aheadBehindStore: AheadBehindStore +} + +interface ICompareBranchListItemState { + readonly comparisonFrom?: string + readonly comparisonTo?: string + readonly aheadBehind?: IAheadBehind +} + +export class CompareBranchListItem extends React.Component< + ICompareBranchListItemProps, + ICompareBranchListItemState +> { + public static getDerivedStateFromProps( + props: ICompareBranchListItemProps, + state: ICompareBranchListItemState + ): Partial | null { + const { repository, aheadBehindStore } = props + const from = props.currentBranch?.tip.sha + const to = props.branch.tip.sha + + if (from === state.comparisonFrom && to === state.comparisonTo) { + return null + } + + if (from === undefined || to === undefined) { + return { aheadBehind: undefined, comparisonFrom: from, comparisonTo: to } + } + + const aheadBehind = aheadBehindStore.tryGetAheadBehind(repository, from, to) + return { aheadBehind, comparisonFrom: from, comparisonTo: to } + } + + private aheadBehindSubscription: DisposableLike | null = null + + public constructor(props: ICompareBranchListItemProps) { + super(props) + this.state = {} + } + + public componentDidMount() { + // If we failed to get a value synchronously in getDerivedStateFromProps + // we'll load one asynchronously now, otherwise we'll wait until the next + // prop update to see if the comparison revs change. + if (this.state.aheadBehind === undefined) { + this.subscribeToAheadBehindStore() + } + } + + public componentDidUpdate( + prevProps: ICompareBranchListItemProps, + prevState: ICompareBranchListItemState + ) { + const { comparisonFrom: from, comparisonTo: to } = this.state + + if (prevState.comparisonFrom !== from || prevState.comparisonTo !== to) { + this.subscribeToAheadBehindStore() + } + } + + public componentWillUnmount() { + this.unsubscribeFromAheadBehindStore() + } + + private subscribeToAheadBehindStore() { + const { aheadBehindStore, repository } = this.props + const { comparisonFrom: from, comparisonTo: to, aheadBehind } = this.state + + this.unsubscribeFromAheadBehindStore() + + if (from !== undefined && to !== undefined && aheadBehind === undefined) { + this.aheadBehindSubscription = aheadBehindStore.getAheadBehind( + repository, + from, + to, + aheadBehind => this.setState({ aheadBehind }) + ) + } + } + + private unsubscribeFromAheadBehindStore() { + if (this.aheadBehindSubscription !== null) { + this.aheadBehindSubscription.dispose() + } + } + + public render() { + const { currentBranch, branch } = this.props + const { aheadBehind } = this.state + const isCurrentBranch = branch.name === currentBranch?.name + const icon = isCurrentBranch ? OcticonSymbol.check : OcticonSymbol.gitBranch + + const aheadBehindElement = aheadBehind ? ( +
    + + {aheadBehind.behind} + + + + + {aheadBehind.ahead} + + +
    + ) : null + + return ( +
    + +
    + +
    + {aheadBehindElement} +
    + ) + } +} diff --git a/app/src/ui/history/compare.tsx b/app/src/ui/history/compare.tsx new file mode 100644 index 0000000000..6c53ca06ec --- /dev/null +++ b/app/src/ui/history/compare.tsx @@ -0,0 +1,712 @@ +import * as React from 'react' + +import { Commit, CommitOneLine, ICommitContext } from '../../models/commit' +import { + HistoryTabMode, + ICompareState, + ICompareBranch, + ComparisonMode, + IDisplayHistory, +} from '../../lib/app-state' +import { CommitList } from './commit-list' +import { Repository } from '../../models/repository' +import { Branch } from '../../models/branch' +import { defaultErrorHandler, Dispatcher } from '../dispatcher' +import { ThrottledScheduler } from '../lib/throttled-scheduler' +import { BranchList } from '../branches' +import { TextBox } from '../lib/text-box' +import { IBranchListItem } from '../branches/group-branches' +import { TabBar } from '../tab-bar' +import { CompareBranchListItem } from './compare-branch-list-item' +import { FancyTextBox } from '../lib/fancy-text-box' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { SelectionSource } from '../lib/filter-list' +import { IMatches } from '../../lib/fuzzy-find' +import { Ref } from '../lib/ref' +import { MergeCallToActionWithConflicts } from './merge-call-to-action-with-conflicts' +import { AheadBehindStore } from '../../lib/stores/ahead-behind-store' +import { DragType } from '../../models/drag-drop' +import { PopupType } from '../../models/popup' +import { getUniqueCoauthorsAsAuthors } from '../../lib/unique-coauthors-as-authors' +import { getSquashedCommitDescription } from '../../lib/squash/squashed-commit-description' +import { doMergeCommitsExistAfterCommit } from '../../lib/git' + +interface ICompareSidebarProps { + readonly repository: Repository + readonly isLocalRepository: boolean + readonly compareState: ICompareState + readonly emoji: Map + readonly commitLookup: Map + readonly localCommitSHAs: ReadonlyArray + readonly askForConfirmationOnCheckoutCommit: boolean + readonly dispatcher: Dispatcher + readonly currentBranch: Branch | null + readonly selectedCommitShas: ReadonlyArray + readonly onRevertCommit: (commit: Commit) => void + readonly onAmendCommit: (commit: Commit, isLocalCommit: boolean) => void + readonly onViewCommitOnGitHub: (sha: string) => void + readonly onCompareListScrolled: (scrollTop: number) => void + readonly onCherryPick: ( + repository: Repository, + commits: ReadonlyArray + ) => void + readonly compareListScrollTop?: number + readonly localTags: Map | null + readonly tagsToPush: ReadonlyArray | null + readonly aheadBehindStore: AheadBehindStore + readonly isMultiCommitOperationInProgress?: boolean + readonly shasToHighlight: ReadonlyArray +} + +interface ICompareSidebarState { + /** + * This branch should only be used when tracking interactions that the user is performing. + * + * For all other cases, use the prop + */ + readonly focusedBranch: Branch | null +} + +/** If we're within this many rows from the bottom, load the next history batch. */ +const CloseToBottomThreshold = 10 + +export class CompareSidebar extends React.Component< + ICompareSidebarProps, + ICompareSidebarState +> { + private textbox: TextBox | null = null + private readonly loadChangedFilesScheduler = new ThrottledScheduler(200) + private branchList: BranchList | null = null + private commitListRef = React.createRef() + private loadingMoreCommitsPromise: Promise | null = null + private resultCount = 0 + + public constructor(props: ICompareSidebarProps) { + super(props) + + this.state = { focusedBranch: null } + } + + public componentWillReceiveProps(nextProps: ICompareSidebarProps) { + const newFormState = nextProps.compareState.formState + const oldFormState = this.props.compareState.formState + + if ( + newFormState.kind !== oldFormState.kind && + newFormState.kind === HistoryTabMode.History + ) { + this.setState({ + focusedBranch: null, + }) + return + } + + if ( + newFormState.kind !== HistoryTabMode.History && + oldFormState.kind !== HistoryTabMode.History + ) { + const oldBranch = oldFormState.comparisonBranch + const newBranch = newFormState.comparisonBranch + + if (oldBranch.name !== newBranch.name) { + // ensure the focused branch is in sync with the chosen branch + this.setState({ + focusedBranch: newBranch, + }) + } + } + } + + public componentDidUpdate(prevProps: ICompareSidebarProps) { + const { showBranchList } = this.props.compareState + + if (showBranchList === prevProps.compareState.showBranchList) { + return + } + + if (this.textbox !== null) { + if (showBranchList) { + this.textbox.focus() + } else if (!showBranchList) { + this.textbox.blur() + } + } + } + + public focusHistory() { + this.commitListRef.current?.focus() + } + + public componentWillMount() { + this.props.dispatcher.initializeCompare(this.props.repository) + } + + public componentWillUnmount() { + this.textbox = null + + // by hiding the branch list here when the component is torn down + // we ensure any ahead/behind computation work is discarded + this.props.dispatcher.updateCompareForm(this.props.repository, { + showBranchList: false, + }) + } + + public render() { + const { branches, filterText, showBranchList } = this.props.compareState + const placeholderText = getPlaceholderText(this.props.compareState) + + return ( +
    +
    + !b.isDesktopForkRemoteBranch)} + onRef={this.onTextBoxRef} + onValueChanged={this.onBranchFilterTextChanged} + onKeyDown={this.onBranchFilterKeyDown} + onSearchCleared={this.handleEscape} + /> +
    + + {showBranchList ? this.renderFilterList() : this.renderCommits()} +
    + ) + } + + private onBranchesListRef = (branchList: BranchList | null) => { + this.branchList = branchList + } + + private renderCommits() { + const formState = this.props.compareState.formState + return ( +
    + {formState.kind === HistoryTabMode.History + ? this.renderCommitList() + : this.renderTabBar(formState)} +
    + ) + } + + private filterListResultsChanged = (resultCount: number) => { + this.resultCount = resultCount + } + + private viewHistoryForBranch = () => { + this.props.dispatcher.executeCompare(this.props.repository, { + kind: HistoryTabMode.History, + }) + + this.props.dispatcher.updateCompareForm(this.props.repository, { + showBranchList: false, + }) + } + + private renderCommitList() { + const { formState, commitSHAs } = this.props.compareState + + let emptyListMessage: string | JSX.Element + if (formState.kind === HistoryTabMode.History) { + emptyListMessage = 'No history' + } else { + const currentlyComparedBranchName = formState.comparisonBranch.name + + emptyListMessage = + formState.comparisonMode === ComparisonMode.Ahead ? ( +

    + The compared branch ({currentlyComparedBranchName}) is up + to date with your branch +

    + ) : ( +

    + Your branch is up to date with the compared branch ( + {currentlyComparedBranchName}) +

    + ) + } + + return ( + + ) + } + + private onDropCommitInsertion = async ( + baseCommit: Commit | null, + commitsToInsert: ReadonlyArray, + lastRetainedCommitRef: string | null + ) => { + if ( + await doMergeCommitsExistAfterCommit( + this.props.repository, + lastRetainedCommitRef + ) + ) { + defaultErrorHandler( + new Error( + `Unable to reorder. Reordering replays all commits up to the last one required for the reorder. A merge commit cannot exist among those commits.` + ), + this.props.dispatcher + ) + return + } + + return this.props.dispatcher.reorderCommits( + this.props.repository, + commitsToInsert, + baseCommit, + lastRetainedCommitRef + ) + } + + private onRenderCommitDragElement = ( + commit: Commit, + selectedCommits: ReadonlyArray + ) => { + this.props.dispatcher.setDragElement({ + type: DragType.Commit, + commit, + selectedCommits, + gitHubRepository: this.props.repository.gitHubRepository, + }) + } + + private onRemoveCommitDragElement = () => { + this.props.dispatcher.clearDragElement() + } + + private renderActiveTab(view: ICompareBranch) { + return ( +
    + {this.renderCommitList()} + {view.comparisonMode === ComparisonMode.Behind + ? this.renderMergeCallToAction(view) + : null} +
    + ) + } + + private renderFilterList() { + const { defaultBranch, branches, recentBranches, filterText } = + this.props.compareState + + return ( + + ) + } + + private renderMergeCallToAction(formState: ICompareBranch) { + if (this.props.currentBranch == null) { + return null + } + + return ( + + ) + } + + private onTabClicked = (index: number) => { + const formState = this.props.compareState.formState + + if (formState.kind === HistoryTabMode.History) { + return + } + + const comparisonMode = + index === 0 ? ComparisonMode.Behind : ComparisonMode.Ahead + const branch = formState.comparisonBranch + + this.props.dispatcher.executeCompare(this.props.repository, { + kind: HistoryTabMode.Compare, + branch, + comparisonMode, + }) + } + + private renderTabBar(formState: ICompareBranch) { + const selectedTab = + formState.comparisonMode === ComparisonMode.Behind ? 0 : 1 + + return ( +
    + + {`Behind (${formState.aheadBehind.behind})`} + {`Ahead (${formState.aheadBehind.ahead})`} + + {this.renderActiveTab(formState)} +
    + ) + } + + private renderCompareBranchListItem = ( + item: IBranchListItem, + matches: IMatches + ) => { + return ( + + ) + } + + private onBranchFilterKeyDown = ( + event: React.KeyboardEvent + ) => { + const key = event.key + + if (key === 'Enter') { + if (this.resultCount === 0) { + event.preventDefault() + return + } + const branch = this.state.focusedBranch + + if (branch === null) { + this.viewHistoryForBranch() + } else { + this.props.dispatcher.executeCompare(this.props.repository, { + kind: HistoryTabMode.Compare, + comparisonMode: ComparisonMode.Behind, + branch, + }) + + this.props.dispatcher.updateCompareForm(this.props.repository, { + filterText: branch.name, + }) + } + + if (this.textbox) { + this.textbox.blur() + } + } else if (key === 'Escape') { + this.handleEscape() + } else if (key === 'ArrowDown') { + if (this.branchList !== null) { + this.branchList.selectNextItem(true, 'down') + } + } else if (key === 'ArrowUp') { + if (this.branchList !== null) { + this.branchList.selectNextItem(true, 'up') + } + } + } + + private handleEscape = () => { + this.clearFilterState() + if (this.textbox) { + this.textbox.blur() + } + } + + private onCommitsSelected = ( + commits: ReadonlyArray, + isContiguous: boolean + ) => { + this.props.dispatcher.changeCommitSelection( + this.props.repository, + commits.map(c => c.sha), + isContiguous + ) + + this.loadChangedFilesScheduler.queue(() => { + this.props.dispatcher.loadChangedFilesForCurrentSelection( + this.props.repository + ) + }) + } + + private onScroll = (start: number, end: number) => { + const compareState = this.props.compareState + const formState = compareState.formState + + if (formState.kind === HistoryTabMode.Compare) { + // as the app is currently comparing the current branch to some other + // branch, everything needed should be loaded + return + } + + const commits = compareState.commitSHAs + if (commits.length - end <= CloseToBottomThreshold) { + if (this.loadingMoreCommitsPromise != null) { + // as this callback fires for any scroll event we need to guard + // against re-entrant calls to loadCommitBatch + return + } + + this.loadingMoreCommitsPromise = this.props.dispatcher + .loadNextCommitBatch(this.props.repository) + .then(() => { + // deferring unsetting this flag to some time _after_ the commits + // have been appended to prevent eagerly adding more commits due + // to scroll events (which fire indiscriminately) + window.setTimeout(() => { + this.loadingMoreCommitsPromise = null + }, 500) + }) + } + } + + private onBranchFilterTextChanged = (filterText: string) => { + if (filterText.length === 0) { + this.setState({ focusedBranch: null }) + } + + this.props.dispatcher.updateCompareForm(this.props.repository, { + filterText, + }) + } + + private clearFilterState = () => { + this.setState({ + focusedBranch: null, + }) + + this.props.dispatcher.updateCompareForm(this.props.repository, { + filterText: '', + }) + + this.viewHistoryForBranch() + } + + private onBranchItemClicked = (branch: Branch) => { + this.props.dispatcher.executeCompare(this.props.repository, { + kind: HistoryTabMode.Compare, + comparisonMode: ComparisonMode.Behind, + branch, + }) + + this.setState({ + focusedBranch: null, + }) + + this.props.dispatcher.updateCompareForm(this.props.repository, { + filterText: branch.name, + showBranchList: false, + }) + } + + private onSelectionChanged = ( + branch: Branch | null, + source: SelectionSource + ) => { + this.setState({ + focusedBranch: branch, + }) + } + + private onTextBoxFocused = () => { + this.props.dispatcher.updateCompareForm(this.props.repository, { + showBranchList: true, + }) + } + + private onTextBoxRef = (textbox: TextBox) => { + this.textbox = textbox + } + + private onCreateTag = (targetCommitSha: string) => { + this.props.dispatcher.showCreateTagDialog( + this.props.repository, + targetCommitSha, + this.props.localTags + ) + } + + private onUndoCommit = (commit: Commit) => { + this.props.dispatcher.undoCommit(this.props.repository, commit) + } + + private onResetToCommit = (commit: Commit) => { + this.props.dispatcher.resetToCommit(this.props.repository, commit) + } + + private onCreateBranch = (commit: CommitOneLine) => { + const { repository, dispatcher } = this.props + + dispatcher.showPopup({ + type: PopupType.CreateBranch, + repository, + targetCommit: commit, + }) + } + + private onCheckoutCommit = (commit: CommitOneLine) => { + const { repository, dispatcher, askForConfirmationOnCheckoutCommit } = + this.props + if (!askForConfirmationOnCheckoutCommit) { + dispatcher.checkoutCommit(repository, commit) + } else { + dispatcher.showPopup({ + type: PopupType.ConfirmCheckoutCommit, + commit: commit, + repository, + }) + } + } + + private onDeleteTag = (tagName: string) => { + this.props.dispatcher.showDeleteTagDialog(this.props.repository, tagName) + } + + private onCherryPick = (commits: ReadonlyArray) => { + this.props.onCherryPick(this.props.repository, commits) + } + + private onSquash = async ( + toSquash: ReadonlyArray, + squashOnto: Commit, + lastRetainedCommitRef: string | null, + isInvokedByContextMenu: boolean + ) => { + const toSquashSansSquashOnto = toSquash.filter( + c => c.sha !== squashOnto.sha + ) + + const allCommitsInSquash = [...toSquashSansSquashOnto, squashOnto] + const coAuthors = getUniqueCoauthorsAsAuthors(allCommitsInSquash) + + const squashedDescription = getSquashedCommitDescription( + toSquashSansSquashOnto, + squashOnto + ) + + if ( + await doMergeCommitsExistAfterCommit( + this.props.repository, + lastRetainedCommitRef + ) + ) { + defaultErrorHandler( + new Error( + `Unable to squash. Squashing replays all commits up to the last one required for the squash. A merge commit cannot exist among those commits.` + ), + this.props.dispatcher + ) + return + } + + this.props.dispatcher.recordSquashInvoked(isInvokedByContextMenu) + + this.props.dispatcher.showPopup({ + type: PopupType.CommitMessage, + repository: this.props.repository, + coAuthors, + showCoAuthoredBy: coAuthors.length > 0, + commitMessage: { + summary: squashOnto.summary, + description: squashedDescription, + }, + dialogTitle: `Squash ${allCommitsInSquash.length} Commits`, + dialogButtonText: `Squash ${allCommitsInSquash.length} Commits`, + prepopulateCommitSummary: true, + onSubmitCommitMessage: async (context: ICommitContext) => { + this.props.dispatcher.squash( + this.props.repository, + toSquashSansSquashOnto, + squashOnto, + lastRetainedCommitRef, + context + ) + return true + }, + }) + } +} + +function getPlaceholderText(state: ICompareState) { + const { branches, formState } = state + + if (!branches.some(b => !b.isDesktopForkRemoteBranch)) { + return __DARWIN__ ? 'No Branches to Compare' : 'No branches to compare' + } else if (formState.kind === HistoryTabMode.History) { + return __DARWIN__ + ? 'Select Branch to Compare...' + : 'Select branch to compare...' + } else { + return undefined + } +} + +// determine if the `onRevertCommit` function should be exposed to the CommitList/CommitListItem. +// `onRevertCommit` is only exposed if the form state of the branch compare form is either +// 1: History mode, 2: Comparison Mode with the 'Ahead' list shown. +// When not exposed, the context menu item 'Revert this commit' is disabled. +function ableToRevertCommit( + formState: IDisplayHistory | ICompareBranch +): boolean { + return ( + formState.kind === HistoryTabMode.History || + formState.comparisonMode === ComparisonMode.Ahead + ) +} diff --git a/app/src/ui/history/file-list.tsx b/app/src/ui/history/file-list.tsx new file mode 100644 index 0000000000..1aee01a359 --- /dev/null +++ b/app/src/ui/history/file-list.tsx @@ -0,0 +1,98 @@ +import * as React from 'react' +import { mapStatus } from '../../lib/status' + +import { CommittedFileChange } from '../../models/status' +import { ClickSource, List } from '../lib/list' +import { CommittedFileItem } from './committed-file-item' + +interface IFileListProps { + readonly files: ReadonlyArray + readonly selectedFile: CommittedFileChange | null + readonly onSelectedFileChanged: (file: CommittedFileChange) => void + readonly onRowDoubleClick: (row: number, source: ClickSource) => void + readonly availableWidth: number + readonly onContextMenu?: ( + file: CommittedFileChange, + event: React.MouseEvent + ) => void +} + +interface IFileListState { + readonly focusedRow: number | null +} + +/** + * Display a list of changed files as part of a commit or stash + */ +export class FileList extends React.Component { + public constructor(props: IFileListProps) { + super(props) + + this.state = { + focusedRow: null, + } + } + + private onSelectedRowChanged = (row: number) => { + const file = this.props.files[row] + this.props.onSelectedFileChanged(file) + } + + private renderFile = (row: number) => { + return ( + + ) + } + + private rowForFile(file: CommittedFileChange | null): number { + return file ? this.props.files.findIndex(f => f.path === file.path) : -1 + } + + private onRowContextMenu = ( + row: number, + event: React.MouseEvent + ) => { + this.props.onContextMenu?.(this.props.files[row], event) + } + + private getFileAriaLabel = (row: number) => { + const file = this.props.files[row] + const { path, status } = file + const fileStatus = mapStatus(status) + return `${path} ${fileStatus}` + } + + public render() { + return ( +
    + +
    + ) + } + + 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/history/index.ts b/app/src/ui/history/index.ts new file mode 100644 index 0000000000..f4fabb6e82 --- /dev/null +++ b/app/src/ui/history/index.ts @@ -0,0 +1,2 @@ +export { SelectedCommits } from './selected-commits' +export { CompareSidebar } from './compare' diff --git a/app/src/ui/history/merge-call-to-action-with-conflicts.tsx b/app/src/ui/history/merge-call-to-action-with-conflicts.tsx new file mode 100644 index 0000000000..01b7b4ac5c --- /dev/null +++ b/app/src/ui/history/merge-call-to-action-with-conflicts.tsx @@ -0,0 +1,322 @@ +import * as React from 'react' + +import { HistoryTabMode } from '../../lib/app-state' +import { Repository } from '../../models/repository' +import { Branch } from '../../models/branch' +import { Dispatcher } from '../dispatcher' +import { ActionStatusIcon } from '../lib/action-status-icon' +import { MergeTreeResult } from '../../models/merge' +import { ComputedAction } from '../../models/computed-action' +import { + DropdownSelectButton, + IDropdownSelectButtonOption, +} from '../dropdown-select-button' +import { getMergeOptions, updateRebasePreview } from '../lib/update-branch' +import { + MultiCommitOperationKind, + isIdMultiCommitOperation, +} from '../../models/multi-commit-operation' +import { RebasePreview } from '../../models/rebase' + +interface IMergeCallToActionWithConflictsProps { + readonly repository: Repository + readonly dispatcher: Dispatcher + readonly mergeStatus: MergeTreeResult | null + readonly currentBranch: Branch + readonly comparisonBranch: Branch + readonly commitsBehind: number +} + +interface IMergeCallToActionWithConflictsState { + readonly selectedOperation: MultiCommitOperationKind + readonly rebasePreview: RebasePreview | null +} + +export class MergeCallToActionWithConflicts extends React.Component< + IMergeCallToActionWithConflictsProps, + IMergeCallToActionWithConflictsState +> { + /** + * This is obtained by either the merge status or the rebase preview. Depending + * on which option is selected in the dropdown. + */ + private get computedAction(): ComputedAction | null { + if (this.state.selectedOperation === MultiCommitOperationKind.Rebase) { + return this.state.rebasePreview !== null + ? this.state.rebasePreview.kind + : null + } + return this.props.mergeStatus !== null ? this.props.mergeStatus.kind : null + } + + /** + * This is obtained by either the merge status or the rebase preview. Depending + * on which option is selected in the dropdown. + */ + private get commitCount(): number { + const { selectedOperation, rebasePreview } = this.state + if (selectedOperation === MultiCommitOperationKind.Rebase) { + return rebasePreview !== null && + rebasePreview.kind === ComputedAction.Clean + ? rebasePreview.commits.length + : 0 + } + + return this.props.commitsBehind + } + + public constructor(props: IMergeCallToActionWithConflictsProps) { + super(props) + + this.state = { + selectedOperation: MultiCommitOperationKind.Merge, + rebasePreview: null, + } + } + + private isUpdateBranchDisabled(): boolean { + if (this.commitCount <= 0) { + return true + } + + const { selectedOperation, rebasePreview } = this.state + if (selectedOperation === MultiCommitOperationKind.Rebase) { + return ( + rebasePreview === null || rebasePreview.kind !== ComputedAction.Clean + ) + } + + return ( + this.props.mergeStatus != null && + this.props.mergeStatus.kind === ComputedAction.Invalid + ) + } + + private updateRebasePreview = async (baseBranch: Branch) => { + const { currentBranch: targetBranch, repository } = this.props + updateRebasePreview(baseBranch, targetBranch, repository, rebasePreview => { + this.setState({ rebasePreview }) + }) + } + + private onOperationChange = (option: IDropdownSelectButtonOption) => { + if (!isIdMultiCommitOperation(option.id)) { + return + } + + this.setState({ selectedOperation: option.id }) + if (option.id === MultiCommitOperationKind.Rebase) { + this.updateRebasePreview(this.props.comparisonBranch) + } + } + + private onOperationInvoked = async ( + event: React.MouseEvent, + selectedOption: IDropdownSelectButtonOption + ) => { + if (!isIdMultiCommitOperation(selectedOption.id)) { + return + } + event.preventDefault() + + const { dispatcher, repository } = this.props + + await this.dispatchOperation(selectedOption.id) + + dispatcher.executeCompare(repository, { + kind: HistoryTabMode.History, + }) + + dispatcher.updateCompareForm(repository, { + showBranchList: false, + filterText: '', + }) + } + + private async dispatchOperation( + operation: MultiCommitOperationKind + ): Promise { + const { + dispatcher, + currentBranch, + comparisonBranch, + repository, + mergeStatus, + } = this.props + + if (operation === MultiCommitOperationKind.Rebase) { + const commits = + this.state.rebasePreview !== null && + this.state.rebasePreview.kind === ComputedAction.Clean + ? this.state.rebasePreview.commits + : [] + return dispatcher.startRebase( + repository, + comparisonBranch, + currentBranch, + commits + ) + } + + const isSquash = operation === MultiCommitOperationKind.Squash + dispatcher.initializeMultiCommitOperation( + repository, + { + kind: MultiCommitOperationKind.Merge, + isSquash, + sourceBranch: comparisonBranch, + }, + currentBranch, + [], + currentBranch.tip.sha + ) + dispatcher.recordCompareInitiatedMerge() + + return dispatcher.mergeBranch( + repository, + comparisonBranch, + mergeStatus, + isSquash + ) + } + + public render() { + const disabled = this.isUpdateBranchDisabled() + const mergeDetails = this.commitCount > 0 ? this.renderMergeStatus() : null + + return ( +
    + {mergeDetails} + + +
    + ) + } + + private renderMergeStatus() { + if (this.computedAction === null) { + return null + } + + return ( +
    + + + {this.renderStatusDetails()} +
    + ) + } + + private renderStatusDetails() { + const { currentBranch, comparisonBranch, mergeStatus } = this.props + const { selectedOperation } = this.state + if (this.computedAction === null) { + return null + } + switch (this.computedAction) { + case ComputedAction.Loading: + return this.renderLoadingMessage() + case ComputedAction.Clean: + return this.renderCleanMessage(currentBranch, comparisonBranch) + case ComputedAction.Invalid: + return this.renderInvalidMessage() + } + + if ( + selectedOperation !== MultiCommitOperationKind.Rebase && + mergeStatus !== null && + mergeStatus.kind === ComputedAction.Conflicts + ) { + return this.renderConflictedMergeMessage( + currentBranch, + comparisonBranch, + mergeStatus.conflictedFiles + ) + } + return null + } + + private renderLoadingMessage() { + return ( +
    + Checking for ability to {this.state.selectedOperation.toLowerCase()}{' '} + automatically… +
    + ) + } + + private renderCleanMessage(currentBranch: Branch, branch: Branch) { + if (this.commitCount <= 0) { + return null + } + + const pluralized = this.commitCount === 1 ? 'commit' : 'commits' + + if (this.state.selectedOperation === MultiCommitOperationKind.Rebase) { + return ( +
    + This will update {currentBranch.name} + {` by applying its `} + {`${this.commitCount} ${pluralized}`} + {` on top of `} + {branch.name} +
    + ) + } + + return ( +
    + This will merge + {` ${this.commitCount} ${pluralized}`} + {` from `} + {branch.name} + {` into `} + {currentBranch.name} +
    + ) + } + + private renderInvalidMessage() { + if (this.state.selectedOperation === MultiCommitOperationKind.Rebase) { + return ( +
    + Unable to start rebase. Check you have chosen a valid branch. +
    + ) + } + + return ( +
    + Unable to merge unrelated histories in this repository +
    + ) + } + + private renderConflictedMergeMessage( + currentBranch: Branch, + branch: Branch, + count: number + ) { + const pluralized = count === 1 ? 'file' : 'files' + return ( +
    + There will be + {` ${count} conflicted ${pluralized}`} + {` when merging `} + {branch.name} + {` into `} + {currentBranch.name} +
    + ) + } +} diff --git a/app/src/ui/history/merge-call-to-action.tsx b/app/src/ui/history/merge-call-to-action.tsx new file mode 100644 index 0000000000..3913925169 --- /dev/null +++ b/app/src/ui/history/merge-call-to-action.tsx @@ -0,0 +1,92 @@ +import * as React from 'react' + +import { ICompareBranch, HistoryTabMode } from '../../lib/app-state' +import { Repository } from '../../models/repository' +import { Branch } from '../../models/branch' +import { Dispatcher } from '../dispatcher' +import { Button } from '../lib/button' + +interface IMergeCallToActionProps { + readonly repository: Repository + readonly dispatcher: Dispatcher + readonly currentBranch: Branch + readonly formState: ICompareBranch + + /** + * Callback to execute after a merge has been performed + */ + readonly onMerged: () => void +} + +export class MergeCallToAction extends React.Component< + IMergeCallToActionProps, + {} +> { + public render() { + const count = this.props.formState.aheadBehind.behind + + return ( +
    + {this.renderMergeDetails( + this.props.formState, + this.props.currentBranch + )} + + +
    + ) + } + + private renderMergeDetails(formState: ICompareBranch, currentBranch: Branch) { + const branch = formState.comparisonBranch + const count = formState.aheadBehind.behind + + if (count > 0) { + const pluralized = count === 1 ? 'commit' : 'commits' + return ( +
    + This will merge + {` ${count} ${pluralized}`} + {` `} + from + {` `} + {branch.name} + {` `} + into + {` `} + {currentBranch.name} +
    + ) + } + + return null + } + + private onMergeClicked = async () => { + const formState = this.props.formState + + this.props.dispatcher.recordCompareInitiatedMerge() + + await this.props.dispatcher.mergeBranch( + this.props.repository, + formState.comparisonBranch, + null + ) + + this.props.dispatcher.executeCompare(this.props.repository, { + kind: HistoryTabMode.History, + }) + + this.props.dispatcher.updateCompareForm(this.props.repository, { + showBranchList: false, + filterText: '', + }) + this.props.onMerged() + } +} diff --git a/app/src/ui/history/selected-commits.tsx b/app/src/ui/history/selected-commits.tsx new file mode 100644 index 0000000000..ec98a0758f --- /dev/null +++ b/app/src/ui/history/selected-commits.tsx @@ -0,0 +1,451 @@ +import * as React from 'react' +import { clipboard } from 'electron' +import * as Path from 'path' + +import { Repository } from '../../models/repository' +import { CommittedFileChange } from '../../models/status' +import { Commit } from '../../models/commit' +import { IDiff, ImageDiffType } from '../../models/diff' + +import { encodePathAsUrl } from '../../lib/path' +import { revealInFileManager } from '../../lib/app-shell' + +import { openFile } from '../lib/open-file' +import { + isSafeFileExtension, + CopyFilePathLabel, + DefaultEditorLabel, + RevealInFileManagerLabel, + OpenWithDefaultProgramLabel, + CopyRelativeFilePathLabel, +} from '../lib/context-menu' +import { ThrottledScheduler } from '../lib/throttled-scheduler' + +import { Dispatcher } from '../dispatcher' +import { Resizable } from '../resizable' +import { showContextualMenu } from '../../lib/menu-item' + +import { CommitSummary } from './commit-summary' +import { FileList } from './file-list' +import { SeamlessDiffSwitcher } from '../diff/seamless-diff-switcher' +import { getDotComAPIEndpoint } from '../../lib/api' +import { IMenuItem } from '../../lib/menu-item' +import { IChangesetData } from '../../lib/git' +import { IConstrainedValue } from '../../lib/app-state' +import { clamp } from '../../lib/clamp' +import { pathExists } from '../lib/path-exists' +import { UnreachableCommitsTab } from './unreachable-commits-dialog' + +interface ISelectedCommitsProps { + readonly repository: Repository + readonly isLocalRepository: boolean + readonly dispatcher: Dispatcher + readonly emoji: Map + readonly selectedCommits: ReadonlyArray + readonly shasInDiff: ReadonlyArray + readonly localCommitSHAs: ReadonlyArray + readonly changesetData: IChangesetData + readonly selectedFile: CommittedFileChange | null + readonly currentDiff: IDiff | null + readonly commitSummaryWidth: IConstrainedValue + readonly selectedDiffType: ImageDiffType + /** The name of the currently selected external editor */ + readonly externalEditorLabel?: string + + /** + * Called to open a file using the user's configured applications + * + * @param path The path of the file relative to the root of the repository + */ + readonly onOpenInExternalEditor: (path: string) => void + readonly onViewCommitOnGitHub: (SHA: string, filePath?: string) => void + readonly hideWhitespaceInDiff: boolean + + /** Whether we should display side by side diffs. */ + readonly showSideBySideDiff: boolean + + /** + * Called when the user requests to open a binary file in an the + * system-assigned application for said file type. + */ + 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. + */ + readonly onChangeImageDiffType: (type: ImageDiffType) => void + + /** Called when the user opens the diff options popover */ + readonly onDiffOptionsOpened: () => void + + /** Whether or not to show the drag overlay */ + readonly showDragOverlay: boolean + + /** Whether or not the selection of commits is contiguous */ + readonly isContiguous: boolean +} + +interface ISelectedCommitsState { + readonly isExpanded: boolean + readonly hideDescriptionBorder: boolean +} + +/** The History component. Contains the commit list, commit summary, and diff. */ +export class SelectedCommits extends React.Component< + ISelectedCommitsProps, + ISelectedCommitsState +> { + private readonly loadChangedFilesScheduler = new ThrottledScheduler(200) + private historyRef: HTMLDivElement | null = null + + public constructor(props: ISelectedCommitsProps) { + super(props) + + this.state = { + isExpanded: false, + hideDescriptionBorder: false, + } + } + + private onFileSelected = (file: CommittedFileChange) => { + this.props.dispatcher.changeFileSelection(this.props.repository, file) + } + + private onRowDoubleClick = (row: number) => { + const files = this.props.changesetData.files + const file = files[row] + + this.props.onOpenInExternalEditor(file.path) + } + + private onHistoryRef = (ref: HTMLDivElement | null) => { + this.historyRef = ref + } + + public componentWillUpdate(nextProps: ISelectedCommitsProps) { + // reset isExpanded if we're switching commits. + const currentValue = this.props.selectedCommits.map(c => c.sha).join('') + const nextValue = nextProps.selectedCommits.map(c => c.sha).join('') + + if (currentValue !== nextValue) { + if (this.state.isExpanded) { + this.setState({ isExpanded: false }) + } + } + } + + public componentWillUnmount() { + this.loadChangedFilesScheduler.clear() + } + + private renderDiff() { + const file = this.props.selectedFile + const diff = this.props.currentDiff + + if (file == null) { + // don't show both 'empty' messages + const message = + this.props.changesetData.files.length === 0 ? '' : 'No file selected' + + return ( +
    + {message} +
    + ) + } + + return ( + + ) + } + + private renderCommitSummary(commits: ReadonlyArray) { + return ( + + ) + } + + private showUnreachableCommits = (selectedTab: UnreachableCommitsTab) => { + this.props.dispatcher.showUnreachableCommits(selectedTab) + } + + private onHighlightShas = (shasToHighlight: ReadonlyArray) => { + this.props.dispatcher.updateShasToHighlight( + this.props.repository, + shasToHighlight + ) + } + + private onExpandChanged = (isExpanded: boolean) => { + this.setState({ isExpanded }) + } + + private onDescriptionBottomChanged = (descriptionBottom: number) => { + if (this.historyRef) { + const historyBottom = this.historyRef.getBoundingClientRect().bottom + this.setState({ + hideDescriptionBorder: descriptionBottom >= historyBottom, + }) + } + } + + private onHideWhitespaceInDiffChanged = (hideWhitespaceInDiff: boolean) => { + return this.props.dispatcher.onHideWhitespaceInHistoryDiffChanged( + hideWhitespaceInDiff, + this.props.repository, + this.props.selectedFile as CommittedFileChange + ) + } + + private onShowSideBySideDiffChanged = (showSideBySideDiff: boolean) => { + this.props.dispatcher.onShowSideBySideDiffChanged(showSideBySideDiff) + } + + private onCommitSummaryReset = () => { + this.props.dispatcher.resetCommitSummaryWidth() + } + + private onCommitSummaryResize = (width: number) => { + this.props.dispatcher.setCommitSummaryWidth(width) + } + + private renderFileList() { + const files = this.props.changesetData.files + if (files.length === 0) { + return
    No files in commit
    + } + + // -1 for right hand side border + const availableWidth = clamp(this.props.commitSummaryWidth) - 1 + + return ( + + ) + } + + /** + * Open file with default application. + * + * @param path The path of the file relative to the root of the repository + */ + private onOpenItem = (path: string) => { + const fullPath = Path.join(this.props.repository.path, path) + openFile(fullPath, this.props.dispatcher) + } + + public render() { + const { selectedCommits, isContiguous } = this.props + + if (selectedCommits.length > 1 && !isContiguous) { + return this.renderMultipleCommitsBlankSlate() + } + + if (selectedCommits.length === 0) { + return + } + + const className = this.state.isExpanded ? 'expanded' : 'collapsed' + const { commitSummaryWidth } = this.props + + return ( +
    + {this.renderCommitSummary(selectedCommits)} +
    + + {this.renderFileList()} + + {this.renderDiff()} +
    + {this.renderDragOverlay()} +
    + ) + } + + private renderDragOverlay(): JSX.Element | null { + if (!this.props.showDragOverlay) { + return null + } + + return
    + } + + private renderMultipleCommitsBlankSlate(): JSX.Element { + const BlankSlateImage = encodePathAsUrl( + __dirname, + 'static/empty-no-commit.svg' + ) + + return ( +
    +
    + +
    +

    + Unable to display diff when multiple non-consecutive selected. +

    +
    You can:
    +
      +
    • + Select a single commit or a range of consecutive commits to view + a diff. +
    • +
    • Drag the commits to the branch menu to cherry-pick them.
    • +
    • Drag the commits to squash or reorder them.
    • +
    • Right click on multiple commits to see options.
    • +
    +
    +
    + {this.renderDragOverlay()} +
    + ) + } + + private onContextMenu = async ( + file: CommittedFileChange, + event: React.MouseEvent + ) => { + event.preventDefault() + + const { + selectedCommits, + localCommitSHAs, + repository, + externalEditorLabel, + } = this.props + + const fullPath = Path.join(repository.path, file.path) + const fileExistsOnDisk = await pathExists(fullPath) + if (!fileExistsOnDisk) { + showContextualMenu([ + { + label: __DARWIN__ + ? 'File Does Not Exist on Disk' + : 'File does not exist on disk', + enabled: false, + }, + ]) + return + } + + const extension = Path.extname(file.path) + + const isSafeExtension = isSafeFileExtension(extension) + const openInExternalEditor = externalEditorLabel + ? `Open in ${externalEditorLabel}` + : DefaultEditorLabel + + const items: IMenuItem[] = [ + { + label: RevealInFileManagerLabel, + action: () => revealInFileManager(repository, file.path), + enabled: fileExistsOnDisk, + }, + { + label: openInExternalEditor, + action: () => this.props.onOpenInExternalEditor(file.path), + enabled: fileExistsOnDisk, + }, + { + label: OpenWithDefaultProgramLabel, + action: () => this.onOpenItem(file.path), + enabled: isSafeExtension && fileExistsOnDisk, + }, + { type: 'separator' }, + { + label: CopyFilePathLabel, + action: () => clipboard.writeText(fullPath), + }, + { + label: CopyRelativeFilePathLabel, + action: () => clipboard.writeText(Path.normalize(file.path)), + }, + { type: 'separator' }, + ] + + let viewOnGitHubLabel = 'View on GitHub' + const gitHubRepository = repository.gitHubRepository + + if ( + gitHubRepository && + gitHubRepository.endpoint !== getDotComAPIEndpoint() + ) { + viewOnGitHubLabel = 'View on GitHub Enterprise' + } + + items.push({ + label: viewOnGitHubLabel, + action: () => this.onViewOnGitHub(selectedCommits[0].sha, file), + enabled: + selectedCommits.length === 1 && + !localCommitSHAs.includes(selectedCommits[0].sha) && + !!gitHubRepository && + this.props.selectedCommits.length > 0, + }) + + showContextualMenu(items) + } + + private onViewOnGitHub = (sha: string, file: CommittedFileChange) => { + this.props.onViewCommitOnGitHub(sha, file.path) + } +} + +function NoCommitSelected() { + const BlankSlateImage = encodePathAsUrl( + __dirname, + 'static/empty-no-commit.svg' + ) + + return ( +
    + + No commit selected +
    + ) +} diff --git a/app/src/ui/history/unreachable-commits-dialog.tsx b/app/src/ui/history/unreachable-commits-dialog.tsx new file mode 100644 index 0000000000..c1d17537e7 --- /dev/null +++ b/app/src/ui/history/unreachable-commits-dialog.tsx @@ -0,0 +1,153 @@ +import * as React from 'react' +import { Dialog, DialogFooter } from '../dialog' +import { TabBar } from '../tab-bar' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { Commit } from '../../models/commit' +import { CommitList } from './commit-list' +import { LinkButton } from '../lib/link-button' + +export enum UnreachableCommitsTab { + Unreachable, + Reachable, +} + +interface IUnreachableCommitsDialogProps { + /** The shas of the currently selected commits */ + readonly selectedShas: ReadonlyArray + + /** The shas of the commits showed in the diff */ + readonly shasInDiff: ReadonlyArray + + /** The commits loaded, keyed by their full SHA. */ + readonly commitLookup: Map + + /** Used to set the selected tab. */ + readonly selectedTab: UnreachableCommitsTab + + /** The emoji lookup to render images inline */ + readonly emoji: Map + + /** Called to dismiss the */ + readonly onDismissed: () => void +} + +interface IUnreachableCommitsDialogState { + /** The currently select tab. */ + readonly selectedTab: UnreachableCommitsTab +} + +/** The component for for viewing the unreachable commits in the current diff a repository. */ +export class UnreachableCommitsDialog extends React.Component< + IUnreachableCommitsDialogProps, + IUnreachableCommitsDialogState +> { + public constructor(props: IUnreachableCommitsDialogProps) { + super(props) + + this.state = { + selectedTab: props.selectedTab, + } + } + + public componentWillUpdate(nextProps: IUnreachableCommitsDialogProps) { + const currentSelectedTab = this.props.selectedTab + const selectedTab = nextProps.selectedTab + + if (currentSelectedTab !== selectedTab) { + this.setState({ selectedTab }) + } + } + + private onTabClicked = (selectedTab: UnreachableCommitsTab) => { + this.setState({ selectedTab }) + } + + private getShasToDisplay = () => { + const { selectedTab } = this.state + const { shasInDiff, selectedShas } = this.props + if (selectedTab === UnreachableCommitsTab.Reachable) { + return shasInDiff + } + + return selectedShas.filter(sha => !shasInDiff.includes(sha)) + } + + private renderTabs() { + return ( + + Unreachable + Reachable + + ) + } + + private renderActiveTab() { + const { commitLookup, emoji } = this.props + + return ( + <> + {this.renderUnreachableCommitsMessage()} +
    + +
    + + ) + } + + private renderFooter() { + return ( + + + + ) + } + + private renderUnreachableCommitsMessage = () => { + const count = this.getShasToDisplay().length + const commitsPluralized = count > 1 ? 'commits' : 'commit' + const pronounPluralized = count > 1 ? `they're` : `it's` + return ( +
    + You will{' '} + {this.state.selectedTab === UnreachableCommitsTab.Unreachable + ? 'not' + : ''}{' '} + see changes from the following {commitsPluralized} because{' '} + {pronounPluralized}{' '} + {this.state.selectedTab === UnreachableCommitsTab.Unreachable + ? 'not' + : ''}{' '} + in the ancestry path of the most recent commit in your selection.{' '} + + Learn more about unreachable commits. + +
    + ) + } + + public render() { + return ( + + {this.renderTabs()} + {this.renderActiveTab()} + {this.renderFooter()} + + ) + } +} diff --git a/app/src/ui/index.tsx b/app/src/ui/index.tsx new file mode 100644 index 0000000000..a1d321136a --- /dev/null +++ b/app/src/ui/index.tsx @@ -0,0 +1,390 @@ +import '../lib/logging/renderer/install' + +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import * as Path from 'path' +import { App } from './app' +import { + Dispatcher, + gitAuthenticationErrorHandler, + externalEditorErrorHandler, + openShellErrorHandler, + mergeConflictHandler, + lfsAttributeMismatchHandler, + defaultErrorHandler, + missingRepositoryHandler, + backgroundTaskHandler, + pushNeedsPullHandler, + upstreamAlreadyExistsHandler, + rebaseConflictsHandler, + localChangesOverwrittenHandler, + refusedWorkflowUpdate, + samlReauthRequired, + insufficientGitHubRepoPermissions, + discardChangesHandler, +} from './dispatcher' +import { + AppStore, + GitHubUserStore, + CloningRepositoriesStore, + IssuesStore, + SignInStore, + RepositoriesStore, + TokenStore, + AccountsStore, + PullRequestStore, +} from '../lib/stores' +import { GitHubUserDatabase } from '../lib/databases' +import { SelectionType, IAppState } from '../lib/app-state' +import { StatsDatabase, StatsStore } from '../lib/stats' +import { + IssuesDatabase, + RepositoriesDatabase, + PullRequestDatabase, +} from '../lib/databases' +import { shellNeedsPatching, updateEnvironmentForProcess } from '../lib/shell' +import { installDevGlobals } from './install-globals' +import { reportUncaughtException, sendErrorReport } from './main-process-proxy' +import { getOS } from '../lib/get-os' +import { + enableSourceMaps, + withSourceMappedStack, +} from '../lib/source-map-support' +import { UiActivityMonitor } from './lib/ui-activity-monitor' +import { RepositoryStateCache } from '../lib/stores/repository-state-cache' +import { ApiRepositoriesStore } from '../lib/stores/api-repositories-store' +import { CommitStatusStore } from '../lib/stores/commit-status-store' +import { PullRequestCoordinator } from '../lib/stores/pull-request-coordinator' + +import { sendNonFatalException } from '../lib/helpers/non-fatal-exception' +import { enableUnhandledRejectionReporting } from '../lib/feature-flag' +import { AheadBehindStore } from '../lib/stores/ahead-behind-store' +import { + ApplicationTheme, + supportsSystemThemeChanges, +} from './lib/application-theme' +import { trampolineUIHelper } from '../lib/trampoline/trampoline-ui-helper' +import { AliveStore } from '../lib/stores/alive-store' +import { NotificationsStore } from '../lib/stores/notifications-store' +import * as ipcRenderer from '../lib/ipc-renderer' +import { migrateRendererGUID } from '../lib/get-renderer-guid' +import { initializeRendererNotificationHandler } from '../lib/notifications/notification-handler' +import { Grid } from 'react-virtualized' +import { NotificationsDebugStore } from '../lib/stores/notifications-debug-store' + +if (__DEV__) { + installDevGlobals() +} + +migrateRendererGUID() + +if (shellNeedsPatching(process)) { + updateEnvironmentForProcess() +} + +enableSourceMaps() + +// Tell dugite where to find the git environment, +// see https://github.com/desktop/dugite/pull/85 +process.env['LOCAL_GIT_DIRECTORY'] = Path.resolve(__dirname, 'git') + +// Ensure that dugite infers the GIT_EXEC_PATH +// based on the LOCAL_GIT_DIRECTORY env variable +// instead of just blindly trusting what's set in +// the current environment. See https://git.io/JJ7KF +delete process.env.GIT_EXEC_PATH + +const startTime = performance.now() + +if (!process.env.TEST_ENV) { + /* This is the magic trigger for webpack to go compile + * our sass into css and inject it into the DOM. */ + require('../../styles/desktop.scss') +} + +// TODO (electron): Remove this once +// https://bugs.chromium.org/p/chromium/issues/detail?id=1113293 +// gets fixed and propagated to electron. +if (__DARWIN__) { + require('../lib/fix-emoji-spacing') +} + +let currentState: IAppState | null = null + +const sendErrorWithContext = ( + error: Error, + context: Record = {}, + nonFatal?: boolean +) => { + error = withSourceMappedStack(error) + + console.error('Uncaught exception', error) + + if (__DEV__ || process.env.TEST_ENV) { + console.error( + `An uncaught exception was thrown. If this were a production build it would be reported to Central. Instead, maybe give it a lil lookyloo.` + ) + } else { + const extra: Record = { + osVersion: getOS(), + ...context, + } + + try { + if (currentState) { + if (currentState.currentBanner !== null) { + extra.currentBanner = currentState.currentBanner.type + } + + if (currentState.currentPopup !== null) { + extra.currentPopup = `${currentState.currentPopup.type}` + } + + if (currentState.selectedState !== null) { + extra.selectedState = `${currentState.selectedState.type}` + + if (currentState.selectedState.type === SelectionType.Repository) { + extra.selectedRepositorySection = `${currentState.selectedState.state.selectedSection}` + } + } + + if (currentState.currentFoldout !== null) { + extra.currentFoldout = `${currentState.currentFoldout.type}` + } + + if (currentState.showWelcomeFlow) { + extra.inWelcomeFlow = 'true' + } + + if (currentState.windowZoomFactor !== 1) { + extra.windowZoomFactor = `${currentState.windowZoomFactor}` + } + + if (currentState.errorCount > 0) { + extra.activeAppErrors = `${currentState.errorCount}` + } + + extra.repositoryCount = `${currentState.repositories.length}` + extra.windowState = currentState.windowState ?? 'Unknown' + extra.accounts = `${currentState.accounts.length}` + + extra.automaticallySwitchTheme = `${ + currentState.selectedTheme === ApplicationTheme.System && + supportsSystemThemeChanges() + }` + } + } catch (err) { + /* ignore */ + } + + sendErrorReport(error, extra, nonFatal ?? false) + } +} + +process.once('uncaughtException', (error: Error) => { + sendErrorWithContext(error) + reportUncaughtException(error) +}) + +// See sendNonFatalException for more information +process.on( + 'send-non-fatal-exception', + (error: Error, context?: { [key: string]: string }) => { + sendErrorWithContext(error, context, true) + } +) + +// See https://github.com/desktop/desktop/pull/15276 and +// https://github.com/desktop/desktop/pull/14885. We want to gradually get back +// to a world where we treat all uncaught exceptions as fatal but as an +// intermediate step to build confidence we're going to route all uncaught +// exceptions to our non-fatal bucket. +if (__RELEASE_CHANNEL__ === 'production') { + // See https://github.com/electron/electron/blob/f07b040cb998a6126979cec9d562acbac5a23c4c/lib/renderer/init.ts#L98 + window.onerror = (_message, _filename, _lineno, _colno, error) => { + sendNonFatalException('uncaughtError', error as any) + // Keep logging to console during the transition period + return false + } +} + +/** + * Chromium won't crash on an unhandled rejection (similar to how it won't crash + * on an unhandled error). We've taken the approach that unhandled errors should + * crash the app and very likely we should do the same thing for unhandled + * promise rejections but that's a bit too risky to do until we've established + * some sense of how often it happens. For now this simply stores the last + * rejection so that we can pass it along with the crash report if the app does + * crash. Note that this does not prevent the default browser behavior of + * logging since we're not calling `preventDefault` on the event. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event + */ +window.addEventListener('unhandledrejection', ev => { + if (enableUnhandledRejectionReporting() && ev.reason instanceof Error) { + sendNonFatalException('unhandledRejection', ev.reason) + } +}) + +const gitHubUserStore = new GitHubUserStore( + new GitHubUserDatabase('GitHubUserDatabase') +) +const cloningRepositoriesStore = new CloningRepositoriesStore() +const issuesStore = new IssuesStore(new IssuesDatabase('IssuesDatabase')) +const statsStore = new StatsStore( + new StatsDatabase('StatsDatabase'), + new UiActivityMonitor() +) +const signInStore = new SignInStore() + +const accountsStore = new AccountsStore(localStorage, TokenStore) +const repositoriesStore = new RepositoriesStore( + new RepositoriesDatabase('Database') +) + +const pullRequestStore = new PullRequestStore( + new PullRequestDatabase('PullRequestDatabase'), + repositoriesStore +) + +const pullRequestCoordinator = new PullRequestCoordinator( + pullRequestStore, + repositoriesStore +) + +const repositoryStateManager = new RepositoryStateCache(statsStore) + +const apiRepositoriesStore = new ApiRepositoriesStore(accountsStore) + +const commitStatusStore = new CommitStatusStore(accountsStore) +const aheadBehindStore = new AheadBehindStore() + +const aliveStore = new AliveStore(accountsStore) + +const notificationsStore = new NotificationsStore( + accountsStore, + aliveStore, + pullRequestCoordinator, + statsStore +) + +const notificationsDebugStore = new NotificationsDebugStore( + accountsStore, + notificationsStore, + pullRequestCoordinator +) + +const appStore = new AppStore( + gitHubUserStore, + cloningRepositoriesStore, + issuesStore, + statsStore, + signInStore, + accountsStore, + repositoriesStore, + pullRequestCoordinator, + repositoryStateManager, + apiRepositoriesStore, + notificationsStore +) + +appStore.onDidUpdate(state => { + currentState = state +}) + +const dispatcher = new Dispatcher( + appStore, + repositoryStateManager, + statsStore, + commitStatusStore +) + +dispatcher.registerErrorHandler(defaultErrorHandler) +dispatcher.registerErrorHandler(upstreamAlreadyExistsHandler) +dispatcher.registerErrorHandler(externalEditorErrorHandler) +dispatcher.registerErrorHandler(openShellErrorHandler) +dispatcher.registerErrorHandler(mergeConflictHandler) +dispatcher.registerErrorHandler(lfsAttributeMismatchHandler) +dispatcher.registerErrorHandler(insufficientGitHubRepoPermissions) +dispatcher.registerErrorHandler(gitAuthenticationErrorHandler) +dispatcher.registerErrorHandler(pushNeedsPullHandler) +dispatcher.registerErrorHandler(samlReauthRequired) +dispatcher.registerErrorHandler(backgroundTaskHandler) +dispatcher.registerErrorHandler(missingRepositoryHandler) +dispatcher.registerErrorHandler(localChangesOverwrittenHandler) +dispatcher.registerErrorHandler(rebaseConflictsHandler) +dispatcher.registerErrorHandler(refusedWorkflowUpdate) +dispatcher.registerErrorHandler(discardChangesHandler) + +document.body.classList.add(`platform-${process.platform}`) + +dispatcher.initializeAppFocusState() + +initializeRendererNotificationHandler(notificationsStore) + +// The trampoline UI helper needs a reference to the dispatcher before it's used +trampolineUIHelper.setDispatcher(dispatcher) + +ipcRenderer.on('focus', () => { + const { selectedState } = appStore.getState() + + // Refresh the currently selected repository on focus (if + // we have a selected repository, that is not cloning). + if ( + selectedState && + !(selectedState.type === SelectionType.CloningRepository) + ) { + dispatcher.refreshRepository(selectedState.repository) + } + + dispatcher.setAppFocusState(true) +}) + +ipcRenderer.on('blur', () => { + // Make sure we stop highlighting the menu button (on non-macOS) + // when someone uses Alt+Tab to switch application since we won't + // get the onKeyUp event for the Alt key in that case. + dispatcher.setAccessKeyHighlightState(false) + dispatcher.setAppFocusState(false) +}) + +ipcRenderer.on('url-action', (_, action) => + dispatcher.dispatchURLAction(action) +) + +// react-virtualized will use the literal string "grid" as the 'aria-label' +// attribute unless we override it. This is a problem because aria-label should +// not be set unless there's a compelling reason for it[1]. +// +// Similarly the default props call for the 'aria-readonly' attribute to be set +// to true which according to MDN doesn't fit our use case[2]: +// +// > This indicates to the user that an interactive element that would normally +// > be focusable and copyable has been placed in a read-only (not disabled) +// > state. +// +// 1. https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label +// 2. https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-readonly +;(function ( + defaults: Record | undefined, + types: Record | undefined +) { + ;['aria-label', 'aria-readonly'].forEach(k => { + delete defaults?.[k] + delete types?.[k] + }) +})(Grid.defaultProps, Grid.propTypes) + +ReactDOM.render( + , + document.getElementById('desktop-app-container')! +) diff --git a/app/src/ui/install-git/index.ts b/app/src/ui/install-git/index.ts new file mode 100644 index 0000000000..0bb8b69fde --- /dev/null +++ b/app/src/ui/install-git/index.ts @@ -0,0 +1 @@ +export * from './install' diff --git a/app/src/ui/install-git/install.tsx b/app/src/ui/install-git/install.tsx new file mode 100644 index 0000000000..ace342f2cc --- /dev/null +++ b/app/src/ui/install-git/install.tsx @@ -0,0 +1,73 @@ +import * as React from 'react' + +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { shell } from '../../lib/app-shell' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +interface IInstallGitProps { + /** + * Event triggered when the dialog is dismissed by the user in the + * ways described in the Dialog component's dismissable prop. + */ + readonly onDismissed: () => void + + /** + * The path to the current repository, in case the user wants to continue + * doing whatever they're doing. + */ + readonly path: string + + /** Called when the user chooses to open the shell. */ + readonly onOpenShell: (path: string) => void +} + +/** + * A dialog indicating that Git wasn't found, to direct the user to an + * external resource for more information about setting up their environment. + */ +export class InstallGit extends React.Component { + public constructor(props: IInstallGitProps) { + super(props) + } + + private onSubmit = () => { + this.props.onOpenShell(this.props.path) + this.props.onDismissed() + } + + private onExternalLink = (e: React.MouseEvent) => { + const url = `https://help.github.com/articles/set-up-git/#setting-up-git` + shell.openExternal(url) + } + + public render() { + return ( + + +

    + We were unable to locate Git on your system. This means you won't be + able to execute any Git commands in the{' '} + {__DARWIN__ ? 'Terminal window' : 'command prompt'}. +

    +

    + To help you get Git installed and configured for your operating + system, we have some external resources available. +

    +
    + + + +
    + ) + } +} diff --git a/app/src/ui/install-globals.ts b/app/src/ui/install-globals.ts new file mode 100644 index 0000000000..93e990e13c --- /dev/null +++ b/app/src/ui/install-globals.ts @@ -0,0 +1,6 @@ +/** Install some globally available values for dev mode. */ +export function installDevGlobals() { + const g: any = global + // Expose GitPerf as a global so it can be started. + g.GitPerf = require('./lib/git-perf') +} diff --git a/app/src/ui/installing-update/installing-update.tsx b/app/src/ui/installing-update/installing-update.tsx new file mode 100644 index 0000000000..64531fc39d --- /dev/null +++ b/app/src/ui/installing-update/installing-update.tsx @@ -0,0 +1,96 @@ +import * as React from 'react' + +import { Row } from '../lib/row' +import { + Dialog, + DialogContent, + OkCancelButtonGroup, + DialogFooter, +} from '../dialog' +import { updateStore, IUpdateState, UpdateStatus } from '../lib/update-store' +import { Disposable } from 'event-kit' +import { DialogHeader } from '../dialog/header' +import { Dispatcher } from '../dispatcher' + +interface IInstallingUpdateProps { + /** + * Event triggered when the dialog is dismissed by the user in the + * ways described in the Dialog component's dismissable prop. + */ + readonly onDismissed: () => void + + readonly dispatcher: Dispatcher +} + +/** + * A dialog that presents information about the + * running application such as name and version. + */ +export class InstallingUpdate extends React.Component { + private updateStoreEventHandle: Disposable | null = null + + private onUpdateStateChanged = (updateState: IUpdateState) => { + // If the update is not being downloaded (`UpdateStatus.UpdateAvailable`), + // i.e. if it's already downloaded or not available, close the window. + if (updateState.status !== UpdateStatus.UpdateAvailable) { + this.props.dispatcher.quitApp(false) + } + } + + public componentDidMount() { + this.updateStoreEventHandle = updateStore.onDidChange( + this.onUpdateStateChanged + ) + + // Manually update the state to ensure we're in sync with the store + this.onUpdateStateChanged(updateStore.state) + } + + public componentWillUnmount() { + if (this.updateStoreEventHandle) { + this.updateStoreEventHandle.dispose() + this.updateStoreEventHandle = null + } + + // This will ensure the app doesn't try to quit after the update is + // installed once the dialog is closed (explicitly or implicitly, by + // opening another dialog on top of this one). + this.props.dispatcher.cancelQuittingApp() + } + + private onQuitAnywayButtonClicked = () => { + this.props.dispatcher.quitApp(true) + } + + public render() { + return ( + + + + + Do not close GitHub Desktop while the update is in progress. Closing + now may break your installation. + + + + + + + ) + } +} diff --git a/app/src/ui/invalidated-token/invalidated-token.tsx b/app/src/ui/invalidated-token/invalidated-token.tsx new file mode 100644 index 0000000000..6c9f9bfd46 --- /dev/null +++ b/app/src/ui/invalidated-token/invalidated-token.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { Dispatcher } from '../dispatcher' +import { Row } from '../lib/row' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { Account } from '../../models/account' +import { getDotComAPIEndpoint } from '../../lib/api' + +interface IInvalidatedTokenProps { + readonly dispatcher: Dispatcher + readonly account: Account + readonly onDismissed: () => void +} + +/** + * Dialog that alerts user that their GitHub (Enterprise) account token is not + * valid and they need to sign in again. + */ +export class InvalidatedToken extends React.Component { + public render() { + const accountTypeSuffix = this.isEnterpriseAccount ? ' Enterprise' : '' + + return ( + + + + Your account token has been invalidated and you have been signed out + from your GitHub{accountTypeSuffix} account. Do you want to sign in + again? + + + + + + + ) + } + + private get isEnterpriseAccount() { + return this.props.account.endpoint !== getDotComAPIEndpoint() + } + + private onSubmit = () => { + const { dispatcher, onDismissed } = this.props + + onDismissed() + + if (this.isEnterpriseAccount) { + dispatcher.showEnterpriseSignInDialog(this.props.account.endpoint) + } else { + dispatcher.showDotComSignInDialog() + } + } +} diff --git a/app/src/ui/keyboard-shortcut/keyboard-shortcut.tsx b/app/src/ui/keyboard-shortcut/keyboard-shortcut.tsx new file mode 100644 index 0000000000..0c1d61145f --- /dev/null +++ b/app/src/ui/keyboard-shortcut/keyboard-shortcut.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' + +interface IKeyboardShortCutProps { + /** Windows/Linux keyboard shortcut */ + readonly keys: ReadonlyArray + /** MacOS keyboard shortcut */ + readonly darwinKeys: ReadonlyArray +} + +export class KeyboardShortcut extends React.Component { + public render() { + const keys = __DARWIN__ ? this.props.darwinKeys : this.props.keys + + return keys.map((k, i) => { + return ( + + {k} + {!__DARWIN__ && i < keys.length - 1 ? <>+ : null} + + ) + }) + } +} diff --git a/app/src/ui/lfs/attribute-mismatch.tsx b/app/src/ui/lfs/attribute-mismatch.tsx new file mode 100644 index 0000000000..f7adf6fc59 --- /dev/null +++ b/app/src/ui/lfs/attribute-mismatch.tsx @@ -0,0 +1,94 @@ +import * as React from 'react' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { LinkButton } from '../lib/link-button' +import { getGlobalConfigPath } from '../../lib/git' +import { shell } from '../../lib/app-shell' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +interface IAttributeMismatchProps { + /** Called when the dialog should be dismissed. */ + readonly onDismissed: () => void + + /** Called when the user has chosen to replace the update filters. */ + readonly onUpdateExistingFilters: () => void +} + +interface IAttributeMismatchState { + readonly globalGitConfigPath: string | null +} + +export class AttributeMismatch extends React.Component< + IAttributeMismatchProps, + IAttributeMismatchState +> { + public constructor(props: IAttributeMismatchProps) { + super(props) + + this.state = { + globalGitConfigPath: null, + } + } + + public async componentDidMount() { + try { + const path = await getGlobalConfigPath() + this.setState({ globalGitConfigPath: path }) + } catch (error) { + log.warn(`Couldn't get the global git config path`, error) + } + } + + private renderGlobalGitConfigLink() { + const path = this.state.globalGitConfigPath + const msg = 'your global git config' + if (path) { + return {msg} + } else { + return msg + } + } + + private showGlobalGitConfig = () => { + const path = this.state.globalGitConfigPath + if (path) { + shell.openPath(path) + } + } + + public render() { + return ( + + +

    + Git LFS filters are already configured in{' '} + {this.renderGlobalGitConfigLink()} but are not the values it + expects. Would you like to update them now? +

    +
    + + + + +
    + ) + } + + private onSubmit = () => { + this.props.onUpdateExistingFilters() + this.props.onDismissed() + } +} diff --git a/app/src/ui/lfs/index.ts b/app/src/ui/lfs/index.ts new file mode 100644 index 0000000000..f00ff45b43 --- /dev/null +++ b/app/src/ui/lfs/index.ts @@ -0,0 +1,2 @@ +export { InitializeLFS } from './initialize-lfs' +export { AttributeMismatch } from './attribute-mismatch' diff --git a/app/src/ui/lfs/initialize-lfs.tsx b/app/src/ui/lfs/initialize-lfs.tsx new file mode 100644 index 0000000000..91299ec263 --- /dev/null +++ b/app/src/ui/lfs/initialize-lfs.tsx @@ -0,0 +1,93 @@ +import * as React from 'react' +import { Repository } from '../../models/repository' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { PathText } from '../lib/path-text' +import { LinkButton } from '../lib/link-button' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' + +const LFSURL = 'https://git-lfs.github.com/' + +/** + * If we're initializing any more than this number, we won't bother listing them + * all. + */ +const MaxRepositoriesToList = 10 + +interface IInitializeLFSProps { + /** The repositories in which LFS needs to be initialized. */ + readonly repositories: ReadonlyArray + + /** + * Event triggered when the dialog is dismissed by the user in the + * ways described in the Dialog component's dismissable prop. + */ + readonly onDismissed: () => void + + /** + * Called when the user chooses to initialize LFS in the repositories. + */ + readonly onInitialize: (repositories: ReadonlyArray) => void +} + +export class InitializeLFS extends React.Component { + public render() { + return ( + + {this.renderRepositories()} + + + + + + ) + } + + private onInitialize = () => { + this.props.onInitialize(this.props.repositories) + this.props.onDismissed() + } + + private renderRepositories() { + if (this.props.repositories.length > MaxRepositoriesToList) { + return ( +

    + {this.props.repositories.length} repositories use{' '} + Git LFS. To contribute to them, + Git LFS must first be initialized. Would you like to do so now? +

    + ) + } else { + const plural = this.props.repositories.length !== 1 + const pluralizedRepositories = plural + ? 'The repositories use' + : 'This repository uses' + const pluralizedUse = plural ? 'them' : 'it' + return ( +
    +

    + {pluralizedRepositories}{' '} + Git LFS. To contribute to{' '} + {pluralizedUse}, Git LFS must first be initialized. Would you like + to do so now? +

    +
      + {this.props.repositories.map(r => ( +
    • + +
    • + ))} +
    +
    + ) + } + } +} diff --git a/app/src/ui/lib/access-text.tsx b/app/src/ui/lib/access-text.tsx new file mode 100644 index 0000000000..6f3bb3c51b --- /dev/null +++ b/app/src/ui/lib/access-text.tsx @@ -0,0 +1,83 @@ +import * as React from 'react' +import classNames from 'classnames' + +interface IAccessTextProps { + /** + * A string which optionally contains an access key modifier (ampersand). + * The access key modifier directly precedes the character which is + * highlighted when the highlight property is set. Literal ampersand + * characters need to be escaped by using two ampersand characters (&&). + * + * At most one character is allowed to have a preceding ampersand character. + */ + readonly text: string + + /** + * Whether or not to highlight the access key (if one exists). + */ + readonly highlight?: boolean +} + +function unescape(accessText: string) { + return accessText.replace('&&', '&') +} + +/** + * A platform helper function which optionally highlights access keys (letters + * prefixed with &) on Windows. On non-Windows platform access key prefixes + * are removed before rendering. + */ +export class AccessText extends React.Component { + public shouldComponentUpdate(nextProps: IAccessTextProps) { + return ( + this.props.text !== nextProps.text || + this.props.highlight !== nextProps.highlight + ) + } + + public render() { + // Match everything (if anything) before an ampersand followed by anything that's + // not an ampersand and then capture the remainder. + const m = this.props.text.match(/^(.*?)?(?:&([^&]))(.*)?$/) + + if (!m) { + return {this.props.text} + } + + const elements = new Array() + + if (m[1]) { + elements.push( + + {unescape(m[1])} + + ) + } + + const className = classNames('access-key', { + highlight: this.props.highlight, + }) + + elements.push( + + {m[2]} + + ) + + if (m[3]) { + elements.push( + + {unescape(m[3])} + + ) + } + + const preText = m[1] ? unescape(m[1]) : '' + const accessKeyText = m[2] + const postText = m[3] ? unescape(m[3]) : '' + + const plainText = `${preText}${accessKeyText}${postText}` + + return {elements} + } +} diff --git a/app/src/ui/lib/action-status-icon.tsx b/app/src/ui/lib/action-status-icon.tsx new file mode 100644 index 0000000000..48ff4f33bc --- /dev/null +++ b/app/src/ui/lib/action-status-icon.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import classNames from 'classnames' +import { ComputedAction } from '../../models/computed-action' +import { assertNever } from '../../lib/fatal-error' + +import { Octicon, OcticonSymbolType } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' + +interface IActionStatusIconProps { + /** The status to display to the user */ + readonly status: { kind: ComputedAction } | null + + /** A required class name prefix for the Octicon component */ + readonly classNamePrefix: string + + /** The classname for the underlying element. */ + readonly className?: string +} + +/** + * A component used to render a visual indication of a `ComputedAction` state. + * + * In essence this is a small wrapper around an `Octicon` which determines which + * icon to use based on the `ComputedAction`. A computed action is essentially + * the current state of a merge or a rebase operation and this component is used + * in the header of merge or rebase conflict dialogs to augment the textual + * representation of the current merge or rebase progress. + */ +export class ActionStatusIcon extends React.Component { + public render() { + const { status, classNamePrefix } = this.props + if (status === null) { + return null + } + + const { kind } = status + + const className = `${classNamePrefix}-icon-container` + + return ( +
    + +
    + ) + } +} + +function getSymbolForState(status: ComputedAction): OcticonSymbolType { + switch (status) { + case ComputedAction.Loading: + return OcticonSymbol.dotFill + case ComputedAction.Conflicts: + return OcticonSymbol.alert + case ComputedAction.Invalid: + return OcticonSymbol.x + case ComputedAction.Clean: + return OcticonSymbol.check + default: + return assertNever(status, `Unknown state: ${JSON.stringify(status)}`) + } +} diff --git a/app/src/ui/lib/app-proxy.ts b/app/src/ui/lib/app-proxy.ts new file mode 100644 index 0000000000..78db36c4fe --- /dev/null +++ b/app/src/ui/lib/app-proxy.ts @@ -0,0 +1,70 @@ +import { getPath } from '../main-process-proxy' +import { getAppPathProxy } from '../main-process-proxy' + +let path: string | null = null +let documentsPath: string | null = null + +export type PathType = + | 'home' + | 'appData' + | 'userData' + | 'temp' + | 'exe' + | 'module' + | 'desktop' + | 'documents' + | 'downloads' + | 'music' + | 'pictures' + | 'videos' + | 'recent' + | 'logs' + | 'crashDumps' + | 'sessionData' + +/** + * Get the version of the app. + * + * This is preferable to using `remote` directly because we cache the result. + */ +export function getVersion(): string { + return __APP_VERSION__ +} + +/** + * Get the name of the app. + */ +export function getName(): string { + return __APP_NAME__ +} + +/** + * Get the path to the application. + * + * This is preferable to using `remote` directly because we cache the result. + */ +export async function getAppPath(): Promise { + if (!path) { + path = await getAppPathProxy() + } + + return path +} + +/** + * Get the path to the user's documents path. + * + * This is preferable to using `remote` directly because we cache the result. + */ +export async function getDocumentsPath(): Promise { + if (!documentsPath) { + try { + documentsPath = await getPath('documents') + } catch (ex) { + // a user profile may not have the Documents folder defined on Windows + documentsPath = await getPath('home') + } + } + + return documentsPath +} diff --git a/app/src/ui/lib/application-theme.ts b/app/src/ui/lib/application-theme.ts new file mode 100644 index 0000000000..f6dd5c9bbf --- /dev/null +++ b/app/src/ui/lib/application-theme.ts @@ -0,0 +1,132 @@ +import { + isMacOSMojaveOrLater, + isWindows10And1809Preview17666OrLater, +} from '../../lib/get-os' +import { getBoolean } from '../../lib/local-storage' +import { + setNativeThemeSource, + shouldUseDarkColors, +} from '../main-process-proxy' +import { ThemeSource } from './theme-source' + +/** + * A set of the user-selectable appearances (aka themes) + */ +export enum ApplicationTheme { + Light = 'light', + Dark = 'dark', + System = 'system', +} + +export type ApplicableTheme = ApplicationTheme.Light | ApplicationTheme.Dark + +/** + * Gets the friendly name of an application theme for use + * in persisting to storage and/or calculating the required + * body class name to set in order to apply the theme. + */ +export function getThemeName(theme: ApplicationTheme): ThemeSource { + switch (theme) { + case ApplicationTheme.Light: + return 'light' + case ApplicationTheme.Dark: + return 'dark' + default: + return 'system' + } +} + +// The key under which the decision to automatically switch the theme is persisted +// in localStorage. +const automaticallySwitchApplicationThemeKey = 'autoSwitchTheme' + +/** + * Function to preserve and convert legacy theme settings + * should be removable after most users have upgraded to 2.7.0+ + */ +function migrateAutomaticallySwitchSetting(): string | null { + const automaticallySwitchApplicationTheme = getBoolean( + automaticallySwitchApplicationThemeKey, + false + ) + + localStorage.removeItem(automaticallySwitchApplicationThemeKey) + + if (automaticallySwitchApplicationTheme) { + setPersistedTheme(ApplicationTheme.System) + return 'system' + } + + return null +} + +// The key under which the currently selected theme is persisted +// in localStorage. +const applicationThemeKey = 'theme' + +/** + * Returns User's theme preference or 'system' if not set or parsable + */ +function getApplicationThemeSetting(): ApplicationTheme { + const themeSetting = localStorage.getItem(applicationThemeKey) + + if ( + themeSetting === ApplicationTheme.Light || + themeSetting === ApplicationTheme.Dark + ) { + return themeSetting + } + + return ApplicationTheme.System +} + +/** + * Load the name of the currently selected theme + */ +export async function getCurrentlyAppliedTheme(): Promise { + return (await isDarkModeEnabled()) + ? ApplicationTheme.Dark + : ApplicationTheme.Light +} + +/** + * Load the name of the currently selected theme + */ +export function getPersistedThemeName(): ApplicationTheme { + if (migrateAutomaticallySwitchSetting() === 'system') { + return ApplicationTheme.System + } + + return getApplicationThemeSetting() +} + +/** + * Stores the given theme in the persistent store. + */ +export function setPersistedTheme(theme: ApplicationTheme): void { + const themeName = getThemeName(theme) + localStorage.setItem(applicationThemeKey, theme) + setNativeThemeSource(themeName) +} + +/** + * Whether or not the current OS supports System Theme Changes + */ +export function supportsSystemThemeChanges(): boolean { + if (__DARWIN__) { + return isMacOSMojaveOrLater() + } else if (__WIN32__) { + // Its technically possible this would still work on prior versions of Windows 10 but 1809 + // was released October 2nd, 2018 and the feature can just be "attained" by upgrading + // See https://github.com/desktop/desktop/issues/9015 for more + return isWindows10And1809Preview17666OrLater() + } else { + // enabling this for Linux users as an experiment to see if distributions + // work with how Chromium detects theme changes + return true + } +} + +function isDarkModeEnabled(): Promise { + return shouldUseDarkColors() +} diff --git a/app/src/ui/lib/aria-types.ts b/app/src/ui/lib/aria-types.ts new file mode 100644 index 0000000000..9da74541e5 --- /dev/null +++ b/app/src/ui/lib/aria-types.ts @@ -0,0 +1,3 @@ +import { HTMLAttributes } from 'react' + +export type AriaHasPopupType = HTMLAttributes['aria-haspopup'] diff --git a/app/src/ui/lib/authentication-form.tsx b/app/src/ui/lib/authentication-form.tsx new file mode 100644 index 0000000000..6d0f8a477d --- /dev/null +++ b/app/src/ui/lib/authentication-form.tsx @@ -0,0 +1,251 @@ +import * as React from 'react' +import { LinkButton } from '../lib/link-button' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Loading } from './loading' +import { Form } from './form' +import { Button } from './button' +import { TextBox } from './text-box' +import { Errors } from './errors' +import { getDotComAPIEndpoint } from '../../lib/api' +import { HorizontalRule } from './horizontal-rule' +import { PasswordTextBox } from './password-text-box' + +/** Text to let the user know their browser will send them back to GH Desktop */ +export const BrowserRedirectMessage = + "Your browser will redirect you back to GitHub Desktop once you've signed in. If your browser asks for your permission to launch GitHub Desktop please allow it to." + +interface IAuthenticationFormProps { + /** + * The URL to the host which we're currently authenticating + * against. This will be either https://api.github.com when + * signing in against GitHub.com or a user-specified + * URL when signing in against a GitHub Enterprise + * instance. + */ + readonly endpoint: string + + /** + * Does the server support basic auth? + * If the server responds that it doesn't, the user will be prompted to use + * that server's web sign in flow. + * + * ("Basic auth" is logging in via user + password entered directly in Desktop.) + */ + readonly supportsBasicAuth: boolean + + /** + * A callback which is invoked once the user has entered a username + * and password and submitted those either by clicking on the submit + * button or by submitting the form through other means (ie hitting Enter). + */ + readonly onSubmit: (username: string, password: string) => void + + /** + * A callback which is invoked if the user requests OAuth sign in using + * their system configured browser. + */ + readonly onBrowserSignInRequested: () => void + + /** + * An array of additional buttons to render after the "Sign In" button. + * (Usually, a 'cancel' button) + */ + readonly additionalButtons?: ReadonlyArray + + /** + * An error which, if present, is presented to the + * user in close proximity to the actions or input fields + * related to the current step. + */ + readonly error: Error | null + + /** + * A value indicating whether or not the sign in store is + * busy processing a request. While this value is true all + * form inputs and actions save for a cancel action will + * be disabled. + */ + readonly loading: boolean + + readonly forgotPasswordUrl: string +} + +interface IAuthenticationFormState { + readonly username: string + readonly password: string +} + +/** The GitHub authentication component. */ +export class AuthenticationForm extends React.Component< + IAuthenticationFormProps, + IAuthenticationFormState +> { + public constructor(props: IAuthenticationFormProps) { + super(props) + + this.state = { username: '', password: '' } + } + + public render() { + const content = this.props.supportsBasicAuth + ? this.renderSignInForm() + : this.renderEndpointRequiresWebFlow() + + return ( +
    + {content} +
    + ) + } + + private renderUsernamePassword() { + const disabled = this.props.loading + return ( + <> + + + + + {this.renderError()} + +
    {this.renderActions()}
    + + ) + } + + private renderActions() { + const signInDisabled = Boolean( + !this.state.username.length || + !this.state.password.length || + this.props.loading + ) + return ( +
    + {this.props.supportsBasicAuth ? ( + + ) : null} + + {this.props.additionalButtons} + + {this.props.supportsBasicAuth ? ( + + Forgot password? + + ) : null} +
    + ) + } + + /** + * Show the sign in locally form + * + * Also displays an option to sign in with browser for + * enterprise users (but not for dot com users since + * they will have already been offered this option + * earlier in the UI flow). + */ + private renderSignInForm() { + return this.props.endpoint === getDotComAPIEndpoint() ? ( + this.renderUsernamePassword() + ) : ( + <> + {this.renderSignInWithBrowserButton()} + + {this.renderUsernamePassword()} + + ) + } + + /** + * Show a message informing the user they must sign in via the web flow + * and a button to do so + */ + private renderEndpointRequiresWebFlow() { + return ( + <> + {getEndpointRequiresWebFlowMessage(this.props.endpoint)} + {this.renderSignInWithBrowserButton()} + {this.props.additionalButtons} + + ) + } + + private renderSignInWithBrowserButton() { + return ( + + ) + } + + private renderError() { + const error = this.props.error + if (!error) { + return null + } + + return {error.message} + } + + private onUsernameChange = (username: string) => { + this.setState({ username }) + } + + private onPasswordChange = (password: string) => { + this.setState({ password }) + } + + private signInWithBrowser = (event?: React.MouseEvent) => { + if (event) { + event.preventDefault() + } + this.props.onBrowserSignInRequested() + } + + private signIn = () => { + this.props.onSubmit(this.state.username, this.state.password) + } +} + +function getEndpointRequiresWebFlowMessage(endpoint: string): JSX.Element { + if (endpoint === getDotComAPIEndpoint()) { + return ( + <> +

    GitHub now requires you to sign in with your browser.

    +

    {BrowserRedirectMessage}

    + + ) + } else { + return ( +

    + Your GitHub Enterprise instance requires you to sign in with your + browser. +

    + ) + } +} diff --git a/app/src/ui/lib/author-input/author-handle.tsx b/app/src/ui/lib/author-input/author-handle.tsx new file mode 100644 index 0000000000..1f58a44e7b --- /dev/null +++ b/app/src/ui/lib/author-input/author-handle.tsx @@ -0,0 +1,166 @@ +import classNames from 'classnames' +import React from 'react' +import { Author, isKnownAuthor } from '../../../models/author' +import { Octicon, syncClockwise } from '../../octicons' +import * as OcticonSymbol from '../../octicons/octicons.generated' +import { getFullTextForAuthor, getDisplayTextForAuthor } from './author-text' + +interface IAuthorHandleProps { + /** Author to render */ + readonly author: Author + + /** Index of the author in the list of added authors */ + readonly index: number + + /** Whether the author is focused */ + readonly isFocused: boolean + + /** Whether the author is the last author in the list of added authors */ + readonly isLastAuthor: boolean + + /** Whether the author is the first author in the list of added authors */ + readonly isFirstAuthor: boolean + + /** Whether the container element has the focus within it */ + readonly isFocusWithinContainer: boolean + + /** Whether the input element has the focus */ + readonly isInputFocused: boolean + + /** Callback to invoke when the user presses a key */ + readonly onKeyDown: ( + index: number, + event: React.KeyboardEvent + ) => void + + /** Callback to invoke when the user clicks on the author */ + readonly onHandleClick: ( + index: number, + event: React.MouseEvent + ) => void + + /** Callback to invoke when the user clicks on the remove button */ + readonly onRemoveClick: ( + index: number, + event: React.MouseEvent + ) => void + + /** Callback to invoke when the user focuses on the author */ + readonly onFocus: ( + index: number, + event: React.FocusEvent + ) => void +} + +export class AuthorHandle extends React.Component { + private getAriaLabel() { + const { author } = this.props + if (isKnownAuthor(author)) { + return `${getFullTextForAuthor( + author + )} press backspace or delete to remove` + } + + const isError = author.state === 'error' + const stateAriaLabel = isError ? 'user not found' : 'searching' + return `${author.username}, ${stateAriaLabel}, press backspace or delete to remove` + } + + private getClassName() { + const { author, isFocused } = this.props + const classNamesArr: Array = ['handle', { focused: isFocused }] + if (!isKnownAuthor(author)) { + const isError = author.state === 'error' + classNamesArr.push({ progress: !isError, error: isError }) + } + return classNames(classNamesArr) + } + + private getTitle() { + const { author } = this.props + + if (isKnownAuthor(author)) { + return undefined + } + + return author.state === 'error' + ? `Could not find user with username ${author.username}` + : `Searching for @${author.username}` + } + + private getTabIndex() { + const { + isFocusWithinContainer, + isFocused, + isLastAuthor, + isFirstAuthor, + isInputFocused, + } = this.props + // If the component is not focused, then only the first author should be + // focusable + if (!isFocusWithinContainer) { + return isFirstAuthor ? 0 : -1 + } + + // If the author is focused already, then it should be focusable + if (isFocused) { + return 0 + } + + // Otherwise, if the input is focused, then only the last author should be + // focusable in order to leave the input with shift+tab + return isLastAuthor && isInputFocused ? 0 : -1 + } + + public render() { + const { author, isFocused } = this.props + + return ( +
    + + {!isKnownAuthor(author) && ( + + )} + +
    + ) + } + + private onKeyDown = (event: React.KeyboardEvent) => { + this.props.onKeyDown(this.props.index, event) + } + + private onRemoveClick = (event: React.MouseEvent) => { + event.preventDefault() + this.props.onRemoveClick(this.props.index, event) + } + + private onHandleClick = (event: React.MouseEvent) => { + if (event.isDefaultPrevented()) { + return + } + + this.props.onHandleClick(this.props.index, event) + } + + private onFocus = (event: React.FocusEvent) => { + this.props.onFocus(this.props.index, event) + } +} diff --git a/app/src/ui/lib/author-input/author-input.tsx b/app/src/ui/lib/author-input/author-input.tsx new file mode 100644 index 0000000000..fb6ad0b9e6 --- /dev/null +++ b/app/src/ui/lib/author-input/author-input.tsx @@ -0,0 +1,495 @@ +import * as React from 'react' +import classNames from 'classnames' +import { + UserAutocompletionProvider, + AutocompletingInput, + UserHit, + KnownUserHit, +} from '../../autocompletion' +import { + Author, + isKnownAuthor, + KnownAuthor, + UnknownAuthor, +} from '../../../models/author' +import { getLegacyStealthEmailForUser } from '../../../lib/email' +import memoizeOne from 'memoize-one' +import { FocusContainer } from '../focus-container' +import { AuthorHandle } from './author-handle' +import { getFullTextForAuthor } from './author-text' + +interface IAuthorInputProps { + /** + * An optional class name for the wrapper element around the + * author input component + */ + readonly className?: string + + /** + * The user autocomplete provider to use when searching for substring + * matches while autocompleting. + */ + readonly autoCompleteProvider: UserAutocompletionProvider + + /** + * The list of authors to fill the input with initially. If this + * prop changes from what's propagated through onAuthorsUpdated + * while the component is mounted it will reset, loosing + * any text that has not yet been resolved to an author. + */ + readonly authors: ReadonlyArray + + /** + * A method called when authors has been added or removed from the + * input field. + */ + readonly onAuthorsUpdated: (authors: ReadonlyArray) => void + + /** + * Whether or not the input should be read-only and styled as being + * disabled. When disabled the component will not accept focus. + */ + readonly disabled: boolean +} + +interface IAuthorInputState { + /** Whether or not the focus is within this component */ + readonly isFocusedWithin: boolean + + /** Index of the added author currently focused */ + readonly focusedAuthorIndex: number | null + + /** Last action description to be announced by screen readers */ + readonly lastActionDescription: string | null +} + +/** + * Returns an email address which can be used on the host side to + * look up the user which is to be given attribution. + * + * If the user has a public email address specified in their profile + * that's used and if they don't then we'll generate a stealth email + * address. + */ +function getEmailAddressForUser(user: KnownUserHit) { + return user.email && user.email.length > 0 + ? user.email + : getLegacyStealthEmailForUser(user.username, user.endpoint) +} + +/** + * Convert a IUserHit object which is returned from + * user-autocomplete-provider into a KnownAuthor object. + * + * If the IUserHit object lacks an email address we'll + * attempt to create a stealth email address. + */ +function authorFromUserHit(user: KnownUserHit): KnownAuthor { + return { + kind: 'known', + name: user.name || user.username, + email: getEmailAddressForUser(user), + username: user.username, + } +} + +/** + * Autocompletable input field for possible authors of a commit. + * + * Intended primarily for co-authors but written in a general enough + * fashion to deal only with authors in general. + */ +export class AuthorInput extends React.Component< + IAuthorInputProps, + IAuthorInputState +> { + private autocompletingInputRef = + React.createRef>() + private shadowInputRef = React.createRef() + private inputRef: HTMLInputElement | null = null + private authorContainerRef = React.createRef() + + private getAutocompleteItemFilter = memoizeOne( + (authors: ReadonlyArray) => (item: UserHit) => { + if (item.kind !== 'known-user') { + return true + } + + const usernames = authors.map(a => a.username) + + return !usernames.some(u => u === item.username) + } + ) + + public constructor(props: IAuthorInputProps) { + super(props) + + this.state = { + isFocusedWithin: false, + focusedAuthorIndex: null, + lastActionDescription: null, + } + } + + public componentDidUpdate( + prevProps: IAuthorInputProps, + prevState: IAuthorInputState + ) { + // If the focus is inside of the component and _something_ changed that + // could affect the focus, make sure the focus is still where it should + if ( + this.state.isFocusedWithin && + (prevProps.authors.length !== this.props.authors.length || + prevState.focusedAuthorIndex !== this.state.focusedAuthorIndex) + ) { + this.focusAuthorHandle(this.state.focusedAuthorIndex) + } + } + + public focus() { + this.autocompletingInputRef.current?.focus() + } + + private focusAuthorHandle(index: number | null) { + if (index === null) { + this.inputRef?.focus() + return + } + + const handle = this.authorContainerRef.current?.getElementsByClassName( + 'handle' + )[index] as HTMLElement | null + handle?.focus() + } + + public render() { + const className = classNames( + 'author-input-component', + this.props.className, + { + disabled: this.props.disabled, + } + ) + + return ( + +
    + {this.state.lastActionDescription} +
    +
    + + {this.renderAuthors()} + + elementId="author-input" + placeholder="@username" + alwaysAutocomplete={true} + autocompletionProviders={[this.props.autoCompleteProvider]} + autocompleteItemFilter={this.getAutocompleteItemFilter( + this.props.authors + )} + ref={this.autocompletingInputRef} + onElementRef={this.onInputRef} + onAutocompleteItemSelected={this.onAutocompleteItemSelected} + onValueChanged={this.onCoAuthorsValueChanged} + onKeyDown={this.onInputKeyDown} + onFocus={this.onInputFocus} + /> + + ) + } + + private renderAuthors() { + return ( +
    + {this.props.authors.map((author, index) => { + return this.renderAuthor(author, index) + })} +
    + ) + } + + private renderAuthor(author: Author, index: number) { + const { focusedAuthorIndex, isFocusedWithin } = this.state + + return ( + + ) + } + private onFocusWithinChanged = (isFocusedWithin: boolean) => { + const focusedAuthorIndex = isFocusedWithin + ? this.state.focusedAuthorIndex + : null + this.setState({ focusedAuthorIndex, isFocusedWithin }) + } + + private onAuthorKeyDown = ( + index: number, + event: React.KeyboardEvent + ) => { + if (event.key === 'ArrowLeft') { + this.focusPreviousAuthor() + } else if (event.key === 'ArrowRight') { + this.focusNextAuthor() + } else if ( + this.state.focusedAuthorIndex !== null && + (event.key === 'Backspace' || event.key === 'Delete') + ) { + this.removeAuthor( + this.state.focusedAuthorIndex, + event.key === 'Backspace' ? 'back' : 'forward' + ) + } + } + + private removeAuthor(index: number, direction: 'back' | 'forward' | 'none') { + const { authors } = this.props + + if (index >= authors.length) { + return + } + + const authorToRemove = authors[index] + const newAuthors = authors.slice(0, index).concat(authors.slice(index + 1)) + let newFocusedAuthorIndex: number | null = null + + // Focus next author depending on the "direction" of the removal: + // - if we're using backspace, move to the previous author + // - if we're using delete, move to the next author (which means staying + // on the same index) + if (newAuthors.length > 0) { + if (direction === 'back') { + newFocusedAuthorIndex = Math.max(0, index - 1) + } else { + newFocusedAuthorIndex = + index === authors.length - 1 + ? null + : Math.min(newAuthors.length - 1, index) + } + } + + let actionDescription = `Removed ${authorToRemove.username}` + if (isKnownAuthor(authorToRemove)) { + actionDescription += ` (${authorToRemove.name})` + } + + this.setState({ + focusedAuthorIndex: newFocusedAuthorIndex, + lastActionDescription: actionDescription, + }) + + this.emitAuthorsUpdated(newAuthors) + } + + private emitAuthorsUpdated(addedAuthors: ReadonlyArray) { + this.props.onAuthorsUpdated(addedAuthors) + } + + private focusPreviousAuthor() { + const { focusedAuthorIndex } = this.state + const { authors } = this.props + + if (focusedAuthorIndex === null) { + this.setState({ focusedAuthorIndex: authors.length - 1 }) + } else if (focusedAuthorIndex > 0) { + this.setState({ focusedAuthorIndex: focusedAuthorIndex - 1 }) + } + } + + private focusNextAuthor() { + const { focusedAuthorIndex } = this.state + const { authors } = this.props + + if ( + focusedAuthorIndex !== null && + focusedAuthorIndex < authors.length - 1 + ) { + this.setState({ focusedAuthorIndex: focusedAuthorIndex + 1 }) + } else { + this.setState({ focusedAuthorIndex: null }) + } + } + + private onInputFocus = () => { + this.setState({ + focusedAuthorIndex: null, + }) + } + + private onCoAuthorsValueChanged = (value: string) => { + if ( + this.shadowInputRef.current === null || + this.inputRef === null || + this.inputRef.parentElement === null || + this.inputRef.parentElement.parentElement === null + ) { + return + } + + // HACK: input elements don't behave as expected when we want them to fit + // to their content, and expand if there is enough space. They take more + // space than needed. + // This HACK uses a "shadow" (invisible) element with same styles as the + // input element to calculate the width of the input element based on its + // content. + // We will also take into account the width of the ancestors' width to make + // the input element expand as much as possible without overflowing. + + this.shadowInputRef.current.textContent = value + const valueWidth = this.shadowInputRef.current.clientWidth + this.shadowInputRef.current.textContent = this.inputRef.placeholder + const placeholderWidth = this.shadowInputRef.current.clientWidth + + const inputParent = this.inputRef.parentElement + const inputGrandparent = this.inputRef.parentElement.parentElement + + const grandparentPadding = 10 + inputParent.style.minWidth = `${Math.min( + inputGrandparent.getBoundingClientRect().width - grandparentPadding, + Math.max(valueWidth, placeholderWidth) + )}px` + } + + private onInputRef = (input: HTMLInputElement | null) => { + if (input === null) { + return + } + + this.inputRef = input + } + + private onAutocompleteItemSelected = (item: UserHit) => { + const authorToAdd: Author = + item.kind === 'known-user' + ? authorFromUserHit(item) + : { + kind: 'unknown', + username: item.username, + state: 'searching', + } + + const newAuthors = [...this.props.authors, authorToAdd] + this.emitAuthorsUpdated(newAuthors) + + let actionDescription = `Added ${authorToAdd.username}` + if (!isKnownAuthor(authorToAdd)) { + this.attemptUnknownAuthorSearch(authorToAdd) + } else { + actionDescription += ` (${authorToAdd.name})` + } + + this.setState({ lastActionDescription: actionDescription }) + + if (this.inputRef !== null) { + this.inputRef.value = '' + this.onCoAuthorsValueChanged('') + } + } + + private async attemptUnknownAuthorSearch(author: UnknownAuthor) { + const knownAuthor = this.props.authors + .filter(isKnownAuthor) + .find(a => a.username?.toLowerCase() === author.username.toLowerCase()) + + if (knownAuthor !== undefined) { + this.updateUnknownAuthor(knownAuthor) + return + } + + const hit = await this.props.autoCompleteProvider.exactMatch( + author.username + ) + + if (hit === null || hit.kind !== 'known-user') { + const erroredUnknownAuthor: UnknownAuthor = { + ...author, + state: 'error', + } + + this.updateUnknownAuthor(erroredUnknownAuthor) + this.setState({ + lastActionDescription: `Error: user ${author.username} not found`, + }) + return + } + + const hitAuthor = authorFromUserHit(hit) + this.updateUnknownAuthor(hitAuthor) + } + + private updateUnknownAuthor(author: Author) { + const newAuthors = this.props.authors.map(a => + a.username?.toLowerCase() === author.username?.toLowerCase() && + !isKnownAuthor(a) + ? author + : a + ) + + this.emitAuthorsUpdated(newAuthors) + } + + private onInputKeyDown = (event: React.KeyboardEvent) => { + if (this.inputRef === null) { + return + } + + if ( + (event.key === 'ArrowLeft' || event.key === 'Backspace') && + this.inputRef.selectionStart === 0 + ) { + this.focusPreviousAuthor() + } + + // If Space is pressed at the end of the text, attempt to autocomplete + if ( + event.key === ' ' && + this.inputRef.selectionStart === this.inputRef.value.length + ) { + event.preventDefault() + + const value = this.inputRef.value.trim() + if (value.length !== 0) { + this.onAutocompleteItemSelected({ + kind: 'unknown-user', + username: value, + }) + } + } + } + + private onAuthorClick = (index: number) => { + this.setState({ focusedAuthorIndex: index }) + } + + private onRemoveAuthorClick = (index: number) => { + this.removeAuthor(index, 'forward') + } + + private onAuthorFocus = (index: number) => { + this.setState({ focusedAuthorIndex: index }) + } +} diff --git a/app/src/ui/lib/author-input/author-text.ts b/app/src/ui/lib/author-input/author-text.ts new file mode 100644 index 0000000000..405d19990f --- /dev/null +++ b/app/src/ui/lib/author-input/author-text.ts @@ -0,0 +1,19 @@ +import { Author, isKnownAuthor } from '../../../models/author' + +export function getFullTextForAuthor(author: Author) { + if (isKnownAuthor(author)) { + return author.username === null + ? author.name + : `@${author.username} (${author.name})` + } else { + return `@${author.username}` + } +} + +export function getDisplayTextForAuthor(author: Author) { + if (isKnownAuthor(author)) { + return author.username === null ? author.name : `@${author.username}` + } else { + return `@${author.username}` + } +} diff --git a/app/src/ui/lib/avatar-stack.tsx b/app/src/ui/lib/avatar-stack.tsx new file mode 100644 index 0000000000..f0681292ef --- /dev/null +++ b/app/src/ui/lib/avatar-stack.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import classNames from 'classnames' +import { Avatar } from './avatar' +import { IAvatarUser } from '../../models/avatar' + +/** + * The maximum number of avatars to stack before hiding + * the rest behind the hover action. Note that changing this + * means that the css needs to change as well. + */ +const MaxDisplayedAvatars = 3 + +interface IAvatarStackProps { + readonly users: ReadonlyArray +} + +/** + * A component which renders one or more avatars into a stacked + * view which expands on hover, replicated from github.com's + * avatar stacks. + */ +export class AvatarStack extends React.Component { + public render() { + const elems = [] + const users = this.props.users + + for (let i = 0; i < this.props.users.length; i++) { + if ( + users.length > MaxDisplayedAvatars + 1 && + i === MaxDisplayedAvatars - 1 + ) { + elems.push(
    ) + } + + elems.push() + } + + const className = classNames('AvatarStack', { + 'AvatarStack--small': true, + 'AvatarStack--two': users.length === 2, + 'AvatarStack--three': users.length === 3, + 'AvatarStack--plus': users.length > MaxDisplayedAvatars, + }) + + return ( +
    +
    {elems}
    +
    + ) + } +} diff --git a/app/src/ui/lib/avatar.tsx b/app/src/ui/lib/avatar.tsx new file mode 100644 index 0000000000..f55c94d2d6 --- /dev/null +++ b/app/src/ui/lib/avatar.tsx @@ -0,0 +1,330 @@ +import * as React from 'react' +import { IAvatarUser } from '../../models/avatar' +import { shallowEquals } from '../../lib/equality' +import { generateGravatarUrl } from '../../lib/gravatar' +import { Octicon } from '../octicons' +import { getDotComAPIEndpoint } from '../../lib/api' +import { TooltippedContent } from './tooltipped-content' +import { TooltipDirection } from './tooltip' +import { supportsAvatarsAPI } from '../../lib/endpoint-capabilities' + +/** + * This maps contains avatar URLs that have failed to load and + * the last time they failed to load (in milliseconds since the epoc) + * + * This is used to prevent us from retrying to load avatars where the + * server returned an error (or was unreachable). Since browsers doesn't + * cache the error itself and since we re-mount our image tags when + * scrolling through our virtualized lists we can end up making a lot + * of redundant requests to the server when it's busy or down. So + * when an avatar fails to load we'll remember that and not attempt + * to load it again for a while (see RetryLimit) + */ +const FailingAvatars = new Map() + +/** + * Don't attempt to load an avatar that failed to load more than + * once every 5 minutes + */ +const RetryLimit = 5 * 60 * 1000 + +function pruneExpiredFailingAvatars() { + const expired = new Array() + + for (const [url, lastError] of FailingAvatars.entries()) { + if (Date.now() - lastError > RetryLimit) { + expired.push(url) + } else { + // Map is sorted by insertion order so we can bail out early assuming + // we can trust the clock (which I know we can't but it's good enough) + break + } + } + + expired.forEach(url => FailingAvatars.delete(url)) +} + +interface IAvatarProps { + /** The user whose avatar should be displayed. */ + readonly user?: IAvatarUser + + /** + * The title of the avatar. + * Defaults to the name and email if undefined and is + * skipped completely if title is null + */ + readonly title?: string | JSX.Element | null + + /** + * The what dimensions of avatar the component should + * attempt to request, defaults to 64px. + */ + readonly size?: number +} + +interface IAvatarState { + readonly user?: IAvatarUser + readonly candidates: ReadonlyArray + readonly imageLoaded: boolean +} + +/** + * This is the person octicon from octicons v5 (which we're using at time of writing). + * The octicon has been tweaked to add some padding and so that it scales nicely in + * a square aspect ratio. + */ +const DefaultAvatarSymbol = { + w: 16, + h: 16, + d: 'M13 13.145a.844.844 0 0 1-.832.855H3.834A.846.846 0 0 1 3 13.142v-.856c0-2.257 3.333-3.429 3.333-3.429s.191-.35 0-.857c-.7-.531-.786-1.363-.833-3.429C5.644 2.503 7.056 2 8 2s2.356.502 2.5 2.571C10.453 6.637 10.367 7.47 9.667 8c-.191.506 0 .857 0 .857S13 10.03 13 12.286v.859z', +} + +/** + * 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 + +/** + * Produces an ordered iterable of avatar urls to attempt to load for the + * given user. + */ +function getAvatarUrlCandidates( + user: IAvatarUser | undefined, + size = 64 +): ReadonlyArray { + const candidates = new Array() + + if (user === undefined) { + return candidates + } + + const { email, endpoint, avatarURL } = user + const isDotCom = endpoint === getDotComAPIEndpoint() + + // By leveraging the avatar url from the API (if we've got it) we can + // load the avatar from one of the load balanced domains (avatars). We can't + // do the same for GHES/GHAE however since the URLs returned by the API are + // behind private mode. + if (isDotCom && avatarURL !== undefined) { + // The avatar urls returned by the API doesn't come with a size parameter, + // they default to the biggest size we need on GitHub.com which is usually + // much bigger than what desktop needs so we'll set a size explicitly. + try { + const url = new URL(avatarURL) + url.searchParams.set('s', `${size}`) + + candidates.push(url.toString()) + } catch (e) { + // This should never happen since URL#constructor only throws for invalid + // URLs which we can expect the API to not give us + candidates.push(avatarURL) + } + } else if (endpoint !== null && !isDotCom && !supportsAvatarsAPI(endpoint)) { + // We're dealing with an old GitHub Enterprise instance so we're unable to + // get to the avatar by requesting the avatarURL due to the private mode + // (see https://github.com/desktop/desktop/issues/821). So we have no choice + // but to fall back to gravatar for now. + candidates.push(generateGravatarUrl(email, size)) + return candidates + } + + // Are we dealing with a GitHub.com stealth/anonymous email address in + // either legacy format: + // niik@users.noreply.github.com + // + // or the current format + // 634063+niik@users.noreply.github.com + // + // If so we unfortunately can't rely on the GitHub avatar endpoint to + // deliver a match based solely on that email address but luckily for us + // the avatar service supports looking up a user based either on user id + // of login, user id being the better option as it's not affected by + // account renames. + const stealthEmailMatch = StealthEmailRegexp.exec(email) + + const avatarEndpoint = + endpoint === null || isDotCom + ? 'https://avatars.githubusercontent.com' + : `${endpoint}/enterprise/avatars` + + if (stealthEmailMatch) { + const [, userId, login, hostname] = stealthEmailMatch + + if ( + hostname === 'github.com' || + (endpoint !== null && hostname === new URL(endpoint).hostname) + ) { + if (userId !== undefined) { + const userIdParam = encodeURIComponent(userId) + candidates.push(`${avatarEndpoint}/u/${userIdParam}?s=${size}`) + } else { + const loginParam = encodeURIComponent(login) + candidates.push(`${avatarEndpoint}/${loginParam}?s=${size}`) + } + } + } + + // The /u/e endpoint above falls back to gravatar (proxied) + // so we don't have to add gravatar to the fallback. + const emailParam = encodeURIComponent(email) + candidates.push(`${avatarEndpoint}/u/e?email=${emailParam}&s=${size}`) + + return candidates +} + +/** A component for displaying a user avatar. */ +export class Avatar extends React.Component { + public static getDerivedStateFromProps( + props: IAvatarProps, + state: IAvatarState + ): Partial | null { + const { user, size } = props + if (!shallowEquals(user, state.user)) { + const candidates = getAvatarUrlCandidates(user, size) + return { user, candidates, imageLoaded: false } + } + return null + } + + public constructor(props: IAvatarProps) { + super(props) + + const { user, size } = props + this.state = { + user, + candidates: getAvatarUrlCandidates(user, size), + imageLoaded: false, + } + } + + private getTitle(): string | JSX.Element | undefined { + if (this.props.title === null) { + return undefined + } + + if (this.props.title !== undefined) { + return this.props.title + } + + const user = this.props.user + if (user) { + if (user.name) { + return ( + <> + +
    +
    + {user.name} +
    +
    {user.email}
    +
    + + ) + } else { + return user.email + } + } + + return 'Unknown user' + } + + private onImageError = (e: React.SyntheticEvent) => { + const { candidates } = this.state + if (candidates.length > 0) { + this.setState({ + candidates: candidates.filter(x => x !== e.currentTarget.src), + imageLoaded: false, + }) + } + } + + private onImageLoad = (e: React.SyntheticEvent) => { + this.setState({ imageLoaded: true }) + } + + public render() { + const title = this.getTitle() + const { user } = this.props + const { imageLoaded } = this.state + const alt = user + ? `Avatar for ${user.name || user.email}` + : `Avatar for unknown user` + + const now = Date.now() + const src = this.state.candidates.find(c => { + const lastFailed = FailingAvatars.get(c) + return lastFailed === undefined || now - lastFailed > RetryLimit + }) + + return ( + + {!imageLoaded && ( + + )} + {src && ( + {alt} + )} + + ) + } + + private onImageRef = (img: HTMLImageElement | null) => { + // This is different from the onImageLoad react event handler because we're + // never unsubscribing from this. If we were to use the react event handler + // we'd miss errors that happen after the Avatar component (or img + // component) has unmounted. We use a `key` on the img element to ensure + // we're always using a new img element for each unique url. + img?.addEventListener('error', () => { + // Keep the map sorted on last failure, see pruneExpiredFailingAvatars + FailingAvatars.delete(img.src) + FailingAvatars.set(img.src, Date.now()) + }) + } + + public componentDidMount() { + window.addEventListener('online', this.onInternetConnected) + pruneExpiredFailingAvatars() + } + + public componentWillUnmount() { + window.removeEventListener('online', this.onInternetConnected) + } + + private onInternetConnected = () => { + // Let's assume us being offline was the reason for failing to + // load the avatars + FailingAvatars.clear() + + // If we've been offline and therefore failed to load an avatar + // we'll automatically retry when the user becomes connected again. + if (this.state.candidates.length === 0) { + const { user, size } = this.props + const candidates = getAvatarUrlCandidates(user, size) + + if (candidates.length > 0) { + this.setState({ candidates }) + } + } + } +} diff --git a/app/src/ui/lib/branch-name-warnings.tsx b/app/src/ui/lib/branch-name-warnings.tsx new file mode 100644 index 0000000000..c0352664e1 --- /dev/null +++ b/app/src/ui/lib/branch-name-warnings.tsx @@ -0,0 +1,63 @@ +import * as React from 'react' +import { Branch, BranchType } from '../../models/branch' + +import { Row } from './row' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Ref } from './ref' +import { IStashEntry } from '../../models/stash-entry' +import { enableMoveStash } from '../../lib/feature-flag' + +export function renderBranchHasRemoteWarning(branch: Branch) { + if (branch.upstream != null) { + return ( + + +

    + This branch is tracking {branch.upstream} and renaming this + branch will not change the branch name on the remote. +

    +
    + ) + } else { + return null + } +} + +export function renderBranchNameExistsOnRemoteWarning( + sanitizedName: string, + branches: ReadonlyArray +) { + const alreadyExistsOnRemote = + branches.findIndex( + b => b.nameWithoutRemote === sanitizedName && b.type === BranchType.Remote + ) > -1 + + if (alreadyExistsOnRemote === false) { + return null + } + + return ( + + +

    + A branch named {sanitizedName} already exists on the remote. +

    +
    + ) +} + +export function renderStashWillBeLostWarning(stash: IStashEntry | null) { + if (stash === null || enableMoveStash()) { + return null + } + return ( + + +

    + Your current stashed changes on this branch will no longer be visible in + GitHub Desktop if the branch is renamed. +

    +
    + ) +} diff --git a/app/src/ui/lib/button.tsx b/app/src/ui/lib/button.tsx new file mode 100644 index 0000000000..044dd25962 --- /dev/null +++ b/app/src/ui/lib/button.tsx @@ -0,0 +1,241 @@ +import * as React from 'react' +import classNames from 'classnames' +import { Tooltip, TooltipDirection } from './tooltip' +import { createObservableRef } from './observable-ref' +import { AriaHasPopupType } from './aria-types' + +export interface IButtonProps { + /** + * A callback which is invoked when the button is clicked + * using a pointer device or keyboard. The source event is + * passed along and can be used to prevent the default action + * or stop the event from bubbling. + */ + readonly onClick?: (event: React.MouseEvent) => void + + /** + * A callback which is invoked when the button's context menu + * is activated using a pointer device or keyboard. The source + * event is passed along and can be used to prevent the default + * action or stop the event from bubbling. + */ + readonly onContextMenu?: (event: React.MouseEvent) => void + + /** + * A function that's called when the user moves over the button with + * a pointer device. + */ + readonly onMouseEnter?: (event: React.MouseEvent) => void + + /** Called on key down. */ + readonly onKeyDown?: (event: React.KeyboardEvent) => void + + /** An optional tooltip to render when hovering over the button */ + readonly tooltip?: string + + /** Is the button disabled? */ + readonly disabled?: boolean + + /** Whether the button is a submit. */ + readonly type?: 'submit' | 'reset' | 'button' + + /** CSS class names */ + readonly className?: string + + /** The type of button size, e.g., normal or small. */ + readonly size?: 'normal' | 'small' + + /** + * The `ref` for the underlying + ) + } + + private onClick = (event: React.MouseEvent) => { + if (this.props.onClick) { + this.props.onClick(event) + } + + if (this.props.type === undefined) { + event.preventDefault() + } + } + + private onContextMenu = (event: React.MouseEvent) => { + this.props.onContextMenu?.(event) + + if (this.props.type === undefined) { + event.preventDefault() + } + } +} + +const preventDefault = (e: Event | React.SyntheticEvent) => e.preventDefault() diff --git a/app/src/ui/lib/bytes.ts b/app/src/ui/lib/bytes.ts new file mode 100644 index 0000000000..675a7351df --- /dev/null +++ b/app/src/ui/lib/bytes.ts @@ -0,0 +1,32 @@ +import { round } from './round' + +const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] + +/** + * Formats a number of bytes into a human readable string. + * + * This method will uses the IEC representation for orders + * of magnitude (KiB/MiB rather than MB/KB) in order to match + * the format that Git uses. + * + * Example output: + * + * 23 GiB + * -43 B + * + * @param bytes - The number of bytes to reformat into human + * readable form + * @param decimals - The number of decimals to round the result + * to, defaults to zero + * @param fixed - Whether to always include the desired number + * of decimals even though the number could be + * made more compact by removing trailing zeroes. + */ +export function formatBytes(bytes: number, decimals = 0, fixed = true) { + if (!Number.isFinite(bytes)) { + return `${bytes}` + } + const unitIx = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)) + const value = round(bytes / Math.pow(1024, unitIx), decimals) + return `${fixed ? value.toFixed(decimals) : value} ${units[unitIx]}` +} diff --git a/app/src/ui/lib/call-to-action.tsx b/app/src/ui/lib/call-to-action.tsx new file mode 100644 index 0000000000..feffd69b5e --- /dev/null +++ b/app/src/ui/lib/call-to-action.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' +import { Row } from './row' +import { Button } from './button' + +interface ICallToActionProps { + /** The action title. */ + readonly actionTitle: string + + /** The function to call when the user clicks the action button. */ + readonly onAction: () => void +} + +/** + * A call-to-action component which displays its children as the message + * followed by an action button. + */ +export class CallToAction extends React.Component { + public render() { + return ( + + {this.props.children} + + + ) + } + + private onClick = (event: React.MouseEvent) => { + event.preventDefault() + + this.props.onAction() + } +} diff --git a/app/src/ui/lib/checkbox.tsx b/app/src/ui/lib/checkbox.tsx new file mode 100644 index 0000000000..5626fca202 --- /dev/null +++ b/app/src/ui/lib/checkbox.tsx @@ -0,0 +1,121 @@ +import * as React from 'react' +import { createUniqueId, releaseUniqueId } from './id-pool' + +/** The possible values for a Checkbox component. */ +export enum CheckboxValue { + On, + Off, + Mixed, +} + +interface ICheckboxProps { + /** Is the component disabled. */ + readonly disabled?: boolean + + /** The current value of the component. */ + readonly value: CheckboxValue + + /** The function to call on value change. */ + readonly onChange?: (event: React.FormEvent) => void + + /** The tab index of the input element. */ + readonly tabIndex?: number + + /** The label for the checkbox. */ + readonly label?: string | JSX.Element + + /** An aria description of a checkbox - intended to provide more verbose + * information than a label that a the user might need */ + readonly ariaDescribedBy?: string +} + +interface ICheckboxState { + /** + * An automatically generated id for the input 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 inputId?: string +} + +/** A checkbox component which supports the mixed value. */ +export class Checkbox extends React.Component { + private input: HTMLInputElement | null = null + + private onChange = (event: React.FormEvent) => { + if (this.props.onChange) { + this.props.onChange(event) + } + } + + public componentDidUpdate() { + this.updateInputState() + } + + public componentWillMount() { + const friendlyName = this.props.label || 'unknown' + const inputId = createUniqueId(`Checkbox_${friendlyName}`) + + this.setState({ inputId }) + } + + public componentWillUnmount() { + if (this.state.inputId) { + releaseUniqueId(this.state.inputId) + } + } + + public focus() { + this.input?.focus() + } + + private updateInputState() { + const input = this.input + if (input) { + const value = this.props.value + input.indeterminate = value === CheckboxValue.Mixed + input.checked = value !== CheckboxValue.Off + } + } + + private onInputRef = (input: HTMLInputElement | null) => { + this.input = input + // Necessary since componentDidUpdate doesn't run on initial + // render + this.updateInputState() + } + + private onDoubleClick = (event: React.MouseEvent) => { + // This will prevent double clicks on the checkbox to be bubbled up in the + // DOM hierarchy and trigger undesired actions. For example, a double click + // on the checkbox in the changed file list should not open the file in the + // external editor. + event.preventDefault() + event.stopPropagation() + } + + private renderLabel() { + const label = this.props.label + const inputId = this.state.inputId + + return label ? : null + } + + public render() { + return ( +
    + + {this.renderLabel()} +
    + ) + } +} diff --git a/app/src/ui/lib/commit-attribution.tsx b/app/src/ui/lib/commit-attribution.tsx new file mode 100644 index 0000000000..d0fbc40980 --- /dev/null +++ b/app/src/ui/lib/commit-attribution.tsx @@ -0,0 +1,95 @@ +import { Commit } from '../../models/commit' +import * as React from 'react' +import { CommitIdentity } from '../../models/commit-identity' +import { GitAuthor } from '../../models/git-author' +import { GitHubRepository } from '../../models/github-repository' +import { isWebFlowCommitter } from '../../lib/web-flow-committer' + +interface ICommitAttributionProps { + /** + * The commit or commits from where to extract the author, committer + * and co-authors from. + */ + readonly commits: ReadonlyArray + + /** + * The GitHub hosted repository that the given commit is + * associated with or null if repository is local or + * not associated with a GitHub account. Used to determine + * whether a commit is a special GitHub web flow user. + */ + readonly gitHubRepository: GitHubRepository | null +} + +/** + * A component used for listing the authors involved in + * a commit, formatting the content as close to what + * GitHub.com does as possible. + */ +export class CommitAttribution extends React.Component< + ICommitAttributionProps, + {} +> { + private renderAuthorInline(author: CommitIdentity | GitAuthor) { + return {author.name} + } + + private renderAuthors(authors: ReadonlyArray) { + if (authors.length === 1) { + return ( + {this.renderAuthorInline(authors[0])} + ) + } else if (authors.length === 2) { + const title = authors.map(a => a.name).join(', ') + + return ( + + {this.renderAuthorInline(authors[0])} + {`, `} + {this.renderAuthorInline(authors[1])} + + ) + } else { + const title = authors.map(a => a.name).join(', ') + + return ( + + {authors.length} people + + ) + } + } + + public render() { + const { commits } = this.props + + const allAuthors = new Map() + for (const commit of commits) { + const { author, committer, coAuthors } = commit + + // do we need to attribute the committer separately from the author? + const committerAttribution = + !commit.authoredByCommitter && + !( + this.props.gitHubRepository !== null && + isWebFlowCommitter(commit, this.props.gitHubRepository) + ) + + const authors: Array = committerAttribution + ? [author, committer, ...coAuthors] + : [author, ...coAuthors] + + for (const a of authors) { + if (!allAuthors.has(a.toString())) { + allAuthors.set(a.toString(), a) + } + } + } + + return ( + + {this.renderAuthors(Array.from(allAuthors.values()))} + + ) + } +} diff --git a/app/src/ui/lib/config-lock-file-exists.tsx b/app/src/ui/lib/config-lock-file-exists.tsx new file mode 100644 index 0000000000..e334528b34 --- /dev/null +++ b/app/src/ui/lib/config-lock-file-exists.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { Ref } from './ref' +import { LinkButton } from './link-button' +import { unlink } from 'fs/promises' + +interface IConfigLockFileExistsProps { + /** + * The path to the lock file that's preventing a configuration + * file update. + */ + readonly lockFilePath: string + + /** + * Called when the lock file has been deleted and the configuration + * update can be retried + */ + readonly onLockFileDeleted: () => void + + /** + * Called if the lock file couldn't be deleted + */ + readonly onError: (e: Error) => void +} + +export class ConfigLockFileExists extends React.Component { + private onDeleteLockFile = async () => { + try { + await unlink(this.props.lockFilePath) + } catch (e) { + // We don't care about failure to unlink due to the + // lock file not existing any more + if (e.code !== 'ENOENT') { + this.props.onError(e) + return + } + } + + this.props.onLockFileDeleted() + } + public render() { + return ( +
    +

    + Failed to update Git configuration file. A lock file already exists at{' '} + {this.props.lockFilePath}. +

    +

    + This can happen if another tool is currently modifying the Git + configuration or if a Git process has terminated earlier without + cleaning up the lock file. Do you want to{' '} + + delete the lock file + {' '} + and try again? +

    +
    + ) + } +} diff --git a/app/src/ui/lib/configure-git-user.tsx b/app/src/ui/lib/configure-git-user.tsx new file mode 100644 index 0000000000..b3cb9f410b --- /dev/null +++ b/app/src/ui/lib/configure-git-user.tsx @@ -0,0 +1,440 @@ +import * as React from 'react' +import { Commit } from '../../models/commit' +import { lookupPreferredEmail } from '../../lib/email' +import { + getGlobalConfigValue, + setGlobalConfigValue, +} from '../../lib/git/config' +import { CommitListItem } from '../history/commit-list-item' +import { Account } from '../../models/account' +import { CommitIdentity } from '../../models/commit-identity' +import { Form } from '../lib/form' +import { Button } from '../lib/button' +import { TextBox } from '../lib/text-box' +import { Row } from '../lib/row' +import { + isConfigFileLockError, + parseConfigLockFilePathFromError, +} from '../../lib/git' +import { ConfigLockFileExists } from './config-lock-file-exists' +import { RadioButton } from './radio-button' +import { Select } from './select' +import { GitEmailNotFoundWarning } from './git-email-not-found-warning' +import { getDotComAPIEndpoint } from '../../lib/api' +import { Loading } from './loading' + +interface IConfigureGitUserProps { + /** The logged-in accounts. */ + readonly accounts: ReadonlyArray + + /** Called after the user has chosen to save their config. */ + readonly onSave?: () => void + + /** The label for the button which saves config changes. */ + readonly saveLabel?: string +} + +interface IConfigureGitUserState { + readonly globalUserName: string | null + readonly globalUserEmail: string | null + + readonly manualName: string + readonly manualEmail: string + + readonly gitHubName: string + readonly gitHubEmail: string + + readonly useGitHubAuthorInfo: boolean + + /** + * If unable to save Git configuration values (name, email) + * due to an existing configuration lock file this property + * will contain the (fully qualified) path to said lock file + * such that an error may be presented and the user given a + * choice to delete the lock file. + */ + readonly existingLockFilePath?: string + + readonly loadingGitConfig: boolean +} + +/** + * A component which allows the user to configure their Git user. + * + * Provide `children` elements which will be rendered below the form. + */ +export class ConfigureGitUser extends React.Component< + IConfigureGitUserProps, + IConfigureGitUserState +> { + private readonly globalUsernamePromise = getGlobalConfigValue('user.name') + private readonly globalEmailPromise = getGlobalConfigValue('user.email') + private loadInitialDataPromise: Promise | null = null + + public constructor(props: IConfigureGitUserProps) { + super(props) + + const account = this.account + + this.state = { + globalUserName: null, + globalUserEmail: null, + manualName: '', + manualEmail: '', + useGitHubAuthorInfo: this.account !== null, + gitHubName: account?.name || account?.login || '', + gitHubEmail: + this.account !== null ? lookupPreferredEmail(this.account) : '', + loadingGitConfig: true, + } + } + + public async componentDidMount() { + this.loadInitialDataPromise = this.loadInitialData() + } + + private async loadInitialData() { + // Capture the current accounts prop because we'll be + // doing a bunch of asynchronous stuff and we can't + // rely on this.props.account to tell us what that prop + // was at mount-time. + const accounts = this.props.accounts + + const [globalUserName, globalUserEmail] = await Promise.all([ + this.globalUsernamePromise, + this.globalEmailPromise, + ]) + + this.setState( + prevState => ({ + globalUserName, + globalUserEmail, + manualName: + prevState.manualName.length === 0 + ? globalUserName || '' + : prevState.manualName, + manualEmail: + prevState.manualEmail.length === 0 + ? globalUserEmail || '' + : prevState.manualEmail, + loadingGitConfig: false, + }), + () => { + // Chances are low that we actually have an account at mount-time + // the way things are designed now but in case the app changes around + // us and we do get passed an account at mount time in the future we + // want to make sure that not only was it passed at mount time but also + // that it hasn't been changed since (if it has been then + // componentDidUpdate would be responsible for handling it). + if (accounts === this.props.accounts && accounts.length > 0) { + this.setDefaultValuesFromAccount(accounts[0]) + } + } + ) + } + + public async componentDidUpdate(prevProps: IConfigureGitUserProps) { + if ( + this.loadInitialDataPromise !== null && + this.props.accounts !== prevProps.accounts && + this.props.accounts.length > 0 + ) { + if (this.props.accounts[0] !== prevProps.accounts[0]) { + // Wait for the initial data load to finish before updating the state + // with the new account info. + // The problem is we might get the account info before we retrieved the + // global user name and email in `loadInitialData` and updated the state + // with them, so `componentDidUpdate` would get called and override + // whatever the user had in the global git config with the account info. + await this.loadInitialDataPromise + + const account = this.props.accounts[0] + this.setDefaultValuesFromAccount(account) + } + } + } + + private setDefaultValuesFromAccount(account: Account) { + const preferredEmail = lookupPreferredEmail(account) + this.setState({ + useGitHubAuthorInfo: true, + gitHubName: account.name || account.login, + gitHubEmail: preferredEmail, + }) + + if (this.state.manualName.length === 0) { + this.setState({ + manualName: account.name || account.login, + }) + } + + if (this.state.manualEmail.length === 0) { + this.setState({ manualEmail: preferredEmail }) + } + } + + private get account(): Account | null { + if (this.props.accounts.length === 0) { + return null + } + + return this.props.accounts[0] + } + + private dateWithMinuteOffset(date: Date, minuteOffset: number): Date { + const copy = new Date(date.getTime()) + copy.setTime(copy.getTime() + minuteOffset * 60 * 1000) + return copy + } + + public render() { + const error = + this.state.existingLockFilePath !== undefined ? ( + + ) : null + + return ( +
    + {this.renderAuthorOptions()} + + {error} + + {this.state.useGitHubAuthorInfo + ? this.renderGitHubInfo() + : this.renderGitConfigForm()} + + {this.renderExampleCommit()} +
    + ) + } + + private renderExampleCommit() { + const now = new Date() + + let name = this.state.manualName + let email = this.state.manualEmail + + if (this.state.useGitHubAuthorInfo) { + name = this.state.gitHubName + email = this.state.gitHubEmail + } + + // NB: We're using the name as the commit SHA: + // 1. `Commit` is referentially transparent wrt the SHA. So in order to get + // it to update when we name changes, we need to change the SHA. + // 2. We don't display the SHA so the user won't ever know our secret. + const author = new CommitIdentity( + name, + email, + this.dateWithMinuteOffset(now, -30) + ) + const dummyCommit = new Commit( + name, + name.slice(0, 7), + 'Fix all the things', + '', + author, + author, + [], + [], + [] + ) + const emoji = new Map() + + return ( +
    +
    Example commit
    + + +
    + ) + } + + private renderAuthorOptions() { + const account = this.account + + if (account === null) { + return + } + + const accountTypeSuffix = + account.endpoint === getDotComAPIEndpoint() ? '' : ' Enterprise' + + return ( +
    + + +
    + ) + } + + private renderGitHubInfo() { + if (this.account === null) { + return + } + + return ( +
    + + + + + + + {this.props.children} + + + ) + } + + private renderGitConfigForm() { + return ( +
    + {this.state.loadingGitConfig && ( +
    + Checking for an existing git config… +
    + )} + {!this.state.loadingGitConfig && ( + <> + + + + + )} + + {this.account !== null && ( + + )} + + + + {this.props.children} + + + ) + } + + private onSelectedGitHubEmailChange = ( + event: React.FormEvent + ) => { + const email = event.currentTarget.value + if (email) { + this.setState({ gitHubEmail: email }) + } + } + + private onLockFileDeleted = () => { + this.setState({ existingLockFilePath: undefined }) + } + + private onLockFileDeleteError = (e: Error) => { + log.error('Failed to unlink config lock file', e) + this.setState({ existingLockFilePath: undefined }) + } + + private onUseGitHubInfoSelected = () => { + this.setState({ useGitHubAuthorInfo: true }) + } + + private onUseGitConfigInfoSelected = () => { + this.setState({ useGitHubAuthorInfo: false }) + } + + private onNameChange = (name: string) => { + this.setState({ manualName: name }) + } + + private onEmailChange = (email: string) => { + this.setState({ manualEmail: email }) + } + + private save = async () => { + const { + manualName, + manualEmail, + globalUserName, + globalUserEmail, + useGitHubAuthorInfo, + gitHubName, + gitHubEmail, + } = this.state + + const name = useGitHubAuthorInfo ? gitHubName : manualName + const email = useGitHubAuthorInfo ? gitHubEmail : manualEmail + + try { + if (name.length > 0 && name !== globalUserName) { + await setGlobalConfigValue('user.name', name) + } + + if (email.length > 0 && email !== globalUserEmail) { + await setGlobalConfigValue('user.email', email) + } + } catch (e) { + if (isConfigFileLockError(e)) { + const lockFilePath = parseConfigLockFilePathFromError(e.result) + + if (lockFilePath !== null) { + this.setState({ existingLockFilePath: lockFilePath }) + return + } + } + } + + if (this.props.onSave) { + this.props.onSave() + } + } +} diff --git a/app/src/ui/lib/conflicts/index.ts b/app/src/ui/lib/conflicts/index.ts new file mode 100644 index 0000000000..ed6d618ea2 --- /dev/null +++ b/app/src/ui/lib/conflicts/index.ts @@ -0,0 +1,2 @@ +export * from './render-functions' +export * from './unmerged-file' diff --git a/app/src/ui/lib/conflicts/render-functions.tsx b/app/src/ui/lib/conflicts/render-functions.tsx new file mode 100644 index 0000000000..4ccafad331 --- /dev/null +++ b/app/src/ui/lib/conflicts/render-functions.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' +import { Octicon } from '../../octicons' +import * as OcticonSymbol from '../../octicons/octicons.generated' +import { LinkButton } from '../link-button' + +export function renderUnmergedFilesSummary(conflictedFilesCount: number) { + // localization, it burns :vampire: + const message = + conflictedFilesCount === 1 + ? `1 conflicted file` + : `${conflictedFilesCount} conflicted files` + return

    {message}

    +} + +export function renderAllResolved() { + return ( +
    +
    + +
    +
    All conflicts resolved
    +
    + ) +} + +export function renderShellLink(openThisRepositoryInShell: () => void) { + return ( +
    + + Open in command line, + {' '} + your tool of choice, or close to resolve manually. +
    + ) +} diff --git a/app/src/ui/lib/conflicts/unmerged-file.tsx b/app/src/ui/lib/conflicts/unmerged-file.tsx new file mode 100644 index 0000000000..49c4878011 --- /dev/null +++ b/app/src/ui/lib/conflicts/unmerged-file.tsx @@ -0,0 +1,473 @@ +import * as React from 'react' +import { + isConflictWithMarkers, + isManualConflict, + ConflictedFileStatus, + ConflictsWithMarkers, + ManualConflict, + GitStatusEntry, +} from '../../../models/status' +import { join } from 'path' +import { Repository } from '../../../models/repository' +import { Dispatcher } from '../../dispatcher' +import { showContextualMenu } from '../../../lib/menu-item' +import { Octicon } from '../../octicons' +import * as OcticonSymbol from '../../octicons/octicons.generated' +import { PathText } from '../path-text' +import { ManualConflictResolution } from '../../../models/manual-conflict-resolution' +import { + OpenWithDefaultProgramLabel, + RevealInFileManagerLabel, +} from '../context-menu' +import { openFile } from '../open-file' +import { shell } from 'electron' +import { Button } from '../button' +import { IMenuItem } from '../../../lib/menu-item' +import { LinkButton } from '../link-button' +import { + hasUnresolvedConflicts, + getUnmergedStatusEntryDescription, + getLabelForManualResolutionOption, +} from '../../../lib/status' + +/** + * Renders an unmerged file status and associated buttons for the merge conflicts modal + * (An "unmerged file" can be conflicted _and_ resolved or _just_ conflicted) + */ +export const renderUnmergedFile: React.FunctionComponent<{ + /** repository this file is in (for pathing and git operations) */ + readonly repository: Repository + /** file path relative to repository */ + readonly path: string + /** this file must have a conflicted status (but that doesn't mean its not resolved) */ + readonly status: ConflictedFileStatus + /** manual resolution choice for the file at `path` + * (optional. only applies to manual merge conflicts) + */ + readonly manualResolution?: ManualConflictResolution + /** + * Current branch associated with the conflicted state for this file: + * + * - for a merge, this is the tip of the repository + * - for a rebase, this is the base branch that commits are being applied on top + * - for a cherry pick, this is the source branch that the commits come from + * + * If the rebase or cherry pick is started outside Desktop, the details about + * this branch may not be known - the rendered component will handle this + * fine. + */ + readonly ourBranch?: string + /** + * The other branch associated with the conflicted state for this file: + * + * - for a merge, this is be the branch being merged into the tip of the repository + * - for a rebase, this is the target branch that is having it's history rewritten + * - for a cherrypick, this is the target branch that the commits are being + * applied to. + * + * If the merge is started outside Desktop, the details about this branch may + * not be known - the rendered component will handle this fine. + */ + readonly theirBranch?: string + /** name of the resolved external editor */ + readonly resolvedExternalEditor: string | null + readonly openFileInExternalEditor: (path: string) => void + readonly dispatcher: Dispatcher +}> = props => { + if ( + isConflictWithMarkers(props.status) && + hasUnresolvedConflicts(props.status, props.manualResolution) + ) { + return renderConflictedFileWithConflictMarkers({ + path: props.path, + status: props.status, + resolvedExternalEditor: props.resolvedExternalEditor, + onOpenEditorClick: () => + props.openFileInExternalEditor(join(props.repository.path, props.path)), + repository: props.repository, + dispatcher: props.dispatcher, + ourBranch: props.ourBranch, + theirBranch: props.theirBranch, + }) + } + if ( + isManualConflict(props.status) && + hasUnresolvedConflicts(props.status, props.manualResolution) + ) { + return renderManualConflictedFile({ + path: props.path, + status: props.status, + repository: props.repository, + dispatcher: props.dispatcher, + ourBranch: props.ourBranch, + theirBranch: props.theirBranch, + }) + } + return renderResolvedFile({ + path: props.path, + status: props.status, + repository: props.repository, + dispatcher: props.dispatcher, + manualResolution: props.manualResolution, + branch: getBranchForResolution( + props.manualResolution, + props.ourBranch, + props.theirBranch + ), + }) +} + +/** renders the status of a resolved file (of a manual or markered conflict) and associated buttons for the merge conflicts modal */ +const renderResolvedFile: React.FunctionComponent<{ + readonly repository: Repository + readonly path: string + readonly status: ConflictedFileStatus + readonly manualResolution?: ManualConflictResolution + readonly branch?: string + readonly dispatcher: Dispatcher +}> = props => { + return ( +
  • + +
    + + {renderResolvedFileStatusSummary({ + path: props.path, + status: props.status, + branch: props.branch, + manualResolution: props.manualResolution, + repository: props.repository, + dispatcher: props.dispatcher, + })} +
    +
    + +
    +
  • + ) +} + +/** renders the status of a manually conflicted file and associated buttons for the merge conflicts modal */ +const renderManualConflictedFile: React.FunctionComponent<{ + readonly path: string + readonly status: ManualConflict + readonly repository: Repository + readonly ourBranch?: string + readonly theirBranch?: string + readonly dispatcher: Dispatcher +}> = props => { + const onDropdownClick = makeManualConflictDropdownClickHandler( + props.path, + props.status, + props.repository, + props.dispatcher, + props.ourBranch, + props.theirBranch + ) + const { ourBranch, theirBranch } = props + const { entry } = props.status + + let conflictTypeString = manualConflictString + + if ([entry.us, entry.them].includes(GitStatusEntry.Deleted)) { + let targetBranch = 'target branch' + if (entry.us === GitStatusEntry.Deleted && ourBranch !== undefined) { + targetBranch = ourBranch + } + + if (entry.them === GitStatusEntry.Deleted && theirBranch !== undefined) { + targetBranch = theirBranch + } + conflictTypeString = `File does not exist on ${targetBranch}.` + } + + const content = ( + <> +
    + +
    {conflictTypeString}
    +
    +
    + +
    + + ) + + return renderConflictedFileWrapper(props.path, content) +} + +function renderConflictedFileWrapper( + path: string, + content: JSX.Element +): JSX.Element { + return ( +
  • + + {content} +
  • + ) +} + +const renderConflictedFileWithConflictMarkers: React.FunctionComponent<{ + readonly path: string + readonly status: ConflictsWithMarkers + readonly resolvedExternalEditor: string | null + readonly onOpenEditorClick: () => void + readonly repository: Repository + readonly dispatcher: Dispatcher + readonly ourBranch?: string + readonly theirBranch?: string +}> = props => { + const humanReadableConflicts = calculateConflicts( + props.status.conflictMarkerCount + ) + const message = + humanReadableConflicts === 1 + ? `1 conflict` + : `${humanReadableConflicts} conflicts` + + const disabled = props.resolvedExternalEditor === null + const tooltip = editorButtonTooltip(props.resolvedExternalEditor) + const onDropdownClick = makeMarkerConflictDropdownClickHandler( + props.path, + props.repository, + props.dispatcher, + props.status, + props.ourBranch, + props.theirBranch + ) + + const content = ( + <> +
    + +
    {message}
    +
    +
    + + +
    + + ) + return renderConflictedFileWrapper(props.path, content) +} + +/** makes a click handling function for manual conflict resolution options */ +const makeManualConflictDropdownClickHandler = ( + relativeFilePath: string, + status: ManualConflict, + repository: Repository, + dispatcher: Dispatcher, + ourBranch?: string, + theirBranch?: string +) => { + return () => { + showContextualMenu( + getManualResolutionMenuItems( + relativeFilePath, + repository, + dispatcher, + status, + ourBranch, + theirBranch + ) + ) + } +} + +/** makes a click handling function for undoing a manual conflict resolution */ +const makeUndoManualResolutionClickHandler = ( + relativeFilePath: string, + repository: Repository, + dispatcher: Dispatcher +) => { + return () => + dispatcher.updateManualConflictResolution( + repository, + relativeFilePath, + null + ) +} + +/** makes a click handling function for marker conflict actions */ +const makeMarkerConflictDropdownClickHandler = ( + relativeFilePath: string, + repository: Repository, + dispatcher: Dispatcher, + status: ConflictsWithMarkers, + ourBranch?: string, + theirBranch?: string +) => { + return () => { + const absoluteFilePath = join(repository.path, relativeFilePath) + const items: IMenuItem[] = [ + { + label: OpenWithDefaultProgramLabel, + action: () => openFile(absoluteFilePath, dispatcher), + }, + { + label: RevealInFileManagerLabel, + action: () => shell.showItemInFolder(absoluteFilePath), + }, + { + type: 'separator', + }, + ...getManualResolutionMenuItems( + relativeFilePath, + repository, + dispatcher, + status, + ourBranch, + theirBranch + ), + ] + showContextualMenu(items) + } +} + +function getManualResolutionMenuItems( + relativeFilePath: string, + repository: Repository, + dispatcher: Dispatcher, + status: ConflictedFileStatus, + ourBranch?: string, + theirBranch?: string +): ReadonlyArray { + return [ + { + label: getLabelForManualResolutionOption(status.entry.us, ourBranch), + action: () => + dispatcher.updateManualConflictResolution( + repository, + relativeFilePath, + ManualConflictResolution.ours + ), + }, + + { + label: getLabelForManualResolutionOption(status.entry.them, theirBranch), + action: () => + dispatcher.updateManualConflictResolution( + repository, + relativeFilePath, + ManualConflictResolution.theirs + ), + }, + ] +} + +function resolvedFileStatusString( + status: ConflictedFileStatus, + manualResolution?: ManualConflictResolution, + branch?: string +): string { + if (manualResolution === ManualConflictResolution.ours) { + return getUnmergedStatusEntryDescription(status.entry.us, branch) + } + if (manualResolution === ManualConflictResolution.theirs) { + return getUnmergedStatusEntryDescription(status.entry.them, branch) + } + return 'No conflicts remaining' +} + +const renderResolvedFileStatusSummary: React.FunctionComponent<{ + path: string + status: ConflictedFileStatus + repository: Repository + dispatcher: Dispatcher + manualResolution?: ManualConflictResolution + branch?: string +}> = props => { + if ( + isConflictWithMarkers(props.status) && + props.status.conflictMarkerCount === 0 + ) { + return
    No conflicts remaining
    + } + + const statusString = resolvedFileStatusString( + props.status, + props.manualResolution, + props.branch + ) + + return ( +
    + {statusString} +   + + Undo + +
    + ) +} + +/** returns the name of the branch that corresponds to the chosen manual resolution */ +function getBranchForResolution( + manualResolution: ManualConflictResolution | undefined, + ourBranch?: string, + theirBranch?: string +): string | undefined { + if (manualResolution === ManualConflictResolution.ours) { + return ourBranch + } + if (manualResolution === ManualConflictResolution.theirs) { + return theirBranch + } + return undefined +} + +/** + * Calculates the number of merge conflicts in a file from the number of markers + * divides by three and rounds up since each conflict is indicated by three separate markers + * (`<<<<<`, `>>>>>`, and `=====`) + * + * @param conflictMarkers number of conflict markers in a file + */ +function calculateConflicts(conflictMarkers: number) { + return Math.ceil(conflictMarkers / 3) +} + +function editorButtonString(editorName: string | null): string { + const defaultEditorString = 'editor' + return `Open in ${editorName || defaultEditorString}` +} + +function editorButtonTooltip(editorName: string | null): string | undefined { + if (editorName !== null) { + // no need to render a tooltip if we have a known editor + return + } + + if (__DARWIN__) { + return `No editor configured in Preferences > Advanced` + } else { + return `No editor configured in Options > Advanced` + } +} + +const manualConflictString = 'Manual conflict' diff --git a/app/src/ui/lib/context-menu.ts b/app/src/ui/lib/context-menu.ts new file mode 100644 index 0000000000..6f96ecb139 --- /dev/null +++ b/app/src/ui/lib/context-menu.ts @@ -0,0 +1,37 @@ +const RestrictedFileExtensions = ['.cmd', '.exe', '.bat', '.sh'] +export const CopyFilePathLabel = __DARWIN__ + ? 'Copy File Path' + : 'Copy file path' + +export const CopyRelativeFilePathLabel = __DARWIN__ + ? 'Copy Relative File Path' + : 'Copy relative file path' + +export const CopySelectedPathsLabel = __DARWIN__ ? 'Copy Paths' : 'Copy paths' + +export const CopySelectedRelativePathsLabel = __DARWIN__ + ? 'Copy Relative Paths' + : 'Copy relative paths' + +export const DefaultEditorLabel = __DARWIN__ + ? 'Open in External Editor' + : 'Open in external editor' + +export const RevealInFileManagerLabel = __DARWIN__ + ? 'Reveal in Finder' + : __WIN32__ + ? 'Show in Explorer' + : 'Show in your File Manager' + +export const TrashNameLabel = __WIN32__ ? 'Recycle Bin' : 'Trash' + +export const OpenWithDefaultProgramLabel = __DARWIN__ + ? 'Open with Default Program' + : 'Open with default program' + +export function isSafeFileExtension(extension: string): boolean { + if (__WIN32__) { + return RestrictedFileExtensions.indexOf(extension.toLowerCase()) === -1 + } + return true +} diff --git a/app/src/ui/lib/default-dir.ts b/app/src/ui/lib/default-dir.ts new file mode 100644 index 0000000000..199bc674a3 --- /dev/null +++ b/app/src/ui/lib/default-dir.ts @@ -0,0 +1,16 @@ +import * as Path from 'path' +import { getDocumentsPath } from './app-proxy' + +const localStorageKey = 'last-clone-location' + +/** The path to the default directory. */ +export async function getDefaultDir(): Promise { + return ( + localStorage.getItem(localStorageKey) || + Path.join(await getDocumentsPath(), 'GitHub') + ) +} + +export function setDefaultDir(path: string) { + localStorage.setItem(localStorageKey, path) +} diff --git a/app/src/ui/lib/diff-mode.tsx b/app/src/ui/lib/diff-mode.tsx new file mode 100644 index 0000000000..60de476337 --- /dev/null +++ b/app/src/ui/lib/diff-mode.tsx @@ -0,0 +1,20 @@ +import { getBoolean, setBoolean } from '../../lib/local-storage' + +export const ShowSideBySideDiffDefault = false +const showSideBySideDiffKey = 'show-side-by-side-diff' + +/** + * Gets a value indicating whether not to present diffs in a split view mode + * as opposed to unified (the default). + */ +export function getShowSideBySideDiff(): boolean { + return getBoolean(showSideBySideDiffKey, ShowSideBySideDiffDefault) +} + +/** + * Sets a local storage key indicating whether not to present diffs in a split + * view mode as opposed to unified (the default). + */ +export function setShowSideBySideDiff(showSideBySideDiff: boolean) { + setBoolean(showSideBySideDiffKey, showSideBySideDiff) +} diff --git a/app/src/ui/lib/draggable.tsx b/app/src/ui/lib/draggable.tsx new file mode 100644 index 0000000000..5dbe91712a --- /dev/null +++ b/app/src/ui/lib/draggable.tsx @@ -0,0 +1,201 @@ +import * as React from 'react' +import { dragAndDropManager } from '../../lib/drag-and-drop-manager' +import { mouseScroller } from '../../lib/mouse-scroller' +import { sleep } from '../../lib/promise' +import { DropTargetSelector } from '../../models/drag-drop' + +interface IDraggableProps { + /** + * Callback for when a drag starts - user must hold down (mouse down event) + * and move the mouse (mouse move event) + */ + readonly onDragStart: () => void + + /** + * Callback for when the drag ends - user releases mouse (mouse up event) or + * mouse goes out of screen + * + * @param dropTargetSelector - if the last element the mouse was over + * before the mouse up event matches one of the dropTargetSelectors provided, + * it is that selector enum + */ + readonly onDragEnd?: ( + dropTargetSelector: DropTargetSelector | undefined + ) => void + + /** Callback to render a drag element inside the #dragElement */ + readonly onRenderDragElement: () => void + + /** Callback to remove a drag element inside the #dragElement */ + readonly onRemoveDragElement: () => void + + /** Whether dragging is enabled */ + readonly isEnabled: boolean + + /** An array of css selectors for elements that are valid drop targets. */ + readonly dropTargetSelectors: ReadonlyArray +} + +export class Draggable extends React.Component { + private hasDragStarted: boolean = false + private hasDragEnded: boolean = false + private dragElement: HTMLElement | null = null + private elemBelow: Element | null = null + // Default offset to place the cursor slightly above the top left corner of + // the drag element. Note: if placed at (0,0) or cursor is inside the + // dragElement then elemBelow will always return the dragElement and cannot + // detect drop targets or scroll elements. + private verticalOffset: number = __DARWIN__ ? 32 : 15 + + public componentDidMount() { + this.dragElement = document.getElementById('dragElement') + } + + /** + * A user can drag a commit if they are holding down the left mouse button or + * event.button === 0 + * + * Exceptions: + * - macOS allow emulating a right click by holding down the ctrl and left + * mouse button. + * - user can not drag during a shift click + * + * All other MouseEvent.button values are: + * 2: right button/pen barrel button + * 1: middle button + * X1, X2: mouse back/forward buttons + * 5: pen eraser + * -1: No button changed + * + * Ref: https://www.w3.org/TR/pointerevents/#the-button-property + * + * */ + private canDragCommit(event: React.MouseEvent): boolean { + const isSpecialClick = + event.button !== 0 || + (__DARWIN__ && event.button === 0 && event.ctrlKey) || + event.shiftKey + + return !isSpecialClick && this.props.isEnabled + } + + private initializeDrag(): void { + this.hasDragStarted = false + this.elemBelow = null + } + + /** + * Invokes the drag event. + * + * - clears variables from last drag + * - sets up mouse move and mouse up listeners + */ + private onMouseDown = async (event: React.MouseEvent) => { + if (!this.canDragCommit(event)) { + return + } + this.hasDragEnded = false + document.onmouseup = this.handleDragEndEvent + await sleep(100) + if (this.hasDragEnded) { + return + } + + this.initializeDrag() + document.addEventListener('mousemove', this.onMouseMove) + } + + /** + * During drag event + * + * Note: A drag is not started until a user moves their mouse. This is + * important or the drag will start and drag element will render for a user + * just clicking a draggable element. + */ + private onMouseMove = (moveEvent: MouseEvent) => { + if (this.hasDragEnded) { + this.onDragEnd() + return + } + // start drag + if (!this.hasDragStarted) { + this.props.onRenderDragElement() + this.props.onDragStart() + dragAndDropManager.dragStarted() + this.hasDragStarted = true + window.addEventListener('keyup', this.onKeyUp) + } + + // move drag element where mouse is + if (this.dragElement !== null) { + this.dragElement.style.left = moveEvent.pageX + 0 + 'px' + this.dragElement.style.top = moveEvent.pageY + this.verticalOffset + 'px' + } + + // inspect element mouse is is hovering over + this.elemBelow = document.elementFromPoint( + moveEvent.clientX, + moveEvent.clientY + ) + + if (this.elemBelow === null) { + mouseScroller.clearScrollTimer() + return + } + + mouseScroller.setupMouseScroll(this.elemBelow, moveEvent.clientY) + } + + /** + * End a drag event + */ + private handleDragEndEvent = () => { + this.hasDragEnded = true + if (this.hasDragStarted) { + this.onDragEnd() + } + document.onmouseup = null + window.removeEventListener('keyup', this.onKeyUp) + } + + private onKeyUp = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return + } + this.handleDragEndEvent() + } + + private onDragEnd(): void { + document.removeEventListener('mousemove', this.onMouseMove) + mouseScroller.clearScrollTimer() + this.props.onRemoveDragElement() + if (this.props.onDragEnd !== undefined) { + this.props.onDragEnd(this.getLastElemBelowDropTarget()) + } + dragAndDropManager.dragEnded(this.getLastElemBelowDropTarget()) + } + + /** + * Compares the last element that the mouse was over during a drag with the + * css selectors provided in dropTargetSelectors to determine if the drag + * ended on target or not. + */ + private getLastElemBelowDropTarget = (): DropTargetSelector | undefined => { + if (this.elemBelow === null) { + return + } + + return this.props.dropTargetSelectors.find(dts => { + return this.elemBelow !== null && this.elemBelow.closest(dts) !== null + }) + } + + public render() { + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
    + {this.props.children} +
    + ) + } +} diff --git a/app/src/ui/lib/enterprise-server-entry.tsx b/app/src/ui/lib/enterprise-server-entry.tsx new file mode 100644 index 0000000000..13adb95c10 --- /dev/null +++ b/app/src/ui/lib/enterprise-server-entry.tsx @@ -0,0 +1,83 @@ +import * as React from 'react' +import { Loading } from './loading' +import { Form } from './form' +import { TextBox } from './text-box' +import { Button } from './button' +import { Errors } from './errors' + +interface IEnterpriseServerEntryProps { + /** + * An error which, if present, is presented to the + * user in close proximity to the actions or input fields + * related to the current step. + */ + readonly error: Error | null + + /** + * A value indicating whether or not the sign in store is + * busy processing a request. While this value is true all + * form inputs and actions save for a cancel action will + * be disabled. + */ + readonly loading: boolean + + /** + * A callback which is invoked once the user has entered an + * endpoint url and submitted it either by clicking on the submit + * button or by submitting the form through other means (ie hitting Enter). + */ + readonly onSubmit: (url: string) => void + + /** An array of additional buttons to render after the "Continue" button. */ + readonly additionalButtons?: ReadonlyArray +} + +interface IEnterpriseServerEntryState { + readonly serverAddress: string +} + +/** An entry form for an Enterprise address. */ +export class EnterpriseServerEntry extends React.Component< + IEnterpriseServerEntryProps, + IEnterpriseServerEntryState +> { + public constructor(props: IEnterpriseServerEntryProps) { + super(props) + this.state = { serverAddress: '' } + } + + public render() { + const disableEntry = this.props.loading + const disableSubmission = + this.state.serverAddress.length === 0 || this.props.loading + + return ( +
    + + + {this.props.error ? {this.props.error.message} : null} + +
    + + {this.props.additionalButtons} +
    + + ) + } + + private onServerAddressChanged = (serverAddress: string) => { + this.setState({ serverAddress }) + } + + private onSubmit = () => { + this.props.onSubmit(this.state.serverAddress) + } +} diff --git a/app/src/ui/lib/enterprise-validate-url.ts b/app/src/ui/lib/enterprise-validate-url.ts new file mode 100644 index 0000000000..5807ac0f25 --- /dev/null +++ b/app/src/ui/lib/enterprise-validate-url.ts @@ -0,0 +1,48 @@ +import * as URL from 'url' + +/** The protocols over which we can connect to Enterprise instances. */ +const AllowedProtocols = new Set(['https:', 'http:']) + +/** The name for errors thrown because of an invalid URL. */ +export const InvalidURLErrorName = 'invalid-url' + +/** The name for errors thrown because of an invalid protocol. */ +export const InvalidProtocolErrorName = 'invalid-protocol' + +/** + * Validate the URL for a GitHub Enterprise instance. + * + * Returns the validated URL, or throws if the URL cannot be validated. + */ +export function validateURL(address: string): string { + // ensure user has specified text and not just whitespace + // we will interact with this server so we can be fairly + // relaxed here about what we accept for the server name + const trimmed = address.trim() + if (trimmed.length === 0) { + const error = new Error('Unknown address') + error.name = InvalidURLErrorName + throw error + } + + let url = URL.parse(trimmed) + if (!url.host) { + // E.g., if they user entered 'ghe.io', let's assume they're using https. + address = `https://${trimmed}` + url = URL.parse(address) + } + + if (!url.protocol) { + const error = new Error('Invalid URL') + error.name = InvalidURLErrorName + throw error + } + + if (!AllowedProtocols.has(url.protocol)) { + const error = new Error('Invalid protocol') + error.name = InvalidProtocolErrorName + throw error + } + + return address +} diff --git a/app/src/ui/lib/errors.tsx b/app/src/ui/lib/errors.tsx new file mode 100644 index 0000000000..8baa1517c4 --- /dev/null +++ b/app/src/ui/lib/errors.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' +import classNames from 'classnames' + +interface IErrorsProps { + /** The class name for the internal element. */ + readonly className?: string +} + +/** + * An Errors element with app-standard styles. + * + * Provide `children` elements to render as the content for the error element. + */ +export class Errors extends React.Component { + public render() { + const className = classNames('errors-component', this.props.className) + return ( +
    + {this.props.children} +
    + ) + } +} diff --git a/app/src/ui/lib/fancy-text-box.tsx b/app/src/ui/lib/fancy-text-box.tsx new file mode 100644 index 0000000000..5734f19f0a --- /dev/null +++ b/app/src/ui/lib/fancy-text-box.tsx @@ -0,0 +1,73 @@ +import * as React from 'react' +import { Octicon, OcticonSymbolType } from '../octicons' +import { TextBox, ITextBoxProps } from './text-box' +import classNames from 'classnames' + +interface IFancyTextBoxProps extends ITextBoxProps { + /** Icon to render */ + readonly symbol: OcticonSymbolType + + /** Callback used to get a reference to internal TextBox */ + readonly onRef: (textbox: TextBox) => void +} + +interface IFancyTextBoxState { + readonly isFocused: boolean +} + +export class FancyTextBox extends React.Component< + IFancyTextBoxProps, + IFancyTextBoxState +> { + public constructor(props: IFancyTextBoxProps) { + super(props) + + this.state = { isFocused: false } + } + + public render() { + const componentCSS = classNames( + 'fancy-text-box-component', + this.props.className, + { disabled: this.props.disabled }, + { focused: this.state.isFocused } + ) + const octiconCSS = classNames('fancy-octicon') + + return ( +
    + + +
    + ) + } + + private onFocus = () => { + if (this.props.onFocus !== undefined) { + this.props.onFocus() + } + + this.setState({ isFocused: true }) + } + + private onBlur = (value: string) => { + if (this.props.onBlur !== undefined) { + this.props.onBlur(value) + } + + this.setState({ isFocused: false }) + } +} diff --git a/app/src/ui/lib/features.ts b/app/src/ui/lib/features.ts new file mode 100644 index 0000000000..a7657b02ea --- /dev/null +++ b/app/src/ui/lib/features.ts @@ -0,0 +1,39 @@ +import { getBoolean } from '../../lib/local-storage' + +function getFeatureOverride( + featureName: string, + defaultValue: boolean +): boolean { + return getBoolean(`features/${featureName}`, defaultValue) +} + +function featureFlag( + featureName: string, + defaultValue: boolean, + memoize: boolean +): () => boolean { + const getter = () => getFeatureOverride(featureName, defaultValue) + + if (memoize) { + const value = getter() + return () => value + } else { + return getter + } +} + +/** + * Gets a value indicating whether the renderer should be responsible for + * rendering an application menu. + * + * Can be overridden with the localStorage variable + * + * features/should-render-application-menu + * + * Default: false on macOS, true on other platforms. + */ +export const shouldRenderApplicationMenu = featureFlag( + 'should-render-application-menu', + __DARWIN__ ? false : true, + true +) diff --git a/app/src/ui/lib/filter-list.tsx b/app/src/ui/lib/filter-list.tsx new file mode 100644 index 0000000000..76917eb488 --- /dev/null +++ b/app/src/ui/lib/filter-list.tsx @@ -0,0 +1,674 @@ +import * as React from 'react' +import classnames from 'classnames' + +import { + List, + SelectionSource as ListSelectionSource, + findNextSelectableRow, + ClickSource, + SelectionDirection, +} from '../lib/list' +import { TextBox } from '../lib/text-box' +import { Row } from '../lib/row' + +import { match, IMatch, IMatches } from '../../lib/fuzzy-find' +import { AriaLiveContainer } from '../accessibility/aria-live-container' + +/** An item in the filter list. */ +export interface IFilterListItem { + /** The text which represents the item. This is used for filtering. */ + readonly text: ReadonlyArray + + /** A unique identifier for the item. */ + readonly id: string +} + +/** A group of items in the list. */ +export interface IFilterListGroup { + /** The identifier for this group. */ + readonly identifier: string + + /** The items in the group. */ + readonly items: ReadonlyArray +} + +interface IFlattenedGroup { + readonly kind: 'group' + readonly identifier: string +} + +interface IFlattenedItem { + readonly kind: 'item' + readonly item: T + /** Array of indexes in `item.text` that should be highlighted */ + readonly matches: IMatches +} + +/** + * A row in the list. This is used internally after the user-provided groups are + * flattened. + */ +type IFilterListRow = + | IFlattenedGroup + | IFlattenedItem + +interface IFilterListProps { + /** A class name for the wrapping element. */ + readonly className?: string + + /** The height of the rows. */ + readonly rowHeight: number + + /** The ordered groups to display in the list. */ + readonly groups: ReadonlyArray> + + /** The selected item. */ + readonly selectedItem: T | null + + /** Called to render each visible item. */ + readonly renderItem: (item: T, matches: IMatches) => JSX.Element | null + + /** Called to render header for the group with the given identifier. */ + readonly renderGroupHeader?: (identifier: string) => JSX.Element | null + + /** Called to render content before/above the filter and list. */ + readonly renderPreList?: () => JSX.Element | null + + /** + * This function will be called when a pointer device is pressed and then + * released on a selectable row. Note that this follows the conventions + * of button elements such that pressing Enter or Space on a keyboard + * while focused on a particular row will also trigger this event. Consumers + * can differentiate between the two using the source parameter. + * + * Note that this event handler will not be called for keyboard events + * if `event.preventDefault()` was called in the onRowKeyDown event handler. + * + * Consumers of this event do _not_ have to call event.preventDefault, + * when this event is subscribed to the list will automatically call it. + */ + readonly onItemClick?: (item: T, source: ClickSource) => void + + /** + * This function will be called when the selection changes as a result of a + * user keyboard or mouse action (i.e. not when props change). This function + * will not be invoked when an already selected row is clicked on. + * + * @param selectedItem - The item that was just selected + * @param source - The kind of user action that provoked the change, + * either a pointer device press, or a keyboard event + * (arrow up/down) + */ + readonly onSelectionChanged?: ( + selectedItem: T | null, + source: SelectionSource + ) => void + + /** + * Called when a key down happens in the filter text input. Users have a + * chance to respond or cancel the default behavior by calling + * `preventDefault()`. + */ + readonly onFilterKeyDown?: ( + event: React.KeyboardEvent + ) => void + + /** Called when the Enter key is pressed in field of type search */ + readonly onEnterPressedWithoutFilteredItems?: (text: string) => void + + /** The current filter text to use in the form */ + readonly filterText?: string + + /** Called when the filter text is changed by the user */ + readonly onFilterTextChanged?: (text: string) => void + + /** + * Whether or not the filter list should allow selection + * and filtering. Defaults to false. + */ + readonly disabled?: boolean + + /** Any props which should cause a re-render if they change. */ + readonly invalidationProps: any + + /** Called to render content after the filter. */ + readonly renderPostFilter?: () => JSX.Element | null + + /** Called when there are no items to render. */ + readonly renderNoItems?: () => JSX.Element | null + + /** + * A reference to a TextBox that will be used to control this component. + * + * See https://github.com/desktop/desktop/issues/4317 for refactoring work to + * make this more composable which should make this unnecessary. + */ + readonly filterTextBox?: TextBox + + /** + * Callback to fire when the items in the filter list are updated + */ + readonly onFilterListResultsChanged?: (resultCount: number) => void + + /** Placeholder text for text box. Default is "Filter". */ + readonly placeholderText?: string + + /** If true, we do not render the filter. */ + readonly hideFilterRow?: boolean + + /** + * A handler called whenever a context menu event is received on the + * row container element. + * + * The context menu is invoked when a user right clicks the row or + * uses keyboard shortcut.s + */ + readonly onItemContextMenu?: ( + item: T, + event: React.MouseEvent + ) => void +} + +interface IFilterListState { + readonly rows: ReadonlyArray> + readonly selectedRow: number + readonly filterValue: string + readonly filterValueChanged: boolean +} + +/** + * Interface describing a user initiated selection change event + * originating from changing the filter text. + */ +export interface IFilterSelectionSource { + kind: 'filter' + + /** The filter text at the time the selection event was raised. */ + filterText: string +} + +export type SelectionSource = ListSelectionSource | IFilterSelectionSource + +/** A List which includes the ability to filter based on its contents. */ +export class FilterList extends React.Component< + IFilterListProps, + IFilterListState +> { + public static getDerivedStateFromProps( + props: IFilterListProps, + state: IFilterListState + ) { + return createStateUpdate(props, state) + } + + private list: List | null = null + private filterTextBox: TextBox | null = null + + public constructor(props: IFilterListProps) { + super(props) + + if (props.filterTextBox !== undefined) { + this.filterTextBox = props.filterTextBox + } + + const filterValue = (props.filterText || '').toLowerCase() + + this.state = { + rows: new Array>(), + selectedRow: -1, + filterValue, + filterValueChanged: filterValue.length > 0, + } + } + + public componentDidUpdate( + prevProps: IFilterListProps, + prevState: IFilterListState + ) { + if (this.props.onSelectionChanged) { + const oldSelectedItemId = getItemIdFromRowIndex( + prevState.rows, + prevState.selectedRow + ) + const newSelectedItemId = getItemIdFromRowIndex( + this.state.rows, + this.state.selectedRow + ) + + if (oldSelectedItemId !== newSelectedItemId) { + const propSelectionId = this.props.selectedItem + ? this.props.selectedItem.id + : null + + if (propSelectionId !== newSelectedItemId) { + const newSelectedItem = getItemFromRowIndex( + this.state.rows, + this.state.selectedRow + ) + this.props.onSelectionChanged(newSelectedItem, { + kind: 'filter', + filterText: this.props.filterText || '', + }) + } + } + } + + if (this.props.onFilterListResultsChanged !== undefined) { + const itemCount = this.state.rows.filter( + row => row.kind === 'item' + ).length + + this.props.onFilterListResultsChanged(itemCount) + } + } + + public componentDidMount() { + if (this.filterTextBox !== null) { + this.filterTextBox.selectAll() + } + } + + public renderTextBox() { + return ( + + ) + } + + public renderLiveContainer() { + if (!this.state.filterValueChanged) { + return null + } + + const itemRows = this.state.rows.filter(row => row.kind === 'item') + const resultsPluralized = itemRows.length === 1 ? 'result' : 'results' + const screenReaderMessage = `${itemRows.length} ${resultsPluralized}` + + return ( + + ) + } + + public renderFilterRow() { + if (this.props.hideFilterRow === true) { + return null + } + + return ( + + {this.props.filterTextBox === undefined ? this.renderTextBox() : null} + {this.props.renderPostFilter ? this.props.renderPostFilter() : null} + + ) + } + + public render() { + return ( +
    + {this.renderLiveContainer()} + + {this.props.renderPreList ? this.props.renderPreList() : null} + + {this.renderFilterRow()} + +
    {this.renderContent()}
    +
    + ) + } + + public selectNextItem( + focus: boolean = false, + inDirection: SelectionDirection = 'down' + ) { + if (this.list === null) { + return + } + let next: number | null = null + + if ( + this.state.selectedRow === -1 || + this.state.selectedRow === this.state.rows.length + ) { + next = findNextSelectableRow( + this.state.rows.length, + { + direction: inDirection, + row: -1, + }, + this.canSelectRow + ) + } else { + next = findNextSelectableRow( + this.state.rows.length, + { + direction: inDirection, + row: this.state.selectedRow, + }, + this.canSelectRow + ) + } + + if (next !== null) { + this.setState({ selectedRow: next }, () => { + if (focus && this.list !== null) { + this.list.focus() + } + }) + } + } + + private renderContent() { + if (this.state.rows.length === 0 && this.props.renderNoItems) { + return this.props.renderNoItems() + } else { + return ( + + ) + } + } + + private renderRow = (index: number) => { + const row = this.state.rows[index] + if (row.kind === 'item') { + return this.props.renderItem(row.item, row.matches) + } else if (this.props.renderGroupHeader) { + return this.props.renderGroupHeader(row.identifier) + } else { + return null + } + } + + private onTextBoxRef = (component: TextBox | null) => { + this.filterTextBox = component + } + + private onListRef = (instance: List | null) => { + this.list = instance + } + + private onFilterValueChanged = (text: string) => { + if (this.props.onFilterTextChanged) { + this.props.onFilterTextChanged(text) + } + } + + private onEnterPressed = (text: string) => { + const rows = this.state.rows.length + if ( + rows === 0 && + text.trim().length > 0 && + this.props.onEnterPressedWithoutFilteredItems !== undefined + ) { + this.props.onEnterPressedWithoutFilteredItems(text) + } + } + + private onSelectedRowChanged = (index: number, source: SelectionSource) => { + this.setState({ selectedRow: index }) + + if (this.props.onSelectionChanged) { + const row = this.state.rows[index] + if (row.kind === 'item') { + this.props.onSelectionChanged(row.item, source) + } + } + } + + private canSelectRow = (index: number) => { + if (this.props.disabled) { + return false + } + + const row = this.state.rows[index] + return row.kind === 'item' + } + + private onRowClick = (index: number, source: ClickSource) => { + if (this.props.onItemClick) { + const row = this.state.rows[index] + + if (row.kind === 'item') { + this.props.onItemClick(row.item, source) + } + } + } + + private onRowContextMenu = ( + index: number, + source: React.MouseEvent + ) => { + if (!this.props.onItemContextMenu) { + return + } + + const row = this.state.rows[index] + + if (row.kind !== 'item') { + return + } + + this.props.onItemContextMenu(row.item, source) + } + + private onRowKeyDown = (row: number, event: React.KeyboardEvent) => { + const list = this.list + if (!list) { + return + } + + const rowCount = this.state.rows.length + + const firstSelectableRow = findNextSelectableRow( + rowCount, + { direction: 'down', row: -1 }, + this.canSelectRow + ) + const lastSelectableRow = findNextSelectableRow( + rowCount, + { direction: 'up', row: 0 }, + this.canSelectRow + ) + + let shouldFocus = false + + if (event.key === 'ArrowUp' && row === firstSelectableRow) { + shouldFocus = true + } else if (event.key === 'ArrowDown' && row === lastSelectableRow) { + shouldFocus = true + } + + if (shouldFocus) { + const textBox = this.filterTextBox + + if (textBox) { + event.preventDefault() + textBox.focus() + } + } + } + + private onKeyDown = (event: React.KeyboardEvent) => { + const list = this.list + const key = event.key + + if (!list) { + return + } + + if (this.props.onFilterKeyDown) { + this.props.onFilterKeyDown(event) + } + + if (event.defaultPrevented) { + return + } + + const rowCount = this.state.rows.length + + if (key === 'ArrowDown') { + if (rowCount > 0) { + const selectedRow = findNextSelectableRow( + rowCount, + { direction: 'down', row: -1 }, + this.canSelectRow + ) + if (selectedRow != null) { + this.setState({ selectedRow }, () => { + list.focus() + }) + } + } + + event.preventDefault() + } else if (key === 'ArrowUp') { + if (rowCount > 0) { + const selectedRow = findNextSelectableRow( + rowCount, + { direction: 'up', row: 0 }, + this.canSelectRow + ) + if (selectedRow != null) { + this.setState({ selectedRow }, () => { + list.focus() + }) + } + } + + event.preventDefault() + } else if (key === 'Enter') { + // no repositories currently displayed, bail out + if (rowCount === 0) { + return event.preventDefault() + } + + const filterText = this.props.filterText + + if (filterText !== undefined && !/\S/.test(filterText)) { + return event.preventDefault() + } + + const row = findNextSelectableRow( + rowCount, + { direction: 'down', row: -1 }, + this.canSelectRow + ) + + if (row != null) { + this.onRowClick(row, { kind: 'keyboard', event }) + } + } + } +} + +export function getText( + item: T +): ReadonlyArray { + return item['text'] +} + +function createStateUpdate( + props: IFilterListProps, + state: IFilterListState +) { + const flattenedRows = new Array>() + const filter = (props.filterText || '').toLowerCase() + + for (const group of props.groups) { + const items: ReadonlyArray> = filter + ? match(filter, group.items, getText) + : group.items.map(item => ({ + score: 1, + matches: { title: [], subtitle: [] }, + item, + })) + + if (!items.length) { + continue + } + + if (props.renderGroupHeader) { + flattenedRows.push({ kind: 'group', identifier: group.identifier }) + } + + for (const { item, matches } of items) { + flattenedRows.push({ kind: 'item', item, matches }) + } + } + + let selectedRow = -1 + const selectedItem = props.selectedItem + if (selectedItem) { + selectedRow = flattenedRows.findIndex( + i => i.kind === 'item' && i.item.id === selectedItem.id + ) + } + + if (selectedRow < 0 && filter.length) { + // If the selected item isn't in the list (e.g., filtered out), then + // select the first visible item. + selectedRow = flattenedRows.findIndex(i => i.kind === 'item') + } + + // Stay true if already set, otherwise become true if the filter has content + const filterValueChanged = state.filterValueChanged ? true : filter.length > 0 + + return { + rows: flattenedRows, + selectedRow, + filterValue: filter, + filterValueChanged, + } +} + +function getItemFromRowIndex( + items: ReadonlyArray>, + index: number +): T | null { + if (index >= 0 && index < items.length) { + const row = items[index] + + if (row.kind === 'item') { + return row.item + } + } + + return null +} + +function getItemIdFromRowIndex( + items: ReadonlyArray>, + index: number +): string | null { + const item = getItemFromRowIndex(items, index) + return item ? item.id : null +} diff --git a/app/src/ui/lib/focus-container.tsx b/app/src/ui/lib/focus-container.tsx new file mode 100644 index 0000000000..13362a4938 --- /dev/null +++ b/app/src/ui/lib/focus-container.tsx @@ -0,0 +1,117 @@ +import * as React from 'react' +import classNames from 'classnames' + +interface IFocusContainerProps { + readonly className?: string + readonly role?: React.HTMLAttributes['role'] + readonly onClick?: (event: React.MouseEvent) => void + readonly onKeyDown?: (event: React.KeyboardEvent) => void + + /** Callback used when focus is within container */ + readonly onFocusWithinChanged?: (focusWithin: boolean) => void +} + +interface IFocusContainerState { + readonly focusWithin: boolean +} + +/** + * A helper component which appends a classname to a wrapper + * element if any of its descendant nodes currently has + * keyboard focus. + * + * In other words it's a little workaround that lets use + * use `:focus-within` + * https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-within + * even though it's not supported in our current version + * of chromium (it'll be in 60 or 61 depending on who you trust) + */ +export class FocusContainer extends React.Component< + IFocusContainerProps, + IFocusContainerState +> { + private wrapperRef: HTMLDivElement | null = null + private focusWithinChangedTimeoutId: number | null = null + + public constructor(props: IFocusContainerProps) { + super(props) + this.state = { focusWithin: false } + } + + /** + * Update the focus state of the container, aborting any in-flight animation + * + * @param focusWithin the new focus state of the control + */ + private onFocusWithinChanged(focusWithin: boolean) { + this.setState({ focusWithin }) + + if (this.focusWithinChangedTimeoutId !== null) { + cancelAnimationFrame(this.focusWithinChangedTimeoutId) + this.focusWithinChangedTimeoutId = null + } + + this.focusWithinChangedTimeoutId = requestAnimationFrame(() => { + if (this.props.onFocusWithinChanged) { + this.props.onFocusWithinChanged(focusWithin) + } + + this.focusWithinChangedTimeoutId = null + }) + } + + private onWrapperRef = (elem: HTMLDivElement) => { + if (elem) { + elem.addEventListener('focusin', () => { + this.onFocusWithinChanged(true) + }) + + elem.addEventListener('focusout', () => { + this.onFocusWithinChanged(false) + }) + } + + this.wrapperRef = elem + } + + private onClick = (e: React.MouseEvent) => { + if (this.props.onClick) { + this.props.onClick(e) + } + } + + private onKeyDown = (e: React.KeyboardEvent) => { + if (this.props.onKeyDown) { + this.props.onKeyDown(e) + } + } + + private onMouseDown = (e: React.MouseEvent) => { + // If someone is clicking on the focuscontainer itself we'll + // cancel it, that saves us from having a focusout/in cycle + // and a janky focus ring toggle. + if (e.target === this.wrapperRef) { + e.preventDefault() + } + } + + public render() { + const className = classNames('focus-container', this.props.className, { + 'focus-within': this.state.focusWithin, + }) + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
    + {this.props.children} +
    + ) + } +} diff --git a/app/src/ui/lib/form.tsx b/app/src/ui/lib/form.tsx new file mode 100644 index 0000000000..45b96a12ef --- /dev/null +++ b/app/src/ui/lib/form.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import classNames from 'classnames' + +interface IFormProps { + /** The class name for the form. */ + readonly className?: string + + /** Called when the form is submitted. */ + readonly onSubmit?: () => void +} + +/** A form element with app-standard styles. */ +export class Form extends React.Component { + public render() { + const className = classNames('form-component', this.props.className) + return ( +
    + {this.props.children} +
    + ) + } + + private onSubmit = (event: React.FormEvent) => { + event.preventDefault() + + if (this.props.onSubmit) { + this.props.onSubmit() + } + } +} diff --git a/app/src/ui/lib/git-config-user-form.tsx b/app/src/ui/lib/git-config-user-form.tsx new file mode 100644 index 0000000000..6bf21d24e9 --- /dev/null +++ b/app/src/ui/lib/git-config-user-form.tsx @@ -0,0 +1,228 @@ +import * as React from 'react' +import { TextBox } from './text-box' +import { Row } from './row' +import { Account } from '../../models/account' +import { Select } from './select' +import { GitEmailNotFoundWarning } from './git-email-not-found-warning' + +const OtherEmailSelectValue = 'Other' + +interface IGitConfigUserFormProps { + readonly name: string + readonly email: string + + readonly dotComAccount: Account | null + readonly enterpriseAccount: Account | null + + readonly disabled?: boolean + + readonly onNameChanged: (name: string) => void + readonly onEmailChanged: (email: string) => void + + readonly isLoadingGitConfig: boolean +} + +interface IGitConfigUserFormState { + /** + * True if the selected email in the dropdown is not one of the suggestions. + * It's used to display the "Other" text box that allows the user to + * enter a custom email address. + */ + readonly emailIsOther: boolean +} + +/** + * Form with a name and email address used to present and change the user's info + * via git config. + * + * It'll offer the email addresses from the user's accounts (if any), and an + * option to enter a custom email address. In this case, it will also warn the + * user when this custom email address could result in misattributed commits. + */ +export class GitConfigUserForm extends React.Component< + IGitConfigUserFormProps, + IGitConfigUserFormState +> { + private emailInputRef = React.createRef() + + public constructor(props: IGitConfigUserFormProps) { + super(props) + + this.state = { + emailIsOther: + this.accountEmails.length > 0 && + !this.accountEmails.includes(this.props.email) && + !this.props.isLoadingGitConfig, + } + } + + public componentDidUpdate( + prevProps: IGitConfigUserFormProps, + prevState: IGitConfigUserFormState + ) { + const isEmailInputFocused = + this.emailInputRef.current !== null && + this.emailInputRef.current.isFocused + + // If the email coming from the props has changed, it means a new config + // was loaded into the form. In that case, make sure to only select the + // option "Other" if strictly needed, and select one of the account emails + // otherwise. + // If the "Other email" input field is currently focused, we won't hide it + // from the user, to prevent annoying UI glitches. + if (prevProps.email !== this.props.email && !isEmailInputFocused) { + this.setState({ + emailIsOther: + this.accountEmails.length > 0 && + !this.accountEmails.includes(this.props.email) && + !this.props.isLoadingGitConfig, + }) + } + + // Focus the text input that allows the user to enter a custom + // email address when the user selects "Other". + if ( + this.state.emailIsOther !== prevState.emailIsOther && + this.state.emailIsOther === true && + this.emailInputRef.current !== null + ) { + const emailInput = this.emailInputRef.current + emailInput.focus() + emailInput.selectAll() + } + } + + public render() { + return ( +
    + + + + {this.renderEmailDropdown()} + {this.renderEmailTextBox()} + {this.state.emailIsOther ? ( + + ) : null} +
    + ) + } + + private renderEmailDropdown() { + if (this.accountEmails.length === 0) { + return null + } + + const dotComEmails = + this.props.dotComAccount?.emails.map(e => e.email) ?? [] + const enterpriseEmails = + this.props.enterpriseAccount?.emails.map(e => e.email) ?? [] + + // When the user signed in both accounts, show a suffix to differentiate + // the origin of each email address + const shouldShowAccountType = + this.props.dotComAccount !== null && this.props.enterpriseAccount !== null + + const dotComSuffix = shouldShowAccountType ? '(GitHub.com)' : '' + const enterpriseSuffix = shouldShowAccountType ? '(GitHub Enterprise)' : '' + + return ( + + + + ) + } + + private renderEmailTextBox() { + if (this.state.emailIsOther === false && this.accountEmails.length > 0) { + return null + } + + // Only show the "Email" label above the textbox when the textbox is + // presented independently, without the email dropdown, not when presented + // as a consequence of the option "Other" selected in the dropdown. + const label = this.state.emailIsOther ? undefined : 'Email' + // If there is not a label, provide a screen reader announcement. + const ariaLabel = label ? undefined : 'Email' + + return ( + + + + ) + } + + private get accounts(): ReadonlyArray { + const accounts = [] + + if (this.props.dotComAccount) { + accounts.push(this.props.dotComAccount) + } + + if (this.props.enterpriseAccount) { + accounts.push(this.props.enterpriseAccount) + } + + return accounts + } + + private get accountEmails(): ReadonlyArray { + // Merge email addresses from all accounts into an array + return this.accounts.reduce>( + (previousValue, currentValue) => { + return previousValue.concat(currentValue.emails.map(e => e.email)) + }, + [] + ) + } + + private onEmailSelectChange = (event: React.FormEvent) => { + const value = event.currentTarget.value + this.setState({ + emailIsOther: value === OtherEmailSelectValue, + }) + + // If the dropdown selection is "Other", the email address itself didn't + // change, technically, so no need to emit an update notification. + if (value !== OtherEmailSelectValue) { + this.props.onEmailChanged?.(value) + } + } +} diff --git a/app/src/ui/lib/git-email-not-found-warning.tsx b/app/src/ui/lib/git-email-not-found-warning.tsx new file mode 100644 index 0000000000..e62d6015fa --- /dev/null +++ b/app/src/ui/lib/git-email-not-found-warning.tsx @@ -0,0 +1,101 @@ +import * as React from 'react' +import { Account } from '../../models/account' +import { LinkButton } from './link-button' +import { getDotComAPIEndpoint } from '../../lib/api' +import { isAttributableEmailFor } from '../../lib/email' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { AriaLiveContainer } from '../accessibility/aria-live-container' + +interface IGitEmailNotFoundWarningProps { + /** The account the commit should be attributed to. */ + readonly accounts: ReadonlyArray + + /** The email address used in the commit author info. */ + readonly email: string +} + +/** + * A component which just displays a warning to the user if their git config + * email doesn't match any of the emails in their GitHub (Enterprise) account. + */ +export class GitEmailNotFoundWarning extends React.Component { + private buildMessage(isAttributableEmail: boolean) { + const indicatorIcon = !isAttributableEmail ? ( + ⚠️ + ) : ( + + + + ) + + const learnMore = !isAttributableEmail ? ( + + Learn more. + + ) : null + + return ( + <> + {indicatorIcon} + {this.buildScreenReaderMessage(isAttributableEmail)} + {learnMore} + + ) + } + + private buildScreenReaderMessage(isAttributableEmail: boolean) { + const verb = !isAttributableEmail ? 'does not match' : 'matches' + const info = !isAttributableEmail + ? 'Your commits will be wrongly attributed. ' + : '' + return `This email address ${verb} ${this.getAccountTypeDescription()}. ${info}` + } + + public render() { + const { accounts, email } = this.props + + if (accounts.length === 0 || email.trim().length === 0) { + return null + } + + const isAttributableEmail = accounts.some(account => + isAttributableEmailFor(account, email) + ) + + /** + * Here we put the message in the top div for visual users immediately and + * in the bottom div for screen readers. The screen reader content is + * debounced to avoid frequent updates from typing in the email field. + */ + return ( + <> +
    + {this.buildMessage(isAttributableEmail)} +
    + + + + ) + } + + private getAccountTypeDescription() { + if (this.props.accounts.length === 1) { + const accountType = + this.props.accounts[0].endpoint === getDotComAPIEndpoint() + ? 'GitHub' + : 'GitHub Enterprise' + + return `your ${accountType} account` + } + + return 'either of your GitHub.com nor GitHub Enterprise accounts' + } +} diff --git a/app/src/ui/lib/git-perf.ts b/app/src/ui/lib/git-perf.ts new file mode 100644 index 0000000000..71868b1e6d --- /dev/null +++ b/app/src/ui/lib/git-perf.ts @@ -0,0 +1,61 @@ +let measuringPerf = false +let markID = 0 + +/** Start capturing git performance measurements. */ +export function start() { + measuringPerf = true +} + +/** Stop capturing git performance measurements. */ +export function stop() { + measuringPerf = false +} + +/** Measure an async git operation. */ +export async function measure( + cmd: string, + fn: () => Promise +): Promise { + const id = ++markID + + const startTime = performance && performance.now ? performance.now() : null + + markBegin(id, cmd) + try { + return await fn() + } finally { + if (startTime) { + const rawTime = performance.now() - startTime + if (__DEV__ || rawTime > 1000) { + const timeInSeconds = (rawTime / 1000).toFixed(3) + log.info(`Executing ${cmd} (took ${timeInSeconds}s)`) + } + } + + markEnd(id, cmd) + } +} + +/** Mark the beginning of a git operation. */ +function markBegin(id: number, cmd: string) { + if (!measuringPerf) { + return + } + + const markName = `${id}::${cmd}` + performance.mark(markName) +} + +/** Mark the end of a git operation. */ +function markEnd(id: number, cmd: string) { + if (!measuringPerf) { + return + } + + const markName = `${id}::${cmd}` + const measurementName = cmd + performance.measure(measurementName, markName) + + performance.clearMarks(markName) + performance.clearMeasures(measurementName) +} diff --git a/app/src/ui/lib/highlight-text.tsx b/app/src/ui/lib/highlight-text.tsx new file mode 100644 index 0000000000..4c39980a72 --- /dev/null +++ b/app/src/ui/lib/highlight-text.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' + +interface IHighlightTextProps { + /** The text to render */ + readonly text: string + /** The characters in `text` to highlight */ + readonly highlight: ReadonlyArray +} + +export const HighlightText: React.FunctionComponent = ({ + text, + highlight, +}) => ( + + { + text + .split('') + .map((ch, i): [string, boolean] => [ch, highlight.includes(i)]) + .concat([['', false]]) + .reduce( + (state, [ch, matched], i, arr) => { + if (matched === state.matched && i < arr.length - 1) { + state.str += ch + } else { + const Component = state.matched ? 'mark' : 'span' + state.result.push({state.str}) + state.str = ch + state.matched = matched + } + return state + }, + { + matched: false, + str: '', + result: new Array>(), + } + ).result + } + +) diff --git a/app/src/ui/lib/horizontal-rule.tsx b/app/src/ui/lib/horizontal-rule.tsx new file mode 100644 index 0000000000..efe9378b9b --- /dev/null +++ b/app/src/ui/lib/horizontal-rule.tsx @@ -0,0 +1,10 @@ +import React from 'react' + +/** Horizontal rule/separator with optional title. */ +export const HorizontalRule: React.FunctionComponent<{ + readonly title?: string +}> = props => ( +
    + {props.title} +
    +) diff --git a/app/src/ui/lib/id-pool.ts b/app/src/ui/lib/id-pool.ts new file mode 100644 index 0000000000..4de054a04c --- /dev/null +++ b/app/src/ui/lib/id-pool.ts @@ -0,0 +1,78 @@ +import { uuid } from '../../lib/uuid' + +const activeIds = new Set() +const poolPrefix = '__' + +function sanitizeId(id: string): string { + // We're following the old HTML4 rules for ids for know + // and we're explicitly not testing for a valid first + // character since we have the poolPrefix which will + // guarantee that. + // See http://stackoverflow.com/a/79022/2114 + return id.replace(/[^a-z0-9\-_:.]+/gi, '_') +} + +/** + * Generate a unique id for an html element. The Id pool + * maintains a list of used ids and if an id with a duplicate + * prefix is already in use a counter value will be appended + * to the generated id to maintain uniqueness. + * + * This method should be called from a component's + * componentWillMount method and then released using the + * releaseUniqueId method from the component's componentWillUnmount + * method. The component should store the generated id in its + * state for the lifetime of the component. + * + * @param prefix - A prefix used to distinguish components + * or instances of components from each other. + * At minimum a component should pass its own + * name and ideally it should pass some other + * form of semi-unique string directly related + * to the currently rendering instance of that + * component such as a friendly name (if such + * a value exist. See TextBox for a good example). + */ +export function createUniqueId(prefix: string): string { + if (__DEV__) { + if (activeIds.size > 50) { + console.warn( + `Id pool contains ${activeIds.size} entries, it's possible that id's aren't being released properly.` + ) + } + } + + const safePrefix = sanitizeId(`${poolPrefix}${prefix}`) + + for (let i = 0; i < 100; i++) { + const id = i > 0 ? `${safePrefix}_${i}` : safePrefix + + if (!activeIds.has(id)) { + activeIds.add(id) + return id + } + } + + // If we've failed to create an id 100 times it's likely + // that we've either started using the id pool so widely + // that 100 isn't enough at which point we should really + // look into the root cause because that shouldn't be + // necessary. In either case, let's just return a uuid + // without storing it in the activeIds set because we + // know it'll be unique. + if (__DEV__) { + console.warn( + `Exhausted search for valid id for ${prefix}. Please investigate.` + ) + } + + return uuid() +} + +/** + * Release a previously generated id such that it can be + * reused by another component instance. + */ +export function releaseUniqueId(id: string) { + activeIds.delete(id) +} diff --git a/app/src/ui/lib/identifier-rules.ts b/app/src/ui/lib/identifier-rules.ts new file mode 100644 index 0000000000..237fcfecaf --- /dev/null +++ b/app/src/ui/lib/identifier-rules.ts @@ -0,0 +1,24 @@ +// Git forbids names consisting entirely of these characters +// (what Git calls crud) but as long as there's at least one +// valid character in the name it'll strip the crud and be happy. +// See https://github.com/git/git/blob/e629a7d28a405e/ident.c#L191-L203 +const crudCharactersRe = /^[\x00-\x20.,:;<>"\\']+$/ + +/** + * Returns a value indicating whether the given `name` is a valid + * Git author name that can be used for the `user.name` Git config + * setting without producing an error about disallowed characters + * at commit time. + * + * This logic is intended to be an exact copy of that of Git's own + * logic, see https://github.com/git/git/blob/e629a7d28a/ident.c#L401 + * + * Note that this method considers an empty string to be a valid + * author name. + */ +export function gitAuthorNameIsValid(name: string): boolean { + return !crudCharactersRe.test(name) +} + +export const InvalidGitAuthorNameMessage = + 'Name is invalid, it consists only of disallowed characters.' diff --git a/app/src/ui/lib/input-description/input-caption.tsx b/app/src/ui/lib/input-description/input-caption.tsx new file mode 100644 index 0000000000..7b1fd9d77d --- /dev/null +++ b/app/src/ui/lib/input-description/input-caption.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' +import { + IBaseInputDescriptionProps, + InputDescription, + InputDescriptionType, +} from './input-description' + +/** + * An caption element with app-standard styles for captions to be used with inputs. + * + * Provide `children` elements to render as the content for the error element. + */ +export class Caption extends React.Component { + public render() { + return ( + + {this.props.children} + + ) + } +} diff --git a/app/src/ui/lib/input-description/input-description.tsx b/app/src/ui/lib/input-description/input-description.tsx new file mode 100644 index 0000000000..3d1d45bfbc --- /dev/null +++ b/app/src/ui/lib/input-description/input-description.tsx @@ -0,0 +1,131 @@ +import * as React from 'react' +import { Octicon } from '../../octicons' +import * as OcticonSymbol from '../../octicons/octicons.generated' +import classNames from 'classnames' +import { AriaLiveContainer } from '../../accessibility/aria-live-container' +import { assertNever } from '../../../lib/fatal-error' + +export enum InputDescriptionType { + Caption, + Warning, + Error, +} + +export interface IBaseInputDescriptionProps { + /** The ID for description. This ID needs be linked to the associated input + * using the `aria-describedby` attribute for screen reader users. */ + readonly id: string + + /** + * 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. + * + * This prop allows us to pass in when the user input changes. 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 + + readonly ariaLiveMessage?: string +} + +export interface IInputDescriptionProps extends IBaseInputDescriptionProps { + /** Whether the description is a caption, a warning, or an error. + * + * Captions are styled with a muted color and are used to provide additional information about the input. + * Warnings are styled with a orange color with warning icon and are used to communicate that the input is valid but may have unintended consequences. + * Errors are styled with a red color with error icon and are used to communicate that the input is invalid. + */ + readonly inputDescriptionType: InputDescriptionType +} + +/** + * An Input description element with app-standard styles for captions, warnings, + * and errors of inputs. + * + * Provide `children` elements to render as the content for the error element. + */ +export class InputDescription extends React.Component { + private getClassName() { + const { inputDescriptionType: type } = this.props + + switch (type) { + case InputDescriptionType.Caption: + return classNames('input-description', 'input-description-caption') + case InputDescriptionType.Warning: + return classNames('input-description', 'input-description-warning') + case InputDescriptionType.Error: + return classNames('input-description', 'input-description-error') + default: + return assertNever(type, `Unknown input type ${type}`) + } + } + + private renderIcon() { + const { inputDescriptionType: type } = this.props + + switch (type) { + case InputDescriptionType.Caption: + return null + case InputDescriptionType.Warning: + return + case InputDescriptionType.Error: + return + default: + return assertNever(type, `Unknown input type ${type}`) + } + } + + /** If a input is a warning or an error that is displayed in response to + * tracked user input. We want it announced on user input debounce. */ + private renderAriaLiveContainer() { + if ( + this.props.inputDescriptionType === InputDescriptionType.Caption || + this.props.trackedUserInput === undefined || + this.props.ariaLiveMessage === undefined + ) { + return null + } + + return ( + + ) + } + + /** If the input is an error, and we are not announcing it based on user + * input. We should have a role of alert so that it at least announced once. + * This may be true if the error is displayed in response to a form submission. + * */ + private getRole() { + if ( + this.props.inputDescriptionType === InputDescriptionType.Error && + this.props.trackedUserInput === undefined + ) { + return 'alert' + } + + return undefined + } + + public render() { + return ( + <> +
    + {this.renderIcon()} +
    {this.props.children}
    +
    + {this.renderAriaLiveContainer()} + + ) + } +} diff --git a/app/src/ui/lib/input-description/input-error.tsx b/app/src/ui/lib/input-description/input-error.tsx new file mode 100644 index 0000000000..4d6300d5c5 --- /dev/null +++ b/app/src/ui/lib/input-description/input-error.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' +import { + IBaseInputDescriptionProps, + InputDescription, + InputDescriptionType, +} from './input-description' + +/** + * An Error component with app-standard styles for errors to be used with inputs. + * + * Provide `children` elements to render as the content for the error element. + */ +export class InputError extends React.Component { + public render() { + return ( + + {this.props.children} + + ) + } +} diff --git a/app/src/ui/lib/input-description/input-warning.tsx b/app/src/ui/lib/input-description/input-warning.tsx new file mode 100644 index 0000000000..fce3d9da6f --- /dev/null +++ b/app/src/ui/lib/input-description/input-warning.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' +import { + IBaseInputDescriptionProps, + InputDescription, + InputDescriptionType, +} from './input-description' + +/** + * An Warning component with app-standard styles for warnings to be used with inputs. + * + * Provide `children` elements to render as the content for the error element. + */ +export class InputWarning extends React.Component { + public render() { + return ( + + {this.props.children} + + ) + } +} diff --git a/app/src/ui/lib/install-cli.ts b/app/src/ui/lib/install-cli.ts new file mode 100644 index 0000000000..e08f56c875 --- /dev/null +++ b/app/src/ui/lib/install-cli.ts @@ -0,0 +1,102 @@ +import * as Path from 'path' + +import * as fsAdmin from 'fs-admin' +import { mkdir, readlink, symlink, unlink } from 'fs/promises' + +/** The path for the installed command line tool. */ +export const InstalledCLIPath = '/usr/local/bin/github' + +/** The path to the packaged CLI. */ +const PackagedPath = Path.resolve(__dirname, 'static', 'github.sh') + +/** Install the command line tool on macOS. */ +export async function installCLI(): Promise { + const installedPath = await getResolvedInstallPath() + if (installedPath === PackagedPath) { + return + } + + try { + await symlinkCLI(false) + } catch (e) { + // If we error without running as an admin, try again as an admin. + await symlinkCLI(true) + } +} + +async function getResolvedInstallPath(): Promise { + try { + return await readlink(InstalledCLIPath) + } catch { + return null + } +} + +function removeExistingSymlink(asAdmin: boolean) { + if (!asAdmin) { + return unlink(InstalledCLIPath) + } + + return new Promise((resolve, reject) => { + fsAdmin.unlink(InstalledCLIPath, error => { + if (error !== null) { + reject( + new Error( + `Failed to remove file at ${InstalledCLIPath}. Authorization of GitHub Desktop Helper is required.` + ) + ) + return + } + + resolve() + }) + }) +} + +function createDirectories(asAdmin: boolean) { + const path = Path.dirname(InstalledCLIPath) + + if (!asAdmin) { + return mkdir(path, { recursive: true }) + } + + return new Promise((resolve, reject) => { + fsAdmin.makeTree(path, error => { + if (error !== null) { + reject( + new Error( + `Failed to create intermediate directories to ${InstalledCLIPath}` + ) + ) + return + } + + resolve() + }) + }) +} + +function createNewSymlink(asAdmin: boolean) { + if (!asAdmin) { + return symlink(PackagedPath, InstalledCLIPath) + } + + return new Promise((resolve, reject) => { + fsAdmin.symlink(PackagedPath, InstalledCLIPath, error => { + if (error !== null) { + reject( + new Error(`Failed to symlink ${PackagedPath} to ${InstalledCLIPath}`) + ) + return + } + + resolve() + }) + }) +} + +async function symlinkCLI(asAdmin: boolean): Promise { + await removeExistingSymlink(asAdmin) + await createDirectories(asAdmin) + await createNewSymlink(asAdmin) +} diff --git a/app/src/ui/lib/link-button.tsx b/app/src/ui/lib/link-button.tsx new file mode 100644 index 0000000000..f765f8424a --- /dev/null +++ b/app/src/ui/lib/link-button.tsx @@ -0,0 +1,84 @@ +import * as React from 'react' +import { shell } from '../../lib/app-shell' +import classNames from 'classnames' +import { Tooltip } from './tooltip' +import { createObservableRef } from './observable-ref' + +interface ILinkButtonProps { + /** A URI to open on click. */ + readonly uri?: string + + /** A function to call on click. */ + readonly onClick?: () => void + + /** A function to call when mouse is hovered over */ + readonly onMouseOver?: () => void + + /** A function to call when mouse is moved off */ + readonly onMouseOut?: () => void + + /** CSS classes attached to the component */ + readonly className?: string + + /** The tab index of the anchor element. */ + readonly tabIndex?: number + + /** Disable the link from being clicked */ + readonly disabled?: boolean + + /** title-text or tooltip for the link */ + readonly title?: string + + /** aria-label for the link */ + readonly ariaLabel?: string +} + +/** + * A link component. + * + * Provide `children` elements for the title of the rendered hyperlink. + */ +export class LinkButton extends React.Component { + private readonly anchorRef = createObservableRef() + + public render() { + const href = this.props.uri || '' + const className = classNames('link-button-component', this.props.className) + const { title } = this.props + + return ( + // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events + + {title && {title}} + {this.props.children} + + ) + } + + private onClick = (event: React.MouseEvent) => { + event.preventDefault() + + if (this.props.disabled) { + return + } + + const uri = this.props.uri + if (uri) { + shell.openExternal(uri) + } + + const onClick = this.props.onClick + if (onClick) { + onClick() + } + } +} diff --git a/app/src/ui/lib/list/index.ts b/app/src/ui/lib/list/index.ts new file mode 100644 index 0000000000..861d4a732c --- /dev/null +++ b/app/src/ui/lib/list/index.ts @@ -0,0 +1,2 @@ +export * from './list' +export * from './selection' diff --git a/app/src/ui/lib/list/list-item-insertion-overlay.tsx b/app/src/ui/lib/list/list-item-insertion-overlay.tsx new file mode 100644 index 0000000000..b85878d3e6 --- /dev/null +++ b/app/src/ui/lib/list/list-item-insertion-overlay.tsx @@ -0,0 +1,202 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import classNames from 'classnames' +import { Disposable } from 'event-kit' +import * as React from 'react' +import { dragAndDropManager } from '../../../lib/drag-and-drop-manager' +import { DragData, DragType, DropTargetType } from '../../../models/drag-drop' +import { RowIndexPath } from './list-row-index-path' + +enum InsertionFeedbackType { + None, + Top, + Bottom, +} + +interface IListItemInsertionOverlayProps { + readonly onDropDataInsertion?: ( + insertionIndex: RowIndexPath, + data: DragData + ) => void + + readonly itemIndex: RowIndexPath + readonly dragType: DragType +} + +interface IListItemInsertionOverlayState { + readonly isDragInProgress: boolean + readonly feedbackType: InsertionFeedbackType +} + +/** A component which displays a single commit in a commit list. */ +export class ListItemInsertionOverlay extends React.PureComponent< + IListItemInsertionOverlayProps, + IListItemInsertionOverlayState +> { + private onDragStartedDisposable: Disposable | null = null + private onDragEndedDisposable: Disposable | null = null + + public constructor(props: IListItemInsertionOverlayProps) { + super(props) + + this.state = { + isDragInProgress: this.isDragInProgress(), + feedbackType: InsertionFeedbackType.None, + } + } + + public componentDidMount() { + this.onDragStartedDisposable = dragAndDropManager.onDragStarted( + this.updateDragInProgressState + ) + this.onDragEndedDisposable = dragAndDropManager.onDragEnded(dropTarget => { + this.updateDragInProgressState() + }) + } + + public componentWillUnmount() { + if (this.onDragStartedDisposable !== null) { + this.onDragStartedDisposable.dispose() + this.onDragStartedDisposable = null + } + + if (this.onDragEndedDisposable !== null) { + this.onDragEndedDisposable.dispose() + this.onDragEndedDisposable = null + } + } + + public updateDragInProgressState = () => { + const isDragInProgress = this.isDragInProgress() + this.setState({ + isDragInProgress, + feedbackType: isDragInProgress + ? this.state.feedbackType + : InsertionFeedbackType.None, + }) + } + + public renderInsertionIndicator(feedbackType: InsertionFeedbackType) { + const isTop = feedbackType === InsertionFeedbackType.Top + + const classes = classNames('list-item-insertion-indicator', { + top: isTop, + bottom: !isTop, + }) + + return ( + <> +
    +
    + + ) + } + + public render() { + // Only render top and bottom elements while dragging, otherwise those + // elements will prevent clicks on them (and therefore starting dragging + // from them). + return ( +
    + {this.state.isDragInProgress && this.renderTopElements()} + {this.props.children} + {this.state.isDragInProgress && this.renderBottomElements()} +
    + ) + } + + private renderTopElements() { + return ( + <> +
    + {this.state.feedbackType === InsertionFeedbackType.Top && + this.renderInsertionIndicator(InsertionFeedbackType.Top)} + + ) + } + + private renderBottomElements() { + return ( + <> + {this.state.feedbackType === InsertionFeedbackType.Bottom && + this.renderInsertionIndicator(InsertionFeedbackType.Bottom)} +
    + + ) + } + + private isDragInProgress() { + return dragAndDropManager.isDragOfTypeInProgress(this.props.dragType) + } + + private getOnInsertionAreaMouseEnter(feedbackType: InsertionFeedbackType) { + return (event: React.MouseEvent) => { + this.switchToInsertionFeedbackType(feedbackType) + } + } + + private onInsertionAreaMouseLeave = (event: React.MouseEvent) => { + this.switchToInsertionFeedbackType(InsertionFeedbackType.None) + } + + private switchToInsertionFeedbackType(feedbackType: InsertionFeedbackType) { + if ( + feedbackType !== InsertionFeedbackType.None && + !this.state.isDragInProgress + ) { + return + } + + this.setState({ feedbackType }) + + if (feedbackType === InsertionFeedbackType.None) { + dragAndDropManager.emitLeaveDropTarget() + } else if ( + this.state.isDragInProgress && + dragAndDropManager.dragData !== null + ) { + dragAndDropManager.emitEnterDropTarget({ + type: DropTargetType.ListInsertionPoint, + data: dragAndDropManager.dragData, + index: this.props.itemIndex, + }) + } + } + + private onInsertionAreaMouseUp = () => { + if ( + !this.state.isDragInProgress || + this.state.feedbackType === InsertionFeedbackType.None || + dragAndDropManager.dragData === null + ) { + return + } + + if (this.props.onDropDataInsertion !== undefined) { + let index = this.props.itemIndex + + if (this.state.feedbackType === InsertionFeedbackType.Bottom) { + index = { + ...index, + row: index.row + 1, + } + } + this.props.onDropDataInsertion(index, dragAndDropManager.dragData) + } + + this.switchToInsertionFeedbackType(InsertionFeedbackType.None) + } +} diff --git a/app/src/ui/lib/list/list-row-index-path.ts b/app/src/ui/lib/list/list-row-index-path.ts new file mode 100644 index 0000000000..20b40044ae --- /dev/null +++ b/app/src/ui/lib/list/list-row-index-path.ts @@ -0,0 +1,92 @@ +export type RowIndexPath = { + readonly row: number + readonly section: number +} + +export const InvalidRowIndexPath: RowIndexPath = { section: -1, row: -1 } + +export function rowIndexPathEquals(a: RowIndexPath, b: RowIndexPath): boolean { + return a.section === b.section && a.row === b.row +} + +export function getTotalRowCount(rowCount: ReadonlyArray) { + return rowCount.reduce((sum, count) => sum + count, 0) +} + +export function rowIndexPathToGlobalIndex( + indexPath: RowIndexPath, + rowCount: ReadonlyArray +): number | null { + if (!isValidRow(indexPath, rowCount)) { + return null + } + + let index = 0 + + for (let section = 0; section < indexPath.section; section++) { + index += rowCount[section] + } + + index += indexPath.row + + return index +} + +export function globalIndexToRowIndexPath( + index: number, + rowCount: ReadonlyArray +): RowIndexPath | null { + if (index < 0 || index >= getTotalRowCount(rowCount)) { + return null + } + + let section = 0 + let row = index + + while (row >= rowCount[section]) { + row -= rowCount[section] + section++ + } + + return { section, row } +} + +export function isValidRow( + indexPath: RowIndexPath, + rowCount: ReadonlyArray +) { + return ( + indexPath.section >= 0 && + indexPath.section < rowCount.length && + indexPath.row >= 0 && + indexPath.row < rowCount[indexPath.section] + ) +} + +export function getFirstRowIndexPath( + rowCount: ReadonlyArray +): RowIndexPath | null { + if (rowCount.length > 0) { + for (let section = 0; section < rowCount.length; section++) { + if (rowCount[section] > 0) { + return { section, row: 0 } + } + } + } + + return null +} + +export function getLastRowIndexPath( + rowCount: ReadonlyArray +): RowIndexPath | null { + if (rowCount.length > 0) { + for (let section = rowCount.length - 1; section >= 0; section--) { + if (rowCount[section] > 0) { + return { section, row: rowCount[section] - 1 } + } + } + } + + return null +} diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx new file mode 100644 index 0000000000..27624684cc --- /dev/null +++ b/app/src/ui/lib/list/list-row.tsx @@ -0,0 +1,228 @@ +import * as React from 'react' +import classNames from 'classnames' +import { RowIndexPath } from './list-row-index-path' + +interface IListRowProps { + /** whether or not the section to which this row belongs has a header */ + readonly sectionHasHeader: boolean + + /** the total number of row in this list */ + readonly rowCount: number + + /** the index of the row in the list */ + readonly rowIndex: RowIndexPath + + /** custom styles to provide to the row */ + readonly style?: React.CSSProperties + + /** set a tab index for this row (if selectable) */ + readonly tabIndex?: number + + /** an optional id to provide to the element */ + readonly id?: string + + /** whether the row should be rendered as selected */ + readonly selected?: boolean + + /** callback to fire when the DOM element is created */ + readonly onRowRef?: ( + index: RowIndexPath, + element: HTMLDivElement | null + ) => void + + /** callback to fire when the row receives a mousedown event */ + readonly onRowMouseDown: ( + index: RowIndexPath, + e: React.MouseEvent + ) => void + + /** callback to fire when the row receives a mouseup event */ + readonly onRowMouseUp: (index: RowIndexPath, e: React.MouseEvent) => void + + /** callback to fire when the row is clicked */ + readonly onRowClick: (index: RowIndexPath, e: React.MouseEvent) => void + + /** callback to fire when the row is double clicked */ + readonly onRowDoubleClick: ( + index: RowIndexPath, + e: React.MouseEvent + ) => void + + /** callback to fire when the row receives a keyboard event */ + readonly onRowKeyDown: ( + index: RowIndexPath, + e: React.KeyboardEvent + ) => void + + /** called when the row (or any of its descendants) receives focus due to a + * keyboard event + */ + readonly onRowKeyboardFocus?: ( + index: RowIndexPath, + e: React.KeyboardEvent + ) => void + + /** called when the row (or any of its descendants) receives focus */ + readonly onRowFocus?: ( + index: RowIndexPath, + e: React.FocusEvent + ) => void + + /** called when the row (and all of its descendants) loses focus */ + readonly onRowBlur?: ( + index: RowIndexPath, + e: React.FocusEvent + ) => void + + /** Called back for when the context menu is invoked (user right clicks of + * uses keyboard shortcuts) */ + readonly onContextMenu?: ( + index: RowIndexPath, + e: React.MouseEvent + ) => void + + /** + * Whether or not this list row is going to be selectable either through + * keyboard navigation, pointer clicks, or both. This is used to determine + * whether or not to present a hover state for the list row. + */ + readonly selectable: boolean + + /** a custom css class to apply to the row */ + readonly className?: string + + /** + * aria label value for screen readers + * + * Note: you may need to apply an aria-hidden attribute to any child text + * elements for this to take precedence. + */ + readonly ariaLabel?: string +} + +export class ListRow extends React.Component { + // Since there is no way of knowing when a row has been focused via keyboard + // or mouse interaction, we will use the keyDown and keyUp events to track + // what the user did to get the row in a focused state. + // The heuristic is that we should receive a focus event followed by a keyUp + // event, with no keyDown events (since that keyDown event should've happened + // in the component that previously had focus). + private keyboardFocusDetectionState: 'ready' | 'failed' | 'focused' = 'ready' + + private onRef = (elem: HTMLDivElement | null) => { + this.props.onRowRef?.(this.props.rowIndex, elem) + } + + private onRowMouseDown = (e: React.MouseEvent) => { + this.props.onRowMouseDown(this.props.rowIndex, e) + } + + private onRowMouseUp = (e: React.MouseEvent) => { + this.props.onRowMouseUp(this.props.rowIndex, e) + } + + private onRowClick = (e: React.MouseEvent) => { + this.props.onRowClick(this.props.rowIndex, e) + } + + private onRowDoubleClick = (e: React.MouseEvent) => { + this.props.onRowDoubleClick(this.props.rowIndex, e) + } + + private onRowKeyDown = (e: React.KeyboardEvent) => { + this.props.onRowKeyDown(this.props.rowIndex, e) + this.keyboardFocusDetectionState = 'failed' + } + + private onRowKeyUp = (e: React.KeyboardEvent) => { + if (this.keyboardFocusDetectionState === 'focused') { + this.props.onRowKeyboardFocus?.(this.props.rowIndex, e) + } + this.keyboardFocusDetectionState = 'ready' + } + + private onFocus = (e: React.FocusEvent) => { + this.props.onRowFocus?.(this.props.rowIndex, e) + if (this.keyboardFocusDetectionState === 'ready') { + this.keyboardFocusDetectionState = 'focused' + } + } + + private onBlur = (e: React.FocusEvent) => { + this.keyboardFocusDetectionState = 'ready' + this.props.onRowBlur?.(this.props.rowIndex, e) + } + + private onContextMenu = (e: React.MouseEvent) => { + this.props.onContextMenu?.(this.props.rowIndex, e) + } + + public render() { + const { + selected, + selectable, + className, + style, + rowCount, + id, + tabIndex, + rowIndex, + children, + sectionHasHeader, + } = this.props + const rowClassName = classNames( + 'list-item', + { selected }, + { 'not-selectable': selectable === false }, + className + ) + // react-virtualized gives us an explicit pixel width for rows, but that + // width doesn't take into account whether or not the scroll bar needs + // width too, e.g., on macOS when "Show scroll bars" is set to "Always." + // + // *But* the parent Grid uses `autoContainerWidth` which means its width + // *does* reflect any width needed by the scroll bar. So we should just use + // that width. + const fullWidthStyle = { ...style, width: '100%' } + + let ariaSetSize: number | undefined = rowCount + let ariaPosInSet: number | undefined = rowIndex.row + 1 + if (sectionHasHeader) { + if (rowIndex.row === 0) { + ariaSetSize = undefined + ariaPosInSet = undefined + } else { + ariaSetSize -= 1 + ariaPosInSet -= 1 + } + } + + return ( +
    + {children} +
    + ) + } +} diff --git a/app/src/ui/lib/list/list.tsx b/app/src/ui/lib/list/list.tsx new file mode 100644 index 0000000000..659d173e9e --- /dev/null +++ b/app/src/ui/lib/list/list.tsx @@ -0,0 +1,1457 @@ +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { Grid, AutoSizer } from 'react-virtualized' +import { shallowEquals, arrayEquals } from '../../../lib/equality' +import { FocusContainer } from '../../lib/focus-container' +import { ListRow } from './list-row' +import { + findNextSelectableRow, + SelectionSource, + SelectionDirection, + IMouseClickSource, + IKeyboardSource, + ISelectAllSource, + findLastSelectableRow, +} from './selection' +import { createUniqueId, releaseUniqueId } from '../../lib/id-pool' +import { range } from '../../../lib/range' +import { ListItemInsertionOverlay } from './list-item-insertion-overlay' +import { DragData, DragType } from '../../../models/drag-drop' +import memoizeOne from 'memoize-one' +import { RowIndexPath } from './list-row-index-path' +import { sendNonFatalException } from '../../../lib/helpers/non-fatal-exception' + +/** + * Describe the first argument given to the cellRenderer, + * See + * https://github.com/bvaughn/react-virtualized/issues/386 + * https://github.com/bvaughn/react-virtualized/blob/8.0.11/source/Grid/defaultCellRangeRenderer.js#L38-L44 + */ +export interface IRowRendererParams { + /** Horizontal (column) index of cell */ + readonly columnIndex: number + + /** The Grid is currently being scrolled */ + readonly isScrolling: boolean + + /** Unique key within array of cells */ + readonly key: React.Key + + /** Vertical (row) index of cell */ + readonly rowIndex: number + + /** Style object to be applied to cell */ + readonly style: React.CSSProperties +} + +export type ClickSource = IMouseClickSource | IKeyboardSource + +interface IListProps { + /** + * Mandatory callback for rendering the contents of a particular + * row. The callback is not responsible for the outer wrapper + * of the row, only its contents and may return null although + * that will result in an empty list item. + */ + readonly rowRenderer: (row: number) => JSX.Element | null + + /** + * The total number of rows in the list. This is used for + * scroll virtualization purposes when calculating the theoretical + * height of the list. + */ + readonly rowCount: number + + /** + * The height of each individual row in the list. This height + * is enforced for each row container and attempting to render a row + * which does not fit inside that height is forbidden. + * + * Can either be a number (most efficient) in which case all rows + * are of equal height, or, a function that, given a row index returns + * the height of that particular row. + */ + readonly rowHeight: number | ((info: { index: number }) => number) + + /** + * Function that generates an ID for a given row. This will allow the + * container component of the list to have control over the ID of the + * row and allow it to be used for things like keyboard navigation. + */ + readonly rowId?: (row: number) => string + + /** + * The currently selected rows indexes. Used to attach a special + * selection class on those row's containers as well as being used + * for keyboard selection. + * + * It is expected that the use case for this is setting of the initially + * selected rows or clearing a list selection. + * + * N.B. Since it is used for keyboard selection, changing the ordering of + * elements in this array in a parent component may result in unexpected + * behaviors when a user modifies their selection via key commands. + * See #15536 lessons learned. + */ + readonly selectedRows: ReadonlyArray + + /** + * Used to attach special classes to specific rows + */ + readonly rowCustomClassNameMap?: Map> + + /** + * This function will be called when a pointer device is pressed and then + * released on a selectable row. Note that this follows the conventions + * of button elements such that pressing Enter or Space on a keyboard + * while focused on a particular row will also trigger this event. Consumers + * can differentiate between the two using the source parameter. + * + * Note that this event handler will not be called for keyboard events + * if `event.preventDefault()` was called in the onRowKeyDown event handler. + * + * Consumers of this event do _not_ have to call event.preventDefault, + * when this event is subscribed to the list will automatically call it. + */ + readonly onRowClick?: (row: number, source: ClickSource) => void + + readonly onRowDoubleClick?: (row: number, source: IMouseClickSource) => void + + /** This function will be called when a row obtains focus, no matter how */ + readonly onRowFocus?: ( + row: number, + event: React.FocusEvent + ) => void + + /** This function will be called only when a row obtains focus via keyboard */ + readonly onRowKeyboardFocus?: ( + row: number, + e: React.KeyboardEvent + ) => void + + /** This function will be called when a row loses focus */ + readonly onRowBlur?: ( + row: number, + event: React.FocusEvent + ) => void + + /** + * This prop defines the behaviour of the selection of items within this list. + * - 'single' : (default) single list-item selection. [shift] and [ctrl] have + * no effect. Use in combination with one of: + * onSelectedRowChanged(row: number) + * onSelectionChanged(rows: number[]) + * - 'range' : allows for selecting continuous ranges. [shift] can be used. + * [ctrl] has no effect. Use in combination with one of: + * onSelectedRangeChanged(start: number, end: number) + * onSelectionChanged(rows: number[]) + * - 'multi' : allows range and/or arbitrary selection. [shift] and [ctrl] + * can be used. Use in combination with: + * onSelectionChanged(rows: number[]) + */ + readonly selectionMode?: 'single' | 'range' | 'multi' + + /** + * This function will be called when the selection changes as a result of a + * user keyboard or mouse action (i.e. not when props change). This function + * will not be invoked when an already selected row is clicked on. + * Use this function when the selectionMode is 'single' + * + * @param row - The index of the row that was just selected + * @param source - The kind of user action that provoked the change, either + * a pointer device press or a keyboard event (arrow up/down) + */ + readonly onSelectedRowChanged?: (row: number, source: SelectionSource) => void + + /** + * This function will be called when the selection changes as a result of a + * user keyboard or mouse action (i.e. not when props change). This function + * will not be invoked when an already selected row is clicked on. + * Index parameters are inclusive + * Use this function when the selectionMode is 'range' + * + * @param start - The index of the first selected row + * @param end - The index of the last selected row + * @param source - The kind of user action that provoked the change, either + * a pointer device press or a keyboard event (arrow up/down) + */ + readonly onSelectedRangeChanged?: ( + start: number, + end: number, + source: SelectionSource + ) => void + + /** + * This function will be called when the selection changes as a result of a + * user keyboard or mouse action (i.e. not when props change). This function + * will not be invoked when an already selected row is clicked on. + * Use this function for any selectionMode + * + * @param rows - The indexes of the row(s) that are part of the selection + * @param source - The kind of user action that provoked the change, either + * a pointer device press or a keyboard event (arrow up/down) + */ + readonly onSelectionChanged?: ( + rows: ReadonlyArray, + source: SelectionSource + ) => void + + /** + * A handler called whenever a key down event is received on the + * row container element. Due to the way the container is currently + * implemented the element produced by the rowRendered will never + * see keyboard events without stealing focus away from the container. + * + * Primary use case for this is to allow items to react to the space + * bar in order to toggle selection. This function is responsible + * for calling event.preventDefault() when acting on a key press. + */ + readonly onRowKeyDown?: (row: number, event: React.KeyboardEvent) => void + + /** + * A handler called whenever a mouse down event is received on the + * row container element. Unlike onSelectionChanged, this is raised + * for every mouse down event, whether the row is selected or not. + */ + readonly onRowMouseDown?: (row: number, event: React.MouseEvent) => void + + /** + * A handler called whenever a context menu event is received on the + * row container element. + * + * The context menu is invoked when a user right clicks the row or + * uses keyboard shortcut. + */ + readonly onRowContextMenu?: ( + row: number, + event: React.MouseEvent + ) => void + + /** + * A handler called whenever the user drops items on the list to be inserted. + * + * @param row - The index of the row where the user intends to insert the new + * items. + * @param data - The data dropped by the user. + */ + readonly onDropDataInsertion?: (row: number, data: DragData) => void + + /** + * An optional handler called to determine whether a given row is + * selectable or not. Reasons for why a row might not be selectable + * includes it being a group header or the item being disabled. + */ + readonly canSelectRow?: (row: number) => boolean + readonly onScroll?: (scrollTop: number, clientHeight: number) => void + + /** + * List's underlying implementation acts as a pure component based on the + * above props. So if there are any other properties that also determine + * whether the list should re-render, List must know about them. + */ + readonly invalidationProps?: any + + /** The unique identifier for the outer element of the component (optional, defaults to null) */ + readonly id?: string + + /** The unique identifier of the accessible list component (optional) */ + readonly accessibleListId?: string + + /** The row that should be scrolled to when the list is rendered. */ + readonly scrollToRow?: number + + /** Type of elements that can be inserted in the list via drag & drop. Optional. */ + readonly insertionDragType?: DragType + + /** + * The number of pixels from the top of the list indicating + * where to scroll do on rendering of the list. + */ + readonly setScrollTop?: number + + /** The aria-labelledby attribute for the list component. */ + readonly ariaLabelledBy?: string + + /** The aria-label attribute for the list component. */ + readonly ariaLabel?: string + + /** + * Optional callback for providing an aria label for screen readers for each + * row. + * + * Note: you may need to apply an aria-hidden attribute to any child text + * elements for this to take precedence. + */ + readonly getRowAriaLabel?: (row: number) => string | undefined +} + +interface IListState { + /** The available height for the list as determined by ResizeObserver */ + readonly height?: number + + /** The available width for the list as determined by ResizeObserver */ + readonly width?: number + + readonly rowIdPrefix?: string +} + +/** + * Create an array with row indices between firstRow and lastRow (inclusive). + * + * This is essentially a range function with the explicit behavior of + * inclusive upper and lower bound. + */ +function createSelectionBetween( + firstRow: number, + lastRow: number +): ReadonlyArray { + // range is upper bound exclusive + const end = lastRow > firstRow ? lastRow + 1 : lastRow - 1 + return range(firstRow, end) +} + +export class List extends React.Component { + private fakeScroll: HTMLDivElement | null = null + private focusRow = -1 + + private readonly rowRefs = new Map() + + /** + * The style prop for our child Grid. We keep this here in order + * to not create a new object on each render and thus forcing + * the Grid to re-render even though nothing has changed. + */ + private gridStyle: React.CSSProperties = { overflowX: 'hidden' } + + /** + * On Win32 we use a fake scroll bar. This variable keeps track of + * which of the actual scroll container and the fake scroll container + * received the scroll event first to avoid bouncing back and forth + * causing jerky scroll bars and more importantly making the mouse + * wheel scroll speed appear different when scrolling over the + * fake scroll bar and the actual one. + */ + private lastScroll: 'grid' | 'fake' | null = null + + private list: HTMLDivElement | null = null + private grid: Grid | null = null + private readonly resizeObserver: ResizeObserver | null = null + private updateSizeTimeoutId: NodeJS.Immediate | null = null + + /** + * Get the props for the inner scroll container (called containerProps on the + * Grid component). This is memoized to avoid causing the Grid component to + * rerender every time the list component rerenders (the Grid component is a + * pure component so a complex object like containerProps being instantiated + * on each render would cause it to rerender constantly). + */ + private getContainerProps = memoizeOne( + ( + activeDescendant: string | undefined + ): React.HTMLProps => ({ + onKeyDown: this.onKeyDown, + 'aria-activedescendant': activeDescendant, + 'aria-multiselectable': + this.props.selectionMode === 'multi' || + this.props.selectionMode === 'range' + ? 'true' + : undefined, + }) + ) + + public constructor(props: IListProps) { + super(props) + + this.state = {} + + const ResizeObserverClass: typeof ResizeObserver = (window as any) + .ResizeObserver + + if (ResizeObserver || false) { + this.resizeObserver = new ResizeObserverClass(entries => { + for (const { target, contentRect } of entries) { + if (target === this.list && this.list !== null) { + // We might end up causing a recursive update by updating the state + // when we're reacting to a resize so we'll defer it until after + // react is done with this frame. + if (this.updateSizeTimeoutId !== null) { + clearImmediate(this.updateSizeTimeoutId) + } + + this.updateSizeTimeoutId = setImmediate( + this.onResized, + this.list, + contentRect + ) + } + } + }) + } + } + + private getRowId(row: number): string | undefined { + if (this.props.rowId) { + return this.props.rowId(row) + } + + return this.state.rowIdPrefix === undefined + ? undefined + : `${this.state.rowIdPrefix}-${row}` + } + + private onResized = (target: HTMLElement, contentRect: ClientRect) => { + this.updateSizeTimeoutId = null + + const [width, height] = [target.offsetWidth, target.offsetHeight] + + if (this.state.width !== width || this.state.height !== height) { + this.setState({ width, height }) + } + } + + private onSelectAll = (event: Event | React.SyntheticEvent) => { + const selectionMode = this.props.selectionMode + + if (selectionMode !== 'range' && selectionMode !== 'multi') { + return + } + + event.preventDefault() + + if (this.props.rowCount <= 0) { + return + } + + const source: ISelectAllSource = { kind: 'select-all' } + const firstRow = 0 + const lastRow = this.props.rowCount - 1 + + if (this.props.onSelectionChanged) { + const newSelection = createSelectionBetween(firstRow, lastRow) + this.props.onSelectionChanged(newSelection, source) + } + + if (selectionMode === 'range' && this.props.onSelectedRangeChanged) { + this.props.onSelectedRangeChanged(firstRow, lastRow, source) + } + } + + private onRef = (element: HTMLDivElement | null) => { + if (element === null && this.list !== null) { + this.list.removeEventListener('select-all', this.onSelectAll) + } + + this.list = element + + if (element !== null) { + // This is a custom event that desktop emits through + // when the user selects the Edit > Select all menu item. We + // hijack it and select all list items rather than let it bubble + // to electron's default behavior which is to select all selectable + // text in the renderer. + element.addEventListener('select-all', this.onSelectAll) + } + + if (this.resizeObserver) { + this.resizeObserver.disconnect() + + if (element !== null) { + this.resizeObserver.observe(element) + } else { + this.setState({ width: undefined, height: undefined }) + } + } + } + + private onKeyDown = (event: React.KeyboardEvent) => { + if (this.props.onRowKeyDown) { + for (const row of this.props.selectedRows) { + this.props.onRowKeyDown(row, event) + } + } + + // The consumer is given a change to prevent the default behavior for + // keyboard navigation so that they can customize its behavior as needed. + if (event.defaultPrevented) { + return + } + + const source: SelectionSource = { kind: 'keyboard', event } + + // Home is Cmd+ArrowUp on macOS, end is Cmd+ArrowDown, see + // https://github.com/desktop/desktop/pull/8644#issuecomment-645965884 + const isHomeKey = __DARWIN__ + ? event.metaKey && event.key === 'ArrowUp' + : event.key === 'Home' + const isEndKey = __DARWIN__ + ? event.metaKey && event.key === 'ArrowDown' + : event.key === 'End' + + const isRangeSelection = + event.shiftKey && + this.props.selectionMode !== undefined && + this.props.selectionMode !== 'single' + + if (isHomeKey || isEndKey) { + const direction = isHomeKey ? 'up' : 'down' + if (isRangeSelection) { + this.addSelectionToLastSelectableRow(direction, source) + } else { + this.moveSelectionToLastSelectableRow(direction, source) + } + event.preventDefault() + } else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + const direction = event.key === 'ArrowUp' ? 'up' : 'down' + if (isRangeSelection) { + this.addSelection(direction, source) + } else { + this.moveSelection(direction, source) + } + event.preventDefault() + } else if (!__DARWIN__ && event.key === 'a' && event.ctrlKey) { + // On Windows Chromium will steal the Ctrl+A shortcut before + // Electron gets its hands on it meaning that the Select all + // menu item can't be invoked by means of keyboard shortcuts + // on Windows. Clicking on the menu item still emits the + // 'select-all' custom DOM event. + this.onSelectAll(event) + } else if (event.key === 'PageUp' || event.key === 'PageDown') { + const direction = event.key === 'PageUp' ? 'up' : 'down' + if (isRangeSelection) { + this.addSelectionByPage(direction, source) + } else { + this.moveSelectionByPage(direction, source) + } + event.preventDefault() + } + } + + private moveSelectionByPage( + direction: SelectionDirection, + source: SelectionSource + ) { + const newSelection = this.getNextPageRowIndex(direction) + this.moveSelectionTo(newSelection, source) + } + + private addSelectionByPage( + direction: SelectionDirection, + source: SelectionSource + ) { + const { selectedRows } = this.props + const newSelection = this.getNextPageRowIndex(direction) + const firstSelection = selectedRows[0] ?? 0 + const range = createSelectionBetween(firstSelection, newSelection) + + if (this.props.onSelectionChanged) { + this.props.onSelectionChanged(range, source) + } + + if (this.props.onSelectedRangeChanged) { + this.props.onSelectedRangeChanged( + range[0], + range[range.length - 1], + source + ) + } + + this.scrollRowToVisible(newSelection) + } + + private getNextPageRowIndex(direction: SelectionDirection) { + const { selectedRows } = this.props + const lastSelection = selectedRows.at(-1) ?? 0 + + return this.findNextPageSelectableRow(lastSelection, direction) + } + + private getRowHeight(index: number) { + const { rowHeight } = this.props + return typeof rowHeight === 'number' ? rowHeight : rowHeight({ index }) + } + + private findNextPageSelectableRow( + fromRow: number, + direction: SelectionDirection + ) { + const { height: listHeight } = this.state + const { rowCount } = this.props + + if (listHeight === undefined) { + return fromRow + } + + let offset = 0 + let newSelection = fromRow + const delta = direction === 'up' ? -1 : 1 + + // Starting from the last selected row, move up or down depending + // on the direction, keeping a sum of the height of all the rows + // we've seen until the accumulated height is about to exceed that + // of the list height. Once we've found the index of the item that + // just about exceeds the height we'll pick that one as the next + // selection. + for (let i = fromRow; i < rowCount && i >= 0; i += delta) { + const h = this.getRowHeight(i) + + if (offset + h > listHeight) { + break + } + offset += h + + if (this.canSelectRow(i)) { + newSelection = i + } + } + + return newSelection + } + + private onRowKeyDown = ( + indexPath: RowIndexPath, + event: React.KeyboardEvent + ) => { + if (this.props.onRowKeyDown) { + this.props.onRowKeyDown(indexPath.row, event) + } + + const hasModifier = + event.altKey || event.ctrlKey || event.metaKey || event.shiftKey + + // We give consumers the power to prevent the onRowClick event by subscribing + // to the onRowKeyDown event and calling event.preventDefault. This lets + // consumers add their own semantics for keyboard presses. + if ( + !event.defaultPrevented && + !hasModifier && + (event.key === 'Enter' || event.key === ' ') + ) { + this.toggleSelection(event) + event.preventDefault() + } + } + + private onFocusContainerKeyDown = (event: React.KeyboardEvent) => { + const hasModifier = + event.altKey || event.ctrlKey || event.metaKey || event.shiftKey + + if ( + !event.defaultPrevented && + !hasModifier && + (event.key === 'Enter' || event.key === ' ') + ) { + this.toggleSelection(event) + event.preventDefault() + } + } + + private onFocusWithinChanged = (focusWithin: boolean) => { + // So the grid lost focus (we manually focus the grid if the focused list + // item is unmounted) so we mustn't attempt to refocus the previously + // focused list item if it scrolls back into view. + if (!focusWithin) { + this.focusRow = -1 + } + } + + private toggleSelection = (event: React.KeyboardEvent) => { + this.props.selectedRows.forEach(row => { + if (!this.props.onRowClick) { + return + } + + const { rowCount } = this.props + + if (row < 0 || row >= rowCount) { + log.debug( + `[List.toggleSelection] unable to onRowClick for row ${row} as it is outside the bounds of the array [0, ${rowCount}]` + ) + return + } + + this.props.onRowClick(row, { kind: 'keyboard', event }) + }) + } + + private onRowFocus = ( + indexPath: RowIndexPath, + e: React.FocusEvent + ) => { + this.focusRow = indexPath.row + this.props.onRowFocus?.(indexPath.row, e) + } + + private onRowKeyboardFocus = ( + indexPath: RowIndexPath, + e: React.KeyboardEvent + ) => { + this.focusRow = indexPath.row + this.props.onRowKeyboardFocus?.(indexPath.row, e) + } + + private onRowBlur = ( + indexPath: RowIndexPath, + e: React.FocusEvent + ) => { + if (this.focusRow === indexPath.row) { + this.focusRow = -1 + } + this.props.onRowBlur?.(indexPath.row, e) + } + + private onRowContextMenu = ( + indexPath: RowIndexPath, + e: React.MouseEvent + ) => { + this.props.onRowContextMenu?.(indexPath.row, e) + } + + /** Convenience method for invoking canSelectRow callback when it exists */ + private canSelectRow = (rowIndex: number) => { + return this.props.canSelectRow ? this.props.canSelectRow(rowIndex) : true + } + + private addSelection(direction: SelectionDirection, source: SelectionSource) { + if (this.props.selectedRows.length === 0) { + return this.moveSelection(direction, source) + } + + const lastSelection = + this.props.selectedRows[this.props.selectedRows.length - 1] + + const selectionOrigin = this.props.selectedRows[0] + + const newRow = findNextSelectableRow( + this.props.rowCount, + { direction, row: lastSelection, wrap: false }, + this.canSelectRow + ) + + if (newRow != null) { + if (this.props.onSelectionChanged) { + const newSelection = createSelectionBetween(selectionOrigin, newRow) + this.props.onSelectionChanged(newSelection, source) + } + + if ( + this.props.selectionMode === 'range' && + this.props.onSelectedRangeChanged + ) { + this.props.onSelectedRangeChanged(selectionOrigin, newRow, source) + } + + this.scrollRowToVisible(newRow) + } + } + + private moveSelection( + direction: SelectionDirection, + source: SelectionSource + ) { + const lastSelection = + this.props.selectedRows.length > 0 + ? this.props.selectedRows[this.props.selectedRows.length - 1] + : -1 + + const newRow = findNextSelectableRow( + this.props.rowCount, + { direction, row: lastSelection }, + this.canSelectRow + ) + + if (newRow != null) { + this.moveSelectionTo(newRow, source) + } + } + + private moveSelectionToLastSelectableRow( + direction: SelectionDirection, + source: SelectionSource + ) { + const { canSelectRow, props } = this + const { rowCount } = props + const row = findLastSelectableRow(direction, rowCount, canSelectRow) + + if (row !== null) { + this.moveSelectionTo(row, source) + } + } + + private addSelectionToLastSelectableRow( + direction: SelectionDirection, + source: SelectionSource + ) { + const { canSelectRow, props } = this + const { rowCount, selectedRows } = props + const row = findLastSelectableRow(direction, rowCount, canSelectRow) + + if (row === null) { + return + } + + const firstSelection = selectedRows[0] ?? 0 + const range = createSelectionBetween(firstSelection, row) + + this.props.onSelectionChanged?.(range, source) + + const from = range.at(0) ?? 0 + const to = range.at(-1) ?? 0 + + this.props.onSelectedRangeChanged?.(from, to, source) + + this.scrollRowToVisible(row) + } + + private moveSelectionTo(row: number, source: SelectionSource) { + if (this.props.onSelectionChanged) { + this.props.onSelectionChanged([row], source) + } + + if (this.props.onSelectedRowChanged) { + const rowCount = this.props.rowCount + + if (row < 0 || row >= rowCount) { + log.debug( + `[List.moveSelection] unable to onSelectedRowChanged for row '${row}' as it is outside the bounds of the array [0, ${rowCount}]` + ) + return + } + + this.props.onSelectedRowChanged(row, source) + } + + this.scrollRowToVisible(row) + } + + private scrollRowToVisible(row: number, moveFocus = true) { + if (this.grid !== null) { + this.grid.scrollToCell({ rowIndex: row, columnIndex: 0 }) + + if (moveFocus) { + this.focusRow = row + this.rowRefs.get(row)?.focus({ preventScroll: true }) + } + } + } + + public componentDidMount() { + const { props, grid } = this + const { selectedRows, scrollToRow, setScrollTop } = props + + // Prefer scrollTop position over scrollToRow + if (grid !== null && setScrollTop === undefined) { + if (scrollToRow !== undefined) { + grid.scrollToCell({ rowIndex: scrollToRow, columnIndex: 0 }) + } else if (selectedRows.length > 0) { + // If we have a selected row when we're about to mount + // we'll scroll to it immediately. + grid.scrollToCell({ rowIndex: selectedRows[0], columnIndex: 0 }) + } + } + } + + public componentDidUpdate(prevProps: IListProps, prevState: IListState) { + const { scrollToRow, setScrollTop } = this.props + if (scrollToRow !== undefined && prevProps.scrollToRow !== scrollToRow) { + // Prefer scrollTop position over scrollToRow + if (setScrollTop === undefined) { + this.scrollRowToVisible(scrollToRow, false) + } + } + + if (this.grid) { + // A non-exhaustive set of checks to see if our current update has already + // triggered a re-render of the Grid. In order to do this perfectly we'd + // have to do a shallow compare on all the props we pass to Grid but + // this should cover the majority of cases. + const gridHasUpdatedAlready = + this.props.rowCount !== prevProps.rowCount || + this.state.width !== prevState.width || + this.state.height !== prevState.height + + if (!gridHasUpdatedAlready) { + const selectedRowChanged = !arrayEquals( + prevProps.selectedRows, + this.props.selectedRows + ) + + const invalidationPropsChanged = !shallowEquals( + prevProps.invalidationProps, + this.props.invalidationProps + ) + + // Now we need to figure out whether anything changed in such a way that + // the Grid has to update regardless of its props. Previously we passed + // our selectedRow and invalidationProps down to Grid and figured that + // it, being a pure component, would do the right thing but that's not + // quite the case since invalidationProps is a complex object. + if (selectedRowChanged || invalidationPropsChanged) { + this.grid.forceUpdate() + } + } + } + } + + public componentWillMount() { + this.setState({ rowIdPrefix: createUniqueId('ListRow') }) + } + + public componentWillUnmount() { + if (this.updateSizeTimeoutId !== null) { + clearImmediate(this.updateSizeTimeoutId) + this.updateSizeTimeoutId = null + } + + if (this.resizeObserver) { + this.resizeObserver.disconnect() + } + + if (this.state.rowIdPrefix) { + releaseUniqueId(this.state.rowIdPrefix) + } + } + + private onRowRef = ( + indexPath: RowIndexPath, + element: HTMLDivElement | null + ) => { + if (element === null) { + this.rowRefs.delete(indexPath.row) + } else { + this.rowRefs.set(indexPath.row, element) + } + + if (indexPath.row === this.focusRow) { + // The currently focused row is going being unmounted so we'll move focus + // programmatically to the grid so that keyboard navigation still works + if (element === null) { + const grid = ReactDOM.findDOMNode(this.grid) + if (grid instanceof HTMLElement) { + grid.focus({ preventScroll: true }) + } + } else { + // A previously focused row is being mounted again, we'll move focus + // back to it + element.focus({ preventScroll: true }) + } + } + } + + private getCustomRowClassNames = (rowIndex: number) => { + const { rowCustomClassNameMap } = this.props + if (rowCustomClassNameMap === undefined) { + return undefined + } + + const customClasses = new Array() + rowCustomClassNameMap.forEach( + (rows: ReadonlyArray, className: string) => { + if (rows.includes(rowIndex)) { + customClasses.push(className) + } + } + ) + + return customClasses.length === 0 ? undefined : customClasses.join(' ') + } + + private renderRow = (params: IRowRendererParams) => { + const rowIndex = params.rowIndex + const selectable = this.canSelectRow(rowIndex) + const selected = this.props.selectedRows.indexOf(rowIndex) !== -1 + const customClasses = this.getCustomRowClassNames(rowIndex) + + // An unselectable row shouldn't be focusable + let tabIndex: number | undefined = undefined + if (selectable) { + tabIndex = selected && this.props.selectedRows[0] === rowIndex ? 0 : -1 + } + + const row = this.props.rowRenderer(rowIndex) + + const element = + this.props.insertionDragType !== undefined ? ( + + {row} + + ) : ( + row + ) + + const id = this.getRowId(rowIndex) + + const ariaLabel = + this.props.getRowAriaLabel !== undefined + ? this.props.getRowAriaLabel(rowIndex) + : undefined + + return ( + + ) + } + + public render() { + let content: JSX.Element[] | JSX.Element | null + if (this.resizeObserver) { + content = this.renderContents( + this.state.width ?? 0, + this.state.height ?? 0 + ) + } else { + // Legacy in the event that we don't have ResizeObserver + content = ( + + {({ width, height }: { width: number; height: number }) => + this.renderContents(width, height) + } + + ) + } + + return ( +
    + {content} +
    + ) + } + + /** + * Renders the react-virtualized Grid component and optionally + * a fake scroll bar component if running on Windows. + * + * @param width - The width of the Grid as given by AutoSizer + * @param height - The height of the Grid as given by AutoSizer + */ + private renderContents(width: number, height: number) { + if (__WIN32__) { + return ( + <> + {this.renderGrid(width, height)} + {this.renderFakeScroll(height)} + + ) + } + + return this.renderGrid(width, height) + } + + private onGridRef = (ref: Grid | null) => { + this.grid = ref + } + + private onFakeScrollRef = (ref: HTMLDivElement | null) => { + this.fakeScroll = ref + } + + /** + * Renders the react-virtualized Grid component + * + * @param width - The width of the Grid as given by AutoSizer + * @param height - The height of the Grid as given by AutoSizer + */ + private renderGrid(width: number, height: number) { + // It is possible to send an invalid array such as [-1] to this component, + // if you do, you get weird focus problems. We shouldn't be doing this.. but + // if we do, send a non fatal exception to tell us about it. + if (this.props.selectedRows[0] < 0) { + sendNonFatalException( + 'The selected rows of the List.tsx contained a negative number.', + new Error( + `Invalid selected rows that contained a negative number passed to List component. This will cause keyboard navigation and focus problems.` + ) + ) + } + + // The currently selected list item is focusable but if there's no focused + // item the list itself needs to be focusable so that you can reach it with + // keyboard navigation and select an item. + const tabIndex = this.props.selectedRows.length < 1 ? 0 : -1 + + // we select the last item from the selection array for this prop + const activeDescendant = + this.props.selectedRows.length && this.state.rowIdPrefix + ? this.getRowId( + this.props.selectedRows[this.props.selectedRows.length - 1] + ) + : undefined + + const containerProps = this.getContainerProps(activeDescendant) + + return ( + + + + ) + } + + /** + * Renders a fake scroll container which sits on top of the + * react-virtualized Grid component in order for us to be + * able to have nice looking scrollbars on Windows. + * + * The fake scroll bar synchronizes its position + * + * NB: Should only be used on win32 platforms and needs to + * be coupled with styling that hides scroll bars on Grid + * and accurately positions the fake scroll bar. + * + * @param height The height of the Grid as given by AutoSizer + */ + private renderFakeScroll(height: number) { + let totalHeight: number = 0 + + if (typeof this.props.rowHeight === 'number') { + totalHeight = this.props.rowHeight * this.props.rowCount + } else { + for (let i = 0; i < this.props.rowCount; i++) { + totalHeight += this.props.rowHeight({ index: i }) + } + } + + return ( +
    +
    +
    + ) + } + + // Set the scroll position of the actual Grid to that + // of the fake scroll bar. This is for mousewheel/touchpad + // scrolling on top of the fake Grid or actual dragging of + // the scroll thumb. + private onFakeScroll = (e: React.UIEvent) => { + // We're getting this event in reaction to the Grid + // having been scrolled and subsequently updating the + // fake scrollTop, ignore it + if (this.lastScroll === 'grid') { + this.lastScroll = null + return + } + + this.lastScroll = 'fake' + + if (this.grid) { + const element = ReactDOM.findDOMNode(this.grid) + if (element instanceof Element) { + element.scrollTop = e.currentTarget.scrollTop + } + } + } + + private onRowMouseDown = ( + indexPath: RowIndexPath, + event: React.MouseEvent + ) => { + const { row } = indexPath + + if (this.canSelectRow(row)) { + if (this.props.onRowMouseDown) { + this.props.onRowMouseDown(row, event) + } + + // macOS allow emulating a right click by holding down the ctrl key while + // performing a "normal" click. + const isRightClick = + event.button === 2 || + (__DARWIN__ && event.button === 0 && event.ctrlKey) + + // prevent the right-click event from changing the selection if not necessary + if (isRightClick && this.props.selectedRows.includes(row)) { + return + } + + const multiSelectKey = __DARWIN__ ? event.metaKey : event.ctrlKey + + if ( + event.shiftKey && + this.props.selectedRows.length && + this.props.selectionMode && + this.props.selectionMode !== 'single' + ) { + /* + * if [shift] is pressed and selectionMode is different than 'single', + * select all in-between first selection and current row + */ + const selectionOrigin = this.props.selectedRows[0] + + if (this.props.onSelectionChanged) { + const newSelection = createSelectionBetween(selectionOrigin, row) + this.props.onSelectionChanged(newSelection, { + kind: 'mouseclick', + event, + }) + } + if ( + this.props.selectionMode === 'range' && + this.props.onSelectedRangeChanged + ) { + this.props.onSelectedRangeChanged(selectionOrigin, row, { + kind: 'mouseclick', + event, + }) + } + } else if (multiSelectKey && this.props.selectionMode === 'multi') { + /* + * if [ctrl] is pressed and selectionMode is 'multi', + * toggle selection of the targeted row + */ + if (this.props.onSelectionChanged) { + let newSelection: ReadonlyArray + if (this.props.selectedRows.includes(row)) { + // remove the ability to deselect the last item + if (this.props.selectedRows.length === 1) { + return + } + newSelection = this.props.selectedRows.filter(ix => ix !== row) + } else { + newSelection = [...this.props.selectedRows, row] + } + + this.props.onSelectionChanged(newSelection, { + kind: 'mouseclick', + event, + }) + } + } else if ( + (this.props.selectionMode === 'range' || + this.props.selectionMode === 'multi') && + this.props.selectedRows.length > 1 && + this.props.selectedRows.includes(row) + ) { + // Do nothing. Multiple rows are already selected. We assume the user is + // pressing down on multiple and may desire to start dragging. We will + // invoke the single selection `onRowMouseUp` if they let go here and no + // special keys are being pressed. + } else if ( + this.props.selectedRows.length !== 1 || + (this.props.selectedRows.length === 1 && + row !== this.props.selectedRows[0]) + ) { + /* + * if no special key is pressed, and that the selection is different, + * single selection occurs + */ + this.selectSingleRowAfterMouseEvent(row, event) + } + } + } + + private onRowMouseUp = ( + indexPath: RowIndexPath, + event: React.MouseEvent + ) => { + const { row } = indexPath + + if (!this.canSelectRow(row)) { + return + } + + // macOS allow emulating a right click by holding down the ctrl key while + // performing a "normal" click. + const isRightClick = + event.button === 2 || (__DARWIN__ && event.button === 0 && event.ctrlKey) + + // prevent the right-click event from changing the selection if not necessary + if (isRightClick && this.props.selectedRows.includes(row)) { + return + } + + const multiSelectKey = __DARWIN__ ? event.metaKey : event.ctrlKey + + if ( + !event.shiftKey && + !multiSelectKey && + this.props.selectedRows.length > 1 && + this.props.selectedRows.includes(row) && + (this.props.selectionMode === 'range' || + this.props.selectionMode === 'multi') + ) { + // No special keys are depressed and multiple rows were selected. The + // onRowMouseDown event was ignored for this scenario because the user may + // desire to started dragging multiple. However, if they let go, we want a + // new single selection to occur. + this.selectSingleRowAfterMouseEvent(row, event) + } + } + + private selectSingleRowAfterMouseEvent( + row: number, + event: React.MouseEvent + ): void { + if (this.props.onSelectionChanged) { + this.props.onSelectionChanged([row], { kind: 'mouseclick', event }) + } + + if (this.props.onSelectedRangeChanged) { + this.props.onSelectedRangeChanged(row, row, { + kind: 'mouseclick', + event, + }) + } + + if (this.props.onSelectedRowChanged) { + const { rowCount } = this.props + + if (row < 0 || row >= rowCount) { + log.debug( + `[List.selectSingleRowAfterMouseEvent] unable to onSelectedRowChanged for row '${row}' as it is outside the bounds of the array [0, ${rowCount}]` + ) + return + } + + this.props.onSelectedRowChanged(row, { kind: 'mouseclick', event }) + } + } + + private onDropDataInsertion = (indexPath: RowIndexPath, data: DragData) => { + this.props.onDropDataInsertion?.(indexPath.row, data) + } + + private onRowClick = ( + indexPath: RowIndexPath, + event: React.MouseEvent + ) => { + if (this.canSelectRow(indexPath.row) && this.props.onRowClick) { + const rowCount = this.props.rowCount + + if (indexPath.row < 0 || indexPath.row >= rowCount) { + log.debug( + `[List.onRowClick] unable to onRowClick for row ${indexPath.row} as it is outside the bounds of the array [0, ${rowCount}]` + ) + return + } + + this.props.onRowClick(indexPath.row, { kind: 'mouseclick', event }) + } + } + + private onRowDoubleClick = ( + indexPath: RowIndexPath, + event: React.MouseEvent + ) => { + if (!this.props.onRowDoubleClick) { + return + } + + this.props.onRowDoubleClick(indexPath.row, { kind: 'mouseclick', event }) + } + + private onScroll = ({ + scrollTop, + clientHeight, + }: { + scrollTop: number + clientHeight: number + }) => { + if (this.props.onScroll) { + this.props.onScroll(scrollTop, clientHeight) + } + + // Set the scroll position of the fake scroll bar to that + // of the actual Grid. This is for mousewheel/touchpad scrolling + // on top of the Grid. + if (__WIN32__ && this.fakeScroll) { + // We're getting this event in reaction to the fake scroll + // having been scrolled and subsequently updating the + // Grid scrollTop, ignore it. + if (this.lastScroll === 'fake') { + this.lastScroll = null + return + } + + this.lastScroll = 'grid' + + this.fakeScroll.scrollTop = scrollTop + } + } + + /** + * Explicitly put keyboard focus on the list or the selected item in the list. + * + * If the list a selected item it will be scrolled (if it's not already + * visible) and it will receive keyboard focus. If the list has no selected + * item the list itself will receive focus. From there keyboard navigation + * can be used to select the first or last items in the list. + * + * This method is a noop if the list has not yet been mounted. + */ + public focus() { + const { selectedRows, rowCount } = this.props + const lastSelectedRow = selectedRows.at(-1) + + if (lastSelectedRow !== undefined && lastSelectedRow < rowCount) { + this.scrollRowToVisible(lastSelectedRow) + } else { + if (this.grid) { + const element = ReactDOM.findDOMNode(this.grid) as HTMLDivElement + if (element) { + element.focus() + } + } + } + } +} diff --git a/app/src/ui/lib/list/section-list-selection.ts b/app/src/ui/lib/list/section-list-selection.ts new file mode 100644 index 0000000000..87e1473bdd --- /dev/null +++ b/app/src/ui/lib/list/section-list-selection.ts @@ -0,0 +1,191 @@ +import * as React from 'react' +import { + getTotalRowCount, + globalIndexToRowIndexPath, + InvalidRowIndexPath, + isValidRow, + RowIndexPath, + rowIndexPathEquals, + rowIndexPathToGlobalIndex, +} from './list-row-index-path' + +export type SelectionDirection = 'up' | 'down' + +interface ISelectRowAction { + /** + * The vertical direction use when searching for a selectable row. + */ + readonly direction: SelectionDirection + + /** + * The starting row index to search from. + */ + readonly row: RowIndexPath + + /** + * A flag to indicate or not to look beyond the last or first + * row (depending on direction) such that given the last row and + * a downward direction will consider the first row as a + * candidate or given the first row and an upward direction + * will consider the last row as a candidate. + * + * Defaults to true if not set. + */ + readonly wrap?: boolean +} + +/** + * Interface describing a user initiated selection change event + * originating from a pointer device clicking or pressing on an item. + */ +export interface IMouseClickSource { + readonly kind: 'mouseclick' + readonly event: React.MouseEvent +} + +/** + * Interface describing a user initiated selection change event + * originating from a pointer device hovering over an item. + * Only applicable when selectedOnHover is set. + */ +export interface IHoverSource { + readonly kind: 'hover' + readonly event: React.MouseEvent +} + +/** + * Interface describing a user initiated selection change event + * originating from a keyboard + */ +export interface IKeyboardSource { + readonly kind: 'keyboard' + readonly event: React.KeyboardEvent +} + +/** + * Interface describing a user initiated selection of all list + * items (usually by clicking the Edit > Select all menu item in + * the application window). This is highly specific to GitHub Desktop + */ +export interface ISelectAllSource { + readonly kind: 'select-all' +} + +/** A type union of possible sources of a selection changed event */ +export type SelectionSource = + | IMouseClickSource + | IHoverSource + | IKeyboardSource + | ISelectAllSource + +/** + * Determine the next selectable row, given the direction and a starting + * row index. Whether a row is selectable or not is determined using + * the `canSelectRow` function, which defaults to true if not provided. + * + * Returns null if no row can be selected or if the only selectable row is + * identical to the given row parameter. + */ +export function findNextSelectableRow( + rowCount: ReadonlyArray, + action: ISelectRowAction, + canSelectRow: (indexPath: RowIndexPath) => boolean = row => true +): RowIndexPath | null { + const totalRowCount = getTotalRowCount(rowCount) + if (totalRowCount === 0) { + return null + } + + const { direction, row } = action + const wrap = action.wrap === undefined ? true : action.wrap + const rowIndex = rowIndexPathEquals(InvalidRowIndexPath, row) + ? -1 + : rowIndexPathToGlobalIndex(row, rowCount) + + if (rowIndex === null) { + return null + } + + // Ensure the row value is in the range between 0 and rowCount - 1 + // + // If the row falls outside this range, use the direction + // given to choose a suitable value: + // + // - move in an upward direction -> select last row + // - move in a downward direction -> select first row + // + let currentRow = isValidRow(row, rowCount) + ? rowIndex + : direction === 'up' + ? totalRowCount - 1 + : 0 + + // handle specific case from switching from filter text to list + // + // locking currentRow to [0,rowCount) above means that the below loops + // will skip over the first entry + if (direction === 'down' && rowIndexPathEquals(row, InvalidRowIndexPath)) { + currentRow = -1 + } + + const delta = direction === 'up' ? -1 : 1 + + // Iterate through all rows (starting offset from the + // given row and ending on and including the given row) + for (let i = 0; i < totalRowCount; i++) { + currentRow += delta + + if (currentRow >= totalRowCount) { + // We've hit rock bottom, wrap around to the top + // if we're allowed to or give up. + if (wrap) { + currentRow = 0 + } else { + break + } + } else if (currentRow < 0) { + // We've reached the top, wrap around to the bottom + // if we're allowed to or give up + if (wrap) { + currentRow = totalRowCount - 1 + } else { + break + } + } + + const currentRowIndexPath = globalIndexToRowIndexPath(currentRow, rowCount) + if ( + currentRowIndexPath !== null && + !rowIndexPathEquals(row, currentRowIndexPath) && + canSelectRow(currentRowIndexPath) + ) { + return currentRowIndexPath + } + } + + return null +} + +/** + * Find the last selectable row in either direction, used + * for moving to the first or last selectable row in a list, + * i.e. Home/End key navigation. + */ +export function findLastSelectableRow( + direction: SelectionDirection, + rowCount: ReadonlyArray, + canSelectRow: (indexPath: RowIndexPath) => boolean +): RowIndexPath | null { + const totalRowCount = getTotalRowCount(rowCount) + let i = direction === 'up' ? 0 : totalRowCount - 1 + const delta = direction === 'up' ? 1 : -1 + + for (; i >= 0 && i < totalRowCount; i += delta) { + const indexPath = globalIndexToRowIndexPath(i, rowCount) + if (indexPath !== null && canSelectRow(indexPath)) { + return indexPath + } + } + + return null +} diff --git a/app/src/ui/lib/list/section-list.tsx b/app/src/ui/lib/list/section-list.tsx new file mode 100644 index 0000000000..1c814482ed --- /dev/null +++ b/app/src/ui/lib/list/section-list.tsx @@ -0,0 +1,1712 @@ +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { Grid, AutoSizer, Index } from 'react-virtualized' +import { shallowEquals, structuralEquals } from '../../../lib/equality' +import { FocusContainer } from '../../lib/focus-container' +import { ListRow } from './list-row' +import { + findNextSelectableRow, + SelectionSource, + SelectionDirection, + IMouseClickSource, + IKeyboardSource, + ISelectAllSource, + findLastSelectableRow, +} from './section-list-selection' +import { createUniqueId, releaseUniqueId } from '../../lib/id-pool' +import { ListItemInsertionOverlay } from './list-item-insertion-overlay' +import { DragData, DragType } from '../../../models/drag-drop' +import memoizeOne from 'memoize-one' +import { + getTotalRowCount, + globalIndexToRowIndexPath, + InvalidRowIndexPath, + isValidRow, + RowIndexPath, + rowIndexPathEquals, + rowIndexPathToGlobalIndex, +} from './list-row-index-path' +import { range } from '../../../lib/range' +import { sendNonFatalException } from '../../../lib/helpers/non-fatal-exception' + +/** + * Describe the first argument given to the cellRenderer, + * See + * https://github.com/bvaughn/react-virtualized/issues/386 + * https://github.com/bvaughn/react-virtualized/blob/8.0.11/source/Grid/defaultCellRangeRenderer.js#L38-L44 + */ +export interface IRowRendererParams { + /** Horizontal (column) index of cell */ + readonly columnIndex: number + + /** The Grid is currently being scrolled */ + readonly isScrolling: boolean + + /** Unique key within array of cells */ + readonly key: React.Key + + /** Vertical (row) index of cell */ + readonly rowIndex: number + + /** Style object to be applied to cell */ + readonly style: React.CSSProperties +} + +export type ClickSource = IMouseClickSource | IKeyboardSource + +interface ISectionListProps { + /** + * Mandatory callback for rendering the contents of a particular + * row. The callback is not responsible for the outer wrapper + * of the row, only its contents and may return null although + * that will result in an empty list item. + */ + readonly rowRenderer: (indexPath: RowIndexPath) => JSX.Element | null + + /** + * Whether or not a given section has a header row at the beginning. When + * ommitted, it's assumed the section does NOT have a header row. + */ + readonly sectionHasHeader?: (section: number) => boolean + + /** Aria label for a section in the list. */ + readonly getSectionAriaLabel?: (section: number) => string | undefined + + /** + * The total number of rows in the list. This is used for + * scroll virtualization purposes when calculating the theoretical + * height of the list. + */ + readonly rowCount: ReadonlyArray + + /** + * The height of each individual row in the list. This height + * is enforced for each row container and attempting to render a row + * which does not fit inside that height is forbidden. + * + * Can either be a number (most efficient) in which case all rows + * are of equal height, or, a function that, given a row index returns + * the height of that particular row. + */ + readonly rowHeight: number | ((info: { index: RowIndexPath }) => number) + + /** + * Function that generates an ID for a given row. This will allow the + * container component of the list to have control over the ID of the + * row and allow it to be used for things like keyboard navigation. + */ + readonly rowId?: (indexPath: RowIndexPath) => string + + /** + * The currently selected rows indexes. Used to attach a special + * selection class on those row's containers as well as being used + * for keyboard selection. + * + * It is expected that the use case for this is setting of the initially + * selected rows or clearing a list selection. + * + * N.B. Since it is used for keyboard selection, changing the ordering of + * elements in this array in a parent component may result in unexpected + * behaviors when a user modifies their selection via key commands. + * See #15536 lessons learned. + */ + readonly selectedRows: ReadonlyArray + + /** + * Used to attach special classes to specific rows + */ + readonly rowCustomClassNameMap?: Map> + + /** + * This function will be called when a pointer device is pressed and then + * released on a selectable row. Note that this follows the conventions + * of button elements such that pressing Enter or Space on a keyboard + * while focused on a particular row will also trigger this event. Consumers + * can differentiate between the two using the source parameter. + * + * Note that this event handler will not be called for keyboard events + * if `event.preventDefault()` was called in the onRowKeyDown event handler. + * + * Consumers of this event do _not_ have to call event.preventDefault, + * when this event is subscribed to the list will automatically call it. + */ + readonly onRowClick?: (row: RowIndexPath, source: ClickSource) => void + + readonly onRowDoubleClick?: ( + indexPath: RowIndexPath, + source: IMouseClickSource + ) => void + + /** This function will be called when a row obtains focus, no matter how */ + readonly onRowFocus?: ( + indexPath: RowIndexPath, + source: React.FocusEvent + ) => void + + /** This function will be called only when a row obtains focus via keyboard */ + readonly onRowKeyboardFocus?: ( + indexPath: RowIndexPath, + e: React.KeyboardEvent + ) => void + + /** This function will be called when a row loses focus */ + readonly onRowBlur?: ( + indexPath: RowIndexPath, + source: React.FocusEvent + ) => void + + /** + * This prop defines the behaviour of the selection of items within this list. + * - 'single' : (default) single list-item selection. [shift] and [ctrl] have + * no effect. Use in combination with one of: + * onSelectedRowChanged(row: number) + * onSelectionChanged(rows: number[]) + * - 'range' : allows for selecting continuous ranges. [shift] can be used. + * [ctrl] has no effect. Use in combination with one of: + * onSelectedRangeChanged(start: number, end: number) + * onSelectionChanged(rows: number[]) + * - 'multi' : allows range and/or arbitrary selection. [shift] and [ctrl] + * can be used. Use in combination with: + * onSelectionChanged(rows: number[]) + */ + readonly selectionMode?: 'single' | 'range' | 'multi' + + /** + * This function will be called when the selection changes as a result of a + * user keyboard or mouse action (i.e. not when props change). This function + * will not be invoked when an already selected row is clicked on. + * Use this function when the selectionMode is 'single' + * + * @param row - The index of the row that was just selected + * @param source - The kind of user action that provoked the change, either + * a pointer device press or a keyboard event (arrow up/down) + */ + readonly onSelectedRowChanged?: ( + indexPath: RowIndexPath, + source: SelectionSource + ) => void + + /** + * This function will be called when the selection changes as a result of a + * user keyboard or mouse action (i.e. not when props change). This function + * will not be invoked when an already selected row is clicked on. + * Index parameters are inclusive + * Use this function when the selectionMode is 'range' + * + * @param start - The index of the first selected row + * @param end - The index of the last selected row + * @param source - The kind of user action that provoked the change, either + * a pointer device press or a keyboard event (arrow up/down) + */ + readonly onSelectedRangeChanged?: ( + start: RowIndexPath, + end: RowIndexPath, + source: SelectionSource + ) => void + + /** + * This function will be called when the selection changes as a result of a + * user keyboard or mouse action (i.e. not when props change). This function + * will not be invoked when an already selected row is clicked on. + * Use this function for any selectionMode + * + * @param rows - The indexes of the row(s) that are part of the selection + * @param source - The kind of user action that provoked the change, either + * a pointer device press or a keyboard event (arrow up/down) + */ + readonly onSelectionChanged?: ( + rows: ReadonlyArray, + source: SelectionSource + ) => void + + /** + * A handler called whenever a key down event is received on the + * row container element. Due to the way the container is currently + * implemented the element produced by the rowRendered will never + * see keyboard events without stealing focus away from the container. + * + * Primary use case for this is to allow items to react to the space + * bar in order to toggle selection. This function is responsible + * for calling event.preventDefault() when acting on a key press. + */ + readonly onRowKeyDown?: ( + indexPath: RowIndexPath, + event: React.KeyboardEvent + ) => void + + /** + * A handler called whenever a mouse down event is received on the + * row container element. Unlike onSelectionChanged, this is raised + * for every mouse down event, whether the row is selected or not. + */ + readonly onRowMouseDown?: ( + indexPath: RowIndexPath, + event: React.MouseEvent + ) => void + + /** + * A handler called whenever a context menu event is received on the + * row container element. + * + * The context menu is invoked when a user right clicks the row or + * uses keyboard shortcut. + */ + readonly onRowContextMenu?: ( + row: RowIndexPath, + event: React.MouseEvent + ) => void + + /** + * A handler called whenever the user drops items on the list to be inserted. + * + * @param row - The index of the row where the user intends to insert the new + * items. + * @param data - The data dropped by the user. + */ + readonly onDropDataInsertion?: ( + indexPath: RowIndexPath, + data: DragData + ) => void + + /** + * An optional handler called to determine whether a given row is + * selectable or not. Reasons for why a row might not be selectable + * includes it being a group header or the item being disabled. + */ + readonly canSelectRow?: (row: RowIndexPath) => boolean + readonly onScroll?: (scrollTop: number, clientHeight: number) => void + + /** + * List's underlying implementation acts as a pure component based on the + * above props. So if there are any other properties that also determine + * whether the list should re-render, List must know about them. + */ + readonly invalidationProps?: any + + /** The unique identifier for the outer element of the component (optional, defaults to null) */ + readonly id?: string + + /** The unique identifier of the accessible list component (optional) */ + readonly accessibleListId?: string + + /** The row that should be scrolled to when the list is rendered. */ + readonly scrollToRow?: RowIndexPath + + /** Type of elements that can be inserted in the list via drag & drop. Optional. */ + readonly insertionDragType?: DragType + + /** + * The number of pixels from the top of the list indicating + * where to scroll do on rendering of the list. + */ + readonly setScrollTop?: number + + /** The aria-labelledby attribute for the list component. */ + readonly ariaLabelledBy?: string + + /** The aria-label attribute for the list component. */ + readonly ariaLabel?: string +} + +interface ISectionListState { + /** The available height for the list as determined by ResizeObserver */ + readonly height?: number + + /** The available width for the list as determined by ResizeObserver */ + readonly width?: number + + readonly rowIdPrefix?: string + + readonly scrollTop: number +} + +/** + * Create an array with row indices between firstRow and lastRow (inclusive). + * + * This is essentially a range function with the explicit behavior of + * inclusive upper and lower bound. + */ +function createSelectionBetween( + firstRow: RowIndexPath, + lastRow: RowIndexPath, + rowCount: ReadonlyArray +): ReadonlyArray { + const firstIndex = rowIndexPathToGlobalIndex(firstRow, rowCount) + const lastIndex = rowIndexPathToGlobalIndex(lastRow, rowCount) + if (firstIndex === null || lastIndex === null) { + return [] + } + + const end = lastIndex > firstIndex ? lastIndex + 1 : lastIndex - 1 + // range is upper bound exclusive + const rowRange = range(firstIndex, end) + const selection = new Array(rowRange.length) + for (let i = 0; i < rowRange.length; i++) { + const indexPath = globalIndexToRowIndexPath(rowRange[i], rowCount) + if (indexPath !== null) { + selection[i] = indexPath + } + } + return selection +} + +// Since objects cannot be keys in a Map, this class encapsulates the logic for +// creating a string key from a RowIndexPath. +class RowRefsMap { + private readonly map = new Map() + + private getIndexPathKey(indexPath: RowIndexPath): string { + return `${indexPath.section}-${indexPath.row}` + } + + public get(indexPath: RowIndexPath): HTMLDivElement | undefined { + return this.map.get(this.getIndexPathKey(indexPath)) + } + + public set(indexPath: RowIndexPath, element: HTMLDivElement) { + this.map.set(this.getIndexPathKey(indexPath), element) + } + + public delete(indexPath: RowIndexPath) { + this.map.delete(this.getIndexPathKey(indexPath)) + } +} + +export class SectionList extends React.Component< + ISectionListProps, + ISectionListState +> { + private fakeScroll: HTMLDivElement | null = null + private focusRow: RowIndexPath = InvalidRowIndexPath + + private readonly rowRefs = new RowRefsMap() + + /** + * The style prop for our child Grid. We keep this here in order + * to not create a new object on each render and thus forcing + * the Grid to re-render even though nothing has changed. + */ + private gridStyle: React.CSSProperties = { overflowX: 'hidden' } + + /** + * On Win32 we use a fake scroll bar. This variable keeps track of + * which of the actual scroll container and the fake scroll container + * received the scroll event first to avoid bouncing back and forth + * causing jerky scroll bars and more importantly making the mouse + * wheel scroll speed appear different when scrolling over the + * fake scroll bar and the actual one. + */ + private lastScroll: 'grid' | 'fake' | null = null + + private list: HTMLDivElement | null = null + private rootGrid: Grid | null = null + private grids = new Map() + private readonly resizeObserver: ResizeObserver | null = null + private updateSizeTimeoutId: NodeJS.Immediate | null = null + + /** + * Get the props for the inner scroll container (called containerProps on the + * Grid component). This is memoized to avoid causing the Grid component to + * rerender every time the list component rerenders (the Grid component is a + * pure component so a complex object like containerProps being instantiated + * on each render would cause it to rerender constantly). + */ + private getContainerProps = memoizeOne( + ( + activeDescendant: string | undefined + ): React.HTMLProps => ({ + onKeyDown: this.onKeyDown, + 'aria-activedescendant': activeDescendant, + 'aria-multiselectable': + this.props.selectionMode === 'multi' || + this.props.selectionMode === 'range' + ? 'true' + : undefined, + }) + ) + + public constructor(props: ISectionListProps) { + super(props) + + this.state = { + scrollTop: 0, + } + + const ResizeObserverClass: typeof ResizeObserver = (window as any) + .ResizeObserver + + if (ResizeObserver || false) { + this.resizeObserver = new ResizeObserverClass(entries => { + for (const { target, contentRect } of entries) { + if (target === this.list && this.list !== null) { + // We might end up causing a recursive update by updating the state + // when we're reacting to a resize so we'll defer it until after + // react is done with this frame. + if (this.updateSizeTimeoutId !== null) { + clearImmediate(this.updateSizeTimeoutId) + } + + this.updateSizeTimeoutId = setImmediate( + this.onResized, + this.list, + contentRect + ) + } + } + }) + } + } + + private get totalRowCount() { + return getTotalRowCount(this.props.rowCount) + } + + private getRowId(indexPath: RowIndexPath): string | undefined { + if (this.props.rowId) { + return this.props.rowId(indexPath) + } + + return this.state.rowIdPrefix === undefined + ? undefined + : `${this.state.rowIdPrefix}-${indexPath.section}-${indexPath.row}` + } + + private onResized = (target: HTMLElement, contentRect: ClientRect) => { + this.updateSizeTimeoutId = null + + const [width, height] = [target.offsetWidth, target.offsetHeight] + + if (this.state.width !== width || this.state.height !== height) { + this.setState({ width, height }) + } + } + + private onSelectAll = (event: Event | React.SyntheticEvent) => { + const selectionMode = this.props.selectionMode + + if (selectionMode !== 'range' && selectionMode !== 'multi') { + return + } + + event.preventDefault() + + if (this.totalRowCount <= 0) { + return + } + + const source: ISelectAllSource = { kind: 'select-all' } + const firstRow: RowIndexPath = { section: 0, row: 0 } + const lastRow: RowIndexPath = { + section: this.props.rowCount.length - 1, + row: this.props.rowCount[this.props.rowCount.length - 1] - 1, + } + + if (this.props.onSelectionChanged) { + const newSelection = createSelectionBetween( + firstRow, + lastRow, + this.props.rowCount + ) + this.props.onSelectionChanged(newSelection, source) + } + + if (selectionMode === 'range' && this.props.onSelectedRangeChanged) { + this.props.onSelectedRangeChanged(firstRow, lastRow, source) + } + } + + private onRef = (element: HTMLDivElement | null) => { + if (element === null && this.list !== null) { + this.list.removeEventListener('select-all', this.onSelectAll) + } + + this.list = element + + if (element !== null) { + // This is a custom event that desktop emits through + // when the user selects the Edit > Select all menu item. We + // hijack it and select all list items rather than let it bubble + // to electron's default behavior which is to select all selectable + // text in the renderer. + element.addEventListener('select-all', this.onSelectAll) + } + + if (this.resizeObserver) { + this.resizeObserver.disconnect() + + if (element !== null) { + this.resizeObserver.observe(element) + } else { + this.setState({ width: undefined, height: undefined }) + } + } + } + + private onKeyDown = (event: React.KeyboardEvent) => { + if (this.props.onRowKeyDown) { + for (const row of this.props.selectedRows) { + this.props.onRowKeyDown(row, event) + } + } + + // The consumer is given a change to prevent the default behavior for + // keyboard navigation so that they can customize its behavior as needed. + if (event.defaultPrevented) { + return + } + + const source: SelectionSource = { kind: 'keyboard', event } + + // Home is Cmd+ArrowUp on macOS, end is Cmd+ArrowDown, see + // https://github.com/desktop/desktop/pull/8644#issuecomment-645965884 + const isHomeKey = __DARWIN__ + ? event.metaKey && event.key === 'ArrowUp' + : event.key === 'Home' + const isEndKey = __DARWIN__ + ? event.metaKey && event.key === 'ArrowDown' + : event.key === 'End' + + const isRangeSelection = + event.shiftKey && + this.props.selectionMode !== undefined && + this.props.selectionMode !== 'single' + + if (isHomeKey || isEndKey) { + const direction = isHomeKey ? 'up' : 'down' + if (isRangeSelection) { + this.addSelectionToLastSelectableRow(direction, source) + } else { + this.moveSelectionToLastSelectableRow(direction, source) + } + event.preventDefault() + } else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + const direction = event.key === 'ArrowUp' ? 'up' : 'down' + if (isRangeSelection) { + this.addSelection(direction, source) + } else { + this.moveSelection(direction, source) + } + event.preventDefault() + } else if (!__DARWIN__ && event.key === 'a' && event.ctrlKey) { + // On Windows Chromium will steal the Ctrl+A shortcut before + // Electron gets its hands on it meaning that the Select all + // menu item can't be invoked by means of keyboard shortcuts + // on Windows. Clicking on the menu item still emits the + // 'select-all' custom DOM event. + this.onSelectAll(event) + } else if (event.key === 'PageUp' || event.key === 'PageDown') { + const direction = event.key === 'PageUp' ? 'up' : 'down' + if (isRangeSelection) { + this.addSelectionByPage(direction, source) + } else { + this.moveSelectionByPage(direction, source) + } + event.preventDefault() + } + } + + private moveSelectionByPage( + direction: SelectionDirection, + source: SelectionSource + ) { + const newSelection = this.getNextPageRowIndexPath(direction) + this.moveSelectionTo(newSelection, source) + } + + private addSelectionByPage( + direction: SelectionDirection, + source: SelectionSource + ) { + const { selectedRows } = this.props + const newSelection = this.getNextPageRowIndexPath(direction) + const firstSelection = selectedRows[0] ?? 0 + const range = createSelectionBetween( + firstSelection, + newSelection, + this.props.rowCount + ) + + if (this.props.onSelectionChanged) { + this.props.onSelectionChanged(range, source) + } + + if (this.props.onSelectedRangeChanged) { + this.props.onSelectedRangeChanged( + range[0], + range[range.length - 1], + source + ) + } + + this.scrollRowToVisible(newSelection) + } + + private getNextPageRowIndexPath(direction: SelectionDirection) { + const { selectedRows } = this.props + const lastSelection: RowIndexPath = selectedRows.at(-1) ?? { + row: 0, + section: 0, + } + + return this.findNextPageSelectableRow(lastSelection, direction) + } + + private getHeightForRowAtIndexPath(index: RowIndexPath) { + const { rowHeight } = this.props + return typeof rowHeight === 'number' ? rowHeight : rowHeight({ index }) + } + + private findNextPageSelectableRow( + fromRow: RowIndexPath, + direction: SelectionDirection + ) { + const { height: listHeight } = this.state + const { rowCount } = this.props + + if (listHeight === undefined) { + return fromRow + } + + let offset = 0 + let newSelection = fromRow + const delta = direction === 'up' ? -1 : 1 + + // Starting from the last selected row, move up or down depending + // on the direction, keeping a sum of the height of all the rows + // we've seen until the accumulated height is about to exceed that + // of the list height. Once we've found the index of the item that + // just about exceeds the height we'll pick that one as the next + // selection. + for (let i = fromRow.section; i < rowCount.length && i >= 0; i += delta) { + const initialRow = i === fromRow.section ? fromRow.row : 0 + + for (let j = initialRow; j < rowCount[i] && j >= 0; j += delta) { + const indexPath = { section: i, row: j } + const h = this.getHeightForRowAtIndexPath(indexPath) + + if (offset + h > listHeight) { + break + } + offset += h + + if (this.canSelectRow(indexPath)) { + newSelection = indexPath + } + } + } + + return newSelection + } + + private onRowKeyDown = ( + rowIndex: RowIndexPath, + event: React.KeyboardEvent + ) => { + if (this.props.onRowKeyDown) { + this.props.onRowKeyDown(rowIndex, event) + } + + const hasModifier = + event.altKey || event.ctrlKey || event.metaKey || event.shiftKey + + // We give consumers the power to prevent the onRowClick event by subscribing + // to the onRowKeyDown event and calling event.preventDefault. This lets + // consumers add their own semantics for keyboard presses. + if ( + !event.defaultPrevented && + !hasModifier && + (event.key === 'Enter' || event.key === ' ') + ) { + this.toggleSelection(event) + event.preventDefault() + } + } + + private onFocusContainerKeyDown = (event: React.KeyboardEvent) => { + const hasModifier = + event.altKey || event.ctrlKey || event.metaKey || event.shiftKey + + if ( + !event.defaultPrevented && + !hasModifier && + (event.key === 'Enter' || event.key === ' ') + ) { + this.toggleSelection(event) + event.preventDefault() + } + } + + private onFocusWithinChanged = (focusWithin: boolean) => { + // So the grid lost focus (we manually focus the grid if the focused list + // item is unmounted) so we mustn't attempt to refocus the previously + // focused list item if it scrolls back into view. + if (!focusWithin) { + this.focusRow = InvalidRowIndexPath + } + } + + private toggleSelection = (event: React.KeyboardEvent) => { + this.props.selectedRows.forEach(row => { + if (!this.props.onRowClick) { + return + } + + if (!isValidRow(row, this.props.rowCount)) { + log.debug( + `[List.toggleSelection] unable to onRowClick for row ${row} as it is outside the bounds` + ) + return + } + + this.props.onRowClick(row, { kind: 'keyboard', event }) + }) + } + + private onRowFocus = ( + index: RowIndexPath, + e: React.FocusEvent + ) => { + this.focusRow = index + this.props.onRowFocus?.(index, e) + } + + private onRowKeyboardFocus = ( + index: RowIndexPath, + e: React.KeyboardEvent + ) => { + this.props.onRowKeyboardFocus?.(index, e) + } + + private onRowBlur = ( + index: RowIndexPath, + e: React.FocusEvent + ) => { + if (rowIndexPathEquals(this.focusRow, index)) { + this.focusRow = InvalidRowIndexPath + } + this.props.onRowBlur?.(index, e) + } + + private onRowContextMenu = ( + row: RowIndexPath, + e: React.MouseEvent + ) => { + this.props.onRowContextMenu?.(row, e) + } + + private get firstRowIndexPath(): RowIndexPath { + for (let section = 0; section < this.props.rowCount.length; section++) { + const rowCount = this.props.rowCount[section] + if (rowCount > 0) { + return { section, row: 0 } + } + } + + return InvalidRowIndexPath + } + + /** Convenience method for invoking canSelectRow callback when it exists */ + private canSelectRow = (rowIndex: RowIndexPath) => { + return this.props.canSelectRow ? this.props.canSelectRow(rowIndex) : true + } + + private addSelection(direction: SelectionDirection, source: SelectionSource) { + if (this.props.selectedRows.length === 0) { + return this.moveSelection(direction, source) + } + + const lastSelection = + this.props.selectedRows[this.props.selectedRows.length - 1] + + const selectionOrigin = this.props.selectedRows[0] + + const newRow = findNextSelectableRow( + this.props.rowCount, + { direction, row: lastSelection, wrap: false }, + this.canSelectRow + ) + + if (newRow != null) { + if (this.props.onSelectionChanged) { + const newSelection = createSelectionBetween( + selectionOrigin, + newRow, + this.props.rowCount + ) + this.props.onSelectionChanged(newSelection, source) + } + + if ( + this.props.selectionMode === 'range' && + this.props.onSelectedRangeChanged + ) { + this.props.onSelectedRangeChanged(selectionOrigin, newRow, source) + } + + this.scrollRowToVisible(newRow) + } + } + + private moveSelection( + direction: SelectionDirection, + source: SelectionSource + ) { + const lastSelection = + this.props.selectedRows.length > 0 + ? this.props.selectedRows[this.props.selectedRows.length - 1] + : InvalidRowIndexPath + + const newRow = findNextSelectableRow( + this.props.rowCount, + { direction, row: lastSelection }, + this.canSelectRow + ) + + if (newRow != null) { + this.moveSelectionTo(newRow, source) + } + } + + private moveSelectionToLastSelectableRow( + direction: SelectionDirection, + source: SelectionSource + ) { + const { canSelectRow, props } = this + const { rowCount } = props + const row = findLastSelectableRow(direction, rowCount, canSelectRow) + + if (row !== null) { + this.moveSelectionTo(row, source) + } + } + + private addSelectionToLastSelectableRow( + direction: SelectionDirection, + source: SelectionSource + ) { + const { canSelectRow, props } = this + const { rowCount, selectedRows } = props + const row = findLastSelectableRow(direction, rowCount, canSelectRow) + + if (row === null) { + return + } + + const firstRow = this.firstRowIndexPath + const firstSelection = selectedRows[0] ?? firstRow + const range = createSelectionBetween(firstSelection, row, rowCount) + + this.props.onSelectionChanged?.(range, source) + + const from = range.at(0) ?? firstRow + const to = range.at(-1) ?? firstRow + + this.props.onSelectedRangeChanged?.(from, to, source) + + this.scrollRowToVisible(row) + } + + private moveSelectionTo(indexPath: RowIndexPath, source: SelectionSource) { + if (this.props.onSelectionChanged) { + this.props.onSelectionChanged([indexPath], source) + } + + if (this.props.onSelectedRowChanged) { + if (!isValidRow(indexPath, this.props.rowCount)) { + log.debug( + `[List.moveSelection] unable to onSelectedRowChanged for row '${indexPath}' as it is outside the bounds` + ) + return + } + + this.props.onSelectedRowChanged(indexPath, source) + } + + this.scrollRowToVisible(indexPath) + } + + private scrollRowToVisible(indexPath: RowIndexPath, moveFocus = true) { + if (this.rootGrid === null) { + return + } + + const { scrollTop } = this.state + + const rowHeight = this.getHeightForRowAtIndexPath(indexPath) + const sectionOffset = this.getSectionScrollOffset(indexPath.section) + const rowOffsetInSection = this.getRowOffsetInSection(indexPath) + + const grid = ReactDOM.findDOMNode(this.rootGrid) + if (!(grid instanceof HTMLElement)) { + return + } + const gridHeight = grid.getBoundingClientRect().height + + const minCellOffset = + sectionOffset + rowOffsetInSection + rowHeight - gridHeight + const maxCellOffset = sectionOffset + rowOffsetInSection + + const newScrollTop = Math.max( + minCellOffset, + Math.min(maxCellOffset, scrollTop) + ) + + this.rootGrid?.scrollToPosition({ + scrollLeft: 0, + scrollTop: newScrollTop, + }) + + if (moveFocus) { + this.focusRow = indexPath + this.rowRefs.get(indexPath)?.focus({ preventScroll: true }) + } + } + + public componentDidMount() { + const { props } = this + const { selectedRows, scrollToRow, setScrollTop } = props + + // If we have a selected row when we're about to mount + // we'll scroll to it immediately. + const row = scrollToRow ?? selectedRows.at(0) + if (row === undefined) { + return + } + + const grid = this.grids.get(row.section) + + // Prefer scrollTop position over scrollToRow + if (grid !== undefined && setScrollTop === undefined) { + grid.scrollToCell({ rowIndex: row.row, columnIndex: 0 }) + } + } + + public componentDidUpdate( + prevProps: ISectionListProps, + prevState: ISectionListState + ) { + const { scrollToRow, setScrollTop } = this.props + if ( + scrollToRow !== undefined && + (prevProps.scrollToRow === undefined || + !rowIndexPathEquals(prevProps.scrollToRow, scrollToRow)) + ) { + // Prefer scrollTop position over scrollToRow + if (setScrollTop === undefined) { + this.scrollRowToVisible(scrollToRow, false) + } + } + + if (this.grids.size > 0) { + const hasEqualRowCount = structuralEquals( + this.props.rowCount, + prevProps.rowCount + ) + + // A non-exhaustive set of checks to see if our current update has already + // triggered a re-render of the Grid. In order to do this perfectly we'd + // have to do a shallow compare on all the props we pass to Grid but + // this should cover the majority of cases. + const gridHasUpdatedAlready = + !hasEqualRowCount || + this.state.width !== prevState.width || + this.state.height !== prevState.height + + // If the number of groups doesn't change, but the size of them does, we + // need to recompute the grid size to ensure that the rows are laid out + // correctly. + if (!hasEqualRowCount) { + this.rootGrid?.recomputeGridSize() + } + + if (!gridHasUpdatedAlready) { + const selectedRowChanged = !structuralEquals( + prevProps.selectedRows, + this.props.selectedRows + ) + + const invalidationPropsChanged = !shallowEquals( + prevProps.invalidationProps, + this.props.invalidationProps + ) + + // Now we need to figure out whether anything changed in such a way that + // the Grid has to update regardless of its props. Previously we passed + // our selectedRow and invalidationProps down to Grid and figured that + // it, being a pure component, would do the right thing but that's not + // quite the case since invalidationProps is a complex object. + if (selectedRowChanged || invalidationPropsChanged) { + for (const grid of this.grids.values()) { + grid.forceUpdate() + } + } + } + } + } + + public componentWillMount() { + this.setState({ rowIdPrefix: createUniqueId('ListRow') }) + } + + public componentWillUnmount() { + if (this.updateSizeTimeoutId !== null) { + clearImmediate(this.updateSizeTimeoutId) + this.updateSizeTimeoutId = null + } + + if (this.resizeObserver) { + this.resizeObserver.disconnect() + } + + if (this.state.rowIdPrefix) { + releaseUniqueId(this.state.rowIdPrefix) + } + } + + private onRowRef = ( + rowIndex: RowIndexPath, + element: HTMLDivElement | null + ) => { + if (element === null) { + this.rowRefs.delete(rowIndex) + } else { + this.rowRefs.set(rowIndex, element) + } + + if (rowIndexPathEquals(rowIndex, this.focusRow)) { + // The currently focused row is going being unmounted so we'll move focus + // programmatically to the grid so that keyboard navigation still works + if (element === null) { + const rowGrid = this.grids.get(rowIndex.section) + if (rowGrid === undefined) { + const grid = ReactDOM.findDOMNode(rowGrid) + if (grid instanceof HTMLElement) { + grid.focus({ preventScroll: true }) + } + } + } else { + // A previously focused row is being mounted again, we'll move focus + // back to it + element.focus({ preventScroll: true }) + } + } + } + + private getCustomRowClassNames = (rowIndex: RowIndexPath) => { + const { rowCustomClassNameMap } = this.props + if (rowCustomClassNameMap === undefined) { + return undefined + } + + const customClasses = new Array() + rowCustomClassNameMap.forEach( + (rows: ReadonlyArray, className: string) => { + if (rows.includes(rowIndex)) { + customClasses.push(className) + } + } + ) + + return customClasses.length === 0 ? undefined : customClasses.join(' ') + } + + private getRowRenderer = (section: number) => { + return (params: IRowRendererParams) => { + const indexPath: RowIndexPath = { + section: section, + row: params.rowIndex, + } + + const selectable = this.canSelectRow(indexPath) + const selected = + this.props.selectedRows.findIndex(r => + rowIndexPathEquals(r, indexPath) + ) !== -1 + const customClasses = this.getCustomRowClassNames(indexPath) + + // An unselectable row shouldn't be focusable + let tabIndex: number | undefined = undefined + if (selectable) { + tabIndex = + selected && rowIndexPathEquals(this.props.selectedRows[0], indexPath) + ? 0 + : -1 + } + + const row = this.props.rowRenderer(indexPath) + const sectionHasHeader = + this.props.sectionHasHeader?.(indexPath.section) ?? false + + const element = + this.props.insertionDragType !== undefined ? ( + + {row} + + ) : ( + row + ) + + const id = this.getRowId(indexPath) + + return ( + + ) + } + } + + public render() { + let content: JSX.Element[] | JSX.Element | null + if (this.resizeObserver) { + content = this.renderContents( + this.state.width ?? 0, + this.state.height ?? 0 + ) + } else { + // Legacy in the event that we don't have ResizeObserver + content = ( + + {({ width, height }: { width: number; height: number }) => + this.renderContents(width, height) + } + + ) + } + + return ( +
    + {content} +
    + ) + } + + /** + * Renders the react-virtualized Grid component and optionally + * a fake scroll bar component if running on Windows. + * + * @param width - The width of the Grid as given by AutoSizer + * @param height - The height of the Grid as given by AutoSizer + */ + private renderContents(width: number, height: number) { + if (__WIN32__) { + return ( + <> + {this.renderGrid(width, height)} + {this.renderFakeScroll(height)} + + ) + } + + return this.renderGrid(width, height) + } + + private getRowHeight = (section: number) => { + const rowHeight = this.props.rowHeight + + if (typeof rowHeight === 'number') { + return rowHeight + } + + return (params: Index) => { + const index: RowIndexPath = { + section: section, + row: params.index, + } + + return rowHeight({ index }) + } + } + + private onRootGridRef = (ref: Grid | null) => { + this.rootGrid = ref + } + + private getOnGridRef = (section: number) => { + return (ref: Grid | null) => { + if (ref === null) { + this.grids.delete(section) + } else { + this.grids.set(section, ref) + } + } + } + + private onFakeScrollRef = (ref: HTMLDivElement | null) => { + this.fakeScroll = ref + } + + private getSectionScrollOffset = (section: number) => + this.props.rowCount + .slice(0, section) + .reduce((height, _x, idx) => height + this.getSectionHeight(idx), 0) + + private getSectionGridRenderer = + (width: number, height: number) => (params: IRowRendererParams) => { + const section = params.rowIndex + + // we select the last item from the selection array for this prop + const sectionHeight = this.getSectionHeight(section) + const offset = this.getSectionScrollOffset(section) + + const relativeScrollTop = Math.max( + 0, + Math.min(sectionHeight, this.state.scrollTop - offset) + ) + + return ( + + ) + } + + private getRowOffsetInSection(indexPath: RowIndexPath) { + if (typeof this.props.rowHeight === 'number') { + return indexPath.row * this.props.rowHeight + } + + let offset = 0 + for (let i = 0; i < indexPath.row; i++) { + offset += this.props.rowHeight({ index: indexPath }) + } + return offset + } + + private getSectionHeight(section: number) { + if (typeof this.props.rowHeight === 'number') { + return this.props.rowCount[section] * this.props.rowHeight + } + + let height = 0 + for (let i = 0; i < this.props.rowCount[section]; i++) { + height += this.props.rowHeight({ index: { section, row: i } }) + } + return height + } + + private get totalHeight() { + return this.props.rowCount.reduce((total, _count, section) => { + return total + this.getSectionHeight(section) + }) + } + + private sectionHeight = ({ index }: Index) => { + return this.getSectionHeight(index) + } + + /** + * Renders the react-virtualized Grid component + * + * @param width - The width of the Grid as given by AutoSizer + * @param height - The height of the Grid as given by AutoSizer + */ + private renderGrid(width: number, height: number) { + // It is possible to send an invalid array such as [-1] to this component, + // if you do, you get weird focus problems. We shouldn't be doing this.. but + // if we do, send a non fatal exception to tell us about it. + const firstSelectedRow = this.props.selectedRows.at(0) + if ( + firstSelectedRow && + rowIndexPathEquals(firstSelectedRow, InvalidRowIndexPath) + ) { + sendNonFatalException( + 'The selected rows of the section-list.tsx contained a negative number.', + new Error( + `Invalid selected rows that contained a negative number passed to SectionList component. This will cause keyboard navigation and focus problems.` + ) + ) + } + + const { selectedRows } = this.props + const activeDescendant = + selectedRows.length && this.state.rowIdPrefix + ? this.getRowId(selectedRows[selectedRows.length - 1]) + : undefined + const containerProps = this.getContainerProps(activeDescendant) + // The currently selected list item is focusable but if there's no focused + // item the list itself needs to be focusable so that you can reach it with + // keyboard navigation and select an item. + const tabIndex = selectedRows.length > 0 ? -1 : 0 + return ( + + + + ) + } + + /** + * Renders a fake scroll container which sits on top of the + * react-virtualized Grid component in order for us to be + * able to have nice looking scrollbars on Windows. + * + * The fake scroll bar synchronizes its position + * + * NB: Should only be used on win32 platforms and needs to + * be coupled with styling that hides scroll bars on Grid + * and accurately positions the fake scroll bar. + * + * @param height The height of the Grid as given by AutoSizer + */ + private renderFakeScroll(height: number) { + return ( +
    +
    +
    + ) + } + + // Set the scroll position of the actual Grid to that + // of the fake scroll bar. This is for mousewheel/touchpad + // scrolling on top of the fake Grid or actual dragging of + // the scroll thumb. + private onFakeScroll = (e: React.UIEvent) => { + // We're getting this event in reaction to the Grid + // having been scrolled and subsequently updating the + // fake scrollTop, ignore it + if (this.lastScroll === 'grid') { + this.lastScroll = null + return + } + + this.lastScroll = 'fake' + + // TODO: calculate scrollTop of the right grid(s)? + + if (this.rootGrid) { + const element = ReactDOM.findDOMNode(this.rootGrid) + if (element instanceof Element) { + element.scrollTop = e.currentTarget.scrollTop + } + } + } + + private onRowMouseDown = ( + row: RowIndexPath, + event: React.MouseEvent + ) => { + if (this.canSelectRow(row)) { + if (this.props.onRowMouseDown) { + this.props.onRowMouseDown(row, event) + } + + // macOS allow emulating a right click by holding down the ctrl key while + // performing a "normal" click. + const isRightClick = + event.button === 2 || + (__DARWIN__ && event.button === 0 && event.ctrlKey) + + // prevent the right-click event from changing the selection if not necessary + if (isRightClick && this.props.selectedRows.includes(row)) { + return + } + + const multiSelectKey = __DARWIN__ ? event.metaKey : event.ctrlKey + + if ( + event.shiftKey && + this.props.selectedRows.length && + this.props.selectionMode && + this.props.selectionMode !== 'single' + ) { + /* + * if [shift] is pressed and selectionMode is different than 'single', + * select all in-between first selection and current row + */ + const selectionOrigin = this.props.selectedRows[0] + + if (this.props.onSelectionChanged) { + const newSelection = createSelectionBetween( + selectionOrigin, + row, + this.props.rowCount + ) + this.props.onSelectionChanged(newSelection, { + kind: 'mouseclick', + event, + }) + } + if ( + this.props.selectionMode === 'range' && + this.props.onSelectedRangeChanged + ) { + this.props.onSelectedRangeChanged(selectionOrigin, row, { + kind: 'mouseclick', + event, + }) + } + } else if (multiSelectKey && this.props.selectionMode === 'multi') { + /* + * if [ctrl] is pressed and selectionMode is 'multi', + * toggle selection of the targeted row + */ + if (this.props.onSelectionChanged) { + let newSelection: ReadonlyArray + if (this.props.selectedRows.includes(row)) { + // remove the ability to deselect the last item + if (this.props.selectedRows.length === 1) { + return + } + newSelection = this.props.selectedRows.filter( + ix => !rowIndexPathEquals(ix, row) + ) + } else { + newSelection = [...this.props.selectedRows, row] + } + + this.props.onSelectionChanged(newSelection, { + kind: 'mouseclick', + event, + }) + } + } else if ( + (this.props.selectionMode === 'range' || + this.props.selectionMode === 'multi') && + this.props.selectedRows.length > 1 && + this.props.selectedRows.includes(row) + ) { + // Do nothing. Multiple rows are already selected. We assume the user is + // pressing down on multiple and may desire to start dragging. We will + // invoke the single selection `onRowMouseUp` if they let go here and no + // special keys are being pressed. + } else if ( + this.props.selectedRows.length !== 1 || + (this.props.selectedRows.length === 1 && + !rowIndexPathEquals(row, this.props.selectedRows[0])) + ) { + /* + * if no special key is pressed, and that the selection is different, + * single selection occurs + */ + this.selectSingleRowAfterMouseEvent(row, event) + } + } + } + + private onRowMouseUp = (row: RowIndexPath, event: React.MouseEvent) => { + if (!this.canSelectRow(row)) { + return + } + + // macOS allow emulating a right click by holding down the ctrl key while + // performing a "normal" click. + const isRightClick = + event.button === 2 || (__DARWIN__ && event.button === 0 && event.ctrlKey) + + // prevent the right-click event from changing the selection if not necessary + if (isRightClick && this.props.selectedRows.includes(row)) { + return + } + + const multiSelectKey = __DARWIN__ ? event.metaKey : event.ctrlKey + + if ( + !event.shiftKey && + !multiSelectKey && + this.props.selectedRows.length > 1 && + this.props.selectedRows.includes(row) && + (this.props.selectionMode === 'range' || + this.props.selectionMode === 'multi') + ) { + // No special keys are depressed and multiple rows were selected. The + // onRowMouseDown event was ignored for this scenario because the user may + // desire to started dragging multiple. However, if they let go, we want a + // new single selection to occur. + this.selectSingleRowAfterMouseEvent(row, event) + } + } + + private selectSingleRowAfterMouseEvent( + row: RowIndexPath, + event: React.MouseEvent + ): void { + if (this.props.onSelectionChanged) { + this.props.onSelectionChanged([row], { kind: 'mouseclick', event }) + } + + if (this.props.onSelectedRangeChanged) { + this.props.onSelectedRangeChanged(row, row, { + kind: 'mouseclick', + event, + }) + } + + if (this.props.onSelectedRowChanged) { + if (!isValidRow(row, this.props.rowCount)) { + log.debug( + `[List.selectSingleRowAfterMouseEvent] unable to onSelectedRowChanged for row '${row}' as it is outside the bounds` + ) + return + } + + this.props.onSelectedRowChanged(row, { kind: 'mouseclick', event }) + } + } + + private onRowClick = (row: RowIndexPath, event: React.MouseEvent) => { + if (this.canSelectRow(row) && this.props.onRowClick) { + if (!isValidRow(row, this.props.rowCount)) { + log.debug( + `[List.onRowClick] unable to onRowClick for row ${row} as it is outside the bounds` + ) + return + } + + this.props.onRowClick(row, { kind: 'mouseclick', event }) + } + } + + private onRowDoubleClick = ( + row: RowIndexPath, + event: React.MouseEvent + ) => { + if (!this.props.onRowDoubleClick) { + return + } + + this.props.onRowDoubleClick(row, { kind: 'mouseclick', event }) + } + + private onScroll = ({ + scrollTop, + clientHeight, + }: { + scrollTop: number + clientHeight: number + }) => { + if (this.props.onScroll) { + this.props.onScroll(scrollTop, clientHeight) + } + + // Set the scroll position of the fake scroll bar to that + // of the actual Grid. This is for mousewheel/touchpad scrolling + // on top of the Grid. + if (__WIN32__ && this.fakeScroll) { + // We're getting this event in reaction to the fake scroll + // having been scrolled and subsequently updating the + // Grid scrollTop, ignore it. + if (this.lastScroll === 'fake') { + this.lastScroll = null + return + } + + this.lastScroll = 'grid' + + this.fakeScroll.scrollTop = scrollTop + } + + this.setState({ scrollTop }) + + // Make sure the root grid re-renders its children + this.rootGrid?.recomputeGridSize() + } + + /** + * Explicitly put keyboard focus on the list or the selected item in the list. + * + * If the list a selected item it will be scrolled (if it's not already + * visible) and it will receive keyboard focus. If the list has no selected + * item the list itself will receive focus. From there keyboard navigation + * can be used to select the first or last items in the list. + * + * This method is a noop if the list has not yet been mounted. + */ + public focus() { + const { selectedRows, rowCount } = this.props + const lastSelectedRow = selectedRows.at(-1) + + if ( + lastSelectedRow !== undefined && + isValidRow(lastSelectedRow, rowCount) + ) { + this.scrollRowToVisible(lastSelectedRow) + } else { + // TODO: decide which grid to focus + // if (this.grid) { + // const element = ReactDOM.findDOMNode(this.grid) as HTMLDivElement + // if (element) { + // element.focus() + // } + // } + } + } +} diff --git a/app/src/ui/lib/list/selection.ts b/app/src/ui/lib/list/selection.ts new file mode 100644 index 0000000000..0c288d7390 --- /dev/null +++ b/app/src/ui/lib/list/selection.ts @@ -0,0 +1,164 @@ +import * as React from 'react' + +export type SelectionDirection = 'up' | 'down' + +interface ISelectRowAction { + /** + * The vertical direction use when searching for a selectable row. + */ + readonly direction: SelectionDirection + + /** + * The starting row index to search from. + */ + readonly row: number + + /** + * A flag to indicate or not to look beyond the last or first + * row (depending on direction) such that given the last row and + * a downward direction will consider the first row as a + * candidate or given the first row and an upward direction + * will consider the last row as a candidate. + * + * Defaults to true if not set. + */ + readonly wrap?: boolean +} + +/** + * Interface describing a user initiated selection change event + * originating from a pointer device clicking or pressing on an item. + */ +export interface IMouseClickSource { + readonly kind: 'mouseclick' + readonly event: React.MouseEvent +} + +/** + * Interface describing a user initiated selection change event + * originating from a pointer device hovering over an item. + * Only applicable when selectedOnHover is set. + */ +export interface IHoverSource { + readonly kind: 'hover' + readonly event: React.MouseEvent +} + +/** + * Interface describing a user initiated selection change event + * originating from a keyboard + */ +export interface IKeyboardSource { + readonly kind: 'keyboard' + readonly event: React.KeyboardEvent +} + +/** + * Interface describing a user initiated selection of all list + * items (usually by clicking the Edit > Select all menu item in + * the application window). This is highly specific to GitHub Desktop + */ +export interface ISelectAllSource { + readonly kind: 'select-all' +} + +/** A type union of possible sources of a selection changed event */ +export type SelectionSource = + | IMouseClickSource + | IHoverSource + | IKeyboardSource + | ISelectAllSource + +/** + * Determine the next selectable row, given the direction and a starting + * row index. Whether a row is selectable or not is determined using + * the `canSelectRow` function, which defaults to true if not provided. + * + * Returns null if no row can be selected or if the only selectable row is + * identical to the given row parameter. + */ +export function findNextSelectableRow( + rowCount: number, + action: ISelectRowAction, + canSelectRow: (row: number) => boolean = row => true +): number | null { + if (rowCount === 0) { + return null + } + + const { direction, row } = action + const wrap = action.wrap ?? true + + // Ensure the row value is in the range between 0 and rowCount - 1 + // + // If the row falls outside this range, use the direction + // given to choose a suitable value: + // + // - move in an upward direction -> select last row + // - move in a downward direction -> select first row + // + let currentRow = + row < 0 || row >= rowCount ? (direction === 'up' ? rowCount - 1 : 0) : row + + // handle specific case from switching from filter text to list + // + // locking currentRow to [0,rowCount) above means that the below loops + // will skip over the first entry + if (direction === 'down' && row === -1) { + currentRow = -1 + } + + const delta = direction === 'up' ? -1 : 1 + + // Iterate through all rows (starting offset from the + // given row and ending on and including the given row) + for (let i = 0; i < rowCount; i++) { + currentRow += delta + + if (currentRow >= rowCount) { + // We've hit rock bottom, wrap around to the top + // if we're allowed to or give up. + if (wrap) { + currentRow = 0 + } else { + break + } + } else if (currentRow < 0) { + // We've reached the top, wrap around to the bottom + // if we're allowed to or give up + if (wrap) { + currentRow = rowCount - 1 + } else { + break + } + } + + if (row !== currentRow && canSelectRow(currentRow)) { + return currentRow + } + } + + return null +} + +/** + * Find the last selectable row in either direction, used + * for moving to the first or last selectable row in a list, + * i.e. Home/End key navigation. + */ +export function findLastSelectableRow( + direction: SelectionDirection, + rowCount: number, + canSelectRow: (row: number) => boolean +) { + let i = direction === 'up' ? 0 : rowCount - 1 + const delta = direction === 'up' ? 1 : -1 + + for (; i >= 0 && i < rowCount; i += delta) { + if (canSelectRow(i)) { + return i + } + } + + return null +} diff --git a/app/src/ui/lib/loading.tsx b/app/src/ui/lib/loading.tsx new file mode 100644 index 0000000000..c903f83cf3 --- /dev/null +++ b/app/src/ui/lib/loading.tsx @@ -0,0 +1,9 @@ +import * as React from 'react' +import { Octicon, syncClockwise } from '../octicons' + +/** A Loading component. */ +export class Loading extends React.Component<{}, {}> { + public render() { + return + } +} diff --git a/app/src/ui/lib/object-id.ts b/app/src/ui/lib/object-id.ts new file mode 100644 index 0000000000..e9db5b784d --- /dev/null +++ b/app/src/ui/lib/object-id.ts @@ -0,0 +1,21 @@ +import { randomBytes } from 'crypto' + +const idMap = new WeakMap() + +/** + * Generates a unique ID for the given object reference. + * + * The same (by reference equality) object will get the same id for the lifetime + * of the application. This is achieved by using a weak reference to the object. + * + */ +export function getObjectId(obj: object): string { + let id = idMap.get(obj) + + if (id === undefined) { + id = randomBytes(8).toString('base64url') + idMap.set(obj, id) + } + + return id +} diff --git a/app/src/ui/lib/observable-ref.ts b/app/src/ui/lib/observable-ref.ts new file mode 100644 index 0000000000..4561056734 --- /dev/null +++ b/app/src/ui/lib/observable-ref.ts @@ -0,0 +1,32 @@ +type RefCallback = (instance: T | null) => void +export type ObservableRef = { + current: T | null + subscribe: (cb: RefCallback) => void + unsubscribe: (cb: RefCallback) => void + (instance: T): void +} + +/** + * Creates an observable React Ref instance. + * + * Observable refs are similar to how refs from React.createRef works with the + * exception that refs created using `createObservableRef` can notify consumers + * when the underlying ref change without having to resort to callback refs. + */ +export function createObservableRef(current?: T): ObservableRef { + const subscribers = new Set>() + + const callback: ObservableRef = Object.assign( + (instance: T | null) => { + callback.current = instance + subscribers.forEach(cb => cb(instance)) + }, + { + current: current ?? null, + subscribe: (cb: RefCallback) => subscribers.add(cb), + unsubscribe: (cb: RefCallback) => subscribers.delete(cb), + } + ) + + return callback +} diff --git a/app/src/ui/lib/open-file.ts b/app/src/ui/lib/open-file.ts new file mode 100644 index 0000000000..738fee01b3 --- /dev/null +++ b/app/src/ui/lib/open-file.ts @@ -0,0 +1,17 @@ +import { shell } from '../../lib/app-shell' +import { Dispatcher } from '../dispatcher' + +export async function openFile( + fullPath: string, + dispatcher: Dispatcher +): Promise { + const result = await shell.openExternal(`file://${fullPath}`) + + if (!result) { + const error = { + name: 'no-external-program', + message: `Unable to open file ${fullPath} in an external program. Please check you have a program associated with this file extension`, + } + await dispatcher.postError(error) + } +} diff --git a/app/src/ui/lib/parse-files-to-be-overwritten.ts b/app/src/ui/lib/parse-files-to-be-overwritten.ts new file mode 100644 index 0000000000..d0c2391cfc --- /dev/null +++ b/app/src/ui/lib/parse-files-to-be-overwritten.ts @@ -0,0 +1,26 @@ +export function parseFilesToBeOverwritten(errorMessage: string) { + const files = new Array() + const lines = errorMessage.split('\n') + + let inFilesList = false + + for (const line of lines) { + if (inFilesList) { + if (!line.startsWith('\t')) { + break + } else { + files.push(line.trimLeft()) + } + } else { + if ( + line.startsWith('error:') && + line.includes('files would be overwritten') && + line.endsWith(':') + ) { + inFilesList = true + } + } + } + + return files +} diff --git a/app/src/ui/lib/password-text-box.tsx b/app/src/ui/lib/password-text-box.tsx new file mode 100644 index 0000000000..e6c4126d81 --- /dev/null +++ b/app/src/ui/lib/password-text-box.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' +import { ITextBoxProps, TextBox } from './text-box' +import { Button } from './button' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' + +interface IPasswordTextBoxState { + /** + * Whether or not the password is currently visible in the underlying input + */ + readonly showPassword: boolean +} + +/** An password input element with app-standard styles and a button for toggling + * the visibility of the user password. */ +export class PasswordTextBox extends React.Component< + ITextBoxProps, + IPasswordTextBoxState +> { + private textBoxRef = React.createRef() + + public constructor(props: ITextBoxProps) { + super(props) + + this.state = { showPassword: false } + } + + private onTogglePasswordVisibility = () => { + this.setState({ showPassword: !this.state.showPassword }) + this.textBoxRef.current!.focus() + } + + public render() { + const buttonIcon = this.state.showPassword + ? OcticonSymbol.eye + : OcticonSymbol.eyeClosed + const type = this.state.showPassword ? 'text' : 'password' + const props: ITextBoxProps = { ...this.props, ...{ type } } + return ( +
    + + +
    + ) + } +} diff --git a/app/src/ui/lib/path-exists.ts b/app/src/ui/lib/path-exists.ts new file mode 100644 index 0000000000..5b195d1877 --- /dev/null +++ b/app/src/ui/lib/path-exists.ts @@ -0,0 +1,9 @@ +import { access } from 'fs/promises' +import { constant } from 'lodash' + +/** + * Returns a value indicating whether or not the provided path exists (as in + * whether it's visible to the current process or not). + */ +export const pathExists = (path: string) => + access(path).then(constant(true), constant(false)) diff --git a/app/src/ui/lib/path-label.tsx b/app/src/ui/lib/path-label.tsx new file mode 100644 index 0000000000..2b47f7eb4f --- /dev/null +++ b/app/src/ui/lib/path-label.tsx @@ -0,0 +1,61 @@ +import * as React from 'react' + +import { AppFileStatus, AppFileStatusKind } from '../../models/status' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { PathText } from './path-text' + +interface IPathLabelProps { + /** the current path of the file */ + readonly path: string + /** the type of change applied to the file */ + readonly status: AppFileStatus + + readonly availableWidth?: number + + /** aria hidden value */ + readonly ariaHidden?: boolean +} + +/** The pixel width reserved to give the resize arrow padding on either side. */ +const ResizeArrowPadding = 10 + +/** + * Render the path details for a given file. + * + * For renames, this will render the old path as well as the current path. + * For other scenarios, only the current path is rendered. + * + */ +export class PathLabel extends React.Component { + public render() { + const props: React.HTMLProps = { + className: 'path-label-component', + } + + const { status } = this.props + + const availableWidth = this.props.availableWidth + if ( + status.kind === AppFileStatusKind.Renamed || + status.kind === AppFileStatusKind.Copied + ) { + const segmentWidth = availableWidth + ? availableWidth / 2 - ResizeArrowPadding + : undefined + return ( + + + + + + ) + } else { + return ( + + + + ) + } + } +} diff --git a/app/src/ui/lib/path-text.tsx b/app/src/ui/lib/path-text.tsx new file mode 100644 index 0000000000..f7cc98018e --- /dev/null +++ b/app/src/ui/lib/path-text.tsx @@ -0,0 +1,503 @@ +import * as React from 'react' +import * as Path from 'path' +import { clamp } from '../../lib/clamp' +import { Tooltip } from './tooltip' +import { createObservableRef } from './observable-ref' + +interface IPathTextProps { + /** + * The file system path which is to be displayed and, if + * necessary, truncated. + */ + readonly path: string + + /** + * An optional maximum width that the path should fit. + * If omitted the available width is calculated at render + * though never updated after the initial measurement. + */ + readonly availableWidth?: number +} + +interface IPathDisplayState { + /** + * The normalized version of the path prop. Normalization in this + * instance refers to formatting of the path in a platform specific + * way, see Path.normalize for more information. + */ + readonly normalizedPath: string + + readonly directoryText: string + readonly fileText: string + + /** + * The current number of characters that the normalizedPath is to + * be truncated to. + */ + readonly length: number +} + +interface IPathTextState extends IPathDisplayState { + /** + * The maximum available width for the path. This corresponds + * to the availableWidth prop if one was specified, if not it's + * calculated at render time. + */ + readonly availableWidth?: number + + /** + * The measured width of the normalized path without any truncation. + * We can use this value to optimize and avoid re-measuring the + * string when the width increases. + */ + readonly fullTextWidth?: number + + /** + * The smallest number of characters that we've tried and found + * to be too wide to fit. + */ + readonly shortestNonFit?: number + + /** + * The highest number of characters that we've tried and found + * to fit inside the available space. + */ + readonly longestFit: number +} + +/** + * Truncates the given string to the number of characters given by + * the length parameter. The value is truncated (if necessary) by + * removing characters from the middle of the string and inserting + * an ellipsis in their place until the value fits within the alloted + * number of characters. + */ +export function truncateMid(value: string, length: number) { + if (value.length <= length) { + return value + } + + if (length <= 0) { + return '' + } + + if (length === 1) { + return '…' + } + + const mid = (length - 1) / 2 + const pre = value.substring(0, Math.floor(mid)) + const post = value.substring(value.length - Math.ceil(mid)) + + return `${pre}…${post}` +} + +/** + * String truncation for paths. + * + * This method takes a path and returns it truncated (if necessary) + * to the exact number of characters specified by the length + * parameter. + */ +export function truncatePath(path: string, length: number) { + if (path.length <= length) { + return path + } + + if (length <= 0) { + return '' + } + + if (length === 1) { + return '…' + } + + const lastSeparator = path.lastIndexOf(Path.sep) + + // No directory prefix, fall back to middle ellipsis + if (lastSeparator === -1) { + return truncateMid(path, length) + } + + const filenameLength = path.length - lastSeparator - 1 + + // File name prefixed with …/ would be too long, fall back + // to middle ellipsis. + if (filenameLength + 2 > length) { + return truncateMid(path, length) + } + + const pre = path.substring(0, length - filenameLength - 2) + const post = path.substring(lastSeparator) + + return `${pre}…${post}` +} + +/** + * Extract the filename and directory from a given normalized path + * + * @param normalizedPath The normalized path (i.e. no '.' or '..' characters in path) + */ +export function extract(normalizedPath: string): { + normalizedFileName: string + normalizedDirectory: string +} { + // for untracked submodules the status entry is returned as a path with a + // trailing path separator which causes the directory to be trimmed in a weird + // way below. let's try to resolve this here + normalizedPath = normalizedPath.endsWith(Path.sep) + ? normalizedPath.substring(0, normalizedPath.length - 1) + : normalizedPath + + const normalizedFileName = Path.basename(normalizedPath) + const normalizedDirectory = normalizedPath.substring( + 0, + normalizedPath.length - normalizedFileName.length + ) + + return { normalizedFileName, normalizedDirectory } +} + +function createPathDisplayState( + normalizedPath: string, + length?: number +): IPathDisplayState { + length = length === undefined ? normalizedPath.length : length + + if (length <= 0) { + return { normalizedPath, directoryText: '', fileText: '', length } + } + + const { normalizedFileName, normalizedDirectory } = extract(normalizedPath) + + // Happy path when it already fits, we already know the length of the directory + if (length >= normalizedPath.length) { + return { + normalizedPath, + directoryText: normalizedDirectory, + fileText: normalizedFileName, + length, + } + } + + const truncatedPath = truncatePath(normalizedPath, length) + let directoryLength = 0 + + // Attempt to determine how much of the truncated path is the directory prefix + // vs the filename (basename). It does so by comparing each character in the + // normalized directory prefix to the truncated path, as long as it's a match + // we know that it's a directory name. + for ( + let i = 0; + i < truncatedPath.length && i < normalizedDirectory.length; + i++ + ) { + const normalizedChar = normalizedDirectory[i] + const truncatedChar = truncatedPath[i] + + if (normalizedChar === truncatedChar) { + directoryLength++ + } else { + // We're no longer matching the directory prefix but if the following + // characters is '…' or '…/' we'll count those towards the directory + // as well, this is purely an aesthetic choice. + if (truncatedChar === '…') { + directoryLength++ + const nextTruncatedIx = i + 1 + + // Do we have one more character to read? Is is a path separator? + if (truncatedPath.length > nextTruncatedIx) { + if (truncatedPath[nextTruncatedIx] === Path.sep) { + directoryLength++ + } + } + } + break + } + } + + const fileText = truncatedPath.substring(directoryLength) + const directoryText = truncatedPath.substring(0, directoryLength) + + return { normalizedPath, directoryText, fileText, length } +} + +function createState(path: string, length?: number): IPathTextState { + const normalizedPath = Path.normalize(path) + return { + longestFit: 0, + shortestNonFit: undefined, + availableWidth: undefined, + fullTextWidth: undefined, + ...createPathDisplayState(normalizedPath, length), + } +} + +/** + * A component for displaying a path (rooted or relative) with truncation + * if necessary. + * + * If the path needs to be truncated this component will set its title element + * to the full path such that it can be seen by hovering the path text. + */ +export class PathText extends React.PureComponent< + IPathTextProps, + IPathTextState +> { + private pathElementRef = createObservableRef() + private pathInnerElement: HTMLSpanElement | null = null + + public constructor(props: IPathTextProps) { + super(props) + this.state = createState(props.path) + } + + public componentWillReceiveProps(nextProps: IPathTextProps) { + if (nextProps.path !== this.props.path) { + this.setState(createState(nextProps.path)) + } + } + + public componentDidMount() { + this.resizeIfNecessary() + document.addEventListener('dialog-show', this.onDialogShow) + } + + public componentWillUnmount() { + document.removeEventListener('dialog-show', this.onDialogShow) + } + + public componentDidUpdate() { + this.resizeIfNecessary() + } + + // In case this component is contained within a , make sure to resize + // it after the dialog element is shown in order to apply correct layout. + // https://github.com/desktop/desktop/issues/6666 + private onDialogShow = (event: Event) => { + const dialogElement = event.target + if ( + dialogElement instanceof Element && + dialogElement.contains(this.pathElementRef.current) + ) { + this.resizeIfNecessary() + } + } + + private onPathInnerElementRef = (element: HTMLSpanElement | null) => { + this.pathInnerElement = element + } + + public render() { + const directoryElement = + this.state.directoryText && this.state.directoryText.length ? ( + {this.state.directoryText} + ) : null + + const truncated = this.state.length < this.state.normalizedPath.length + + return ( +
    + + {directoryElement} + {this.state.fileText} + + {truncated && ( + + {this.state.normalizedPath} + + )} +
    + ) + } + + private resizeIfNecessary() { + if (!this.pathElementRef.current || !this.pathInnerElement) { + return + } + + const computedAvailableWidth = + this.props.availableWidth !== undefined + ? this.props.availableWidth + : this.pathElementRef.current.getBoundingClientRect().width + + const availableWidth = Math.max(computedAvailableWidth, 0) + + // Can we fit the entire path in the available width? + if ( + this.state.fullTextWidth !== undefined && + this.state.fullTextWidth <= availableWidth + ) { + // Are we already doing so? + if (this.state.length === this.state.normalizedPath.length) { + // Yeay, happy path, we're already displaying the full path and it + // fits in our new available width. Nothing left to do. + // + // This conditional update isn't strictly necessary but it'll save + // us one round of comparisons in the PureComponent shallowCompare + if (availableWidth !== this.state.availableWidth) { + this.setState({ ...this.state, availableWidth }) + } + + return + } else { + // We _can_ fit the entire path inside the available width but we're + // not doing so right now. Let's make sure we do by keeping all the + // state properties and updating the availableWidth and setting length + // to the maximum number of characters available. + this.setState({ + ...this.state, + ...createPathDisplayState(this.state.normalizedPath), + availableWidth, + }) + + return + } + } + + // The available width has changed from underneath us + if ( + this.state.availableWidth !== undefined && + this.state.availableWidth !== availableWidth + ) { + // Keep the current length as that's likely a good starting point + const resetState = createState(this.props.path, this.state.length) + + if (availableWidth < this.state.availableWidth) { + // We've gotten less space to work with so we can keep our shortest non-fit since + // that's still valid + this.setState({ + ...resetState, + fullTextWidth: this.state.fullTextWidth, + shortestNonFit: this.state.shortestNonFit, + availableWidth, + }) + } else if (availableWidth > this.state.availableWidth) { + // We've gotten more space to work with so we can keep our longest fit since + // that's still valid. + this.setState({ + ...resetState, + fullTextWidth: this.state.fullTextWidth, + longestFit: this.state.longestFit, + availableWidth, + }) + } + + return + } + + // Optimization, if we know that we can't fit anything we can avoid a reflow + // by not measuring the actual width. + if (availableWidth === 0) { + if (this.state.length !== 0) { + this.setState({ + ...this.state, + ...createPathDisplayState(this.state.normalizedPath, 0), + availableWidth, + longestFit: 0, + shortestNonFit: 1, + }) + } + return + } + + const actualWidth = this.pathInnerElement.getBoundingClientRect().width + + // Did we just measure the full path? If so let's persist it in state, if + // not we'll just take what we've got (could be nothing) and persist that + const fullTextWidth = + this.state.length === this.state.normalizedPath.length + ? actualWidth + : this.state.fullTextWidth + + // We shouldn't get into this state but if we do, guard against division by zero + // and use a normal binary search ratio. + const ratio = actualWidth === 0 ? 0.5 : availableWidth / actualWidth + + // It fits! + if (actualWidth <= availableWidth) { + // We're done, the entire path fits + if (this.state.length === this.state.normalizedPath.length) { + this.setState({ ...this.state, availableWidth, fullTextWidth }) + return + } else { + // There might be more space to fill + const longestFit = this.state.length + const maxChars = + this.state.shortestNonFit !== undefined + ? this.state.shortestNonFit - 1 + : this.state.normalizedPath.length + + const minChars = longestFit + 1 + + // We've run out of options, it fits here but we can't grow any further, i.e + // we're done. + if (minChars >= maxChars) { + this.setState({ + ...this.state, + longestFit, + availableWidth, + fullTextWidth, + }) + return + } + + // Optimization, if our available space for growth is less than 3px it's unlikely + // that we'll be able to fit any more characters in here. Note that this is very + // much an optimization for our specific styles and it also assumes that the + // directory and file name spans are styled similarly. Another approach could be + // to calculated the average width of a character when we're measuring the full + // width and use that instead but this works pretty well for now and lets us + // avoid one more measure phase. + if (availableWidth - actualWidth < 3) { + this.setState({ + ...this.state, + longestFit, + availableWidth, + fullTextWidth, + }) + return + } + + const length = clamp( + Math.floor(this.state.length * ratio), + minChars, + maxChars + ) + + // We could potentially fit more characters, there's room to try so we'll go for it + this.setState({ + ...this.state, + ...createPathDisplayState(this.state.normalizedPath, length), + longestFit, + availableWidth, + fullTextWidth, + }) + } + } else { + // Okay, so it didn't quite fit, let's trim it down a little + const shortestNonFit = this.state.length + + const maxChars = shortestNonFit - 1 + const minChars = this.state.longestFit || 0 + + const length = clamp( + Math.floor(this.state.length * ratio), + minChars, + maxChars + ) + + this.setState({ + ...this.state, + ...createPathDisplayState(this.state.normalizedPath, length), + shortestNonFit, + availableWidth, + fullTextWidth, + }) + } + } +} diff --git a/app/src/ui/lib/popover-dropdown.tsx b/app/src/ui/lib/popover-dropdown.tsx new file mode 100644 index 0000000000..1750d25f50 --- /dev/null +++ b/app/src/ui/lib/popover-dropdown.tsx @@ -0,0 +1,104 @@ +import * as React from 'react' +import { Button } from './button' +import { Popover, PopoverAnchorPosition, PopoverDecoration } from './popover' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import classNames from 'classnames' + +const maxPopoverContentHeight = 500 + +interface IPopoverDropdownProps { + readonly className?: string + readonly contentTitle: string + readonly buttonContent: JSX.Element | string + readonly label: string +} + +interface IPopoverDropdownState { + readonly showPopover: boolean +} + +/** + * A dropdown component for displaying a dropdown button that opens + * a popover to display contents relative to the button content. + */ +export class PopoverDropdown extends React.Component< + IPopoverDropdownProps, + IPopoverDropdownState +> { + private invokeButtonRef: HTMLButtonElement | null = null + + public constructor(props: IPopoverDropdownProps) { + super(props) + + this.state = { + showPopover: false, + } + } + + private onInvokeButtonRef = (buttonRef: HTMLButtonElement | null) => { + this.invokeButtonRef = buttonRef + } + + private togglePopover = () => { + this.setState({ showPopover: !this.state.showPopover }) + } + + public closePopover = () => { + this.setState({ showPopover: false }) + } + + private renderPopover() { + if (!this.state.showPopover) { + return + } + + const { contentTitle } = this.props + + return ( + +
    +
    + {contentTitle} + + +
    +
    {this.props.children}
    +
    +
    + ) + } + + public render() { + const { className, buttonContent, label } = this.props + const cn = classNames('popover-dropdown-component', className) + + return ( +
    + + {this.renderPopover()} +
    + ) + } +} diff --git a/app/src/ui/lib/popover.tsx b/app/src/ui/lib/popover.tsx new file mode 100644 index 0000000000..026a2cbb1b --- /dev/null +++ b/app/src/ui/lib/popover.tsx @@ -0,0 +1,406 @@ +import * as React from 'react' +import FocusTrap from 'focus-trap-react' +import { Options as FocusTrapOptions } from 'focus-trap' +import classNames from 'classnames' +import { + ComputePositionReturn, + autoUpdate, + computePosition, +} from '@floating-ui/react-dom' +import { + arrow, + flip, + offset, + Placement, + shift, + Side, + size, +} from '@floating-ui/core' +import { assertNever } from '../../lib/fatal-error' +import { isMacOSVentura } from '../../lib/get-os' + +/** + * Position of the popover relative to its anchor element. It's composed by 2 + * dimensions: + * - The first one is the edge of the anchor element from which the popover will + * be displayed. + * - The second one is the alignment of the popover within that edge. + * + * Example: BottomRight means the popover will be in the bottom edge of the + * anchor element, on its right side. + **/ +export enum PopoverAnchorPosition { + Top = 'top', + TopRight = 'top-right', + TopLeft = 'top-left', + Left = 'left', + LeftTop = 'left-top', + LeftBottom = 'left-bottom', + Bottom = 'bottom', + BottomLeft = 'bottom-left', + BottomRight = 'bottom-right', + Right = 'right', + RightTop = 'right-top', + RightBottom = 'right-bottom', +} + +export enum PopoverAppearEffect { + Shake = 'shake', +} + +export enum PopoverDecoration { + None = 'none', + Balloon = 'balloon', +} + +const TipSize = 8 +const TipCornerPadding = TipSize +const ScreenBorderPadding = 10 + +interface IPopoverProps { + readonly onClickOutside?: (event?: MouseEvent) => void + readonly onMousedownOutside?: (event?: MouseEvent) => void + /** Element to anchor the popover to */ + readonly anchor: HTMLElement | null + /** The position of the popover relative to the anchor. */ + readonly anchorPosition: PopoverAnchorPosition + /** + * The position of the tip or pointer of the popover relative to the side at + * which the tip is presented. Optional. Default: Center + */ + readonly className?: string + readonly style?: React.CSSProperties + readonly appearEffect?: PopoverAppearEffect + readonly ariaLabelledby?: string + readonly trapFocus?: boolean // Default: true + readonly decoration?: PopoverDecoration // Default: none + + /** Maximum height decided by clients of Popover */ + readonly maxHeight?: number + /** Minimum height decided by clients of Popover */ + readonly minHeight?: number +} + +interface IPopoverState { + readonly position: ComputePositionReturn | null +} + +export class Popover extends React.Component { + private focusTrapOptions: FocusTrapOptions + private containerDivRef = React.createRef() + private contentDivRef = React.createRef() + private tipDivRef = React.createRef() + private floatingCleanUp: (() => void) | null = null + + public constructor(props: IPopoverProps) { + super(props) + + this.focusTrapOptions = { + allowOutsideClick: true, + escapeDeactivates: true, + onDeactivate: this.props.onClickOutside, + } + + this.state = { position: null } + } + + private async setupPosition() { + this.floatingCleanUp?.() + this.floatingCleanUp = null + + const { anchor } = this.props + + if ( + anchor === null || + anchor === undefined || + this.containerDivRef.current === null + ) { + return + } + + this.floatingCleanUp = autoUpdate( + anchor, + this.containerDivRef.current, + this.updatePosition + ) + } + + private updatePosition = async () => { + const { anchor, decoration, maxHeight } = this.props + const containerDiv = this.containerDivRef.current + const contentDiv = this.contentDivRef.current + + if ( + anchor === null || + anchor === undefined || + containerDiv === null || + contentDiv === null + ) { + return + } + + const tipDiv = this.tipDivRef.current + + const middleware = [ + offset(decoration === PopoverDecoration.Balloon ? TipSize : 0), + shift({ padding: ScreenBorderPadding }), + flip({ padding: ScreenBorderPadding }), + size({ + apply({ availableHeight, availableWidth }) { + Object.assign(contentDiv.style, { + maxHeight: + maxHeight === undefined + ? `${availableHeight}px` + : `${Math.min(availableHeight, maxHeight)}px`, + maxWidth: `${availableWidth}px`, + }) + }, + padding: ScreenBorderPadding, + }), + ] + + if (decoration === PopoverDecoration.Balloon && tipDiv) { + middleware.push(arrow({ element: tipDiv, padding: TipCornerPadding })) + } + + const position = await computePosition(anchor, containerDiv, { + strategy: 'fixed', + placement: this.getFloatingPlacementForAnchorPosition(), + middleware, + }) + + this.setState({ position }) + } + + public componentDidMount() { + document.addEventListener('click', this.onDocumentClick) + document.addEventListener('mousedown', this.onDocumentMouseDown) + this.setupPosition() + } + + public componentDidUpdate(prevProps: IPopoverProps) { + if (prevProps.anchor !== this.props.anchor) { + this.setupPosition() + } + } + + public componentWillUnmount() { + document.removeEventListener('click', this.onDocumentClick) + document.removeEventListener('mousedown', this.onDocumentMouseDown) + } + + private onDocumentClick = (event: MouseEvent) => { + const ref = this.containerDivRef.current + const { target } = event + + if ( + ref !== null && + ref.parentElement !== null && + target instanceof Node && + !ref.parentElement.contains(target) && + this.props.onClickOutside !== undefined + ) { + this.props.onClickOutside(event) + } + } + + private onDocumentMouseDown = (event: MouseEvent) => { + const ref = this.containerDivRef.current + const { target } = event + + if ( + ref !== null && + ref.parentElement !== null && + target instanceof Node && + !ref.parentElement.contains(target) && + this.props.onMousedownOutside !== undefined + ) { + this.props.onMousedownOutside(event) + } + } + + /** + * Gets the aria-labelledby or aria-describedby attribute + * + * The correct semantics are that a dialog element (which this is) should have + * an aria-labelledby for it's title. + * + * However, macOs Ventura introduced a regression in that the aria-labelledby + * is not announced and if provided prevents the aria-describedby from being + * announced. Thus, this method will use aria-describedby instead of the + * aria-labelledby for macOs Ventura. This is not semantically correct tho, + * hopefully, macOs will be fixed in a future release. The issue is known for + * macOS versions 13.0 to the current version of 13.5 as of 2023-07-31. + */ + private getAriaAttributes() { + if (!isMacOSVentura()) { + return { + 'aria-labelledby': this.props.ariaLabelledby, + } + } + + return { + 'aria-describedby': this.props.ariaLabelledby, + } + } + + public render() { + const { + trapFocus, + className, + appearEffect, + children, + decoration, + maxHeight, + minHeight, + } = this.props + const cn = classNames( + decoration === PopoverDecoration.Balloon && 'popover-component', + className, + appearEffect && `appear-${appearEffect}` + ) + + const { position } = this.state + // Make sure the popover *always* has at least `position: fixed` set, otherwise + // it can cause weird layout glitches. + const style: React.CSSProperties = { + position: 'fixed', + zIndex: 17, // same as --foldout-z-index + height: 'auto', + } + const contentStyle: React.CSSProperties = { + overflow: 'hidden', + width: '100%', + } + let tipStyle: React.CSSProperties = {} + + if (position) { + style.top = position.y === undefined ? undefined : `${position.y}px` + style.left = position.x === undefined ? undefined : `${position.x}px` + contentStyle.minHeight = + minHeight === undefined ? undefined : `${minHeight}px` + contentStyle.height = + maxHeight === undefined ? undefined : `${maxHeight}px` + + const arrow = position.middlewareData.arrow + + if (arrow) { + const side: Side = position.placement.split('-')[0] as Side + + const staticSide = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[side] + + const angle = { + top: '270deg', + right: '0deg', + bottom: '90deg', + left: '180deg', + }[side] + + tipStyle = { + top: arrow.y, + left: arrow.x, + transform: `rotate(${angle})`, + [staticSide]: this.tipDivRef.current + ? `${-this.tipDivRef.current.offsetWidth}px` + : undefined, + } + } + } + + const content = ( +
    +
    + {children} +
    + {decoration === PopoverDecoration.Balloon && ( +
    +
    +
    +
    + )} +
    + ) + + return trapFocus !== false ? ( + {content} + ) : ( + content + ) + } + + private getFloatingPlacementForAnchorPosition(): Placement { + const { anchorPosition } = this.props + switch (anchorPosition) { + case PopoverAnchorPosition.Top: + return 'top' + case PopoverAnchorPosition.TopLeft: + return 'top-start' + case PopoverAnchorPosition.TopRight: + return 'top-end' + case PopoverAnchorPosition.Left: + return 'left' + case PopoverAnchorPosition.LeftTop: + return 'left-start' + case PopoverAnchorPosition.LeftBottom: + return 'left-end' + case PopoverAnchorPosition.Right: + return 'right' + case PopoverAnchorPosition.RightTop: + return 'right-start' + case PopoverAnchorPosition.RightBottom: + return 'right-end' + case PopoverAnchorPosition.Bottom: + return 'bottom' + case PopoverAnchorPosition.BottomLeft: + return 'bottom-start' + case PopoverAnchorPosition.BottomRight: + return 'bottom-end' + default: + assertNever(anchorPosition, 'Unknown anchor position') + } + } +} diff --git a/app/src/ui/lib/radio-button.tsx b/app/src/ui/lib/radio-button.tsx new file mode 100644 index 0000000000..13d7eea1f8 --- /dev/null +++ b/app/src/ui/lib/radio-button.tsx @@ -0,0 +1,80 @@ +import * as React from 'react' +import { createUniqueId, releaseUniqueId } from './id-pool' + +interface IRadioButtonProps { + /** + * Called when the user selects this radio button. + * + * The function will be called with the value of the RadioButton + * and the original event that triggered the change. + */ + readonly onSelected: ( + value: T, + event: React.FormEvent + ) => void + + /** + * Whether the radio button is selected. + */ + readonly checked: boolean + + /** + * The label of the radio button. If not provided, the children are used + */ + readonly label?: string | JSX.Element + + /** + * The value of the radio button. + */ + readonly value: T + + /** Optional: The tab index of the radio button */ + readonly tabIndex?: number + + /** Whether the textarea field should auto focus when mounted. */ + readonly autoFocus?: boolean +} + +interface IRadioButtonState { + readonly inputId: string +} + +export class RadioButton extends React.Component< + IRadioButtonProps, + IRadioButtonState +> { + public constructor(props: IRadioButtonProps) { + super(props) + + this.state = { + inputId: createUniqueId(`RadioButton_${this.props.value}`), + } + } + + public componentWillUnmount() { + releaseUniqueId(this.state.inputId) + } + + public render() { + return ( +
    + + +
    + ) + } + + private onSelected = (evt: React.FormEvent) => { + this.props.onSelected(this.props.value, evt) + } +} diff --git a/app/src/ui/lib/radio-group.tsx b/app/src/ui/lib/radio-group.tsx new file mode 100644 index 0000000000..d80a32c9ba --- /dev/null +++ b/app/src/ui/lib/radio-group.tsx @@ -0,0 +1,74 @@ +import * as React from 'react' +import { RadioButton } from './radio-button' + +interface IRadioGroupProps { + /** The id of the element that serves as the menu's accessibility label */ + readonly ariaLabelledBy?: string + + /** + * The currently selected item, denoted by its key. + */ + readonly selectedKey: T + + /** + * The keys of the radio buttons to display, in order of the radio buttons. + */ + readonly radioButtonKeys: ReadonlyArray + + /** Optional class for radio group*/ + readonly className?: string + + /** + * A function that's called whenever the selected item changes, either + * as a result of a click using a pointer device or as a result of the user + * hitting an up/down while the component has focus. + * + * The key argument corresponds to the key property of the selected item. + */ + readonly onSelectionChanged: (key: T) => void + + /** Render radio button label contents */ + readonly renderRadioButtonLabelContents: (key: T) => JSX.Element +} + +/** + * A component for presenting a small number of choices to the user. + */ +export class RadioGroup extends React.Component< + IRadioGroupProps +> { + private onSelectionChanged = (key: T) => { + this.props.onSelectionChanged(key) + } + + private renderRadioButtons() { + const { radioButtonKeys, selectedKey } = this.props + + return radioButtonKeys.map(key => { + const checked = selectedKey === key + return ( + + key={key} + checked={checked} + value={key} + onSelected={this.onSelectionChanged} + tabIndex={checked ? 0 : -1} + > + {this.props.renderRadioButtonLabelContents(key)} + + ) + }) + } + + public render() { + return ( +
    + {this.renderRadioButtons()} +
    + ) + } +} diff --git a/app/src/ui/lib/rect.ts b/app/src/ui/lib/rect.ts new file mode 100644 index 0000000000..7192f3fea2 --- /dev/null +++ b/app/src/ui/lib/rect.ts @@ -0,0 +1,38 @@ +/** + * Returns a value indicating whether the two supplied client + * rect instances are structurally equal (i.e. all their individual + * values are equal). + */ +export function rectEquals(x: ClientRect, y: ClientRect) { + if (x === y) { + return true + } + + return ( + x.left === y.left && + x.right === y.right && + x.top === y.top && + x.bottom === y.bottom && + x.width === y.width && + x.height === y.height + ) +} + +/** + * Returns true if `y` is entirely contained within `x` + */ +export function rectContains(x: ClientRect, y: ClientRect) { + if (x === y) { + return true + } + + return ( + y.top >= x.top && + y.left >= x.left && + y.bottom <= x.bottom && + y.right <= x.right + ) +} + +export const offsetRect = (rect: DOMRect, x: number, y: number) => + new DOMRect(rect.x + x, rect.y + y, rect.width, rect.height) diff --git a/app/src/ui/lib/ref-name-text-box.tsx b/app/src/ui/lib/ref-name-text-box.tsx new file mode 100644 index 0000000000..7d5909e602 --- /dev/null +++ b/app/src/ui/lib/ref-name-text-box.tsx @@ -0,0 +1,179 @@ +import * as React from 'react' + +import { sanitizedRefName } from '../../lib/sanitize-ref-name' +import { TextBox } from './text-box' +import { Octicon } from '../octicons' +import * as OcticonSymbol from '../octicons/octicons.generated' +import { Ref } from './ref' + +interface IRefNameProps { + /** + * The initial value for the ref name. + * + * Note that updates to this prop will be ignored. + */ + readonly initialValue?: string + + /** + * The label of the text box. + */ + readonly label?: string | JSX.Element + + /** + * The aria-describedby attribute for the text box. + */ + readonly ariaDescribedBy?: string + + /** + * Called when the user changes the ref name. + * + * A sanitized value for the ref name is passed. + */ + readonly onValueChange?: (sanitizedValue: string) => void + + /** + * Called when the user-entered ref name is not valid. + * + * This gives the opportunity to the caller to specify + * a custom warning message explaining that the sanitized + * value will be used instead. + */ + readonly renderWarningMessage?: ( + sanitizedValue: string, + proposedValue: string + ) => JSX.Element | string + + /** + * Callback used when the component loses focus. + * + * A sanitized value for the ref name is passed. + */ + readonly onBlur?: (sanitizedValue: string) => void +} + +interface IRefNameState { + readonly proposedValue: string + readonly sanitizedValue: string +} + +export class RefNameTextBox extends React.Component< + IRefNameProps, + IRefNameState +> { + private textBoxRef = React.createRef() + + public constructor(props: IRefNameProps) { + super(props) + + const proposedValue = props.initialValue || '' + + this.state = { + proposedValue, + sanitizedValue: sanitizedRefName(proposedValue), + } + } + + public componentDidMount() { + if ( + this.state.sanitizedValue !== this.props.initialValue && + this.props.onValueChange !== undefined + ) { + this.props.onValueChange(this.state.sanitizedValue) + } + } + + public render() { + return ( +
    + + + {this.renderRefValueWarning()} +
    + ) + } + + /** + * Programmatically moves keyboard focus to the inner text input element if it can be focused + * (i.e. if it's not disabled explicitly or implicitly through for example a fieldset). + */ + public focus() { + if (this.textBoxRef.current !== null) { + this.textBoxRef.current.focus() + } + } + + private onValueChange = (proposedValue: string) => { + const sanitizedValue = sanitizedRefName(proposedValue) + const previousSanitizedValue = this.state.sanitizedValue + + this.setState({ proposedValue, sanitizedValue }) + + if (sanitizedValue === previousSanitizedValue) { + return + } + + if (this.props.onValueChange === undefined) { + return + } + + this.props.onValueChange(sanitizedValue) + } + + private onBlur = (proposedValue: string) => { + if (this.props.onBlur !== undefined) { + // It's possible (although rare) that we receive the onBlur + // event before the sanitized value has been committed to the + // state so we need to use the value received from the onBlur + // event instead of the one stored in state. + this.props.onBlur(sanitizedRefName(proposedValue)) + } + } + + private renderRefValueWarning() { + const { proposedValue, sanitizedValue } = this.state + + if (proposedValue === sanitizedValue) { + return null + } + + const renderWarningMessage = + this.props.renderWarningMessage ?? this.defaultRenderWarningMessage + + return ( +
    + + +

    {renderWarningMessage(sanitizedValue, proposedValue)}

    +
    + ) + } + + private defaultRenderWarningMessage( + sanitizedValue: string, + proposedValue: string + ) { + // If the proposed value ends up being sanitized as + // an empty string we show a message saying that the + // proposed value is invalid. + if (sanitizedValue.length === 0) { + return ( + <> + {proposedValue} is not a valid name. + + ) + } + + return ( + <> + Will be created as {sanitizedValue}. + + ) + } +} diff --git a/app/src/ui/lib/ref.tsx b/app/src/ui/lib/ref.tsx new file mode 100644 index 0000000000..9b178e5e16 --- /dev/null +++ b/app/src/ui/lib/ref.tsx @@ -0,0 +1,16 @@ +import * as React from 'react' + +/** + * A simple style component used to mark up arbitrary references such as + * branches, commit SHAs, paths or other content which needs to be presented + * in an emphasized way and that benefit from fixed-width fonts. + * + * While the styling of the component _may_ differs depending on what context + * it appears in the general style is an inline-box with a suitable background + * color, using a fixed-width font. + */ +export class Ref extends React.Component<{}, {}> { + public render() { + return {this.props.children} + } +} diff --git a/app/src/ui/lib/releases.ts b/app/src/ui/lib/releases.ts new file mode 100644 index 0000000000..958ccad372 --- /dev/null +++ b/app/src/ui/lib/releases.ts @@ -0,0 +1,4 @@ +export const ReleaseNotesUri = + __RELEASE_CHANNEL__ === 'beta' + ? 'https://desktop.github.com/release-notes/?env=beta' + : 'https://desktop.github.com/release-notes/' diff --git a/app/src/ui/lib/rich-text.tsx b/app/src/ui/lib/rich-text.tsx new file mode 100644 index 0000000000..59a7f05292 --- /dev/null +++ b/app/src/ui/lib/rich-text.tsx @@ -0,0 +1,151 @@ +import * as React from 'react' + +import { LinkButton } from './link-button' +import { Repository } from '../../models/repository' +import { Tokenizer, TokenType, TokenResult } from '../../lib/text-token-parser' +import { assertNever } from '../../lib/fatal-error' +import memoizeOne from 'memoize-one' +import { createObservableRef } from './observable-ref' +import { Tooltip } from './tooltip' + +interface IRichTextProps { + readonly className?: string + + /** A lookup of emoji characters to map to image resources */ + readonly emoji: Map + + /** + * The raw text to inspect for things to highlight or an array + * of tokens already compiled by the `Tokenizer` class in + * `text-token-parser.ts`. If a string is provided the component + * will call upon the Tokenizer to product a list of tokens. + */ + readonly text: string | ReadonlyArray + + /** Should URLs be rendered as clickable links. Default true. */ + readonly renderUrlsAsLinks?: boolean + + /** + * The repository to use as the source for URLs for the rich text. + * + * If not specified, or the repository is a non-GitHub repository, + * no link highlighting is performed. + */ + readonly repository?: Repository +} + +function getElements( + emoji: Map, + repository: Repository | undefined, + renderUrlsAsLinks: boolean | undefined, + text: string | ReadonlyArray +) { + const tokenizer = new Tokenizer(emoji, repository) + const tokens = typeof text === 'string' ? tokenizer.tokenize(text) : text + + return tokens.map((token, index) => { + switch (token.kind) { + case TokenType.Emoji: + return ( + {token.text} + ) + case TokenType.Link: + if (renderUrlsAsLinks !== false) { + const title = token.text !== token.url ? token.url : undefined + return ( + + {token.text} + + ) + } else { + return {token.text} + } + case TokenType.Text: + return {token.text} + default: + return assertNever(token, `Unknown token type: ${token}`) + } + }) +} + +interface IRichTextState { + readonly overflowed: boolean +} + +/** + * A component which replaces any emoji shortcuts (e.g., :+1:) in its child text + * with the appropriate image tag, and also highlights username and issue mentions + * with hyperlink tags if it has a repository to read. + */ +export class RichText extends React.Component { + private getElements = memoizeOne(getElements) + private getTitle = memoizeOne((text: string | ReadonlyArray) => + typeof text === 'string' ? text : text.map(x => x.text).join('') + ) + private containerRef = createObservableRef() + private readonly resizeObserver: ResizeObserver + private resizeDebounceId: number | null = null + private lastKnownWidth: number | null = null + + public constructor(props: IRichTextProps) { + super(props) + this.state = { overflowed: false } + this.containerRef.subscribe(this.onContainerRef) + this.resizeObserver = new ResizeObserver(entries => { + const newWidth = entries[0].contentRect.width + + if (this.lastKnownWidth !== newWidth) { + this.lastKnownWidth = newWidth + + if (this.resizeDebounceId !== null) { + cancelAnimationFrame(this.resizeDebounceId) + this.resizeDebounceId = null + } + this.resizeDebounceId = requestAnimationFrame(_ => this.onResized()) + } + }) + } + + private onContainerRef = (elem: HTMLDivElement | null) => { + if (elem === null) { + this.resizeObserver.disconnect() + return + } + + this.resizeObserver.observe(elem) + this.onResized(elem) + } + + private onResized = (elem?: HTMLDivElement) => { + elem = elem ?? this.containerRef.current ?? undefined + if (elem && elem.scrollWidth > elem.clientWidth) { + this.setState({ overflowed: true }) + } else { + this.setState({ overflowed: false }) + } + } + + public render() { + const { emoji, repository, renderUrlsAsLinks, text } = this.props + + // If we've been given an empty string then return null so that we don't end + // up introducing an extra empty . + if (text.length === 0) { + return null + } + + return ( +
    + {this.state.overflowed && ( + {this.getTitle(text)} + )} + {this.getElements(emoji, repository, renderUrlsAsLinks, text)} +
    + ) + } +} diff --git a/app/src/ui/lib/round.ts b/app/src/ui/lib/round.ts new file mode 100644 index 0000000000..24c9989ad6 --- /dev/null +++ b/app/src/ui/lib/round.ts @@ -0,0 +1,23 @@ +/** + * Round a number to the desired number of decimals. + * + * This differs from toFixed in that it toFixed returns a + * string which will always contain exactly two decimals even + * though the number might be an integer. + * + * See https://stackoverflow.com/a/11832950/2114 + * + * @param value The number to round to the number of + * decimals specified + * @param decimals The number of decimals to round to. Ex: + * 2: 1234.56789 => 1234.57 + * 3: 1234.56789 => 1234.568 + */ +export function round(value: number, decimals: number) { + if (decimals <= 0) { + return Math.round(value) + } + + const factor = Math.pow(10, decimals) + return Math.round((value + Number.EPSILON) * factor) / factor +} diff --git a/app/src/ui/lib/row.tsx b/app/src/ui/lib/row.tsx new file mode 100644 index 0000000000..76b26ea17b --- /dev/null +++ b/app/src/ui/lib/row.tsx @@ -0,0 +1,26 @@ +import * as React from 'react' +import classNames from 'classnames' + +interface IRowProps { + /** The id of the internal element */ + readonly id?: string + + /** The class name for the internal element. */ + readonly className?: string +} + +/** + * A horizontal row element with app-standard styles. + * + * Provide `children` elements for the contents of this row. + */ +export class Row extends React.Component { + public render() { + const className = classNames('row-component', this.props.className) + return ( +
    + {this.props.children} +
    + ) + } +} diff --git a/app/src/ui/lib/sandboxed-markdown.tsx b/app/src/ui/lib/sandboxed-markdown.tsx new file mode 100644 index 0000000000..3519735313 --- /dev/null +++ b/app/src/ui/lib/sandboxed-markdown.tsx @@ -0,0 +1,387 @@ +import * as React from 'react' +import * as Path from 'path' +import { MarkdownContext } from '../../lib/markdown-filters/node-filter' +import { GitHubRepository } from '../../models/github-repository' +import { readFile } from 'fs/promises' +import { Tooltip } from './tooltip' +import { createObservableRef } from './observable-ref' +import { getObjectId } from './object-id' +import { debounce } from 'lodash' +import { + MarkdownEmitter, + parseMarkdown, +} from '../../lib/markdown-filters/markdown-filter' + +interface ISandboxedMarkdownProps { + /** A string of unparsed markdown to display */ + readonly markdown: string | MarkdownEmitter + + /** The baseHref of the markdown content for when the markdown has relative links */ + readonly baseHref?: string + + /** + * A callback with the url of a link clicked in the parsed markdown + * + * Note: On a markdown link click, this component attempts to parse the link + * href as a url and verifies it to be https. If the href fails those tests, + * this will not fire. + */ + readonly onMarkdownLinkClicked?: (url: string) => void + + /** A callback for after the markdown has been parsed and the contents have + * been mounted to the iframe */ + readonly onMarkdownParsed?: () => void + + /** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */ + readonly emoji: Map + + /** The GitHub repository for some markdown filters such as issue and commits. */ + readonly repository?: GitHubRepository + + /** The context of which markdown resides - such as PullRequest, PullRequestComment, Commit */ + readonly markdownContext?: MarkdownContext +} + +interface ISandboxedMarkdownState { + readonly tooltipElements: ReadonlyArray + readonly tooltipOffset?: DOMRect +} + +/** + * Parses and sanitizes markdown into html and outputs it inside a sandboxed + * iframe. + **/ +export class SandboxedMarkdown extends React.PureComponent< + ISandboxedMarkdownProps, + ISandboxedMarkdownState +> { + private frameRef: HTMLIFrameElement | null = null + private frameContainingDivRef: HTMLDivElement | null = null + private contentDivRef: HTMLDivElement | null = null + private markdownEmitter?: MarkdownEmitter + + /** + * Resize observer used for tracking height changes in the markdown + * content and update the size of the iframe container. + */ + private readonly resizeObserver: ResizeObserver + private resizeDebounceId: number | null = null + + private onDocumentScroll = debounce(() => { + this.setState({ + tooltipOffset: this.frameRef?.getBoundingClientRect() ?? new DOMRect(), + }) + }, 100) + + /** + * We debounce the markdown updating because it is updated on each custom + * markdown filter. Leading is true so that users will at a minimum see the + * markdown parsed by markedjs while the custom filters are being applied. + * (So instead of being updated, 10+ times it is updated 1 or 2 times.) + */ + private onMarkdownUpdated = debounce( + markdown => this.mountIframeContents(markdown), + 10, + { leading: true } + ) + + public constructor(props: ISandboxedMarkdownProps) { + super(props) + + this.resizeObserver = new ResizeObserver(this.scheduleResizeEvent) + this.state = { tooltipElements: [] } + } + + private scheduleResizeEvent = () => { + if (this.resizeDebounceId !== null) { + cancelAnimationFrame(this.resizeDebounceId) + this.resizeDebounceId = null + } + this.resizeDebounceId = requestAnimationFrame(this.onContentResized) + } + + private onContentResized = () => { + if (this.frameRef === null) { + return + } + + this.setFrameContainerHeight(this.frameRef) + } + + private onFrameRef = (frameRef: HTMLIFrameElement | null) => { + this.frameRef = frameRef + } + + private onFrameContainingDivRef = ( + frameContainingDivRef: HTMLIFrameElement | null + ) => { + this.frameContainingDivRef = frameContainingDivRef + } + + private initializeMarkdownEmitter = () => { + if (this.markdownEmitter !== undefined) { + this.markdownEmitter.dispose() + } + const { emoji, repository, markdownContext } = this.props + this.markdownEmitter = + typeof this.props.markdown !== 'string' + ? this.props.markdown + : parseMarkdown(this.props.markdown, { + emoji, + repository, + markdownContext, + }) + + this.markdownEmitter.onMarkdownUpdated((markdown: string) => { + this.onMarkdownUpdated(markdown) + }) + } + + public async componentDidMount() { + this.initializeMarkdownEmitter() + + if (this.frameRef !== null) { + this.setupFrameLoadListeners(this.frameRef) + } + + document.addEventListener('scroll', this.onDocumentScroll, { + capture: true, + }) + } + + public async componentDidUpdate(prevProps: ISandboxedMarkdownProps) { + // rerender iframe contents if provided markdown changes + if (prevProps.markdown !== this.props.markdown) { + this.initializeMarkdownEmitter() + } + } + + public componentWillUnmount() { + this.markdownEmitter?.dispose() + this.resizeObserver.disconnect() + document.removeEventListener('scroll', this.onDocumentScroll) + } + + /** + * Since iframe styles are isolated from the rest of the app, we have a + * markdown.css file that we added to app/static directory that we can read in + * and provide to the iframe. + * + * Additionally, the iframe will not be aware of light/dark theme variables, + * thus we will scrape the subset of them needed for the markdown css from the + * document body and provide them aswell. + */ + private async getInlineStyleSheet(): Promise { + const css = await readFile( + Path.join(__dirname, 'static', 'markdown.css'), + 'utf8' + ) + + // scrape theme variables so iframe theme will match app + const docStyle = getComputedStyle(document.body) + + function scrapeVariable(variableName: string): string { + return `${variableName}: ${docStyle.getPropertyValue(variableName)};` + } + + return `` + } + + /** + * We still want to be able to navigate to links provided in the markdown. + * However, we want to intercept them an verify they are valid links first. + */ + private setupFrameLoadListeners(frameRef: HTMLIFrameElement): void { + frameRef.addEventListener('load', () => { + this.setupContentDivRef(frameRef) + this.setupLinkInterceptor(frameRef) + this.setupTooltips(frameRef) + this.setFrameContainerHeight(frameRef) + }) + } + + private setupTooltips(frameRef: HTMLIFrameElement) { + if (frameRef.contentDocument === null) { + return + } + + const tooltipElements = new Array() + + for (const e of frameRef.contentDocument.querySelectorAll('[aria-label]')) { + if (frameRef.contentWindow?.HTMLElement) { + if (e instanceof frameRef.contentWindow.HTMLElement) { + tooltipElements.push(e) + } + } + } + + this.setState({ + tooltipElements, + tooltipOffset: frameRef.getBoundingClientRect(), + }) + } + + private setupContentDivRef(frameRef: HTMLIFrameElement): void { + if (frameRef.contentDocument === null) { + return + } + + /* + * We added an additional wrapper div#content around the markdown to + * determine a more accurate scroll height as the iframe's document or body + * element was not adjusting it's height dynamically when new content was + * provided. + */ + this.contentDivRef = frameRef.contentDocument.documentElement.querySelector( + '#content' + ) as HTMLDivElement + + if (this.contentDivRef !== null) { + this.resizeObserver.disconnect() + this.resizeObserver.observe(this.contentDivRef) + } + } + + /** + * Iframes without much styling help will act like a block element that has a + * predetermiend height and width and scrolling. We want our iframe to feel a + * bit more like a div. Thus, we want to capture the scroll height, and set + * the container div to that height and with some additional css we can + * achieve a inline feel. + */ + private setFrameContainerHeight(frameRef: HTMLIFrameElement): void { + if ( + frameRef.contentDocument === null || + this.frameContainingDivRef === null || + this.contentDivRef === null + ) { + return + } + + // Not sure why the content height != body height exactly. But we need to + // set the height explicitly to prevent scrollbar/content cut off. + const divHeight = this.contentDivRef.clientHeight + this.frameContainingDivRef.style.height = `${divHeight}px` + this.props.onMarkdownParsed?.() + } + + /** + * We still want to be able to navigate to links provided in the markdown. + * However, we want to intercept them an verify they are valid links first. + */ + private setupLinkInterceptor(frameRef: HTMLIFrameElement): void { + frameRef.contentDocument?.addEventListener('click', ev => { + const { contentWindow } = frameRef + + if (contentWindow && ev.target instanceof contentWindow.Element) { + const a = ev.target.closest('a') + if (a !== null) { + ev.preventDefault() + + if (/^https?:/.test(a.protocol)) { + this.props.onMarkdownLinkClicked?.(a.href) + } + } + } + }) + } + + /** + * Builds a tag for cases where markdown has relative links + */ + private getBaseTag(baseHref?: string): string { + if (baseHref === undefined) { + return '' + } + + const base = document.createElement('base') + base.href = baseHref + return base.outerHTML + } + + /** + * Populates the mounted iframe with HTML generated from the provided markdown + */ + private async mountIframeContents(markdown: string) { + if (this.frameRef === null) { + return + } + + const styleSheet = await this.getInlineStyleSheet() + + const src = ` + + + ${this.getBaseTag(this.props.baseHref)} + ${styleSheet} + + +
    + ${markdown} +
    + + + ` + + // We used this `Buffer.toString('base64')` approach because `btoa` could not + // convert non-latin strings that existed in the markedjs. + const b64src = Buffer.from(src, 'utf8').toString('base64') + + if (this.frameRef === null) { + // If frame is destroyed before markdown parsing completes, frameref will be null. + return + } + + // We are using `src` and data uri as opposed to an html string in the + // `srcdoc` property because the `srcdoc` property renders the html in the + // parent dom and we want all rendering to be isolated to our sandboxed iframe. + // -- https://csplite.com/csp/test188/ + this.frameRef.src = `data:text/html;charset=utf-8;base64,${b64src}` + } + + public render() { + const { tooltipElements, tooltipOffset } = this.state + + return ( +
    +