diff --git a/.eslintrc.yml b/.eslintrc.yml index 615c247693e..d339201eff3 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -113,6 +113,7 @@ rules: react/jsx-uses-vars: error react/jsx-uses-react: error react/no-unused-state: error + # niik 2023-12-05: turning this off to not muddy up the TS5 upgrade. react/no-unused-prop-types: error react/prop-types: - error @@ -215,6 +216,9 @@ overrides: - files: 'script/**/*' rules: '@typescript-eslint/no-non-null-assertion': off + - files: 'app/src/ui/octicons/octicons.generated.ts' + rules: + '@typescript-eslint/naming-convention': off parserOptions: sourceType: module diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d10ac1b2c53..12b8b11eae9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -66,7 +66,7 @@ comment to the existing issue if there is extra information you can contribute. Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/). -Simply create an issue on the [GitHub Desktop issue tracker](https://github.com/desktop/desktop/issues/new?template=bug_report.md) +Simply create an issue on the [GitHub Desktop issue tracker](https://github.com/desktop/desktop/issues/new?template=bug_report.yaml) and fill out the provided issue template. The information we are interested in includes: @@ -87,7 +87,7 @@ community understand your suggestion :pencil: and find related suggestions Before creating enhancement suggestions, please check [this list](#before-submitting-an-enhancement-suggestion) as you might find out that you don't need to create one. When you are creating an enhancement suggestion, please [include as many details as possible](#how-do-i-submit-a-good-enhancement-suggestion). -Fill in [the template](ISSUE_TEMPLATE/problem-to-raise.md), including the steps +Fill in [the template](ISSUE_TEMPLATE/feature_request.yaml), including the steps that you imagine you would take if the feature you're requesting existed. #### Before Submitting An Enhancement Suggestion @@ -101,7 +101,7 @@ information you would like to add. Enhancement suggestions are tracked as [GitHub issues](https://guides.github.com/features/issues/). -Simply create an issue on the [GitHub Desktop issue tracker](https://github.com/desktop/desktop/issues/new?template=feature_request.md) +Simply create an issue on the [GitHub Desktop issue tracker](https://github.com/desktop/desktop/issues/new?template=feature_request.yaml) and fill out the provided issue template. Some additional advice: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ca79ca5b4d5..e22a9bd70ab 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,5 @@ updates: directory: / schedule: interval: weekly + # Disable version updates and keep only security updates + open-pull-requests-limit: 0 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f99b109d580..c5fcc02d167 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,6 @@ Closes #[issue number] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e113903455..55addcedfd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,14 @@ on: environment: type: string required: true + sign: + type: boolean + default: true + required: false secrets: + AZURE_CODE_SIGNING_TENANT_ID: + AZURE_CODE_SIGNING_CLIENT_ID: + AZURE_CODE_SIGNING_CLIENT_SECRET: DESKTOP_OAUTH_CLIENT_ID: DESKTOP_OAUTH_CLIENT_SECRET: APPLE_ID: @@ -29,8 +36,9 @@ on: APPLE_TEAM_ID: APPLE_APPLICATION_CERT: APPLE_APPLICATION_CERT_PASSWORD: - WINDOWS_CERT_PFX: - WINDOWS_CERT_PASSWORD: + +env: + NODE_VERSION: 20.17.0 jobs: lint: @@ -44,9 +52,9 @@ jobs: repository: ${{ inputs.repository || github.repository }} ref: ${{ inputs.ref }} submodules: recursive - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: - node-version: 16.17.1 + node-version: ${{ env.NODE_VERSION }} cache: yarn - run: yarn - run: yarn validate-electron-version @@ -62,7 +70,6 @@ jobs: strategy: fail-fast: false matrix: - node: [18.16.1] os: [macos-13-xl-arm64, windows-2019] arch: [x64, arm64] include: @@ -71,6 +78,7 @@ jobs: - os: windows-2019 friendlyName: Windows timeout-minutes: 60 + environment: ${{ inputs.environment }} env: RELEASE_CHANNEL: ${{ inputs.environment }} steps: @@ -79,21 +87,14 @@ jobs: repository: ${{ inputs.repository || github.repository }} ref: ${{ inputs.ref }} submodules: recursive - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Use Node.js ${{ matrix.node }} - uses: actions/setup-node@v3 + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node }} + node-version: ${{ env.NODE_VERSION }} 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: @@ -123,19 +124,24 @@ jobs: - 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: Install Azure Code Signing Client + if: ${{ runner.os == 'Windows' && inputs.sign }} + run: | + $acsZip = Join-Path $env:RUNNER_TEMP "acs.zip" + $acsDir = Join-Path $env:RUNNER_TEMP "acs" + Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/1.0.52 -OutFile $acsZip -Verbose + Expand-Archive $acsZip -Destination $acsDir -Force -Verbose + # Replace ancient signtool in electron-winstall with one that supports ACS + Copy-Item -Path "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\*" -Include signtool.exe,signtool.exe.manifest,Microsoft.Windows.Build.Signing.mssign32.dll.manifest,mssign32.dll,Microsoft.Windows.Build.Signing.wintrust.dll.manifest,wintrust.dll,Microsoft.Windows.Build.Appx.AppxSip.dll.manifest,AppxSip.dll,Microsoft.Windows.Build.Appx.AppxPackaging.dll.manifest,AppxPackaging.dll,Microsoft.Windows.Build.Appx.OpcServices.dll.manifest,OpcServices.dll -Destination "node_modules\electron-winstaller\vendor" -Verbose - name: Package production app run: yarn package env: npm_config_arch: ${{ matrix.arch }} - WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_CODE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CODE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CODE_SIGNING_CLIENT_SECRET }} - name: Upload artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ inputs.upload-artifacts }} with: name: ${{matrix.friendlyName}}-${{matrix.arch}} diff --git a/.github/workflows/close-invalid.yml b/.github/workflows/close-invalid.yml new file mode 100644 index 00000000000..c1fc8564a59 --- /dev/null +++ b/.github/workflows/close-invalid.yml @@ -0,0 +1,36 @@ +name: Close issue/PR on adding invalid label + +# **What it does**: This action closes issues and PRs that are labeled as invalid in the Desktop repo. + +on: + issues: + types: [labeled] + # Needed in lieu of `pull_request` so that PRs from a fork can be + # closed when marked as invalid. + pull_request_target: + types: [labeled] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + close-on-adding-invalid-label: + if: + github.repository == 'desktop/desktop' && github.event.label.name == + 'invalid' + runs-on: ubuntu-latest + + steps: + - name: Close issue + if: ${{ github.event_name == 'issues' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh issue close ${{ github.event.issue.html_url }} + + - name: Close PR + if: ${{ github.event_name == 'pull_request_target' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr close ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/close-single-word-issues.yml b/.github/workflows/close-single-word-issues.yml new file mode 100644 index 00000000000..f2ef0dae8e3 --- /dev/null +++ b/.github/workflows/close-single-word-issues.yml @@ -0,0 +1,44 @@ +name: Close Single-Word Issues + +on: + issues: + types: + - opened + +permissions: + issues: write + +jobs: + close-issue: + runs-on: ubuntu-latest + + steps: + - name: Close Single-Word Issue + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const issueTitle = context.payload.issue.title.trim(); + const isSingleWord = /^\S+$/.test(issueTitle); + + if (isSingleWord) { + const issueNumber = context.payload.issue.number; + const repo = context.repo.repo; + + // Close the issue and add the invalid label + github.rest.issues.update({ + owner: context.repo.owner, + repo: repo, + issue_number: issueNumber, + labels: ['invalid'], + state: 'closed' + }); + + // Comment on the issue + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: repo, + issue_number: issueNumber, + body: `This issue may have been opened accidentally. I'm going to close it now, but feel free to open a new issue with a more descriptive title.` + }); + } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3be1aff3bfc..6b78b39f3f4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,7 +22,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: config-file: ./.github/codeql/codeql-config.yml @@ -32,7 +32,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below). - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -46,4 +46,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/on-issue-close.yml b/.github/workflows/on-issue-close.yml new file mode 100644 index 00000000000..e768226d088 --- /dev/null +++ b/.github/workflows/on-issue-close.yml @@ -0,0 +1,17 @@ +name: Remove triage tab from closed issues +on: + issues: + types: + - closed +jobs: + label_issues: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - run: gh issue edit "$NUMBER" --remove-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: triage diff --git a/.github/workflows/prs-waiting-for-review.yml b/.github/workflows/prs-waiting-for-review.yml new file mode 100644 index 00000000000..9cc133aea88 --- /dev/null +++ b/.github/workflows/prs-waiting-for-review.yml @@ -0,0 +1,28 @@ +name: Triage new pull requests + +# **What it does**: Adds triage label to new pull requests in the open source repository. +# **Why we have it**: Update project board for new pull requests for triage. +# **Who does it impact**: Docs open source. + +on: + # Needed in lieu of `pull_request` so that PRs from a fork can be triaged. + pull_request_target: + types: + - reopened + - opened + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + triage_pulls: + runs-on: ubuntu-latest + steps: + - run: gh issue edit "$NUMBER" --add-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: waiting for review diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 960641b4c47..f03f1f074f3 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -37,7 +37,7 @@ jobs: private_key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }} - name: Create Release Pull Request - uses: peter-evans/create-pull-request@v5.0.2 + uses: peter-evans/create-pull-request@v6.0.5 if: | startsWith(github.ref, 'refs/heads/releases/') && !contains(github.ref, 'test') with: diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml new file mode 100644 index 00000000000..677e1a8ea86 --- /dev/null +++ b/.github/workflows/triage-issues.yml @@ -0,0 +1,18 @@ +name: Label incoming issues +on: + issues: + types: + - reopened + - opened +jobs: + label_issues: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - run: gh issue edit "$NUMBER" --add-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: triage diff --git a/.gitignore b/.gitignore index 30a0037ef0b..91bdd56682f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ coverage/ npm-debug.log yarn-error.log app/node_modules/ +vendor/windows-argv-parser/build/ .DS_Store .awcache .idea/ diff --git a/.node-version b/.node-version index 3876fd49864..3516580bbbc 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -18.16.1 +20.17.0 diff --git a/.nvmrc b/.nvmrc index 5e0828ad15c..016e34baf16 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18.16.1 +v20.17.0 diff --git a/.tool-versions b/.tool-versions index 35dde7c8767..5f93b9a8287 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ python 3.9.5 -nodejs 18.16.1 +nodejs 20.17.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index 952fd9a02c7..9c1caea584f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,7 +27,7 @@ "editor.formatOnSave": true, "prettier.ignorePath": ".prettierignore", "eslint.options": { - "configFile": ".eslintrc.yml", + "overrideConfigFile": ".eslintrc.yml", "rulePaths": ["eslint-rules"] }, "eslint.validate": [ diff --git a/README.md b/README.md index 006b2c5f32d..27a7e9d105f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # [GitHub Desktop](https://desktop.github.com) -[GitHub Desktop](https://desktop.github.com/) is an open source [Electron](https://www.electronjs.org/)-based +[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/). @@ -39,6 +39,9 @@ beta channel to get access to early builds of Desktop: The release notes for the latest beta versions are available [here](https://desktop.github.com/release-notes/?env=beta). +### Past Releases +You can find past releases at https://desktop.githubusercontent.com. After installation of a past version, the auto update functionality will attempt to download the latest version. + ### Community Releases There are several community-supported package managers that can be used to @@ -90,7 +93,7 @@ To setup your development environment for building Desktop, check out: [`setup.m See [desktop.github.com](https://desktop.github.com) for more product-oriented information about GitHub Desktop. -See our [getting started documentation](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/overview/getting-started-with-github-desktop) for more information on how to set up, authenticate, and configure GitHub Desktop. +See our [getting started documentation](https://docs.github.com/en/desktop/overview/getting-started-with-github-desktop) for more information on how to set up, authenticate, and configure GitHub Desktop. ## License diff --git a/app/.npmrc b/app/.npmrc index 104a19e8335..94caaa92c37 100644 --- a/app/.npmrc +++ b/app/.npmrc @@ -1,3 +1,3 @@ runtime = electron disturl = https://electronjs.org/headers -target = 26.2.4 +target = 32.1.2 diff --git a/app/jest.unit.config.js b/app/jest.unit.config.js index 13d8d2482d0..f7d69ec2c99 100644 --- a/app/jest.unit.config.js +++ b/app/jest.unit.config.js @@ -2,8 +2,9 @@ module.exports = { roots: ['/src/', '/test/'], transform: { '^.+\\.tsx?$': 'ts-jest', - '\\.m?jsx?$': 'jest-esm-transformer', + '\\.m?jsx?$': '/test/esm-transformer.js', }, + resolver: `/test/resolver.js`, testMatch: ['**/unit/**/*-test.ts{,x}'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], setupFiles: ['/test/globals.ts', '/test/unit-test-env.ts'], @@ -11,4 +12,5 @@ module.exports = { reporters: ['default', '../script/jest-actions-reporter.js'], // For now, @github Node modules required to be transformed by jest-esm-transformer transformIgnorePatterns: ['node_modules/(?!(@github))'], + testEnvironment: 'jsdom', } diff --git a/app/package.json b/app/package.json index d1ce62bd0cb..e46180f6b0a 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "productName": "GitHub Desktop", "bundleID": "com.github.GitHubClient", "companyName": "GitHub, Inc.", - "version": "3.3.5", + "version": "3.4.10-beta1", "main": "./main.js", "repository": { "type": "git", @@ -23,15 +23,17 @@ "byline": "^5.0.0", "chalk": "^2.3.0", "classnames": "^2.2.5", - "codemirror": "^5.60.0", + "codemirror": "^5.65.17", "codemirror-mode-elixir": "^1.1.2", + "codemirror-mode-luau": "^1.0.2", + "codemirror-mode-zig": "^1.0.7", "compare-versions": "^3.6.0", "deep-equal": "^1.0.1", "desktop-notifications": "^0.2.4", - "desktop-trampoline": "desktop/desktop-trampoline#v0.9.8", + "desktop-trampoline": "desktop/desktop-trampoline#v0.9.10", "dexie": "^3.2.2", - "dompurify": "^2.3.3", - "dugite": "^2.5.0", + "dompurify": "^2.5.4", + "dugite": "3.0.0-rc5", "electron-window-state": "^5.0.3", "event-kit": "^2.0.0", "focus-trap-react": "^8.1.0", @@ -54,15 +56,17 @@ "react-dom": "^16.8.4", "react-transition-group": "^4.4.1", "react-virtualized": "^9.20.0", - "registry-js": "^1.15.0", + "registry-js": "^1.16.0", "source-map-support": "^0.4.15", + "split2": "^4.2.0", + "string-argv": "^0.3.2", "strip-ansi": "^4.0.0", "textarea-caret": "^3.0.2", "triple-beam": "^1.3.0", "tslib": "^2.0.0", "untildify": "^3.0.2", - "username": "^5.1.0", "uuid": "^3.0.1", + "windows-argv-parser": "file:../vendor/windows-argv-parser", "winston": "^3.6.0" }, "devDependencies": { diff --git a/app/src/crash/crash-app.tsx b/app/src/crash/crash-app.tsx index 1d2c861a921..4f1e4a30333 100644 --- a/app/src/crash/crash-app.tsx +++ b/app/src/crash/crash-app.tsx @@ -4,7 +4,7 @@ import { TitleBar } from '../ui/window/title-bar' import { encodePathAsUrl } from '../lib/path' import { WindowState } from '../lib/window-state' import { Octicon } from '../ui/octicons' -import * as OcticonSymbol from '../ui/octicons/octicons.generated' +import * as octicons from '../ui/octicons/octicons.generated' import { Button } from '../ui/lib/button' import { LinkButton } from '../ui/lib/link-button' import { getVersion } from '../ui/lib/app-proxy' @@ -141,7 +141,7 @@ export class CrashApp extends React.Component { return (
- +

{message}

) diff --git a/app/src/highlighter/index.ts b/app/src/highlighter/index.ts index fd9f22ce09e..72b75de21ef 100644 --- a/app/src/highlighter/index.ts +++ b/app/src/highlighter/index.ts @@ -104,6 +104,7 @@ const extensionModes: ReadonlyArray = [ mappings: { '.markdown': 'text/x-markdown', '.md': 'text/x-markdown', + '.mdx': 'text/x-markdown', }, }, { @@ -118,6 +119,7 @@ const extensionModes: ReadonlyArray = [ mappings: { '.xml': 'text/xml', '.xaml': 'text/xml', + '.xsd': 'text/xml', '.csproj': 'text/xml', '.fsproj': 'text/xml', '.vcxproj': 'text/xml', @@ -149,6 +151,9 @@ const extensionModes: ReadonlyArray = [ '.cpp': 'text/x-c++src', '.hpp': 'text/x-c++src', '.cc': 'text/x-c++src', + '.hh': 'text/x-c++src', + '.hxx': 'text/x-c++src', + '.cxx': 'text/x-c++src', '.ino': 'text/x-c++src', '.kt': 'text/x-kotlin', }, @@ -208,6 +213,8 @@ const extensionModes: ReadonlyArray = [ install: () => import('codemirror/mode/python/python'), mappings: { '.py': 'text/x-python', + '.pyi': 'text/x-python', + '.vpy': 'text/x-python', }, }, { @@ -270,9 +277,10 @@ const extensionModes: ReadonlyArray = [ }, }, { - install: () => import('codemirror/mode/lua/lua'), + install: () => import('codemirror-mode-luau'), mappings: { '.lua': 'text/x-lua', + '.luau': 'text/x-luau', }, }, { @@ -425,6 +433,18 @@ const extensionModes: ReadonlyArray = [ '.dart': 'application/dart', }, }, + { + install: () => import('codemirror-mode-zig'), + mappings: { + '.zig': 'text/x-zig', + }, + }, + { + install: () => import('codemirror/mode/cmake/cmake'), + mappings: { + '.cmake': 'text/x-cmake', + }, + }, ] /** @@ -445,6 +465,12 @@ const basenameModes: ReadonlyArray = [ dockerfile: 'text/x-dockerfile', }, }, + { + install: () => import('codemirror/mode/toml/toml'), + mappings: { + 'cargo.lock': 'text/x-toml', + }, + }, ] /** @@ -588,8 +614,8 @@ function readToken( throw new Error(`Mode ${getModeName(mode)} failed to advance stream.`) } -onmessage = async (ev: MessageEvent) => { - const request = ev.data as IHighlightRequest +onmessage = async (ev: MessageEvent) => { + const request = ev.data const tabSize = request.tabSize || 4 const addModeClass = request.addModeClass === true diff --git a/app/src/lib/2fa.ts b/app/src/lib/2fa.ts deleted file mode 100644 index 4e289590e66..00000000000 --- a/app/src/lib/2fa.ts +++ /dev/null @@ -1,26 +0,0 @@ -const authenticatorAppWelcomeText = - 'Open the two-factor authentication app on your device to view your authentication code and verify your identity.' -const smsMessageWelcomeText = - 'We just sent you a message via SMS with your authentication code. Enter the code in the form below to verify your identity.' - -/** - * When authentication is requested via 2FA, the endpoint provides - * a hint in the response header as to where the user should look - * to retrieve the token. - */ -export enum AuthenticationMode { - /* - * User should authenticate via a received text message. - */ - Sms, - /* - * User should open TOTP mobile application and obtain code. - */ - App, -} - -export function getWelcomeMessage(type: AuthenticationMode): string { - return type === AuthenticationMode.Sms - ? smsMessageWelcomeText - : authenticatorAppWelcomeText -} diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 65c658f7752..d6f38944169 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -1,4 +1,3 @@ -import * as OS from 'os' import * as URL from 'url' import { Account } from '../models/account' @@ -8,13 +7,20 @@ import { HTTPMethod, APIError, urlWithQueryString, + getUserAgent, } from './http' -import { AuthenticationMode } from './2fa' import { uuid } from './uuid' -import username from 'username' import { GitProtocol } from './remote-parsing' -import { Emitter } from 'event-kit' -import { updateEndpointVersion } from './endpoint-capabilities' +import { + getEndpointVersion, + isDotCom, + isGHE, + updateEndpointVersion, +} from './endpoint-capabilities' +import { + clearCertificateErrorSuppressionFor, + suppressCertificateErrorFor, +} from './suppress-certificate-error' const envEndpoint = process.env['DESKTOP_GITHUB_DOTCOM_API_ENDPOINT'] const envHTMLURL = process.env['DESKTOP_GITHUB_DOTCOM_HTML_URL'] @@ -105,9 +111,6 @@ enum HttpStatusCode { NotFound = 404, } -/** The note URL used for authorizations the app creates. */ -const NoteURL = 'https://desktop.github.com/' - /** * Information about a repository as returned by the GitHub API. */ @@ -630,15 +633,6 @@ export interface IAPIComment { 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 @@ -646,11 +640,6 @@ interface IAPIAccessToken { readonly token_type: string } -/** The partial server response when creating a new authorization on behalf of a user */ -interface IAPIAuthorization { - readonly token: string -} - /** The response we receive from fetching mentionables. */ interface IAPIMentionablesResponse { readonly etag: string | undefined @@ -787,20 +776,23 @@ interface IAPIAliveWebSocket { readonly url: string } +type TokenInvalidatedCallback = (endpoint: string, token: string) => void + /** * An object for making authenticated requests to the GitHub API */ export class API { - private static readonly TOKEN_INVALIDATED_EVENT = 'token-invalidated' + private static readonly tokenInvalidatedListeners = + new Set() - private static readonly emitter = new Emitter() - - public static onTokenInvalidated(callback: (endpoint: string) => void) { - API.emitter.on(API.TOKEN_INVALIDATED_EVENT, callback) + public static onTokenInvalidated(callback: TokenInvalidatedCallback) { + this.tokenInvalidatedListeners.add(callback) } - private static emitTokenInvalidated(endpoint: string) { - API.emitter.emit(API.TOKEN_INVALIDATED_EVENT, endpoint) + private static emitTokenInvalidated(endpoint: string, token: string) { + this.tokenInvalidatedListeners.forEach(callback => + callback(endpoint, token) + ) } /** Create a new API client from the given account. */ @@ -1546,6 +1538,23 @@ export class API { }) } + public async getAvatarToken() { + return this.request('GET', `/desktop/avatar-token`) + .then(x => x.json()) + .then((x: unknown) => + x && + typeof x === 'object' && + 'avatar_token' in x && + typeof x.avatar_token === 'string' + ? x.avatar_token + : null + ) + .catch(err => { + log.debug(`Failed to load avatar token`, err) + return null + }) + } + /** * Gets a single check suite using its id */ @@ -1757,7 +1766,7 @@ export class API { response.headers.has('X-GitHub-Request-Id') && !response.headers.has('X-GitHub-OTP') ) { - API.emitTokenInvalidated(this.endpoint) + API.emitTokenInvalidated(this.endpoint, this.token) } tryUpdateEndpointVersionFromResponse(this.endpoint, response) @@ -1851,139 +1860,23 @@ export class API { } } -export enum AuthorizationResponseKind { - Authorized, - Failed, - TwoFactorAuthenticationRequired, - UserRequiresVerification, - PersonalAccessTokenBlocked, - Error, - EnterpriseTooOld, - /** - * The API has indicated that the user is required to go through - * the web authentication flow. - */ - WebFlowRequired, -} - -export type AuthorizationResponse = - | { kind: AuthorizationResponseKind.Authorized; token: string } - | { kind: AuthorizationResponseKind.Failed; response: Response } - | { - kind: AuthorizationResponseKind.TwoFactorAuthenticationRequired - type: AuthenticationMode - } - | { kind: AuthorizationResponseKind.Error; response: Response } - | { kind: AuthorizationResponseKind.UserRequiresVerification } - | { kind: AuthorizationResponseKind.PersonalAccessTokenBlocked } - | { kind: AuthorizationResponseKind.EnterpriseTooOld } - | { kind: AuthorizationResponseKind.WebFlowRequired } - -/** - * Create an authorization with the given login, password, and one-time - * password. - */ -export async function createAuthorization( - endpoint: string, - login: string, - password: string, - oneTimePassword: string | null -): Promise { - const creds = Buffer.from(`${login}:${password}`, 'utf8').toString('base64') - const authorization = `Basic ${creds}` - const optHeader = oneTimePassword ? { 'X-GitHub-OTP': oneTimePassword } : {} - - const note = await getNote() - - const response = await request( - endpoint, - null, - 'POST', - 'authorizations', - { - scopes: oauthScopes, - client_id: ClientID, - client_secret: ClientSecret, - note: note, - note_url: NoteURL, - fingerprint: uuid(), - }, - { - Authorization: authorization, - ...optHeader, - } - ) - - tryUpdateEndpointVersionFromResponse(endpoint, response) - +export async function deleteToken(account: Account) { try { - const result = await parsedResponse(response) - if (result) { - const token = result.token - if (token && typeof token === 'string' && token.length) { - return { kind: AuthorizationResponseKind.Authorized, token } - } - } - } catch (e) { - if (response.status === 401) { - const otpResponse = response.headers.get('x-github-otp') - if (otpResponse) { - const pieces = otpResponse.split(';') - if (pieces.length === 2) { - const type = pieces[1].trim() - switch (type) { - case 'app': - return { - kind: AuthorizationResponseKind.TwoFactorAuthenticationRequired, - type: AuthenticationMode.App, - } - case 'sms': - return { - kind: AuthorizationResponseKind.TwoFactorAuthenticationRequired, - type: AuthenticationMode.Sms, - } - default: - return { kind: AuthorizationResponseKind.Failed, response } - } - } - } - - return { kind: AuthorizationResponseKind.Failed, response } - } + const creds = Buffer.from(`${ClientID}:${ClientSecret}`).toString('base64') + const response = await request( + account.endpoint, + null, + 'DELETE', + `applications/${ClientID}/token`, + { access_token: account.token }, + { Authorization: `Basic ${creds}` } + ) - const apiError = e instanceof APIError && e.apiError - if (apiError) { - if ( - response.status === 403 && - apiError.message === - 'This API can only be accessed with username and password Basic Auth' - ) { - // Authorization API does not support providing personal access tokens - return { kind: AuthorizationResponseKind.PersonalAccessTokenBlocked } - } else if (response.status === 410) { - return { kind: AuthorizationResponseKind.WebFlowRequired } - } else if (response.status === 422) { - if (apiError.errors) { - for (const error of apiError.errors) { - const isExpectedResource = - error.resource.toLowerCase() === 'oauthaccess' - const isExpectedField = error.field.toLowerCase() === 'user' - if (isExpectedField && isExpectedResource) { - return { - kind: AuthorizationResponseKind.UserRequiresVerification, - } - } - } - } else if ( - apiError.message === 'Invalid OAuth application client_id or secret.' - ) { - return { kind: AuthorizationResponseKind.EnterpriseTooOld } - } - } - } + return response.status === 204 + } catch (e) { + log.error(`deleteToken: failed with endpoint ${account.endpoint}`, e) + return false } - - return { kind: AuthorizationResponseKind.Error, response } } /** Fetch the user authenticated by the token. */ @@ -2012,49 +1905,6 @@ export async function fetchUser( } } -/** Get metadata from the server. */ -export async function fetchMetadata( - endpoint: string -): Promise { - const url = `${endpoint}/meta` - - try { - const response = await request(endpoint, null, 'GET', 'meta', undefined, { - 'Content-Type': 'application/json', - }) - - tryUpdateEndpointVersionFromResponse(endpoint, response) - - const result = await parsedResponse(response) - if (!result || result.verifiable_password_authentication === undefined) { - return null - } - - return result - } catch (e) { - log.error( - `fetchMetadata: unable to load metadata from '${url}' as a fallback`, - e - ) - return null - } -} - -/** The note used for created authorizations. */ -async function getNote(): Promise { - let localUsername = await username() - - if (localUsername === undefined) { - localUsername = 'unknown' - - log.error( - `getNote: unable to resolve machine username, using '${localUsername}' as a fallback` - ) - } - - return `GitHub Desktop on ${localUsername}@${OS.hostname()}` -} - /** * Map a repository's URL to the endpoint associated with it. For example: * @@ -2092,6 +1942,18 @@ export function getHTMLURL(endpoint: string): string { if (endpoint === getDotComAPIEndpoint() && !envEndpoint) { return 'https://github.com' } else { + if (isGHE(endpoint)) { + const url = new window.URL(endpoint) + + url.pathname = '/' + + if (url.hostname.startsWith('api.')) { + url.hostname = url.hostname.replace(/^api\./, '') + } + + return url.toString() + } + const parsed = URL.parse(endpoint) return `${parsed.protocol}//${parsed.hostname}` } @@ -2103,6 +1965,15 @@ export function getHTMLURL(endpoint: string): string { * http://github.mycompany.com -> http://github.mycompany.com/api/v3 */ export function getEnterpriseAPIURL(endpoint: string): string { + if (isGHE(endpoint)) { + const url = new window.URL(endpoint) + + url.pathname = '/' + url.hostname = `api.${url.hostname}` + + return url.toString() + } + const parsed = URL.parse(endpoint) return `${parsed.protocol}//${parsed.hostname}/api/v3` } @@ -2134,8 +2005,7 @@ export function getOAuthAuthorizationURL( state: string ): string { const urlBase = getHTMLURL(endpoint) - const scopes = oauthScopes - const scope = encodeURIComponent(scopes.join(' ')) + const scope = encodeURIComponent(oauthScopes.join(' ')) return `${urlBase}/login/oauth/authorize?client_id=${ClientID}&scope=${scope}&state=${state}` } @@ -2175,3 +2045,88 @@ function tryUpdateEndpointVersionFromResponse( updateEndpointVersion(endpoint, gheVersion) } } + +const knownThirdPartyHosts = new Set([ + 'dev.azure.com', + 'gitlab.com', + 'bitbucket.org', + 'amazonaws.com', + 'visualstudio.com', +]) + +const isKnownThirdPartyHost = (hostname: string) => { + if (knownThirdPartyHosts.has(hostname)) { + return true + } + + for (const knownHost of knownThirdPartyHosts) { + if (hostname.endsWith(`.${knownHost}`)) { + return true + } + } + + return false +} + +/** + * Attempts to determine whether or not the url belongs to a GitHub host. + * + * This is a best-effort attempt and may return `undefined` if encountering + * an error making the discovery request + */ +export async function isGitHubHost(url: string) { + const { hostname } = new window.URL(url) + + const endpoint = + hostname === 'github.com' || hostname === 'api.github.com' + ? getDotComAPIEndpoint() + : getEnterpriseAPIURL(url) + + if (isDotCom(endpoint) || isGHE(endpoint)) { + return true + } + + if (isKnownThirdPartyHost(hostname)) { + return false + } + + // github.example.com, + if (/(^|\.)(github)\./.test(hostname)) { + return true + } + + // bitbucket.example.com, etc + if (/(^|\.)(bitbucket|gitlab)\./.test(hostname)) { + return false + } + + if (getEndpointVersion(endpoint) !== null) { + return true + } + + // Add a unique identifier to the URL to make sure our certificate error + // supression only catches this request + const metaUrl = `${endpoint}/meta?ghd=${uuid()}` + + const ac = new AbortController() + const timeoutId = setTimeout(() => ac.abort(), 2000) + suppressCertificateErrorFor(metaUrl) + try { + const response = await fetch(metaUrl, { + headers: { 'user-agent': getUserAgent() }, + signal: ac.signal, + credentials: 'omit', + method: 'HEAD', + }) + + tryUpdateEndpointVersionFromResponse(endpoint, response) + + return response.headers.has('x-github-request-id') + } catch (e) { + log.debug(`isGitHubHost: failed with endpoint ${endpoint}`, e) + return undefined + } finally { + clearTimeout(timeoutId) + clearCertificateErrorSuppressionFor(metaUrl) + } +} diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 9a0bc7c140f..3ec85adc2f8 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -48,6 +48,8 @@ import { IChangesetData } from './git' import { Popup } from '../models/popup' import { RepoRulesInfo } from '../models/repo-rules' import { IAPIRepoRuleset } from './api' +import { ICustomIntegration } from './custom-integration' +import { Emoji } from './emoji' export enum SelectionType { Repository, @@ -160,7 +162,7 @@ export interface IAppState { readonly errorCount: number /** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */ - readonly emoji: Map + readonly emoji: Map /** * The width of the repository sidebar. @@ -185,6 +187,12 @@ export interface IAppState { /** The width of the files list in the pull request files changed view */ readonly pullRequestFilesListWidth: IConstrainedValue + /** The width of the resizable branch drop down button in the toolbar. */ + readonly branchDropdownWidth: IConstrainedValue + + /** The width of the resizable push/pull button in the toolbar. */ + readonly pushPullButtonWidth: IConstrainedValue + /** * Used to highlight access keys throughout the app when the * Alt key is pressed. Only applicable on non-macOS platforms. @@ -200,6 +208,9 @@ export interface IAppState { /** Whether we should ask the user to move the app to /Applications */ readonly askToMoveToApplicationsFolderSetting: boolean + /** Whether we should use an external credential helper for third-party private repositories */ + readonly useExternalCredentialHelper: boolean + /** Whether we should show a confirmation dialog */ readonly askForConfirmationOnRepositoryRemoval: boolean @@ -278,6 +289,9 @@ export interface IAppState { /** The currently applied appearance (aka theme) */ readonly currentTheme: ApplicableTheme + /** The selected tab size preference */ + readonly selectedTabSize: number + /** * A map keyed on a user account (GitHub.com or GitHub Enterprise) * containing an object with repositories that the authenticated @@ -317,6 +331,18 @@ export interface IAppState { */ readonly lastThankYou: ILastThankYou | undefined + /** Whether or not the user wants to use a custom editor. */ + readonly useCustomEditor: boolean + + /** Info needed to launch a custom editor chosen by the user. */ + readonly customEditor: ICustomIntegration | null + + /** Whether or not the user wants to use a custom shell. */ + readonly useCustomShell: boolean + + /** Info needed to launch a custom shell chosen by the user. */ + readonly customShell: ICustomIntegration | null + /** * Whether or not the CI status popover is visible. */ @@ -332,11 +358,16 @@ export interface IAppState { | PullRequestSuggestedNextAction | undefined + /** Whether or not the user will see check marks indicating a line is included in the check in the diff */ + readonly showDiffCheckMarks: boolean + /** * Cached repo rulesets. Used to prevent repeatedly querying the same * rulesets to check their bypass status. */ readonly cachedRepoRulesets: ReadonlyMap + + readonly underlineLinks: boolean } export enum FoldoutType { @@ -356,15 +387,6 @@ export type AppMenuFoldout = { * keyboard navigation by pressing access keys. */ enableAccessKeyNavigation: boolean - - /** - * Whether the menu was opened by pressing Alt (or Alt+X where X is an - * access key for one of the top level menu items). This is used as a - * one-time signal to the AppMenu to use some special semantics for - * selection and focus. Specifically it will ensure that the last opened - * menu will receive focus. - */ - openedWithAccessKey?: boolean } export type BranchFoldout = { diff --git a/app/src/lib/ci-checks/ci-checks.ts b/app/src/lib/ci-checks/ci-checks.ts index c9735448ed5..1efb276b1dc 100644 --- a/app/src/lib/ci-checks/ci-checks.ts +++ b/app/src/lib/ci-checks/ci-checks.ts @@ -1,17 +1,20 @@ +import { Account } from '../../models/account' +import { GitHubRepository } from '../../models/github-repository' import { - APICheckStatus, + API, APICheckConclusion, - IAPIWorkflowJobStep, + APICheckStatus, IAPIRefCheckRun, IAPIRefStatusItem, - API, + IAPIWorkflowJobStep, IAPIWorkflowJobs, IAPIWorkflowRun, } from '../api' -import { GitHubRepository } from '../../models/github-repository' -import { Account } from '../../models/account' import { supportsRetrieveActionWorkflowByCheckSuiteId } from '../endpoint-capabilities' -import { formatPreciseDuration } from '../format-duration' +import { + formatLongPreciseDuration, + formatPreciseDuration, +} from '../format-duration' /** * A Desktop-specific model closely related to a GitHub API Check Run. @@ -283,7 +286,7 @@ export function isSuccess(check: IRefCheck) { * We use the check suite id as a proxy for determining what's * the "latest" of two check runs with the same name. */ -export function getLatestCheckRunsByName( +export function getLatestCheckRunsById( checkRuns: ReadonlyArray ): ReadonlyArray { const latestCheckRunsByName = new Map() @@ -298,7 +301,7 @@ export function getLatestCheckRunsByName( // feels hacky... but we don't have any other meta data on a check run that // differieates these. const nameAndHasPRs = - checkRun.name + + checkRun.id + (checkRun.pull_requests.length > 0 ? 'isPullRequestCheckRun' : 'isPushCheckRun') @@ -535,7 +538,7 @@ function mapActionWorkflowsRunsToCheckRuns( /** * Gets the duration of a check run or job step formatted in minutes and - * seconds. + * seconds with short notation (e.g. 1m 30s) */ export function getFormattedCheckRunDuration( checkRun: IAPIRefCheckRun | IAPIWorkflowJobStep @@ -544,6 +547,17 @@ export function getFormattedCheckRunDuration( return isNaN(duration) ? '' : formatPreciseDuration(duration) } +/** + * Gets the duration of a check run or job step formatted in minutes and + * seconds with long notation (e.g. 1 minute 30 seconds) + */ +export function getFormattedCheckRunLongDuration( + checkRun: IAPIRefCheckRun | IAPIWorkflowJobStep +) { + const duration = getCheckDurationInMilliseconds(checkRun) + return isNaN(duration) ? '' : formatLongPreciseDuration(duration) +} + /** * Generates the URL pointing to the details of a given check run. If that check * run has no specific URL, returns the URL of the associated pull request. diff --git a/app/src/lib/create-terminal-stream.ts b/app/src/lib/create-terminal-stream.ts new file mode 100644 index 00000000000..557a2475e88 --- /dev/null +++ b/app/src/lib/create-terminal-stream.ts @@ -0,0 +1,118 @@ +import { Transform } from 'node:stream' + +/** + * Creates a transform stream which removes redundant Git progress output by + * handling carriage returns the same way a terminal would, i.e by + * moving the cursor and (potentially) overwriting text. + * + * Git (and many other CLI tools) use this trick to present the + * user with nice looking progress. When writing something like... + * + * 'Downloading: 1% \r' + * 'Downloading: 2% \r' + * + * ...to the terminal the user is gonna perceive it as if the 1 just + * magically changes to a two. + * + * The carriage return character for all of you kids out there + * that haven't yet played with a manual typewriter refers to the + * "carriage" which held the character arms, see + * + * https://en.wikipedia.org/wiki/Carriage_return#Typewriters + */ +export const createTerminalStream = () => { + // The virtual line buffer, think of this as one long line (1 KiB) in a + // terminal where `l` is the farthest we've written in that line and `p` is + // the current cursor position, i.e. where we'll write the next characters + let buf: Buffer, l: number, p: number + + function reset() { + buf = Buffer.alloc(1024) + p = l = 0 + } + + reset() + + return new Transform({ + decodeStrings: true, + transform(chunk: Buffer, _, callback) { + let i = 0 + let next, cr, lf + + while (i < chunk.length) { + cr = chunk.indexOf(0x0d, i) + + if (cr === -1) { + // Happy path, there's no carriage return so we can jump to the last + // linefeed. Significant performance boost for streams without CRs. + lf = chunk.subarray(i).lastIndexOf(0x0a) + lf = lf === -1 ? -1 : lf + i + } else { + // Slow path, we need to look for the next linefeed to see if it comes + // before or after the carriage return. + lf = chunk.indexOf(0x0a, i) + } + + // The next LF, CR, or the last index if we don't find either + next = Math.min( + cr === -1 ? chunk.length - 1 : cr, + lf === -1 ? chunk.length - 1 : lf + ) + next = next === -1 ? chunk.length - 1 : next + + let sliceLength + let start = i + const end = next + 1 + + // Take the chunk and copy it into the buffer, if we can't fit it + while ((sliceLength = end - start) > 0) { + // Writing the chunk from the current cursor position will overflow + // the "line" (buf). When this happens in a terminal the line will + // wrap and the cursor will be moved to the next line. We simulate + // this by pushing our current "line" (if any) and the chunk + if (p + sliceLength > buf.length) { + // It's possible that our cursor has just been reset to 0, in that + // case we don't want to push because the chunk will "overwrite" + // the content in our buf. + if (p > 0) { + this.push(buf.subarray(0, p)) + } + + // Push at most however many bytes is left on the "line" + const remaining = buf.length - p + this.push(chunk.subarray(start, start + remaining)) + start += remaining + reset() + } else { + // We can fit the entire chunk into the buffer, so just copy it + chunk.copy(buf, p, start) + p += sliceLength + // We may have written over only parts of the previous line + // contents, for example, with this input: + // 1. "foo bar\r" + // 2. "baz" + // the buffer should contain "baz bar" + l = Math.max(p, l) + break + } + } + + if (chunk[next] === 0x0a /* \n */ && l > 0) { + // We found a line feed; push the current "line" and reset + this.push(buf.subarray(0, l)) + reset() + } else if (chunk[next] === 0x0d /* \r */) { + // We found a carriage return, reset the cursor + p = 0 + } + + i = next + 1 + } + + callback() + }, + flush(callback) { + callback(null, l > 0 ? buf.subarray(0, l) : null) + }, + }) +} diff --git a/app/src/lib/custom-integration.ts b/app/src/lib/custom-integration.ts new file mode 100644 index 00000000000..55a9a57d6b9 --- /dev/null +++ b/app/src/lib/custom-integration.ts @@ -0,0 +1,204 @@ +import { ChildProcess, SpawnOptions, spawn } from 'child_process' +import { parseCommandLineArgv } from 'windows-argv-parser' +import stringArgv from 'string-argv' +import { promisify } from 'util' +import { exec } from 'child_process' +import { access, lstat } from 'fs/promises' +import * as fs from 'fs' + +const execAsync = promisify(exec) + +/** The string that will be replaced by the target path in the custom integration arguments */ +export const TargetPathArgument = '%TARGET_PATH%' + +/** The interface representing a custom integration (external editor or shell) */ +export interface ICustomIntegration { + /** The path to the custom integration */ + readonly path: string + /** The arguments to pass to the custom integration */ + readonly arguments: string + /** The bundle ID of the custom integration (macOS only) */ + readonly bundleID?: string +} + +/** + * Parse the arguments string of a custom integration into an array of strings. + * + * @param args The arguments string to parse + */ +export function parseCustomIntegrationArguments( + args: string +): ReadonlyArray { + return __WIN32__ ? parseCommandLineArgv(args) : stringArgv(args) +} + +// Function to retrieve, on macOS, the bundleId of an app given its path +async function getAppBundleID(path: string) { + try { + // Ensure the path ends with `.app` for applications + if (!path.endsWith('.app')) { + throw new Error( + 'The provided path does not point to a macOS application.' + ) + } + + // Use mdls to query the kMDItemCFBundleIdentifier attribute + const { stdout } = await execAsync( + `mdls -name kMDItemCFBundleIdentifier -raw "${path}"` + ) + const bundleId = stdout.trim() + + // Check for valid output + if (!bundleId || bundleId === '(null)') { + return undefined + } + + return bundleId + } catch (error) { + console.error('Failed to retrieve bundle ID:', error) + return undefined + } +} + +/** + * Replace the target path placeholder in the custom integration arguments with + * the actual target path. + * + * @param args The custom integration arguments + * @param repoPath The target path to replace the placeholder with + */ +export function expandTargetPathArgument( + args: ReadonlyArray, + repoPath: string +): ReadonlyArray { + return args.map(arg => arg.replaceAll(TargetPathArgument, repoPath)) +} + +/** + * Check if the custom integration arguments contain the target path placeholder. + * + * @param args The custom integration arguments + */ +export function checkTargetPathArgument(args: ReadonlyArray): boolean { + return args.some(arg => arg.includes(TargetPathArgument)) +} + +/** + * Validate the path of a custom integration. + * + * @param path The path to the custom integration + * + * @returns An object with a boolean indicating if the path is valid and, if + * the path is a macOS app, the bundle ID of the app. + */ +export async function validateCustomIntegrationPath( + path: string +): Promise<{ isValid: boolean; bundleID?: string }> { + if (path.length === 0) { + return { isValid: false } + } + + let bundleID = undefined + + try { + const pathStat = await lstat(path) + const canBeExecuted = await access(path, fs.constants.X_OK) + .then(() => true) + .catch(() => false) + + const isExecutableFile = + (pathStat.isFile() || pathStat.isSymbolicLink()) && canBeExecuted + + // On macOS, not only executable files are valid, but also apps (which are + // directories with a `.app` extension and from which we can retrieve + // the app bundle ID) + if (__DARWIN__ && !isExecutableFile && pathStat.isDirectory()) { + bundleID = await getAppBundleID(path) + } + + return { isValid: isExecutableFile || !!bundleID, bundleID } + } catch (e) { + log.error(`Failed to validate path: ${path}`, e) + return { isValid: false } + } +} + +/** + * Check if a custom integration is valid (meaning both the path and the + * arguments are valid). + * + * @param customIntegration The custom integration to validate + */ +export async function isValidCustomIntegration( + customIntegration: ICustomIntegration +): Promise { + try { + const pathResult = await validateCustomIntegrationPath( + customIntegration.path + ) + const argv = parseCustomIntegrationArguments(customIntegration.arguments) + const targetPathPresent = checkTargetPathArgument(argv) + return pathResult.isValid && targetPathPresent + } catch (e) { + log.error('Failed to validate custom integration:', e) + return false + } +} + +/** + * Migrates custom integrations stored with the old format (with the arguments + * stored as an array of strings) to the new format (with the arguments stored + * as a single string). + * + * @param customIntegration The custom integration to migrate + * + * @returns The migrated custom integration, or `null` if the custom integration + * is already in the new format. + */ +export function migratedCustomIntegration( + customIntegration: ICustomIntegration | null +): ICustomIntegration | null { + if (customIntegration === null) { + return null + } + + // The first public release of the custom integrations feature stored the + // arguments as an array of strings. This caused some issues because the + // APIs used to parse them and split them into an array would remove any + // quotes. Storing exactly the same string as the user entered and then parse + // it right before invoking the custom integration is a better approach. + if (!Array.isArray(customIntegration.arguments)) { + return null + } + + return { + ...customIntegration, + arguments: customIntegration.arguments.join(' '), + } +} + +/** + * This helper function will use spawn to launch an integration (editor or shell). + * Its main purpose is to do some platform-specific argument handling, for example + * on Windows, where we need to wrap the command and arguments in quotes when + * the shell option is enabled. + * + * @param command Command to spawn + * @param args Arguments to pass to the command + * @param options Options to pass to spawn (optional) + * @returns The ChildProcess object returned by spawn + */ +export function spawnCustomIntegration( + command: string, + args: readonly string[], + options?: SpawnOptions +): ChildProcess { + // On Windows, we need to wrap the arguments and the command in quotes, + // otherwise the shell will split them by spaces again after invoking spawn. + if (__WIN32__ && options?.shell) { + command = `"${command}"` + args = args.map(a => `"${a}"`) + } + + return options ? spawn(command, args, options) : spawn(command, args) +} diff --git a/app/src/lib/diff-parser.ts b/app/src/lib/diff-parser.ts index 7b82932c548..3598ddb065c 100644 --- a/app/src/lib/diff-parser.ts +++ b/app/src/lib/diff-parser.ts @@ -311,7 +311,6 @@ export class DiffParser { let diffLineNumber = linesConsumed while ((c = this.parseLinePrefix(this.peek()))) { const line = this.readLine() - diffLineNumber++ if (!line) { throw new Error('Expected unified diff line but reached end of diff') @@ -338,6 +337,12 @@ export class DiffParser { continue } + // We must increase `diffLineNumber` only when we're certain that the line + // is not a "no newline" marker. Otherwise, we'll end up with a wrong + // `diffLineNumber` for the next line. This could happen if the last line + // in the file doesn't have a newline before the change. + diffLineNumber++ + let diffLine: DiffLine if (c === DiffPrefixAdd) { diff --git a/app/src/lib/editors/darwin.ts b/app/src/lib/editors/darwin.ts index f09710ea974..dd30b20b661 100644 --- a/app/src/lib/editors/darwin.ts +++ b/app/src/lib/editors/darwin.ts @@ -127,6 +127,10 @@ const editors: IDarwinExternalEditor[] = [ name: 'RubyMine', bundleIdentifiers: ['com.jetbrains.RubyMine'], }, + { + name: 'RustRover', + bundleIdentifiers: ['com.jetbrains.RustRover'], + }, { name: 'RStudio', bundleIdentifiers: ['org.rstudio.RStudio', 'com.rstudio.desktop'], diff --git a/app/src/lib/editors/launch.ts b/app/src/lib/editors/launch.ts index 6e1f5ee73ff..ed00ff396ff 100644 --- a/app/src/lib/editors/launch.ts +++ b/app/src/lib/editors/launch.ts @@ -1,6 +1,12 @@ import { spawn, SpawnOptions } from 'child_process' import { pathExists } from '../../ui/lib/path-exists' import { ExternalEditorError, FoundEditor } from './shared' +import { + expandTargetPathArgument, + ICustomIntegration, + parseCustomIntegrationArguments, + spawnCustomIntegration, +} from '../custom-integration' /** * Open a given file or folder in the desired external editor. @@ -14,8 +20,8 @@ export async function launchExternalEditor( ): Promise { const editorPath = editor.path const exists = await pathExists(editorPath) + const label = __DARWIN__ ? 'Settings' : 'Options' if (!exists) { - const label = __DARWIN__ ? '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 } @@ -29,13 +35,95 @@ export async function launchExternalEditor( detached: true, } - if (editor.usesShell) { - spawn(`"${editorPath}"`, [`"${fullPath}"`], { ...opts, shell: true }) - } else if (__DARWIN__) { - // In macOS we can use `open`, which will open the right executable file - // for us, we only need the path to the editor .app folder. - spawn('open', ['-a', editorPath, fullPath], opts) - } else { - spawn(editorPath, [fullPath], opts) + try { + if (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) + } + } catch (error) { + log.error(`Error while launching ${editor.editor}`, error) + if (error?.code === 'EACCES') { + throw new ExternalEditorError( + `GitHub Desktop doesn't have the proper permissions to start '${editor.editor}'. Please open ${label} and try another editor.`, + { openPreferences: true } + ) + } else { + throw new ExternalEditorError( + `Something went wrong while trying to start '${editor.editor}'. Please open ${label} and try another editor.`, + { openPreferences: true } + ) + } + } +} + +/** + * Open a given file or folder in the desired custom external editor. + * + * @param fullPath A folder or file path to pass as an argument when launching the editor. + * @param customEditor The external editor to launch. + */ +export async function launchCustomExternalEditor( + fullPath: string, + customEditor: ICustomIntegration +): Promise { + const editorPath = customEditor.path + const exists = await pathExists(editorPath) + const label = __DARWIN__ ? 'Settings' : 'Options' + if (!exists) { + throw new ExternalEditorError( + `Could not find executable for custom editor at path '${customEditor.path}'. Please open ${label} and select an available editor.`, + { openPreferences: true } + ) + } + + const opts: SpawnOptions = { + // Make sure the editor processes are detached from the Desktop app. + // Otherwise, some editors (like Notepad++) will be killed when the + // Desktop app is closed. + detached: true, + } + + const argv = parseCustomIntegrationArguments(customEditor.arguments) + + // Replace instances of RepoPathArgument with fullPath in customEditor.arguments + const args = expandTargetPathArgument(argv, fullPath) + + try { + // This logic around `usesShell` is also used in Windows `getAvailableEditors` implementation + const usesShell = editorPath.endsWith('.cmd') + if (usesShell) { + spawnCustomIntegration(editorPath, args, { + ...opts, + shell: true, + }) + } else if (__DARWIN__ && customEditor.bundleID) { + // In macOS we can use `open` if it's an app (i.e. if we have a bundleID), + // which will open the right executable file for us, we only need the path + // to the editor .app folder. + spawnCustomIntegration('open', ['-a', editorPath, ...args], opts) + } else { + spawnCustomIntegration(editorPath, args, opts) + } + } catch (error) { + log.error( + `Error while launching custom editor at path ${customEditor.path} with arguments ${args}`, + error + ) + if (error?.code === 'EACCES') { + throw new ExternalEditorError( + `GitHub Desktop doesn't have the proper permissions to start custom editor at path ${customEditor.path}. Please open ${label} and try another editor.`, + { openPreferences: true } + ) + } else { + throw new ExternalEditorError( + `Something went wrong while trying to start custom editor at path ${customEditor.path}. Please open ${label} and try another editor.`, + { openPreferences: true } + ) + } } } diff --git a/app/src/lib/editors/linux.ts b/app/src/lib/editors/linux.ts index 025b6df0e6b..5d9c527622d 100644 --- a/app/src/lib/editors/linux.ts +++ b/app/src/lib/editors/linux.ts @@ -42,20 +42,33 @@ const editors: ILinuxExternalEditor[] = [ '/snap/bin/code', '/usr/bin/code', '/mnt/c/Program Files/Microsoft VS Code/bin/code', + '/var/lib/flatpak/app/com.visualstudio.code/current/active/export/bin/com.visualstudio.code', + '.local/share/flatpak/app/com.visualstudio.code/current/active/export/bin/com.visualstudio.code', ], }, { name: 'Visual Studio Code (Insiders)', - paths: ['/snap/bin/code-insiders', '/usr/bin/code-insiders'], + paths: [ + '/snap/bin/code-insiders', + '/usr/bin/code-insiders', + '/var/lib/flatpak/app/com.visualstudio.code.insiders/current/active/export/bin/com.visualstudio.code.insiders', + '.local/share/flatpak/app/com.visualstudio.code.insiders/current/active/export/bin/com.visualstudio.code.insiders', + ], }, { name: 'VSCodium', paths: [ '/usr/bin/codium', - '/var/lib/flatpak/app/com.vscodium.codium', + '/var/lib/flatpak/app/com.vscodium.codium/current/active/export/bin/com.vscodium.codium', '/usr/share/vscodium-bin/bin/codium', + '.local/share/flatpak/app/com.vscodium.codium/current/active/export/bin/com.vscodium.codium', + '/snap/bin/codium', ], }, + { + name: 'VSCodium (Insiders)', + paths: ['/usr/bin/codium-insiders'], + }, { name: 'Sublime Text', paths: ['/usr/bin/subl'], @@ -87,7 +100,7 @@ const editors: ILinuxExternalEditor[] = [ name: 'JetBrains PhpStorm', paths: [ '/snap/bin/phpstorm', - '.local/share/JetBrains/Toolbox/scripts/phpstorm', + '.local/share/JetBrains/Toolbox/scripts/PhpStorm', ], }, { @@ -101,13 +114,50 @@ const editors: ILinuxExternalEditor[] = [ name: 'IntelliJ IDEA', paths: ['/snap/bin/idea', '.local/share/JetBrains/Toolbox/scripts/idea'], }, + { + name: 'IntelliJ IDEA Ultimate Edition', + paths: [ + '/snap/bin/intellij-idea-ultimate', + '.local/share/JetBrains/Toolbox/scripts/intellij-idea-ultimate', + ], + }, + { + name: 'IntelliJ Goland', + paths: [ + '/snap/bin/goland', + '.local/share/JetBrains/Toolbox/scripts/goland', + ], + }, + { + name: 'JetBrains CLion', + paths: ['/snap/bin/clion', '.local/share/JetBrains/Toolbox/scripts/clion1'], + }, + { + name: 'JetBrains Rider', + paths: ['/snap/bin/rider', '.local/share/JetBrains/Toolbox/scripts/rider'], + }, + { + name: 'JetBrains RubyMine', + paths: [ + '/snap/bin/rubymine', + '.local/share/JetBrains/Toolbox/scripts/rubymine', + ], + }, { name: 'JetBrains PyCharm', paths: [ '/snap/bin/pycharm', + '/snap/bin/pycharm-professional', '.local/share/JetBrains/Toolbox/scripts/pycharm', ], }, + { + name: 'JetBrains JetBrains RustRover', + paths: [ + '/snap/bin/rustrover', + '.local/share/JetBrains/Toolbox/scripts/rustrover', + ], + }, { name: 'Android Studio', paths: [ @@ -139,10 +189,6 @@ const editors: ILinuxExternalEditor[] = [ name: 'Notepadqq', paths: ['/usr/bin/notepadqq'], }, - { - name: 'Geany', - paths: ['/usr/bin/geany'], - }, { name: 'Mousepad', paths: ['/usr/bin/mousepad'], @@ -151,6 +197,20 @@ const editors: ILinuxExternalEditor[] = [ name: 'Pulsar', paths: ['/usr/bin/pulsar'], }, + { + name: 'Pluma', + paths: ['/usr/bin/pluma'], + }, + { + name: 'Zed', + paths: [ + '/usr/bin/zedit', + '/usr/bin/zeditor', + '/usr/bin/zed-editor', + '~/.local/bin/zed', + '/usr/bin/zed', + ], + }, ] async function getAvailablePath(paths: string[]): Promise { diff --git a/app/src/lib/editors/win32.ts b/app/src/lib/editors/win32.ts index d6b8ce4184b..a2411b7f293 100644 --- a/app/src/lib/editors/win32.ts +++ b/app/src/lib/editors/win32.ts @@ -149,6 +149,18 @@ const validateStartsWith = ( return definedVal.some(subString => registryVal.startsWith(subString)) } +/** + * Handles cases where the value includes: + * - An icon index after a comma (e.g., "C:\Path\app.exe,0") + * - Surrounding quotes (e.g., ""C:\Path\app.exe",0") + * and returns only the path to the executable. + */ +const getCleanInstallLocationFromDisplayIcon = ( + displayIconValue: string +): string => { + return displayIconValue.split(',')[0].replace(/"/g, '') +} + /** * This list contains all the external editors supported on Windows. Add a new * entry here to add support for your favorite editor. @@ -488,6 +500,14 @@ const editors: WindowsExternalEditor[] = [ displayNamePrefixes: ['DataSpell '], publishers: ['JetBrains s.r.o.'], }, + { + name: 'JetBrains RustRover', + registryKeys: registryKeysForJetBrainsIDE('RustRover'), + executableShimPaths: executableShimPathsForJetBrainsIDE('rustrover'), + jetBrainsToolboxScriptName: 'rustrover', + displayNamePrefixes: ['RustRover '], + publishers: ['JetBrains s.r.o.'], + }, { name: 'Pulsar', registryKeys: [ @@ -498,6 +518,15 @@ const editors: WindowsExternalEditor[] = [ displayNamePrefixes: ['Pulsar'], publishers: ['Pulsar-Edit'], }, + { + name: 'Cursor', + registryKeys: [ + CurrentUserUninstallKey('62625861-8486-5be9-9e46-1da50df5f8ff'), + ], + installLocationRegistryKey: 'DisplayIcon', + displayNamePrefixes: ['Cursor'], + publishers: ['Cursor AI, Inc.'], + }, ] function getKeyOrEmpty( @@ -540,7 +569,7 @@ async function findApplication(editor: WindowsExternalEditor) { const executableShimPaths = editor.installLocationRegistryKey === 'DisplayIcon' - ? [installLocation] + ? [getCleanInstallLocationFromDisplayIcon(installLocation)] : editor.executableShimPaths.map(p => Path.join(installLocation, ...p)) for (const path of executableShimPaths) { diff --git a/app/src/lib/email.ts b/app/src/lib/email.ts index 2229a4709e2..565a0825623 100644 --- a/app/src/lib/email.ts +++ b/app/src/lib/email.ts @@ -1,7 +1,6 @@ -import * as URL from 'url' - -import { IAPIEmail, getDotComAPIEndpoint } from './api' +import { IAPIEmail } from './api' import { Account } from '../models/account' +import { isGHES } from './endpoint-capabilities' /** * Lookup a suitable email address to display in the application, based on the @@ -53,11 +52,10 @@ function isEmailPublic(email: IAPIEmail): boolean { * email host is hardcoded to the subdomain users.noreply under the * endpoint host. */ -function getStealthEmailHostForEndpoint(endpoint: string) { - return getDotComAPIEndpoint() !== endpoint - ? `users.noreply.${URL.parse(endpoint).hostname}` +const getStealthEmailHostForEndpoint = (endpoint: string) => + isGHES(endpoint) + ? `users.noreply.${new URL(endpoint).hostname}` : 'users.noreply.github.com' -} /** * Generate a legacy stealth email address for the user @@ -122,3 +120,24 @@ export const isAttributableEmailFor = (account: Account, email: string) => { getLegacyStealthEmailForUser(login, endpoint).toLowerCase() === needle ) } + +/** + * A regular expression meant to match both the legacy format GitHub.com + * stealth email address and the modern format (login@ vs id+login@). + * + * Yields two capture groups, the first being an optional capture of the + * user id and the second being the mandatory login. + */ +const StealthEmailRegexp = /^(?:(\d+)\+)?(.+?)@(users\.noreply\..+)$/i + +export const parseStealthEmail = (email: string, endpoint: string) => { + const stealthEmailHost = getStealthEmailHostForEndpoint(endpoint) + const match = StealthEmailRegexp.exec(email) + + if (!match || stealthEmailHost !== match[3]) { + return null + } + + const [, id, login] = match + return { id: id ? parseInt(id, 10) : undefined, login } +} diff --git a/app/src/lib/emoji.ts b/app/src/lib/emoji.ts new file mode 100644 index 00000000000..3c2f02454f1 --- /dev/null +++ b/app/src/lib/emoji.ts @@ -0,0 +1,18 @@ +/** Represents an emoji */ +export type Emoji = { + /** + * The unicode string of the emoji if emoji is part of + * the unicode specification. If missing this emoji is + * a GitHub custom emoji such as :shipit: + */ + readonly emoji?: string + + /** URL of the image of the emoji (alternative to the unicode character) */ + readonly url: string + + /** One or more human readable aliases for the emoji character */ + readonly aliases: ReadonlyArray + + /** An optional, human readable, description of the emoji */ + readonly description?: string +} diff --git a/app/src/lib/endpoint-capabilities.ts b/app/src/lib/endpoint-capabilities.ts index ad39622de6f..b39d0372a7e 100644 --- a/app/src/lib/endpoint-capabilities.ts +++ b/app/src/lib/endpoint-capabilities.ts @@ -3,19 +3,22 @@ import { getDotComAPIEndpoint } from './api' import { assertNonNullable } from './fatal-error' export type VersionConstraint = { - /** Whether this constrain will be satisfied when using GitHub.com */ - dotcom: boolean /** - * Whether this constrain will be satisfied when using GitHub AE - * Supports specifying a version constraint as a SemVer Range (ex: >= 3.1.0) + * Whether this constrain will be satisfied when using GitHub.com, defaults + * to false + **/ + dotcom?: boolean + /** + * Whether this constrain will be satisfied when using ghe.com, defaults to + * the value of `dotcom` if not specified */ - ae: boolean | string + ghe?: boolean /** * Whether this constrain will be satisfied when using GitHub Enterprise * Server. Supports specifying a version constraint as a SemVer Range (ex: >= - * 3.1.0) + * 3.1.0), defaults to false */ - es: boolean | string + es?: boolean | string } /** @@ -29,16 +32,6 @@ export type VersionConstraint = { */ const assumedGHESVersion = new semver.SemVer('3.1.0') -/** - * If we're connected to a GHAE instance we won't know its version number - * since it doesn't report that so we'll use this substitute GHES equivalent - * version number. - * - * This should correspond loosely with the most recent GHES series and - * needs to be updated manually. - */ -const assumedGHAEVersion = new semver.SemVer('3.2.0') - /** Stores raw x-github-enterprise-version headers keyed on endpoint */ const rawVersionCache = new Map() @@ -49,29 +42,32 @@ const versionCache = new Map() const endpointVersionKey = (ep: string) => `endpoint-version:${ep}` /** - * Whether or not the given endpoint URI matches GitHub.com's - * - * I.e. https://api.github.com/ - * - * Most often used to check if an endpoint _isn't_ GitHub.com meaning it's - * either GitHub Enterprise Server or GitHub AE + * Whether or not the given endpoint belong's to GitHub.com */ -export const isDotCom = (ep: string) => ep === getDotComAPIEndpoint() +export const isDotCom = (ep: string) => { + if (ep === getDotComAPIEndpoint()) { + return true + } -/** - * Whether or not the given endpoint URI appears to point to a GitHub AE - * instance - */ -export const isGHAE = (ep: string) => - /^https:\/\/[a-z0-9-]+\.ghe\.com$/i.test(ep) + const { hostname } = new URL(ep) + return hostname === 'api.github.com' || hostname === 'github.com' +} + +export const isGist = (ep: string) => { + const { hostname } = new URL(ep) + return hostname === 'gist.github.com' || hostname === 'gist.ghe.io' +} + +/** Whether or not the given endpoint URI is under the ghe.com domain */ +export const isGHE = (ep: string) => new URL(ep).hostname.endsWith('.ghe.com') /** * Whether or not the given endpoint URI appears to point to a GitHub Enterprise * Server instance */ -export const isGHES = (ep: string) => !isDotCom(ep) && !isGHAE(ep) +export const isGHES = (ep: string) => !isDotCom(ep) && !isGHE(ep) -function getEndpointVersion(endpoint: string) { +export function getEndpointVersion(endpoint: string) { const key = endpointVersionKey(endpoint) const cached = versionCache.get(key) @@ -104,12 +100,12 @@ export function updateEndpointVersion(endpoint: string, version: string) { } function checkConstraint( - epConstraint: string | boolean, + epConstraint: string | boolean | undefined, epMatchesType: boolean, epVersion?: semver.SemVer ) { // Denial of endpoint type regardless of version - if (epConstraint === false) { + if (epConstraint === undefined || epConstraint === false) { return false } @@ -131,32 +127,25 @@ function checkConstraint( * Consumers should use the various `supports*` methods instead. */ export const endpointSatisfies = - ({ dotcom, ae, es }: VersionConstraint, getVersion = getEndpointVersion) => + ({ dotcom, ghe, es }: VersionConstraint, getVersion = getEndpointVersion) => (ep: string) => checkConstraint(dotcom, isDotCom(ep)) || - checkConstraint(ae, isGHAE(ep), assumedGHAEVersion) || + checkConstraint(ghe ?? dotcom, isGHE(ep)) || checkConstraint(es, isGHES(ep), getVersion(ep) ?? assumedGHESVersion) /** * Whether or not the endpoint supports the internal GitHub Enterprise Server * avatars API */ -export const supportsAvatarsAPI = endpointSatisfies({ - dotcom: false, - ae: '>= 3.0.0', - es: '>= 3.0.0', -}) +export const supportsAvatarsAPI = endpointSatisfies({ es: '>= 3.0.0' }) export const supportsRerunningChecks = endpointSatisfies({ dotcom: true, - ae: '>= 3.4.0', es: '>= 3.4.0', }) export const supportsRerunningIndividualOrFailedChecks = endpointSatisfies({ dotcom: true, - ae: false, - es: false, }) /** @@ -165,18 +154,8 @@ export const supportsRerunningIndividualOrFailedChecks = endpointSatisfies({ */ export const supportsRetrieveActionWorkflowByCheckSuiteId = endpointSatisfies({ dotcom: true, - ae: false, - es: false, }) -export const supportsAliveSessions = endpointSatisfies({ - dotcom: true, - ae: false, - es: false, -}) +export const supportsAliveSessions = endpointSatisfies({ dotcom: true }) -export const supportsRepoRules = endpointSatisfies({ - dotcom: true, - ae: false, - es: false, -}) +export const supportsRepoRules = endpointSatisfies({ dotcom: true }) diff --git a/app/src/lib/enterprise.ts b/app/src/lib/enterprise.ts deleted file mode 100644 index 8390cd6e0c4..00000000000 --- a/app/src/lib/enterprise.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * The oldest officially supported version of GitHub Enterprise. - * This information is used in user-facing text and shouldn't be - * considered a hard limit, i.e. older versions of GitHub Enterprise - * might (and probably do) work just fine but this should be a fairly - * recent version that we can safely say that we'll work well with. - */ -export const minimumSupportedEnterpriseVersion = '3.0.0' diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index 7b95bb863e6..ae9ab63eae6 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -28,6 +28,17 @@ function enableBetaFeatures(): boolean { return enableDevelopmentFeatures() || __RELEASE_CHANNEL__ === 'beta' } +/** + * Should the app show menu items that are used for testing various parts of the + * UI + * + * For our own testing purposes, this will likely remain enabled. But, sometimes + * we may want to create a test release for a user to test a fix in which case + * they should not need access to the test menu items. + */ +export const enableTestMenuItems = () => + enableDevelopmentFeatures() || __RELEASE_CHANNEL__ === 'test' + /** Should git pass `--recurse-submodules` when performing operations? */ export function enableRecurseSubmodulesFlag(): boolean { return true @@ -42,13 +53,6 @@ export function enableWSLDetection(): boolean { return enableBetaFeatures() } -/** - * Should we use the new diff viewer for unified diffs? - */ -export function enableExperimentalDiffViewer(): boolean { - return true -} - /** * Should we allow reporting unhandled rejections as if they were crashes? */ @@ -70,7 +74,7 @@ export function enableUpdateFromEmulatedX64ToARM64(): boolean { /** Should we allow resetting to a previous commit? */ export function enableResetToCommit(): boolean { - return enableDevelopmentFeatures() + return true } /** Should we allow checking out a single commit? */ @@ -88,16 +92,12 @@ export function enablePullRequestQuickView(): boolean { return enableDevelopmentFeatures() } -export function enableMoveStash(): boolean { - return true -} - -export const enableCustomGitUserAgent = enableBetaFeatures - -export function enableSectionList(): boolean { +/** Should we support image previews for dds files? */ +export function enableImagePreviewsForDDSFiles(): boolean { return enableBetaFeatures() } -export const enableRepoRulesBeta = () => true +export const enableCustomIntegration = () => true -export const enableCommitDetailsHeaderExpansion = enableBetaFeatures +export const enableResizingToolbarButtons = () => true +export const enableGitConfigParameters = enableBetaFeatures diff --git a/app/src/lib/find-account.ts b/app/src/lib/find-account.ts index 84c12257fde..de6bc675b5c 100644 --- a/app/src/lib/find-account.ts +++ b/app/src/lib/find-account.ts @@ -80,7 +80,7 @@ export async function findAccountForRemoteURL( // As this needs to be done efficiently, we consider endpoints not matching // `getDotComAPIEndpoint()` to be GitHub Enterprise accounts, and accounts // without a token to be unauthenticated. - const sortedAccounts = Array.from(allAccounts).sort((a1, a2) => { + const sortedAccounts = allAccounts.toSorted((a1, a2) => { if (a1.endpoint === getDotComAPIEndpoint()) { return a1.token.length ? -1 : 1 } else if (a2.endpoint === getDotComAPIEndpoint()) { diff --git a/app/src/lib/format-duration.ts b/app/src/lib/format-duration.ts index e36efd8c8a5..6659c46e297 100644 --- a/app/src/lib/format-duration.ts +++ b/app/src/lib/format-duration.ts @@ -1,8 +1,14 @@ -export const units: [string, number][] = [ - ['d', 86400000], - ['h', 3600000], - ['m', 60000], - ['s', 1000], +type TimeUnitDescriptor = { + shortUnit: string + longUnit: string + ms: number +} + +const units: TimeUnitDescriptor[] = [ + { shortUnit: 'd', longUnit: 'day', ms: 86400000 }, + { shortUnit: 'h', longUnit: 'hour', ms: 3600000 }, + { shortUnit: 'm', longUnit: 'minute', ms: 60000 }, + { shortUnit: 's', longUnit: 'second', ms: 1000 }, ] /** @@ -17,11 +23,34 @@ export const formatPreciseDuration = (ms: number) => { const parts = new Array() ms = Math.abs(ms) - for (const [unit, value] of units) { - if (parts.length > 0 || ms >= value || unit === 's') { - const qty = Math.floor(ms / value) - ms -= qty * value - parts.push(`${qty}${unit}`) + for (const unit of units) { + if (parts.length > 0 || ms >= unit.ms || unit.shortUnit === 's') { + const qty = Math.floor(ms / unit.ms) + ms -= qty * unit.ms + parts.push(`${qty}${unit.shortUnit}`) + } + } + + return parts.join(' ') +} + +/** + * Creates a long style precise duration format used for displaying things + * like check run durations that typically only last for a few minutes. + * + * Example: formatLongPreciseDuration(3670000) -> "1 hour 1 minute 10 seconds" + * + * @param ms The duration in milliseconds + */ +export const formatLongPreciseDuration = (ms: number) => { + const parts = new Array() + ms = Math.abs(ms) + + for (const unit of units) { + if (parts.length > 0 || ms >= unit.ms || unit.shortUnit === 's') { + const qty = Math.floor(ms / unit.ms) + ms -= qty * unit.ms + parts.push(`${qty} ${unit.longUnit}${qty === 1 ? '' : 's'}`) } } diff --git a/app/src/lib/generic-git-auth.ts b/app/src/lib/generic-git-auth.ts index c234d70fb31..4eb5344f34f 100644 --- a/app/src/lib/generic-git-auth.ts +++ b/app/src/lib/generic-git-auth.ts @@ -1,45 +1,49 @@ -import * as URL from 'url' -import { parseRemote } from './remote-parsing' import { getKeyForEndpoint } from './auth' import { TokenStore } from './stores/token-store' -/** Get the hostname to use for the given remote. */ -export function getGenericHostname(remoteURL: string): string { - const parsed = parseRemote(remoteURL) - if (parsed) { - return parsed.hostname - } +export const genericGitAuthUsernameKeyPrefix = 'genericGitAuth/username/' - const urlHostname = URL.parse(remoteURL).hostname - if (urlHostname) { - return urlHostname - } - - return remoteURL -} - -function getKeyForUsername(hostname: string): string { - return `genericGitAuth/username/${hostname}` +function getKeyForUsername(endpoint: string): string { + return `${genericGitAuthUsernameKeyPrefix}${endpoint}` } /** Get the username for the host. */ -export function getGenericUsername(hostname: string): string | null { - const key = getKeyForUsername(hostname) +export function getGenericUsername(endpoint: string): string | null { + const key = getKeyForUsername(endpoint) return localStorage.getItem(key) } /** Set the username for the host. */ -export function setGenericUsername(hostname: string, username: string) { - const key = getKeyForUsername(hostname) +export function setGenericUsername(endpoint: string, username: string) { + const key = getKeyForUsername(endpoint) return localStorage.setItem(key, username) } /** Set the password for the username and host. */ export function setGenericPassword( - hostname: string, + endpoint: string, username: string, password: string ): Promise { - const key = getKeyForEndpoint(hostname) + const key = getKeyForEndpoint(endpoint) return TokenStore.setItem(key, username, password) } + +export function setGenericCredential( + endpoint: string, + username: string, + password: string +) { + setGenericUsername(endpoint, username) + return setGenericPassword(endpoint, username, password) +} + +/** Get the password for the given username and host. */ +export const getGenericPassword = (endpoint: string, username: string) => + TokenStore.getItem(getKeyForEndpoint(endpoint), username) + +/** Delete a generic credential */ +export function deleteGenericCredential(endpoint: string, username: string) { + localStorage.removeItem(getKeyForUsername(endpoint)) + return TokenStore.deleteItem(getKeyForEndpoint(endpoint), username) +} diff --git a/app/src/lib/get-os.ts b/app/src/lib/get-os.ts index f54387d4547..b8d55bffd43 100644 --- a/app/src/lib/get-os.ts +++ b/app/src/lib/get-os.ts @@ -27,7 +27,7 @@ function systemVersionLessThan(version: string) { } /** Get the OS we're currently running on. */ -export function getOS() { +export function getOS(): string { const version = getSystemVersionSafe() if (__DARWIN__) { return `Mac OS ${version}` @@ -46,7 +46,7 @@ export const isMacOSVentura = memoizeOne( systemVersionLessThan('14.0') ) -/** We're currently running macOS and it is macOS Ventura. */ +/** We're currently running macOS and it is macOS Sonoma. */ export const isMacOSSonoma = memoizeOne( () => __DARWIN__ && @@ -54,6 +54,14 @@ export const isMacOSSonoma = memoizeOne( systemVersionLessThan('15.0') ) +/** We're currently running macOS and it is macOS Sequoia. */ +export const isMacOSSequoia = memoizeOne( + () => + __DARWIN__ && + systemVersionGreaterThanOrEqualTo('15.0') && + systemVersionLessThan('16.0') +) + /** We're currently running macOS and it is macOS Catalina or earlier. */ export const isMacOSCatalinaOrEarlier = memoizeOne( () => __DARWIN__ && systemVersionLessThan('10.16') @@ -79,3 +87,13 @@ export const isWindows10And1809Preview17666OrLater = memoizeOne( export const isWindowsAndNoLongerSupportedByElectron = memoizeOne( () => __WIN32__ && systemVersionLessThan('10') ) + +export const isMacOSAndNoLongerSupportedByElectron = memoizeOne( + () => __DARWIN__ && systemVersionLessThan('10.15') +) + +export const isOSNoLongerSupportedByElectron = memoizeOne( + () => + isMacOSAndNoLongerSupportedByElectron() || + isWindowsAndNoLongerSupportedByElectron() +) diff --git a/app/src/lib/get-updater-guid.ts b/app/src/lib/get-updater-guid.ts new file mode 100644 index 00000000000..0468e273c93 --- /dev/null +++ b/app/src/lib/get-updater-guid.ts @@ -0,0 +1,25 @@ +import { app } from 'electron' +import { readFile, writeFile } from 'fs/promises' +import { join } from 'path' +import { uuid } from './uuid' + +let cachedGUID: string | undefined = undefined + +const getUpdateGUIDPath = () => join(app.getPath('userData'), '.update-id') +const writeUpdateGUID = (id: string) => + writeFile(getUpdateGUIDPath(), id).then(() => id) + +export const getUpdaterGUID = async () => { + return ( + cachedGUID ?? + readFile(getUpdateGUIDPath(), 'utf8') + .then(id => id.trim()) + .then(id => (id.length === 36 ? id : writeUpdateGUID(uuid()))) + .catch(() => writeUpdateGUID(uuid())) + .catch(e => { + log.error(`Could not read update id`, e) + return undefined + }) + .then(id => (cachedGUID = id)) + ) +} diff --git a/app/src/lib/git/authentication.ts b/app/src/lib/git/authentication.ts index 59d2b0f71b0..a6961d18a62 100644 --- a/app/src/lib/git/authentication.ts +++ b/app/src/lib/git/authentication.ts @@ -1,24 +1,13 @@ import { GitError as DugiteError } from 'dugite' -import { IGitAccount } from '../../models/git-account' /** Get the environment for authenticating remote operations. */ -export function envForAuthentication(auth: IGitAccount | null): Object { - const env = { +export function envForAuthentication(): Record { + return { // supported since Git 2.3, this is used to ensure we never interactively prompt // for credentials - even as a fallback GIT_TERMINAL_PROMPT: '0', GIT_TRACE: localStorage.getItem('git-trace') || '0', } - - if (!auth) { - return env - } - - return { - ...env, - DESKTOP_USERNAME: auth.login, - DESKTOP_ENDPOINT: auth.endpoint, - } } /** The set of errors which fit under the "authentication failed" umbrella. */ diff --git a/app/src/lib/git/branch.ts b/app/src/lib/git/branch.ts index 936c792191a..d1988ee3d0c 100644 --- a/app/src/lib/git/branch.ts +++ b/app/src/lib/git/branch.ts @@ -1,16 +1,12 @@ -import { git, gitNetworkArguments } from './core' +import { git } from './core' import { Repository } from '../../models/repository' import { Branch } from '../../models/branch' -import { IGitAccount } from '../../models/git-account' import { formatAsLocalRef } from './refs' import { deleteRef } from './update-ref' import { GitError as DugiteError } from 'dugite' -import { getRemoteURL } from './remote' -import { - envForRemoteOperation, - getFallbackUrlForProxyResolve, -} from './environment' +import { envForRemoteOperation } from './environment' import { createForEachRefParser } from './git-delimiter-parser' +import { IRemote } from '../../models/remote' /** * Create a new branch from the given start point. @@ -72,30 +68,15 @@ export async function deleteLocalBranch( */ export async function deleteRemoteBranch( repository: Repository, - account: IGitAccount | null, - remoteName: string, + remote: IRemote, remoteBranchName: string ): Promise { - const remoteUrl = - (await getRemoteURL(repository, remoteName).catch(err => { - // If we can't get the URL then it's very unlikely Git will be able to - // either and the push will fail. The URL is only used to resolve the - // proxy though so it's not critical. - log.error(`Could not resolve remote url for remote ${remoteName}`, err) - return null - })) || getFallbackUrlForProxyResolve(account, repository) - - const args = [ - ...gitNetworkArguments(), - 'push', - remoteName, - `:${remoteBranchName}`, - ] + const args = ['push', remote.name, `:${remoteBranchName}`] // If the user is not authenticated, the push is going to fail // Let this propagate and leave it to the caller to handle const result = await git(args, repository.path, 'deleteRemoteBranch', { - env: await envForRemoteOperation(account, remoteUrl), + env: await envForRemoteOperation(remote.url), expectedErrors: new Set([DugiteError.BranchDeletionFailed]), }) @@ -104,7 +85,7 @@ export async function deleteRemoteBranch( // error we can safely remove our remote ref which is what would // happen if the push didn't fail. if (result.gitError === DugiteError.BranchDeletionFailed) { - const ref = `refs/remotes/${remoteName}/${remoteBranchName}` + const ref = `refs/remotes/${remote.name}/${remoteBranchName}` await deleteRef(repository, ref) } diff --git a/app/src/lib/git/checkout.ts b/app/src/lib/git/checkout.ts index ae25b04c81a..7a026ccd80e 100644 --- a/app/src/lib/git/checkout.ts +++ b/app/src/lib/git/checkout.ts @@ -1,8 +1,7 @@ -import { git, IGitExecutionOptions, gitNetworkArguments } from './core' +import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { Branch, BranchType } from '../../models/branch' import { ICheckoutProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { CheckoutProgressParser, executionOptionsWithProgress, @@ -16,46 +15,36 @@ import { import { WorkingDirectoryFileChange } from '../../models/status' import { ManualConflictResolution } from '../../models/manual-conflict-resolution' import { CommitOneLine, shortenSHA } from '../../models/commit' +import { IRemote } from '../../models/remote' export type ProgressCallback = (progress: ICheckoutProgress) => void function getCheckoutArgs(progressCallback?: ProgressCallback) { - return progressCallback != null - ? [...gitNetworkArguments(), 'checkout', '--progress'] - : [...gitNetworkArguments(), 'checkout'] + return ['checkout', ...(progressCallback ? ['--progress'] : [])] } 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, '--') + return [ + branch.name, + ...(branch.type === BranchType.Remote + ? ['-b', branch.nameWithoutRemote] + : []), + ...(enableRecurseSubmodulesFlag() ? ['--recurse-submodules'] : []), + '--', + ] } async function getCheckoutOpts( repository: Repository, - account: IGitAccount | null, title: string, target: string, + currentRemote: IRemote | null, progressCallback?: ProgressCallback, initialDescription?: string -): Promise { - const opts: IGitExecutionOptions = { +): Promise { + const opts: IGitStringExecutionOptions = { env: await envForRemoteOperation( - account, - getFallbackUrlForProxyResolve(account, repository) + getFallbackUrlForProxyResolve(repository, currentRemote) ), expectedErrors: AuthenticationErrors, } @@ -111,15 +100,15 @@ async function getCheckoutOpts( */ export async function checkoutBranch( repository: Repository, - account: IGitAccount | null, branch: Branch, + currentRemote: IRemote | null, progressCallback?: ProgressCallback ): Promise { const opts = await getCheckoutOpts( repository, - account, `Checking out branch ${branch.name}`, branch.name, + currentRemote, progressCallback, `Switching to ${__DARWIN__ ? 'Branch' : 'branch'}` ) @@ -151,16 +140,16 @@ export async function checkoutBranch( */ export async function checkoutCommit( repository: Repository, - account: IGitAccount | null, commit: CommitOneLine, + currentRemote: IRemote | null, progressCallback?: ProgressCallback ): Promise { const title = `Checking out ${__DARWIN__ ? 'Commit' : 'commit'}` const opts = await getCheckoutOpts( repository, - account, title, shortenSHA(commit.sha), + currentRemote, progressCallback ) diff --git a/app/src/lib/git/cherry-pick.ts b/app/src/lib/git/cherry-pick.ts index 2ddacd68053..688ad8b4f34 100644 --- a/app/src/lib/git/cherry-pick.ts +++ b/app/src/lib/git/cherry-pick.ts @@ -5,7 +5,12 @@ import { AppFileStatusKind, WorkingDirectoryFileChange, } from '../../models/status' -import { git, IGitExecutionOptions, IGitResult } from './core' +import { + git, + IGitExecutionOptions, + IGitResult, + IGitStringExecutionOptions, +} from './core' import { getStatus } from './status' import { stageFiles } from './update-index' import { getCommitsInRange, revRange } from './rev-list' @@ -101,8 +106,8 @@ class GitCherryPickParser { * @param progressCallback - the callback method that accepts an * `ICherryPickProgress` instance created by the parser */ -function configureOptionsWithCallBack( - baseOptions: IGitExecutionOptions, +function configureOptionsWithCallBack( + baseOptions: T, commits: readonly CommitOneLine[], progressCallback: (progress: IMultiCommitOperationProgress) => void, cherryPickedCount: number = 0 @@ -142,7 +147,7 @@ export async function cherryPick( return CherryPickResult.UnableToStart } - let baseOptions: IGitExecutionOptions = { + let baseOptions: IGitStringExecutionOptions = { expectedErrors: new Set([ GitError.MergeConflicts, GitError.ConflictModifyDeletedInBranch, @@ -157,7 +162,7 @@ export async function cherryPick( ) } - // --keep-redundant-commits follows pattern of making sure someone cherry + // --empty=keep follows pattern of making sure someone cherry // picked commit summaries appear in target branch history even tho they may // be empty. This flag also results in the ability to cherry pick empty // commits (thus, --allow-empty is not required.) @@ -167,12 +172,7 @@ export async function cherryPick( // there could be multiple empty commits. I.E. If user does a range that // includes commits from that merge. const result = await git( - [ - 'cherry-pick', - ...commits.map(c => c.sha), - '--keep-redundant-commits', - '-m 1', - ], + ['cherry-pick', ...commits.map(c => c.sha), '--empty=keep', '-m 1'], repository.path, 'cherry-pick', baseOptions @@ -415,7 +415,7 @@ export async function continueCherryPick( return CherryPickResult.UnableToStart } - let options: IGitExecutionOptions = { + let options: IGitStringExecutionOptions = { expectedErrors: new Set([ GitError.MergeConflicts, GitError.ConflictModifyDeletedInBranch, @@ -465,12 +465,8 @@ export async function continueCherryPick( return parseCherryPickResult(result) } - // --keep-redundant-commits follows pattern of making sure someone cherry - // picked commit summaries appear in target branch history even tho they may - // be empty. This flag also results in the ability to cherry pick empty - // commits (thus, --allow-empty is not required.) const result = await git( - ['cherry-pick', '--continue', '--keep-redundant-commits'], + ['cherry-pick', '--continue'], repository.path, 'continueCherryPick', options diff --git a/app/src/lib/git/clone.ts b/app/src/lib/git/clone.ts index 12d3c5955da..954f85cc078 100644 --- a/app/src/lib/git/clone.ts +++ b/app/src/lib/git/clone.ts @@ -1,4 +1,4 @@ -import { git, IGitExecutionOptions, gitNetworkArguments } from './core' +import { git, IGitStringExecutionOptions } from './core' import { ICloneProgress } from '../../models/progress' import { CloneOptions } from '../../models/clone-options' import { CloneProgressParser, executionOptionsWithProgress } from '../progress' @@ -30,19 +30,21 @@ export async function clone( options: CloneOptions, progressCallback?: (progress: ICloneProgress) => void ): Promise { - const env = await envForRemoteOperation(options.account, url) + const env = { + ...(await envForRemoteOperation(url)), + GIT_CLONE_PROTECTION_ACTIVE: 'false', + } const defaultBranch = options.defaultBranch ?? (await getDefaultBranch()) const args = [ - ...gitNetworkArguments(), '-c', `init.defaultBranch=${defaultBranch}`, 'clone', '--recursive', ] - let opts: IGitExecutionOptions = { env } + let opts: IGitStringExecutionOptions = { env } if (progressCallback) { args.push('--progress') diff --git a/app/src/lib/git/config.ts b/app/src/lib/git/config.ts index 0c442b7b117..08519747d03 100644 --- a/app/src/lib/git/config.ts +++ b/app/src/lib/git/config.ts @@ -122,34 +122,21 @@ async function getConfigValueInPath( return pieces[0] } -/** Get the path to the global git config. */ -export async function getGlobalConfigPath(env?: { - HOME: string -}): Promise { - const options = env ? { env } : undefined - const result = await git( - ['config', '--global', '--list', '--show-origin', '--name-only', '-z'], - __dirname, - 'getGlobalConfigPath', - options - ) - const segments = result.stdout.split('\0') - if (segments.length < 1) { - return null - } - - const pathSegment = segments[0] - if (!pathSegment.length) { - return null - } - - const path = pathSegment.match(/file:(.+)/i) - if (!path || path.length < 2) { - return null - } - - return normalize(path[1]) -} +/** + * Get the path to the global git config + * + * Note: this uses git config --edit which will automatically create the global + * config file if it doesn't exist yet. The primary purpose behind this method + * is to support opening the global git config for editing. + */ +export const getGlobalConfigPath = (env?: { HOME: string }) => + git(['config', '--edit', '--global'], __dirname, 'getGlobalConfigPath', { + // We're using printf instead of echo because echo could attempt to decode + // escape sequences like \n which would be bad in a case like + // c:\Users\niik\.gitconfig + // ^^ + env: { ...env, GIT_EDITOR: 'printf %s' }, + }).then(x => normalize(x.stdout)) /** Set the local config value by name. */ export async function setConfigValue( diff --git a/app/src/lib/git/core.ts b/app/src/lib/git/core.ts index 2f58683f20e..fcfad597274 100644 --- a/app/src/lib/git/core.ts +++ b/app/src/lib/git/core.ts @@ -1,20 +1,41 @@ import { - GitProcess, - IGitResult as DugiteResult, + exec, GitError as DugiteError, + parseError, + IGitResult as DugiteResult, IGitExecutionOptions as DugiteExecutionOptions, + parseBadConfigValueErrorInfo, + ExecError, } from 'dugite' import { assertNever } from '../fatal-error' import * as GitPerf from '../../ui/lib/git-perf' import * as Path from 'path' import { isErrnoException } from '../errno-exception' -import { ChildProcess } from 'child_process' -import { Readable } from 'stream' -import split2 from 'split2' import { getFileFromExceedsError } from '../helpers/regex' import { merge } from '../merge' import { withTrampolineEnv } from '../trampoline/trampoline-environment' +import { createTailStream } from './create-tail-stream' +import { createTerminalStream } from '../create-terminal-stream' + +export const coerceToString = ( + value: string | Buffer, + encoding: BufferEncoding = 'utf8' +) => (Buffer.isBuffer(value) ? value.toString(encoding) : value) + +export const coerceToBuffer = ( + value: string | Buffer, + encoding: BufferEncoding = 'utf8' +) => (Buffer.isBuffer(value) ? value : Buffer.from(value, encoding)) + +export const isMaxBufferExceededError = ( + error: unknown +): error is ExecError & { code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' } => { + return ( + error instanceof ExecError && + error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + ) +} /** * An extension of the execution options in dugite that @@ -37,6 +58,12 @@ export interface IGitExecutionOptions extends DugiteExecutionOptions { /** Should it track & report LFS progress? */ readonly trackLFSProgress?: boolean + + /** + * Whether the command about to run is part of a background task or not. + * This affects error handling and UI such as credential prompts. + */ + readonly isBackgroundTask?: boolean } /** @@ -54,9 +81,6 @@ export interface IGitResult extends DugiteResult { /** The human-readable error description, based on `gitError`. */ readonly gitErrorDescription: string | null - /** Both stdout and stderr combined. */ - readonly combinedOutput: string - /** * The path that the Git command was executed from, i.e. the * process working directory (not to be confused with the Git @@ -64,6 +88,33 @@ export interface IGitResult extends DugiteResult { */ readonly path: string } + +/** The result of shelling out to git using a string encoding (default) */ +export interface IGitStringResult extends IGitResult { + /** The standard output from git. */ + readonly stdout: string + + /** The standard error output from git. */ + readonly stderr: string +} + +export interface IGitStringExecutionOptions extends IGitExecutionOptions { + readonly encoding?: BufferEncoding +} + +export interface IGitBufferExecutionOptions extends IGitExecutionOptions { + readonly encoding: 'buffer' +} + +/** The result of shelling out to git using a buffer encoding */ +export interface IGitBufferResult extends IGitResult { + /** The standard output from git. */ + readonly stdout: Buffer + + /** The standard error output from git. */ + readonly stderr: Buffer +} + export class GitError extends Error { /** The result from the failed command. */ public readonly result: IGitResult @@ -76,21 +127,25 @@ export class GitError extends Error { */ public readonly isRawMessage: boolean - public constructor(result: IGitResult, args: ReadonlyArray) { + public constructor( + result: IGitResult, + args: ReadonlyArray, + terminalOutput: string + ) { let rawMessage = true let message if (result.gitErrorDescription) { message = result.gitErrorDescription rawMessage = false - } else if (result.combinedOutput.length > 0) { - message = result.combinedOutput + } else if (terminalOutput.length > 0) { + message = terminalOutput } else if (result.stderr.length) { - message = result.stderr + message = coerceToString(result.stderr) } else if (result.stdout.length) { - message = result.stdout + message = coerceToString(result.stdout) } else { - message = 'Unknown error' + message = `Unknown error (exit code ${result.exitCode})` rawMessage = false } @@ -122,6 +177,18 @@ export class GitError extends Error { * `successExitCodes` or an error not in `expectedErrors`, a `GitError` will be * thrown. */ +export async function git( + args: string[], + path: string, + name: string, + options?: IGitStringExecutionOptions +): Promise +export async function git( + args: string[], + path: string, + name: string, + options?: IGitBufferExecutionOptions +): Promise export async function git( args: string[], path: string, @@ -133,119 +200,127 @@ export async function git( expectedErrors: new Set(), } - let combinedOutput = '' - const opts = { - ...defaultOptions, - ...options, - } - - opts.processCallback = (process: ChildProcess) => { + const opts = { ...defaultOptions, ...options } + + // The combined contents of stdout and stderr with some light processing + // applied to remove redundant lines caused by Git's use of `\r` to "erase" + // the current line while writing progress output. See createTerminalOutput. + // + // Note: The output is capped at a maximum of 256kb and the sole intent of + // this property is to provide "terminal-like" output to the user when a Git + // command fails. + let terminalOutput = '' + + // Keep at most 256kb of combined stderr and stdout output. This is used + // to provide more context in error messages. + opts.processCallback = process => { + const terminalStream = createTerminalStream() + const tailStream = createTailStream(256 * 1024, { encoding: 'utf8' }) + + terminalStream + .pipe(tailStream) + .on('data', (data: string) => (terminalOutput = data)) + .on('error', e => log.error(`Terminal output error`, e)) + + process.stdout?.pipe(terminalStream, { end: false }) + process.stderr?.pipe(terminalStream, { end: false }) + process.on('close', () => terminalStream.end()) options?.processCallback?.(process) - - const combineOutput = (readable: Readable | null) => { - if (readable) { - readable.pipe(split2()).on('data', (line: string) => { - combinedOutput += line + '\n' - }) - } - } - - combineOutput(process.stderr) - combineOutput(process.stdout) } - return withTrampolineEnv(async env => { - const combinedEnv = merge(opts.env, env) - - // Explicitly set TERM to 'dumb' so that if Desktop was launched - // from a terminal or if the system environment variables - // have TERM set Git won't consider us as a smart terminal. - // See https://github.com/git/git/blob/a7312d1a2/editor.c#L11-L15 - opts.env = { TERM: 'dumb', ...combinedEnv } as object - - const commandName = `${name}: git ${args.join(' ')}` - - const result = await GitPerf.measure(commandName, () => - GitProcess.exec(args, path, opts) - ).catch(err => { - // If this is an exception thrown by Node.js (as opposed to - // dugite) let's keep the salient details but include the name of - // the operation. - if (isErrnoException(err)) { - throw new Error(`Failed to execute ${name}: ${err.code}`) + return withTrampolineEnv( + async env => { + const combinedEnv = merge(opts.env, env) + + // Explicitly set TERM to 'dumb' so that if Desktop was launched + // from a terminal or if the system environment variables + // have TERM set Git won't consider us as a smart terminal. + // See https://github.com/git/git/blob/a7312d1a2/editor.c#L11-L15 + opts.env = { TERM: 'dumb', ...combinedEnv } + + const commandName = `${name}: git ${args.join(' ')}` + + const result = await GitPerf.measure(commandName, () => + exec(args, path, opts) + ).catch(err => { + // If this is an exception thrown by Node.js (as opposed to + // dugite) let's keep the salient details but include the name of + // the operation. + if (isErrnoException(err)) { + throw new Error(`Failed to execute ${name}: ${err.code}`) + } + + throw err + }) + + const exitCode = result.exitCode + + let gitError: DugiteError | null = null + const acceptableExitCode = opts.successExitCodes + ? opts.successExitCodes.has(exitCode) + : false + if (!acceptableExitCode) { + gitError = parseError(coerceToString(result.stderr)) + if (gitError === null) { + gitError = parseError(coerceToString(result.stdout)) + } } - throw err - }) - - const exitCode = result.exitCode - - let gitError: DugiteError | null = null - const acceptableExitCode = opts.successExitCodes - ? opts.successExitCodes.has(exitCode) - : false - if (!acceptableExitCode) { - gitError = GitProcess.parseError(result.stderr) - if (!gitError) { - gitError = GitProcess.parseError(result.stdout) + const gitErrorDescription = + gitError !== null + ? getDescriptionForError(gitError, coerceToString(result.stderr)) + : null + const gitResult = { + ...result, + gitError, + gitErrorDescription, + path, } - } - - const gitErrorDescription = gitError - ? getDescriptionForError(gitError) - : null - const gitResult = { - ...result, - gitError, - gitErrorDescription, - combinedOutput, - path, - } - let acceptableError = true - if (gitError && opts.expectedErrors) { - acceptableError = opts.expectedErrors.has(gitError) - } - - if ((gitError && acceptableError) || acceptableExitCode) { - return gitResult - } - - // The caller should either handle this error, or expect that exit code. - const errorMessage = new Array() - errorMessage.push( - `\`git ${args.join(' ')}\` exited with an unexpected code: ${exitCode}.` - ) - - if (result.stdout) { - errorMessage.push('stdout:') - errorMessage.push(result.stdout) - } + let acceptableError = true + if (gitError !== null && opts.expectedErrors) { + acceptableError = opts.expectedErrors.has(gitError) + } - if (result.stderr) { - errorMessage.push('stderr:') - errorMessage.push(result.stderr) - } + if ((gitError !== null && acceptableError) || acceptableExitCode) { + return gitResult + } - if (gitError) { + // The caller should either handle this error, or expect that exit code. + const errorMessage = new Array() errorMessage.push( - `(The error was parsed as ${gitError}: ${gitErrorDescription})` + `\`git ${args.join(' ')}\` exited with an unexpected code: ${exitCode}.` ) - } - log.error(errorMessage.join('\n')) + if (terminalOutput.length > 0) { + // Leave even less of the combined output in the log + errorMessage.push(terminalOutput.slice(-1024)) + } + + if (gitError !== null) { + 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 (gitError === DugiteError.PushWithFileSizeExceedingLimit) { + const files = getFileFromExceedsError( + coerceToString(result.stderr) + ).join('\n') - if (files !== '') { - gitResult.gitErrorDescription += '\n\nFile causing error:\n\n' + files + if (files !== '') { + gitResult.gitErrorDescription += '\n\nFile causing error:\n\n' + files + } } - } - throw new GitError(gitResult, args) - }) + throw new GitError(gitResult, args, terminalOutput) + }, + path, + options?.isBackgroundTask ?? false, + options?.env + ) } /** @@ -290,7 +365,7 @@ const lockFilePathRe = /^error: could not lock config file (.+?): File exists$/m * output. */ export function parseConfigLockFilePathFromError(result: IGitResult) { - const match = lockFilePathRe.exec(result.stderr) + const match = lockFilePathRe.exec(coerceToString(result.stderr)) if (match === null) { return null @@ -306,7 +381,10 @@ export function parseConfigLockFilePathFromError(result: IGitResult) { return Path.resolve(result.path, `${normalized}.lock`) } -function getDescriptionForError(error: DugiteError): string | null { +export function getDescriptionForError( + error: DugiteError, + stderr: string +): string | null { if (isAuthFailureError(error)) { const menuHint = __DARWIN__ ? 'GitHub Desktop > Settings.' @@ -323,6 +401,13 @@ function getDescriptionForError(error: DugiteError): string | null { } switch (error) { + case DugiteError.BadConfigValue: + const errorInfo = parseBadConfigValueErrorInfo(stderr) + if (errorInfo === null) { + return 'Unsupported git configuration value.' + } + + return `Unsupported value '${errorInfo.value}' for git config key '${errorInfo.key}'` case DugiteError.SSHKeyAuditUnverified: return 'The SSH key is unverified.' case DugiteError.RemoteDisconnection: @@ -436,21 +521,6 @@ function getDescriptionForError(error: DugiteError): string | null { } } -/** - * 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. @@ -462,14 +532,13 @@ export function gitRebaseArguments() { // uses the merge backend even if the user has the apply backend // configured, since this is the only one supported. // This can go away once git deprecates the apply backend. - '-c', - 'rebase.backend=merge', + ...['-c', 'rebase.backend=merge'], ] } /** * Returns the SHA of the passed in IGitResult */ -export function parseCommitSHA(result: IGitResult): string { +export function parseCommitSHA(result: IGitStringResult): string { return result.stdout.split(']')[0].split(' ')[1] } diff --git a/app/src/lib/git/create-tail-stream.ts b/app/src/lib/git/create-tail-stream.ts new file mode 100644 index 00000000000..0592f9f8e95 --- /dev/null +++ b/app/src/lib/git/create-tail-stream.ts @@ -0,0 +1,36 @@ +import assert from 'assert' +import { Transform, TransformOptions } from 'stream' + +type Options = Pick + +export function createTailStream(capacity: number, options?: Options) { + assert.ok(capacity > 0, 'The "capacity" argument must be greater than 0') + + const chunks: Buffer[] = [] + let length = 0 + + return new Transform({ + ...options, + decodeStrings: true, + transform(chunk, _, cb) { + chunks.push(chunk) + length += chunk.length + + while (length > capacity) { + const firstChunk = chunks[0] + const overrun = length - capacity + + if (overrun >= firstChunk.length) { + chunks.shift() + length -= firstChunk.length + } else { + chunks[0] = firstChunk.subarray(overrun) + length -= overrun + } + } + + cb() + }, + flush: cb => cb(null, Buffer.concat(chunks)), + }) +} diff --git a/app/src/lib/git/credential.ts b/app/src/lib/git/credential.ts new file mode 100644 index 00000000000..943ea76692e --- /dev/null +++ b/app/src/lib/git/credential.ts @@ -0,0 +1,68 @@ +import { exec as git } from 'dugite' + +export const parseCredential = (value: string) => { + const cred = new Map() + + // The credential helper protocol is a simple key=value format but some of its + // keys are actually arrays which are represented as multiple key[] entries. + // Since we're currently storing credentials as a Map we need to handle this + // and expand multiple key[] entries into a key[0], key[1]... key[n] sequence. + // We then remove the number from the key when we're formatting the credential + for (const [, k, v] of value.matchAll(/^(.*?)=(.*)$/gm)) { + if (k.endsWith('[]')) { + let i = 0 + let newKey + + do { + newKey = `${k.slice(0, -2)}[${i}]` + i++ + } while (cred.has(newKey)) + + cred.set(newKey, v) + } else { + cred.set(k, v) + } + } + + return cred +} + +export const formatCredential = (credential: Map) => + [...credential] + .map(([k, v]) => `${k.replace(/\[\d+\]$/, '[]')}=${v}\n`) + .join('') + +// Can't use git() as that will call withTrampolineEnv which calls this method +const exec = ( + cmd: string, + cred: Map, + path: string, + env: Record = {} +) => + git( + [ + ...['-c', 'credential.helper='], + ...['-c', `credential.helper=manager`], + 'credential', + cmd, + ], + path, + { + stdin: formatCredential(cred), + env: { + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + TERM: 'dumb', + ...env, + }, + } + ).then(({ exitCode, stderr, stdout }) => { + if (exitCode !== 0) { + throw new Error(stderr) + } + return parseCredential(stdout) + }) + +export const fillCredential = exec.bind(null, 'fill') +export const approveCredential = exec.bind(null, 'approve') +export const rejectCredential = exec.bind(null, 'reject') diff --git a/app/src/lib/git/diff-check.ts b/app/src/lib/git/diff-check.ts index 131773078d7..3e418897fd1 100644 --- a/app/src/lib/git/diff-check.ts +++ b/app/src/lib/git/diff-check.ts @@ -1,5 +1,4 @@ -import { spawnAndComplete } from './spawn' -import { getCaptures } from '../helpers/regex' +import { git } from './core' /** * Returns a list of files with conflict markers present @@ -10,33 +9,19 @@ import { getCaptures } from '../helpers/regex' export async function getFilesWithConflictMarkers( repositoryPath: string ): Promise> { - // git operation - const args = ['diff', '--check'] - const { output } = await spawnAndComplete( - args, + const { stdout } = await git( + ['diff', '--check'], repositoryPath, 'getFilesWithConflictMarkers', - new Set([0, 2]) + { successExitCodes: new Set([0, 2]) } ) - // result parsing - const outputStr = output.toString('utf8') - const captures = getCaptures(outputStr, fileNameCaptureRe) - if (captures.length === 0) { - return new Map() + const files = new Map() + const matches = stdout.matchAll(/^(.+):\d+: leftover conflict marker/gm) + + for (const [, path] of matches) { + files.set(path, (files.get(path) ?? 0) + 1) } - // flatten the list (only does one level deep) - const flatCaptures = captures.reduce((acc, val) => acc.concat(val)) - // count number of occurrences - const counted = flatCaptures.reduce( - (acc, val) => acc.set(val, (acc.get(val) || 0) + 1), - new Map() - ) - return counted -} -/** - * matches a line reporting a leftover conflict marker - * and captures the name of the file - */ -const fileNameCaptureRe = /(.+):\d+: leftover conflict marker/gi + return files +} diff --git a/app/src/lib/git/diff.ts b/app/src/lib/git/diff.ts index 3665ef4c106..6d72c19787a 100644 --- a/app/src/lib/git/diff.ts +++ b/app/src/lib/git/diff.ts @@ -8,6 +8,7 @@ import { FileChange, AppFileStatusKind, SubmoduleStatus, + CommittedFileChange, } from '../../models/status' import { DiffType, @@ -20,11 +21,8 @@ import { 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' @@ -33,6 +31,9 @@ import { GitError } from 'dugite' import { IChangesetData, parseRawLogWithNumstat } from './log' import { getConfigValue } from './config' import { getMergeBase } from './merge' +import { IStatusEntry } from '../status-parser' +import { createLogParser } from './git-delimiter-parser' +import { enableImagePreviewsForDDSFiles } from '../feature-flag' /** * V8 has a limit on the size of string it can create (~256MB), and unless we want to @@ -97,6 +98,10 @@ const imageFileExtensions = new Set([ '.avif', ]) +if (enableImagePreviewsForDDSFiles()) { + imageFileExtensions.add('.dds') +} + /** * Render the difference between a file in the given commit and its parent * @@ -130,13 +135,11 @@ export async function getCommitDiff( args.push(file.status.oldPath) } - const { output } = await spawnAndComplete( - args, - repository.path, - 'getCommitDiff' - ) + const { stdout } = await git(args, repository.path, 'getCommitDiff', { + encoding: 'buffer', + }) - return buildDiff(output, repository, file, commitish) + return buildDiff(stdout, repository, file, commitish) } /** @@ -172,10 +175,10 @@ export async function getBranchMergeBaseDiff( } const result = await git(args, repository.path, 'getBranchMergeBaseDiff', { - maxBuffer: Infinity, + encoding: 'buffer', }) - return buildDiff(Buffer.from(result.stdout), repository, file, latestCommit) + return buildDiff(result.stdout, repository, file, latestCommit) } /** @@ -215,7 +218,7 @@ export async function getCommitRangeDiff( } const result = await git(args, repository.path, 'getCommitsDiff', { - maxBuffer: Infinity, + encoding: 'buffer', expectedErrors: new Set([GitError.BadRevision]), }) @@ -232,7 +235,7 @@ export async function getCommitRangeDiff( ) } - return buildDiff(Buffer.from(result.stdout), repository, file, latestCommit) + return buildDiff(result.stdout, repository, file, latestCommit) } /** @@ -379,15 +382,15 @@ export async function getWorkingDirectoryDiff( args.push('HEAD', '--', file.path) } - const { output, error } = await spawnAndComplete( + const { stdout, stderr } = await git( args, repository.path, 'getWorkingDirectoryDiff', - successExitCodes + { successExitCodes, encoding: 'buffer' } ) - const lineEndingsChange = parseLineEndingsWarning(error) + const lineEndingsChange = parseLineEndingsWarning(stderr) - return buildDiff(output, repository, file, 'HEAD', lineEndingsChange) + return buildDiff(stdout, repository, file, 'HEAD', lineEndingsChange) } async function getImageDiff( @@ -433,7 +436,8 @@ async function getImageDiff( // File status can't be conflicted for a file in a commit if ( file.status.kind !== AppFileStatusKind.New && - file.status.kind !== AppFileStatusKind.Untracked + file.status.kind !== AppFileStatusKind.Untracked && + file.status.kind !== AppFileStatusKind.Deleted ) { // TODO: commitish^ won't work for the first commit // @@ -445,6 +449,17 @@ async function getImageDiff( `${oldestCommitish}^` ) } + + if ( + file instanceof CommittedFileChange && + file.status.kind === AppFileStatusKind.Deleted + ) { + previous = await getBlobImage( + repository, + getOldPathOrDefault(file), + file.parentCommitish + ) + } } return { @@ -509,6 +524,9 @@ function getMediaType(extension: string) { if (extension === '.avif') { return 'image/avif' } + if (extension === '.dds') { + return 'image/vnd-ms.dds' + } // fallback value as per the spec return 'text/plain' @@ -666,6 +684,7 @@ export async function getBlobImage( const extension = Path.extname(path) const contents = await getBlobContents(repository, commitish, path) return new Image( + contents.buffer, contents.toString('base64'), getMediaType(extension), contents.length @@ -686,6 +705,7 @@ export async function getWorkingDirectoryImage( ): Promise { const contents = await readFile(Path.join(repository.path, file.path)) return new Image( + contents.buffer, contents.toString('base64'), getMediaType(Path.extname(file.path)), contents.length @@ -703,20 +723,51 @@ export async function getWorkingDirectoryImage( */ export async function getBinaryPaths( repository: Repository, - ref: string + ref: string, + conflictedFilesInIndex: ReadonlyArray ): Promise> { - const { output } = await spawnAndComplete( + const [detectedBinaryFiles, conflictedFilesUsingBinaryMergeDriver] = + await Promise.all([ + getDetectedBinaryFiles(repository, ref), + getFilesUsingBinaryMergeDriver(repository, conflictedFilesInIndex), + ]) + + return Array.from( + new Set([...detectedBinaryFiles, ...conflictedFilesUsingBinaryMergeDriver]) + ) +} + +/** + * Runs diff --numstat to get the list of files that have changed and which + * Git have detected as binary files + */ +async function getDetectedBinaryFiles(repository: Repository, ref: string) { + const { stdout } = await git( ['diff', '--numstat', '-z', ref], repository.path, 'getBinaryPaths' ) - const captures = getCaptures(output.toString('utf8'), binaryListRegex) - if (captures.length === 0) { - return [] - } - // flatten the list (only does one level deep) - const flatCaptures = captures.reduce((acc, val) => acc.concat(val)) - return flatCaptures + + return Array.from(stdout.matchAll(binaryListRegex), m => m[1]) } const binaryListRegex = /-\t-\t(?:\0.+\0)?([^\0]*)/gi + +async function getFilesUsingBinaryMergeDriver( + repository: Repository, + files: ReadonlyArray +) { + const { stdout } = await git( + ['check-attr', '--stdin', '-z', 'merge'], + repository.path, + 'getConflictedFilesUsingBinaryMergeDriver', + { + stdin: files.map(f => f.path).join('\0'), + } + ) + + return createLogParser({ path: '', attr: '', value: '' }) + .parse(stdout) + .filter(x => x.attr === 'merge' && x.value === 'binary') + .map(x => x.path) +} diff --git a/app/src/lib/git/environment.ts b/app/src/lib/git/environment.ts index ad05be9c779..fedefb09aaa 100644 --- a/app/src/lib/git/environment.ts +++ b/app/src/lib/git/environment.ts @@ -1,8 +1,11 @@ import { envForAuthentication } from './authentication' -import { IGitAccount } from '../../models/git-account' import { resolveGitProxy } from '../resolve-git-proxy' -import { getDotComAPIEndpoint } from '../api' -import { Repository } from '../../models/repository' +import { getHTMLURL } from '../api' +import { + Repository, + isRepositoryWithGitHubRepository, +} from '../../models/repository' +import { IRemote } from '../../models/remote' /** * For many remote operations it's well known what the primary remote @@ -17,25 +20,34 @@ import { Repository } from '../../models/repository' * be on a different server as well. That's too advanced for our usage * at the moment though so we'll just need to figure out some reasonable * url to fall back on. + * + * @param branchName If the operation we're about to undertake is related to a + * local ref (i.e branch) then we can use that to resolve its + * upstream tracking branch (and thereby its remote) and use + * that as the probable url to resolve a proxy for. */ export function getFallbackUrlForProxyResolve( - account: IGitAccount | null, - repository: Repository + repository: Repository, + currentRemote: IRemote | null ) { - // If we've got an account with an endpoint that means we've already done the - // heavy lifting to figure out what the most likely endpoint is gonna be - // so we'll try to leverage that. - if (account !== null) { - // A GitHub.com Account will have api.github.com as its endpoint - return account.endpoint === getDotComAPIEndpoint() - ? 'https://github.com' - : account.endpoint + // We used to use account.endpoint here but we look up account by the + // repository endpoint (see getAccountForRepository) so we can skip the use + // of the account here and just use the repository endpoint directly. + if (isRepositoryWithGitHubRepository(repository)) { + return getHTMLURL(repository.gitHubRepository.endpoint) } - if (repository.gitHubRepository !== null) { - if (repository.gitHubRepository.cloneURL !== null) { - return repository.gitHubRepository.cloneURL - } + // This is a carry-over from the old code where we would use the current + // remote to resolve an account and then use that account's endpoint here. + // We've since removed the need to pass an account down here but unfortunately + // that means we need to pass the current remote instead. Note that ideally + // this should be looking up the remote url either based on the currently + // checked out branch, the upstream tracking branch of the branch being + // checked out, or the default remote if neither of those are available. + // Doing so by shelling out to Git here was deemed to costly and in order to + // finish this refactor we've opted to replicate the previous behavior here. + if (currentRemote) { + return currentRemote.url } // If all else fails let's assume that whatever network resource @@ -61,12 +73,9 @@ export function getFallbackUrlForProxyResolve( * pointing to another host entirely. Used to resolve which * proxy (if any) should be used for the operation. */ -export async function envForRemoteOperation( - account: IGitAccount | null, - remoteUrl: string -) { +export async function envForRemoteOperation(remoteUrl: string) { return { - ...envForAuthentication(account), + ...envForAuthentication(), ...(await envForProxy(remoteUrl)), } } @@ -85,7 +94,7 @@ export async function envForProxy( remoteUrl: string, env: NodeJS.ProcessEnv = process.env, resolve: (url: string) => Promise = resolveGitProxy -): Promise { +): Promise | undefined> { const protocolMatch = /^(https?):\/\//i.exec(remoteUrl) // We can only resolve and use a proxy for the protocols where cURL diff --git a/app/src/lib/git/fetch.ts b/app/src/lib/git/fetch.ts index 0049df0296e..408b29015a3 100644 --- a/app/src/lib/git/fetch.ts +++ b/app/src/lib/git/fetch.ts @@ -1,6 +1,5 @@ -import { git, IGitExecutionOptions, gitNetworkArguments } from './core' +import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' -import { IGitAccount } from '../../models/git-account' import { IFetchProgress } from '../../models/progress' import { FetchProgressParser, executionOptionsWithProgress } from '../progress' import { enableRecurseSubmodulesFlag } from '../feature-flag' @@ -9,33 +8,18 @@ import { ITrackingBranch } from '../../models/branch' import { envForRemoteOperation } from './environment' async function getFetchArgs( - repository: Repository, remote: string, - account: IGitAccount | null, progressCallback?: (progress: IFetchProgress) => void ) { - if (enableRecurseSubmodulesFlag()) { - return progressCallback != null - ? [ - ...gitNetworkArguments(), - 'fetch', - '--progress', - '--prune', - '--recurse-submodules=on-demand', - remote, - ] - : [ - ...gitNetworkArguments(), - 'fetch', - '--prune', - '--recurse-submodules=on-demand', - remote, - ] - } else { - return progressCallback != null - ? [...gitNetworkArguments(), 'fetch', '--progress', '--prune', remote] - : [...gitNetworkArguments(), 'fetch', '--prune', remote] - } + return [ + 'fetch', + ...(progressCallback ? ['--progress'] : []), + '--prune', + ...(enableRecurseSubmodulesFlag() + ? ['--recurse-submodules=on-demand'] + : []), + remote, + ] } /** @@ -52,16 +36,18 @@ async function getFetchArgs( * of the fetch operation. When provided this enables * the '--progress' command line flag for * 'git fetch'. + * @param isBackgroundTask - Whether the fetch is being performed as a + * background task as opposed to being user initiated */ export async function fetch( repository: Repository, - account: IGitAccount | null, remote: IRemote, - progressCallback?: (progress: IFetchProgress) => void + progressCallback?: (progress: IFetchProgress) => void, + isBackgroundTask = false ): Promise { - let opts: IGitExecutionOptions = { + let opts: IGitStringExecutionOptions = { successExitCodes: new Set([0]), - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), } if (progressCallback) { @@ -69,7 +55,7 @@ export async function fetch( const kind = 'fetch' opts = await executionOptionsWithProgress( - { ...opts, trackLFSProgress: true }, + { ...opts, trackLFSProgress: true, isBackgroundTask }, new FetchProgressParser(), progress => { // In addition to progress output from the remote end and from @@ -100,12 +86,7 @@ export async function fetch( progressCallback({ kind, title, value: 0, remote: remote.name }) } - const args = await getFetchArgs( - repository, - remote.name, - account, - progressCallback - ) + const args = await getFetchArgs(remote.name, progressCallback) await git(args, repository.path, 'fetch', opts) } @@ -113,19 +94,13 @@ export async function fetch( /** Fetch a given refspec from the given remote. */ export async function fetchRefspec( repository: Repository, - account: IGitAccount | null, remote: IRemote, refspec: string ): Promise { - await git( - [...gitNetworkArguments(), 'fetch', remote.name, refspec], - repository.path, - 'fetchRefspec', - { - successExitCodes: new Set([0, 128]), - env: await envForRemoteOperation(account, remote.url), - } - ) + await git(['fetch', remote.name, refspec], repository.path, 'fetchRefspec', { + successExitCodes: new Set([0, 128]), + env: await envForRemoteOperation(remote.url), + }) } export async function fastForwardBranches( @@ -138,18 +113,6 @@ export async function fastForwardBranches( const refPairs = branches.map(branch => `${branch.upstreamRef}:${branch.ref}`) - const opts: IGitExecutionOptions = { - // Fetch exits with an exit code of 1 if one or more refs failed to update - // which is what we expect will happen - successExitCodes: new Set([0, 1]), - env: { - // This will make sure the reflog entries are correct after - // fast-forwarding the branches. - GIT_REFLOG_ACTION: 'pull', - }, - stdin: refPairs.join('\n'), - } - await git( [ 'fetch', @@ -166,6 +129,16 @@ export async function fastForwardBranches( ], repository.path, 'fastForwardBranches', - opts + { + // Fetch exits with an exit code of 1 if one or more refs failed to update + // which is what we expect will happen + successExitCodes: new Set([0, 1]), + env: { + // This will make sure the reflog entries are correct after + // fast-forwarding the branches. + GIT_REFLOG_ACTION: 'pull', + }, + stdin: refPairs.join('\n'), + } ) } diff --git a/app/src/lib/git/format-patch.ts b/app/src/lib/git/format-patch.ts index 28cd1173350..27c068477ca 100644 --- a/app/src/lib/git/format-patch.ts +++ b/app/src/lib/git/format-patch.ts @@ -1,6 +1,6 @@ import { revRange } from './rev-list' import { Repository } from '../../models/repository' -import { spawnAndComplete } from './spawn' +import { git } from '.' /** * Generate a patch representing the changes associated with a range of commits @@ -10,16 +10,8 @@ import { spawnAndComplete } from './spawn' * @param head ending commit in rage * @returns patch generated */ -export async function formatPatch( - repository: Repository, - base: string, - head: string -): Promise { +export function formatPatch({ path }: Repository, base: string, head: string) { const range = revRange(base, head) - const { output } = await spawnAndComplete( - ['format-patch', '--unified=1', '--minimal', '--stdout', range], - repository.path, - 'formatPatch' - ) - return output.toString('utf8') + const args = ['format-patch', '--unified=1', '--minimal', '--stdout', range] + return git(args, path, 'formatPatch').then(x => x.stdout) } diff --git a/app/src/lib/git/log.ts b/app/src/lib/git/log.ts index b908c187232..c491645eedf 100644 --- a/app/src/lib/git/log.ts +++ b/app/src/lib/git/log.ts @@ -12,7 +12,6 @@ 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' @@ -155,9 +154,12 @@ export async function getCommits( const parsed = parse(result.stdout) return parsed.map(commit => { - const tags = getCaptures(commit.refs, /tag: ([^\s,]+)/g) - .filter(i => i[0] !== undefined) - .map(i => i[0]) + // Ref is of the format: (HEAD -> master, tag: some-tag-name, tag: some-other-tag,with-a-comma, origin/master, origin/HEAD) + // Refs are comma separated, but some like tags can also contain commas in the name, so we split on the pattern ", " and then + // check each ref for the tag prefix. We used to use the regex /tag: ([^\s,]+)/g)`, but will clip a tag with a comma short. + const tags = commit.refs + .split(', ') + .flatMap(ref => (ref.startsWith('tag: ') ? ref.substring(5) : [])) return new Commit( commit.sha, diff --git a/app/src/lib/git/pull.ts b/app/src/lib/git/pull.ts index fa95a8c9104..3be7ab34ec2 100644 --- a/app/src/lib/git/pull.ts +++ b/app/src/lib/git/pull.ts @@ -1,15 +1,7 @@ -import { - git, - GitError, - IGitExecutionOptions, - gitNetworkArguments, - gitRebaseArguments, -} from './core' +import { git, gitRebaseArguments, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { IPullProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { PullProgressParser, executionOptionsWithProgress } from '../progress' -import { AuthenticationErrors } from './authentication' import { enableRecurseSubmodulesFlag } from '../feature-flag' import { IRemote } from '../../models/remote' import { envForRemoteOperation } from './environment' @@ -18,31 +10,16 @@ import { getConfigValue } from './config' async function getPullArgs( repository: Repository, remote: string, - account: IGitAccount | null, progressCallback?: (progress: IPullProgress) => void ) { - const divergentPathArgs = await getDefaultPullDivergentBranchArguments( - repository - ) - - const args = [ - ...gitNetworkArguments(), + return [ ...gitRebaseArguments(), 'pull', - ...divergentPathArgs, + ...(await getDefaultPullDivergentBranchArguments(repository)), + ...(enableRecurseSubmodulesFlag() ? ['--recurse-submodules'] : []), + ...(progressCallback ? ['--progress'] : []), + remote, ] - - if (enableRecurseSubmodulesFlag()) { - args.push('--recurse-submodules') - } - - if (progressCallback != null) { - args.push('--progress') - } - - args.push(remote) - - return args } /** @@ -60,13 +37,11 @@ async function getPullArgs( */ export async function pull( repository: Repository, - account: IGitAccount | null, remote: IRemote, progressCallback?: (progress: IPullProgress) => void ): Promise { - let opts: IGitExecutionOptions = { - env: await envForRemoteOperation(account, remote.url), - expectedErrors: AuthenticationErrors, + let opts: IGitStringExecutionOptions = { + env: await envForRemoteOperation(remote.url), } if (progressCallback) { @@ -106,17 +81,8 @@ export async function pull( progressCallback({ kind, title, value: 0, remote: remote.name }) } - const args = await getPullArgs( - repository, - remote.name, - account, - progressCallback - ) - const result = await git(args, repository.path, 'pull', opts) - - if (result.gitErrorDescription) { - throw new GitError(result, args) - } + const args = await getPullArgs(repository, remote.name, progressCallback) + await git(args, repository.path, 'pull', opts) } /** diff --git a/app/src/lib/git/push.ts b/app/src/lib/git/push.ts index eb81701baf1..a58f89e8377 100644 --- a/app/src/lib/git/push.ts +++ b/app/src/lib/git/push.ts @@ -1,18 +1,10 @@ -import { GitError as DugiteError } from 'dugite' - -import { - git, - IGitExecutionOptions, - gitNetworkArguments, - GitError, -} from './core' +import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { IPushProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { PushProgressParser, executionOptionsWithProgress } from '../progress' -import { AuthenticationErrors } from './authentication' import { IRemote } from '../../models/remote' import { envForRemoteOperation } from './environment' +import { Branch } from '../../models/branch' export type PushOptions = { /** @@ -22,6 +14,9 @@ export type PushOptions = { * See https://git-scm.com/docs/git-push#Documentation/git-push.txt---no-force-with-lease */ readonly forceWithLease: boolean + + /** A branch to push instead of the current branch */ + readonly branch?: Branch } /** @@ -50,7 +45,6 @@ export type PushOptions = { */ export async function push( repository: Repository, - account: IGitAccount | null, remote: IRemote, localBranch: string, remoteBranch: string | null, @@ -61,7 +55,6 @@ export async function push( progressCallback?: (progress: IPushProgress) => void ): Promise { const args = [ - ...gitNetworkArguments(), 'push', remote.name, remoteBranch ? `${localBranch}:${remoteBranch}` : localBranch, @@ -76,12 +69,8 @@ export async function push( args.push('--force-with-lease') } - const expectedErrors = new Set(AuthenticationErrors) - expectedErrors.add(DugiteError.ProtectedBranchForcePush) - - let opts: IGitExecutionOptions = { - env: await envForRemoteOperation(account, remote.url), - expectedErrors, + let opts: IGitStringExecutionOptions = { + env: await envForRemoteOperation(remote.url), } if (progressCallback) { @@ -118,9 +107,5 @@ export async function push( }) } - const result = await git(args, repository.path, 'push', opts) - - if (result.gitErrorDescription) { - throw new GitError(result, args) - } + await git(args, repository.path, 'push', opts) } diff --git a/app/src/lib/git/rebase.ts b/app/src/lib/git/rebase.ts index 7515f584580..dd5c93ffcc1 100644 --- a/app/src/lib/git/rebase.ts +++ b/app/src/lib/git/rebase.ts @@ -18,9 +18,10 @@ import { formatRebaseValue } from '../rebase' import { git, - IGitResult, IGitExecutionOptions, gitRebaseArguments, + IGitStringExecutionOptions, + IGitStringResult, } from './core' import { stageManualConflictResolution } from './stage' import { stageFiles } from './update-index' @@ -313,8 +314,8 @@ class GitRebaseParser { } } -function configureOptionsForRebase( - options: IGitExecutionOptions, +function configureOptionsForRebase( + options: T, progress?: RebaseProgressOptions ) { if (progress === undefined) { @@ -361,7 +362,7 @@ export async function rebase( targetBranch: Branch, progressCallback?: (progress: IMultiCommitOperationProgress) => void ): Promise { - const baseOptions: IGitExecutionOptions = { + const baseOptions: IGitStringExecutionOptions = { expectedErrors: new Set([GitError.RebaseConflicts]), } @@ -405,7 +406,7 @@ export async function abortRebase(repository: Repository) { await git(['rebase', '--abort'], repository.path, 'abortRebase') } -function parseRebaseResult(result: IGitResult): RebaseResult { +function parseRebaseResult(result: IGitStringResult): RebaseResult { if (result.exitCode === 0) { if (result.stdout.trim().match(/^Current branch [^ ]+ is up to date.$/i)) { return RebaseResult.AlreadyUpToDate @@ -477,7 +478,7 @@ export async function continueRebase( f => f.status.kind !== AppFileStatusKind.Untracked ) - const baseOptions: IGitExecutionOptions = { + const baseOptions: IGitStringExecutionOptions = { expectedErrors: new Set([ GitError.RebaseConflicts, GitError.UnresolvedConflicts, @@ -554,9 +555,10 @@ export async function rebaseInteractive( progressCallback?: (progress: IMultiCommitOperationProgress) => void, commits?: ReadonlyArray ): Promise { - const baseOptions: IGitExecutionOptions = { + const baseOptions: IGitStringExecutionOptions = { expectedErrors: new Set([GitError.RebaseConflicts]), env: { + GIT_SEQUENCE_EDITOR: undefined, GIT_EDITOR: gitEditor, }, } diff --git a/app/src/lib/git/remote.ts b/app/src/lib/git/remote.ts index dd3d3a76250..dc07b6a51e2 100644 --- a/app/src/lib/git/remote.ts +++ b/app/src/lib/git/remote.ts @@ -4,9 +4,7 @@ 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. @@ -22,14 +20,9 @@ export async function getRemotes( 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 + return [...result.stdout.matchAll(/^(.+)\t(.+)\s\(fetch\)/gm)].map( + ([, name, url]) => ({ name, url }) + ) } /** Add a new remote with the given URL. */ @@ -95,19 +88,23 @@ export async function getRemoteURL( /** * Update the HEAD ref of the remote, which is the default branch. + * + * @param isBackgroundTask Whether the fetch is being performed as a + * background task as opposed to being user initiated */ export async function updateRemoteHEAD( repository: Repository, - account: IGitAccount | null, - remote: IRemote + remote: IRemote, + isBackgroundTask: boolean ): Promise { const options = { successExitCodes: new Set([0, 1, 128]), - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), + isBackgroundTask, } await git( - [...gitNetworkArguments(), 'remote', 'set-head', '-a', remote.name], + ['remote', 'set-head', '-a', remote.name], repository.path, 'updateRemoteHEAD', options diff --git a/app/src/lib/git/rev-parse.ts b/app/src/lib/git/rev-parse.ts index 171c27fa0c8..27414411a5e 100644 --- a/app/src/lib/git/rev-parse.ts +++ b/app/src/lib/git/rev-parse.ts @@ -56,3 +56,23 @@ export async function getRepositoryType(path: string): Promise { throw err } } + +export async function getUpstreamRefForRef(path: string, ref?: string) { + const rev = (ref ?? '') + '@{upstream}' + const args = ['rev-parse', '--symbolic-full-name', rev] + const opts = { successExitCodes: new Set([0, 128]) } + const result = await git(args, path, 'getUpstreamRefForRef', opts) + + return result.exitCode === 0 ? result.stdout.trim() : null +} + +export async function getUpstreamRemoteNameForRef(path: string, ref?: string) { + const remoteRef = await getUpstreamRefForRef(path, ref) + return remoteRef?.match(/^refs\/remotes\/([^/]+)\//)?.[1] ?? null +} + +export const getCurrentUpstreamRef = (path: string) => + getUpstreamRefForRef(path) + +export const getCurrentUpstreamRemoteName = (path: string) => + getUpstreamRemoteNameForRef(path) diff --git a/app/src/lib/git/revert.ts b/app/src/lib/git/revert.ts index cee54c28d9f..6c8129f57a8 100644 --- a/app/src/lib/git/revert.ts +++ b/app/src/lib/git/revert.ts @@ -1,9 +1,8 @@ -import { git, gitNetworkArguments, IGitExecutionOptions } from './core' +import { git, IGitStringExecutionOptions } from './core' import { Repository } from '../../models/repository' import { Commit } from '../../models/commit' import { IRevertProgress } from '../../models/progress' -import { IGitAccount } from '../../models/git-account' import { executionOptionsWithProgress } from '../progress/from-process' import { RevertProgressParser } from '../progress/revert' @@ -11,6 +10,7 @@ import { envForRemoteOperation, getFallbackUrlForProxyResolve, } from './environment' +import { IRemote } from '../../models/remote' /** * Creates a new commit that reverts the changes of a previous commit @@ -22,21 +22,20 @@ import { export async function revertCommit( repository: Repository, commit: Commit, - account: IGitAccount | null, + currentRemote: IRemote | null, progressCallback?: (progress: IRevertProgress) => void ) { - const args = [...gitNetworkArguments(), 'revert'] + const args = ['revert'] if (commit.parentSHAs.length > 1) { args.push('-m', '1') } args.push(commit.sha) - let opts: IGitExecutionOptions = {} + let opts: IGitStringExecutionOptions = {} if (progressCallback) { const env = await envForRemoteOperation( - account, - getFallbackUrlForProxyResolve(account, repository) + getFallbackUrlForProxyResolve(repository, currentRemote) ) opts = await executionOptionsWithProgress( { env, trackLFSProgress: true }, diff --git a/app/src/lib/git/show.ts b/app/src/lib/git/show.ts index ab9cc51a44e..ec91683609d 100644 --- a/app/src/lib/git/show.ts +++ b/app/src/lib/git/show.ts @@ -1,6 +1,4 @@ -import { ChildProcess } from 'child_process' - -import { git } from './core' +import { coerceToBuffer, git, isMaxBufferExceededError } from './core' import { Repository } from '../../models/repository' import { GitError } from 'dugite' @@ -21,30 +19,15 @@ import { GitError } from 'dugite' * @param path - The file path, relative to the repository * root from where to read the blob contents */ -export async function getBlobContents( +export const getBlobContents = ( repository: Repository, commitish: string, path: string -): Promise { - const successExitCodes = new Set([0, 1]) - const setBinaryEncoding: (process: ChildProcess) => void = cb => { - // If Node.js encounters a synchronous runtime error while spawning - // `stdout` will be undefined and the error will be emitted asynchronously - if (cb.stdout) { - cb.stdout.setEncoding('binary') - } - } - - const args = ['show', `${commitish}:${path}`] - const opts = { - successExitCodes, - processCallback: setBinaryEncoding, - } - - const blobContents = await git(args, repository.path, 'getBlobContents', opts) - - return Buffer.from(blobContents.stdout, 'binary') -} +) => + git(['show', `${commitish}:${path}`], repository.path, 'getBlobContents', { + successExitCodes: new Set([0, 1]), + encoding: 'buffer', + }).then(r => r.stdout) /** * Retrieve some or all binary contents of a blob from the repository @@ -90,19 +73,15 @@ export async function getPartialBlobContentsCatchPathNotInRef( ): 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) + return git(args, repository.path, 'getPartialBlobContentsCatchPathNotInRef', { + maxBuffer: length, + expectedErrors: new Set([GitError.PathExistsButNotInRef]), + encoding: 'buffer', + }) + .then(r => + r.gitError === GitError.PathExistsButNotInRef ? null : r.stdout + ) + .catch(e => + isMaxBufferExceededError(e) ? coerceToBuffer(e.stdout) : Promise.reject(e) + ) } diff --git a/app/src/lib/git/spawn.ts b/app/src/lib/git/spawn.ts index 5eb2831274d..73fe80bce43 100644 --- a/app/src/lib/git/spawn.ts +++ b/app/src/lib/git/spawn.ts @@ -1,16 +1,13 @@ -import { GitProcess } from 'dugite' -import { IGitSpawnExecutionOptions } from 'dugite/build/lib/git-process' +import { spawn, IGitSpawnOptions } from 'dugite' import * as GitPerf from '../../ui/lib/git-perf' -import { isErrnoException } from '../errno-exception' import { withTrampolineEnv } from '../trampoline/trampoline-environment' -type ProcessOutput = { - /** The contents of stdout received from the spawned process */ - output: Buffer - /** The contents of stderr received from the spawned process */ - error: Buffer - /** The exit code returned by the spawned process */ - exitCode: number | null +type SpawnOptions = IGitSpawnOptions & { + /** + * Whether the command about to run is part of a background task or not. + * This affects error handling and UI such as credential prompts. + */ + readonly isBackgroundTask?: boolean } /** @@ -20,124 +17,22 @@ type ProcessOutput = { * @param path The path to execute the command from. * @param name The name of the operation - for tracing purposes. * @param successExitCodes An optional array of exit codes that indicate success. - * @param stdOutMaxLength An optional maximum number of bytes to read from stdout. - * If the process writes more than this number of bytes it - * will be killed silently and the truncated output is - * returned. */ export const spawnGit = ( args: string[], path: string, name: string, - options?: IGitSpawnExecutionOptions + options?: SpawnOptions ) => - withTrampolineEnv(trampolineEnv => - GitPerf.measure(`${name}: git ${args.join(' ')}`, async () => - GitProcess.spawn(args, path, { - ...options, - env: { ...options?.env, ...trampolineEnv }, - }) - ) - ) - -/** - * Spawn a Git process and buffer the stdout and stderr streams, deferring - * all processing work to the caller. - * - * @param args Array of strings to pass to the Git executable. - * @param path The path to execute the command from. - * @param name The name of the operation - for tracing purposes. - * @param successExitCodes An optional array of exit codes that indicate success. - * @param stdOutMaxLength An optional maximum number of bytes to read from stdout. - * If the process writes more than this number of bytes it - * will be killed silently and the truncated output is - * returned. - */ -export async function spawnAndComplete( - args: string[], - path: string, - name: string, - successExitCodes?: Set, - stdOutMaxLength?: number -): Promise { - return new Promise(async (resolve, reject) => { - const process = await spawnGit(args, path, name) - - process.on('error', err => { - // If this is an exception thrown by Node.js while attempting to - // spawn let's keep the salient details but include the name of - // the operation. - if (isErrnoException(err)) { - reject(new Error(`Failed to execute ${name}: ${err.code}`)) - } else { - // for unhandled errors raised by the process, let's surface this in the - // promise and make the caller handle it - reject(err) - } - }) - - let totalStdoutLength = 0 - let killSignalSent = false - - const stdoutChunks = new Array() - - // If Node.js encounters a synchronous runtime error while spawning - // `stdout` will be undefined and the error will be emitted asynchronously - if (process.stdout) { - process.stdout.on('data', (chunk: Buffer) => { - if (!stdOutMaxLength || totalStdoutLength < stdOutMaxLength) { - stdoutChunks.push(chunk) - totalStdoutLength += chunk.length - } - - if ( - stdOutMaxLength && - totalStdoutLength >= stdOutMaxLength && - !killSignalSent - ) { - process.kill() - killSignalSent = true - } - }) - } - - const stderrChunks = new Array() - - // See comment above about stdout and asynchronous errors. - if (process.stderr) { - process.stderr.on('data', (chunk: Buffer) => { - stderrChunks.push(chunk) - }) - } - - process.on('close', (code, signal) => { - const stdout = Buffer.concat( - stdoutChunks, - stdOutMaxLength - ? Math.min(stdOutMaxLength, totalStdoutLength) - : totalStdoutLength - ) - - const stderr = Buffer.concat(stderrChunks) - - // mimic the experience of GitProcess.exec for handling known codes when - // the process terminates - const exitCodes = successExitCodes || new Set([0]) - - if ((code !== null && exitCodes.has(code)) || signal) { - resolve({ - output: stdout, - error: stderr, - exitCode: code, + withTrampolineEnv( + trampolineEnv => + GitPerf.measure(`${name}: git ${args.join(' ')}`, async () => + spawn(args, path, { + ...options, + env: { ...options?.env, ...trampolineEnv }, }) - return - } else { - reject( - new Error( - `Git returned an unexpected exit code '${code}' which should be handled by the caller (${name}).'` - ) - ) - } - }) - }) -} + ), + path, + options?.isBackgroundTask ?? false, + options?.env + ) diff --git a/app/src/lib/git/stash.ts b/app/src/lib/git/stash.ts index 696e7a16a05..8667850a270 100644 --- a/app/src/lib/git/stash.ts +++ b/app/src/lib/git/stash.ts @@ -1,5 +1,5 @@ import { GitError as DugiteError } from 'dugite' -import { git, GitError } from './core' +import { coerceToString, git, GitError } from './core' import { Repository } from '../../models/repository' import { IStashEntry, @@ -51,7 +51,7 @@ export async function getStashes(repository: Repository): Promise { }) const result = await git( - ['log', '-g', ...formatArgs, 'refs/stash'], + ['log', '-g', ...formatArgs, 'refs/stash', '--'], repository.path, 'getStashEntries', { successExitCodes: new Set([0, 128]) } @@ -157,28 +157,45 @@ export async function createDesktopStashEntry( const message = createDesktopStashMessage(branchName) const args = ['stash', 'push', '-m', message] - const result = await git(args, repository.path, 'createStashEntry', { - successExitCodes: new Set([0, 1]), - }) - - if (result.exitCode === 1) { - // search for any line starting with `error:` - /m here to ensure this is - // applied to each line, without needing to split the text - const errorPrefixRe = /^error: /m - - const matches = errorPrefixRe.exec(result.stderr) - if (matches !== null && matches.length > 0) { - // rethrow, because these messages should prevent the stash from being created - throw new GitError(result, args) + const result = await git(args, repository.path, 'createStashEntry').catch( + e => { + // Note: 2024: Here be dragons. As I converted this code to get rid of the + // successExitCode use I got curious about the assumptions made in the + // following logic. It assumes that as long as the exit code for `git + // stash push` is 1 and there are no lines beginning with "error: " then + // a stash was created. That didn't hold up to a quick read of the stash + // code. For example, running git stash push in an unborn repository will + // get you an exit code of 1 but no stash was created: + // + // % git stash push -m foo ; echo $? + // You do not have the initial commit yet + // 1 + // + // I'm not going to mess with this now but I felt the need to document + // my findings should I or any other brave soul choose to tackle this in + // the future. + if (e instanceof GitError && e.result.exitCode === 1) { + // search for any line starting with `error:` - /m here to ensure this is + // applied to each line, without needing to split the text + const errorPrefixRe = /^error: /m + + const matches = errorPrefixRe.exec(coerceToString(e.result.stderr)) + if (matches !== null && matches.length > 0) { + // rethrow, because these messages should prevent the stash from being created + return Promise.reject(e) + } + + // if no error messages were emitted by Git, we should log but continue because + // a valid stash was created and this should not interfere with the checkout + + log.info( + `[createDesktopStashEntry] a stash was created successfully but exit code ${result.exitCode} reported. stderr: ${result.stderr}` + ) + return e.result + } + return Promise.reject(e) } - - // if no error messages were emitted by Git, we should log but continue because - // a valid stash was created and this should not interfere with the checkout - - log.info( - `[createDesktopStashEntry] a stash was created successfully but exit code ${result.exitCode} reported. stderr: ${result.stderr}` - ) - } + ) // Stash doesn't consider it an error that there aren't any local changes to save. if (result.stdout === 'No local changes to save\n') { @@ -224,31 +241,31 @@ export async function popStashEntry( // ignoring these git errors for now, this will change when we start // implementing the stash conflict flow const expectedErrors = new Set([DugiteError.MergeConflicts]) - const successExitCodes = new Set([0, 1]) const stashToPop = await getStashEntryMatchingSha(repository, stashSha) if (stashToPop !== null) { const args = ['stash', 'pop', '--quiet', `${stashToPop.name}`] - const result = await git(args, repository.path, 'popStashEntry', { + await git(args, repository.path, 'popStashEntry', { expectedErrors, - successExitCodes, - }) - - // popping a stashes that create conflicts in the working directory - // report an exit code of `1` and are not dropped after being applied. - // so, we check for this case and drop them manually - if (result.exitCode === 1) { - if (result.stderr.length > 0) { - // rethrow, because anything in stderr should prevent the stash from being popped - throw new GitError(result, args) + }).catch(e => { + // popping a stashes that create conflicts in the working directory + // report an exit code of `1` and are not dropped after being applied. + // so, we check for this case and drop them manually unless there's + // anything in stderr as that could have prevented the stash from being + // popped. Not the greatest approach but stash isn't very communicative + if ( + e instanceof GitError && + e.result.exitCode === 1 && + e.result.stderr.length === 0 + ) { + log.info( + `[popStashEntry] a stash was popped successfully but exit code ${e.result.exitCode} reported.` + ) + // bye bye + return dropDesktopStashEntry(repository, stashSha) } - - log.info( - `[popStashEntry] a stash was popped successfully but exit code ${result.exitCode} reported.` - ) - // bye bye - await dropDesktopStashEntry(repository, stashSha) - } + return Promise.reject(e) + }) } } diff --git a/app/src/lib/git/status.ts b/app/src/lib/git/status.ts index 4038ece0f98..91d39f3e081 100644 --- a/app/src/lib/git/status.ts +++ b/app/src/lib/git/status.ts @@ -1,4 +1,3 @@ -import { spawnAndComplete } from './spawn' import { getFilesWithConflictMarkers } from './diff-check' import { WorkingDirectoryStatus, @@ -28,6 +27,7 @@ import { getBinaryPaths } from './diff' import { getRebaseInternalState } from './rebase' import { RebaseInternalState } from '../../models/rebase' import { isCherryPickHeadFound } from './cherry-pick' +import { coerceToString, git } from '.' /** * V8 has a limit on the size of string it can create (~256MB), and unless we want to @@ -202,35 +202,32 @@ export async function getStatus( '-z', ] - const result = await spawnAndComplete( - args, - repository.path, - 'getStatus', - new Set([0, 128]) - ) + const { stdout, exitCode } = await git(args, repository.path, 'getStatus', { + successExitCodes: new Set([0, 128]), + encoding: 'buffer', + }) - if (result.exitCode === 128) { + if (exitCode === 128) { log.debug( `'git status' returned 128 for '${repository.path}' and is likely missing its .git directory` ) return null } - if (result.output.length > MaxStatusBufferSize) { + if (stdout.length > MaxStatusBufferSize) { log.error( - `'git status' emitted ${result.output.length} bytes, which is beyond the supported threshold of ${MaxStatusBufferSize} bytes` + `'git status' emitted ${stdout.length} bytes, which is beyond the supported threshold of ${MaxStatusBufferSize} bytes` ) return null } - const stdout = result.output.toString('utf8') - const parsed = parsePorcelainStatus(stdout) + const parsed = parsePorcelainStatus(coerceToString(stdout)) const headers = parsed.filter(isStatusHeader) const entries = parsed.filter(isStatusEntry) const mergeHeadFound = await isMergeHeadSet(repository) - const conflictedFilesInIndex = entries.some( - e => conflictStatusCodes.indexOf(e.statusCode) > -1 + const conflictedFilesInIndex = entries.filter(e => + conflictStatusCodes.includes(e.statusCode) ) const rebaseInternalState = await getRebaseInternalState(repository) @@ -277,7 +274,7 @@ export async function getStatus( workingDirectory, isCherryPickingHeadFound, squashMsgFound, - doConflictedFilesExist: conflictedFilesInIndex, + doConflictedFilesExist: conflictedFilesInIndex.length > 0, } } @@ -379,22 +376,36 @@ function parseStatusHeader(results: IStatusHeadersData, header: IStatusHeader) { } } -async function getMergeConflictDetails(repository: Repository) { +async function getMergeConflictDetails( + repository: Repository, + conflictedFilesInIndex: ReadonlyArray +) { const conflictCountsByPath = await getFilesWithConflictMarkers( repository.path ) - const binaryFilePaths = await getBinaryPaths(repository, 'MERGE_HEAD') + const binaryFilePaths = await getBinaryPaths( + repository, + 'MERGE_HEAD', + conflictedFilesInIndex + ) return { conflictCountsByPath, binaryFilePaths, } } -async function getRebaseConflictDetails(repository: Repository) { +async function getRebaseConflictDetails( + repository: Repository, + conflictedFilesInIndex: ReadonlyArray +) { const conflictCountsByPath = await getFilesWithConflictMarkers( repository.path ) - const binaryFilePaths = await getBinaryPaths(repository, 'REBASE_HEAD') + const binaryFilePaths = await getBinaryPaths( + repository, + 'REBASE_HEAD', + conflictedFilesInIndex + ) return { conflictCountsByPath, binaryFilePaths, @@ -405,14 +416,21 @@ async function getRebaseConflictDetails(repository: Repository) { * We need to do these operations to detect conflicts that were the result * of popping a stash into the index */ -async function getWorkingDirectoryConflictDetails(repository: Repository) { +async function getWorkingDirectoryConflictDetails( + repository: Repository, + conflictedFilesInIndex: ReadonlyArray +) { const conflictCountsByPath = await getFilesWithConflictMarkers( repository.path ) let binaryFilePaths: ReadonlyArray = [] try { // its totally fine if HEAD doesn't exist, which throws an error - binaryFilePaths = await getBinaryPaths(repository, 'HEAD') + binaryFilePaths = await getBinaryPaths( + repository, + 'HEAD', + conflictedFilesInIndex + ) } catch (error) {} return { @@ -427,26 +445,36 @@ async function getWorkingDirectoryConflictDetails(repository: Repository) { * * @param repository to get details from * @param mergeHeadFound whether a merge conflict has been detected - * @param lookForStashConflicts whether it looks like a stash has introduced conflicts - * @param rebaseInternalState details about the current rebase operation (if found) + * @param conflictedFilesInIndex all files marked as being conflicted in the + * index. Used to check for files using the binary + * merge driver and whether it looks like a stash + * has introduced conflicts + * @param rebaseInternalState details about the current rebase operation (if + * found) */ async function getConflictDetails( repository: Repository, mergeHeadFound: boolean, - lookForStashConflicts: boolean, + conflictedFilesInIndex: ReadonlyArray, rebaseInternalState: RebaseInternalState | null ): Promise { try { if (mergeHeadFound) { - return await getMergeConflictDetails(repository) + return await getMergeConflictDetails(repository, conflictedFilesInIndex) } if (rebaseInternalState !== null) { - return await getRebaseConflictDetails(repository) + return await getRebaseConflictDetails(repository, conflictedFilesInIndex) } - if (lookForStashConflicts) { - return await getWorkingDirectoryConflictDetails(repository) + // If there's conflicted files in the index but we don't have a merge head + // or a rebase internal state, then we're likely in a situation where a + // stash has introduced conflicts + if (conflictedFilesInIndex.length > 0) { + return await getWorkingDirectoryConflictDetails( + repository, + conflictedFilesInIndex + ) } } catch (error) { log.error( diff --git a/app/src/lib/git/tag.ts b/app/src/lib/git/tag.ts index 50f8d38c79b..cdb775edc6f 100644 --- a/app/src/lib/git/tag.ts +++ b/app/src/lib/git/tag.ts @@ -1,6 +1,5 @@ -import { git, gitNetworkArguments } from './core' +import { git } from './core' import { Repository } from '../../models/repository' -import { IGitAccount } from '../../models/git-account' import { IRemote } from '../../models/remote' import { envForRemoteOperation } from './environment' @@ -86,12 +85,10 @@ export async function getAllTags( */ export async function fetchTagsToPush( repository: Repository, - account: IGitAccount | null, remote: IRemote, branchName: string ): Promise> { const args = [ - ...gitNetworkArguments(), 'push', remote.name, branchName, @@ -102,7 +99,7 @@ export async function fetchTagsToPush( ] const result = await git(args, repository.path, 'fetchTagsToPush', { - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), successExitCodes: new Set([0, 1, 128]), }) diff --git a/app/src/lib/gravatar.ts b/app/src/lib/gravatar.ts deleted file mode 100644 index 8b6338000b6..00000000000 --- a/app/src/lib/gravatar.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as crypto from 'crypto' - -/** - * Convert an email address to a Gravatar URL format - * - * @param email The email address associated with a user - * @param size The size (in pixels) of the avatar to render - */ -export function generateGravatarUrl(email: string, size: number = 60): string { - const input = email.trim().toLowerCase() - const hash = crypto.createHash('md5').update(input).digest('hex') - - return `https://www.gravatar.com/avatar/${hash}?s=${size}` -} diff --git a/app/src/lib/helpers/default-branch.ts b/app/src/lib/helpers/default-branch.ts index a21328e20fc..521666c4647 100644 --- a/app/src/lib/helpers/default-branch.ts +++ b/app/src/lib/helpers/default-branch.ts @@ -12,12 +12,6 @@ const DefaultBranchInDesktop = 'main' */ const DefaultBranchSettingName = 'init.defaultBranch' -/** - * The branch names that Desktop shows by default as radio buttons on the - * form that allows users to change default branch name. - */ -export const SuggestedBranchNames: ReadonlyArray = ['main', 'master'] - /** * Returns the configured default branch when creating new repositories */ diff --git a/app/src/lib/helpers/non-fatal-exception.ts b/app/src/lib/helpers/non-fatal-exception.ts index cdc2d3ca853..c55fdf52c69 100644 --- a/app/src/lib/helpers/non-fatal-exception.ts +++ b/app/src/lib/helpers/non-fatal-exception.ts @@ -26,7 +26,23 @@ let lastNonFatalException: number | undefined = undefined /** Max one non fatal exeception per minute */ const minIntervalBetweenNonFatalExceptions = 60 * 1000 -export function sendNonFatalException(kind: string, error: Error) { +export type ExceptionKinds = + | 'invalidListSelection' + | 'TooManyPopups' + | 'remoteNameMismatch' + | 'tutorialRepoCreation' + | 'multiCommitOperation' + | 'PullRequestState' + | 'trampolineCommandParser' + | 'trampolineServer' + | 'PopupNoId' + | 'FailedToStartPullRequest' + | 'unhandledRejection' + | 'rebaseConflictsWithBranchAlreadyUpToDate' + | 'forkCreation' + | 'NoSuggestedActionsProvided' + +export function sendNonFatalException(kind: ExceptionKinds, error: Error) { if (getHasOptedOutOfStats()) { return } diff --git a/app/src/lib/helpers/regex.ts b/app/src/lib/helpers/regex.ts index 9e479c45edc..130ff6002b2 100644 --- a/app/src/lib/helpers/regex.ts +++ b/app/src/lib/helpers/regex.ts @@ -1,47 +1,3 @@ -/** - * Get all regex captures within a body of text - * - * @param text string to search - * @param re regex to search with. must have global option and one capture - * - * @returns arrays of strings captured by supplied regex - */ -export function getCaptures( - text: string, - re: RegExp -): ReadonlyArray> { - const matches = getMatches(text, re) - const captures = matches.reduce( - (acc, match) => acc.concat([match.slice(1)]), - new Array>() - ) - return captures -} - -/** - * Get all regex matches within a body of text - * - * @param text string to search - * @param re regex to search with. must have global option - * @returns set of strings captured by supplied regex - */ -export function getMatches(text: string, re: RegExp): Array { - if (re.global === false) { - throw new Error( - 'A regex has been provided that is not marked as global, and has the potential to execute forever if it finds a match' - ) - } - - const matches = new Array() - let match = re.exec(text) - - while (match !== null) { - matches.push(match) - match = re.exec(text) - } - return matches -} - /* * Looks for the phrases "remote: error File " and " is (file size I.E. 106.5 MB); this exceeds GitHub's file size limit of 100.00 MB" * inside of a string containing errors and return an array of all the filenames and their sizes located between these two strings. diff --git a/app/src/lib/helpers/repo-rules.ts b/app/src/lib/helpers/repo-rules.ts index e3fe43d64af..0ad6eee48b7 100644 --- a/app/src/lib/helpers/repo-rules.ts +++ b/app/src/lib/helpers/repo-rules.ts @@ -12,7 +12,6 @@ import { IAPIRepoRuleMetadataParameters, IAPIRepoRuleset, } from '../api' -import { enableRepoRulesBeta } from '../feature-flag' import { supportsRepoRules } from '../endpoint-capabilities' import { Account } from '../../models/account' import { @@ -33,7 +32,6 @@ export function useRepoRulesLogic( if ( !account || !repository || - !enableRepoRulesBeta() || !isRepositoryWithGitHubRepository(repository) ) { return false diff --git a/app/src/lib/http.ts b/app/src/lib/http.ts index af011b326b8..f0d5ac1a409 100644 --- a/app/src/lib/http.ts +++ b/app/src/lib/http.ts @@ -2,7 +2,7 @@ import * as appProxy from '../ui/lib/app-proxy' import { URL } from 'url' /** The HTTP methods available. */ -export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'HEAD' +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'HEAD' | 'DELETE' /** * The structure of error messages returned from the GitHub API. @@ -153,7 +153,7 @@ export function request( } /** Get the user agent to use for all requests. */ -function getUserAgent() { +export function getUserAgent() { const platform = __DARWIN__ ? 'Macintosh' : 'Windows' return `GitHubDesktop/${appProxy.getVersion()} (${platform})` } diff --git a/app/src/lib/ipc-shared.ts b/app/src/lib/ipc-shared.ts index a8d4b3328a9..8b6387b37e8 100644 --- a/app/src/lib/ipc-shared.ts +++ b/app/src/lib/ipc-shared.ts @@ -25,6 +25,7 @@ import { DesktopAliveEvent } from './stores/alive-store' */ export type RequestChannels = { 'select-all-window-contents': () => void + 'dialog-did-open': () => void 'update-menu-state': ( state: Array<{ id: MenuIDs; state: IMenuItemState }> ) => void @@ -81,6 +82,8 @@ export type RequestChannels = { 'notification-event': NotificationCallback 'set-window-zoom-factor': (zoomFactor: number) => void 'show-installing-update': () => void + 'install-windows-cli': () => void + 'uninstall-windows-cli': () => void } /** diff --git a/app/src/lib/markdown-filters/emoji-filter.ts b/app/src/lib/markdown-filters/emoji-filter.ts index 9c6e3f67b76..a42c83f5901 100644 --- a/app/src/lib/markdown-filters/emoji-filter.ts +++ b/app/src/lib/markdown-filters/emoji-filter.ts @@ -2,6 +2,7 @@ import { INodeFilter } from './node-filter' import { fileURLToPath } from 'url' import { readFile } from 'fs/promises' import escapeRegExp from 'lodash/escapeRegExp' +import { Emoji } from '../emoji' /** * The Emoji Markdown filter will take a text node and create multiple text and @@ -17,15 +18,15 @@ import escapeRegExp from 'lodash/escapeRegExp' */ export class EmojiFilter implements INodeFilter { private readonly emojiRegex: RegExp - private readonly emojiFilePath: Map + private readonly allEmoji: Map private readonly emojiBase64URICache: Map = new Map() /** * @param emoji Map from the emoji ref (e.g., :+1:) to the image's local path. */ - public constructor(emojiFilePath: Map) { - this.emojiFilePath = emojiFilePath - this.emojiRegex = this.buildEmojiRegExp(emojiFilePath) + public constructor(emoji: Map) { + this.allEmoji = emoji + this.emojiRegex = this.buildEmojiRegExp(emoji) } /** @@ -66,16 +67,16 @@ export class EmojiFilter implements INodeFilter { return null } - const nodes = new Array() + const nodes = new Array() for (let i = 0; i < emojiMatches.length; i++) { const emojiKey = emojiMatches[i] - const emojiPath = this.emojiFilePath.get(emojiKey) - if (emojiPath === undefined) { + const emoji = this.allEmoji.get(emojiKey) + if (emoji === undefined) { continue } - const emojiImg = await this.createEmojiNode(emojiPath) - if (emojiImg === null) { + const emojiNode = await this.createEmojiNode(emoji) + if (emojiNode === null) { continue } @@ -83,7 +84,7 @@ export class EmojiFilter implements INodeFilter { const textBeforeEmoji = text.slice(0, emojiPosition) const textNodeBeforeEmoji = document.createTextNode(textBeforeEmoji) nodes.push(textNodeBeforeEmoji) - nodes.push(emojiImg) + nodes.push(emojiNode) text = text.slice(emojiPosition + emojiKey.length) } @@ -97,17 +98,25 @@ export class EmojiFilter implements INodeFilter { } /** - * Method to build an emoji image node to insert in place of the emoji ref. - * If we fail to create the image element, returns null. + * Method to build an emoji node to insert in place of the emoji ref. + * If we fail to create the emoji element, returns null. */ private async createEmojiNode( - emojiPath: string - ): Promise { + emoji: Emoji + ): Promise { try { - const dataURI = await this.getBase64FromImageUrl(emojiPath) + if (emoji.emoji) { + const emojiSpan = document.createElement('span') + emojiSpan.classList.add('emoji') + emojiSpan.textContent = emoji.emoji + return emojiSpan + } + + const dataURI = await this.getBase64FromImageUrl(emoji.url) const emojiImg = new Image() emojiImg.classList.add('emoji') emojiImg.src = dataURI + emojiImg.alt = emoji.description ?? '' return emojiImg } catch (e) {} return null @@ -136,7 +145,7 @@ export class EmojiFilter implements INodeFilter { * * @param emoji Map from the emoji ref (e.g., :+1:) to the image's local path. */ - private buildEmojiRegExp(emoji: Map): RegExp { + private buildEmojiRegExp(emoji: Map): RegExp { const emojiGroups = [...emoji.keys()] .map(emoji => escapeRegExp(emoji)) .join('|') diff --git a/app/src/lib/markdown-filters/node-filter.ts b/app/src/lib/markdown-filters/node-filter.ts index 83ad9782600..fddf80765c5 100644 --- a/app/src/lib/markdown-filters/node-filter.ts +++ b/app/src/lib/markdown-filters/node-filter.ts @@ -14,6 +14,7 @@ import { import { CommitMentionLinkFilter } from './commit-mention-link-filter' import { MarkdownEmitter } from './markdown-filter' import { GitHubRepository } from '../../models/github-repository' +import { Emoji } from '../emoji' export interface INodeFilter { /** @@ -39,7 +40,7 @@ export interface INodeFilter { } export interface ICustomMarkdownFilterOptions { - emoji: Map + emoji: Map repository?: GitHubRepository markdownContext?: MarkdownContext } diff --git a/app/src/lib/oauth.ts b/app/src/lib/oauth.ts deleted file mode 100644 index f2692cc92c0..00000000000 --- a/app/src/lib/oauth.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { shell } from './app-shell' -import { Account } from '../models/account' -import { fatalError } from './fatal-error' -import { getOAuthAuthorizationURL, requestOAuthToken, fetchUser } from './api' -import { uuid } from './uuid' - -interface IOAuthState { - readonly state: string - readonly endpoint: string - readonly resolve: (account: Account) => void - readonly reject: (error: Error) => void -} - -let oauthState: IOAuthState | null = null - -/** - * Ask the user to auth with the given endpoint. This will open their browser. - * - * @param endpoint - The endpoint to auth against. - * - * Returns a {Promise} which will resolve when the OAuth flow as been completed. - * Note that the promise may not complete if the user doesn't complete the OAuth - * flow. - */ -export function askUserToOAuth(endpoint: string) { - return new Promise((resolve, reject) => { - oauthState = { state: uuid(), endpoint, resolve, reject } - - const oauthURL = getOAuthAuthorizationURL(endpoint, oauthState.state) - shell.openExternal(oauthURL) - }) -} - -/** - * Request the authenticated using, using the code given to us by the OAuth - * callback. - * - * @returns `undefined` if there is no valid OAuth state to use, or `null` if - * the code cannot be used to retrieve a valid GitHub user. - */ -export async function requestAuthenticatedUser( - code: string, - state: string -): Promise { - if (!oauthState || state !== oauthState.state) { - log.warn( - 'requestAuthenticatedUser was not called with valid OAuth state. This is likely due to a browser reloading the callback URL. Contact GitHub Support if you believe this is an error' - ) - return undefined - } - - const token = await requestOAuthToken(oauthState.endpoint, code) - if (token) { - return fetchUser(oauthState.endpoint, token) - } else { - return null - } -} - -/** - * Resolve the current OAuth request with the given account. - * - * Note that this can only be called after `askUserToOAuth` has been called and - * must only be called once. - */ -export function resolveOAuthRequest(account: Account) { - if (!oauthState) { - fatalError( - '`askUserToOAuth` must be called before resolving an auth request.' - ) - } - - oauthState.resolve(account) - - oauthState = null -} - -/** - * Reject the current OAuth request with the given error. - * - * Note that this can only be called after `askUserToOAuth` has been called and - * must only be called once. - */ -export function rejectOAuthRequest(error: Error) { - if (!oauthState) { - fatalError( - '`askUserToOAuth` must be called before rejecting an auth request.' - ) - } - - oauthState.reject(error) - - oauthState = null -} diff --git a/app/src/lib/parse-carriage-return.ts b/app/src/lib/parse-carriage-return.ts deleted file mode 100644 index 4d0b532d14d..00000000000 --- a/app/src/lib/parse-carriage-return.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Parses carriage returns the same way a terminal would, i.e by - * moving the cursor and (potentially) overwriting text. - * - * Git (and many other CLI tools) use this trick to present the - * user with nice looking progress. When writing something like... - * - * 'Downloading: 1% \r' - * 'Downloading: 2% \r' - * - * ...to the terminal the user is gonna perceive it as if the 1 just - * magically changes to a two. - * - * The carriage return character for all of you kids out there - * that haven't yet played with a manual typewriter refers to the - * "carriage" which held the character arms, see - * - * https://en.wikipedia.org/wiki/Carriage_return#Typewriters - */ -export function parseCarriageReturn(text: string) { - // Happy path, there are no carriage returns in - // the text, making this method a noop. - if (text.indexOf('\r') < 0) { - return text - } - - return text - .split('\n') - .map(line => - line.split('\r').reduce((buf, cur) => - // Happy path, if the new line is equal to or longer - // than the previous, we can just use the new one - // without creating any new strings. - cur.length >= buf.length ? cur : cur + buf.substring(cur.length) - ) - ) - .join('\n') -} diff --git a/app/src/lib/process/win32.ts b/app/src/lib/process/win32.ts index a49358df2f0..ca62be737a4 100644 --- a/app/src/lib/process/win32.ts +++ b/app/src/lib/process/win32.ts @@ -1,11 +1,11 @@ import { spawn as spawnInternal } from 'child_process' -import * as Path from 'path' import { HKEY, RegistryValueType, RegistryValue, RegistryStringEntry, enumerateValues, + setValue, } from 'registry-js' function isStringRegistryValue(rv: RegistryValue): rv is RegistryStringEntry { @@ -15,31 +15,49 @@ function isStringRegistryValue(rv: RegistryValue): rv is RegistryStringEntry { ) } -/** Get the path segments in the user's `Path`. */ -export function getPathSegments(): ReadonlyArray { +export function getPathRegistryValue(): RegistryStringEntry | null { for (const value of enumerateValues(HKEY.HKEY_CURRENT_USER, 'Environment')) { if (value.name === 'Path' && isStringRegistryValue(value)) { - return value.data.split(';').filter(x => x.length > 0) + return value } } - throw new Error('Could not find PATH environment variable') + return null +} + +/** Get the path segments in the user's `Path`. */ +export function getPathSegments(): ReadonlyArray { + const value = getPathRegistryValue() + + if (value === null) { + throw new Error('Could not find PATH environment variable') + } + + return value.data.split(';').filter(x => x.length > 0) } /** Set the user's `Path`. */ export async function setPathSegments( paths: ReadonlyArray ): Promise { - let setxPath: string - const systemRoot = process.env['SystemRoot'] - if (systemRoot) { - const system32Path = Path.join(systemRoot, 'System32') - setxPath = Path.join(system32Path, 'setx.exe') - } else { - setxPath = 'setx.exe' + const value = getPathRegistryValue() + if (value === null) { + throw new Error('Could not find PATH environment variable') } - await spawn(setxPath, ['Path', paths.join(';')]) + try { + setValue( + HKEY.HKEY_CURRENT_USER, + 'Environment', + 'Path', + value.type, + paths.join(';') + ) + } catch (e) { + log.error('Failed setting PATH environment variable', e) + + throw new Error('Could not set the PATH environment variable') + } } /** Spawn a command with arguments and capture its output. */ diff --git a/app/src/lib/progress/from-process.ts b/app/src/lib/progress/from-process.ts index d86de2e43be..4c19dc8284e 100644 --- a/app/src/lib/progress/from-process.ts +++ b/app/src/lib/progress/from-process.ts @@ -16,11 +16,13 @@ import { tailByLine } from '../file-system' * If the given options object already has a processCallback specified it will * be overwritten. */ -export async function executionOptionsWithProgress( - options: IGitExecutionOptions, +export async function executionOptionsWithProgress< + T extends IGitExecutionOptions +>( + options: T, parser: GitProgressParser, progressCallback: (progress: IGitProgress | IGitOutput) => void -): Promise { +): Promise { let lfsProgressPath = null let env = {} if (options.trackLFSProgress) { diff --git a/app/src/lib/read-emoji.ts b/app/src/lib/read-emoji.ts index d5a67f2b970..2cc229ae7d2 100644 --- a/app/src/lib/read-emoji.ts +++ b/app/src/lib/read-emoji.ts @@ -1,6 +1,7 @@ import * as Fs from 'fs' import * as Path from 'path' import { encodePathAsUrl } from './path' +import { Emoji } from './emoji' /** * Type representing the contents of the gemoji json database @@ -81,8 +82,8 @@ function getUrlFromUnicodeEmoji(emoji: string): string | null { * * @param rootDir - The folder containing the entry point (index.html or main.js) of the application. */ -export function readEmoji(rootDir: string): Promise> { - return new Promise>((resolve, reject) => { +export function readEmoji(rootDir: string): Promise> { + return new Promise>((resolve, reject) => { const path = Path.join(rootDir, 'emoji.json') Fs.readFile(path, 'utf8', (err, data) => { if (err) { @@ -90,7 +91,7 @@ export function readEmoji(rootDir: string): Promise> { return } - const tmp = new Map() + const tmp = new Map() try { const db: IGemojiDb = JSON.parse(data) @@ -107,14 +108,17 @@ export function readEmoji(rootDir: string): Promise> { } emoji.aliases.forEach(alias => { - tmp.set(`:${alias}:`, url) + tmp.set(`:${alias}:`, { + ...emoji, + url, + }) }) }) } catch (e) { reject(e) } - const emoji = new Map() + const emoji = new Map() // Sort and insert into actual map const keys = Array.from(tmp.keys()).sort() diff --git a/app/src/lib/shells/darwin.ts b/app/src/lib/shells/darwin.ts index f8363699c2d..0828ccd483b 100644 --- a/app/src/lib/shells/darwin.ts +++ b/app/src/lib/shells/darwin.ts @@ -1,8 +1,14 @@ import { spawn, ChildProcess } from 'child_process' import { assertNever } from '../fatal-error' -import { IFoundShell } from './found-shell' import appPath from 'app-path' import { parseEnumValue } from '../enum' +import { FoundShell } from './shared' +import { + expandTargetPathArgument, + ICustomIntegration, + parseCustomIntegrationArguments, + spawnCustomIntegration, +} from '../custom-integration' export enum Shell { Terminal = 'Terminal', @@ -22,113 +28,142 @@ export function parse(label: string): Shell { return parseEnumValue(Shell, label) ?? Default } -function getBundleID(shell: Shell): string { +function getBundleIDs(shell: Shell): ReadonlyArray { switch (shell) { case Shell.Terminal: - return 'com.apple.Terminal' + return ['com.apple.Terminal'] case Shell.iTerm2: - return 'com.googlecode.iterm2' + return ['com.googlecode.iterm2'] case Shell.Hyper: - return 'co.zeit.hyper' + return ['co.zeit.hyper'] case Shell.PowerShellCore: - return 'com.microsoft.powershell' + return ['com.microsoft.powershell'] case Shell.Kitty: - return 'net.kovidgoyal.kitty' + return ['net.kovidgoyal.kitty'] case Shell.Alacritty: - return 'io.alacritty' + return ['org.alacritty', 'io.alacritty'] case Shell.Tabby: - return 'org.tabby' + return ['org.tabby'] case Shell.WezTerm: - return 'com.github.wez.wezterm' + return ['com.github.wez.wezterm'] case Shell.Warp: - return 'dev.warp.Warp-Stable' + return ['dev.warp.Warp-Stable'] default: return assertNever(shell, `Unknown shell: ${shell}`) } } -async function getShellPath(shell: Shell): Promise { - const bundleId = getBundleID(shell) - try { - return await appPath(bundleId) - } catch (e) { - // `appPath` will raise an error if it cannot find the program. - return null +async function getShellInfo( + shell: Shell +): Promise<{ path: string; bundleID: string } | null> { + const bundleIds = getBundleIDs(shell) + for (const id of bundleIds) { + try { + const path = await appPath(id) + return { path, bundleID: id } + } catch (error) { + log.debug( + `Unable to locate ${shell} installation with bundle id ${id}`, + error + ) + } } + + return null } export async function getAvailableShells(): Promise< - ReadonlyArray> + ReadonlyArray> > { const [ - terminalPath, - hyperPath, - iTermPath, - powerShellCorePath, - kittyPath, - alacrittyPath, - tabbyPath, - wezTermPath, - warpPath, + terminalInfo, + hyperInfo, + iTermInfo, + powerShellCoreInfo, + kittyInfo, + alacrittyInfo, + tabbyInfo, + wezTermInfo, + warpInfo, ] = await Promise.all([ - getShellPath(Shell.Terminal), - getShellPath(Shell.Hyper), - getShellPath(Shell.iTerm2), - getShellPath(Shell.PowerShellCore), - getShellPath(Shell.Kitty), - getShellPath(Shell.Alacritty), - getShellPath(Shell.Tabby), - getShellPath(Shell.WezTerm), - getShellPath(Shell.Warp), + getShellInfo(Shell.Terminal), + getShellInfo(Shell.Hyper), + getShellInfo(Shell.iTerm2), + getShellInfo(Shell.PowerShellCore), + getShellInfo(Shell.Kitty), + getShellInfo(Shell.Alacritty), + getShellInfo(Shell.Tabby), + getShellInfo(Shell.WezTerm), + getShellInfo(Shell.Warp), ]) - const shells: Array> = [] - if (terminalPath) { - shells.push({ shell: Shell.Terminal, path: terminalPath }) + const shells: Array> = [] + if (terminalInfo) { + shells.push({ shell: Shell.Terminal, ...terminalInfo }) } - if (hyperPath) { - shells.push({ shell: Shell.Hyper, path: hyperPath }) + if (hyperInfo) { + shells.push({ shell: Shell.Hyper, ...hyperInfo }) } - if (iTermPath) { - shells.push({ shell: Shell.iTerm2, path: iTermPath }) + if (iTermInfo) { + shells.push({ shell: Shell.iTerm2, ...iTermInfo }) } - if (powerShellCorePath) { - shells.push({ shell: Shell.PowerShellCore, path: powerShellCorePath }) + if (powerShellCoreInfo) { + shells.push({ shell: Shell.PowerShellCore, ...powerShellCoreInfo }) } - if (kittyPath) { - const kittyExecutable = `${kittyPath}/Contents/MacOS/kitty` - shells.push({ shell: Shell.Kitty, path: kittyExecutable }) + if (kittyInfo) { + const kittyExecutable = `${kittyInfo.path}/Contents/MacOS/kitty` + shells.push({ + shell: Shell.Kitty, + path: kittyExecutable, + bundleID: kittyInfo.bundleID, + }) } - if (alacrittyPath) { - const alacrittyExecutable = `${alacrittyPath}/Contents/MacOS/alacritty` - shells.push({ shell: Shell.Alacritty, path: alacrittyExecutable }) + if (alacrittyInfo) { + const alacrittyExecutable = `${alacrittyInfo.path}/Contents/MacOS/alacritty` + shells.push({ + shell: Shell.Alacritty, + path: alacrittyExecutable, + bundleID: alacrittyInfo.bundleID, + }) } - if (tabbyPath) { - const tabbyExecutable = `${tabbyPath}/Contents/MacOS/Tabby` - shells.push({ shell: Shell.Tabby, path: tabbyExecutable }) + if (tabbyInfo) { + const tabbyExecutable = `${tabbyInfo.path}/Contents/MacOS/Tabby` + shells.push({ + shell: Shell.Tabby, + path: tabbyExecutable, + bundleID: tabbyInfo.bundleID, + }) } - if (wezTermPath) { - const wezTermExecutable = `${wezTermPath}/Contents/MacOS/wezterm` - shells.push({ shell: Shell.WezTerm, path: wezTermExecutable }) + if (wezTermInfo) { + const wezTermExecutable = `${wezTermInfo.path}/Contents/MacOS/wezterm` + shells.push({ + shell: Shell.WezTerm, + path: wezTermExecutable, + bundleID: wezTermInfo.bundleID, + }) } - if (warpPath) { - const warpExecutable = `${warpPath}/Contents/MacOS/stable` - shells.push({ shell: Shell.Warp, path: warpExecutable }) + if (warpInfo) { + const warpExecutable = `${warpInfo.path}/Contents/MacOS/stable` + shells.push({ + shell: Shell.Warp, + path: warpExecutable, + bundleID: warpInfo.bundleID, + }) } return shells } export function launch( - foundShell: IFoundShell, + foundShell: FoundShell, path: string ): ChildProcess { if (foundShell.shell === Shell.Kitty) { @@ -158,7 +193,18 @@ export function launch( // the working directory, followed by the path. return spawn(foundShell.path, ['start', '--cwd', path]) } else { - const bundleID = getBundleID(foundShell.shell) - return spawn('open', ['-b', bundleID, path]) + return spawn('open', ['-b', foundShell.bundleID, path]) } } + +export function launchCustomShell( + customShell: ICustomIntegration, + path: string +): ChildProcess { + const argv = parseCustomIntegrationArguments(customShell.arguments) + const args = expandTargetPathArgument(argv, path) + + return customShell.bundleID + ? spawnCustomIntegration('open', ['-b', customShell.bundleID, ...args]) + : spawnCustomIntegration(customShell.path, args) +} diff --git a/app/src/lib/shells/found-shell.ts b/app/src/lib/shells/found-shell.ts deleted file mode 100644 index 89934594313..00000000000 --- a/app/src/lib/shells/found-shell.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface IFoundShell { - readonly shell: T - readonly path: string - readonly extraArgs?: string[] -} diff --git a/app/src/lib/shells/linux.ts b/app/src/lib/shells/linux.ts index 7ab0a4a5e89..afb8087cc31 100644 --- a/app/src/lib/shells/linux.ts +++ b/app/src/lib/shells/linux.ts @@ -1,11 +1,18 @@ import { spawn, ChildProcess } from 'child_process' import { assertNever } from '../fatal-error' -import { IFoundShell } from './found-shell' import { parseEnumValue } from '../enum' import { pathExists } from '../../ui/lib/path-exists' +import { FoundShell } from './shared' +import { + expandTargetPathArgument, + ICustomIntegration, + parseCustomIntegrationArguments, + spawnCustomIntegration, +} from '../custom-integration' export enum Shell { Gnome = 'GNOME Terminal', + GnomeConsole = 'GNOME Console', Mate = 'MATE Terminal', Tilix = 'Tilix', Terminator = 'Terminator', @@ -18,6 +25,8 @@ export enum Shell { XFCE = 'XFCE Terminal', Alacritty = 'Alacritty', Kitty = 'Kitty', + LXTerminal = 'LXDE Terminal', + Warp = 'Warp', } export const Default = Shell.Gnome @@ -34,6 +43,8 @@ function getShellPath(shell: Shell): Promise { switch (shell) { case Shell.Gnome: return getPathIfAvailable('/usr/bin/gnome-terminal') + case Shell.GnomeConsole: + return getPathIfAvailable('/usr/bin/kgx') case Shell.Mate: return getPathIfAvailable('/usr/bin/mate-terminal') case Shell.Tilix: @@ -58,16 +69,21 @@ function getShellPath(shell: Shell): Promise { return getPathIfAvailable('/usr/bin/alacritty') case Shell.Kitty: return getPathIfAvailable('/usr/bin/kitty') + case Shell.LXTerminal: + return getPathIfAvailable('/usr/bin/lxterminal') + case Shell.Warp: + return getPathIfAvailable('/usr/bin/warp-terminal') default: return assertNever(shell, `Unknown shell: ${shell}`) } } export async function getAvailableShells(): Promise< - ReadonlyArray> + ReadonlyArray> > { const [ gnomeTerminalPath, + gnomeConsolePath, mateTerminalPath, tilixPath, terminatorPath, @@ -80,8 +96,11 @@ export async function getAvailableShells(): Promise< xfcePath, alacrittyPath, kittyPath, + lxterminalPath, + warpPath, ] = await Promise.all([ getShellPath(Shell.Gnome), + getShellPath(Shell.GnomeConsole), getShellPath(Shell.Mate), getShellPath(Shell.Tilix), getShellPath(Shell.Terminator), @@ -94,13 +113,19 @@ export async function getAvailableShells(): Promise< getShellPath(Shell.XFCE), getShellPath(Shell.Alacritty), getShellPath(Shell.Kitty), + getShellPath(Shell.LXTerminal), + getShellPath(Shell.Warp), ]) - const shells: Array> = [] + const shells: Array> = [] if (gnomeTerminalPath) { shells.push({ shell: Shell.Gnome, path: gnomeTerminalPath }) } + if (gnomeConsolePath) { + shells.push({ shell: Shell.GnomeConsole, path: gnomeConsolePath }) + } + if (mateTerminalPath) { shells.push({ shell: Shell.Mate, path: mateTerminalPath }) } @@ -149,16 +174,25 @@ export async function getAvailableShells(): Promise< shells.push({ shell: Shell.Kitty, path: kittyPath }) } + if (lxterminalPath) { + shells.push({ shell: Shell.LXTerminal, path: lxterminalPath }) + } + + if (warpPath) { + shells.push({ shell: Shell.Warp, path: warpPath }) + } + return shells } export function launch( - foundShell: IFoundShell, + foundShell: FoundShell, path: string ): ChildProcess { const shell = foundShell.shell switch (shell) { case Shell.Gnome: + case Shell.GnomeConsole: case Shell.Mate: case Shell.Tilix: case Shell.Terminator: @@ -179,7 +213,20 @@ export function launch( return spawn(foundShell.path, ['-w', path]) case Shell.Kitty: return spawn(foundShell.path, ['--single-instance', '--directory', path]) + case Shell.LXTerminal: + return spawn(foundShell.path, ['--working-directory=' + path]) + case Shell.Warp: + return spawn(foundShell.path, [], { cwd: path }) default: return assertNever(shell, `Unknown shell: ${shell}`) } } + +export function launchCustomShell( + customShell: ICustomIntegration, + path: string +): ChildProcess { + const argv = parseCustomIntegrationArguments(customShell.arguments) + const args = expandTargetPathArgument(argv, path) + return spawnCustomIntegration(customShell.path, args) +} diff --git a/app/src/lib/shells/shared.ts b/app/src/lib/shells/shared.ts index 2f8c8be10a8..25e52bfd3ea 100644 --- a/app/src/lib/shells/shared.ts +++ b/app/src/lib/shells/shared.ts @@ -3,13 +3,23 @@ import { ChildProcess } from 'child_process' import * as Darwin from './darwin' import * as Win32 from './win32' import * as Linux from './linux' -import { IFoundShell } from './found-shell' import { ShellError } from './error' import { pathExists } from '../../ui/lib/path-exists' +import { ICustomIntegration } from '../custom-integration' export type Shell = Darwin.Shell | Win32.Shell | Linux.Shell -export type FoundShell = IFoundShell +export type FoundShell = { + readonly shell: T + readonly path: string + readonly extraArgs?: ReadonlyArray +} & (T extends Darwin.Shell + ? { + readonly bundleID: string + } + : {}) + +type AnyFoundShell = FoundShell /** The default shell. */ export const Default = (function () { @@ -22,7 +32,7 @@ export const Default = (function () { } })() -let shellCache: ReadonlyArray | null = null +let shellCache: ReadonlyArray | null = null /** Parse the label into the specified shell type. */ export function parse(label: string): Shell { @@ -40,7 +50,9 @@ export function parse(label: string): Shell { } /** Get the shells available for the user. */ -export async function getAvailableShells(): Promise> { +export async function getAvailableShells(): Promise< + ReadonlyArray +> { if (shellCache) { return shellCache } @@ -62,7 +74,7 @@ export async function getAvailableShells(): Promise> { } /** Find the given shell or the default if the given shell can't be found. */ -export async function findShellOrDefault(shell: Shell): Promise { +export async function findShellOrDefault(shell: Shell): Promise { const available = await getAvailableShells() const found = available.find(s => s.shell === shell) if (found) { @@ -74,7 +86,7 @@ export async function findShellOrDefault(shell: Shell): Promise { /** Launch the given shell at the path. */ export async function launchShell( - shell: FoundShell, + shell: AnyFoundShell, path: string, onError: (error: Error) => void ): Promise { @@ -92,11 +104,11 @@ export async function launchShell( let cp: ChildProcess | null = null if (__DARWIN__) { - cp = Darwin.launch(shell as IFoundShell, path) + cp = Darwin.launch(shell as FoundShell, path) } else if (__WIN32__) { - cp = Win32.launch(shell as IFoundShell, path) + cp = Win32.launch(shell as FoundShell, path) } else if (__LINUX__) { - cp = Linux.launch(shell as IFoundShell, path) + cp = Linux.launch(shell as FoundShell, path) } if (cp != null) { @@ -109,8 +121,45 @@ export async function launchShell( } } +/** Launch custom shell at the path. */ +export async function launchCustomShell( + customShell: ICustomIntegration, + path: string, + onError: (error: Error) => void +): Promise { + // We have to manually cast the wider `Shell` type into the platform-specific + // type. This is less than ideal, but maybe the best we can do without + // platform-specific build targets. + const exists = await pathExists(customShell.path) + if (!exists) { + const label = __DARWIN__ ? 'Settings' : 'Options' + throw new ShellError( + `Could not find executable for custom shell at path '${customShell.path}'. Please open ${label} and select an available shell.` + ) + } + + let cp: ChildProcess | null = null + + if (__DARWIN__) { + cp = Darwin.launchCustomShell(customShell, path) + } else if (__WIN32__) { + cp = Win32.launchCustomShell(customShell, path) + } else if (__LINUX__) { + cp = Linux.launchCustomShell(customShell, path) + } + + if (cp != null) { + addErrorTracing('Custom Shell', cp, onError) + return Promise.resolve() + } else { + return Promise.reject( + `Platform not currently supported for launching shells: ${process.platform}` + ) + } +} + function addErrorTracing( - shell: Shell, + shell: Shell | 'Custom Shell', cp: ChildProcess, onError: (error: Error) => void ) { diff --git a/app/src/lib/shells/win32.ts b/app/src/lib/shells/win32.ts index 8609d85e13c..6c01d700569 100644 --- a/app/src/lib/shells/win32.ts +++ b/app/src/lib/shells/win32.ts @@ -2,11 +2,17 @@ import { spawn, ChildProcess } from 'child_process' import * as Path from 'path' import { enumerateValues, HKEY, RegistryValueType } from 'registry-js' import { assertNever } from '../fatal-error' -import { IFoundShell } from './found-shell' import { enableWSLDetection } from '../feature-flag' import { findGitOnPath } from '../is-git-on-path' import { parseEnumValue } from '../enum' import { pathExists } from '../../ui/lib/path-exists' +import { FoundShell } from './shared' +import { + expandTargetPathArgument, + ICustomIntegration, + parseCustomIntegrationArguments, + spawnCustomIntegration, +} from '../custom-integration' export enum Shell { Cmd = 'Command Prompt', @@ -28,12 +34,12 @@ export function parse(label: string): Shell { } export async function getAvailableShells(): Promise< - ReadonlyArray> + ReadonlyArray> > { const gitPath = await findGitOnPath() const rootDir = process.env.WINDIR || 'C:\\Windows' const dosKeyExePath = `"${rootDir}\\system32\\doskey.exe git=^"${gitPath}^" $*"` - const shells: IFoundShell[] = [ + const shells: FoundShell[] = [ { shell: Shell.Cmd, path: process.env.comspec || 'C:\\Windows\\System32\\cmd.exe', @@ -392,7 +398,7 @@ async function findFluentTerminal(): Promise { } export function launch( - foundShell: IFoundShell, + foundShell: FoundShell, path: string ): ChildProcess { const shell = foundShell.shell @@ -475,3 +481,16 @@ export function launch( return assertNever(shell, `Unknown shell: ${shell}`) } } + +export function launchCustomShell( + customShell: ICustomIntegration, + path: string +): ChildProcess { + log.info(`launching custom shell at path: ${customShell.path}`) + const argv = parseCustomIntegrationArguments(customShell.arguments) + const args = expandTargetPathArgument(argv, path) + return spawnCustomIntegration(`"${customShell.path}"`, args, { + shell: true, + cwd: path, + }) +} diff --git a/app/src/lib/ssh/ssh-credential-storage.ts b/app/src/lib/ssh/ssh-credential-storage.ts new file mode 100644 index 00000000000..35c7ba11f1a --- /dev/null +++ b/app/src/lib/ssh/ssh-credential-storage.ts @@ -0,0 +1,87 @@ +import { TokenStore } from '../stores' + +const appName = __DEV__ ? 'GitHub Desktop Dev' : 'GitHub Desktop' + +export function getSSHCredentialStoreKey(name: string) { + return `${appName} - ${name}` +} + +type SSHCredentialEntry = { + /** Store where this entry is stored. */ + store: string + + /** Key used to identify the credential in the store (e.g. username or hash). */ + key: string +} + +/** + * This map contains the SSH credentials that are pending to be stored. What this + * means is that a git operation is currently in progress, and the user wanted + * to store the passphrase for the SSH key, however we don't want to store it + * until we know the git operation finished successfully. + */ +const mostRecentSSHCredentials = new Map() + +/** + * Stores an SSH credential and also keeps it in memory to be deleted later if + * the ongoing git operation fails to authenticate. + * + * @param operationGUID A unique identifier for the ongoing git operation. In + * practice, it will always be the trampoline token for the + * ongoing git operation. + * @param store Store where the SSH credential is stored. + * @param key Key that identifies the SSH credential (e.g. username or + * key hash). + * @param password Password for the SSH key / user. + */ +export async function setSSHCredential( + operationGUID: string, + store: string, + key: string, + password: string +) { + setMostRecentSSHCredential(operationGUID, store, key) + await TokenStore.setItem(store, key, password) +} + +/** + * Keeps the SSH credential details in memory to be deleted later if the ongoing + * git operation fails to authenticate. + * + * @param operationGUID A unique identifier for the ongoing git operation. In + * practice, it will always be the trampoline token for the + * ongoing git operation. + * @param store Store where the SSH credential is stored. + * @param key Key that identifies the SSH credential (e.g. username or + * key hash). + */ +export function setMostRecentSSHCredential( + operationGUID: string, + store: string, + key: string +) { + mostRecentSSHCredentials.set(operationGUID, { store, key }) +} + +/** + * Removes the SSH credential from memory. This must be used after a git + * operation finished, regardless the result. + */ +export function removeMostRecentSSHCredential(operationGUID: string) { + mostRecentSSHCredentials.delete(operationGUID) +} + +/** + * Deletes the SSH credential from the TokenStore. Used when the git operation + * fails to authenticate. + */ +export async function deleteMostRecentSSHCredential(operationGUID: string) { + const entry = mostRecentSSHCredentials.get(operationGUID) + if (entry) { + log.info( + `SSH auth failed, deleting credential for ${entry.store}:${entry.key}` + ) + + await TokenStore.deleteItem(entry.store, entry.key) + } +} diff --git a/app/src/lib/ssh/ssh-key-passphrase.ts b/app/src/lib/ssh/ssh-key-passphrase.ts index aeaa7dedad0..b20c4569534 100644 --- a/app/src/lib/ssh/ssh-key-passphrase.ts +++ b/app/src/lib/ssh/ssh-key-passphrase.ts @@ -1,11 +1,12 @@ import { getFileHash } from '../get-file-hash' import { TokenStore } from '../stores' import { - getSSHSecretStoreKey, - keepSSHSecretToStore, -} from './ssh-secret-storage' + getSSHCredentialStoreKey, + setMostRecentSSHCredential, + setSSHCredential, +} from './ssh-credential-storage' -const SSHKeyPassphraseTokenStoreKey = getSSHSecretStoreKey( +const SSHKeyPassphraseTokenStoreKey = getSSHCredentialStoreKey( 'SSH key passphrases' ) @@ -25,8 +26,7 @@ export async function getSSHKeyPassphrase(keyPath: string) { } /** - * Keeps the SSH key passphrase in memory to be stored later if the ongoing git - * operation succeeds. + * Stores the SSH key passphrase. * * @param operationGUID A unique identifier for the ongoing git operation. In * practice, it will always be the trampoline token for the @@ -34,14 +34,15 @@ export async function getSSHKeyPassphrase(keyPath: string) { * @param keyPath Path to the SSH key. * @param passphrase Passphrase for the SSH key. */ -export async function keepSSHKeyPassphraseToStore( +export async function setSSHKeyPassphrase( operationGUID: string, keyPath: string, passphrase: string ) { try { const keyHash = await getHashForSSHKey(keyPath) - keepSSHSecretToStore( + + await setSSHCredential( operationGUID, SSHKeyPassphraseTokenStoreKey, keyHash, @@ -51,3 +52,29 @@ export async function keepSSHKeyPassphraseToStore( log.error('Could not store passphrase for SSH key:', e) } } + +/** + * Keeps the SSH credential details in memory to be deleted later if the ongoing + * git operation fails to authenticate. + * + * @param operationGUID A unique identifier for the ongoing git operation. In + * practice, it will always be the trampoline secret for the + * ongoing git operation. + * @param keyPath Path of the SSH key. + */ +export async function setMostRecentSSHKeyPassphrase( + operationGUID: string, + keyPath: string +) { + try { + const keyHash = await getHashForSSHKey(keyPath) + + setMostRecentSSHCredential( + operationGUID, + SSHKeyPassphraseTokenStoreKey, + keyHash + ) + } catch (e) { + log.error('Could not store passphrase for SSH key:', e) + } +} diff --git a/app/src/lib/ssh/ssh-secret-storage.ts b/app/src/lib/ssh/ssh-secret-storage.ts deleted file mode 100644 index 7f651591f53..00000000000 --- a/app/src/lib/ssh/ssh-secret-storage.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { TokenStore } from '../stores' - -const appName = __DEV__ ? 'GitHub Desktop Dev' : 'GitHub Desktop' - -export function getSSHSecretStoreKey(name: string) { - return `${appName} - ${name}` -} - -type SSHSecretEntry = { - /** Store where this entry will be stored. */ - store: string - - /** Key used to identify the secret in the store (e.g. username or hash). */ - key: string - - /** Actual secret to be stored (password). */ - secret: string -} - -/** - * This map contains the SSH secrets that are pending to be stored. What this - * means is that a git operation is currently in progress, and the user wanted - * to store the passphrase for the SSH key, however we don't want to store it - * until we know the git operation finished successfully. - */ -const SSHSecretsToStore = new Map() - -/** - * Keeps the SSH secret in memory to be stored later if the ongoing git operation - * succeeds. - * - * @param operationGUID A unique identifier for the ongoing git operation. In - * practice, it will always be the trampoline secret for the - * ongoing git operation. - * @param key Key that identifies the SSH secret (e.g. username or key - * hash). - * @param secret Actual SSH secret to store. - */ -export async function keepSSHSecretToStore( - operationGUID: string, - store: string, - key: string, - secret: string -) { - SSHSecretsToStore.set(operationGUID, { store, key, secret }) -} - -/** Removes the SSH key passphrase from memory. */ -export function removePendingSSHSecretToStore(operationGUID: string) { - SSHSecretsToStore.delete(operationGUID) -} - -/** Stores a pending SSH key passphrase if the operation succeeded. */ -export async function storePendingSSHSecret(operationGUID: string) { - const entry = SSHSecretsToStore.get(operationGUID) - if (entry === undefined) { - return - } - - await TokenStore.setItem(entry.store, entry.key, entry.secret) -} diff --git a/app/src/lib/ssh/ssh-user-password.ts b/app/src/lib/ssh/ssh-user-password.ts index 4bb25c43012..e4f930183c3 100644 --- a/app/src/lib/ssh/ssh-user-password.ts +++ b/app/src/lib/ssh/ssh-user-password.ts @@ -1,10 +1,12 @@ import { TokenStore } from '../stores' import { - getSSHSecretStoreKey, - keepSSHSecretToStore, -} from './ssh-secret-storage' + getSSHCredentialStoreKey, + setMostRecentSSHCredential, + setSSHCredential, +} from './ssh-credential-storage' -const SSHUserPasswordTokenStoreKey = getSSHSecretStoreKey('SSH user password') +const SSHUserPasswordTokenStoreKey = + getSSHCredentialStoreKey('SSH user password') /** Retrieves the password for the given SSH username. */ export async function getSSHUserPassword(username: string) { @@ -17,8 +19,7 @@ export async function getSSHUserPassword(username: string) { } /** - * Keeps the SSH user password in memory to be stored later if the ongoing git - * operation succeeds. + * Stores the SSH user password. * * @param operationGUID A unique identifier for the ongoing git operation. In * practice, it will always be the trampoline token for the @@ -26,15 +27,35 @@ export async function getSSHUserPassword(username: string) { * @param username SSH user name. Usually in the form of `user@hostname`. * @param password Password for the given user. */ -export async function keepSSHUserPasswordToStore( +export async function setSSHUserPassword( operationGUID: string, username: string, password: string ) { - keepSSHSecretToStore( + await setSSHCredential( operationGUID, SSHUserPasswordTokenStoreKey, username, password ) } + +/** + * Keeps the SSH credential details in memory to be deleted later if the ongoing + * git operation fails to authenticate. + * + * @param operationGUID A unique identifier for the ongoing git operation. In + * practice, it will always be the trampoline secret for the + * ongoing git operation. + * @param username SSH user name. + */ +export function setMostRecentSSHUserPassword( + operationGUID: string, + username: string +) { + setMostRecentSSHCredential( + operationGUID, + SSHUserPasswordTokenStoreKey, + username + ) +} diff --git a/app/src/lib/ssh/ssh.ts b/app/src/lib/ssh/ssh.ts index c77f2650fea..856cc0b11d5 100644 --- a/app/src/lib/ssh/ssh.ts +++ b/app/src/lib/ssh/ssh.ts @@ -2,7 +2,7 @@ import memoizeOne from 'memoize-one' import { pathExists } from '../../ui/lib/path-exists' import { getBoolean } from '../local-storage' import { - getDesktopTrampolinePath, + getDesktopAskpassTrampolinePath, getSSHWrapperPath, } from '../trampoline/trampoline-environment' @@ -43,7 +43,7 @@ function isWindowsOpenSSHUseEnabled() { */ export async function getSSHEnvironment() { const baseEnv = { - SSH_ASKPASS: getDesktopTrampolinePath(), + SSH_ASKPASS: getDesktopAskpassTrampolinePath(), // DISPLAY needs to be set to _something_ so ssh actually uses SSH_ASKPASS DISPLAY: '.', } diff --git a/app/src/lib/stats/stats-database.ts b/app/src/lib/stats/stats-database.ts index 3ebdd8a4107..66bff8a4610 100644 --- a/app/src/lib/stats/stats-database.ts +++ b/app/src/lib/stats/stats-database.ts @@ -122,7 +122,7 @@ export interface IDailyMeasures { */ readonly enterpriseCommits: number - /** The number of times the user made a commit to a repo hosted on Github.com */ + /** The number of times the user made a commit to a repo hosted on GitHub.com */ readonly dotcomCommits: number /** The number of times the user made a commit to a protected GitHub or GitHub Enterprise repository */ diff --git a/app/src/lib/stats/stats-store.ts b/app/src/lib/stats/stats-store.ts index 5dc4d8d809c..27c99f8bb43 100644 --- a/app/src/lib/stats/stats-store.ts +++ b/app/src/lib/stats/stats-store.ts @@ -9,7 +9,14 @@ import { merge } from '../../lib/merge' import { getPersistedThemeName } from '../../ui/lib/application-theme' import { IUiActivityMonitor } from '../../ui/lib/ui-activity-monitor' import { Disposable } from 'event-kit' -import { SignInMethod } from '../stores' +import { + showDiffCheckMarksDefault, + showDiffCheckMarksKey, + underlineLinksDefault, + underlineLinksKey, + useCustomEditorKey, + useCustomShellKey, +} from '../stores' import { assertNever } from '../fatal-error' import { getNumber, @@ -28,6 +35,7 @@ import { getNotificationsEnabled } from '../stores/notifications-store' import { isInApplicationFolder } from '../../ui/main-process-proxy' import { getRendererGUID } from '../get-renderer-guid' import { ValidNotificationPullRequestReviewState } from '../valid-notification-pull-request-review' +import { useExternalCredentialHelperKey } from '../trampoline/use-external-credential-helper' type PullRequestReviewStatFieldInfix = | 'Approved' @@ -64,7 +72,6 @@ const FirstCommitCreatedAtKey = 'first-commit-created-at' const FirstPushToGitHubAtKey = 'first-push-to-github-at' const FirstNonDefaultBranchCheckoutAtKey = 'first-non-default-branch-checkout-at' -const WelcomeWizardSignInMethodKey = 'welcome-wizard-sign-in-method' const terminalEmulatorKey = 'shell' const textEditorKey: string = 'externalEditor' @@ -310,14 +317,6 @@ interface IOnboardingStats { * 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 { @@ -386,6 +385,18 @@ interface ICalculatedStats { /** Whether or not the user has enabled high-signal notifications */ readonly notificationsEnabled: boolean + + /** Whether or not the user has their accessibility setting set for viewing link underlines */ + readonly linkUnderlinesVisible: boolean + + /** Whether or not the user has their accessibility setting set for viewing diff check marks */ + readonly diffCheckMarksVisible: boolean + + /** + * Whether or not the user has enabled the external credential helper or null + * if the user has not yet made an active decision + **/ + readonly useExternalCredentialHelper?: boolean | null } type DailyStats = ICalculatedStats & @@ -410,19 +421,25 @@ export interface IStatsStore { ) => void } +const defaultPostImplementation = (body: Record) => + fetch(StatsEndpoint, { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify(body), + }) + /** The store for the app's stats. */ export class StatsStore implements IStatsStore { - private readonly db: StatsDatabase - private readonly uiActivityMonitor: IUiActivityMonitor private uiActivityMonitorSubscription: Disposable | null = null /** Has the user opted out of stats reporting? */ private optOut: boolean - public constructor(db: StatsDatabase, uiActivityMonitor: IUiActivityMonitor) { - this.db = db - this.uiActivityMonitor = uiActivityMonitor - + public constructor( + private readonly db: StatsDatabase, + private readonly uiActivityMonitor: IUiActivityMonitor, + private readonly post = defaultPostImplementation + ) { const storedValue = getHasOptedOutOfStats() this.optOut = storedValue || false @@ -545,13 +562,29 @@ export class StatsStore implements IStatsStore { const userType = this.determineUserType(accounts) const repositoryCounts = this.categorizedRepositoryCounts(repositories) const onboardingStats = this.getOnboardingStats() - const selectedTerminalEmulator = - localStorage.getItem(terminalEmulatorKey) || 'none' - const selectedTextEditor = localStorage.getItem(textEditorKey) || 'none' + const useCustomShell = getBoolean(useCustomShellKey, false) + const selectedTerminalEmulator = useCustomShell + ? 'custom' + : localStorage.getItem(terminalEmulatorKey) || 'none' + const useCustomEditor = getBoolean(useCustomEditorKey, false) + const selectedTextEditor = useCustomEditor + ? 'custom' + : localStorage.getItem(textEditorKey) || 'none' const repositoriesCommittedInWithoutWriteAccess = getNumberArray( RepositoriesCommittedInWithoutWriteAccessKey ).length const diffMode = getShowSideBySideDiff() ? 'split' : 'unified' + const linkUnderlinesVisible = getBoolean( + underlineLinksKey, + underlineLinksDefault + ) + const diffCheckMarksVisible = getBoolean( + showDiffCheckMarksKey, + showDiffCheckMarksDefault + ) + + const useExternalCredentialHelper = + getBoolean(useExternalCredentialHelperKey) ?? null // isInApplicationsFolder is undefined when not running on Darwin const launchedFromApplicationsFolder = __DARWIN__ @@ -577,6 +610,9 @@ export class StatsStore implements IStatsStore { repositoriesCommittedInWithoutWriteAccess, diffMode, launchedFromApplicationsFolder, + linkUnderlinesVisible, + diffCheckMarksVisible, + useExternalCredentialHelper, } } @@ -602,8 +638,6 @@ export class StatsStore implements IStatsStore { FirstNonDefaultBranchCheckoutAtKey ) - const welcomeWizardSignInMethod = getWelcomeWizardSignInMethod() - return { timeToWelcomeWizardTerminated, timeToFirstAddedRepository, @@ -612,7 +646,6 @@ export class StatsStore implements IStatsStore { timeToFirstCommit, timeToFirstGitHubPush, timeToFirstNonDefaultBranchCheckout, - welcomeWizardSignInMethod, } } @@ -842,10 +875,6 @@ export class StatsStore implements IStatsStore { createLocalStorageTimestamp(FirstNonDefaultBranchCheckoutAtKey) } - public recordWelcomeWizardSignInMethod(method: SignInMethod) { - localStorage.setItem(WelcomeWizardSignInMethodKey, method) - } - /** Record the number of stash entries created outside of Desktop for the day * */ public addStashEntriesCreatedOutsideDesktop = (stashCount: number) => @@ -1098,14 +1127,6 @@ export class StatsStore implements IStatsStore { m => ({ [k]: m[k] + n } as Pick) ) - /** Post some data to our stats endpoint. */ - private post = (body: object) => - fetch(StatsEndpoint, { - method: 'POST', - headers: new Headers({ 'Content-Type': 'application/json' }), - body: JSON.stringify(body), - }) - /** * Send opt-in ping with details of previous stored value (if known) * @@ -1191,23 +1212,6 @@ function timeTo(key: string): number | undefined { : Math.round((endTime - startTime) / 1000) } -/** - * Get a string representing the sign in method that was used when - * authenticating a user in the welcome flow. This method ensures that the - * reported value is known to the analytics system regardless of whether the - * enum value of the SignInMethod type changes. - */ -function getWelcomeWizardSignInMethod(): 'basic' | 'web' | undefined { - const method = localStorage.getItem(WelcomeWizardSignInMethodKey) ?? undefined - - if (method === 'basic' || method === 'web' || method === undefined) { - return method - } - - log.error(`Could not parse welcome wizard sign in method: ${method}`) - return undefined -} - /** * Return a value indicating whether the user has opted out of stats reporting * or not. diff --git a/app/src/lib/stores/accounts-store.ts b/app/src/lib/stores/accounts-store.ts index ea871253cad..c25251aaed5 100644 --- a/app/src/lib/stores/accounts-store.ts +++ b/app/src/lib/stores/accounts-store.ts @@ -1,9 +1,10 @@ import { IDataStore, ISecureStore } from './stores' import { getKeyForAccount } from '../auth' import { Account } from '../../models/account' -import { fetchUser, EmailVisibility } from '../api' +import { fetchUser, EmailVisibility, getEnterpriseAPIURL } from '../api' import { fatalError } from '../fatal-error' import { TypedBaseStore } from './base-store' +import { isGHE } from '../endpoint-capabilities' /** The data-only interface for storage. */ interface IEmail { @@ -163,6 +164,29 @@ export class AccountsStore extends TypedBaseStore> { this.save() } + private getMigratedGHEAccounts( + accounts: ReadonlyArray + ): ReadonlyArray | null { + let migrated = false + const migratedAccounts = accounts.map(account => { + let endpoint = account.endpoint + const endpointURL = new URL(endpoint) + // Migrate endpoints of subdomains of `.ghe.com` that use the `/api/v3` + // path to the correct URL using the `api.` subdomain. + if (isGHE(endpoint) && !endpointURL.hostname.startsWith('api.')) { + endpoint = getEnterpriseAPIURL(endpoint) + migrated = true + } + + return { + ...account, + endpoint, + } + }) + + return migrated ? migratedAccounts : null + } + /** * Load the users into memory from storage. */ @@ -172,7 +196,10 @@ export class AccountsStore extends TypedBaseStore> { return } - const rawAccounts: ReadonlyArray = JSON.parse(raw) + const parsedAccounts: ReadonlyArray = JSON.parse(raw) + const migratedAccounts = this.getMigratedGHEAccounts(parsedAccounts) + const rawAccounts = migratedAccounts ?? parsedAccounts + const accountsWithTokens = [] for (const account of rawAccounts) { const accountWithoutToken = new Account( @@ -198,7 +225,12 @@ export class AccountsStore extends TypedBaseStore> { } this.accounts = accountsWithTokens - this.emitUpdate(this.accounts) + // If any account was migrated, make sure to persist the new value + if (migratedAccounts !== null) { + this.save() // Save already emits an update + } else { + this.emitUpdate(this.accounts) + } } private save() { diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 00df5c2815b..0f3da9ffda8 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -7,6 +7,7 @@ import { IssuesStore, PullRequestCoordinator, RepositoriesStore, + SignInResult, SignInStore, UpstreamRemoteName, } from '.' @@ -70,7 +71,6 @@ import { IMultiCommitOperationProgress, } from '../../models/progress' import { Popup, PopupType } from '../../models/popup' -import { IGitAccount } from '../../models/git-account' import { themeChangeMonitor } from '../../ui/lib/theme-change-monitor' import { getAppPath } from '../../ui/lib/app-proxy' import { @@ -101,6 +101,7 @@ import { IAPIFullRepository, IAPIComment, IAPIRepoRuleset, + deleteToken, } from '../api' import { shell } from '../app-shell' import { @@ -129,12 +130,12 @@ import { import { findEditorOrDefault, getAvailableEditors, + launchCustomExternalEditor, launchExternalEditor, } from '../editors' import { assertNever, fatalError, forceUnwrap } from '../fatal-error' import { formatCommitMessage } from '../format-commit-message' -import { getGenericHostname, getGenericUsername } from '../generic-git-auth' import { getAccountForRepository } from '../get-account-for-repository' import { abortMerge, @@ -182,6 +183,8 @@ import { getBranchMergeBaseChangedFiles, getBranchMergeBaseDiff, checkoutCommit, + getRemoteURL, + getGlobalConfigPath, } from '../git' import { installGlobalLFSFilters, @@ -202,6 +205,7 @@ import { RetryAction, RetryActionType } from '../../models/retry-actions' import { Default as DefaultShell, findShellOrDefault, + launchCustomShell, launchShell, parse as parseShell, Shell, @@ -215,6 +219,7 @@ import { promiseWithMinimumTimeout } from '../promise' import { BackgroundFetcher } from './helpers/background-fetcher' import { RepositoryStateCache } from './repository-state-cache' import { readEmoji } from '../read-emoji' +import { Emoji } from '../emoji' import { GitStoreCache } from './git-store-cache' import { GitErrorContext } from '../git-error-context' import { @@ -238,7 +243,7 @@ import { } from './updates/changes-state' import { ManualConflictResolution } from '../../models/manual-conflict-resolution' import { BranchPruner } from './helpers/branch-pruner' -import { enableMoveStash } from '../feature-flag' +import { enableCustomIntegration } from '../feature-flag' import { Banner, BannerType } from '../../models/banner' import { ComputedAction } from '../../models/computed-action' import { @@ -327,6 +332,16 @@ import { resizableComponentClass } from '../../ui/resizable' import { compare } from '../compare' import { parseRepoRules, useRepoRulesLogic } from '../helpers/repo-rules' import { RepoRulesInfo } from '../../models/repo-rules' +import { + setUseExternalCredentialHelper, + useExternalCredentialHelper, + useExternalCredentialHelperDefault, +} from '../trampoline/use-external-credential-helper' +import { IOAuthAction } from '../parse-app-url' +import { + ICustomIntegration, + migratedCustomIntegration, +} from '../custom-integration' const LastSelectedRepositoryIDKey = 'last-selected-repository-id' @@ -349,6 +364,12 @@ const stashedFilesWidthConfigKey: string = 'stashed-files-width' const defaultPullRequestFileListWidth: number = 250 const pullRequestFileListConfigKey: string = 'pull-request-files-width' +const defaultBranchDropdownWidth: number = 230 +const branchDropdownWidthConfigKey: string = 'branch-dropdown-width' + +const defaultPushPullButtonWidth: number = 230 +const pushPullButtonWidthConfigKey: string = 'push-pull-button-width' + const askToMoveToApplicationsFolderDefault: boolean = true const confirmRepoRemovalDefault: boolean = true const showCommitLengthWarningDefault: boolean = false @@ -387,6 +408,9 @@ const hideWhitespaceInPullRequestDiffKey = const commitSpellcheckEnabledDefault = true const commitSpellcheckEnabledKey = 'commit-spellcheck-enabled' +export const tabSizeDefault: number = 8 +const tabSizeKey: string = 'tab-size' + const shellKey = 'shell' const repositoryIndicatorsEnabledKey = 'enable-repository-indicators' @@ -407,6 +431,18 @@ const lastThankYouKey = 'version-and-users-of-last-thank-you' const pullRequestSuggestedNextActionKey = 'pull-request-suggested-next-action-key' +export const useCustomEditorKey = 'use-custom-editor' +const customEditorKey = 'custom-editor' + +export const useCustomShellKey = 'use-custom-shell' +const customShellKey = 'custom-shell' + +export const underlineLinksKey = 'underline-links' +export const underlineLinksDefault = true + +export const showDiffCheckMarksDefault = true +export const showDiffCheckMarksKey = 'diff-check-marks-visible' + export class AppStore extends TypedBaseStore { private readonly gitStoreCache: GitStoreCache @@ -435,7 +471,7 @@ export class AppStore extends TypedBaseStore { >() /** Map from shortcut (e.g., :+1:) to on disk URL. */ - private emoji = new Map() + private emoji = new Map() /** * The Application menu as an AppMenu instance or null if @@ -460,6 +496,8 @@ export class AppStore extends TypedBaseStore { private commitSummaryWidth = constrain(defaultCommitSummaryWidth) private stashedFilesWidth = constrain(defaultStashedFilesWidth) private pullRequestFileListWidth = constrain(defaultPullRequestFileListWidth) + private branchDropdownWidth = constrain(defaultBranchDropdownWidth) + private pushPullButtonWidth = constrain(defaultPushPullButtonWidth) private windowState: WindowState | null = null private windowZoomFactor: number = 1 @@ -469,6 +507,8 @@ export class AppStore extends TypedBaseStore { private askToMoveToApplicationsFolderSetting: boolean = askToMoveToApplicationsFolderDefault + private useExternalCredentialHelper: boolean = + useExternalCredentialHelperDefault private askForConfirmationOnRepositoryRemoval: boolean = confirmRepoRemovalDefault private confirmDiscardChanges: boolean = confirmDiscardChangesDefault @@ -496,7 +536,7 @@ export class AppStore extends TypedBaseStore { private resolvedExternalEditor: string | null = null /** The user's preferred shell. */ - private selectedShell = DefaultShell + private selectedShell: Shell = DefaultShell /** The current repository filter text */ private repositoryFilterText: string = '' @@ -513,6 +553,7 @@ export class AppStore extends TypedBaseStore { private selectedBranchesTab = BranchesTab.Branches private selectedTheme = ApplicationTheme.System private currentTheme: ApplicableTheme = ApplicationTheme.Light + private selectedTabSize = tabSizeDefault private useWindowsOpenSSH: boolean = false @@ -528,6 +569,13 @@ export class AppStore extends TypedBaseStore { private currentDragElement: DragElement | null = null private lastThankYou: ILastThankYou | undefined + + private useCustomEditor: boolean = false + private customEditor: ICustomIntegration | null = null + + private useCustomShell: boolean = false + private customShell: ICustomIntegration | null = null + private showCIStatusPopover: boolean = false /** A service for managing the stack of open popups */ @@ -537,8 +585,12 @@ export class AppStore extends TypedBaseStore { | PullRequestSuggestedNextAction | undefined = undefined + private showDiffCheckMarks: boolean = showDiffCheckMarksDefault + private cachedRepoRulesets = new Map() + private underlineLinks: boolean = underlineLinksDefault + public constructor( private readonly gitHubUserStore: GitHubUserStore, private readonly cloningRepositoriesStore: CloningRepositoriesStore, @@ -674,17 +726,19 @@ export class AppStore extends TypedBaseStore { return zoomFactor } - private onTokenInvalidated = (endpoint: string) => { + private onTokenInvalidated = (endpoint: string, token: string) => { const account = getAccountForEndpoint(this.accounts, endpoint) if (account === null) { return } - // If there is a currently open popup, don't do anything here. Since the - // app can only show one popup at a time, we don't want to close the current - // one in favor of the error we're about to show. - if (this.popupManager.isAPopupOpen) { + // If we have a token for the account but it doesn't match the token that + // was invalidated that likely means that someone held onto an account for + // longer than they should have which is bad but what's even worse is if we + // invalidate an active account. + if (account.token && account.token !== token) { + log.error(`Token for ${endpoint} invalidated but token mismatch`) return } @@ -752,6 +806,9 @@ export class AppStore extends TypedBaseStore { this.statsStore.recordTutorialPrCreated() this.statsStore.recordTutorialCompleted() break + case TutorialStep.Announced: + // don't need to record anything for announcment + break default: assertNever(step, 'Unaccounted for step type') } @@ -782,6 +839,11 @@ export class AppStore extends TypedBaseStore { await this.updateCurrentTutorialStep(repository) } + public async _markTutorialCompletionAsAnnounced(repository: Repository) { + this.tutorialAssessor.markTutorialCompletionAsAnnounced() + await this.updateCurrentTutorialStep(repository) + } + private wireupIpcEventHandlers() { ipcRenderer.on('window-state-changed', (_, windowState) => { this.windowState = windowState @@ -806,13 +868,7 @@ export class AppStore extends TypedBaseStore { this.cloningRepositoriesStore.onDidError(e => this.emitError(e)) - this.signInStore.onDidAuthenticate((account, method) => { - this._addAccount(account) - - if (this.showWelcomeFlow) { - this.statsStore.recordWelcomeWizardSignInMethod(method) - } - }) + this.signInStore.onDidAuthenticate(account => this._addAccount(account)) this.signInStore.onDidUpdate(() => this.emitUpdate()) this.signInStore.onDidError(error => this.emitError(error)) @@ -963,6 +1019,8 @@ export class AppStore extends TypedBaseStore { focusCommitMessage: this.focusCommitMessage, emoji: this.emoji, sidebarWidth: this.sidebarWidth, + branchDropdownWidth: this.branchDropdownWidth, + pushPullButtonWidth: this.pushPullButtonWidth, commitSummaryWidth: this.commitSummaryWidth, stashedFilesWidth: this.stashedFilesWidth, pullRequestFilesListWidth: this.pullRequestFileListWidth, @@ -973,6 +1031,7 @@ export class AppStore extends TypedBaseStore { currentBanner: this.currentBanner, askToMoveToApplicationsFolderSetting: this.askToMoveToApplicationsFolderSetting, + useExternalCredentialHelper: this.useExternalCredentialHelper, askForConfirmationOnRepositoryRemoval: this.askForConfirmationOnRepositoryRemoval, askForConfirmationOnDiscardChanges: this.confirmDiscardChanges, @@ -996,6 +1055,7 @@ export class AppStore extends TypedBaseStore { selectedBranchesTab: this.selectedBranchesTab, selectedTheme: this.selectedTheme, currentTheme: this.currentTheme, + selectedTabSize: this.selectedTabSize, apiRepositories: this.apiRepositoriesStore.getState(), useWindowsOpenSSH: this.useWindowsOpenSSH, showCommitLengthWarning: this.showCommitLengthWarning, @@ -1005,11 +1065,17 @@ export class AppStore extends TypedBaseStore { commitSpellcheckEnabled: this.commitSpellcheckEnabled, currentDragElement: this.currentDragElement, lastThankYou: this.lastThankYou, + useCustomEditor: this.useCustomEditor, + customEditor: this.customEditor, + useCustomShell: this.useCustomShell, + customShell: this.customShell, showCIStatusPopover: this.showCIStatusPopover, notificationsEnabled: getNotificationsEnabled(), pullRequestSuggestedNextAction: this.pullRequestSuggestedNextAction, resizablePaneActive: this.resizablePaneActive, cachedRepoRulesets: this.cachedRepoRulesets, + underlineLinks: this.underlineLinks, + showDiffCheckMarks: this.showDiffCheckMarks, } } @@ -2032,11 +2098,6 @@ export class AppStore extends TypedBaseStore { ) } - const account = getAccountForRepository(this.accounts, repository) - if (!account) { - return - } - if (!repository.gitHubRepository) { return } @@ -2045,8 +2106,8 @@ export class AppStore extends TypedBaseStore { // similar to what's being done in `refreshAllIndicators` const fetcher = new BackgroundFetcher( repository, - account, - r => this.performFetch(r, account, FetchType.BackgroundTask), + this.accountsStore, + r => this._fetch(r, FetchType.BackgroundTask), r => this.shouldBackgroundFetch(r, null) ) fetcher.start(withInitialSkew) @@ -2084,6 +2145,12 @@ export class AppStore extends TypedBaseStore { this.pullRequestFileListWidth = constrain( getNumber(pullRequestFileListConfigKey, defaultPullRequestFileListWidth) ) + this.branchDropdownWidth = constrain( + getNumber(branchDropdownWidthConfigKey, defaultBranchDropdownWidth) + ) + this.pushPullButtonWidth = constrain( + getNumber(pushPullButtonWidthConfigKey, defaultPushPullButtonWidth) + ) this.updateResizableConstraints() // TODO: Initiliaze here for now... maybe move to dialog mounting @@ -2094,6 +2161,8 @@ export class AppStore extends TypedBaseStore { askToMoveToApplicationsFolderDefault ) + this.useExternalCredentialHelper = useExternalCredentialHelper() + this.askForConfirmationOnRepositoryRemoval = getBoolean( confirmRepoRemovalKey, confirmRepoRemovalDefault @@ -2185,6 +2254,8 @@ export class AppStore extends TypedBaseStore { this.currentTheme = await getCurrentlyAppliedTheme() + this.selectedTabSize = getNumber(tabSizeKey, tabSizeDefault) + themeChangeMonitor.onThemeChanged(theme => { this.currentTheme = theme this.emitUpdate() @@ -2192,15 +2263,45 @@ export class AppStore extends TypedBaseStore { this.lastThankYou = getObject(lastThankYouKey) + this.useCustomEditor = + enableCustomIntegration() && getBoolean(useCustomEditorKey, false) + this.customEditor = getObject(customEditorKey) ?? null + + this.useCustomShell = + enableCustomIntegration() && getBoolean(useCustomShellKey, false) + this.customShell = getObject(customShellKey) ?? null + + // Migrate custom editor and shell to the new format if needed. This + // will persist the new format to local storage. + // Hopefully we can remove this migration in the future. + const migratedCustomEditor = migratedCustomIntegration(this.customEditor) + if (migratedCustomEditor !== null) { + this._setCustomEditor(migratedCustomEditor) + } + const migratedCustomShell = migratedCustomIntegration(this.customShell) + if (migratedCustomShell !== null) { + this._setCustomShell(migratedCustomShell) + } + this.pullRequestSuggestedNextAction = getEnum( pullRequestSuggestedNextActionKey, PullRequestSuggestedNextAction ) ?? defaultPullRequestSuggestedNextAction + // Always false if the feature flag is disabled. + this.underlineLinks = getBoolean(underlineLinksKey, underlineLinksDefault) + + this.showDiffCheckMarks = getBoolean( + showDiffCheckMarksKey, + showDiffCheckMarksDefault + ) + this.emitUpdateNow() this.accountsStore.refresh() + + this.updateMenuLabelsForSelectedRepository() } /** @@ -2208,11 +2309,12 @@ export class AppStore extends TypedBaseStore { * dimensions change. */ private updateResizableConstraints() { - // The combined width of the branch dropdown and the push pull fetch button + // The combined width of the branch dropdown and the push/pull/fetch button // Since the repository list toolbar button width is tied to the width of - // the sidebar we can't let it push the branch, and push/pull/fetch buttons + // the sidebar we can't let it push the branch, and push/pull/fetch button // off screen. - const toolbarButtonsWidth = 460 + const toolbarButtonsMinWidth = + defaultPushPullButtonWidth + defaultBranchDropdownWidth // Start with all the available width let available = window.innerWidth @@ -2223,7 +2325,7 @@ export class AppStore extends TypedBaseStore { // 220 was determined as the minimum value since it is the smallest width // that will still fit the placeholder text in the branch selector textbox // of the history tab - const maxSidebarWidth = available - toolbarButtonsWidth + const maxSidebarWidth = available - toolbarButtonsMinWidth this.sidebarWidth = constrain(this.sidebarWidth, 220, maxSidebarWidth) // Now calculate the width we have left to distribute for the other panes @@ -2239,6 +2341,34 @@ export class AppStore extends TypedBaseStore { this.commitSummaryWidth = constrain(this.commitSummaryWidth, 100, filesMax) this.stashedFilesWidth = constrain(this.stashedFilesWidth, 100, filesMax) + + // Update the maximum width available for the branch dropdown resizable. + // The branch dropdown can only be as wide as the available space after + // taking the sidebar and pull/push/fetch button widths. If the room + // available is less than the default width, we will split the difference + // between the branch dropdown and the push/pull/fetch button so they stay + // visible on the most zoomed view. + const branchDropdownMax = available - defaultPushPullButtonWidth + const minimumBranchDropdownWidth = + defaultBranchDropdownWidth > available / 2 + ? available / 2 - 10 // 10 is to give a little bit of space to see the fetch dropdown button + : defaultBranchDropdownWidth + this.branchDropdownWidth = constrain( + this.branchDropdownWidth, + minimumBranchDropdownWidth, + branchDropdownMax + ) + + const pushPullButtonMaxWidth = available - this.branchDropdownWidth.value + const minimumPushPullToolBarWidth = + defaultPushPullButtonWidth > available / 2 + ? available / 2 + 30 // 30 to clip the fetch dropdown button in favor of seeing more of the words on the toolbar buttons + : defaultPushPullButtonWidth + this.pushPullButtonWidth = constrain( + this.pushPullButtonWidth, + minimumPushPullToolBarWidth, + pushPullButtonMaxWidth + ) } /** @@ -2333,16 +2463,18 @@ export class AppStore extends TypedBaseStore { */ private updateMenuItemLabels(state: IRepositoryState | null) { const { + useCustomShell, selectedShell, selectedRepository, + useCustomEditor, selectedExternalEditor, askForConfirmationOnRepositoryRemoval, askForConfirmationOnForcePush, } = this const labels: MenuLabelsEvent = { - selectedShell, - selectedExternalEditor, + selectedShell: useCustomShell ? null : selectedShell, + selectedExternalEditor: useCustomEditor ? null : selectedExternalEditor, askForConfirmationOnRepositoryRemoval, askForConfirmationOnForcePush, } @@ -3512,12 +3644,12 @@ export class AppStore extends TypedBaseStore { * of the current branch to its upstream tracking branch. */ private fetchForRepositoryIndicator(repo: Repository) { - return this.withAuthenticatingUser(repo, async (repo, account) => { + return this.withRefreshedGitHubRepository(repo, async repo => { const isBackgroundTask = true const gitStore = this.gitStoreCache.get(repo) await this.withPushPullFetch(repo, () => - gitStore.fetch(account, isBackgroundTask, progress => + gitStore.fetch(isBackgroundTask, progress => this.updatePushPullFetchProgress(repo, progress) ) ) @@ -3825,11 +3957,11 @@ export class AppStore extends TypedBaseStore { } } - return this.withAuthenticatingUser(repository, (repository, account) => { + return this.withRefreshedGitHubRepository(repository, repository => { // We always want to end with refreshing the repository regardless of // whether the checkout succeeded or not in order to present the most // up-to-date information to the user. - return this.checkoutImplementation(repository, branch, account, strategy) + return this.checkoutImplementation(repository, branch, strategy) .then(() => this.onSuccessfulCheckout(repository, branch)) .catch(e => this.emitError(new CheckoutError(e, repository, branch))) .then(() => this.refreshAfterCheckout(repository, branch.name)) @@ -3841,15 +3973,16 @@ export class AppStore extends TypedBaseStore { private checkoutImplementation( repository: Repository, branch: Branch, - account: IGitAccount | null, strategy: UncommittedChangesStrategy ) { + const { currentRemote } = this.gitStoreCache.get(repository) + if (strategy === UncommittedChangesStrategy.StashOnCurrentBranch) { - return this.checkoutAndLeaveChanges(repository, branch, account) + return this.checkoutAndLeaveChanges(repository, branch, currentRemote) } else if (strategy === UncommittedChangesStrategy.MoveToNewBranch) { - return this.checkoutAndBringChanges(repository, branch, account) + return this.checkoutAndBringChanges(repository, branch, currentRemote) } else { - return this.checkoutIgnoringChanges(repository, branch, account) + return this.checkoutIgnoringChanges(repository, branch, currentRemote) } } @@ -3857,9 +3990,9 @@ export class AppStore extends TypedBaseStore { private async checkoutIgnoringChanges( repository: Repository, branch: Branch, - account: IGitAccount | null + currentRemote: IRemote | null ) { - await checkoutBranch(repository, account, branch, progress => { + await checkoutBranch(repository, branch, currentRemote, progress => { this.updateCheckoutProgress(repository, progress) }) } @@ -3872,7 +4005,7 @@ export class AppStore extends TypedBaseStore { private async checkoutAndLeaveChanges( repository: Repository, branch: Branch, - account: IGitAccount | null + currentRemote: IRemote | null ) { const repositoryState = this.repositoryStateCache.get(repository) const { workingDirectory } = repositoryState.changesState @@ -3883,7 +4016,7 @@ export class AppStore extends TypedBaseStore { this.statsStore.increment('stashCreatedOnCurrentBranchCount') } - return this.checkoutIgnoringChanges(repository, branch, account) + return this.checkoutIgnoringChanges(repository, branch, currentRemote) } /** @@ -3899,10 +4032,10 @@ export class AppStore extends TypedBaseStore { private async checkoutAndBringChanges( repository: Repository, branch: Branch, - account: IGitAccount | null + currentRemote: IRemote | null ) { try { - await this.checkoutIgnoringChanges(repository, branch, account) + await this.checkoutIgnoringChanges(repository, branch, currentRemote) } catch (checkoutError) { if (!isLocalChangesOverwrittenError(checkoutError)) { throw checkoutError @@ -3920,7 +4053,7 @@ export class AppStore extends TypedBaseStore { throw checkoutError } - await this.checkoutIgnoringChanges(repository, branch, account) + await this.checkoutIgnoringChanges(repository, branch, currentRemote) await popStashEntry(repository, stash.stashSha) this.statsStore.increment('changesTakenToNewBranchCount') @@ -3981,6 +4114,7 @@ export class AppStore extends TypedBaseStore { const repositoryState = this.repositoryStateCache.get(repository) const { branchesState } = repositoryState const { tip } = branchesState + const { currentRemote } = this.gitStoreCache.get(repository) // No point in checking out the currently checked out commit. if ( @@ -3990,11 +4124,15 @@ export class AppStore extends TypedBaseStore { return repository } - return this.withAuthenticatingUser(repository, (repository, account) => { + return this.withRefreshedGitHubRepository(repository, repository => { // We always want to end with refreshing the repository regardless of // whether the checkout succeeded or not in order to present the most // up-to-date information to the user. - return this.checkoutCommitDefaultBehaviour(repository, commit, account) + return this.checkoutCommitDefaultBehaviour( + repository, + commit, + currentRemote + ) .catch(e => this.emitError(new Error(e))) .then(() => this.refreshAfterCheckout(repository, shortenSHA(commit.sha)) @@ -4006,9 +4144,9 @@ export class AppStore extends TypedBaseStore { private async checkoutCommitDefaultBehaviour( repository: Repository, commit: CommitOneLine, - account: IGitAccount | null + currentRemote: IRemote | null ) { - await checkoutCommit(repository, account, commit, progress => { + await checkoutCommit(repository, commit, currentRemote, progress => { this.updateCheckoutProgress(repository, progress) }) } @@ -4169,12 +4307,10 @@ export class AppStore extends TypedBaseStore { await gitStore.performFailableOperation(async () => { await renameBranch(repository, branch, newName) - if (enableMoveStash()) { - const stashEntry = gitStore.desktopStashEntries.get(branch.name) + const stashEntry = gitStore.desktopStashEntries.get(branch.name) - if (stashEntry) { - await moveStashEntry(repository, stashEntry, newName) - } + if (stashEntry) { + await moveStashEntry(repository, stashEntry, newName) } }) @@ -4188,8 +4324,8 @@ export class AppStore extends TypedBaseStore { includeUpstream?: boolean, toCheckout?: Branch | null ): Promise { - return this.withAuthenticatingUser(repository, async (r, account) => { - const gitStore = this.gitStoreCache.get(r) + return this.withRefreshedGitHubRepository(repository, async repository => { + const gitStore = this.gitStoreCache.get(repository) // If solely a remote branch, there is no need to checkout a branch. if (branch.type === BranchType.Remote) { @@ -4202,8 +4338,18 @@ export class AppStore extends TypedBaseStore { ) } + const remote = + gitStore.remotes.find(r => r.name === remoteName) ?? + (await getRemoteURL(repository, remoteName) + .then(url => (url ? { name: remoteName, url } : undefined)) + .catch(e => log.debug(`Could not get remote URL`, e))) + + if (remote === undefined) { + throw new Error(`Could not determine remote url from: ${branch.ref}.`) + } + await gitStore.performFailableOperation(() => - deleteRemoteBranch(r, account, remoteName, nameWithoutRemote) + deleteRemoteBranch(repository, remote, nameWithoutRemote) ) // We log the remote branch's sha so that the user can recover it. @@ -4211,17 +4357,17 @@ export class AppStore extends TypedBaseStore { `Deleted branch ${branch.upstreamWithoutRemote} (was ${tip.sha})` ) - return this._refreshRepository(r) + return this._refreshRepository(repository) } // If a local branch, user may have the branch to delete checked out and // we need to switch to a different branch (default or recent). const branchToCheckout = - toCheckout ?? this.getBranchToCheckoutAfterDelete(branch, r) + toCheckout ?? this.getBranchToCheckoutAfterDelete(branch, repository) if (branchToCheckout !== null) { await gitStore.performFailableOperation(() => - checkoutBranch(r, account, branchToCheckout) + checkoutBranch(repository, branchToCheckout, gitStore.currentRemote) ) } @@ -4229,12 +4375,11 @@ export class AppStore extends TypedBaseStore { return this.deleteLocalBranchAndUpstreamBranch( repository, branch, - account, includeUpstream ) }) - return this._refreshRepository(r) + return this._refreshRepository(repository) }) } @@ -4245,7 +4390,6 @@ export class AppStore extends TypedBaseStore { private async deleteLocalBranchAndUpstreamBranch( repository: Repository, branch: Branch, - account: IGitAccount | null, includeUpstream?: boolean ): Promise { await deleteLocalBranch(repository, branch.name) @@ -4255,12 +4399,20 @@ export class AppStore extends TypedBaseStore { branch.upstreamRemoteName !== null && branch.upstreamWithoutRemote !== null ) { - await deleteRemoteBranch( - repository, - account, - branch.upstreamRemoteName, - branch.upstreamWithoutRemote - ) + const gitStore = this.gitStoreCache.get(repository) + const remoteName = branch.upstreamRemoteName + + const remote = + gitStore.remotes.find(r => r.name === remoteName) ?? + (await getRemoteURL(repository, remoteName) + .then(url => (url ? { name: remoteName, url } : undefined)) + .catch(e => log.debug(`Could not get remote URL`, e))) + + if (!remote) { + throw new Error(`Could not determine remote url from: ${branch.ref}.`) + } + + await deleteRemoteBranch(repository, remote, branch.upstreamWithoutRemote) } return } @@ -4309,14 +4461,40 @@ export class AppStore extends TypedBaseStore { repository: Repository, options?: PushOptions ): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performPush(repository, account, options) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performPush(repository, options) }) } + private getBranchToPush( + repository: Repository, + options?: PushOptions + ): Branch | undefined { + if (options?.branch !== undefined) { + return options?.branch + } + + const state = this.repositoryStateCache.get(repository) + + const { tip } = state.branchesState + + if (tip.kind === TipState.Unborn) { + throw new Error('The current branch is unborn.') + } + + if (tip.kind === TipState.Detached) { + throw new Error('The current repository is in a detached HEAD state.') + } + + if (tip.kind === TipState.Valid) { + return tip.branch + } + + return + } + private async performPush( repository: Repository, - account: IGitAccount | null, options?: PushOptions ): Promise { const state = this.repositoryStateCache.get(repository) @@ -4331,165 +4509,151 @@ export class AppStore extends TypedBaseStore { } return this.withPushPullFetch(repository, async () => { - const { tip } = state.branchesState - - if (tip.kind === TipState.Unborn) { - throw new Error('The current branch is unborn.') - } + const branch = this.getBranchToPush(repository, options) - if (tip.kind === TipState.Detached) { - throw new Error('The current repository is in a detached HEAD state.') + if (branch === undefined) { + return } - if (tip.kind === TipState.Valid) { - const { branch } = tip + const remoteName = branch.upstreamRemoteName || remote.name - const remoteName = branch.upstreamRemoteName || remote.name + const pushTitle = `Pushing to ${remoteName}` - const pushTitle = `Pushing to ${remoteName}` + // Emit an initial progress even before our push begins + // since we're doing some work to get remotes up front. + this.updatePushPullFetchProgress(repository, { + kind: 'push', + title: pushTitle, + value: 0, + remote: remoteName, + branch: branch.name, + }) - // Emit an initial progress even before our push begins - // since we're doing some work to get remotes up front. - this.updatePushPullFetchProgress(repository, { - kind: 'push', - title: pushTitle, - value: 0, - remote: remoteName, - branch: branch.name, - }) + // Let's say that a push takes roughly twice as long as a fetch, + // this is of course highly inaccurate. + let pushWeight = 2.5 + let fetchWeight = 1 - // Let's say that a push takes roughly twice as long as a fetch, - // this is of course highly inaccurate. - let pushWeight = 2.5 - let fetchWeight = 1 + // Let's leave 10% at the end for refreshing + const refreshWeight = 0.1 - // Let's leave 10% at the end for refreshing - const refreshWeight = 0.1 + // Scale pull and fetch weights to be between 0 and 0.9. + const scale = (1 / (pushWeight + fetchWeight)) * (1 - refreshWeight) - // Scale pull and fetch weights to be between 0 and 0.9. - const scale = (1 / (pushWeight + fetchWeight)) * (1 - refreshWeight) + pushWeight *= scale + fetchWeight *= scale - pushWeight *= scale - fetchWeight *= scale + const retryAction: RetryAction = { + type: RetryActionType.Push, + repository, + } - const retryAction: RetryAction = { - type: RetryActionType.Push, - repository, - } + // This is most likely not necessary and is only here out of + // an abundance of caution. We're introducing support for + // automatically configuring Git proxies based on system + // proxy settings and therefore need to pass along the remote + // url to functions such as push, pull, fetch etc. + // + // Prior to this we relied primarily on the `branch.remote` + // property and used the `remote.name` as a fallback in case the + // branch object didn't have a remote name (i.e. if it's not + // published yet). + // + // The remote.name is derived from the current tip first and falls + // back to using the defaultRemote if the current tip isn't valid + // or if the current branch isn't published. There's however no + // guarantee that they'll be refreshed at the exact same time so + // there's a theoretical possibility that `branch.remote` and + // `remote.name` could be out of sync. I have no reason to suspect + // that's the case and if it is then we already have problems as + // the `fetchRemotes` call after the push already relies on the + // `remote` and not the `branch.remote`. All that said this is + // a critical path in the app and somehow breaking pushing would + // be near unforgivable so I'm introducing this `safeRemote` + // temporarily to ensure that there's no risk of us using an + // out of sync remote name while still providing envForRemoteOperation + // with an url to use when resolving proxies. + // + // I'm also adding a non fatal exception if this ever happens + // so that we can confidently remove this safeguard in a future + // release. + const safeRemote: IRemote = { name: remoteName, url: remote.url } + + if (safeRemote.name !== remote.name) { + sendNonFatalException( + 'remoteNameMismatch', + new Error('The current remote name differs from the branch remote') + ) + } - // This is most likely not necessary and is only here out of - // an abundance of caution. We're introducing support for - // automatically configuring Git proxies based on system - // proxy settings and therefore need to pass along the remote - // url to functions such as push, pull, fetch etc. - // - // Prior to this we relied primarily on the `branch.remote` - // property and used the `remote.name` as a fallback in case the - // branch object didn't have a remote name (i.e. if it's not - // published yet). - // - // The remote.name is derived from the current tip first and falls - // back to using the defaultRemote if the current tip isn't valid - // or if the current branch isn't published. There's however no - // guarantee that they'll be refreshed at the exact same time so - // there's a theoretical possibility that `branch.remote` and - // `remote.name` could be out of sync. I have no reason to suspect - // that's the case and if it is then we already have problems as - // the `fetchRemotes` call after the push already relies on the - // `remote` and not the `branch.remote`. All that said this is - // a critical path in the app and somehow breaking pushing would - // be near unforgivable so I'm introducing this `safeRemote` - // temporarily to ensure that there's no risk of us using an - // out of sync remote name while still providing envForRemoteOperation - // with an url to use when resolving proxies. - // - // I'm also adding a non fatal exception if this ever happens - // so that we can confidently remove this safeguard in a future - // release. - const safeRemote: IRemote = { name: remoteName, url: remote.url } - - if (safeRemote.name !== remote.name) { - sendNonFatalException( - 'remoteNameMismatch', - new Error('The current remote name differs from the branch remote') + const gitStore = this.gitStoreCache.get(repository) + await gitStore.performFailableOperation( + async () => { + await pushRepo( + repository, + safeRemote, + branch.name, + branch.upstreamWithoutRemote, + gitStore.tagsToPush, + options, + progress => { + this.updatePushPullFetchProgress(repository, { + ...progress, + title: pushTitle, + value: pushWeight * progress.value, + }) + } ) - } - - const gitStore = this.gitStoreCache.get(repository) - await gitStore.performFailableOperation( - async () => { - await pushRepo( - repository, - account, - safeRemote, - branch.name, - branch.upstreamWithoutRemote, - gitStore.tagsToPush, - options, - progress => { - this.updatePushPullFetchProgress(repository, { - ...progress, - title: pushTitle, - value: pushWeight * progress.value, - }) - } - ) - gitStore.clearTagsToPush() - - await gitStore.fetchRemotes( - account, - [safeRemote], - false, - fetchProgress => { - this.updatePushPullFetchProgress(repository, { - ...fetchProgress, - value: pushWeight + fetchProgress.value * fetchWeight, - }) - } - ) - - const refreshTitle = __DARWIN__ - ? 'Refreshing Repository' - : 'Refreshing repository' - const refreshStartProgress = pushWeight + fetchWeight + gitStore.clearTagsToPush() + await gitStore.fetchRemotes([safeRemote], false, fetchProgress => { this.updatePushPullFetchProgress(repository, { - kind: 'generic', - title: refreshTitle, - description: 'Fast-forwarding branches', - value: refreshStartProgress, + ...fetchProgress, + value: pushWeight + fetchProgress.value * fetchWeight, }) + }) - await this.fastForwardBranches(repository) + const refreshTitle = __DARWIN__ + ? 'Refreshing Repository' + : 'Refreshing repository' + const refreshStartProgress = pushWeight + fetchWeight - this.updatePushPullFetchProgress(repository, { - kind: 'generic', - title: refreshTitle, - value: refreshStartProgress + refreshWeight * 0.5, - }) + this.updatePushPullFetchProgress(repository, { + kind: 'generic', + title: refreshTitle, + description: 'Fast-forwarding branches', + value: refreshStartProgress, + }) - // manually refresh branch protections after the push, to ensure - // any new branch will immediately report as protected - await this.refreshBranchProtectionState(repository) + await this.fastForwardBranches(repository) - await this._refreshRepository(repository) - }, - { retryAction } - ) + this.updatePushPullFetchProgress(repository, { + kind: 'generic', + title: refreshTitle, + value: refreshStartProgress + refreshWeight * 0.5, + }) - this.updatePushPullFetchProgress(repository, null) + // manually refresh branch protections after the push, to ensure + // any new branch will immediately report as protected + await this.refreshBranchProtectionState(repository) - this.updateMenuLabelsForSelectedRepository() + await this._refreshRepository(repository) + }, + { retryAction } + ) - // 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 - ) - } + 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 + ) }) } @@ -4544,16 +4708,13 @@ export class AppStore extends TypedBaseStore { } public async _pull(repository: Repository): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performPull(repository, account) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performPull(repository) }) } /** This shouldn't be called directly. See `Dispatcher`. */ - private async performPull( - repository: Repository, - account: IGitAccount | null - ): Promise { + private async performPull(repository: Repository): Promise { return this.withPushPullFetch(repository, async () => { const gitStore = this.gitStoreCache.get(repository) const remote = gitStore.currentRemote @@ -4626,21 +4787,32 @@ export class AppStore extends TypedBaseStore { this.statsStore.increment('pullWithDefaultSettingCount') } - await gitStore.performFailableOperation( - () => - pullRepo(repository, account, remote, progress => { + const pullSucceeded = await gitStore.performFailableOperation( + async () => { + await pullRepo(repository, remote, progress => { this.updatePushPullFetchProgress(repository, { ...progress, value: progress.value * pullWeight, }) - }), - { - gitContext, - retryAction, - } + }) + return true + }, + { gitContext, retryAction } ) - await updateRemoteHEAD(repository, account, remote) + // If the pull failed we shouldn't try to update the remote HEAD + // because there's a decent chance that it failed either because we + // didn't have the correct credentials (which we won't this time + // either) or because there's a network error which likely will + // persist for the next operation as well. + if (pullSucceeded) { + // Updating the local HEAD symref isn't critical so we don't want + // to show an error message to the user and have them retry the + // entire pull operation if it fails. + await updateRemoteHEAD(repository, remote, false).catch(e => + log.error('Failed updating remote HEAD', e) + ) + } const refreshStartProgress = pullWeight + fetchWeight const refreshTitle = __DARWIN__ @@ -4716,7 +4888,16 @@ export class AppStore extends TypedBaseStore { // skip pushing if the current branch is a detached HEAD or the repository // is unborn if (gitStore.tip.kind === TipState.Valid) { - await this.performPush(repository, account) + if ( + gitStore.defaultBranch !== null && + gitStore.tip.branch.name !== gitStore.defaultBranch.name + ) { + await this.performPush(repository, { + branch: gitStore.defaultBranch, + forceWithLease: false, + }) + } + await this.performPush(repository) } await gitStore.refreshDefaultBranch() @@ -4724,47 +4905,16 @@ export class AppStore extends TypedBaseStore { 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 } + options: { branch?: string; defaultBranch?: string } = {} ): { promise: Promise repository: CloningRepository } { - const account = this.getAccountForRemoteURL(url) - const promise = this.cloningRepositoriesStore.clone(url, path, { - ...options, - account, - }) + const promise = this.cloningRepositoriesStore.clone(url, path, options) const repository = this.cloningRepositoriesStore.repositories.find( r => r.url === url && r.path === path )! @@ -4821,7 +4971,49 @@ export class AppStore extends TypedBaseStore { return this._refreshRepository(repository) } - public _setRepositoryCommitToAmend( + public async _startAmendingRepository( + repository: Repository, + commit: Commit, + isLocalCommit: boolean, + continueWithForcePush: boolean = false + ) { + const repositoryState = this.repositoryStateCache.get(repository) + const { tip } = repositoryState.branchesState + const { askForConfirmationOnForcePush } = this.getState() + + if ( + askForConfirmationOnForcePush && + !continueWithForcePush && + !isLocalCommit && + tip.kind === TipState.Valid + ) { + return this._showPopup({ + type: PopupType.WarnForcePush, + operation: 'Amend', + onBegin: () => { + this._startAmendingRepository(repository, commit, isLocalCommit, true) + }, + }) + } + + await this._changeRepositorySection( + repository, + RepositorySectionTab.Changes + ) + + const gitStore = this.gitStoreCache.get(repository) + await gitStore.prepareToAmendCommit(commit) + + this.setRepositoryCommitToAmend(repository, commit) + + this.statsStore.increment('amendCommitStartedCount') + } + + public async _stopAmendingRepository(repository: Repository) { + this.setRepositoryCommitToAmend(repository, null) + } + + private setRepositoryCommitToAmend( repository: Repository, commit: Commit | null ) { @@ -4923,15 +5115,12 @@ export class AppStore extends TypedBaseStore { repository: Repository, refspec: string ): Promise { - return this.withAuthenticatingUser( - repository, - async (repository, account) => { - const gitStore = this.gitStoreCache.get(repository) - await gitStore.fetchRefspec(account, refspec) + return this.withRefreshedGitHubRepository(repository, async repository => { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.fetchRefspec(refspec) - return this._refreshRepository(repository) - } - ) + return this._refreshRepository(repository) + }) } /** @@ -4943,8 +5132,8 @@ export class AppStore extends TypedBaseStore { * if _any_ fetches or pulls are currently in-progress. */ public _fetch(repository: Repository, fetchType: FetchType): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performFetch(repository, account, fetchType) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performFetch(repository, fetchType) }) } @@ -4959,8 +5148,8 @@ export class AppStore extends TypedBaseStore { remote: IRemote, fetchType: FetchType ): Promise { - return this.withAuthenticatingUser(repository, (repository, account) => { - return this.performFetch(repository, account, fetchType, [remote]) + return this.withRefreshedGitHubRepository(repository, repository => { + return this.performFetch(repository, fetchType, [remote]) }) } @@ -4973,7 +5162,6 @@ export class AppStore extends TypedBaseStore { */ private async performFetch( repository: Repository, - account: IGitAccount | null, fetchType: FetchType, remotes?: IRemote[] ): Promise { @@ -4993,10 +5181,9 @@ export class AppStore extends TypedBaseStore { } if (remotes === undefined) { - await gitStore.fetch(account, isBackgroundTask, progressCallback) + await gitStore.fetch(isBackgroundTask, progressCallback) } else { await gitStore.fetchRemotes( - account, remotes, isBackgroundTask, progressCallback @@ -5075,6 +5262,48 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + public _setBranchDropdownWidth(width: number): Promise { + this.branchDropdownWidth = { ...this.branchDropdownWidth, value: width } + setNumber(branchDropdownWidthConfigKey, width) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _resetBranchDropdownWidth(): Promise { + this.branchDropdownWidth = { + ...this.branchDropdownWidth, + value: defaultBranchDropdownWidth, + } + localStorage.removeItem(branchDropdownWidthConfigKey) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _setPushPullButtonWidth(width: number): Promise { + this.pushPullButtonWidth = { ...this.pushPullButtonWidth, value: width } + setNumber(pushPullButtonWidthConfigKey, width) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _resetPushPullButtonWidth(): Promise { + this.pushPullButtonWidth = { + ...this.pushPullButtonWidth, + value: defaultPushPullButtonWidth, + } + localStorage.removeItem(pushPullButtonWidthConfigKey) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + public _setCommitSummaryWidth(width: number): Promise { this.commitSummaryWidth = { ...this.commitSummaryWidth, value: width } setNumber(commitSummaryWidthConfigKey, width) @@ -5381,10 +5610,17 @@ export class AppStore extends TypedBaseStore { /** This shouldn't be called directly. See `Dispatcher`. */ public async _openShell(path: string) { this.statsStore.increment('openShellCount') + const { useCustomShell, customShell } = this.getState() try { - const match = await findShellOrDefault(this.selectedShell) - await launchShell(match, path, error => this._pushError(error)) + if (useCustomShell && customShell) { + await launchCustomShell(customShell, path, error => + this._pushError(error) + ) + } else { + const match = await findShellOrDefault(this.selectedShell) + await launchShell(match, path, error => this._pushError(error)) + } } catch (error) { this.emitError(error) } @@ -5395,23 +5631,34 @@ export class AppStore extends TypedBaseStore { return shell.openExternal(url) } + public async _editGlobalGitConfig() { + await getGlobalConfigPath() + .then(p => this._openInExternalEditor(p)) + .catch(e => log.error('Could not open global Git config for editing', e)) + } + /** Open a path to a repository or file using the user's configured editor */ public async _openInExternalEditor(fullPath: string): Promise { - const { selectedExternalEditor } = this.getState() + const { selectedExternalEditor, useCustomEditor, customEditor } = + this.getState() try { - const match = await findEditorOrDefault(selectedExternalEditor) - if (match === null) { - this.emitError( - new ExternalEditorError( - `No suitable editors installed for GitHub Desktop to launch. Install ${suggestedExternalEditor.name} for your platform and restart GitHub Desktop to try again.`, - { suggestDefaultEditor: true } + if (useCustomEditor && customEditor) { + await launchCustomExternalEditor(fullPath, customEditor) + } else { + const match = await findEditorOrDefault(selectedExternalEditor) + if (match === null) { + this.emitError( + new ExternalEditorError( + `No suitable editors installed for GitHub Desktop to launch. Install ${suggestedExternalEditor.name} for your platform and restart GitHub Desktop to try again.`, + { suggestDefaultEditor: true } + ) ) - ) - return - } + return + } - await launchExternalEditor(fullPath, match) + await launchExternalEditor(fullPath, match) + } } catch (error) { this.emitError(error) } @@ -5436,6 +5683,12 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } + public _setUseExternalCredentialHelper(value: boolean) { + setUseExternalCredentialHelper(value) + this.useExternalCredentialHelper = value + this.emitUpdate() + } + public _setAskToMoveToApplicationsFolderSetting( value: boolean ): Promise { @@ -5662,38 +5915,31 @@ export class AppStore extends TypedBaseStore { return this._refreshRepository(repository) } + public _resolveOAuthRequest(action: IOAuthAction) { + return this.signInStore.resolveOAuthRequest(action) + } + public _resetSignInState(): Promise { this.signInStore.reset() return Promise.resolve() } - public _beginDotComSignIn(): Promise { - this.signInStore.beginDotComSignIn() - return Promise.resolve() + public _beginDotComSignIn(resultCallback?: (result: SignInResult) => void) { + return this.signInStore.beginDotComSignIn(resultCallback) } - public _beginEnterpriseSignIn(): Promise { - this.signInStore.beginEnterpriseSignIn() - return Promise.resolve() + public _beginEnterpriseSignIn( + resultCallback?: (result: SignInResult) => void + ) { + return this.signInStore.beginEnterpriseSignIn(resultCallback) } public _setSignInEndpoint(url: string): Promise { return this.signInStore.setEndpoint(url) } - public _setSignInCredentials( - username: string, - password: string - ): Promise { - return this.signInStore.authenticateWithBasicAuth(username, password) - } - - public _requestBrowserAuthentication(): Promise { - return this.signInStore.authenticateWithBrowser() - } - - public _setSignInOTP(otp: string): Promise { - return this.signInStore.setTwoFactorOTP(otp) + public _requestBrowserAuthentication() { + this.signInStore.authenticateWithBrowser() } public async _setAppFocusState(isFocused: boolean): Promise { @@ -5754,11 +6000,12 @@ export class AppStore extends TypedBaseStore { return this.repositoriesStore.updateRepositoryPath(repository, path) } - public _removeAccount(account: Account): Promise { + public async _removeAccount(account: Account) { log.info( `[AppStore] removing account ${account.login} (${account.name}) from store` ) - return this.accountsStore.removeAccount(account) + await this.accountsStore.removeAccount(account) + await deleteToken(account) } private async _addAccount(account: Account): Promise { @@ -5977,12 +6224,12 @@ export class AppStore extends TypedBaseStore { }` } - private async withAuthenticatingUser( + private async withRefreshedGitHubRepository( repository: Repository, - fn: (repository: Repository, account: IGitAccount | null) => Promise + fn: (repository: Repository) => Promise ): Promise { let updatedRepository = repository - let account: IGitAccount | null = getAccountForRepository( + const account: Account | null = getAccountForRepository( this.accounts, updatedRepository ) @@ -5995,30 +6242,9 @@ export class AppStore extends TypedBaseStore { updatedRepository = await this.repositoryWithRefreshedGitHubRepository( repository ) - account = getAccountForRepository(this.accounts, updatedRepository) } - if (!account) { - const gitStore = this.gitStoreCache.get(repository) - const remote = gitStore.currentRemote - if (remote) { - const hostname = getGenericHostname(remote.url) - const username = getGenericUsername(hostname) - if (username != null) { - account = { login: username, endpoint: hostname } - } - } - } - - if (account instanceof Account) { - const hasValidToken = - account.token.length > 0 ? 'has token' : 'empty token' - log.info( - `[AppStore.withAuthenticatingUser] account found for repository: ${repository.name} - ${account.login} (${hasValidToken})` - ) - } - - return fn(updatedRepository, account) + return fn(updatedRepository) } private updateRevertProgress( @@ -6039,43 +6265,18 @@ export class AppStore extends TypedBaseStore { repository: Repository, commit: Commit ): Promise { - return this.withAuthenticatingUser(repository, async (repo, account) => { - const gitStore = this.gitStoreCache.get(repo) + return this.withRefreshedGitHubRepository(repository, async repository => { + const gitStore = this.gitStoreCache.get(repository) - await gitStore.revertCommit(repo, commit, account, progress => { - this.updateRevertProgress(repo, progress) + await gitStore.revertCommit(repository, commit, progress => { + this.updateRevertProgress(repository, progress) }) - this.updateRevertProgress(repo, null) + this.updateRevertProgress(repository, null) await this._refreshRepository(repository) }) } - public async promptForGenericGitAuthentication( - repository: Repository | CloningRepository, - retryAction: RetryAction - ): Promise { - let url - if (repository instanceof Repository) { - const gitStore = this.gitStoreCache.get(repository) - const remote = gitStore.currentRemote - if (!remote) { - return - } - - url = remote.url - } else { - url = repository.url - } - - const hostname = getGenericHostname(url) - return this._showPopup({ - type: PopupType.GenericGitAuthentication, - hostname, - retryAction, - }) - } - public async _installGlobalLFSFilters(force: boolean): Promise { try { await installGlobalLFSFilters(force) @@ -6312,10 +6513,6 @@ export class AppStore extends TypedBaseStore { const baseURL = `${htmlURL}/pull/new/${compareString}` await this._openInBrowser(baseURL) - - if (this.currentOnboardingTutorialStep === TutorialStep.OpenPullRequest) { - this._markPullRequestTutorialStepAsComplete(repository) - } } public async _updateExistingUpstreamRemote( @@ -6502,6 +6699,19 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + /** + * Set the application-wide tab indentation + */ + public _setSelectedTabSize(tabSize: number) { + if (!isNaN(tabSize)) { + this.selectedTabSize = tabSize + setNumber(tabSizeKey, tabSize) + this.emitUpdate() + } + + return Promise.resolve() + } + public async _resolveCurrentEditor() { const match = await findEditorOrDefault(this.selectedExternalEditor) const resolvedExternalEditor = match != null ? match.editor : null @@ -6826,7 +7036,7 @@ export class AppStore extends TypedBaseStore { const { commitSHAs } = compareState const commitIndexBySha = new Map(commitSHAs.map((sha, i) => [sha, i])) - return [...commits].sort((a, b) => + return commits.toSorted((a, b) => compare(commitIndexBySha.get(b.sha), commitIndexBySha.get(a.sha)) ) } @@ -6846,7 +7056,7 @@ export class AppStore extends TypedBaseStore { const { commitSHAs } = compareState const commitIndexBySha = new Map(commitSHAs.map((sha, i) => [sha, i])) - return [...commits].sort((a, b) => + return commits.toSorted((a, b) => compare(commitIndexBySha.get(b), commitIndexBySha.get(a)) ) } @@ -6920,11 +7130,11 @@ export class AppStore extends TypedBaseStore { ): Promise { const gitStore = this.gitStoreCache.get(repository) - const checkoutSuccessful = await this.withAuthenticatingUser( + const checkoutSuccessful = await this.withRefreshedGitHubRepository( repository, - (r, account) => { + repository => { return gitStore.performFailableOperation(() => - checkoutBranch(repository, account, targetBranch) + checkoutBranch(repository, targetBranch, gitStore.currentRemote) ) } ) @@ -7035,9 +7245,9 @@ export class AppStore extends TypedBaseStore { } const gitStore = this.gitStoreCache.get(repository) - await this.withAuthenticatingUser(repository, async (r, account) => { + await this.withRefreshedGitHubRepository(repository, async repository => { await gitStore.performFailableOperation(() => - checkoutBranch(repository, account, sourceBranch) + checkoutBranch(repository, sourceBranch, gitStore.currentRemote) ) }) } @@ -7077,6 +7287,30 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } + public _setUseCustomEditor(useCustomEditor: boolean) { + setBoolean(useCustomEditorKey, useCustomEditor) + this.useCustomEditor = useCustomEditor + this.emitUpdate() + } + + public _setCustomEditor(customEditor: ICustomIntegration) { + setObject(customEditorKey, customEditor) + this.customEditor = customEditor + this.emitUpdate() + } + + public _setUseCustomShell(useCustomShell: boolean) { + setBoolean(useCustomShellKey, useCustomShell) + this.useCustomShell = useCustomShell + this.emitUpdate() + } + + public _setCustomShell(customShell: ICustomIntegration) { + setObject(customShellKey, customShell) + this.customShell = customShell + this.emitUpdate() + } + /** This shouldn't be called directly. See `Dispatcher`. */ public async _reorderCommits( repository: Repository, @@ -7437,8 +7671,6 @@ export class AppStore extends TypedBaseStore { public onChecksFailedNotification = async ( repository: RepositoryWithGitHubRepository, pullRequest: PullRequest, - commitMessage: string, - commitSha: string, checks: ReadonlyArray ) => { const selectedRepository = @@ -7449,8 +7681,6 @@ export class AppStore extends TypedBaseStore { pullRequest, repository, shouldChangeRepository: true, - commitMessage, - commitSha, checks, } @@ -7876,6 +8106,22 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } } + + public _updateUnderlineLinks(underlineLinks: boolean) { + if (underlineLinks !== this.underlineLinks) { + this.underlineLinks = underlineLinks + setBoolean(underlineLinksKey, underlineLinks) + this.emitUpdate() + } + } + + public _updateShowDiffCheckMarks(showDiffCheckMarks: boolean) { + if (showDiffCheckMarks !== this.showDiffCheckMarks) { + this.showDiffCheckMarks = showDiffCheckMarks + setBoolean(showDiffCheckMarksKey, showDiffCheckMarks) + this.emitUpdate() + } + } } /** diff --git a/app/src/lib/stores/commit-status-store.ts b/app/src/lib/stores/commit-status-store.ts index 6990edd9695..506e32f39cd 100644 --- a/app/src/lib/stores/commit-status-store.ts +++ b/app/src/lib/stores/commit-status-store.ts @@ -1,24 +1,24 @@ import pLimit from 'p-limit' import QuickLRU from 'quick-lru' +import { Disposable, DisposableLike } from 'event-kit' +import xor from 'lodash/xor' import { Account } from '../../models/account' -import { AccountsStore } from './accounts-store' import { GitHubRepository } from '../../models/github-repository' import { API, getAccountForEndpoint, IAPICheckSuite } from '../api' -import { DisposableLike, Disposable } from 'event-kit' import { - ICombinedRefCheck, - IRefCheck, - createCombinedCheckFromChecks, apiCheckRunToRefCheck, - getLatestCheckRunsByName, apiStatusToRefCheck, - getLatestPRWorkflowRunsLogsForCheckRun, + createCombinedCheckFromChecks, getCheckRunActionsWorkflowRuns, + getLatestCheckRunsById, + getLatestPRWorkflowRunsLogsForCheckRun, + ICombinedRefCheck, + IRefCheck, manuallySetChecksToPending, } from '../ci-checks/ci-checks' -import xor from 'lodash/xor' import { offsetFromNow } from '../offset-from' +import { AccountsStore } from './accounts-store' interface ICommitStatusCacheEntry { /** @@ -310,9 +310,7 @@ export class CommitStatusStore { } if (checkRuns !== null) { - const latestCheckRunsByName = getLatestCheckRunsByName( - checkRuns.check_runs - ) + const latestCheckRunsByName = getLatestCheckRunsById(checkRuns.check_runs) checks.push(...latestCheckRunsByName.map(apiCheckRunToRefCheck)) } diff --git a/app/src/lib/stores/git-store.ts b/app/src/lib/stores/git-store.ts index f1641072ee5..2ad88655838 100644 --- a/app/src/lib/stores/git-store.ts +++ b/app/src/lib/stores/git-store.ts @@ -89,7 +89,6 @@ 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' @@ -145,6 +144,8 @@ export class GitStore extends BaseStore { private _tagsToPush: ReadonlyArray = [] + private _remotes: ReadonlyArray = [] + private _defaultRemote: IRemote | null = null private _currentRemote: IRemote | null = null @@ -704,6 +705,32 @@ export class GitStore extends BaseStore { return } + const coAuthorsRestored = await this.restoreCoAuthorsFromCommit(commit) + if (coAuthorsRestored) { + return + } + + this._commitMessage = { + summary: commit.summary, + description: commit.body, + } + this.emitUpdate() + } + + public async prepareToAmendCommit(commit: Commit) { + const coAuthorsRestored = await this.restoreCoAuthorsFromCommit(commit) + if (coAuthorsRestored) { + return + } + + this._commitMessage = { + summary: commit.summary, + description: commit.body, + } + this.emitUpdate() + } + + private async restoreCoAuthorsFromCommit(commit: Commit) { // Let's be safe about this since it's untried waters. // If we can restore co-authors then that's fantastic // but if we can't we shouldn't be throwing an error, @@ -713,17 +740,14 @@ export class GitStore extends BaseStore { try { await this.loadCommitAndCoAuthors(commit) this.emitUpdate() - return + + return true } catch (e) { log.error('Failed to restore commit and co-authors, falling back', e) } } - this._commitMessage = { - summary: commit.summary, - description: commit.body, - } - this.emitUpdate() + return false } /** @@ -925,7 +949,6 @@ export class GitStore extends BaseStore { * the overall fetch progress. */ public async fetch( - account: IGitAccount | null, backgroundTask: boolean, progressCallback?: (fetchProgress: IFetchProgress) => void ): Promise { @@ -951,7 +974,6 @@ export class GitStore extends BaseStore { if (remotes.size > 0) { await this.fetchRemotes( - account, [...remotes.values()], backgroundTask, progressCallback @@ -991,7 +1013,6 @@ export class GitStore extends BaseStore { * the overall fetch progress. */ public async fetchRemotes( - account: IGitAccount | null, remotes: ReadonlyArray, backgroundTask: boolean, progressCallback?: (fetchProgress: IFetchProgress) => void @@ -1006,7 +1027,7 @@ export class GitStore extends BaseStore { const remote = remotes[i] const startProgressValue = i * weight - await this.fetchRemote(account, remote, backgroundTask, progress => { + await this.fetchRemote(remote, backgroundTask, progress => { if (progress && progressCallback) { progressCallback({ ...progress, @@ -1027,21 +1048,36 @@ export class GitStore extends BaseStore { * the overall fetch progress. */ public async fetchRemote( - account: IGitAccount | null, remote: IRemote, backgroundTask: boolean, progressCallback?: (fetchProgress: IFetchProgress) => void ): Promise { + const repo = this.repository const retryAction: RetryAction = { type: RetryActionType.Fetch, - repository: this.repository, + repository: repo, } - await this.performFailableOperation( - () => fetchRepo(this.repository, account, remote, progressCallback), + const fetchSucceeded = await this.performFailableOperation( + async () => { + await fetchRepo(repo, remote, progressCallback, backgroundTask) + return true + }, { backgroundTask, retryAction } ) - await updateRemoteHEAD(this.repository, account, remote) + // If the pull failed we shouldn't try to update the remote HEAD + // because there's a decent chance that it failed either because we + // didn't have the correct credentials (which we won't this time + // either) or because there's a network error which likely will + // persist for the next operation as well. + if (fetchSucceeded) { + // Updating the local HEAD symref isn't critical so we don't want + // to show an error message to the user and have them retry the + // entire pull operation if it fails. + await updateRemoteHEAD(repo, remote, backgroundTask).catch(e => + log.error('Failed updating remote HEAD', e) + ) + } } /** @@ -1052,16 +1088,13 @@ export class GitStore extends BaseStore { * part of this action. Refer to git-scm for more * information on refspecs: https://www.git-scm.com/book/tr/v2/Git-Internals-The-Refspec */ - public async fetchRefspec( - account: IGitAccount | null, - refspec: string - ): Promise { + public async fetchRefspec(refspec: string): Promise { // TODO: we should favour origin here const remotes = await getRemotes(this.repository) for (const remote of remotes) { await this.performFailableOperation(() => - fetchRefspec(this.repository, account, remote, refspec) + fetchRefspec(this.repository, remote, refspec) ) } } @@ -1228,6 +1261,7 @@ export class GitStore extends BaseStore { public async loadRemotes(): Promise { const remotes = await getRemotes(this.repository) + this._remotes = remotes this._defaultRemote = findDefaultRemote(remotes) const currentRemoteName = @@ -1329,6 +1363,11 @@ export class GitStore extends BaseStore { return this._aheadBehind } + /** The list of configured remotes for the repository */ + public get remotes() { + return this._remotes + } + /** * The remote considered to be the "default" remote in the repository. * @@ -1571,11 +1610,10 @@ export class GitStore extends BaseStore { public async revertCommit( repository: Repository, commit: Commit, - account: IGitAccount | null, progressCallback?: (fetchProgress: IRevertProgress) => void ): Promise { await this.performFailableOperation(() => - revertCommit(repository, commit, account, progressCallback) + revertCommit(repository, commit, this.currentRemote, progressCallback) ) this.emitUpdate() diff --git a/app/src/lib/stores/helpers/background-fetcher.ts b/app/src/lib/stores/helpers/background-fetcher.ts index 8dd6e468972..964b5a7868b 100644 --- a/app/src/lib/stores/helpers/background-fetcher.ts +++ b/app/src/lib/stores/helpers/background-fetcher.ts @@ -1,8 +1,8 @@ import { Repository } from '../../../models/repository' -import { Account } from '../../../models/account' import { GitHubRepository } from '../../../models/github-repository' -import { API } from '../../api' +import { API, getAccountForEndpoint } from '../../api' import { fatalError } from '../../fatal-error' +import { AccountsStore } from '../accounts-store' /** * A default interval at which to automatically fetch repositories, if the @@ -24,13 +24,6 @@ const SkewUpperBound = 30 * 1000 /** The class which handles doing background fetches of the repository. */ export class BackgroundFetcher { - private readonly repository: Repository - private readonly account: Account - private readonly fetch: (repository: Repository) => Promise - private readonly shouldPerformFetch: ( - repository: Repository - ) => Promise - /** The handle for our setTimeout invocation. */ private timeoutHandle: number | null = null @@ -38,16 +31,13 @@ export class BackgroundFetcher { private stopped = false public constructor( - repository: Repository, - account: Account, - fetch: (repository: Repository) => Promise, - shouldPerformFetch: (repository: Repository) => Promise - ) { - this.repository = repository - this.account = account - this.fetch = fetch - this.shouldPerformFetch = shouldPerformFetch - } + private readonly repository: Repository, + private readonly accountsStore: AccountsStore, + private readonly fetch: (repository: Repository) => Promise, + private readonly shouldPerformFetch: ( + repository: Repository + ) => Promise + ) {} /** Start background fetching. */ public start(withInitialSkew: boolean) { @@ -129,21 +119,29 @@ export class BackgroundFetcher { private async getFetchInterval( repository: GitHubRepository ): Promise { - const api = API.fromAccount(this.account) + const account = getAccountForEndpoint( + await this.accountsStore.getAll(), + repository.endpoint + ) let interval = DefaultFetchInterval - try { - const pollInterval = await api.getFetchPollInterval( - repository.owner.login, - repository.name - ) - if (pollInterval) { - interval = Math.max(pollInterval, MinimumInterval) - } else { - interval = DefaultFetchInterval + + if (account) { + const api = API.fromAccount(account) + + try { + const pollInterval = await api.getFetchPollInterval( + repository.owner.login, + repository.name + ) + if (pollInterval) { + interval = Math.max(pollInterval, MinimumInterval) + } else { + interval = DefaultFetchInterval + } + } catch (e) { + log.error('Error fetching poll interval', e) } - } catch (e) { - log.error('Error fetching poll interval', e) } return interval + skewInterval() diff --git a/app/src/lib/stores/helpers/branch-pruner.ts b/app/src/lib/stores/helpers/branch-pruner.ts index e9ab9b64fc5..33b4b945a79 100644 --- a/app/src/lib/stores/helpers/branch-pruner.ts +++ b/app/src/lib/stores/helpers/branch-pruner.ts @@ -241,7 +241,9 @@ export class BranchPruner { log.info(`[BranchPruner] Branch '${branchName}' marked for deletion`) } } - this.onPruneCompleted(this.repository) + this.onPruneCompleted(this.repository).catch(e => { + log.error(`[BranchPruner] Error calling onPruneCompleted`, e) + }) } } diff --git a/app/src/lib/stores/helpers/create-tutorial-repository.ts b/app/src/lib/stores/helpers/create-tutorial-repository.ts index dea55ed2208..ceacd9e94f5 100644 --- a/app/src/lib/stores/helpers/create-tutorial-repository.ts +++ b/app/src/lib/stores/helpers/create-tutorial-repository.ts @@ -73,7 +73,7 @@ async function pushRepo( const pushOpts = await executionOptionsWithProgress( { - env: await envForRemoteOperation(account, remote.url), + env: await envForRemoteOperation(remote.url), }, new PushProgressParser(), progress => { diff --git a/app/src/lib/stores/helpers/tutorial-assessor.ts b/app/src/lib/stores/helpers/tutorial-assessor.ts index ab52366eb3a..a80af641215 100644 --- a/app/src/lib/stores/helpers/tutorial-assessor.ts +++ b/app/src/lib/stores/helpers/tutorial-assessor.ts @@ -29,6 +29,8 @@ export class OnboardingTutorialAssessor { /** Is the tutorial currently paused? */ private tutorialPaused: boolean = getBoolean(tutorialPausedKey, false) + private tutorialAnnounced: boolean = false + public constructor( /** Method to call when we need to get the current editor */ private getResolvedExternalEditor: () => string | null @@ -61,8 +63,10 @@ export class OnboardingTutorialAssessor { return TutorialStep.PushBranch } else if (!this.pullRequestCreated(repositoryState)) { return TutorialStep.OpenPullRequest - } else { + } else if (!this.tutorialAnnounced) { return TutorialStep.AllDone + } else { + return TutorialStep.Announced } } @@ -145,6 +149,10 @@ export class OnboardingTutorialAssessor { setBoolean(pullRequestStepCompleteKey, this.prStepComplete) } + public markTutorialCompletionAsAnnounced = () => { + this.tutorialAnnounced = true + } + /** * Call when a new tutorial repository is created * diff --git a/app/src/lib/stores/notifications-debug-store.ts b/app/src/lib/stores/notifications-debug-store.ts index 67547f5de9a..20355c69236 100644 --- a/app/src/lib/stores/notifications-debug-store.ts +++ b/app/src/lib/stores/notifications-debug-store.ts @@ -4,7 +4,6 @@ import { PullRequest, getPullRequestCommitRef } from '../../models/pull-request' import { RepositoryWithGitHubRepository } from '../../models/repository' import { Dispatcher, defaultErrorHandler } from '../../ui/dispatcher' import { API, APICheckConclusion, IAPIComment } from '../api' -import { getCommit } from '../git' import { showNotification } from '../notifications/show-notification' import { isValidNotificationPullRequestReview, @@ -226,8 +225,6 @@ export class NotificationsDebugStore { commit_sha: commitSha, } - const commit = await getCommit(repository, commitSha) - const numberOfFailedChecks = checks.filter( check => check.conclusion === APICheckConclusion.Failure ).length @@ -239,13 +236,7 @@ export class NotificationsDebugStore { const title = 'Pull Request checks failed' const body = `${pullRequest.title} #${pullRequest.pullRequestNumber} (${shortSHA})\n${numberOfFailedChecks} ${pluralChecks} not successful.` const onClick = () => { - dispatcher.onChecksFailedNotification( - repository, - pullRequest, - commit?.summary ?? 'Could not load commit summary', - commitSha, - checks - ) + dispatcher.onChecksFailedNotification(repository, pullRequest, checks) } showNotification({ diff --git a/app/src/lib/stores/notifications-store.ts b/app/src/lib/stores/notifications-store.ts index d251dc42440..60453ae270b 100644 --- a/app/src/lib/stores/notifications-store.ts +++ b/app/src/lib/stores/notifications-store.ts @@ -1,25 +1,34 @@ +import { NotificationCallback } from 'desktop-notifications/dist/notification-callback' +import { Commit, shortenSHA } from '../../models/commit' +import { GitHubRepository } from '../../models/github-repository' +import { PullRequest, getPullRequestCommitRef } from '../../models/pull-request' import { Repository, - isRepositoryWithGitHubRepository, RepositoryWithGitHubRepository, - isRepositoryWithForkedGitHubRepository, getForkContributionTarget, + isRepositoryWithForkedGitHubRepository, + isRepositoryWithGitHubRepository, } from '../../models/repository' import { ForkContributionTarget } from '../../models/workflow-preferences' -import { getPullRequestCommitRef, PullRequest } from '../../models/pull-request' +import { getVerbForPullRequestReview } from '../../ui/notifications/pull-request-review-helpers' import { API, APICheckConclusion, IAPIComment } from '../api' import { - createCombinedCheckFromChecks, - getLatestCheckRunsByName, - apiStatusToRefCheck, - apiCheckRunToRefCheck, IRefCheck, + apiCheckRunToRefCheck, + apiStatusToRefCheck, + createCombinedCheckFromChecks, + getLatestCheckRunsById, } from '../ci-checks/ci-checks' -import { AccountsStore } from './accounts-store' import { getCommit } from '../git' -import { GitHubRepository } from '../../models/github-repository' -import { PullRequestCoordinator } from './pull-request-coordinator' -import { Commit, shortenSHA } from '../../models/commit' +import { getBoolean, setBoolean } from '../local-storage' +import { showNotification } from '../notifications/show-notification' +import { StatsStore } from '../stats' +import { truncateWithEllipsis } from '../truncate-with-ellipsis' +import { + ValidNotificationPullRequestReview, + isValidNotificationPullRequestReview, +} from '../valid-notification-pull-request-review' +import { AccountsStore } from './accounts-store' import { AliveStore, DesktopAliveEvent, @@ -27,22 +36,11 @@ import { 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' +import { PullRequestCoordinator } from './pull-request-coordinator' export type OnChecksFailedCallback = ( repository: RepositoryWithGitHubRepository, pullRequest: PullRequest, - commitMessage: string, - commitSha: string, checkRuns: ReadonlyArray ) => void @@ -114,7 +112,7 @@ export class NotificationsStore { async (event, id, userInfo) => this.handleAliveEvent(userInfo, true) public simulateAliveEvent(event: DesktopAliveEvent) { - if (__DEV__) { + if (__DEV__ || __RELEASE_CHANNEL__ === 'test') { this.handleAliveEvent(event, false) } } @@ -187,7 +185,7 @@ export class NotificationsStore { return } - const title = `@${comment.user.login} commented your pull request` + const title = `@${comment.user.login} commented on your pull request` const body = `${pullRequest.title} #${ pullRequest.pullRequestNumber }\n${truncateWithEllipsis(comment.body, 50)}` @@ -403,13 +401,7 @@ export class NotificationsStore { const onClick = () => { this.statsStore.increment('checksFailedNotificationClicked') - this.onChecksFailedCallback?.( - repository, - pullRequest, - commit.summary, - commitSHA, - checks - ) + this.onChecksFailedCallback?.(repository, pullRequest, checks) } if (skipNotification) { @@ -545,9 +537,7 @@ export class NotificationsStore { } if (checkRuns !== null) { - const latestCheckRunsByName = getLatestCheckRunsByName( - checkRuns.check_runs - ) + const latestCheckRunsByName = getLatestCheckRunsById(checkRuns.check_runs) checks.push(...latestCheckRunsByName.map(apiCheckRunToRefCheck)) } diff --git a/app/src/lib/stores/sign-in-store.ts b/app/src/lib/stores/sign-in-store.ts index cd981ad7666..fa243b2145a 100644 --- a/app/src/lib/stores/sign-in-store.ts +++ b/app/src/lib/stores/sign-in-store.ts @@ -1,7 +1,6 @@ import { Disposable } from 'event-kit' import { Account } from '../../models/account' -import { assertNever, fatalError } from '../fatal-error' -import { askUserToOAuth } from '../../lib/oauth' +import { fatalError } from '../fatal-error' import { validateURL, InvalidURLErrorName, @@ -9,27 +8,19 @@ import { } from '../../ui/lib/enterprise-validate-url' import { - createAuthorization, - AuthorizationResponse, fetchUser, - AuthorizationResponseKind, - getHTMLURL, getDotComAPIEndpoint, getEnterpriseAPIURL, - fetchMetadata, + requestOAuthToken, + getOAuthAuthorizationURL, } from '../../lib/api' -import { AuthenticationMode } from '../../lib/2fa' - -import { minimumSupportedEnterpriseVersion } from '../../lib/enterprise' import { TypedBaseStore } from './base-store' -import { timeout } from '../promise' - -function getUnverifiedUserErrorMessage(login: string): string { - return `Unable to authenticate. The account ${login} is lacking a verified email address. Please sign in to GitHub.com, confirm your email address in the Emails section under Personal settings, and try again.` -} - -const EnterpriseTooOldMessage = `The GitHub Enterprise version does not support GitHub Desktop. Talk to your server's administrator about upgrading to the latest version of GitHub Enterprise.` +import uuid from 'uuid' +import { IOAuthAction } from '../parse-app-url' +import { shell } from '../app-shell' +import noop from 'lodash/noop' +import { AccountsStore } from './accounts-store' /** * An enumeration of the possible steps that the sign in @@ -37,6 +28,7 @@ const EnterpriseTooOldMessage = `The GitHub Enterprise version does not support */ export enum SignInStep { EndpointEntry = 'EndpointEntry', + ExistingAccountWarning = 'ExistingAccountWarning', Authentication = 'Authentication', TwoFactorAuthentication = 'TwoFactorAuthentication', Success = 'Success', @@ -48,8 +40,8 @@ export enum SignInStep { */ export type SignInState = | IEndpointEntryState + | IExistingAccountWarning | IAuthenticationState - | ITwoFactorAuthenticationState | ISuccessState /** @@ -76,6 +68,8 @@ export interface ISignInState { * sign in process is ongoing. */ readonly loading: boolean + + readonly resultCallback: (result: SignInResult) => void } /** @@ -83,20 +77,8 @@ export interface ISignInState { * This is the initial step in the Enterprise sign in * flow and is not present when signing in to GitHub.com */ -export interface IEndpointEntryState extends ISignInState { - readonly kind: SignInStep.EndpointEntry -} - -/** - * State interface representing the Authentication step where - * the user provides credentials and/or initiates a browser - * OAuth sign in process. This step occurs as the first step - * when signing in to GitHub.com and as the second step when - * signing in to a GitHub Enterprise instance. - */ -export interface IAuthenticationState extends ISignInState { - readonly kind: SignInStep.Authentication - +export interface IExistingAccountWarning extends ISignInState { + readonly kind: SignInStep.ExistingAccountWarning /** * The URL to the host which we're currently authenticating * against. This will be either https://api.github.com when @@ -104,31 +86,31 @@ export interface IAuthenticationState extends ISignInState { * URL when signing in against a GitHub Enterprise * instance. */ + readonly existingAccount: Account readonly endpoint: string - /** - * A value indicating whether or not the endpoint supports - * basic authentication (i.e. username and password). All - * GitHub Enterprise instances support OAuth (or web - * flow sign-in). - */ - readonly supportsBasicAuth: boolean + readonly resultCallback: (result: SignInResult) => void +} - /** - * The endpoint-specific URL for resetting credentials. - */ - readonly forgotPasswordUrl: string +/** + * State interface representing the endpoint entry step. + * This is the initial step in the Enterprise sign in + * flow and is not present when signing in to GitHub.com + */ +export interface IEndpointEntryState extends ISignInState { + readonly kind: SignInStep.EndpointEntry + readonly resultCallback: (result: SignInResult) => void } /** - * State interface representing the TwoFactorAuthentication - * step where the user provides an OTP token. This step - * occurs after the authentication step both for GitHub.com, - * and GitHub Enterprise when the user has enabled two - * factor authentication on the host. + * State interface representing the Authentication step where + * the user provides credentials and/or initiates a browser + * OAuth sign in process. This step occurs as the first step + * when signing in to GitHub.com and as the second step when + * signing in to a GitHub Enterprise instance. */ -export interface ITwoFactorAuthenticationState extends ISignInState { - readonly kind: SignInStep.TwoFactorAuthentication +export interface IAuthenticationState extends ISignInState { + readonly kind: SignInStep.Authentication /** * The URL to the host which we're currently authenticating @@ -139,22 +121,14 @@ export interface ITwoFactorAuthenticationState extends ISignInState { */ readonly endpoint: string - /** - * The username specified by the user in the preceding - * Authentication step - */ - readonly username: string - - /** - * The password specified by the user in the preceding - * Authentication step - */ - readonly password: string + readonly resultCallback: (result: SignInResult) => void - /** - * The 2FA type expected by the GitHub endpoint. - */ - readonly type: AuthenticationMode + readonly oauthState?: { + state: string + endpoint: string + onAuthCompleted: (account: Account) => void + onAuthError: (error: Error) => void + } } /** @@ -165,31 +139,16 @@ export interface ITwoFactorAuthenticationState extends ISignInState { */ export interface ISuccessState { readonly kind: SignInStep.Success -} - -/** - * The method used to authenticate a user. - */ -export enum SignInMethod { - /** - * In-app sign-in with username, password, and possibly a - * two-factor code. - */ - Basic = 'basic', - /** - * Sign-in through a web browser with a redirect back to - * the application. - */ - Web = 'web', + readonly resultCallback: (result: SignInResult) => void } interface IAuthenticationEvent { readonly account: Account - readonly method: SignInMethod } -/** The maximum time to wait for a `/meta` API call in milliseconds */ -const ServerMetaDataTimeout = 2000 +export type SignInResult = + | { kind: 'success'; account: Account } + | { kind: 'cancelled' } /** * A store encapsulating all logic related to signing in a user @@ -197,29 +156,35 @@ const ServerMetaDataTimeout = 2000 */ export class SignInStore extends TypedBaseStore { private state: SignInState | null = null - /** - * A map keyed on an endpoint url containing the last known - * value of the verifiable_password_authentication meta property - * for that endpoint. - */ - private endpointSupportBasicAuth = new Map() - private emitAuthenticate(account: Account, method: SignInMethod) { - const event: IAuthenticationEvent = { account, method } + private accounts: ReadonlyArray = [] + + public constructor(private readonly accountStore: AccountsStore) { + super() + + this.accountStore.getAll().then(accounts => { + this.accounts = accounts + }) + this.accountStore.onDidUpdate(accounts => { + this.accounts = accounts + }) + } + + private emitAuthenticate(account: Account) { + const event: IAuthenticationEvent = { account } this.emitter.emit('did-authenticate', event) + this.state?.resultCallback({ kind: 'success', account }) } /** * Registers an event handler which will be invoked whenever * a user has successfully completed a sign-in process. */ - public onDidAuthenticate( - fn: (account: Account, method: SignInMethod) => void - ): Disposable { + public onDidAuthenticate(fn: (account: Account) => void): Disposable { return this.emitter.on( 'did-authenticate', - ({ account, method }: IAuthenticationEvent) => { - fn(account, method) + ({ account }: IAuthenticationEvent) => { + fn(account) } ) } @@ -241,250 +206,159 @@ export class SignInStore extends TypedBaseStore { this.emitUpdate(this.getState()) } - private async endpointSupportsBasicAuth(endpoint: string): Promise { - if (endpoint === getDotComAPIEndpoint()) { - return false - } - - const cached = this.endpointSupportBasicAuth.get(endpoint) - const fallbackValue = - cached === undefined - ? null - : { verifiable_password_authentication: cached } - - const response = await timeout( - fetchMetadata(endpoint), - ServerMetaDataTimeout, - fallbackValue - ) - - if (response !== null) { - const supportsBasicAuth = - response.verifiable_password_authentication === true - this.endpointSupportBasicAuth.set(endpoint, supportsBasicAuth) - - return supportsBasicAuth - } - - throw new Error( - `Unable to authenticate with the GitHub Enterprise instance. Verify that the URL is correct, that your GitHub Enterprise instance is running version ${minimumSupportedEnterpriseVersion} or later, that you have an internet connection and try again.` - ) - } - - private getForgotPasswordURL(endpoint: string): string { - return `${getHTMLURL(endpoint)}/password_reset` - } - /** * Clear any in-flight sign in state and return to the * initial (no sign-in) state. */ public reset() { + const currentState = this.state + this.state?.resultCallback({ kind: 'cancelled' }) this.setState(null) + + if (currentState?.kind === SignInStep.Authentication) { + currentState.oauthState?.onAuthError(new Error('cancelled')) + } } /** * Initiate a sign in flow for github.com. This will put the store * in the Authentication step ready to receive user credentials. */ - public beginDotComSignIn() { + public beginDotComSignIn(resultCallback?: (result: SignInResult) => void) { const endpoint = getDotComAPIEndpoint() - this.setState({ - kind: SignInStep.Authentication, - endpoint, - supportsBasicAuth: false, - error: null, - loading: false, - forgotPasswordUrl: this.getForgotPasswordURL(endpoint), - }) + if (this.state !== null) { + this.reset() + } - // Asynchronously refresh our knowledge about whether GitHub.com - // support username and password authentication or not. - this.endpointSupportsBasicAuth(endpoint) - .then(supportsBasicAuth => { - if ( - this.state !== null && - this.state.kind === SignInStep.Authentication && - this.state.endpoint === endpoint - ) { - this.setState({ ...this.state, supportsBasicAuth }) - } + const existingAccount = this.accounts.find( + x => x.endpoint === getDotComAPIEndpoint() + ) + + if (existingAccount) { + this.setState({ + kind: SignInStep.ExistingAccountWarning, + endpoint, + existingAccount, + error: null, + loading: false, + resultCallback: resultCallback ?? noop, }) - .catch(err => - log.error( - 'Failed resolving whether GitHub.com supports password authentication', - err - ) - ) + } else { + this.setState({ + kind: SignInStep.Authentication, + endpoint, + error: null, + loading: false, + resultCallback: resultCallback ?? noop, + }) + } } /** - * Attempt to advance from the authentication step using a username - * and password. This method must only be called when the store is - * in the authentication step or an error will be thrown. If the - * provided credentials are valid the store will either advance to - * the Success step or to the TwoFactorAuthentication step if the - * user has enabled two factor authentication. - * - * If an error occurs during sign in (such as invalid credentials) - * the authentication state will be updated with that error so that - * the responsible component can present it to the user. + * Initiate an OAuth sign in using the system configured browser. + * This method must only be called when the store is in the authentication + * step or an error will be thrown. */ - public async authenticateWithBasicAuth( - username: string, - password: string - ): Promise { + public async authenticateWithBrowser() { const currentState = this.state - if (!currentState || currentState.kind !== SignInStep.Authentication) { + if ( + currentState?.kind !== SignInStep.Authentication && + currentState?.kind !== SignInStep.ExistingAccountWarning + ) { const stepText = currentState ? currentState.kind : 'null' return fatalError( - `Sign in step '${stepText}' not compatible with authentication` + `Sign in step '${stepText}' not compatible with browser authentication` ) } - const endpoint = currentState.endpoint - this.setState({ ...currentState, loading: true }) - let response: AuthorizationResponse - try { - response = await createAuthorization(endpoint, username, password, null) - } catch (e) { - this.emitError(e) - return - } - - if (!this.state || this.state.kind !== SignInStep.Authentication) { - // Looks like the sign in flow has been aborted - return + if (currentState.kind === SignInStep.ExistingAccountWarning) { + const { existingAccount } = currentState + // Try to avoid emitting an error out of AccountsStore if the account + // is already gone. + if (this.accounts.find(x => x.endpoint === existingAccount.endpoint)) { + await this.accountStore.removeAccount(existingAccount) + } } - if (response.kind === AuthorizationResponseKind.Authorized) { - const token = response.token - const user = await fetchUser(endpoint, token) + const csrfToken = uuid() - if (!this.state || this.state.kind !== SignInStep.Authentication) { - // Looks like the sign in flow has been aborted - return - } - - this.emitAuthenticate(user, SignInMethod.Basic) - this.setState({ kind: SignInStep.Success }) - } else if ( - response.kind === - AuthorizationResponseKind.TwoFactorAuthenticationRequired - ) { + new Promise((resolve, reject) => { + const { endpoint, resultCallback } = currentState + log.info('[SignInStore] initializing OAuth flow') this.setState({ - kind: SignInStep.TwoFactorAuthentication, + kind: SignInStep.Authentication, endpoint, - username, - password, - type: response.type, + resultCallback, error: null, - loading: false, + loading: true, + oauthState: { + state: csrfToken, + endpoint, + onAuthCompleted: resolve, + onAuthError: reject, + }, }) - } else { - if (response.kind === AuthorizationResponseKind.Error) { - this.emitError( - new Error( - `The server responded with an error while attempting to authenticate (${response.response.status})\n\n${response.response.statusText}` - ) - ) - this.setState({ ...currentState, loading: false }) - } else if (response.kind === AuthorizationResponseKind.Failed) { - if (username.includes('@')) { - this.setState({ - ...currentState, - loading: false, - error: new Error('Incorrect email or password.'), - }) - } else { - this.setState({ - ...currentState, - loading: false, - error: new Error('Incorrect username or password.'), - }) + shell.openExternal(getOAuthAuthorizationURL(endpoint, csrfToken)) + }) + .then(account => { + if (!this.state || this.state.kind !== SignInStep.Authentication) { + // Looks like the sign in flow has been aborted + log.warn('[SignInStore] account resolved but session has changed') + return } - } else if ( - response.kind === AuthorizationResponseKind.UserRequiresVerification - ) { - this.setState({ - ...currentState, - loading: false, - error: new Error(getUnverifiedUserErrorMessage(username)), - }) - } else if ( - response.kind === AuthorizationResponseKind.PersonalAccessTokenBlocked - ) { - this.setState({ - ...currentState, - loading: false, - error: new Error( - 'A personal access token cannot be used to login to GitHub Desktop.' - ), - }) - } else if (response.kind === AuthorizationResponseKind.EnterpriseTooOld) { - this.setState({ - ...currentState, - loading: false, - error: new Error(EnterpriseTooOldMessage), - }) - } else if (response.kind === AuthorizationResponseKind.WebFlowRequired) { + + log.info('[SignInStore] account resolved') + this.emitAuthenticate(account) this.setState({ - ...currentState, - loading: false, - supportsBasicAuth: false, - kind: SignInStep.Authentication, + kind: SignInStep.Success, + resultCallback: this.state.resultCallback, }) - } else { - return assertNever(response, `Unsupported response: ${response}`) - } - } + }) + .catch(e => { + // Make sure we're still in the same sign in session + if ( + this.state?.kind === SignInStep.Authentication && + this.state.oauthState?.state === csrfToken + ) { + log.info('[SignInStore] error with OAuth flow', e) + this.setState({ ...this.state, error: e, loading: false }) + } else { + log.info(`[SignInStore] OAuth error but session has changed: ${e}`) + } + }) } - /** - * Initiate an OAuth sign in using the system configured browser. - * This method must only be called when the store is in the authentication - * step or an error will be thrown. - * - * The promise returned will only resolve once the user has successfully - * authenticated. If the user terminates the sign-in process by closing - * their browser before the protocol handler is invoked, by denying the - * protocol handler to execute or by providing the wrong credentials - * this promise will never complete. - */ - public async authenticateWithBrowser(): Promise { - const currentState = this.state - - if (!currentState || currentState.kind !== SignInStep.Authentication) { - const stepText = currentState ? currentState.kind : 'null' - return fatalError( - `Sign in step '${stepText}' not compatible with browser authentication` - ) + public async resolveOAuthRequest(action: IOAuthAction) { + if (!this.state || this.state.kind !== SignInStep.Authentication) { + return } - this.setState({ ...currentState, loading: true }) - - let account: Account - try { - log.info('[SignInStore] initializing OAuth flow') - account = await askUserToOAuth(currentState.endpoint) - log.info('[SignInStore] account resolved') - } catch (e) { - log.info('[SignInStore] error with OAuth flow', e) - this.setState({ ...currentState, error: e, loading: false }) + if (!this.state.oauthState) { return } - if (!this.state || this.state.kind !== SignInStep.Authentication) { - // Looks like the sign in flow has been aborted + if (this.state.oauthState.state !== action.state) { + log.warn( + 'requestAuthenticatedUser was not called with valid OAuth state. This is likely due to a browser reloading the callback URL. Contact GitHub Support if you believe this is an error' + ) return } - this.emitAuthenticate(account, SignInMethod.Web) - this.setState({ kind: SignInStep.Success }) + const { endpoint } = this.state + const token = await requestOAuthToken(endpoint, action.code) + + if (token) { + const account = await fetchUser(endpoint, token) + this.state.oauthState.onAuthCompleted(account) + } else { + this.state.oauthState.onAuthError( + new Error('Failed retrieving authenticated user') + ) + } } /** @@ -492,11 +366,18 @@ export class SignInStore extends TypedBaseStore { * This will put the store in the EndpointEntry step ready to * receive the url to the enterprise instance. */ - public beginEnterpriseSignIn() { + public beginEnterpriseSignIn( + resultCallback?: (result: SignInResult) => void + ) { + if (this.state !== null) { + this.reset() + } + this.setState({ kind: SignInStep.EndpointEntry, error: null, loading: false, + resultCallback: resultCallback ?? noop, }) } @@ -516,13 +397,25 @@ export class SignInStore extends TypedBaseStore { public async setEndpoint(url: string): Promise { const currentState = this.state - if (!currentState || currentState.kind !== SignInStep.EndpointEntry) { + if ( + currentState?.kind !== SignInStep.EndpointEntry && + currentState?.kind !== SignInStep.ExistingAccountWarning + ) { const stepText = currentState ? currentState.kind : 'null' return fatalError( `Sign in step '${stepText}' not compatible with endpoint entry` ) } + /** + * If the user enters a github.com url in the GitHub Enterprise sign-in + * flow we'll redirect them to the GitHub.com sign-in flow. + */ + if (/^(?:https:\/\/)?(?:api\.)?github\.com($|\/)/.test(url)) { + this.beginDotComSignIn(currentState.resultCallback) + return + } + this.setState({ ...currentState, loading: true }) let validUrl: string @@ -536,7 +429,7 @@ export class SignInStore extends TypedBaseStore { ) } else if (e.name === InvalidProtocolErrorName) { error = new Error( - 'Unsupported protocol. Only http or https is supported when authenticating with GitHub Enterprise instances.' + 'Unsupported protocol. Only https is supported when authenticating with GitHub Enterprise instances.' ) } @@ -545,140 +438,26 @@ export class SignInStore extends TypedBaseStore { } const endpoint = getEnterpriseAPIURL(validUrl) - try { - const supportsBasicAuth = await this.endpointSupportsBasicAuth(endpoint) - if (!this.state || this.state.kind !== SignInStep.EndpointEntry) { - // Looks like the sign in flow has been aborted - return - } + const existingAccount = this.accounts.find(x => x.endpoint === endpoint) + if (existingAccount) { this.setState({ - kind: SignInStep.Authentication, + kind: SignInStep.ExistingAccountWarning, endpoint, - supportsBasicAuth, + existingAccount, error: null, loading: false, - forgotPasswordUrl: this.getForgotPasswordURL(endpoint), + resultCallback: currentState.resultCallback, }) - } catch (e) { - let error = e - // We'll get an ENOTFOUND if the address couldn't be resolved. - if (e.code === 'ENOTFOUND') { - error = new Error( - 'The server could not be found. Please verify that the URL is correct and that you have a stable internet connection.' - ) - } - - this.setState({ ...currentState, loading: false, error }) - } - } - - /** - * Attempt to complete the sign in flow with the given OTP token.\ - * This method must only be called when the store is in the - * TwoFactorAuthentication step or an error will be thrown. - * - * If the provided token is valid the store will advance to - * the Success step. - * - * If an error occurs during sign in (such as invalid credentials) - * the authentication state will be updated with that error so that - * the responsible component can present it to the user. - */ - public async setTwoFactorOTP(otp: string) { - const currentState = this.state - - if ( - !currentState || - currentState.kind !== SignInStep.TwoFactorAuthentication - ) { - const stepText = currentState ? currentState.kind : 'null' - fatalError( - `Sign in step '${stepText}' not compatible with two factor authentication` - ) - } - - this.setState({ ...currentState, loading: true }) - - let response: AuthorizationResponse - - try { - response = await createAuthorization( - currentState.endpoint, - currentState.username, - currentState.password, - otp - ) - } catch (e) { - this.emitError(e) - return - } - - if (!this.state || this.state.kind !== SignInStep.TwoFactorAuthentication) { - // Looks like the sign in flow has been aborted - return - } - - if (response.kind === AuthorizationResponseKind.Authorized) { - const token = response.token - const user = await fetchUser(currentState.endpoint, token) - - if ( - !this.state || - this.state.kind !== SignInStep.TwoFactorAuthentication - ) { - // Looks like the sign in flow has been aborted - return - } - - this.emitAuthenticate(user, SignInMethod.Basic) - this.setState({ kind: SignInStep.Success }) } else { - switch (response.kind) { - case AuthorizationResponseKind.Failed: - case AuthorizationResponseKind.TwoFactorAuthenticationRequired: - this.setState({ - ...currentState, - loading: false, - error: new Error('Two-factor authentication failed.'), - }) - break - case AuthorizationResponseKind.Error: - this.emitError( - new Error( - `The server responded with an error (${response.response.status})\n\n${response.response.statusText}` - ) - ) - break - case AuthorizationResponseKind.UserRequiresVerification: - this.emitError( - new Error(getUnverifiedUserErrorMessage(currentState.username)) - ) - break - case AuthorizationResponseKind.PersonalAccessTokenBlocked: - this.emitError( - new Error( - 'A personal access token cannot be used to login to GitHub Desktop.' - ) - ) - break - case AuthorizationResponseKind.EnterpriseTooOld: - this.emitError(new Error(EnterpriseTooOldMessage)) - break - case AuthorizationResponseKind.WebFlowRequired: - this.setState({ - ...currentState, - forgotPasswordUrl: this.getForgotPasswordURL(currentState.endpoint), - loading: false, - supportsBasicAuth: false, - kind: SignInStep.Authentication, - error: null, - }) - break - default: - assertNever(response, `Unknown response: ${response}`) - } + this.setState({ + kind: SignInStep.Authentication, + endpoint, + error: null, + loading: false, + resultCallback: currentState.resultCallback, + }) } } } diff --git a/app/src/lib/suppress-certificate-error.ts b/app/src/lib/suppress-certificate-error.ts new file mode 100644 index 00000000000..3bc795f9b48 --- /dev/null +++ b/app/src/lib/suppress-certificate-error.ts @@ -0,0 +1,13 @@ +const suppressedUrls = new Set() + +export function suppressCertificateErrorFor(url: string) { + suppressedUrls.add(url) +} + +export function clearCertificateErrorSuppressionFor(url: string) { + suppressedUrls.delete(url) +} + +export function isCertificateErrorSuppressedFor(url: string) { + return suppressedUrls.has(url) +} diff --git a/app/src/lib/text-token-parser.ts b/app/src/lib/text-token-parser.ts index 3dc6fce6b13..bb2a802cea5 100644 --- a/app/src/lib/text-token-parser.ts +++ b/app/src/lib/text-token-parser.ts @@ -5,6 +5,7 @@ import { } from '../models/repository' import { GitHubRepository } from '../models/github-repository' import { getHTMLURL } from './api' +import { Emoji } from './emoji' export enum TokenType { /* @@ -28,6 +29,10 @@ export type EmojiMatch = { readonly text: string // The path on disk to the image. readonly path: string + // The unicode character of the emoji, if available + readonly emoji?: string + // The human description of the emoji, if available + readonly description?: string } export type HyperlinkMatch = { @@ -54,14 +59,14 @@ type LookupResult = { * A look-ahead tokenizer designed for scanning commit messages for emoji, issues, mentions and links. */ export class Tokenizer { - private readonly emoji: Map + private readonly allEmoji: Map private readonly repository: GitHubRepository | null = null private _results = new Array() private _currentString = '' - public constructor(emoji: Map, repository?: Repository) { - this.emoji = emoji + public constructor(emoji: Map, repository?: Repository) { + this.allEmoji = emoji if (repository && isRepositoryWithGitHubRepository(repository)) { this.repository = getNonForkGitHubRepository(repository) @@ -115,13 +120,19 @@ export class Tokenizer { return null } - const path = this.emoji.get(maybeEmoji) - if (!path) { + const emoji = this.allEmoji.get(maybeEmoji) + if (!emoji) { return null } this.flush() - this._results.push({ kind: TokenType.Emoji, text: maybeEmoji, path }) + this._results.push({ + kind: TokenType.Emoji, + text: maybeEmoji, + path: emoji.url, + emoji: emoji.emoji, + description: emoji.description, + }) return { nextIndex } } diff --git a/app/src/lib/to_sentence.ts b/app/src/lib/to_sentence.ts new file mode 100644 index 00000000000..6bb2cc44210 --- /dev/null +++ b/app/src/lib/to_sentence.ts @@ -0,0 +1,31 @@ +/** + * Converts the array to a comma-separated sentence where the last element is joined by the connector word + * + * Example output: + * [].to_sentence # => "" + * ['one'].to_sentence # => "one" + * ['one', 'two'].to_sentence # => "one and two" + * ['one', 'two', 'three'].to_sentence # => "one, two, and three" + * + * Based on https://gist.github.com/mudge/1076046 to emulate https://apidock.com/rails/Array/to_sentence + */ +export function toSentence(array: ReadonlyArray): string { + const wordsConnector = ', ', + twoWordsConnector = ' and ', + lastWordConnector = ', and ' + + switch (array.length) { + case 0: + return '' + case 1: + return array.at(0) ?? '' + case 2: + return array.at(0) + twoWordsConnector + array.at(1) + default: + return ( + array.slice(0, -1).join(wordsConnector) + + lastWordConnector + + array.at(-1) + ) + } +} diff --git a/app/src/lib/trampoline/find-account.ts b/app/src/lib/trampoline/find-account.ts new file mode 100644 index 00000000000..151f1637914 --- /dev/null +++ b/app/src/lib/trampoline/find-account.ts @@ -0,0 +1,60 @@ +import memoizeOne from 'memoize-one' +import { getHTMLURL } from '../api' +import { getGenericPassword, getGenericUsername } from '../generic-git-auth' +import { AccountsStore } from '../stores' +import { urlWithoutCredentials } from './url-without-credentials' +import { Account } from '../../models/account' + +/** + * When we're asked for credentials we're typically first asked for the username + * immediately followed by the password. We memoize the getGenericPassword call + * such that we only call it once per endpoint/login pair. Since we include the + * trampoline token in the invalidation key we'll only call it once per + * trampoline session. + */ +const memoizedGetGenericPassword = memoizeOne( + (_trampolineToken: string, endpoint: string, login: string) => + getGenericPassword(endpoint, login) +) + +export async function findGitHubTrampolineAccount( + accountsStore: AccountsStore, + remoteUrl: string +): Promise { + const accounts = await accountsStore.getAll() + const parsedUrl = new URL(remoteUrl) + return accounts.find( + a => new URL(getHTMLURL(a.endpoint)).origin === parsedUrl.origin + ) +} + +export async function findGenericTrampolineAccount( + trampolineToken: string, + remoteUrl: string +) { + const parsedUrl = new URL(remoteUrl) + const endpoint = urlWithoutCredentials(remoteUrl) + + const login = + parsedUrl.username === '' + ? getGenericUsername(endpoint) + : parsedUrl.username + + if (!login) { + return undefined + } + + const token = await memoizedGetGenericPassword( + trampolineToken, + endpoint, + login + ) + + if (!token) { + // We have a username but no password, that warrants a warning + log.warn(`credential: generic password for ${remoteUrl} missing`) + return undefined + } + + return { login, endpoint, token } +} diff --git a/app/src/lib/trampoline/trampoline-askpass-handler.ts b/app/src/lib/trampoline/trampoline-askpass-handler.ts index 68d6cc62a93..d2120c0e276 100644 --- a/app/src/lib/trampoline/trampoline-askpass-handler.ts +++ b/app/src/lib/trampoline/trampoline-askpass-handler.ts @@ -1,19 +1,22 @@ -import { getKeyForEndpoint } from '../auth' import { getSSHKeyPassphrase, - keepSSHKeyPassphraseToStore, + setMostRecentSSHKeyPassphrase, + setSSHKeyPassphrase, } from '../ssh/ssh-key-passphrase' -import { TokenStore } from '../stores' +import { AccountsStore } from '../stores/accounts-store' import { TrampolineCommandHandler } from './trampoline-command' import { trampolineUIHelper } from './trampoline-ui-helper' import { parseAddSSHHostPrompt } from '../ssh/ssh' import { getSSHUserPassword, - keepSSHUserPasswordToStore, + setMostRecentSSHUserPassword, + setSSHUserPassword, } from '../ssh/ssh-user-password' -import { removePendingSSHSecretToStore } from '../ssh/ssh-secret-storage' +import { removeMostRecentSSHCredential } from '../ssh/ssh-credential-storage' +import { getIsBackgroundTaskEnvironment } from './trampoline-environment' async function handleSSHHostAuthenticity( + operationGUID: string, prompt: string ): Promise<'yes' | 'no' | undefined> { const info = parseAddSSHHostPrompt(prompt) @@ -33,6 +36,13 @@ async function handleSSHHostAuthenticity( return 'yes' } + if (getIsBackgroundTaskEnvironment(operationGUID)) { + log.debug( + 'handleSSHHostAuthenticity: background task environment, skipping prompt' + ) + return undefined + } + const addHost = await trampolineUIHelper.promptAddingSSHHost( info.host, info.ip, @@ -66,9 +76,19 @@ async function handleSSHKeyPassphrase( const storedPassphrase = await getSSHKeyPassphrase(keyPath) if (storedPassphrase !== null) { + // Keep this stored passphrase around in case it's not valid and we need to + // delete it if the git operation fails to authenticate. + await setMostRecentSSHKeyPassphrase(operationGUID, keyPath) return storedPassphrase } + if (getIsBackgroundTaskEnvironment(operationGUID)) { + log.debug( + 'handleSSHKeyPassphrase: background task environment, skipping prompt' + ) + return undefined + } + const { secret: passphrase, storeSecret: storePassphrase } = await trampolineUIHelper.promptSSHKeyPassphrase(keyPath) @@ -80,9 +100,9 @@ async function handleSSHKeyPassphrase( // when, in one of those multiple attempts, the user chooses NOT to remember // the passphrase. if (passphrase !== undefined && storePassphrase) { - keepSSHKeyPassphraseToStore(operationGUID, keyPath, passphrase) + setSSHKeyPassphrase(operationGUID, keyPath, passphrase) } else { - removePendingSSHSecretToStore(operationGUID) + removeMostRecentSSHCredential(operationGUID) } return passphrase ?? '' @@ -100,23 +120,35 @@ async function handleSSHUserPassword(operationGUID: string, prompt: string) { const storedPassword = await getSSHUserPassword(username) if (storedPassword !== null) { + // Keep this stored password around in case it's not valid and we need to + // delete it if the git operation fails to authenticate. + setMostRecentSSHUserPassword(operationGUID, username) return storedPassword } + if (getIsBackgroundTaskEnvironment(operationGUID)) { + log.debug( + 'handleSSHUserPassword: background task environment, skipping prompt' + ) + return undefined + } + const { secret: password, storeSecret: storePassword } = await trampolineUIHelper.promptSSHUserPassword(username) if (password !== undefined && storePassword) { - keepSSHUserPasswordToStore(operationGUID, username, password) + setSSHUserPassword(operationGUID, username, password) } else { - removePendingSSHSecretToStore(operationGUID) + removeMostRecentSSHCredential(operationGUID) } return password ?? '' } -export const askpassTrampolineHandler: TrampolineCommandHandler = - async command => { +export const createAskpassTrampolineHandler: ( + accountsStore: AccountsStore +) => TrampolineCommandHandler = + (accountsStore: AccountsStore) => async command => { if (command.parameters.length !== 1) { return undefined } @@ -124,7 +156,7 @@ export const askpassTrampolineHandler: TrampolineCommandHandler = const firstParameter = command.parameters[0] if (firstParameter.startsWith('The authenticity of host ')) { - return handleSSHHostAuthenticity(firstParameter) + return handleSSHHostAuthenticity(command.trampolineToken, firstParameter) } if (firstParameter.startsWith('Enter passphrase for key ')) { @@ -135,23 +167,5 @@ export const askpassTrampolineHandler: TrampolineCommandHandler = return handleSSHUserPassword(command.trampolineToken, firstParameter) } - const username = command.environmentVariables.get('DESKTOP_USERNAME') - if (username === undefined || username.length === 0) { - return undefined - } - - if (firstParameter.startsWith('Username')) { - return username - } else if (firstParameter.startsWith('Password')) { - const endpoint = command.environmentVariables.get('DESKTOP_ENDPOINT') - if (endpoint === undefined || endpoint.length === 0) { - return undefined - } - - const key = getKeyForEndpoint(endpoint) - const token = await TokenStore.getItem(key, username) - return token ?? undefined - } - return undefined } diff --git a/app/src/lib/trampoline/trampoline-command-parser.ts b/app/src/lib/trampoline/trampoline-command-parser.ts index 9dee69d77c7..d760082f69d 100644 --- a/app/src/lib/trampoline/trampoline-command-parser.ts +++ b/app/src/lib/trampoline/trampoline-command-parser.ts @@ -1,4 +1,5 @@ import { parseEnumValue } from '../enum' +import { assertNever } from '../fatal-error' import { sendNonFatalException } from '../helpers/non-fatal-exception' import { ITrampolineCommand, @@ -10,6 +11,7 @@ enum TrampolineCommandParserState { Parameters, EnvironmentVariablesCount, EnvironmentVariables, + Stdin, Finished, } @@ -22,6 +24,7 @@ export class TrampolineCommandParser { private readonly parameters: string[] = [] private environmentVariablesCount: number = 0 private readonly environmentVariables = new Map() + private stdin = '' private state: TrampolineCommandParserState = TrampolineCommandParserState.ParameterCount @@ -63,7 +66,7 @@ export class TrampolineCommandParser { if (this.environmentVariablesCount > 0) { this.state = TrampolineCommandParserState.EnvironmentVariables } else { - this.state = TrampolineCommandParserState.Finished + this.state = TrampolineCommandParserState.Stdin } break @@ -86,12 +89,17 @@ export class TrampolineCommandParser { this.environmentVariables.set(variableKey, variableValue) if (this.environmentVariables.size === this.environmentVariablesCount) { - this.state = TrampolineCommandParserState.Finished + this.state = TrampolineCommandParserState.Stdin } break - + case TrampolineCommandParserState.Stdin: + this.stdin = value + this.state = TrampolineCommandParserState.Finished + break + case TrampolineCommandParserState.Finished: + throw new Error(`Received value when in Finished`) default: - throw new Error(`Received value during invalid state: ${this.state}`) + assertNever(this.state, `Invalid state: ${this.state}`) } } @@ -152,6 +160,7 @@ export class TrampolineCommandParser { trampolineToken, parameters: this.parameters, environmentVariables: this.environmentVariables, + stdin: this.stdin, } } diff --git a/app/src/lib/trampoline/trampoline-command.ts b/app/src/lib/trampoline/trampoline-command.ts index 18834e553c3..c0c2c6bc48a 100644 --- a/app/src/lib/trampoline/trampoline-command.ts +++ b/app/src/lib/trampoline/trampoline-command.ts @@ -1,5 +1,6 @@ export enum TrampolineCommandIdentifier { AskPass = 'ASKPASS', + CredentialHelper = 'CREDENTIALHELPER', } /** Represents a command in our trampoline mechanism. */ @@ -28,6 +29,12 @@ export interface ITrampolineCommand { /** Environment variables that were set when the command was invoked. */ readonly environmentVariables: ReadonlyMap + + /** + * The standard input received by the trampoline (note that when running as + * an askpass handler the trampoline won't read from stdin) + **/ + readonly stdin: string } /** diff --git a/app/src/lib/trampoline/trampoline-credential-helper.ts b/app/src/lib/trampoline/trampoline-credential-helper.ts new file mode 100644 index 00000000000..c8a88b22fc2 --- /dev/null +++ b/app/src/lib/trampoline/trampoline-credential-helper.ts @@ -0,0 +1,265 @@ +import { AccountsStore } from '../stores' +import { TrampolineCommandHandler } from './trampoline-command' +import { forceUnwrap } from '../fatal-error' +import { + approveCredential, + fillCredential, + formatCredential, + parseCredential, + rejectCredential, +} from '../git/credential' +import { + getCredentialUrl, + getIsBackgroundTaskEnvironment, + getTrampolineEnvironmentPath, + setHasRejectedCredentialsForEndpoint, +} from './trampoline-environment' +import { useExternalCredentialHelper } from './use-external-credential-helper' +import { + findGenericTrampolineAccount, + findGitHubTrampolineAccount, +} from './find-account' +import { IGitAccount } from '../../models/git-account' +import { + deleteGenericCredential, + setGenericCredential, +} from '../generic-git-auth' +import { urlWithoutCredentials } from './url-without-credentials' +import { trampolineUIHelper as ui } from './trampoline-ui-helper' +import { isGitHubHost } from '../api' +import { isDotCom, isGHE, isGist } from '../endpoint-capabilities' + +type Credential = Map +type Store = AccountsStore + +const info = (msg: string) => log.info(`credential-helper: ${msg}`) +const debug = (msg: string) => log.debug(`credential-helper: ${msg}`) +const error = (msg: string, e: any) => log.error(`credential-helper: ${msg}`, e) + +/** + * Merges credential info from account into credential + * + * When looking up a first-party account (GitHub.com et al) we can use the + * account's endpoint host in the credential since that's the API url so instead + * we take all the fields from the credential and set the username and password + * from the Account on top of those. + */ +const credWithAccount = (c: Credential, a: IGitAccount | undefined) => + a && new Map(c).set('username', a.login).set('password', a.token) + +async function getGitHubCredential(cred: Credential, store: AccountsStore) { + const endpoint = `${getCredentialUrl(cred)}` + const account = await findGitHubTrampolineAccount(store, endpoint) + if (account) { + info(`found GitHub credential for ${endpoint} in store`) + } + return credWithAccount(cred, account) +} + +async function promptForCredential(cred: Credential, endpoint: string) { + const parsedUrl = new URL(endpoint) + const username = parsedUrl.username === '' ? undefined : parsedUrl.username + const account = await ui.promptForGenericGitAuthentication(endpoint, username) + info(`prompt for ${endpoint}: ${account ? 'completed' : 'cancelled'}`) + return credWithAccount(cred, account) +} + +async function getGenericCredential(cred: Credential, token: string) { + const endpoint = `${getCredentialUrl(cred)}` + const account = await findGenericTrampolineAccount(token, endpoint) + + if (account) { + info(`found generic credential for ${endpoint}`) + return credWithAccount(cred, account) + } + + if (getIsBackgroundTaskEnvironment(token)) { + debug('background task environment, skipping prompt') + return undefined + } else { + return promptForCredential(cred, endpoint) + } +} + +async function getExternalCredential(input: Credential, token: string) { + const path = getTrampolineEnvironmentPath(token) + const cred = await fillCredential(input, path, getGcmEnv(token)) + if (cred) { + info(`found credential for ${getCredentialUrl(cred)} in external helper`) + } + return cred +} + +/** Implementation of the 'get' git credential helper command */ +async function getCredential(cred: Credential, store: Store, token: string) { + const ghCred = await getGitHubCredential(cred, store) + + if (ghCred) { + return ghCred + } + + const endpointKind = await getEndpointKind(cred, store) + const accounts = await store.getAll() + + const hasDotComAccount = accounts.some(a => isDotCom(a.endpoint)) + const hasEnterpriseAccount = accounts.some(a => !isDotCom(a.endpoint)) + + // If it appears as if the endpoint is a GitHub host and we don't have an + // account for it (since we currently only allow one GitHub.com account and + // one Enterprise account) we prompt the user to sign in. + if ( + (endpointKind === 'github.com' && !hasDotComAccount) || + (endpointKind === 'enterprise' && !hasEnterpriseAccount) + ) { + if (getIsBackgroundTaskEnvironment(token)) { + debug('background task environment, skipping prompt') + return undefined + } + + const endpoint = `${getCredentialUrl(cred)}` + const account = await ui.promptForGitHubSignIn(endpoint) + + if (!account) { + setHasRejectedCredentialsForEndpoint(token, endpoint) + } + + return credWithAccount(cred, account) + } + + // GitHub.com/GHE creds are only stored internally + if (endpointKind !== 'generic') { + return undefined + } + + return useExternalCredentialHelper() + ? getExternalCredential(cred, token) + : getGenericCredential(cred, token) +} + +const getEndpointKind = async (cred: Credential, store: Store) => { + const credentialUrl = getCredentialUrl(cred) + const endpoint = `${credentialUrl}` + + if (isGist(endpoint)) { + return 'generic' + } + + if (isDotCom(endpoint)) { + return 'github.com' + } + + if (isGHE(endpoint)) { + return 'ghe.com' + } + + // When Git attempts to authenticate with a host it captures any + // WWW-Authenticate headers and forwards them to the credential helper. We + // use them as a happy-path to determine if the host is a GitHub host without + // having to resort to making a request ourselves. + for (const [k, v] of cred.entries()) { + if (k.startsWith('wwwauth[')) { + if (v.includes('realm="GitHub"')) { + return 'enterprise' + } else if (/realm="(GitLab|Gitea|Atlassian Bitbucket)"/.test(v)) { + return 'generic' + } + } + } + + const existingAccount = await findGitHubTrampolineAccount(store, endpoint) + if (existingAccount) { + return isDotCom(existingAccount.endpoint) ? 'github.com' : 'enterprise' + } + + return (await isGitHubHost(endpoint)) ? 'enterprise' : 'generic' +} + +/** Implementation of the 'store' git credential helper command */ +async function storeCredential(cred: Credential, store: Store, token: string) { + if ((await getEndpointKind(cred, store)) !== 'generic') { + return + } + + return useExternalCredentialHelper() + ? storeExternalCredential(cred, token) + : setGenericCredential( + urlWithoutCredentials(getCredentialUrl(cred)), + forceUnwrap(`credential missing username`, cred.get('username')), + forceUnwrap(`credential missing password`, cred.get('password')) + ) +} + +const storeExternalCredential = (cred: Credential, token: string) => { + const path = getTrampolineEnvironmentPath(token) + return approveCredential(cred, path, getGcmEnv(token)) +} + +/** Implementation of the 'erase' git credential helper command */ +async function eraseCredential(cred: Credential, store: Store, token: string) { + if ((await getEndpointKind(cred, store)) !== 'generic') { + return + } + + return useExternalCredentialHelper() + ? eraseExternalCredential(cred, token) + : deleteGenericCredential( + urlWithoutCredentials(getCredentialUrl(cred)), + forceUnwrap(`credential missing username`, cred.get('username')) + ) +} + +const eraseExternalCredential = (cred: Credential, token: string) => { + const path = getTrampolineEnvironmentPath(token) + return rejectCredential(cred, path, getGcmEnv(token)) +} + +export const createCredentialHelperTrampolineHandler: ( + store: AccountsStore +) => TrampolineCommandHandler = (store: Store) => async command => { + const firstParameter = command.parameters.at(0) + if (!firstParameter) { + return undefined + } + + const { trampolineToken: token } = command + const input = parseCredential(command.stdin) + + if (__DEV__) { + debug( + `${firstParameter}\n${command.stdin + .replaceAll(/^password=.*$/gm, 'password=***') + .replaceAll(/^(.*)$/gm, ' $1') + .trimEnd()}` + ) + } + + try { + if (firstParameter === 'get') { + const cred = await getCredential(input, store, token) + if (!cred) { + const endpoint = `${getCredentialUrl(input)}` + info(`could not find credential for ${endpoint}`) + setHasRejectedCredentialsForEndpoint(token, endpoint) + } + return cred ? formatCredential(cred) : undefined + } else if (firstParameter === 'store') { + await storeCredential(input, store, token) + } else if (firstParameter === 'erase') { + await eraseCredential(input, store, token) + } + return undefined + } catch (e) { + error(`${firstParameter} failed`, e) + return undefined + } +} + +function getGcmEnv(token: string): Record { + const isBackgroundTask = getIsBackgroundTaskEnvironment(token) + return { + ...(process.env.GITHUB_DESKTOP_DISABLE_HARDWARE_ACCELERATION + ? { GCM_GUI_SOFTWARE_RENDERING: '1' } + : {}), + GCM_INTERACTIVE: isBackgroundTask ? '0' : '1', + } +} diff --git a/app/src/lib/trampoline/trampoline-environment.ts b/app/src/lib/trampoline/trampoline-environment.ts index 97106594359..059d5092d28 100644 --- a/app/src/lib/trampoline/trampoline-environment.ts +++ b/app/src/lib/trampoline/trampoline-environment.ts @@ -1,22 +1,69 @@ 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' + deleteMostRecentSSHCredential, + removeMostRecentSSHCredential, +} from '../ssh/ssh-credential-storage' +import { GitError as DugiteError, exec } from 'dugite' import memoizeOne from 'memoize-one' -import { enableCustomGitUserAgent } from '../feature-flag' +import { enableGitConfigParameters } from '../feature-flag' +import { GitError, getDescriptionForError } from '../git/core' +import { getDesktopAskpassTrampolineFilename } from 'desktop-trampoline' + +const hasRejectedCredentialsForEndpoint = new Map>() + +export const setHasRejectedCredentialsForEndpoint = ( + trampolineToken: string, + endpoint: string +) => { + const set = hasRejectedCredentialsForEndpoint.get(trampolineToken) + if (set) { + set.add(endpoint) + } else { + hasRejectedCredentialsForEndpoint.set(trampolineToken, new Set([endpoint])) + } +} + +export const getHasRejectedCredentialsForEndpoint = ( + trampolineToken: string, + endpoint: string +) => { + return ( + hasRejectedCredentialsForEndpoint.get(trampolineToken)?.has(endpoint) ?? + false + ) +} +const isBackgroundTaskEnvironment = new Map() +const trampolineEnvironmentPath = new Map() + +export const getTrampolineEnvironmentPath = (trampolineToken: string) => + trampolineEnvironmentPath.get(trampolineToken) ?? process.cwd() + +export const getIsBackgroundTaskEnvironment = (trampolineToken: string) => + isBackgroundTaskEnvironment.get(trampolineToken) ?? false + +export const getCredentialUrl = (cred: Map) => { + const u = cred.get('url') + if (u) { + return new URL(u) + } + + const protocol = cred.get('protocol') ?? '' + const username = cred.get('username') + const user = username ? `${encodeURIComponent(username)}@` : '' + const host = cred.get('host') ?? '' + const path = cred.get('path') ?? '' + + return new URL(`${protocol}://${user}${host}/${path}`) +} export const GitUserAgent = memoizeOne(() => // Can't use git() as that will call withTrampolineEnv which calls this method - GitProcess.exec(['--version'], process.cwd()) + exec(['--version'], process.cwd()) // https://github.com/git/git/blob/a9e066fa63149291a55f383cfa113d8bdbdaa6b3/help.c#L733-L739 - .then(r => /git version (.*)/.exec(r.stdout)?.at(1)) + .then(r => /git version (.*)/.exec(r.stdout)?.at(1) ?? 'unknown') .catch(e => { log.warn(`Could not get git version information`, e) return 'unknown' @@ -30,6 +77,9 @@ export const GitUserAgent = memoizeOne(() => }) ) +const fatalPromptsDisabledRe = + /^fatal: could not read .*?: terminal prompts disabled\n$/ + /** * 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 @@ -42,15 +92,28 @@ export const GitUserAgent = memoizeOne(() => * variables. */ export async function withTrampolineEnv( - fn: (env: object) => Promise + fn: (env: object) => Promise, + path: string, + isBackgroundTask = false, + customEnv?: Record ): Promise { const sshEnv = await getSSHEnvironment() return withTrampolineToken(async token => { + isBackgroundTaskEnvironment.set(token, isBackgroundTask) + trampolineEnvironmentPath.set(token, path) + + const existingGitEnvConfig = + customEnv?.['GIT_CONFIG_PARAMETERS'] ?? + process.env['GIT_CONFIG_PARAMETERS'] ?? + '' + + const gitEnvConfigPrefix = + existingGitEnvConfig.length > 0 ? `${existingGitEnvConfig} ` : '' + // The code below assumes a few things in order to manage SSH key passphrases // correctly: - // 1. `withTrampolineEnv` is only used in the functions `git` (core.ts) and - // `spawnAndComplete` (spawn.ts) + // 1. `withTrampolineEnv` is only used in the functions `git` (core.ts) // 2. Those two functions always thrown an error when something went wrong, // and just return a result when everything went fine. // @@ -58,33 +121,107 @@ export async function withTrampolineEnv( // `fn` has been invoked, we can store the SSH key passphrase for this git // operation if there was one pending to be stored. try { - const result = await fn({ + return await fn({ DESKTOP_PORT: await trampolineServer.getPort(), DESKTOP_TRAMPOLINE_TOKEN: token, - GIT_ASKPASS: getDesktopTrampolinePath(), - DESKTOP_TRAMPOLINE_IDENTIFIER: TrampolineCommandIdentifier.AskPass, - ...(enableCustomGitUserAgent() - ? { GIT_USER_AGENT: await GitUserAgent() } - : {}), - + GIT_ASKPASS: '', + // This warrants some explanation. We're configuring the + // credential helper using environment variables rather than + // arguments (i.e. -c credential.helper=) because we want commands + // invoked by filters (i.e. Git LFS) to be able to pick up our + // configuration. Arguments passed to git commands are not passed + // down to filters. + // + // We're using the undocumented GIT_CONFIG_PARAMETERS environment + // variable over the documented GIT_CONFIG_{COUNT,KEY,VALUE} due + // to an apparent bug either in a Windows Python runtime + // dependency or in a Python project commonly used to manage hooks + // which isn't able to handle the blank environment variables we + // need when using GIT_CONFIG_*. + // + // See https://github.com/desktop/desktop/issues/18945 + // See https://github.com/git/git/blob/ed155187b429a/config.c#L664 + ...(enableGitConfigParameters() + ? { + GIT_CONFIG_PARAMETERS: `${gitEnvConfigPrefix}'credential.helper=' 'credential.helper=desktop'`, + } + : { + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '', + GIT_CONFIG_KEY_1: 'credential.helper', + GIT_CONFIG_VALUE_1: 'desktop', + }), + GIT_USER_AGENT: await GitUserAgent(), ...sshEnv, }) + } catch (e) { + if (!getIsBackgroundTaskEnvironment(token)) { + // If the operation fails with an SSHAuthenticationFailed error, we + // assume that it's because the last credential we provided via the + // askpass handler was rejected. That's not necessarily the case but for + // practical purposes, it's as good as we can get with the information we + // have. We're limited by the ASKPASS flow here. + if (isSSHAuthFailure(e)) { + deleteMostRecentSSHCredential(token) + } + } + + // Prior to us introducing the credential helper trampoline, our askpass + // trampoline would return an empty string as the username and password if + // we were unable to find an account or acquire credentials from the user. + // Git would take that to mean that the literal username and password were + // an empty string and would attempt to authenticate with those. This + // would fail and Git would then exit with an authentication error which + // would bubble up to the user. Now that we're using the credential helper + // Git knows that we failed to provide credentials and instead of trying + // to authenticate with an empty string it will exit with an error saying + // that it couldn't read the username since terminal prompts were + // disabled. + // + // We catch that specific error here and throw the user-friendly + // authentication failed error that we've always done in the past. + if ( + hasRejectedCredentialsForEndpoint.has(token) && + e instanceof GitError && + fatalPromptsDisabledRe.test(e.message) + ) { + const msg = 'Authentication failed: user cancelled authentication' + const gitErrorDescription = + getDescriptionForError(DugiteError.HTTPSAuthenticationFailed, '') ?? + msg - await storePendingSSHSecret(token) + const fakeAuthError = new GitError( + { ...e.result, gitErrorDescription }, + e.args, + msg + ) - return result + fakeAuthError.cause = e + throw fakeAuthError + } + + throw e } finally { - removePendingSSHSecretToStore(token) + removeMostRecentSSHCredential(token) + isBackgroundTaskEnvironment.delete(token) + hasRejectedCredentialsForEndpoint.delete(token) + trampolineEnvironmentPath.delete(token) } }) } -/** Returns the path of the desktop-trampoline binary. */ -export function getDesktopTrampolinePath(): string { +const isSSHAuthFailure = (e: unknown): e is GitError => + e instanceof GitError && + (e.result.gitError === DugiteError.SSHAuthenticationFailed || + e.result.gitError === DugiteError.SSHPermissionDenied) + +/** Returns the path of the desktop-askpass-trampoline binary. */ +export function getDesktopAskpassTrampolinePath(): string { return Path.resolve( __dirname, 'desktop-trampoline', - getDesktopTrampolineFilename() + getDesktopAskpassTrampolineFilename() ) } diff --git a/app/src/lib/trampoline/trampoline-server.ts b/app/src/lib/trampoline/trampoline-server.ts index cb64fd48b59..a866049b9ec 100644 --- a/app/src/lib/trampoline/trampoline-server.ts +++ b/app/src/lib/trampoline/trampoline-server.ts @@ -1,7 +1,6 @@ import { createServer, AddressInfo, Server, Socket } from 'net' import split2 from 'split2' import { sendNonFatalException } from '../helpers/non-fatal-exception' -import { askpassTrampolineHandler } from './trampoline-askpass-handler' import { ITrampolineCommand, TrampolineCommandHandler, @@ -42,11 +41,6 @@ export class TrampolineServer { // suite runner would never finish, hitting a 45min timeout for the whole // GitHub Action. this.server.unref() - - this.registerCommandHandler( - TrampolineCommandIdentifier.AskPass, - askpassTrampolineHandler - ) } private async listen(): Promise { @@ -158,7 +152,7 @@ export class TrampolineServer { * @param identifier Identifier of the command. * @param handler Handler to register. */ - private registerCommandHandler( + public registerCommandHandler( identifier: TrampolineCommandIdentifier, handler: TrampolineCommandHandler ) { @@ -177,7 +171,9 @@ export class TrampolineServer { return } - const result = await handler(command) + const result = await handler(command).catch(e => + log.error('Error processing trampoline command', e) + ) if (result !== undefined) { socket.end(result) diff --git a/app/src/lib/trampoline/trampoline-ui-helper.ts b/app/src/lib/trampoline/trampoline-ui-helper.ts index 7e6fe66d5e0..51e66310fc8 100644 --- a/app/src/lib/trampoline/trampoline-ui-helper.ts +++ b/app/src/lib/trampoline/trampoline-ui-helper.ts @@ -1,5 +1,8 @@ +import { Account } from '../../models/account' +import { IGitAccount } from '../../models/git-account' import { PopupType } from '../../models/popup' import { Dispatcher } from '../../ui/dispatcher' +import { SignInResult } from '../stores' type PromptSSHSecretResponse = { readonly secret: string | undefined @@ -57,6 +60,48 @@ class TrampolineUIHelper { }) }) } + + public promptForGenericGitAuthentication( + endpoint: string, + username?: string + ): Promise { + return new Promise(resolve => { + this.dispatcher.showPopup({ + type: PopupType.GenericGitAuthentication, + remoteUrl: endpoint, + username, + onSubmit: (login: string, token: string) => + resolve({ login, token, endpoint }), + onDismiss: () => resolve(undefined), + }) + }) + } + + public promptForGitHubSignIn(endpoint: string): Promise { + return new Promise(async resolve => { + const cb = (result: SignInResult) => { + resolve(result.kind === 'success' ? result.account : undefined) + this.dispatcher.closePopup(PopupType.SignIn) + } + + const { hostname, origin } = new URL(endpoint) + if (hostname === 'github.com') { + this.dispatcher.beginDotComSignIn(cb) + } else { + this.dispatcher.beginEnterpriseSignIn(cb) + await this.dispatcher.setSignInEndpoint(origin) + } + + this.dispatcher.showPopup({ + type: PopupType.SignIn, + isCredentialHelperSignIn: true, + credentialHelperUrl: endpoint, + }) + }).catch(e => { + log.error(`Could not prompt for GitHub sign in`, e) + return undefined + }) + } } export const trampolineUIHelper = new TrampolineUIHelper() diff --git a/app/src/lib/trampoline/url-without-credentials.ts b/app/src/lib/trampoline/url-without-credentials.ts new file mode 100644 index 00000000000..cec2631ccf1 --- /dev/null +++ b/app/src/lib/trampoline/url-without-credentials.ts @@ -0,0 +1,6 @@ +export function urlWithoutCredentials(remoteUrl: string | URL): string { + const url = new URL(remoteUrl) + url.username = '' + url.password = '' + return url.toString() +} diff --git a/app/src/lib/trampoline/use-external-credential-helper.ts b/app/src/lib/trampoline/use-external-credential-helper.ts new file mode 100644 index 00000000000..12ff1556810 --- /dev/null +++ b/app/src/lib/trampoline/use-external-credential-helper.ts @@ -0,0 +1,11 @@ +import { getBoolean, setBoolean } from '../local-storage' + +export const useExternalCredentialHelperDefault = false +export const useExternalCredentialHelperKey: string = + 'useExternalCredentialHelper' + +export const useExternalCredentialHelper = () => + getBoolean(useExternalCredentialHelperKey, useExternalCredentialHelperDefault) + +export const setUseExternalCredentialHelper = (value: boolean) => + setBoolean(useExternalCredentialHelperKey, value) diff --git a/app/src/main-process/alive-origin-filter.ts b/app/src/main-process/alive-origin-filter.ts index 5b6800da1cb..64b4d93b9bc 100644 --- a/app/src/main-process/alive-origin-filter.ts +++ b/app/src/main-process/alive-origin-filter.ts @@ -8,19 +8,26 @@ export function installAliveOriginFilter(orderedWebRequest: OrderedWebRequest) { orderedWebRequest.onBeforeSendHeaders.addEventListener(async details => { const { protocol, host } = new URL(details.url) - // If it's a WebSocket Secure request directed to a github.com subdomain, - // probably related to the Alive server, we need to override the `Origin` - // header with a valid value. - if (protocol === 'wss:' && /(^|\.)github\.com$/.test(host)) { - return { - requestHeaders: { - ...details.requestHeaders, - // TODO: discuss with Alive team a good Origin value to use here - Origin: 'https://desktop.github.com', - }, - } + // Here we're only interested in WebSockets + if (protocol !== 'wss:') { + return {} } - return {} + // Alive URLs are supposed to be prefixed by "alive" and then the hostname + if ( + !/^alive\.github\.com$/.test(host) && + !/^alive\.(.*)\.ghe\.com$/.test(host) + ) { + return {} + } + + // We will just replace the `alive` prefix (which indicates the service) + // with `desktop`. + return { + requestHeaders: { + ...details.requestHeaders, + Origin: `https://${host.replace('alive.', 'desktop.')}`, + }, + } }) } diff --git a/app/src/main-process/app-window.ts b/app/src/main-process/app-window.ts index e321e5662ec..c5ee06f0560 100644 --- a/app/src/main-process/app-window.ts +++ b/app/src/main-process/app-window.ts @@ -6,6 +6,7 @@ import { autoUpdater, nativeTheme, } from 'electron' +import { shell } from '../lib/app-shell' import { Emitter, Disposable } from 'event-kit' import { encodePathAsUrl } from '../lib/path' import { @@ -26,6 +27,7 @@ import { terminateDesktopNotifications, } from './notifications' import { addTrustedIPCSender } from './trusted-ipc-sender' +import { getUpdaterGUID } from '../lib/get-updater-guid' export class AppWindow { private window: Electron.BrowserWindow @@ -324,6 +326,32 @@ export class AppWindow { } } + /** Handle when a modal dialog is opened. */ + public dialogDidOpen() { + if (this.window.isFocused()) { + // No additional notifications are needed. + return + } + // Care is taken to mimic OS dialog behaviors. + if (__DARWIN__) { + // macOS beeps when a modal dialog is opened. + shell.beep() + // See https://developer.apple.com/documentation/appkit/nsapplication/1428358-requestuserattention + // "If the inactive app presents a modal panel, this method will be invoked with NSCriticalRequest + // automatically. The modal panel is not brought to the front for an inactive app." + // NOTE: flashFrame() uses the 'informational' level, so we need to explicitly bounce the dock + // with the 'critical' level in order to that described behavior. + app.dock.bounce('critical') + } else { + // See https://learn.microsoft.com/en-us/windows/win32/uxguide/winenv-taskbar#taskbar-button-flashing + // "If an inactive program requires immediate attention, + // flash its taskbar button to draw attention and leave it highlighted." + // It advises not to beep. + this.window.once('focus', () => this.window.flashFrame(false)) + this.window.flashFrame(true) + } + } + /** Send a certificate error to the renderer. */ public sendCertificateError( certificate: Electron.Certificate, @@ -415,9 +443,9 @@ export class AppWindow { }) } - public checkForUpdates(url: string) { + public async checkForUpdates(url: string) { try { - autoUpdater.setFeedURL({ url }) + autoUpdater.setFeedURL({ url: await trySetUpdaterGuid(url) }) autoUpdater.checkForUpdates() } catch (e) { return e @@ -480,3 +508,18 @@ export class AppWindow { return filePaths.length > 0 ? filePaths[0] : null } } + +const trySetUpdaterGuid = async (url: string) => { + try { + const id = await getUpdaterGUID() + if (!id) { + return url + } + + const parsed = new URL(url) + parsed.searchParams.set('guid', id) + return parsed.toString() + } catch (e) { + return url + } +} diff --git a/app/src/main-process/authenticated-image-filter.ts b/app/src/main-process/authenticated-image-filter.ts index 951684e0c51..8e970193719 100644 --- a/app/src/main-process/authenticated-image-filter.ts +++ b/app/src/main-process/authenticated-image-filter.ts @@ -8,7 +8,11 @@ function isEnterpriseAvatarPath(pathname: string) { function isGitHubRepoAssetPath(pathname: string) { // Matches paths like: /repo/owner/assets/userID/guid - return /^\/[^/]+\/[^/]+\/assets\/[^/]+\/[^/]+\/?$/.test(pathname) + return ( + /^\/[^/]+\/[^/]+\/assets\/[^/]+\/[^/]+\/?$/.test(pathname) || + // or: /user-attachments/assets/guid + /^\/user-attachments\/assets\/[^/]+\/?$/.test(pathname) + ) } /** diff --git a/app/src/main-process/main.ts b/app/src/main-process/main.ts index 71e4d9c9529..070663f9961 100644 --- a/app/src/main-process/main.ts +++ b/app/src/main-process/main.ts @@ -16,7 +16,11 @@ import { AppWindow } from './app-window' import { buildDefaultMenu, getAllMenuItems } from './menu' import { shellNeedsPatching, updateEnvironmentForProcess } from '../lib/shell' import { parseAppURL } from '../lib/parse-app-url' -import { handleSquirrelEvent } from './squirrel-updater' +import { + handleSquirrelEvent, + installWindowsCLI, + uninstallWindowsCLI, +} from './squirrel-updater' import { fatalError } from '../lib/fatal-error' import { log as writeLog } from './log' @@ -522,6 +526,11 @@ app.on('ready', () => { mainWindow?.setWindowZoomFactor(zoomFactor) ) + if (__WIN32__) { + ipcMain.on('install-windows-cli', installWindowsCLI) + ipcMain.on('uninstall-windows-cli', uninstallWindowsCLI) + } + /** * An event sent by the renderer asking for a copy of the current * application menu. @@ -608,6 +617,9 @@ app.on('ready', () => { mainWindow?.selectAllWindowContents() ) + /** An event sent by the renderer indicating a modal dialog is opened */ + ipcMain.on('dialog-did-open', () => mainWindow?.dialogDidOpen()) + /** * An event sent by the renderer asking whether the Desktop is in the * applications folder diff --git a/app/src/main-process/menu/build-default-menu.ts b/app/src/main-process/menu/build-default-menu.ts index f35b6805c47..a67781c2921 100644 --- a/app/src/main-process/menu/build-default-menu.ts +++ b/app/src/main-process/menu/build-default-menu.ts @@ -7,8 +7,8 @@ import { UNSAFE_openDirectory } from '../shell' import { MenuLabelsEvent } from '../../models/menu-labels' import * as ipcWebContents from '../ipc-webcontents' import { mkdir } from 'fs/promises' +import { buildTestMenu } from './build-test-menu' -const platformDefaultShell = __WIN32__ ? 'Command Prompt' : 'Terminal' const createPullRequestLabel = __DARWIN__ ? 'Create Pull Request' : 'Create &pull request' @@ -31,6 +31,10 @@ enum ZoomDirection { Out, } +export const separator: Electron.MenuItemConstructorOptions = { + type: 'separator', +} + export function buildDefaultMenu({ selectedExternalEditor, selectedShell, @@ -56,7 +60,6 @@ export function buildDefaultMenu({ : createPullRequestLabel const template = new Array() - const separator: Electron.MenuItemConstructorOptions = { type: 'separator' } if (__DARWIN__) { template.push({ @@ -78,7 +81,7 @@ export function buildDefaultMenu({ { label: 'Install Command Line Toolâ€Ļ', id: 'install-cli', - click: emit('install-cli'), + click: emit('install-darwin-cli'), }, separator, { @@ -122,6 +125,7 @@ export function buildDefaultMenu({ if (!__DARWIN__) { const fileItems = fileMenu.submenu as Electron.MenuItemConstructorOptions[] + const exitAccelerator = __WIN32__ ? 'Alt+F4' : 'CmdOrCtrl+Q' fileItems.push( separator, @@ -135,7 +139,7 @@ export function buildDefaultMenu({ { role: 'quit', label: 'E&xit', - accelerator: 'Alt+F4', + accelerator: exitAccelerator, } ) } @@ -253,8 +257,8 @@ export function buildDefaultMenu({ // chorded shortcuts, but this menu item is not a user-facing feature // so we are going to keep this one around. accelerator: 'CmdOrCtrl+Alt+R', - click(item: any, focusedWindow: Electron.BrowserWindow | undefined) { - if (focusedWindow) { + click(item: any, focusedWindow: Electron.BaseWindow | undefined) { + if (focusedWindow instanceof BrowserWindow) { focusedWindow.reload() } }, @@ -268,8 +272,8 @@ export function buildDefaultMenu({ accelerator: (() => { return __DARWIN__ ? 'Alt+Command+I' : 'Ctrl+Shift+I' })(), - click(item: any, focusedWindow: Electron.BrowserWindow | undefined) { - if (focusedWindow) { + click(item: any, focusedWindow: Electron.BaseWindow | undefined) { + if (focusedWindow instanceof BrowserWindow) { focusedWindow.webContents.toggleDevTools() } }, @@ -321,8 +325,8 @@ export function buildDefaultMenu({ }, { label: __DARWIN__ - ? `Open in ${selectedShell ?? platformDefaultShell}` - : `O&pen in ${selectedShell ?? platformDefaultShell}`, + ? `Open in ${selectedShell ?? 'Shell'}` + : `O&pen in ${selectedShell ?? 'shell'}`, id: 'open-in-shell', accelerator: 'Ctrl+`', click: emit('open-in-shell'), @@ -546,89 +550,7 @@ export function buildDefaultMenu({ showLogsItem, ] - if (__DEV__) { - helpItems.push( - separator, - { - label: 'Crash main processâ€Ļ', - click() { - throw new Error('Boomtown!') - }, - }, - { - label: 'Crash renderer processâ€Ļ', - click: emit('boomtown'), - }, - { - label: 'Show popup', - submenu: [ - { - label: 'Release notes', - click: emit('show-release-notes-popup'), - }, - { - label: 'Thank you', - click: emit('show-thank-you-popup'), - }, - { - label: 'Show App Error', - click: emit('show-app-error'), - }, - ], - }, - { - label: 'Show banner', - submenu: [ - { - label: 'Reorder Successful', - click: emit('show-test-reorder-banner'), - }, - { - label: 'Reorder Undone', - click: emit('show-test-undone-banner'), - }, - { - label: 'Cherry Pick Conflicts', - click: emit('show-test-cherry-pick-conflicts-banner'), - }, - ], - }, - { - 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'), - }, - { - label: 'Show banner', - submenu: [ - { - label: 'Update banner', - click: emit('show-update-banner'), - }, - { - label: `Showcase Update banner`, - click: emit('show-showcase-update-banner'), - }, - { - label: `${__DARWIN__ ? 'Apple silicon' : 'Arm64'} banner`, - click: emit('show-arm64-banner'), - }, - { - label: 'Thank you', - click: emit('show-thank-you-banner'), - }, - ], - } - ) - } + helpItems.push(...buildTestMenu()) if (__DARWIN__) { template.push({ @@ -680,7 +602,7 @@ function getStashedChangesLabel(isStashedChangesVisible: boolean): string { type ClickHandler = ( menuItem: Electron.MenuItem, - browserWindow: Electron.BrowserWindow | undefined, + browserWindow: Electron.BaseWindow | undefined, event: Electron.KeyboardEvent ) => void @@ -688,14 +610,17 @@ type ClickHandler = ( * Utility function returning a Click event handler which, when invoked, emits * the provided menu event over IPC. */ -function emit(name: MenuEvent): ClickHandler { +export function emit(name: MenuEvent): ClickHandler { return (_, focusedWindow) => { // focusedWindow can be null if the menu item was clicked without the window // being in focus. A simple way to reproduce this is to click on a menu item // while in DevTools. Since Desktop only supports one window at a time we // can be fairly certain that the first BrowserWindow we find is the one we // want. - const window = focusedWindow ?? BrowserWindow.getAllWindows()[0] + const window = + focusedWindow instanceof BrowserWindow + ? focusedWindow + : BrowserWindow.getAllWindows()[0] if (window !== undefined) { ipcWebContents.send(window.webContents, 'menu-event', name) } @@ -724,7 +649,7 @@ function findClosestValue(arr: Array, value: number) { */ function zoom(direction: ZoomDirection): ClickHandler { return (menuItem, window) => { - if (!window) { + if (!(window instanceof BrowserWindow)) { return } diff --git a/app/src/main-process/menu/build-spell-check-menu.ts b/app/src/main-process/menu/build-spell-check-menu.ts index d5d5d6130ed..730ece72368 100644 --- a/app/src/main-process/menu/build-spell-check-menu.ts +++ b/app/src/main-process/menu/build-spell-check-menu.ts @@ -13,9 +13,21 @@ export async function buildSpellCheckMenu( dom. */ return new Promise(resolve => { - window.webContents.once('context-menu', (event, params) => + /** This is to make sure the context menu invocation doesn't just hang + * waiting to find out if it needs spell checker menu items if electron + * never emits it's context menu event. This is known to happen with the + * Shift + F10 key on macOS */ + const timer = setTimeout(() => { + resolve(undefined) + log.error( + `Unable to get spell check menu items - no electron context-menu event` + ) + }, 100) + + window.webContents.once('context-menu', (event, params) => { + clearTimeout(timer) resolve(getSpellCheckMenuItems(event, params, window.webContents)) - ) + }) }) } diff --git a/app/src/main-process/menu/build-test-menu.ts b/app/src/main-process/menu/build-test-menu.ts new file mode 100644 index 00000000000..bad757febf4 --- /dev/null +++ b/app/src/main-process/menu/build-test-menu.ts @@ -0,0 +1,194 @@ +import { MenuItemConstructorOptions } from 'electron' +import { enableTestMenuItems } from '../../lib/feature-flag' +import { emit, separator } from './build-default-menu' + +export function buildTestMenu() { + if (!enableTestMenuItems()) { + return [] + } + + const testMenuItems: MenuItemConstructorOptions[] = [] + + if (__WIN32__) { + testMenuItems.push(separator, { + label: 'Command Line Tool', + submenu: [ + { + label: 'Install', + click: emit('install-windows-cli'), + }, + { + label: 'Uninstall', + click: emit('uninstall-windows-cli'), + }, + ], + }) + } + + const errorDialogsSubmenu: MenuItemConstructorOptions[] = [ + { + label: 'Confirm Committing Conflicted Files', + click: emit('test-confirm-committing-conflicted-files'), + }, + { + label: 'Discarded Changes Will Be Unrecoverable', + click: emit('test-discarded-changes-will-be-unrecoverable'), + }, + { + label: 'Do you want to fork this repository?', + click: emit('test-do-you-want-fork-this-repository'), + }, + { + label: 'Newer Commits On Remote', + click: emit('test-newer-commits-on-remote'), + }, + { + label: 'Files Too Large', + click: emit('test-files-too-large'), + }, + { + label: 'Generic Git Authentication', + click: emit('test-generic-git-authentication'), + }, + { + label: 'Invalidated Account Token', + click: emit('test-invalidated-account-token'), + }, + ] + + if (__DARWIN__) { + errorDialogsSubmenu.push({ + label: 'Move to Application Folder', + click: emit('test-move-to-application-folder'), + }) + } + + errorDialogsSubmenu.push( + { + label: 'Push Rejected', + click: emit('test-push-rejected'), + }, + { + label: 'Re-Authorization Required', + click: emit('test-re-authorization-required'), + }, + { + label: 'Unable to Locate Git', + click: emit('test-unable-to-locate-git'), + }, + { + label: 'Unable to Open External Editor', + click: emit('test-no-external-editor'), + }, + { + label: 'Unable to Open Shell', + click: emit('test-unable-to-open-shell'), + }, + { + label: 'Untrusted Server', + click: emit('test-untrusted-server'), + }, + { + label: 'Update Existing Git LFS Filters?', + click: emit('test-update-existing-git-lfs-filters'), + }, + { + label: 'Upstream Already Exists', + click: emit('test-upstream-already-exists'), + } + ) + + testMenuItems.push( + separator, + { + label: 'Crash main processâ€Ļ', + click() { + throw new Error('Boomtown!') + }, + }, + { + label: 'Crash renderer processâ€Ļ', + click: emit('boomtown'), + }, + { + label: 'Prune branches', + click: emit('test-prune-branches'), + }, + { + label: 'Show notification', + click: emit('test-notification'), + }, + { + label: 'Show popup', + submenu: [ + { + label: 'Release notes', + click: emit('test-release-notes-popup'), + }, + { + label: 'Thank you', + click: emit('test-thank-you-popup'), + }, + { + label: 'Show App Error', + click: emit('test-app-error'), + }, + { + label: 'Octicons', + click: emit('test-icons'), + }, + ], + }, + { + label: 'Show banner', + submenu: [ + { + label: 'Update banner', + click: emit('test-update-banner'), + }, + { + label: `Showcase Update banner`, + click: emit('test-showcase-update-banner'), + }, + { + label: `${__DARWIN__ ? 'Apple silicon' : 'Arm64'} banner`, + click: emit('test-arm64-banner'), + }, + { + label: 'Thank you', + click: emit('test-thank-you-banner'), + }, + { + label: 'Reorder Successful', + click: emit('test-reorder-banner'), + }, + { + label: 'Reorder Undone', + click: emit('test-undone-banner'), + }, + { + label: 'Cherry Pick Conflicts', + click: emit('test-cherry-pick-conflicts-banner'), + }, + { + label: 'Merge Successful', + click: emit('test-merge-successful-banner'), + }, + { + label: 'Accessibility', + click: emit('test-accessibility-banner'), + }, + { + label: 'OS Version No Longer Supported', + click: emit('test-os-version-no-longer-supported'), + }, + ], + }, + { + label: 'Show Error Dialogs', + submenu: errorDialogsSubmenu, + } + ) + + return testMenuItems +} diff --git a/app/src/main-process/menu/menu-event.ts b/app/src/main-process/menu/menu-event.ts index b132652c0bd..78dc361f0a2 100644 --- a/app/src/main-process/menu/menu-event.ts +++ b/app/src/main-process/menu/menu-event.ts @@ -30,27 +30,64 @@ export type MenuEvent = | 'clone-repository' | 'show-about' | 'go-to-commit-message' - | 'boomtown' | 'open-pull-request' - | 'install-cli' + | 'install-darwin-cli' + | 'install-windows-cli' + | 'uninstall-windows-cli' | 'open-external-editor' | 'select-all' - | 'show-release-notes-popup' | 'show-stashed-changes' | 'hide-stashed-changes' - | 'test-show-notification' - | 'test-prune-branches' | 'find-text' | 'create-issue-in-repository-on-github' | 'preview-pull-request' - | 'show-app-error' + | 'test-app-error' | 'decrease-active-resizable-width' | 'increase-active-resizable-width' - | 'show-thank-you-popup' - | 'show-update-banner' - | 'show-thank-you-banner' - | 'show-arm64-banner' - | 'show-showcase-update-banner' - | 'show-test-reorder-banner' - | 'show-test-undone-banner' - | 'show-test-cherry-pick-conflicts-banner' + | TestMenuEvent + +/** + * This is an alphabetized list of menu event's that are only used for testing + * UI. + */ +const TestMenuEvents = [ + 'boomtown', + 'test-accessibility-banner', + 'test-app-error', + 'test-arm64-banner', + 'test-confirm-committing-conflicted-files', + 'test-cherry-pick-conflicts-banner', + 'test-discarded-changes-will-be-unrecoverable', + 'test-do-you-want-fork-this-repository', + 'test-files-too-large', + 'test-generic-git-authentication', + 'test-icons', + 'test-invalidated-account-token', + 'test-merge-successful-banner', + 'test-move-to-application-folder', + 'test-newer-commits-on-remote', + 'test-no-external-editor', + 'test-notification', + 'test-os-version-no-longer-supported', + 'test-prune-branches', + 'test-push-rejected', + 'test-re-authorization-required', + 'test-release-notes-popup', + 'test-reorder-banner', + 'test-showcase-update-banner', + 'test-thank-you-banner', + 'test-thank-you-popup', + 'test-unable-to-locate-git', + 'test-unable-to-open-shell', + 'test-undone-banner', + 'test-untrusted-server', + 'test-update-banner', + 'test-update-existing-git-lfs-filters', + 'test-upstream-already-exists', +] as const + +export type TestMenuEvent = typeof TestMenuEvents[number] + +export function isTestMenuEvent(value: any): value is TestMenuEvent { + return TestMenuEvents.includes(value) +} diff --git a/app/src/main-process/squirrel-updater.ts b/app/src/main-process/squirrel-updater.ts index eb0ef380a42..d319880b0a8 100644 --- a/app/src/main-process/squirrel-updater.ts +++ b/app/src/main-process/squirrel-updater.ts @@ -38,15 +38,15 @@ export function handleSquirrelEvent(eventName: string): Promise | null { async function handleInstalled(): Promise { await createShortcut(['StartMenu', 'Desktop']) - await installCLI() + await installWindowsCLI() } async function handleUpdated(): Promise { await updateShortcut() - await installCLI() + await installWindowsCLI() } -async function installCLI(): Promise { +export async function installWindowsCLI(): Promise { const binPath = getBinPath() await mkdir(binPath, { recursive: true }) await writeBatchScriptCLITrampoline(binPath) @@ -61,6 +61,17 @@ async function installCLI(): Promise { } } +export async function uninstallWindowsCLI() { + try { + const paths = getPathSegments() + const binPath = getBinPath() + const pathsWithoutBinPath = paths.filter(p => p !== binPath) + return setPathSegments(pathsWithoutBinPath) + } catch (e) { + log.error('Failed removing bin path from PATH environment variable', e) + } +} + /** * Get the path for the `bin` directory which exists in our `AppData` but * outside path which includes the installed app version. @@ -135,15 +146,7 @@ function createShortcut(locations: ShortcutLocations): Promise { async function handleUninstall(): Promise { await removeShortcut() - - try { - const paths = getPathSegments() - const binPath = getBinPath() - const pathsWithoutBinPath = paths.filter(p => p !== binPath) - return setPathSegments(pathsWithoutBinPath) - } catch (e) { - log.error('Failed removing bin path from PATH environment variable', e) - } + return uninstallWindowsCLI() } function removeShortcut(): Promise { diff --git a/app/src/models/banner.ts b/app/src/models/banner.ts index 8c4488a1716..0d24b65ac06 100644 --- a/app/src/models/banner.ts +++ b/app/src/models/banner.ts @@ -1,3 +1,4 @@ +import { Emoji } from '../lib/emoji' import { Popup } from './popup' export enum BannerType { @@ -15,7 +16,8 @@ export enum BannerType { SuccessfulSquash = 'SuccessfulSquash', SuccessfulReorder = 'SuccessfulReorder', ConflictsFound = 'ConflictsFound', - WindowsVersionNoLongerSupported = 'WindowsVersionNoLongerSupported', + OSVersionNoLongerSupported = 'OSVersionNoLongerSupported', + AccessibilitySettingsBanner = 'AccessibilitySettingsBanner', } export type Banner = @@ -79,7 +81,7 @@ export type Banner = } | { readonly type: BannerType.OpenThankYouCard - readonly emoji: Map + readonly emoji: Map readonly onOpenCard: () => void readonly onThrowCardAway: () => void } @@ -120,4 +122,8 @@ export type Banner = /** callback to run when user clicks on link in banner text */ readonly onOpenConflictsDialog: () => void } - | { readonly type: BannerType.WindowsVersionNoLongerSupported } + | { readonly type: BannerType.OSVersionNoLongerSupported } + | { + readonly type: BannerType.AccessibilitySettingsBanner + readonly onOpenAccessibilitySettings: () => void + } diff --git a/app/src/models/clone-options.ts b/app/src/models/clone-options.ts index b50cccf4e8c..2138191b35b 100644 --- a/app/src/models/clone-options.ts +++ b/app/src/models/clone-options.ts @@ -1,9 +1,5 @@ -import { IGitAccount } from './git-account' - /** Additional arguments to provide when cloning a repository */ export type CloneOptions = { - /** The optional identity to provide when cloning. */ - readonly account: IGitAccount | null /** The branch to checkout after the clone has completed. */ readonly branch?: string /** The default branch name in case we're cloning an empty repository. */ diff --git a/app/src/models/diff/diff-selection.ts b/app/src/models/diff/diff-selection.ts index 3adbf4c4b24..c7e45bfb378 100644 --- a/app/src/models/diff/diff-selection.ts +++ b/app/src/models/diff/diff-selection.ts @@ -135,6 +135,54 @@ export class DiffSelection { } } + /** + * Returns a value indicating whether the range is all selected, partially + * selected, or not selected. + * + * @param from The line index (inclusive) from where to checking the range. + * + * @param length The number of lines to check from the start point of + * 'from', Assumes positive number, returns None if length is <= 0. + */ + public isRangeSelected(from: number, length: number): DiffSelectionType { + if (length <= 0) { + // This shouldn't happen? But if it does we'll log it and return None. + return DiffSelectionType.None + } + + const computedSelectionType = this.getSelectionType() + if (computedSelectionType !== DiffSelectionType.Partial) { + // Nothing for us to do here. If all lines are selected or none, then any + // range of lines will be the same. + return computedSelectionType + } + + if (length === 1) { + return this.isSelected(from) + ? DiffSelectionType.All + : DiffSelectionType.None + } + + const to = from + length + let foundSelected = false + let foundDeselected = false + for (let i = from; i < to; i++) { + if (this.isSelected(i)) { + foundSelected = true + } + + if (!this.isSelected(i)) { + foundDeselected = true + } + + if (foundSelected && foundDeselected) { + return DiffSelectionType.Partial + } + } + + return foundSelected ? DiffSelectionType.All : DiffSelectionType.None + } + /** * Returns a value indicating wether the given line number is selectable. * A line not being selectable usually means it's a hunk header or a context diff --git a/app/src/models/diff/image.ts b/app/src/models/diff/image.ts index 9eb4702e375..f34992baef4 100644 --- a/app/src/models/diff/image.ts +++ b/app/src/models/diff/image.ts @@ -8,6 +8,7 @@ export class Image { * @param bytes Size of the file in bytes. */ public constructor( + public readonly rawContents: ArrayBufferLike, public readonly contents: string, public readonly mediaType: string, public readonly bytes: number diff --git a/app/src/models/git-account.ts b/app/src/models/git-account.ts index 0f5e04918c7..6d9bdcca8a8 100644 --- a/app/src/models/git-account.ts +++ b/app/src/models/git-account.ts @@ -7,4 +7,7 @@ export interface IGitAccount { /** The endpoint with which the user is authenticating. */ readonly endpoint: string + + /** The token/password to authenticate with */ + readonly token: string } diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index 7726f0c317f..9191b3a94e8 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -94,7 +94,7 @@ export enum PopupType { TestNotifications = 'TestNotifications', PullRequestComment = 'PullRequestComment', UnknownAuthors = 'UnknownAuthors', - ConfirmRepoRulesBypass = 'ConfirmRepoRulesBypass', + TestIcons = 'TestIcons', } interface IBasePopup { @@ -149,7 +149,11 @@ export type PopupDetail = initialName?: string targetCommit?: CommitOneLine } - | { type: PopupType.SignIn } + | { + type: PopupType.SignIn + isCredentialHelperSignIn?: boolean + credentialHelperUrl?: string + } | { type: PopupType.About } | { type: PopupType.InstallGit; path: string } | { type: PopupType.PublishRepository; repository: Repository } @@ -170,8 +174,10 @@ export type PopupDetail = | { type: PopupType.CLIInstalled } | { type: PopupType.GenericGitAuthentication - hostname: string - retryAction: RetryAction + remoteUrl: string + username?: string + onSubmit: (username: string, password: string) => void + onDismiss: () => void } | { type: PopupType.ExternalEditorFailed @@ -351,8 +357,6 @@ export type PopupDetail = repository: RepositoryWithGitHubRepository pullRequest: PullRequest shouldChangeRepository: boolean - commitMessage: string - commitSha: string checks: ReadonlyArray } | { @@ -417,10 +421,7 @@ export type PopupDetail = onCommit: () => void } | { - type: PopupType.ConfirmRepoRulesBypass - repository: GitHubRepository - branch: string - onConfirm: () => void + type: PopupType.TestIcons } export type Popup = IBasePopup & PopupDetail diff --git a/app/src/models/preferences.ts b/app/src/models/preferences.ts index 8e4cabd560f..26e379aaa2b 100644 --- a/app/src/models/preferences.ts +++ b/app/src/models/preferences.ts @@ -6,4 +6,5 @@ export enum PreferencesTab { Notifications, Prompts, Advanced, + Accessibility, } diff --git a/app/src/models/tutorial-step.ts b/app/src/models/tutorial-step.ts index 6a7c47292cb..ae3e962da75 100644 --- a/app/src/models/tutorial-step.ts +++ b/app/src/models/tutorial-step.ts @@ -8,6 +8,7 @@ export enum TutorialStep { OpenPullRequest = 'OpenPullRequest', AllDone = 'AllDone', Paused = 'Paused', + Announced = 'Announced', } export type ValidTutorialStep = @@ -18,6 +19,7 @@ export type ValidTutorialStep = | TutorialStep.PushBranch | TutorialStep.OpenPullRequest | TutorialStep.AllDone + | TutorialStep.Announced export function isValidTutorialStep( step: TutorialStep @@ -33,4 +35,5 @@ export const orderedTutorialSteps: ReadonlyArray = [ TutorialStep.PushBranch, TutorialStep.OpenPullRequest, TutorialStep.AllDone, + TutorialStep.Announced, ] diff --git a/app/src/ui/about/about.tsx b/app/src/ui/about/about.tsx index 4c3d6df132a..f4e458ec366 100644 --- a/app/src/ui/about/about.tsx +++ b/app/src/ui/about/about.tsx @@ -16,8 +16,7 @@ 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' +import { isOSNoLongerSupportedByElectron } from '../../lib/get-os' const logoPath = __DARWIN__ ? 'static/logo-64x64@2x.png' @@ -27,7 +26,7 @@ const DesktopLogo = encodePathAsUrl(__dirname, logoPath) interface IAboutProps { /** * Event triggered when the dialog is dismissed by the user in the - * ways described in the Dialog component's dismissable prop. + * ways described in the Dialog component's dismissible prop. */ readonly onDismissed: () => void @@ -46,9 +45,6 @@ interface IAboutProps { */ readonly applicationArchitecture: string - /** A function to call to kick off an update check. */ - readonly onCheckForUpdates: () => void - /** A function to call to kick off a non-staggered update check. */ readonly onCheckForNonStaggeredUpdates: () => void @@ -56,14 +52,10 @@ interface IAboutProps { /** 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 } /** @@ -72,23 +64,12 @@ interface IAboutState { */ 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, } } @@ -101,11 +82,6 @@ export class About extends React.Component { this.onUpdateStateChanged ) this.setState({ updateState: updateStore.state }) - this.checkIsTopMostDialog(this.props.isTopMost) - } - - public componentDidUpdate(): void { - this.checkIsTopMostDialog(this.props.isTopMost) } public componentWillUnmount() { @@ -113,19 +89,6 @@ export class About extends React.Component { 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 = () => { @@ -156,23 +119,16 @@ export class About extends React.Component { ![ 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' + ].includes(updateStatus) || isOSNoLongerSupportedByElectron() - 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." - : '' + const buttonTitle = 'Check for Updates' return ( - @@ -271,7 +227,7 @@ export class About extends React.Component { return null } - if (isWindowsAndNoLongerSupportedByElectron()) { + if (isOSNoLongerSupportedByElectron()) { return ( This operating system is no longer supported. Software updates have @@ -322,10 +278,12 @@ export class About extends React.Component { ) const versionText = __DEV__ ? `Build ${version}` : `Version ${version}` + const titleId = 'Dialog_about' return ( @@ -339,7 +297,7 @@ export class About extends React.Component { height="64" /> -

{name}

+

About {name}

{versionText} ({this.props.applicationArchitecture}) diff --git a/app/src/ui/accessibility/aria-live-container.tsx b/app/src/ui/accessibility/aria-live-container.tsx index 0f8d3e9cd98..af752f9ff88 100644 --- a/app/src/ui/accessibility/aria-live-container.tsx +++ b/app/src/ui/accessibility/aria-live-container.tsx @@ -82,12 +82,7 @@ export class AriaLiveContainer extends Component< // 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} - - ) + return <>{this.props.message + this.suffix} } private renderMessage() { diff --git a/app/src/ui/add-repository/add-existing-repository.tsx b/app/src/ui/add-repository/add-existing-repository.tsx index 00ef8968bdc..6eeed3ee481 100644 --- a/app/src/ui/add-repository/add-existing-repository.tsx +++ b/app/src/ui/add-repository/add-existing-repository.tsx @@ -30,17 +30,6 @@ interface IAddExistingRepositoryProps { interface IAddExistingRepositoryState { readonly path: string - /** - * Indicates whether or not the path provided in the path state field exists and - * is a valid Git repository. This value is immediately switched - * to false when the path changes and updated (if necessary) by the - * function, checkIfPathIsRepository. - * - * If set to false the user will be prevented from submitting this dialog - * and given the option to create a new repository instead. - */ - readonly isRepository: boolean - /** * Indicates whether or not to render a warning message about the entered path * not containing a valid Git repository. This value differs from `isGitRepository` in that it holds @@ -61,6 +50,8 @@ export class AddExistingRepository extends React.Component< IAddExistingRepositoryProps, IAddExistingRepositoryState > { + private pathTextBoxRef = React.createRef() + public constructor(props: IAddExistingRepositoryProps) { super(props) @@ -68,7 +59,6 @@ export class AddExistingRepository extends React.Component< this.state = { path, - isRepository: false, showNonGitRepositoryWarning: false, isRepositoryBare: false, isRepositoryUnsafe: false, @@ -76,14 +66,6 @@ export class AddExistingRepository extends React.Component< } } - public async componentDidMount() { - const { path } = this.state - - if (path.length !== 0) { - await this.validatePath(path) - } - } - private onTrustDirectory = async () => { this.setState({ isTrustingRepository: true }) const { repositoryUnsafePath, path } = this.state @@ -95,18 +77,16 @@ export class AddExistingRepository extends React.Component< } private async updatePath(path: string) { - this.setState({ path, isRepository: false }) - await this.validatePath(path) + this.setState({ path }) } - private async validatePath(path: string) { + private async validatePath(path: string): Promise { if (path.length === 0) { this.setState({ - isRepository: false, isRepositoryBare: false, showNonGitRepositoryWarning: false, }) - return + return false } const type = await getRepositoryType(path) @@ -120,7 +100,6 @@ export class AddExistingRepository extends React.Component< this.setState(state => path === state.path ? { - isRepository, isRepositoryBare, isRepositoryUnsafe, showNonGitRepositoryWarning, @@ -128,6 +107,8 @@ export class AddExistingRepository extends React.Component< } : null ) + + return path.length > 0 && isRepository && !isRepositoryBare } private buildBareRepositoryError() { @@ -199,13 +180,14 @@ export class AddExistingRepository extends React.Component< const displayedMessage = ( <> - This directory does not appear to be a Git repository. -
- Would you like to{' '} - - create a repository - {' '} - here instead? +

This directory does not appear to be a Git repository.

+

+ Would you like to{' '} + + create a repository + {' '} + here instead? +

) @@ -229,7 +211,6 @@ export class AddExistingRepository extends React.Component< {msg.displayedMessage} @@ -239,11 +220,6 @@ export class AddExistingRepository extends React.Component< } public render() { - const disabled = - this.state.path.length === 0 || - !this.state.isRepository || - this.state.isRepositoryBare - return ( @@ -299,10 +275,18 @@ export class AddExistingRepository extends React.Component< } private addRepository = async () => { + const { path } = this.state + const isValidPath = await this.validatePath(path) + + if (!isValidPath) { + this.pathTextBoxRef.current?.focus() + return + } + this.props.onDismissed() const { dispatcher } = this.props - const resolvedPath = this.resolvedPath(this.state.path) + const resolvedPath = this.resolvedPath(path) const repositories = await dispatcher.addRepositories([resolvedPath]) if (repositories.length > 0) { diff --git a/app/src/ui/add-repository/create-repository.tsx b/app/src/ui/add-repository/create-repository.tsx index e492aab3321..1544e20e52c 100644 --- a/app/src/ui/add-repository/create-repository.tsx +++ b/app/src/ui/add-repository/create-repository.tsx @@ -23,8 +23,6 @@ import { ILicense, getLicenses, writeLicense } from './licenses' import { writeGitAttributes } from './git-attributes' import { getDefaultDir, setDefaultDir } from '../lib/default-dir' import { Dialog, DialogContent, DialogFooter, DialogError } from '../dialog' -import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' import { LinkButton } from '../lib/link-button' import { PopupType } from '../../models/popup' import { Ref } from '../lib/ref' @@ -40,6 +38,9 @@ import { isTopMostDialog } from '../dialog/is-top-most' import { InputError } from '../lib/input-description/input-error' import { InputWarning } from '../lib/input-description/input-warning' +/** URL used to provide information about submodules to the user. */ +const submoduleDocsUrl = 'https://gh.io/git-submodules' + /** The sentinel value used to indicate no gitignore should be used. */ const NoGitIgnoreValue = 'None' @@ -51,23 +52,6 @@ const NoLicenseValue: ILicense = { hidden: false, } -/** Is the path a git repository? */ -export const isGitRepository = async (path: string) => { - const type = await getRepositoryType(path).catch(e => { - log.error(`Unable to determine repository type`, e) - return { kind: 'missing' } as RepositoryType - }) - - if (type.kind === 'unsafe') { - // If the path is considered unsafe by Git we won't be able to - // verify that it's a repository (or worktree). So we'll fall back to this - // naive approximation. - return directoryExists(join(path, '.git')) - } - - return type.kind !== 'missing' -} - interface ICreateRepositoryProps { readonly dispatcher: Dispatcher readonly onDismissed: () => void @@ -90,6 +74,9 @@ interface ICreateRepositoryState { /** Is the given path already a repository? */ readonly isRepository: boolean + /** Is the given path already a subfolder of a repository? */ + readonly isSubFolderOfRepository: boolean + /** Should the repository be created with a default README? */ readonly createWithReadme: boolean @@ -153,6 +140,7 @@ export class CreateRepository extends React.Component< isValidPath: null, isRepository: false, readMeExists: false, + isSubFolderOfRepository: false, } if (path === null) { @@ -209,12 +197,35 @@ export class CreateRepository extends React.Component< private async updateIsRepository(path: string, name: string) { const fullPath = Path.join(path, sanitizedRepositoryName(name)) - const isRepository = await isGitRepository(fullPath) + + const type = await getRepositoryType(fullPath).catch(e => { + log.error(`Unable to determine repository type`, e) + return { kind: 'missing' } as RepositoryType + }) + + let isRepository: boolean = type.kind !== 'missing' + let isSubFolderOfRepository = false + if (type.kind === 'unsafe') { + // If the path is considered unsafe by Git we won't be able to + // verify that it's a repository (or worktree). So we'll fall back to this + // naive approximation. + isRepository = await directoryExists(join(path, '.git')) + } + + if (type.kind === 'regular') { + // If the path is a regular repository, we'll check if the top level. If it + // isn't than, the path is a subfolder of the repository and a user may want + // to make it into a repository. + isRepository = type.topLevelWorkingDirectory === fullPath + isSubFolderOfRepository = !isRepository + } // Only update isRepository if the path is still the same one we were using // to check whether it looked like a repository. this.setState(state => - state.path === path && state.name === name ? { isRepository } : null + state.path === path && state.name === name + ? { isRepository, isSubFolderOfRepository } + : null ) } @@ -442,10 +453,16 @@ export class CreateRepository extends React.Component< } return ( - - - Will be created as {sanitizedName} - + +

Will be created as {sanitizedName}

+ + Spaces and invalid characters have been replaced by hyphens. + +
) } @@ -528,22 +545,23 @@ export class CreateRepository extends React.Component< } private renderGitRepositoryError() { - const isRepo = this.state.isRepository + const { isRepository, path, name } = this.state - if (!this.state.path || this.state.path.length === 0 || !isRepo) { + if (!path || path.length === 0 || !isRepository) { return null } + const fullPath = Path.join(path, sanitizedRepositoryName(name)) + return ( - This directory appears to be a Git repository. Would you like to{' '} + The directory {fullPath}appears to be a Git repository. + Would you like to{' '} add this repository {' '} @@ -553,6 +571,32 @@ export class CreateRepository extends React.Component< ) } + private renderGitRepositorySubFolderMessage() { + const { isSubFolderOfRepository, path, name } = this.state + + if (!path || path.length === 0 || !isSubFolderOfRepository) { + return null + } + + const fullPath = Path.join(path, sanitizedRepositoryName(name)) + + return ( + + + The directory {fullPath}appears to be a subfolder of Git + repository. + + Learn about submodules. + + + + ) + } + private renderReadmeOverwriteWarning() { if (!enableReadmeOverwriteWarning()) { return null @@ -580,6 +624,22 @@ export class CreateRepository extends React.Component< ) } + private renderPathMessage = () => { + const { path, name, isRepository } = this.state + + if (path === null || path === '' || name === '' || isRepository) { + return null + } + + const fullPath = Path.join(path, sanitizedRepositoryName(name)) + + return ( +
+ The repository will be created at {fullPath}. +
+ ) + } + private onAddRepositoryClicked = () => { this.props.onDismissed() @@ -624,7 +684,7 @@ export class CreateRepository extends React.Component< label="Name" placeholder="repository name" onValueChanged={this.onNameChanged} - ariaDescribedBy="existing-repository-path-error" + ariaDescribedBy="existing-repository-path-error repo-sanitized-name-warning" />
@@ -645,7 +705,7 @@ export class CreateRepository extends React.Component< placeholder="repository path" onValueChanged={this.onPathChanged} disabled={readOnlyPath || loadingDefaultDir} - ariaDescribedBy="existing-repository-path-error" + ariaDescribedBy="existing-repository-path-error path-is-subfolder-of-repository" />
diff --git a/app/src/ui/app-error.tsx b/app/src/ui/app-error.tsx index b9cac22a3f9..7d5e291f181 100644 --- a/app/src/ui/app-error.tsx +++ b/app/src/ui/app-error.tsx @@ -13,8 +13,6 @@ 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 */ @@ -44,7 +42,6 @@ interface IAppErrorState { */ export class AppError extends React.Component { private dialogContent: HTMLDivElement | null = null - private formatGitErrorMessage = memoizeOne(parseCarriageReturn) public constructor(props: IAppErrorProps) { super(props) @@ -94,8 +91,7 @@ export class AppError extends React.Component { // If the error message is just the raw git output, display it in // fixed-width font if (isRawGitError(e)) { - const formattedMessage = this.formatGitErrorMessage(e.message) - return

{formattedMessage}

+ return

{e.message}

} return

{e.message}

@@ -220,7 +216,7 @@ export class AppError extends React.Component { type="error" key="error" title={this.getTitle(error)} - dismissable={false} + backdropDismissable={false} onSubmit={this.props.onDismissed} onDismissed={this.props.onDismissed} disabled={this.state.disabled} diff --git a/app/src/ui/app-menu/app-menu-bar-button.tsx b/app/src/ui/app-menu/app-menu-bar-button.tsx index ad8f68581ca..91950bba86f 100644 --- a/app/src/ui/app-menu/app-menu-bar-button.tsx +++ b/app/src/ui/app-menu/app-menu-bar-button.tsx @@ -27,15 +27,6 @@ interface IAppMenuBarButtonProps { */ readonly enableAccessKeyNavigation: boolean - /** - * Whether the menu was opened by pressing Alt (or Alt+X where X is an - * access key for one of the top level menu items). This is used as a - * one-time signal to the AppMenu to use some special semantics for - * selection and focus. Specifically it will ensure that the last opened - * menu will receive focus. - */ - readonly openedWithAccessKey: boolean - /** * Whether or not to highlight the access key of a top-level menu * items (if they have one). This is normally true when the Alt-key @@ -275,10 +266,8 @@ export class AppMenuBarButton extends React.Component< ) diff --git a/app/src/ui/app-menu/app-menu-bar.tsx b/app/src/ui/app-menu/app-menu-bar.tsx index 2083d2c1bd0..0e4940f66a9 100644 --- a/app/src/ui/app-menu/app-menu-bar.tsx +++ b/app/src/ui/app-menu/app-menu-bar.tsx @@ -449,10 +449,6 @@ export class AppMenuBar extends React.Component< ? this.props.appMenu.slice(1) : [] - const openedWithAccessKey = foldoutState - ? foldoutState.openedWithAccessKey || false - : false - const enableAccessKeyNavigation = foldoutState ? foldoutState.enableAccessKeyNavigation : false @@ -472,7 +468,6 @@ export class AppMenuBar extends React.Component< menuState={menuState} highlightMenuAccessKey={highlightMenuAccessKey} enableAccessKeyNavigation={enableAccessKeyNavigation} - openedWithAccessKey={openedWithAccessKey} onClose={this.onMenuClose} onOpen={this.onMenuOpen} onMouseEnter={this.onMenuButtonMouseEnter} diff --git a/app/src/ui/app-menu/app-menu.tsx b/app/src/ui/app-menu/app-menu.tsx index 7ca0ccb1a07..21f1174c2a2 100644 --- a/app/src/ui/app-menu/app-menu.tsx +++ b/app/src/ui/app-menu/app-menu.tsx @@ -31,25 +31,6 @@ interface IAppMenuProps { */ readonly enableAccessKeyNavigation: boolean - /** - * Whether the menu was opened by pressing Alt (or Alt+X where X is an - * access key for one of the top level menu items). This is used as a - * one-time signal to the AppMenu to use some special semantics for - * selection and focus. Specifically it will ensure that the last opened - * menu will receive focus. - */ - readonly openedWithAccessKey: boolean - - /** - * If true the MenuPane only takes up as much vertical space needed to - * show all menu items. This does not affect maximum height, i.e. if the - * visible menu items takes up more space than what is available the menu - * will still overflow and be scrollable. - * - * @default false - */ - readonly autoHeight?: boolean - /** The id of the element that serves as the menu's accessibility label */ readonly ariaLabelledby: string } diff --git a/app/src/ui/app-menu/menu-list-item.tsx b/app/src/ui/app-menu/menu-list-item.tsx index b83580f8886..b18661d281e 100644 --- a/app/src/ui/app-menu/menu-list-item.tsx +++ b/app/src/ui/app-menu/menu-list-item.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import classNames from 'classnames' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' import { MenuItem } from '../../models/app-menu' import { AccessText } from '../lib/access-text' import { getPlatformSpecificNameOrSymbolForModifier } from '../../lib/menu-item' @@ -95,9 +95,9 @@ export class MenuListItem extends React.Component { private getIcon(item: MenuItem): JSX.Element | null { if (item.type === 'checkbox' && item.checked) { - return + return } else if (item.type === 'radio' && item.checked) { - return + return } return null @@ -153,10 +153,7 @@ export class MenuListItem extends React.Component { const arrow = item.type === 'submenuItem' && this.props.renderSubMenuArrow !== false ? ( - + ) : null const accelerator = diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 5139f443cbd..a636924ed98 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -20,8 +20,13 @@ 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 { + getOS, + isOSNoLongerSupportedByElectron, + isMacOSAndNoLongerSupportedByElectron, + isWindowsAndNoLongerSupportedByElectron, +} from '../lib/get-os' +import { MenuEvent, isTestMenuEvent } from '../main-process/menu' import { Repository, getGitHubHtmlUrl, @@ -51,13 +56,15 @@ import { BranchDropdown, RevertProgress, } from './toolbar' -import { iconForRepository, OcticonSymbolType } from './octicons' -import * as OcticonSymbol from './octicons/octicons.generated' +import { iconForRepository, OcticonSymbol } from './octicons' +import * as octicons from './octicons/octicons.generated' import { showCertificateTrustDialog, sendReady, isInApplicationFolder, selectAllWindowContents, + installWindowsCLI, + uninstallWindowsCLI, } from './main-process-proxy' import { DiscardChanges } from './discard-changes' import { Welcome } from './welcome' @@ -152,7 +159,6 @@ 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 { getRepositoryType } from '../lib/git' import { SSHUserPassword } from './ssh/ssh-user-password' @@ -161,17 +167,20 @@ 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 { UnsupportedOSBannerDismissedAtKey } from './banners/os-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' +import { getBoolean, getNumber } from '../lib/local-storage' +import { IconPreviewDialog } from './octicons/icon-preview-dialog' +import { accessibilityBannerDismissed } from './banners/accessibilty-settings-banner' +import { isCertificateErrorSuppressedFor } from '../lib/suppress-certificate-error' +import { webUtils } from 'electron' +import { showTestUI } from './lib/test-ui-components/test-ui-components' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -305,6 +314,10 @@ export class App extends React.Component { }) ipcRenderer.on('certificate-error', (_, certificate, error, url) => { + if (isCertificateErrorSuppressedFor(url)) { + return + } + this.props.dispatcher.showPopup({ type: PopupType.UntrustedCertificate, certificate, @@ -317,6 +330,10 @@ export class App extends React.Component { public componentWillUnmount() { window.clearInterval(this.updateIntervalHandle) + + if (__DARWIN__) { + window.removeEventListener('keydown', this.onMacOSWindowKeyDown) + } } private async performDeferredLaunchActions() { @@ -356,21 +373,52 @@ export class App extends React.Component { this.showPopup({ type: PopupType.MoveToApplicationsFolder }) } - this.checkIfThankYouIsInOrder() + this.setOnOpenBanner() + } - if (isWindowsAndNoLongerSupportedByElectron()) { + private onOpenAccessibilitySettings = () => { + this.props.dispatcher.showPopup({ + type: PopupType.Preferences, + initialSelectedTab: PreferencesTab.Accessibility, + }) + } + + /** + * This method sets the app banner on opening the app. The last banner set in + * this method will be the one shown as only one banner is shown at a time. + * The only exception is the update available banner is always + * prioritized over other banners. + * + * Priority: + * 1. OS Not Supported by Electron + * 2. Accessibility Settings Banner + * 3. Thank you banner + */ + private setOnOpenBanner() { + if (isOSNoLongerSupportedByElectron()) { const dismissedAt = getNumber(UnsupportedOSBannerDismissedAtKey, 0) // Remind the user that they're running an unsupported OS every 90 days if (dismissedAt < offsetFromNow(-90, 'days')) { - this.setBanner({ type: BannerType.WindowsVersionNoLongerSupported }) + this.setBanner({ type: BannerType.OSVersionNoLongerSupported }) + return } } + + if (getBoolean(accessibilityBannerDismissed) !== true) { + this.setBanner({ + type: BannerType.AccessibilitySettingsBanner, + onOpenAccessibilitySettings: this.onOpenAccessibilitySettings, + }) + return + } + + this.checkIfThankYouIsInOrder() } private onMenuEvent(name: MenuEvent): any { // Don't react to menu events when an error dialog is shown. - if (name !== 'show-app-error' && this.state.errorCount > 1) { + if (name !== 'test-app-error' && this.state.errorCount > 1) { return } @@ -443,196 +491,45 @@ export class App extends React.Component { return this.showCloneRepo() case 'show-about': return this.showAbout() - case 'boomtown': - return this.boomtown() case 'go-to-commit-message': return this.goToCommitMessage() case 'open-pull-request': return this.openPullRequest() case 'preview-pull-request': return this.startPullRequest() - case 'install-cli': - return this.props.dispatcher.installCLI() + case 'install-darwin-cli': + return this.props.dispatcher.installDarwinCLI() + case 'install-windows-cli': + return installWindowsCLI() + case 'uninstall-windows-cli': + return uninstallWindowsCLI() case 'open-external-editor': return this.openCurrentRepositoryInExternalEditor() case 'select-all': return this.selectAll() - case 'show-release-notes-popup': - return this.showFakeReleaseNotesPopup() - case 'show-thank-you-popup': - return this.showFakeThankYouPopup() 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 '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') - case 'show-update-banner': - return this.showFakeUpdateBanner({}) - case 'show-arm64-banner': - return this.showFakeUpdateBanner({ isArm64: true }) - case 'show-showcase-update-banner': - return this.showFakeUpdateBanner({ isShowcase: true }) - case 'show-thank-you-banner': - return this.showFakeThankYouBanner() - case 'show-test-reorder-banner': - return this.showFakeReorderBanner() - case 'show-test-undone-banner': - return this.showFakeUndoneBanner() - case 'show-test-cherry-pick-conflicts-banner': - return this.showFakeCherryPickConflictBanner() default: + if (isTestMenuEvent(name)) { + return showTestUI( + name, + this.getRepository(), + this.props.dispatcher, + this.state.emoji + ) + } return assertNever(name, `Unknown menu event name: ${name}`) } } - private showFakeUpdateBanner(options: { - isArm64?: boolean - isShowcase?: boolean - }) { - updateStore.setIsx64ToARM64ImmediateAutoUpdate(options.isArm64 === true) - - if (options.isShowcase) { - this.props.dispatcher.setUpdateShowCaseVisibility(true) - return - } - - this.props.dispatcher.setUpdateBannerVisibility(true) - } - - private showFakeThankYouBanner() { - const userContributions: ReadonlyArray = [ - { - kind: 'fixed', - message: 'A totally awesome fix that fixes something - #123. Thanks!', - }, - { - kind: 'added', - message: - 'You can now do this new thing that was added here - #456. Thanks!', - }, - ] - - 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, getVersion()), - onThrowCardAway: () => { - console.log('Thrown away :(....') - }, - } - this.setBanner(banner) - } - - /** - * 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 showFakeThankYouPopup() { - if (__DEV__) { - this.props.dispatcher.showPopup({ - type: PopupType.ThankYou, - userContributions: [ - { - kind: 'new', - message: '[New] Added fake thank you dialog', - }, - ], - friendlyName: 'kind contributor', - latestVersion: '3.0.0', - }) - } - } - - private async showFakeReorderBanner() { - if (__DEV__) { - this.props.dispatcher.setBanner({ - type: BannerType.SuccessfulReorder, - count: 1, - onUndo: () => { - this.props.dispatcher.setBanner({ - type: BannerType.ReorderUndone, - commitsCount: 1, - }) - }, - }) - } - } - - private async showFakeUndoneBanner() { - if (__DEV__) { - this.props.dispatcher.setBanner({ - type: BannerType.ReorderUndone, - commitsCount: 1, - }) - } - } - - private async showFakeCherryPickConflictBanner() { - if (__DEV__) { - this.props.dispatcher.setBanner({ - type: BannerType.CherryPickConflictsFound, - targetBranchName: 'fake-branch-yo', - onOpenConflictsDialog: () => {}, - }) - } - } - - 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 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 @@ -695,12 +592,6 @@ export class App extends React.Component { } } - private boomtown() { - setImmediate(() => { - throw new Error('Boomtown!') - }) - } - private async goToCommitMessage() { await this.showChanges(false) this.props.dispatcher.setCommitMessageFocus(true) @@ -721,6 +612,13 @@ export class App extends React.Component { return } + if (isMacOSAndNoLongerSupportedByElectron()) { + log.error( + `Can't check for updates on macOS 10.14 or older. Next available update only supports macOS 10.15 and later` + ) + return + } + updateStore.checkForUpdates(inBackground, skipGuidCheck) } @@ -1115,6 +1013,10 @@ export class App extends React.Component { window.addEventListener('keyup', this.onWindowKeyUp) } + if (__DARWIN__) { + window.addEventListener('keydown', this.onMacOSWindowKeyDown) + } + document.addEventListener('focus', this.onDocumentFocus, { capture: true, }) @@ -1124,6 +1026,31 @@ export class App extends React.Component { this.props.dispatcher.appFocusedElementChanged() } + /** + * Manages keyboard shortcuts specific to macOS. + * - adds Shift+F10 to open the context menus (like on Windows so macOS + * keyboard users are not required to use VoiceOver to trigger context + * menus) + */ + private onMacOSWindowKeyDown = (event: KeyboardEvent) => { + // We do not want to override Shift+F10 behavior for the context menu on Windows. + if (!__DARWIN__) { + return + } + + if (event.defaultPrevented) { + return + } + + if (event.shiftKey && event.key === 'F10') { + document.activeElement?.dispatchEvent( + new Event('contextmenu', { + bubbles: true, // Required for React's event system + }) + ) + } + } + /** * On Windows pressing the Alt key and holding it down should * highlight the application menu. @@ -1184,7 +1111,6 @@ export class App extends React.Component { this.props.dispatcher.showFoldout({ type: FoldoutType.AppMenu, enableAccessKeyNavigation: true, - openedWithAccessKey: true, }) } else { this.props.dispatcher.executeMenuItem(menuItemForAccessKey) @@ -1226,7 +1152,6 @@ export class App extends React.Component { this.props.dispatcher.showFoldout({ type: FoldoutType.AppMenu, enableAccessKeyNavigation: true, - openedWithAccessKey: false, }) } } @@ -1235,7 +1160,7 @@ export class App extends React.Component { } private async handleDragAndDrop(fileList: FileList) { - const paths = [...fileList].map(x => x.path) + const paths = Array.from(fileList, webUtils.getPathForFile) const { dispatcher } = this.props // If they're bulk adding repositories then just blindly try to add them. @@ -1535,18 +1460,12 @@ export class App extends React.Component { 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 ( ) @@ -1644,12 +1563,21 @@ export class App extends React.Component { showCommitLengthWarning={this.state.showCommitLengthWarning} notificationsEnabled={this.state.notificationsEnabled} optOutOfUsageTracking={this.state.optOutOfUsageTracking} + useExternalCredentialHelper={this.state.useExternalCredentialHelper} enterpriseAccount={this.getEnterpriseAccount()} repository={repository} onDismissed={onPopupDismissedFn} selectedShell={this.state.selectedShell} selectedTheme={this.state.selectedTheme} + selectedTabSize={this.state.selectedTabSize} + useCustomEditor={this.state.useCustomEditor} + customEditor={this.state.customEditor} + useCustomShell={this.state.useCustomShell} + customShell={this.state.customShell} repositoryIndicatorsEnabled={this.state.repositoryIndicatorsEnabled} + onEditGlobalGitConfig={this.editGlobalGitConfig} + underlineLinks={this.state.underlineLinks} + showDiffCheckMarks={this.state.showDiffCheckMarks} /> ) case PopupType.RepositorySettings: { @@ -1679,6 +1607,8 @@ export class App extends React.Component { signInState={this.state.signInState} dispatcher={this.props.dispatcher} onDismissed={onPopupDismissedFn} + isCredentialHelperSignIn={popup.isCredentialHelperSignIn} + credentialHelperUrl={popup.credentialHelperUrl} /> ) case PopupType.AddRepository: @@ -1772,11 +1702,9 @@ export class App extends React.Component { applicationName={getName()} applicationVersion={version} applicationArchitecture={process.arch} - onCheckForUpdates={this.onCheckForUpdates} onCheckForNonStaggeredUpdates={this.onCheckForNonStaggeredUpdates} onShowAcknowledgements={this.showAcknowledgements} onShowTermsAndConditions={this.showTermsAndConditions} - isTopMost={isTopMost} /> ) case PopupType.PublishRepository: @@ -1840,13 +1768,19 @@ export class App extends React.Component { ) case PopupType.GenericGitAuthentication: + const onDismiss = () => { + popup.onDismiss?.() + onPopupDismissedFn() + } + return ( ) case PopupType.ExternalEditorFailed: @@ -1858,7 +1792,7 @@ export class App extends React.Component { key="editor-error" message={popup.message} onDismissed={onPopupDismissedFn} - showPreferencesDialog={this.onShowAdvancedPreferences} + showPreferencesDialog={this.onShowIntegrationsPreferences} viewPreferences={openPreferences} suggestDefaultEditor={suggestDefaultEditor} /> @@ -1869,7 +1803,7 @@ export class App extends React.Component { key="shell-error" message={popup.message} onDismissed={onPopupDismissedFn} - showPreferencesDialog={this.onShowAdvancedPreferences} + showPreferencesDialog={this.onShowIntegrationsPreferences} /> ) case PopupType.InitializeLFS: @@ -1887,6 +1821,7 @@ export class App extends React.Component { key="lsf-attribute-mismatch" onDismissed={onPopupDismissedFn} onUpdateExistingFilters={this.updateExistingLFSFilters} + onEditGlobalGitConfig={this.editGlobalGitConfig} /> ) case PopupType.UpstreamAlreadyExists: @@ -1907,6 +1842,7 @@ export class App extends React.Component { emoji={this.state.emoji} newReleases={popup.newReleases} onDismissed={onPopupDismissedFn} + underlineLinks={this.state.underlineLinks} /> ) case PopupType.DeletePullRequest: @@ -2214,6 +2150,7 @@ export class App extends React.Component { onDismissed={onPopupDismissedFn} onSubmitCommitMessage={popup.onSubmitCommitMessage} repositoryAccount={repositoryAccount} + accounts={this.state.accounts} /> ) case PopupType.MultiCommitOperation: { @@ -2332,8 +2269,6 @@ export class App extends React.Component { shouldChangeRepository={popup.shouldChangeRepository} repository={popup.repository} pullRequest={popup.pullRequest} - commitMessage={popup.commitMessage} - commitSha={popup.commitSha} checks={popup.checks} accounts={this.state.accounts} onSubmit={onPopupDismissedFn} @@ -2394,9 +2329,10 @@ export class App extends React.Component { pullRequest={popup.pullRequest} review={popup.review} emoji={this.state.emoji} - accounts={this.state.accounts} onSubmit={onPopupDismissedFn} onDismissed={onPopupDismissedFn} + underlineLinks={this.state.underlineLinks} + accounts={this.state.accounts} /> ) } @@ -2422,6 +2358,7 @@ export class App extends React.Component { selectedTab={popup.selectedTab} emoji={emoji} onDismissed={onPopupDismissedFn} + accounts={this.state.accounts} /> ) } @@ -2520,9 +2457,10 @@ export class App extends React.Component { pullRequest={popup.pullRequest} comment={popup.comment} emoji={this.state.emoji} - accounts={this.state.accounts} onSubmit={onPopupDismissedFn} onDismissed={onPopupDismissedFn} + underlineLinks={this.state.underlineLinks} + accounts={this.state.accounts} /> ) } @@ -2536,13 +2474,10 @@ export class App extends React.Component { /> ) } - case PopupType.ConfirmRepoRulesBypass: { + case PopupType.TestIcons: { return ( - ) @@ -2600,6 +2535,9 @@ export class App extends React.Component { this.props.dispatcher.installGlobalLFSFilters(true) } + private editGlobalGitConfig = () => + this.props.dispatcher.editGlobalGitConfig() + private initializeLFS = (repositories: ReadonlyArray) => { this.props.dispatcher.installLFSHooks(repositories) } @@ -2612,10 +2550,10 @@ export class App extends React.Component { this.props.dispatcher.refreshApiRepositories(account) } - private onShowAdvancedPreferences = () => { + private onShowIntegrationsPreferences = () => { this.props.dispatcher.showPopup({ type: PopupType.Preferences, - initialSelectedTab: PreferencesTab.Advanced, + initialSelectedTab: PreferencesTab.Integrations, }) } @@ -2630,22 +2568,6 @@ export class App extends React.Component { this.props.dispatcher.openShell(path, true) } - private onSaveCredentials = async ( - hostname: string, - username: string, - password: string, - retryAction: RetryAction - ) => { - await this.props.dispatcher.saveGenericGitCredentials( - hostname, - username, - password - ) - - this.props.dispatcher.performRetry(retryAction) - } - - private onCheckForUpdates = () => this.checkForUpdates(false) private onCheckForNonStaggeredUpdates = () => this.checkForUpdates(false, true) @@ -2694,6 +2616,7 @@ export class App extends React.Component { commit={commit} selectedCommits={selectedCommits} emoji={emoji} + accounts={this.state.accounts} /> ) default: @@ -2762,7 +2685,7 @@ export class App extends React.Component { const externalEditorLabel = this.state.selectedExternalEditor ? this.state.selectedExternalEditor : undefined - const shellLabel = this.state.selectedShell + const { useCustomShell, selectedShell } = this.state const filterText = this.state.repositoryFilterText return ( { onShowRepository={this.showRepository} onOpenInExternalEditor={this.openInExternalEditor} externalEditorLabel={externalEditorLabel} - shellLabel={shellLabel} + shellLabel={useCustomShell ? undefined : selectedShell} dispatcher={this.props.dispatcher} /> ) @@ -2871,17 +2794,17 @@ export class App extends React.Component { const repository = selection ? selection.repository : null - let icon: OcticonSymbolType + let icon: OcticonSymbol let title: string if (repository) { const alias = repository instanceof Repository ? repository.alias : null icon = iconForRepository(repository) title = alias ?? repository.name } else if (this.state.repositories.length > 0) { - icon = OcticonSymbol.repo + icon = octicons.repo title = __DARWIN__ ? 'Select a Repository' : 'Select a repository' } else { - icon = OcticonSymbol.repo + icon = octicons.repo title = __DARWIN__ ? 'No Repositories' : 'No repositories' } @@ -2956,7 +2879,9 @@ export class App extends React.Component { onRemoveRepositoryAlias: onRemoveRepositoryAlias, onViewOnGitHub: this.viewOnGitHub, repository: repository, - shellLabel: this.state.selectedShell, + shellLabel: this.state.useCustomShell + ? undefined + : this.state.selectedShell, }) showContextualMenu(items) @@ -2971,7 +2896,13 @@ export class App extends React.Component { const state = selection.state const revertProgress = state.revertProgress if (revertProgress) { - return + return ( + + ) } let remoteName = state.remote ? state.remote.name : null @@ -3029,6 +2960,7 @@ export class App extends React.Component { askForConfirmationOnForcePush={this.state.askForConfirmationOnForcePush} onDropdownStateChanged={this.onPushPullDropdownStateChanged} enableFocusTrap={enableFocusTrap} + pushPullButtonWidth={this.state.pushPullButtonWidth} /> ) } @@ -3132,6 +3064,7 @@ export class App extends React.Component { { showCIStatusPopover={this.state.showCIStatusPopover} emoji={this.state.emoji} enableFocusTrap={enableFocusTrap} + underlineLinks={this.state.underlineLinks} /> ) } @@ -3173,13 +3107,18 @@ export class App extends React.Component { banner = this.renderUpdateBanner() } return ( - - {banner && ( - - {banner} - - )} - +
+ + {banner && ( + + {banner} + + )} + +
) } @@ -3249,9 +3188,9 @@ export class App extends React.Component { } if (selectedState.type === SelectionType.Repository) { - const externalEditorLabel = state.selectedExternalEditor - ? state.selectedExternalEditor - : undefined + const externalEditorLabel = state.useCustomEditor + ? undefined + : state.selectedExternalEditor ?? undefined return ( { imageDiffType={state.imageDiffType} hideWhitespaceInChangesDiff={state.hideWhitespaceInChangesDiff} hideWhitespaceInHistoryDiff={state.hideWhitespaceInHistoryDiff} + showDiffCheckMarks={state.showDiffCheckMarks} showSideBySideDiff={state.showSideBySideDiff} focusCommitMessage={state.focusCommitMessage} askForConfirmationOnDiscardChanges={ @@ -3284,6 +3224,9 @@ export class App extends React.Component { state.askForConfirmationOnCheckoutCommit } accounts={state.accounts} + isExternalEditorAvailable={ + state.useCustomEditor || state.selectedExternalEditor !== null + } externalEditorLabel={externalEditorLabel} resolvedExternalEditor={state.resolvedExternalEditor} onOpenInExternalEditor={this.onOpenInExternalEditor} @@ -3322,7 +3265,6 @@ export class App extends React.Component { return ( @@ -3334,14 +3276,25 @@ export class App extends React.Component { return null } - const className = this.state.appIsFocused ? 'focused' : 'blurred' + const className = classNames( + this.state.appIsFocused ? 'focused' : 'blurred', + { + 'underline-links': this.state.underlineLinks, + } + ) const currentTheme = this.state.showWelcomeFlow ? ApplicationTheme.Light : this.state.currentTheme + const currentTabSize = this.state.selectedTabSize + return ( -
+
{this.renderTitlebar()} {this.state.showWelcomeFlow diff --git a/app/src/ui/autocompletion/autocompleting-text-input.tsx b/app/src/ui/autocompletion/autocompleting-text-input.tsx index a4595f0f832..3586ccc2f13 100644 --- a/app/src/ui/autocompletion/autocompleting-text-input.tsx +++ b/app/src/ui/autocompletion/autocompleting-text-input.tsx @@ -24,6 +24,9 @@ interface IRange { } interface IAutocompletingTextInputProps { + /** An optional specified id for the input */ + readonly inputId?: string + /** * An optional className to be applied to the rendered * top level element of the component. @@ -36,6 +39,11 @@ interface IAutocompletingTextInputProps { /** Content of an optional invisible label element for screen readers. */ readonly screenReaderLabel?: string + /** + * The label of the text box. + */ + readonly label?: string | JSX.Element + /** The placeholder for the input field. */ readonly placeholder?: string @@ -146,7 +154,7 @@ interface IAutocompletingTextInputState { /** A text area which provides autocompletions as the user types. */ export abstract class AutocompletingTextInput< ElementType extends HTMLInputElement | HTMLTextAreaElement, - AutocompleteItemType extends Object + AutocompleteItemType extends object > extends React.Component< IAutocompletingTextInputProps, IAutocompletingTextInputState @@ -175,7 +183,8 @@ export abstract class AutocompletingTextInput< } public componentWillMount() { - const elementId = createUniqueId('autocompleting-text-input') + const elementId = + this.props.inputId ?? createUniqueId('autocompleting-text-input') const autocompleteContainerId = createUniqueId('autocomplete-container') this.setState({ @@ -209,6 +218,16 @@ export abstract class AutocompletingTextInput< return this.props.elementId ?? this.state.uniqueInternalElementId } + private getItemAriaLabel = (row: number): string | undefined => { + const state = this.state.autocompletionState + if (!state) { + return undefined + } + + const item = state.items[row] + return state.provider.getItemAriaLabel?.(item) + } + private renderItem = (row: number): JSX.Element | null => { const state = this.state.autocompletionState if (!state) { @@ -235,9 +254,9 @@ export abstract class AutocompletingTextInput< return null } - const selectedRow = state.selectedItem - ? items.indexOf(state.selectedItem) - : -1 + const selectedRows = state.selectedItem + ? [items.indexOf(state.selectedItem)] + : [] // The height needed to accommodate all the matched items without overflowing // @@ -266,6 +285,7 @@ export abstract class AutocompletingTextInput< minHeight={minHeight} trapFocus={false} className={className} + isDialog={false} > ) @@ -514,6 +538,7 @@ export abstract class AutocompletingTextInput< 'text-area-component': tagName === 'textarea', } ) + const { label, screenReaderLabel } = this.props const autoCompleteItems = this.state.autocompletionState?.items ?? [] @@ -525,11 +550,12 @@ export abstract class AutocompletingTextInput< return (
{this.renderAutocompletions()} - {this.props.screenReaderLabel && ( + {screenReaderLabel && label === undefined && ( )} + {label && } {this.renderTextInput()} {this.renderInvisibleCaret()} extends AutocompletingTextInput { protected getElementTagName(): 'textarea' | 'input' { return 'textarea' } } export class AutocompletingInput< - AutocompleteItemType = Object + AutocompleteItemType extends object = object > extends AutocompletingTextInput { protected getElementTagName(): 'textarea' | 'input' { return 'input' @@ -44,6 +44,9 @@ export interface IAutocompletionProvider { */ renderItem(item: T): JSX.Element + /** Returns the aria-label attribute for the rendered item. Optional. */ + getItemAriaLabel?(item: T): string + /** * Returns a text representation of a given autocompletion results. * This is the text that will end up going into the textbox if the diff --git a/app/src/ui/autocompletion/build-autocompletion-providers.ts b/app/src/ui/autocompletion/build-autocompletion-providers.ts index 8bc938d2c27..75f8bc38935 100644 --- a/app/src/ui/autocompletion/build-autocompletion-providers.ts +++ b/app/src/ui/autocompletion/build-autocompletion-providers.ts @@ -13,11 +13,12 @@ import { import { Dispatcher } from '../dispatcher' import { GitHubUserStore, IssuesStore } from '../../lib/stores' import { Account } from '../../models/account' +import { Emoji } from '../../lib/emoji' export function buildAutocompletionProviders( repository: Repository, dispatcher: Dispatcher, - emoji: Map, + emoji: Map, issuesStore: IssuesStore, gitHubUserStore: GitHubUserStore, accounts: ReadonlyArray diff --git a/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx b/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx index aa68c87319f..608393da232 100644 --- a/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx +++ b/app/src/ui/autocompletion/emoji-autocompletion-provider.tsx @@ -2,6 +2,9 @@ import * as React from 'react' import { IAutocompletionProvider } from './index' import { compare } from '../../lib/compare' import { DefaultMaxHits } from './common' +import { Emoji } from '../../lib/emoji' + +const sanitizeEmoji = (emoji: string) => emoji.replaceAll(':', '') /** * Interface describing a autocomplete match for the given search @@ -9,7 +12,14 @@ import { DefaultMaxHits } from './common' */ export interface IEmojiHit { /** A human-readable markdown representation of the emoji, ex :heart: */ - readonly emoji: string + readonly title: string + + /** + * The unicode string of the emoji if emoji is part of + * the unicode specification. If missing this emoji is + * a GitHub custom emoji such as :shipit: + */ + readonly emoji?: string /** * The offset into the emoji string where the @@ -31,10 +41,10 @@ export class EmojiAutocompletionProvider { public readonly kind = 'emoji' - private readonly emoji: Map + private readonly allEmoji: Map - public constructor(emoji: Map) { - this.emoji = emoji + public constructor(emoji: Map) { + this.allEmoji = emoji } public getRegExp(): RegExp { @@ -49,18 +59,28 @@ export class EmojiAutocompletionProvider // when the user types a ':'. We want to open the popup // with suggestions as fast as possible. if (text.length === 0) { - return [...this.emoji.keys()] - .map(emoji => ({ emoji, matchStart: 0, matchLength: 0 })) + return [...this.allEmoji.entries()] + .map(([title, { emoji }]) => ({ + title, + emoji, + matchStart: 0, + matchLength: 0, + })) .slice(0, maxHits) } const results = new Array() const needle = text.toLowerCase() - for (const emoji of this.emoji.keys()) { - const index = emoji.indexOf(needle) + for (const [key, emoji] of this.allEmoji.entries()) { + const index = key.indexOf(needle) if (index !== -1) { - results.push({ emoji, matchStart: index, matchLength: needle.length }) + results.push({ + title: key, + emoji: emoji.emoji, + matchStart: index, + matchLength: needle.length, + }) } } @@ -79,25 +99,44 @@ export class EmojiAutocompletionProvider .sort( (x, y) => compare(x.matchStart, y.matchStart) || - compare(x.emoji.length, y.emoji.length) || - compare(x.emoji, y.emoji) + compare(x.title.length, y.title.length) || + compare(x.title, y.title) ) .slice(0, maxHits) } + public getItemAriaLabel(hit: IEmojiHit): string { + const emoji = this.allEmoji.get(hit.title) + const sanitizedEmoji = sanitizeEmoji(hit.title) + const emojiDescription = emoji?.emoji + ? emoji.emoji + : emoji?.description ?? sanitizedEmoji + return emojiDescription === sanitizedEmoji + ? emojiDescription + : `${emojiDescription}, ${sanitizedEmoji}` + } + public renderItem(hit: IEmojiHit) { - const emoji = hit.emoji + const emoji = this.allEmoji.get(hit.title) return ( -
- {emoji} +
+ {emoji?.emoji ? ( +
{emoji?.emoji}
+ ) : ( + {emoji?.description + )} {this.renderHighlightedTitle(hit)}
) } private renderHighlightedTitle(hit: IEmojiHit) { - const emoji = hit.emoji.replaceAll(':', '') + const emoji = sanitizeEmoji(hit.title) if (!hit.matchLength) { return
{emoji}
@@ -117,6 +156,6 @@ export class EmojiAutocompletionProvider } public getCompletionText(item: IEmojiHit) { - return item.emoji + return item.emoji ?? item.title } } diff --git a/app/src/ui/banners/accessibilty-settings-banner.tsx b/app/src/ui/banners/accessibilty-settings-banner.tsx new file mode 100644 index 00000000000..2091509981f --- /dev/null +++ b/app/src/ui/banners/accessibilty-settings-banner.tsx @@ -0,0 +1,44 @@ +import * as React from 'react' +import { Octicon } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import { Banner } from './banner' +import { LinkButton } from '../lib/link-button' +import { setBoolean } from '../../lib/local-storage' + +export const accessibilityBannerDismissed = 'accessibility-banner-dismissed' + +interface IAccessibilitySettingsBannerProps { + readonly onOpenAccessibilitySettings: () => void + readonly onDismissed: () => void +} + +export class AccessibilitySettingsBanner extends React.Component { + private onDismissed = () => { + setBoolean(accessibilityBannerDismissed, true) + this.props.onDismissed() + } + + private onOpenAccessibilitySettings = () => { + this.props.onOpenAccessibilitySettings() + this.onDismissed() + } + + public render() { + return ( + + +
+ Check out the new{' '} + + accessibility settings + {' '} + to control the visibility of the link underlines and diff check marks. +
+
+ ) + } +} diff --git a/app/src/ui/banners/banner.tsx b/app/src/ui/banners/banner.tsx index 4d33136b62c..8b768b0a552 100644 --- a/app/src/ui/banners/banner.tsx +++ b/app/src/ui/banners/banner.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { Octicon } from '../octicons' -import * as OcticonSymbol from '../octicons/octicons.generated' +import * as octicons from '../octicons/octicons.generated' interface IBannerProps { readonly id?: string @@ -20,13 +20,7 @@ export class Banner extends React.Component { public render() { return ( -