mirror of
https://github.com/desktop/desktop
synced 2024-09-13 21:31:32 +00:00
Merge branch 'development' into dependency-version-lag-docs
This commit is contained in:
commit
61cea247d1
|
@ -3,7 +3,7 @@ version: 2
|
|||
defaults: &defaults
|
||||
working_directory: ~/desktop/desktop
|
||||
macos:
|
||||
xcode: '9.3.0'
|
||||
xcode: '9.4.1'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
|
@ -6,6 +6,10 @@ plugins:
|
|||
- react
|
||||
- json
|
||||
|
||||
settings:
|
||||
react:
|
||||
version: '16.3'
|
||||
|
||||
extends:
|
||||
- prettier
|
||||
- prettier/react
|
||||
|
@ -36,8 +40,6 @@ rules:
|
|||
# this rule now works but generates a lot of issues with the codebase
|
||||
# '@typescript-eslint/member-ordering': error
|
||||
|
||||
'@typescript-eslint/type-annotation-spacing': error
|
||||
|
||||
# Babel
|
||||
babel/no-invalid-this: error
|
||||
|
||||
|
@ -70,6 +72,7 @@ rules:
|
|||
strict:
|
||||
- error
|
||||
- global
|
||||
no-buffer-constructor: error
|
||||
|
||||
###########
|
||||
# SPECIAL #
|
||||
|
|
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -64,8 +64,8 @@ https://github.com/desktop/desktop/blob/development/docs/contributing/timeline-p
|
|||
<!--
|
||||
Attach your log file (You can simply drag your file here to insert it) to this issue. Please make sure the generated link to your log file is **below** this comment section otherwise it will not appear when you submit your issue.
|
||||
|
||||
macOS logs location: `~/Library/Application Support/GitHub Desktop/logs/*.desktop.production.log`
|
||||
Windows logs location: `%APPDATA%\GitHub Desktop\logs\*.desktop.production.log`
|
||||
macOS logs accessible from the Help menu or location: `~/Library/Application Support/GitHub Desktop/logs/*.desktop.production.log`
|
||||
Windows logs accessible from the Help menu or location: `%APPDATA%\GitHub Desktop\logs\*.desktop.production.log`
|
||||
|
||||
The log files are organized by date, so see if anything was generated for today's date.
|
||||
-->
|
||||
|
|
9
.github/ISSUE_TEMPLATE/problem-to-raise.md
vendored
9
.github/ISSUE_TEMPLATE/problem-to-raise.md
vendored
|
@ -4,6 +4,15 @@ about: Surface a problem that you think should be solved
|
|||
|
||||
---
|
||||
|
||||
<!--
|
||||
First and foremost, we’d like to thank you for taking the time to contribute to our project. Before submitting your issue, please follow these steps:
|
||||
|
||||
1. Familiarize yourself with our contributing guide:
|
||||
* https://github.com/desktop/desktop/blob/development/.github/CONTRIBUTING.md#contributing-to-github-desktop
|
||||
2. Make sure your issue isn’t a duplicate of another issue
|
||||
3. If you have made it to this step, go ahead and fill out the template below
|
||||
-->
|
||||
|
||||
**Please describe the problem you think should be solved**
|
||||
A clear and concise description of what the problem is and who else might be impacted. Screenshots are encouraged.
|
||||
|
||||
|
|
2
.github/config.yml
vendored
2
.github/config.yml
vendored
|
@ -13,7 +13,7 @@ requestInfoReplyComment: >
|
|||
|
||||
Thanks for understanding and meeting us halfway 😀
|
||||
|
||||
requestInfoLabelToAdd: more-information-needed
|
||||
requestInfoLabelToAdd: more-info-needed
|
||||
|
||||
requestInfoOn:
|
||||
pullRequest: false
|
||||
|
|
|
@ -1 +1 @@
|
|||
8.12.0
|
||||
10.16.0
|
||||
|
|
|
@ -12,5 +12,4 @@ app/coverage
|
|||
app/static/common
|
||||
app/test/fixtures
|
||||
gemoji
|
||||
*.json
|
||||
*.md
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
python 2.7
|
||||
nodejs 8.12.0
|
||||
python 2.7.16
|
||||
nodejs 10.15.3
|
||||
|
|
|
@ -23,11 +23,12 @@ addons:
|
|||
branches:
|
||||
only:
|
||||
- development
|
||||
- /releases\/.+/
|
||||
- /^__release-.*/
|
||||
|
||||
language: node_js
|
||||
node_js:
|
||||
- '8.12'
|
||||
- '10'
|
||||
|
||||
cache:
|
||||
yarn: true
|
||||
|
|
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
|
@ -2,8 +2,7 @@
|
|||
"recommendations": [
|
||||
"ms-vscode.vscode-typescript-tslint-plugin",
|
||||
"msjsdiag.debugger-for-chrome",
|
||||
"samverschueren.final-newline",
|
||||
"DmitryDorofeev.empty-indent",
|
||||
"esbenp.prettier-vscode"
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
|
|
10
.vscode/launch.json
vendored
10
.vscode/launch.json
vendored
|
@ -14,12 +14,12 @@
|
|||
],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"env":{
|
||||
"env": {
|
||||
"ELECTRON_RUN_AS_NODE": "1"
|
||||
},
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
|
@ -35,12 +35,12 @@
|
|||
],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"env":{
|
||||
"env": {
|
||||
"ELECTRON_RUN_AS_NODE": "1"
|
||||
},
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
|
@ -24,5 +24,15 @@
|
|||
"prettier.trailingComma": "es5",
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.ignorePath": ".prettierignore",
|
||||
"tslint.ignoreDefinitionFiles": true
|
||||
"tslint.ignoreDefinitionFiles": true,
|
||||
"eslint.options": {
|
||||
"configFile": ".eslintrc.yml",
|
||||
"rulePaths": ["eslint-rules"]
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
]
|
||||
}
|
||||
|
|
22
README.md
22
README.md
|
@ -21,13 +21,6 @@ Download the official installer for your operating system:
|
|||
- [Windows](https://central.github.com/deployments/desktop/desktop/latest/win32)
|
||||
- [Windows machine-wide install](https://central.github.com/deployments/desktop/desktop/latest/win32?format=msi)
|
||||
|
||||
There are several community-supported package managers that can be used to install Github Desktop.
|
||||
- Windows users can install using [Chocolatey](https://chocolatey.org/) package manager:
|
||||
`c:\> choco install github-desktop`
|
||||
- macOS users can install using [Homebrew](https://brew.sh/) package manager:
|
||||
`$ brew cask install github`
|
||||
- Arch Linux users can install the latest version from the [AUR](https://aur.archlinux.org/packages/github-desktop/).
|
||||
|
||||
You can install this alongside your existing GitHub Desktop for Mac or GitHub
|
||||
Desktop for Windows application.
|
||||
|
||||
|
@ -43,6 +36,21 @@ beta channel to get access to early builds of Desktop:
|
|||
- [macOS](https://central.github.com/deployments/desktop/desktop/latest/darwin?env=beta)
|
||||
- [Windows](https://central.github.com/deployments/desktop/desktop/latest/win32?env=beta)
|
||||
|
||||
### Community Releases
|
||||
|
||||
There are several community-supported package managers that can be used to
|
||||
install GitHub Desktop:
|
||||
- Windows users can install using [Chocolatey](https://chocolatey.org/) package manager:
|
||||
`c:\> choco install github-desktop`
|
||||
- macOS users can install using [Homebrew](https://brew.sh/) package manager:
|
||||
`$ brew cask install github`
|
||||
|
||||
Installers for various Linux distributions can be found on the
|
||||
[`shiftkey/desktop`](https://github.com/shiftkey/desktop) fork.
|
||||
|
||||
Arch Linux users can install the latest version from the
|
||||
[AUR](https://aur.archlinux.org/packages/github-desktop-bin/).
|
||||
|
||||
## Is GitHub Desktop right for me? What are the primary areas of focus?
|
||||
|
||||
[This document](https://github.com/desktop/desktop/blob/development/docs/process/what-is-desktop.md) describes the focus of GitHub Desktop and who the product is most useful for.
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
runtime = electron
|
||||
disturl = https://atom.io/download/electron
|
||||
target = 3.1.6
|
||||
target = 5.0.6
|
||||
arch = x64
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"productName": "GitHub Desktop",
|
||||
"bundleID": "com.github.GitHubClient",
|
||||
"companyName": "GitHub, Inc.",
|
||||
"version": "1.7.0-beta1",
|
||||
"version": "2.1.1-beta2",
|
||||
"main": "./main.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -27,14 +27,15 @@
|
|||
"dexie": "^2.0.0",
|
||||
"double-ended-queue": "^2.1.0-0",
|
||||
"dugite": "1.87.0",
|
||||
"electron-window-state": "^4.0.2",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"event-kit": "^2.0.0",
|
||||
"file-uri-to-path": "0.0.2",
|
||||
"file-url": "^2.0.2",
|
||||
"fs-admin": "^0.3.0",
|
||||
"fs-admin": "^0.3.1",
|
||||
"fs-extra": "^6.0.0",
|
||||
"fuzzaldrin-plus": "^0.6.0",
|
||||
"keytar": "^4.4.1",
|
||||
"mem": "^4.3.0",
|
||||
"memoize-one": "^4.0.3",
|
||||
"moment": "^2.24.0",
|
||||
"mri": "^1.1.0",
|
||||
|
@ -48,7 +49,7 @@
|
|||
"react-dom": "^16.3.2",
|
||||
"react-transition-group": "^1.2.0",
|
||||
"react-virtualized": "^9.20.0",
|
||||
"registry-js": "^1.0.7",
|
||||
"registry-js": "^1.4.0",
|
||||
"source-map-support": "^0.4.15",
|
||||
"strip-ansi": "^4.0.0",
|
||||
"textarea-caret": "^3.0.2",
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import chalk from 'chalk'
|
||||
import * as Path from 'path'
|
||||
|
||||
import { ICommandModule, mriArgv } from '../load-commands'
|
||||
import { openDesktop } from '../open-desktop'
|
||||
import { parseRemote } from '../../lib/remote-parsing'
|
||||
|
||||
const command: ICommandModule = {
|
||||
command: 'open <path>',
|
||||
|
@ -21,9 +23,18 @@ const command: ICommandModule = {
|
|||
openDesktop()
|
||||
return
|
||||
}
|
||||
const repositoryPath = Path.resolve(process.cwd(), pathArg)
|
||||
const url = `openLocalRepo/${encodeURIComponent(repositoryPath)}`
|
||||
openDesktop(url)
|
||||
//Check if the pathArg is a remote url
|
||||
if (parseRemote(pathArg) != null) {
|
||||
console.log(
|
||||
`\nYou cannot open a remote URL in GitHub Desktop\n` +
|
||||
`Use \`${chalk.bold(`git clone ` + pathArg)}\`` +
|
||||
` instead to initiate the clone`
|
||||
)
|
||||
} else {
|
||||
const repositoryPath = Path.resolve(process.cwd(), pathArg)
|
||||
const url = `openLocalRepo/${encodeURIComponent(repositoryPath)}`
|
||||
openDesktop(url)
|
||||
}
|
||||
},
|
||||
}
|
||||
export = command
|
||||
|
|
|
@ -113,6 +113,7 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
|
|||
'.vcxproj': 'text/xml',
|
||||
'.vbproj': 'text/xml',
|
||||
'.svg': 'text/xml',
|
||||
'.resx': 'text/xml',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -265,6 +266,126 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
|
|||
'.jl': 'text/x-julia',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/stex/stex'),
|
||||
mappings: {
|
||||
'.tex': 'text/x-stex',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/sparql/sparql'),
|
||||
mappings: {
|
||||
'.rq': 'application/sparql-query',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/stylus/stylus'),
|
||||
mappings: {
|
||||
'.styl': 'text/x-styl',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/soy/soy'),
|
||||
mappings: {
|
||||
'.soy': 'text/x-soy',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/smalltalk/smalltalk'),
|
||||
mappings: {
|
||||
'.st': 'text/x-stsrc',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/slim/slim'),
|
||||
mappings: {
|
||||
'.slim': 'application/x-slim',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/sieve/sieve'),
|
||||
mappings: {
|
||||
'.sieve': 'application/sieve',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/scheme/scheme'),
|
||||
mappings: {
|
||||
'.ss': 'text/x-scheme',
|
||||
'.sls': 'text/x-scheme',
|
||||
'.scm': 'text/x-scheme',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/rst/rst'),
|
||||
mappings: {
|
||||
'.rst': 'text/x-rst',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/rpm/rpm'),
|
||||
mappings: {
|
||||
'.rpm': 'text/x-rpm-spec',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/q/q'),
|
||||
mappings: {
|
||||
'.q': 'text/x-q',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/puppet/puppet'),
|
||||
mappings: {
|
||||
'.pp': 'text/x-puppet',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/pug/pug'),
|
||||
mappings: {
|
||||
'.pug': 'text/x-pug',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/protobuf/protobuf'),
|
||||
mappings: {
|
||||
'.proto': 'text/x-protobuf',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/properties/properties'),
|
||||
mappings: {
|
||||
'.properties': 'text/x-properties',
|
||||
'.gitattributes': 'text/x-properties',
|
||||
'.gitignore': 'text/x-properties',
|
||||
'.editorconfig': 'text/x-properties',
|
||||
'.ini': 'text/x-ini',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/pig/pig'),
|
||||
mappings: {
|
||||
'.pig': 'text/x-pig',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/asciiarmor/asciiarmor'),
|
||||
mappings: {
|
||||
'.pgp': 'application/pgp',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/oz/oz'),
|
||||
mappings: {
|
||||
'.oz': 'text/x-oz',
|
||||
},
|
||||
},
|
||||
{
|
||||
install: () => import('codemirror/mode/pascal/pascal'),
|
||||
mappings: {
|
||||
'.pas': 'text/x-pascal',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
@ -14,6 +14,50 @@ import { uuid } from './uuid'
|
|||
import { getAvatarWithEnterpriseFallback } from './gravatar'
|
||||
import { getDefaultEmail } from './email'
|
||||
|
||||
/**
|
||||
* Optional set of configurable settings for the fetchAll method
|
||||
*/
|
||||
interface IFetchAllOptions<T> {
|
||||
/**
|
||||
* The number of results to ask for on each page when making
|
||||
* requests to paged API endpoints.
|
||||
*/
|
||||
perPage?: number
|
||||
|
||||
/**
|
||||
* An optional predicate which determines whether or not to
|
||||
* continue loading results from the API. This can be used
|
||||
* to put a limit on the number of results to return from
|
||||
* a paged API resource.
|
||||
*
|
||||
* As an example, to stop loading results after 500 results:
|
||||
*
|
||||
* `(results) => results.length < 500`
|
||||
*
|
||||
* @param results All results retrieved thus far
|
||||
*/
|
||||
continue?: (results: ReadonlyArray<T>) => boolean
|
||||
|
||||
/**
|
||||
* Calculate the next page path given the response.
|
||||
*
|
||||
* Optional, see `getNextPagePathFromLink` for the default
|
||||
* implementation.
|
||||
*/
|
||||
getNextPagePath?: (response: Response) => string | null
|
||||
|
||||
/**
|
||||
* Whether or not to silently suppress request errors and
|
||||
* return the results retrieved thus far. If this field is
|
||||
* `true` the fetchAll method will suppress errors (this is
|
||||
* also the default behavior if no value is provided for
|
||||
* this field). Setting this field to false will cause the
|
||||
* fetchAll method to throw if it encounters an API error
|
||||
* on any page.
|
||||
*/
|
||||
suppressErrors?: boolean
|
||||
}
|
||||
|
||||
const username: () => Promise<string> = require('username')
|
||||
|
||||
const ClientID = process.env.TEST_ENV ? '' : __OAUTH_CLIENT_ID__
|
||||
|
@ -129,6 +173,12 @@ export interface IAPIMentionableUser {
|
|||
readonly name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown by `fetchUpdatedPullRequests` when receiving more results than
|
||||
* what the `maxResults` parameter allows for.
|
||||
*/
|
||||
export class MaxResultsError extends Error {}
|
||||
|
||||
/**
|
||||
* `null` can be returned by the API for legacy reasons. A non-null value is
|
||||
* set for the primary email address currently, but in the future visibility
|
||||
|
@ -176,6 +226,23 @@ export interface IAPIRefStatus {
|
|||
readonly statuses: ReadonlyArray<IAPIRefStatusItem>
|
||||
}
|
||||
|
||||
/** Branch information returned by the GitHub API */
|
||||
export interface IAPIBranch {
|
||||
/**
|
||||
* The name of the branch stored on the remote.
|
||||
*
|
||||
* NOTE: this is NOT a fully-qualified ref (i.e. `refs/heads/master`)
|
||||
*/
|
||||
readonly name: string
|
||||
/**
|
||||
* Branch protection settings:
|
||||
*
|
||||
* - `true` indicates that the branch is protected in some way
|
||||
* - `false` indicates no branch protection set
|
||||
*/
|
||||
readonly protected: boolean
|
||||
}
|
||||
|
||||
interface IAPIPullRequestRef {
|
||||
readonly ref: string
|
||||
readonly sha: string
|
||||
|
@ -192,9 +259,11 @@ export interface IAPIPullRequest {
|
|||
readonly number: number
|
||||
readonly title: string
|
||||
readonly created_at: string
|
||||
readonly updated_at: string
|
||||
readonly user: IAPIIdentity
|
||||
readonly head: IAPIPullRequestRef
|
||||
readonly base: IAPIPullRequestRef
|
||||
readonly state: 'open' | 'closed'
|
||||
}
|
||||
|
||||
/** The metadata about a GitHub server. */
|
||||
|
@ -235,7 +304,7 @@ interface ISearchResults<T> {
|
|||
*
|
||||
* If no link rel next header is found this method returns null.
|
||||
*/
|
||||
function getNextPagePath(response: Response): string | null {
|
||||
function getNextPagePathFromLink(response: Response): string | null {
|
||||
const linkHeader = response.headers.get('Link')
|
||||
|
||||
if (!linkHeader) {
|
||||
|
@ -255,6 +324,91 @@ function getNextPagePath(response: Response): string | null {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the 'next' Link header from GitHub using
|
||||
* `getNextPagePathFromLink`. Unlike `getNextPagePathFromLink`
|
||||
* this method will attempt to double the page size when
|
||||
* the current page index and the page size allows for it
|
||||
* leading to a ramp up in page size.
|
||||
*
|
||||
* This might sound confusing, and it is, but the primary use
|
||||
* case for this is when retrieving updated PRs. By specifying
|
||||
* an initial page size of, for example, 10 this method will
|
||||
* increase the page size to 20 once the second page has been
|
||||
* loaded. See the table below for an example. The ramp-up
|
||||
* will stop at a page size of 100 since that's the maximum
|
||||
* that the GitHub API supports.
|
||||
*
|
||||
* ```
|
||||
* |-----------|------|-----------|-----------------|
|
||||
* | Request # | Page | Page size | Retrieved items |
|
||||
* |-----------|------|-----------|-----------------|
|
||||
* | 1 | 1 | 10 | 10 |
|
||||
* | 2 | 2 | 10 | 20 |
|
||||
* | 3 | 2 | 20 | 40 |
|
||||
* | 4 | 2 | 40 | 80 |
|
||||
* | 5 | 2 | 80 | 160 |
|
||||
* | 6 | 3 | 80 | 240 |
|
||||
* | 7 | 4 | 80 | 320 |
|
||||
* | 8 | 5 | 80 | 400 |
|
||||
* | 9 | 5 | 100 | 500 |
|
||||
* |-----------|------|-----------|-----------------|
|
||||
* ```
|
||||
* This algorithm means we can have the best of both worlds.
|
||||
* If there's a small number of changed pull requests since
|
||||
* our last update we'll do small requests that use minimal
|
||||
* bandwidth but if we encounter a repository where a lot
|
||||
* of PRs have changed since our last fetch (like a very
|
||||
* active repository or one we haven't fetched in a long time)
|
||||
* we'll spool up our page size in just a few requests and load
|
||||
* in bulk.
|
||||
*
|
||||
* As an example I used a very active internal repository and
|
||||
* asked for all PRs updated in the last 24 hours which was 320.
|
||||
* With the previous regime of fetching with a page size of 10
|
||||
* that obviously took 32 requests. With this new regime it
|
||||
* would take 7.
|
||||
*/
|
||||
export function getNextPagePathWithIncreasingPageSize(response: Response) {
|
||||
const nextPath = getNextPagePathFromLink(response)
|
||||
|
||||
if (!nextPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { pathname, query } = URL.parse(nextPath, true)
|
||||
const { per_page, page } = query
|
||||
|
||||
const pageSize = typeof per_page === 'string' ? parseInt(per_page, 10) : NaN
|
||||
const pageNumber = typeof page === 'string' ? parseInt(page, 10) : NaN
|
||||
|
||||
if (!pageSize || !pageNumber) {
|
||||
return nextPath
|
||||
}
|
||||
|
||||
// Confusing, but we're looking at the _next_ page path here
|
||||
// so the current is whatever came before it.
|
||||
const currentPage = pageNumber - 1
|
||||
|
||||
// Number of received items thus far
|
||||
const received = currentPage * pageSize
|
||||
|
||||
// Can't go above 100, that's the max the API will allow.
|
||||
const nextPageSize = Math.min(100, pageSize * 2)
|
||||
|
||||
// Have we received exactly the amount of items
|
||||
// such that doubling the page size and loading the
|
||||
// second page would seamlessly fit? No sense going
|
||||
// above 100 since that's the max the API supports
|
||||
if (pageSize !== nextPageSize && received % nextPageSize === 0) {
|
||||
query.per_page = `${nextPageSize}`
|
||||
query.page = `${received / nextPageSize + 1}`
|
||||
return URL.format({ pathname, query })
|
||||
}
|
||||
|
||||
return nextPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ISO 8601 time string with second resolution instead of
|
||||
* the standard javascript toISOString which returns millisecond
|
||||
|
@ -419,7 +573,7 @@ export class API {
|
|||
throw new Error(
|
||||
`Unable to create repository for organization '${
|
||||
org.login
|
||||
}'. Verify it exists and that you have permission to create a repository there.`
|
||||
}'. Verify that it exists, that it's a paid organization, and that you have permission to create a repository there.`
|
||||
)
|
||||
}
|
||||
throw e
|
||||
|
@ -461,18 +615,84 @@ export class API {
|
|||
}
|
||||
}
|
||||
|
||||
/** Fetch the pull requests in the given repository. */
|
||||
public async fetchPullRequests(
|
||||
/** Fetch all open pull requests in the given repository. */
|
||||
public async fetchAllOpenPullRequests(owner: string, name: string) {
|
||||
const url = urlWithQueryString(`repos/${owner}/${name}/pulls`, {
|
||||
state: 'open',
|
||||
})
|
||||
try {
|
||||
return await this.fetchAll<IAPIPullRequest>(url)
|
||||
} catch (e) {
|
||||
log.warn(`failed fetching open PRs for repository ${owner}/${name}`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all pull requests in the given repository that have been
|
||||
* updated on or after the provided date.
|
||||
*
|
||||
* Note: The GitHub API doesn't support providing a last-updated
|
||||
* limitation for PRs like it does for issues so we're emulating
|
||||
* the issues API by sorting PRs descending by last updated and
|
||||
* only grab as many pages as we need to until we no longer receive
|
||||
* PRs that have been update more recently than the `since`
|
||||
* parameter.
|
||||
*
|
||||
* If there's more than `maxResults` updated PRs since the last time
|
||||
* we fetched this method will throw an error such that we can abort
|
||||
* this strategy and commence loading all open PRs instead.
|
||||
*/
|
||||
public async fetchUpdatedPullRequests(
|
||||
owner: string,
|
||||
name: string,
|
||||
state: 'open' | 'closed' | 'all'
|
||||
): Promise<ReadonlyArray<IAPIPullRequest>> {
|
||||
const url = urlWithQueryString(`repos/${owner}/${name}/pulls`, { state })
|
||||
since: Date,
|
||||
// 320 is chosen because with a ramp-up page size starting with
|
||||
// a page size of 10 we'll reach 320 in exactly 7 pages. See
|
||||
// getNextPagePathWithIncreasingPageSize
|
||||
maxResults = 320
|
||||
) {
|
||||
const sinceTime = since.getTime()
|
||||
const url = urlWithQueryString(`repos/${owner}/${name}/pulls`, {
|
||||
state: 'all',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
try {
|
||||
const prs = await this.fetchAll<IAPIPullRequest>(url)
|
||||
return prs
|
||||
const prs = await this.fetchAll<IAPIPullRequest>(url, {
|
||||
// We use a page size smaller than our default 100 here because we
|
||||
// expect that the majority use case will return much less than
|
||||
// 100 results. Given that as long as _any_ PR has changed we'll
|
||||
// get the full list back (PRs doesn't support ?since=) we want
|
||||
// to keep this number fairly conservative in order to not use
|
||||
// up bandwidth needlessly while balancing it such that we don't
|
||||
// have to use a lot of requests to update our database. We then
|
||||
// ramp up the page size (see getNextPagePathWithIncreasingPageSize)
|
||||
// if it turns out there's a lot of updated PRs.
|
||||
perPage: 10,
|
||||
getNextPagePath: getNextPagePathWithIncreasingPageSize,
|
||||
continue(results) {
|
||||
if (results.length >= maxResults) {
|
||||
throw new MaxResultsError('got max pull requests, aborting')
|
||||
}
|
||||
|
||||
// Given that we sort the results in descending order by their
|
||||
// updated_at field we can safely say that if the last item
|
||||
// is modified after our sinceTime then haven't reached the
|
||||
// end of updated PRs.
|
||||
const last = results[results.length - 1]
|
||||
return last !== undefined && Date.parse(last.updated_at) > sinceTime
|
||||
},
|
||||
// We can't ignore errors here as that might mean that we haven't
|
||||
// retrieved enough pages to fully capture the changes since the
|
||||
// last time we updated. Ignoring errors here would mean that we'd
|
||||
// store an incorrect lastUpdated field in the database.
|
||||
suppressErrors: false,
|
||||
})
|
||||
return prs.filter(pr => Date.parse(pr.updated_at) >= sinceTime)
|
||||
} catch (e) {
|
||||
log.warn(`fetchPullRequests: failed for repository ${owner}/${name}`, e)
|
||||
log.warn(`failed fetching updated PRs for repository ${owner}/${name}`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
@ -493,6 +713,23 @@ export class API {
|
|||
return await parsedResponse<IAPIRefStatus>(response)
|
||||
}
|
||||
|
||||
public async fetchProtectedBranches(
|
||||
owner: string,
|
||||
name: string
|
||||
): Promise<ReadonlyArray<IAPIBranch>> {
|
||||
const path = `repos/${owner}/${name}/branches?protected=true`
|
||||
try {
|
||||
const response = await this.request('GET', path)
|
||||
return await parsedResponse<IAPIBranch[]>(response)
|
||||
} catch (err) {
|
||||
log.info(
|
||||
`[fetchProtectedBranches] unable to list protected branches`,
|
||||
err
|
||||
)
|
||||
return new Array<IAPIBranch>()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated requests to a paginating resource such as issues.
|
||||
*
|
||||
|
@ -500,31 +737,28 @@ export class API {
|
|||
* pages when available, buffers all items and returns them in
|
||||
* one array when done.
|
||||
*/
|
||||
private async fetchAll<T>(path: string): Promise<ReadonlyArray<T>> {
|
||||
private async fetchAll<T>(path: string, options?: IFetchAllOptions<T>) {
|
||||
const buf = new Array<T>()
|
||||
const opts: IFetchAllOptions<T> = { perPage: 100, ...options }
|
||||
const params = { per_page: `${opts.perPage}` }
|
||||
|
||||
const params = {
|
||||
per_page: '100',
|
||||
}
|
||||
let nextPath: string | null = urlWithQueryString(path, params)
|
||||
|
||||
do {
|
||||
const response = await this.request('GET', nextPath)
|
||||
if (response.status === HttpStatusCode.NotFound) {
|
||||
log.warn(`fetchAll: '${path}' returned a 404`)
|
||||
return []
|
||||
}
|
||||
if (response.status === HttpStatusCode.NotModified) {
|
||||
log.warn(`fetchAll: '${path}' returned a 304`)
|
||||
return []
|
||||
const response: Response = await this.request('GET', nextPath)
|
||||
if (opts.suppressErrors !== false && !response.ok) {
|
||||
log.warn(`fetchAll: '${path}' returned a ${response.status}`)
|
||||
return buf
|
||||
}
|
||||
|
||||
const items = await parsedResponse<ReadonlyArray<T>>(response)
|
||||
if (items) {
|
||||
buf.push(...items)
|
||||
}
|
||||
nextPath = getNextPagePath(response)
|
||||
} while (nextPath)
|
||||
|
||||
nextPath = opts.getNextPagePath
|
||||
? opts.getNextPagePath(response)
|
||||
: getNextPagePathFromLink(response)
|
||||
} while (nextPath && (!opts.continue || opts.continue(buf)))
|
||||
|
||||
return buf
|
||||
}
|
||||
|
@ -844,7 +1078,7 @@ export function getHTMLURL(endpoint: string): string {
|
|||
// In the case of GitHub.com, the HTML site lives on the parent domain.
|
||||
// E.g., https://api.github.com -> https://github.com
|
||||
//
|
||||
// Whereas with Enterprise, it lives on the same domain but without the
|
||||
// Whereas with Enterprise Server, it lives on the same domain but without the
|
||||
// API path:
|
||||
// E.g., https://github.mycompany.com/api/v3 -> https://github.mycompany.com
|
||||
//
|
||||
|
|
|
@ -4,7 +4,7 @@ import { IDiff, ImageDiffType } from '../models/diff'
|
|||
import { Repository, ILocalRepositoryState } from '../models/repository'
|
||||
import { Branch, IAheadBehind } from '../models/branch'
|
||||
import { Tip } from '../models/tip'
|
||||
import { Commit } from '../models/commit'
|
||||
import { Commit, CommitOneLine } from '../models/commit'
|
||||
import { CommittedFileChange, WorkingDirectoryStatus } from '../models/status'
|
||||
import { CloningRepository } from '../models/cloning-repository'
|
||||
import { IMenu } from '../models/app-menu'
|
||||
|
@ -35,6 +35,9 @@ import { ApplicationTheme } from '../ui/lib/application-theme'
|
|||
import { IAccountRepositories } from './stores/api-repositories-store'
|
||||
import { ManualConflictResolution } from '../models/manual-conflict-resolution'
|
||||
import { Banner } from '../models/banner'
|
||||
import { GitRebaseProgress } from '../models/rebase'
|
||||
import { RebaseFlowStep } from '../models/rebase-flow-step'
|
||||
import { IStashEntry } from '../models/stash-entry'
|
||||
|
||||
export enum SelectionType {
|
||||
Repository,
|
||||
|
@ -149,6 +152,9 @@ export interface IAppState {
|
|||
/** The width of the commit summary column in the history view */
|
||||
readonly commitSummaryWidth: number
|
||||
|
||||
/** The width of the files list in the stash view */
|
||||
readonly stashedFilesWidth: number
|
||||
|
||||
/** Whether we should hide the toolbar (and show inverted window controls) */
|
||||
readonly titleBarStyle: 'light' | 'dark'
|
||||
|
||||
|
@ -173,6 +179,8 @@ export interface IAppState {
|
|||
/** The external editor to use when opening repositories */
|
||||
readonly selectedExternalEditor?: ExternalEditor
|
||||
|
||||
/** The current setting for whether the user has disable usage reports */
|
||||
readonly optOutOfUsageTracking: boolean
|
||||
/**
|
||||
* A cached entry representing an external editor found on the user's machine:
|
||||
*
|
||||
|
@ -248,10 +256,20 @@ export type AppMenuFoldout = {
|
|||
openedWithAccessKey?: boolean
|
||||
}
|
||||
|
||||
export type BranchFoldout = {
|
||||
type: FoldoutType.Branch
|
||||
|
||||
/**
|
||||
* A flag to indicate the user clicked the "switch branch" link when they
|
||||
* saw the prompt about the current branch being protected.
|
||||
*/
|
||||
handleProtectedBranchWarning?: boolean
|
||||
}
|
||||
|
||||
export type Foldout =
|
||||
| { type: FoldoutType.Repository }
|
||||
| { type: FoldoutType.Branch }
|
||||
| { type: FoldoutType.AddMenu }
|
||||
| BranchFoldout
|
||||
| AppMenuFoldout
|
||||
|
||||
export enum RepositorySectionTab {
|
||||
|
@ -341,6 +359,8 @@ export interface IRepositoryState {
|
|||
|
||||
readonly branchesState: IBranchesState
|
||||
|
||||
readonly rebaseState: IRebaseState
|
||||
|
||||
/**
|
||||
* Mapping from lowercased email addresses to the associated GitHub user. Note
|
||||
* that an email address may not have an associated GitHub user, or the user
|
||||
|
@ -396,12 +416,6 @@ export interface IRepositoryState {
|
|||
* null if no such operation is in flight.
|
||||
*/
|
||||
readonly revertProgress: IRevertProgress | null
|
||||
|
||||
/** The current branch filter text. */
|
||||
readonly branchFilterText: string
|
||||
|
||||
/** The current pull request filter text. */
|
||||
readonly pullRequestFilterText: string
|
||||
}
|
||||
|
||||
export interface IBranchesState {
|
||||
|
@ -455,6 +469,39 @@ export interface IBranchesState {
|
|||
readonly rebasedBranches: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/** State associated with a rebase being performed on a repository */
|
||||
export interface IRebaseState {
|
||||
/**
|
||||
* The current step of the flow the user should see.
|
||||
*
|
||||
* `null` indicates that there is no rebase underway.
|
||||
*/
|
||||
readonly step: RebaseFlowStep | null
|
||||
|
||||
/**
|
||||
* The underlying Git information associated with the current rebase
|
||||
*
|
||||
* This will be set to `null` when no base branch has been selected to
|
||||
* initiate the rebase.
|
||||
*/
|
||||
readonly progress: GitRebaseProgress | null
|
||||
|
||||
/**
|
||||
* The known range of commits that will be applied to the repository
|
||||
*
|
||||
* This will be set to `null` when no base branch has been selected to
|
||||
* initiate the rebase.
|
||||
*/
|
||||
readonly commits: ReadonlyArray<CommitOneLine> | null
|
||||
|
||||
/**
|
||||
* Whether the user has done work to resolve any conflicts as part of this
|
||||
* rebase, as the rebase flow should confirm the user wishes to abort the
|
||||
* rebase and lose that work.
|
||||
*/
|
||||
readonly userHasResolvedConflicts: boolean
|
||||
}
|
||||
|
||||
export interface ICommitSelection {
|
||||
/** The commit currently selected in the app */
|
||||
readonly sha: string | null
|
||||
|
@ -469,16 +516,38 @@ export interface ICommitSelection {
|
|||
readonly diff: IDiff | null
|
||||
}
|
||||
|
||||
export interface IChangesState {
|
||||
readonly workingDirectory: WorkingDirectoryStatus
|
||||
export enum ChangesSelectionKind {
|
||||
WorkingDirectory = 'WorkingDirectory',
|
||||
Stash = 'Stash',
|
||||
}
|
||||
|
||||
export type ChangesWorkingDirectorySelection = {
|
||||
readonly kind: ChangesSelectionKind.WorkingDirectory
|
||||
|
||||
/**
|
||||
* The ID of the selected files. The files themselves can be looked up in
|
||||
* `workingDirectory`.
|
||||
* the `workingDirectory` property in `IChangesState`.
|
||||
*/
|
||||
readonly selectedFileIDs: string[]
|
||||
|
||||
readonly diff: IDiff | null
|
||||
}
|
||||
|
||||
export type ChangesStashSelection = {
|
||||
readonly kind: ChangesSelectionKind.Stash
|
||||
|
||||
/** Currently selected file in the stash diff viewer UI (aka the file we want to show the diff for) */
|
||||
readonly selectedStashedFile: CommittedFileChange | null
|
||||
|
||||
/** Currently selected file's diff */
|
||||
readonly selectedStashedFileDiff: IDiff | null
|
||||
}
|
||||
|
||||
export type ChangesSelection =
|
||||
| ChangesWorkingDirectorySelection
|
||||
| ChangesStashSelection
|
||||
|
||||
export interface IChangesState {
|
||||
readonly workingDirectory: WorkingDirectoryStatus
|
||||
|
||||
/** The commit message for a work-in-progress commit in the changes view. */
|
||||
readonly commitMessage: ICommitMessage
|
||||
|
@ -504,6 +573,23 @@ export interface IChangesState {
|
|||
* The absence of a value means there is no merge or rebase conflict underway
|
||||
*/
|
||||
readonly conflictState: ConflictState | null
|
||||
|
||||
/**
|
||||
* The latest GitHub Desktop stash entry for the current branch, or `null`
|
||||
* if no stash exists for the current branch.
|
||||
*/
|
||||
readonly stashEntry: IStashEntry | null
|
||||
|
||||
/**
|
||||
* The current selection state in the Changes view. Can be either
|
||||
* working directory or a stash. In the case of a working directory
|
||||
* selection multiple files may be selected. See `ChangesSelection`
|
||||
* for more information about the differences between the two.
|
||||
*/
|
||||
readonly selection: ChangesSelection
|
||||
|
||||
/** `true` if the GitHub API reports that the branch is protected */
|
||||
readonly currentBranchProtected: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import Dexie from 'dexie'
|
||||
import { APIRefState, IAPIRefStatusItem } from '../api'
|
||||
import { BaseDatabase } from './base-database'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { fatalError, forceUnwrap } from '../fatal-error'
|
||||
|
||||
export interface IPullRequestRef {
|
||||
/**
|
||||
* The database ID of the GitHub repository in which this ref lives. It could
|
||||
* be null if the repository was deleted on the site after the PR was opened.
|
||||
*/
|
||||
readonly repoId: number | null
|
||||
readonly repoId: number
|
||||
|
||||
/** The name of the ref. */
|
||||
readonly ref: string
|
||||
|
@ -17,12 +18,6 @@ export interface IPullRequestRef {
|
|||
}
|
||||
|
||||
export interface IPullRequest {
|
||||
/**
|
||||
* The database ID. This will be undefined if the pull request hasn't been
|
||||
* inserted into the DB.
|
||||
*/
|
||||
readonly id?: number
|
||||
|
||||
/** The GitHub PR number. */
|
||||
readonly number: number
|
||||
|
||||
|
@ -32,6 +27,9 @@ export interface IPullRequest {
|
|||
/** The string formatted date on which the PR was created. */
|
||||
readonly createdAt: string
|
||||
|
||||
/** The string formatted date on which the PR was created. */
|
||||
readonly updatedAt: string
|
||||
|
||||
/** The ref from which the pull request's changes are coming. */
|
||||
readonly head: IPullRequestRef
|
||||
|
||||
|
@ -42,35 +40,38 @@ export interface IPullRequest {
|
|||
readonly author: string
|
||||
}
|
||||
|
||||
export interface IPullRequestStatus {
|
||||
/**
|
||||
* Interface describing a record in the
|
||||
* pullRequestsLastUpdated table.
|
||||
*/
|
||||
interface IPullRequestsLastUpdated {
|
||||
/**
|
||||
* The database ID. This will be undefined if the status hasn't been inserted
|
||||
* into the DB.
|
||||
* The primary key. Corresponds to the
|
||||
* dbId property for the associated `GitHubRepository`
|
||||
* instance.
|
||||
*/
|
||||
readonly id?: number
|
||||
|
||||
/** The ID of the pull request in the database. */
|
||||
readonly pullRequestId: number
|
||||
|
||||
/** The status' state. */
|
||||
readonly state: APIRefState
|
||||
|
||||
/** The number of statuses represented in this combined status. */
|
||||
readonly totalCount: number
|
||||
|
||||
/** The SHA for which this status applies. */
|
||||
readonly sha: string
|
||||
readonly repoId: number
|
||||
|
||||
/**
|
||||
* The list of statuses for this specific ref or undefined
|
||||
* if the database object was created prior to status support
|
||||
* being added in #3588
|
||||
* The maximum value of the updated_at field on a
|
||||
* pull request that we've seen in milliseconds since
|
||||
* the epoch.
|
||||
*/
|
||||
readonly statuses?: ReadonlyArray<IAPIRefStatusItem>
|
||||
readonly lastUpdated: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull Requests are keyed on the ID of the GitHubRepository
|
||||
* that they belong to _and_ the PR number.
|
||||
*
|
||||
* Index 0 contains the GitHubRepository dbID and index 1
|
||||
* contains the PR number.
|
||||
*/
|
||||
export type PullRequestKey = [number, number]
|
||||
|
||||
export class PullRequestDatabase extends BaseDatabase {
|
||||
public pullRequests!: Dexie.Table<IPullRequest, number>
|
||||
public pullRequests!: Dexie.Table<IPullRequest, PullRequestKey>
|
||||
public pullRequestsLastUpdated!: Dexie.Table<IPullRequestsLastUpdated, number>
|
||||
|
||||
public constructor(name: string, schemaVersion?: number) {
|
||||
super(name, schemaVersion)
|
||||
|
@ -95,5 +96,165 @@ export class PullRequestDatabase extends BaseDatabase {
|
|||
|
||||
// Remove the pullRequestStatus table
|
||||
this.conditionalVersion(5, { pullRequestStatus: null })
|
||||
|
||||
// Delete pullRequestsTable in order to recreate it again
|
||||
// in version 7 with a new primary key
|
||||
this.conditionalVersion(6, { pullRequests: null })
|
||||
|
||||
// new primary key and a new table dedicated to keeping track
|
||||
// of the most recently updated PR we've seen.
|
||||
this.conditionalVersion(7, {
|
||||
pullRequests: '[base.repoId+number]',
|
||||
pullRequestsLastUpdated: 'repoId',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the pull requests associated with the given repository
|
||||
* from the database. Also clears the last updated date for that repository
|
||||
* if it exists.
|
||||
*/
|
||||
public async deleteAllPullRequestsInRepository(repository: GitHubRepository) {
|
||||
const dbId = forceUnwrap(
|
||||
"Can't delete PRs for repository, no dbId",
|
||||
repository.dbID
|
||||
)
|
||||
|
||||
await this.transaction(
|
||||
'rw',
|
||||
this.pullRequests,
|
||||
this.pullRequestsLastUpdated,
|
||||
async () => {
|
||||
await this.clearLastUpdated(repository)
|
||||
await this.pullRequests
|
||||
.where('[base.repoId+number]')
|
||||
.between([dbId], [dbId + 1])
|
||||
.delete()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the given pull requests from the database.
|
||||
*/
|
||||
public async deletePullRequests(keys: PullRequestKey[]) {
|
||||
// I believe this to be a bug in Dexie's type declarations.
|
||||
// It definitely supports passing an array of keys but the
|
||||
// type thinks that if it's an array it should be an array
|
||||
// of void which I believe to be a mistake. Therefore we
|
||||
// type it as any and hand it off to Dexie.
|
||||
await this.pullRequests.bulkDelete(keys as any)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the given pull requests, overwriting any existing records
|
||||
* in the process.
|
||||
*/
|
||||
public async putPullRequests(prs: IPullRequest[]) {
|
||||
await this.pullRequests.bulkPut(prs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all PRs for the given repository.
|
||||
*
|
||||
* Note: This method will throw if the GitHubRepository hasn't
|
||||
* yet been inserted into the database (i.e the dbID field is null).
|
||||
*/
|
||||
public getAllPullRequestsInRepository(repository: GitHubRepository) {
|
||||
if (repository.dbID === null) {
|
||||
return fatalError("Can't retrieve PRs for repository, no dbId")
|
||||
}
|
||||
|
||||
return this.pullRequests
|
||||
.where('[base.repoId+number]')
|
||||
.between([repository.dbID], [repository.dbID + 1])
|
||||
.toArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single pull requests for a particular repository
|
||||
*/
|
||||
public getPullRequest(repository: GitHubRepository, prNumber: number) {
|
||||
if (repository.dbID === null) {
|
||||
return fatalError("Can't retrieve PRs for repository with a null dbID")
|
||||
}
|
||||
|
||||
return this.pullRequests.get([repository.dbID, prNumber])
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a value indicating the most recently updated PR
|
||||
* that we've seen for a particular repository.
|
||||
*
|
||||
* Note:
|
||||
* This value might differ from max(updated_at) in the pullRequests
|
||||
* table since the most recently updated PR we saw might have
|
||||
* been closed and we only store open PRs in the pullRequests
|
||||
* table.
|
||||
*/
|
||||
public async getLastUpdated(repository: GitHubRepository) {
|
||||
if (repository.dbID === null) {
|
||||
return fatalError("Can't retrieve PRs for repository with a null dbID")
|
||||
}
|
||||
|
||||
const row = await this.pullRequestsLastUpdated.get(repository.dbID)
|
||||
|
||||
return row ? new Date(row.lastUpdated) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the stored date for the most recently updated PR seen for
|
||||
* a given repository.
|
||||
*/
|
||||
public async clearLastUpdated(repository: GitHubRepository) {
|
||||
if (repository.dbID === null) {
|
||||
throw new Error(
|
||||
"Can't clear last updated PR for repository with a null dbID"
|
||||
)
|
||||
}
|
||||
|
||||
await this.pullRequestsLastUpdated.delete(repository.dbID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value indicating the most recently updated PR
|
||||
* that we've seen for a particular repository.
|
||||
*
|
||||
* Note:
|
||||
* This value might differ from max(updated_at) in the pullRequests
|
||||
* table since the most recently updated PR we saw might have
|
||||
* been closed and we only store open PRs in the pullRequests
|
||||
* table.
|
||||
*/
|
||||
public async setLastUpdated(repository: GitHubRepository, lastUpdated: Date) {
|
||||
if (repository.dbID === null) {
|
||||
throw new Error("Can't set last updated for PR with a null dbID")
|
||||
}
|
||||
|
||||
await this.pullRequestsLastUpdated.put({
|
||||
repoId: repository.dbID,
|
||||
lastUpdated: lastUpdated.getTime(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pull request key from a GitHub repository and a PR number.
|
||||
*
|
||||
* This method is mainly a helper function to ensure we don't
|
||||
* accidentally swap the order of the repository id and the pr number
|
||||
* if we were to create the key array manually.
|
||||
*
|
||||
* @param repository The GitHub repository to which this PR belongs
|
||||
* @param prNumber The PR number as returned from the GitHub API
|
||||
*/
|
||||
export function getPullRequestKey(
|
||||
repository: GitHubRepository,
|
||||
prNumber: number
|
||||
) {
|
||||
const dbId = forceUnwrap(
|
||||
`Can get key for PR, repository not inserted in database.`,
|
||||
repository.dbID
|
||||
)
|
||||
return [dbId, prNumber] as PullRequestKey
|
||||
}
|
||||
|
|
|
@ -23,13 +23,33 @@ export interface IDatabaseGitHubRepository {
|
|||
readonly lastPruneDate: number | null
|
||||
}
|
||||
|
||||
/** A record to track the protected branch information for a GitHub repository */
|
||||
export interface IDatabaseProtectedBranch {
|
||||
readonly repoId: number
|
||||
/**
|
||||
* The branch name associated with the branch protection settings
|
||||
*
|
||||
* NOTE: this is NOT a fully-qualified ref (i.e. `refs/heads/master`)
|
||||
*/
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export interface IDatabaseRepository {
|
||||
readonly id?: number | null
|
||||
readonly gitHubRepositoryID: number | null
|
||||
readonly path: string
|
||||
readonly missing: boolean
|
||||
|
||||
/** The last time the stash entries were checked for the repository */
|
||||
readonly lastStashCheckDate: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Branches are keyed on the ID of the GitHubRepository that they belong to
|
||||
* and the short name of the branch.
|
||||
*/
|
||||
type BranchKey = [number, string]
|
||||
|
||||
/** The repositories database. */
|
||||
export class RepositoriesDatabase extends BaseDatabase {
|
||||
/** The local repositories table. */
|
||||
|
@ -38,6 +58,9 @@ export class RepositoriesDatabase extends BaseDatabase {
|
|||
/** The GitHub repositories table. */
|
||||
public gitHubRepositories!: Dexie.Table<IDatabaseGitHubRepository, number>
|
||||
|
||||
/** A table containing the names of protected branches per repository. */
|
||||
public protectedBranches!: Dexie.Table<IDatabaseProtectedBranch, BranchKey>
|
||||
|
||||
/** The GitHub repository owners table. */
|
||||
public owners!: Dexie.Table<IDatabaseOwner, number>
|
||||
|
||||
|
@ -74,6 +97,10 @@ export class RepositoriesDatabase extends BaseDatabase {
|
|||
this.conditionalVersion(5, {
|
||||
gitHubRepositories: '++id, name, &[ownerID+name], cloneURL',
|
||||
})
|
||||
|
||||
this.conditionalVersion(6, {
|
||||
protectedBranches: '[repoId+name], repoId',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,8 @@ import { assertNever } from '../fatal-error'
|
|||
|
||||
export enum ExternalEditor {
|
||||
Atom = 'Atom',
|
||||
AtomBeta = 'Atom Beta',
|
||||
AtomNightly = 'Atom Nightly',
|
||||
VisualStudioCode = 'Visual Studio Code',
|
||||
VisualStudioCodeInsiders = 'Visual Studio Code (Insiders)',
|
||||
SublimeText = 'Sublime Text',
|
||||
|
@ -27,6 +29,12 @@ export function parse(label: string): ExternalEditor | null {
|
|||
if (label === ExternalEditor.Atom) {
|
||||
return ExternalEditor.Atom
|
||||
}
|
||||
if (label === ExternalEditor.AtomBeta) {
|
||||
return ExternalEditor.AtomBeta
|
||||
}
|
||||
if (label === ExternalEditor.AtomNightly) {
|
||||
return ExternalEditor.AtomNightly
|
||||
}
|
||||
if (label === ExternalEditor.VisualStudioCode) {
|
||||
return ExternalEditor.VisualStudioCode
|
||||
}
|
||||
|
@ -69,6 +77,22 @@ function getRegistryKeys(
|
|||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\atom',
|
||||
},
|
||||
]
|
||||
case ExternalEditor.AtomBeta:
|
||||
return [
|
||||
{
|
||||
key: HKEY.HKEY_CURRENT_USER,
|
||||
subKey:
|
||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\atom-beta',
|
||||
},
|
||||
]
|
||||
case ExternalEditor.AtomNightly:
|
||||
return [
|
||||
{
|
||||
key: HKEY.HKEY_CURRENT_USER,
|
||||
subKey:
|
||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\atom-nightly',
|
||||
},
|
||||
]
|
||||
case ExternalEditor.VisualStudioCode:
|
||||
return [
|
||||
// 64-bit version of VSCode (user) - provided by default in 64-bit Windows
|
||||
|
@ -258,6 +282,10 @@ function getExecutableShim(
|
|||
switch (editor) {
|
||||
case ExternalEditor.Atom:
|
||||
return Path.join(installLocation, 'bin', 'atom.cmd') // remember, CMD must 'useShell'
|
||||
case ExternalEditor.AtomBeta:
|
||||
return Path.join(installLocation, 'bin', 'atom-beta.cmd') // remember, CMD must 'useShell'
|
||||
case ExternalEditor.AtomNightly:
|
||||
return Path.join(installLocation, 'bin', 'atom-nightly.cmd') // remember, CMD must 'useShell'
|
||||
case ExternalEditor.VisualStudioCode:
|
||||
return Path.join(installLocation, 'bin', 'code.cmd') // remember, CMD must 'useShell'
|
||||
case ExternalEditor.VisualStudioCodeInsiders:
|
||||
|
@ -292,6 +320,10 @@ function isExpectedInstallation(
|
|||
switch (editor) {
|
||||
case ExternalEditor.Atom:
|
||||
return displayName === 'Atom' && publisher === 'GitHub Inc.'
|
||||
case ExternalEditor.AtomBeta:
|
||||
return displayName === 'Atom Beta' && publisher === 'GitHub Inc.'
|
||||
case ExternalEditor.AtomNightly:
|
||||
return displayName === 'Atom Nightly' && publisher === 'GitHub Inc.'
|
||||
case ExternalEditor.VisualStudioCode:
|
||||
return (
|
||||
displayName.startsWith('Microsoft Visual Studio Code') &&
|
||||
|
@ -352,6 +384,20 @@ function extractApplicationInformation(
|
|||
return { displayName, publisher, installLocation }
|
||||
}
|
||||
|
||||
if (editor === ExternalEditor.AtomBeta) {
|
||||
const displayName = getKeyOrEmpty(keys, 'DisplayName')
|
||||
const publisher = getKeyOrEmpty(keys, 'Publisher')
|
||||
const installLocation = getKeyOrEmpty(keys, 'InstallLocation')
|
||||
return { displayName, publisher, installLocation }
|
||||
}
|
||||
|
||||
if (editor === ExternalEditor.AtomNightly) {
|
||||
const displayName = getKeyOrEmpty(keys, 'DisplayName')
|
||||
const publisher = getKeyOrEmpty(keys, 'Publisher')
|
||||
const installLocation = getKeyOrEmpty(keys, 'InstallLocation')
|
||||
return { displayName, publisher, installLocation }
|
||||
}
|
||||
|
||||
if (
|
||||
editor === ExternalEditor.VisualStudioCode ||
|
||||
editor === ExternalEditor.VisualStudioCodeInsiders
|
||||
|
@ -496,6 +542,8 @@ export async function getAvailableEditors(): Promise<
|
|||
|
||||
const [
|
||||
atomPath,
|
||||
atomBetaPath,
|
||||
atomNightlyPath,
|
||||
codePath,
|
||||
codeInsidersPath,
|
||||
sublimePath,
|
||||
|
@ -504,6 +552,8 @@ export async function getAvailableEditors(): Promise<
|
|||
slickeditPath,
|
||||
] = await Promise.all([
|
||||
findApplication(ExternalEditor.Atom),
|
||||
findApplication(ExternalEditor.AtomBeta),
|
||||
findApplication(ExternalEditor.AtomNightly),
|
||||
findApplication(ExternalEditor.VisualStudioCode),
|
||||
findApplication(ExternalEditor.VisualStudioCodeInsiders),
|
||||
findApplication(ExternalEditor.SublimeText),
|
||||
|
@ -520,6 +570,22 @@ export async function getAvailableEditors(): Promise<
|
|||
})
|
||||
}
|
||||
|
||||
if (atomBetaPath) {
|
||||
results.push({
|
||||
editor: ExternalEditor.AtomBeta,
|
||||
path: atomBetaPath,
|
||||
usesShell: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (atomNightlyPath) {
|
||||
results.push({
|
||||
editor: ExternalEditor.AtomNightly,
|
||||
path: atomNightlyPath,
|
||||
usesShell: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (codePath) {
|
||||
results.push({
|
||||
editor: ExternalEditor.VisualStudioCode,
|
||||
|
|
|
@ -40,7 +40,7 @@ export function lookupPreferredEmail(
|
|||
*/
|
||||
function isEmailPublic(email: IAPIEmail): boolean {
|
||||
// If an email doesn't have a visibility setting it means it's coming from an
|
||||
// older Enterprise server which doesn't have the concept of visiblity.
|
||||
// older Enterprise Server which doesn't have the concept of visiblity.
|
||||
return email.visibility === 'public' || !email.visibility
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* The oldest officially supported version of GitHub Enterprise.
|
||||
* The oldest officially supported version of GitHub Enterprise Server.
|
||||
* 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
|
||||
|
|
|
@ -37,18 +37,13 @@ export function enableRecurseSubmodulesFlag(): boolean {
|
|||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
/** Should the app set protocol.version=2 for any fetch/push/pull/clone operation? */
|
||||
export function enableGitProtocolVersionTwo(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
export function enableReadmeOverwriteWarning(): boolean {
|
||||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
/** Shoult the app automatically prune branches that are no longer actively being used */
|
||||
/** Should the app automatically prune branches that are no longer actively being used */
|
||||
export function enableBranchPruning(): boolean {
|
||||
return enableBetaFeatures()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -60,11 +55,6 @@ export function enableBranchPruning(): boolean {
|
|||
* just yet.
|
||||
*/
|
||||
export function enableNoChangesCreatePRBlankslateAction(): boolean {
|
||||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
/** Should the app detect and handle rebase conflicts when `pull.rebase` is set? */
|
||||
export function enablePullWithRebase(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -73,10 +63,41 @@ export function enablePullWithRebase(): boolean {
|
|||
* grouping and filtering (GitHub) repositories by owner/organization.
|
||||
*/
|
||||
export function enableGroupRepositoriesByOwner(): boolean {
|
||||
return enableBetaFeatures()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Should the app show the "rebase current branch" dialog? */
|
||||
export function enableRebaseDialog(): boolean {
|
||||
return enableDevelopmentFeatures()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Should the app show the "stash changes" dialog? */
|
||||
export function enableStashing(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the application query for branch protection information and store this
|
||||
* to help the maintainers understand how broadly branch protections are
|
||||
* encountered?
|
||||
*/
|
||||
export function enableBranchProtectionChecks(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Should the app detect Windows Subsystem for Linux as a valid shell? */
|
||||
export function enableWSLDetection(): boolean {
|
||||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the application warn the user when they are about to commit to a
|
||||
* protected branch, and encourage them into a flow to move their changes to
|
||||
* a new branch?
|
||||
*
|
||||
* As this builds upon existing branch protection features in the codebase, this
|
||||
* flag is linked to to `enableBranchProtectionChecks()`.
|
||||
*/
|
||||
export function enableBranchProtectionWarningFlow(): boolean {
|
||||
return enableBranchProtectionChecks() && enableDevelopmentFeatures()
|
||||
}
|
||||
|
|
|
@ -33,7 +33,7 @@ async function canAccessRepositoryUsingAPI(
|
|||
* @param urlOrRepositoryAlias - the URL or repository alias whose account
|
||||
* should be found
|
||||
* @param accounts - the list of active GitHub and GitHub Enterprise
|
||||
* accounts
|
||||
* Server accounts
|
||||
*/
|
||||
export async function findAccountForRemoteURL(
|
||||
urlOrRepositoryAlias: string,
|
||||
|
@ -74,11 +74,11 @@ export async function findAccountForRemoteURL(
|
|||
|
||||
// This chunk of code is designed to sort the user's accounts in this order:
|
||||
// - authenticated GitHub account
|
||||
// - GitHub Enterprise accounts
|
||||
// - GitHub Enterprise Server accounts
|
||||
// - unauthenticated GitHub account (access public repositories)
|
||||
//
|
||||
// As this needs to be done efficiently, we consider endpoints not matching
|
||||
// `getDotComAPIEndpoint()` to be GitHub Enterprise accounts, and accounts
|
||||
// `getDotComAPIEndpoint()` to be GitHub Enterprise Server accounts, and accounts
|
||||
// without a token to be unauthenticated.
|
||||
const sortedAccounts = Array.from(allAccounts).sort((a1, a2) => {
|
||||
if (a1.endpoint === getDotComAPIEndpoint()) {
|
||||
|
|
|
@ -3,9 +3,20 @@ type MergeOrPullConflictsErrorContext = {
|
|||
readonly kind: 'merge' | 'pull'
|
||||
/** The branch being merged into the current branch, "theirs" in Git terminology */
|
||||
readonly theirBranch: string
|
||||
|
||||
/** The branch associated with the current tip of the repository, "ours" in Git terminology */
|
||||
readonly currentBranch: string
|
||||
}
|
||||
|
||||
type CheckoutBranchErrorContext = {
|
||||
/** The Git operation that triggered the error */
|
||||
readonly kind: 'checkout'
|
||||
|
||||
/** The branch associated with the current tip of the repository, "ours" in Git terminology */
|
||||
readonly branchToCheckout: string
|
||||
}
|
||||
|
||||
/** A custom shape of data for actions to provide to help with error handling */
|
||||
export type GitErrorContext = MergeOrPullConflictsErrorContext
|
||||
export type GitErrorContext =
|
||||
| MergeOrPullConflictsErrorContext
|
||||
| CheckoutBranchErrorContext
|
||||
|
|
|
@ -6,18 +6,6 @@ import { IGitAccount } from '../../models/git-account'
|
|||
import { envForAuthentication } from './authentication'
|
||||
import { formatAsLocalRef } from './refs'
|
||||
|
||||
export interface IMergedBranch {
|
||||
/**
|
||||
* The canonical reference to the merged branch
|
||||
*/
|
||||
readonly canonicalRef: string
|
||||
|
||||
/**
|
||||
* The full-length Object ID (SHA) in HEX (32 chars)
|
||||
*/
|
||||
readonly sha: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new branch from the given start point.
|
||||
*
|
||||
|
@ -30,9 +18,10 @@ export interface IMergedBranch {
|
|||
export async function createBranch(
|
||||
repository: Repository,
|
||||
name: string,
|
||||
startPoint?: string
|
||||
startPoint: string | null
|
||||
): Promise<Branch | null> {
|
||||
const args = startPoint ? ['branch', name, startPoint] : ['branch', name]
|
||||
const args =
|
||||
startPoint !== null ? ['branch', name, startPoint] : ['branch', name]
|
||||
|
||||
try {
|
||||
await git(args, repository.path, 'createBranch')
|
||||
|
@ -183,11 +172,12 @@ export async function getBranchesPointedAt(
|
|||
*
|
||||
* @param repository The repository in which to search
|
||||
* @param branchName The to be used as the base branch
|
||||
* @returns map of branch canonical refs paired to its sha
|
||||
*/
|
||||
export async function getMergedBranches(
|
||||
repository: Repository,
|
||||
branchName: string
|
||||
): Promise<ReadonlyArray<IMergedBranch>> {
|
||||
): Promise<Map<string, string>> {
|
||||
const canonicalBranchRef = formatAsLocalRef(branchName)
|
||||
|
||||
const args = [
|
||||
|
@ -202,7 +192,7 @@ export async function getMergedBranches(
|
|||
|
||||
// Remove the trailing newline
|
||||
lines.splice(-1, 1)
|
||||
const mergedBranches = new Array<IMergedBranch>()
|
||||
const mergedBranches = new Map<string, string>()
|
||||
|
||||
for (const line of lines) {
|
||||
const [sha, canonicalRef] = line.split('\0')
|
||||
|
@ -217,7 +207,7 @@ export async function getMergedBranches(
|
|||
continue
|
||||
}
|
||||
|
||||
mergedBranches.push({ sha, canonicalRef })
|
||||
mergedBranches.set(canonicalRef, sha)
|
||||
}
|
||||
|
||||
return mergedBranches
|
||||
|
|
|
@ -61,7 +61,7 @@ export async function checkoutBranch(
|
|||
account: IGitAccount | null,
|
||||
branch: Branch,
|
||||
progressCallback?: ProgressCallback
|
||||
): Promise<void> {
|
||||
): Promise<true> {
|
||||
let opts: IGitExecutionOptions = {
|
||||
env: envForAuthentication(account),
|
||||
expectedErrors: AuthenticationErrors,
|
||||
|
@ -97,6 +97,9 @@ export async function checkoutBranch(
|
|||
)
|
||||
|
||||
await git(args, repository.path, 'checkoutBranch', opts)
|
||||
// we return `true` here so `GitStore.performFailableGitOperation`
|
||||
// will return _something_ differentiable from `undefined` if this succeeds
|
||||
return true
|
||||
}
|
||||
|
||||
/** Check out the paths at HEAD. */
|
||||
|
|
|
@ -7,7 +7,6 @@ import {
|
|||
|
||||
import { assertNever } from '../fatal-error'
|
||||
import { getDotComAPIEndpoint } from '../api'
|
||||
import { enableGitProtocolVersionTwo } from '../feature-flag'
|
||||
|
||||
import { IGitAccount } from '../../models/git-account'
|
||||
|
||||
|
@ -149,16 +148,18 @@ export async function git(
|
|||
}
|
||||
|
||||
// The caller should either handle this error, or expect that exit code.
|
||||
const errorMessage = []
|
||||
const errorMessage = new Array<string>()
|
||||
errorMessage.push(
|
||||
`\`git ${args.join(' ')}\` exited with an unexpected code: ${exitCode}.`
|
||||
)
|
||||
|
||||
if (result.stdout) {
|
||||
errorMessage.push('stdout:')
|
||||
errorMessage.push(result.stdout)
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
errorMessage.push('stderr:')
|
||||
errorMessage.push(result.stderr)
|
||||
}
|
||||
|
||||
|
@ -180,9 +181,16 @@ function getDescriptionForError(error: DugiteError): string {
|
|||
case DugiteError.SSHAuthenticationFailed:
|
||||
case DugiteError.SSHPermissionDenied:
|
||||
case DugiteError.HTTPSAuthenticationFailed:
|
||||
return `Authentication failed. You may not have permission to access the repository or the repository may have been archived. Open ${
|
||||
__DARWIN__ ? 'preferences' : 'options'
|
||||
} and verify that you're signed in with an account that has permission to access this repository.`
|
||||
const menuHint = __DARWIN__
|
||||
? 'GitHub Desktop > Preferences.'
|
||||
: 'File > Options.'
|
||||
return `Authentication failed. Some common reasons include:
|
||||
|
||||
- You are not logged in to your account: see ${menuHint}
|
||||
- You may need to log out and log back in to refresh your token.
|
||||
- You do not have permission to access this repository.
|
||||
- The repository is archived on GitHub. Check the repository settings to confirm you are still permitted to push commits.
|
||||
- If you use SSH authentication, check that your key is added to the ssh-agent and associated with your account.`
|
||||
case DugiteError.RemoteDisconnection:
|
||||
return 'The remote disconnected. Check your Internet connection and try again.'
|
||||
case DugiteError.HostDown:
|
||||
|
@ -267,7 +275,7 @@ function getDescriptionForError(error: DugiteError): string {
|
|||
case DugiteError.NoExistingRemoteBranch:
|
||||
return 'The remote branch does not exist.'
|
||||
case DugiteError.LocalChangesOverwritten:
|
||||
return 'Some of your changes would be overwritten.'
|
||||
return 'Unable to switch branches as there are working directory changes which would be overwritten. Please commit or stash your changes.'
|
||||
case DugiteError.UnresolvedConflicts:
|
||||
return 'There are unresolved conflicts in the working directory.'
|
||||
default:
|
||||
|
@ -306,10 +314,6 @@ export async function gitNetworkArguments(
|
|||
'credential.helper=',
|
||||
]
|
||||
|
||||
if (!enableGitProtocolVersionTwo()) {
|
||||
return baseArgs
|
||||
}
|
||||
|
||||
if (account === null) {
|
||||
return baseArgs
|
||||
}
|
||||
|
|
|
@ -179,11 +179,23 @@ export async function getChangedFiles(
|
|||
]
|
||||
const result = await git(args, repository.path, 'getChangedFiles')
|
||||
|
||||
const out = result.stdout
|
||||
const lines = out.split('\0')
|
||||
return parseChangedFiles(result.stdout, sha)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses git `log` or `diff` output into a list of changed files
|
||||
* (see `getChangedFiles` for an example of use)
|
||||
*
|
||||
* @param stdout raw ouput from a git `-z` and `--name-status` flags
|
||||
* @param committish commitish command was run against
|
||||
*/
|
||||
export function parseChangedFiles(
|
||||
stdout: string,
|
||||
committish: string
|
||||
): ReadonlyArray<CommittedFileChange> {
|
||||
const lines = stdout.split('\0')
|
||||
// Remove the trailing empty line
|
||||
lines.splice(-1, 1)
|
||||
|
||||
const files: CommittedFileChange[] = []
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const statusText = lines[i]
|
||||
|
@ -201,7 +213,7 @@ export async function getChangedFiles(
|
|||
|
||||
const path = lines[++i]
|
||||
|
||||
files.push(new CommittedFileChange(path, status, sha))
|
||||
files.push(new CommittedFileChange(path, status, committish))
|
||||
}
|
||||
|
||||
return files
|
||||
|
|
|
@ -9,10 +9,7 @@ import { IPullProgress } from '../../models/progress'
|
|||
import { IGitAccount } from '../../models/git-account'
|
||||
import { PullProgressParser, executionOptionsWithProgress } from '../progress'
|
||||
import { envForAuthentication, AuthenticationErrors } from './authentication'
|
||||
import {
|
||||
enableRecurseSubmodulesFlag,
|
||||
enablePullWithRebase,
|
||||
} from '../feature-flag'
|
||||
import { enableRecurseSubmodulesFlag } from '../feature-flag'
|
||||
|
||||
async function getPullArgs(
|
||||
repository: Repository,
|
||||
|
@ -24,10 +21,6 @@ async function getPullArgs(
|
|||
|
||||
const args = [...networkArguments, 'pull']
|
||||
|
||||
if (!enablePullWithRebase()) {
|
||||
args.push('--no-rebase')
|
||||
}
|
||||
|
||||
if (enableRecurseSubmodulesFlag()) {
|
||||
args.push('--recurse-submodules')
|
||||
}
|
||||
|
|
|
@ -6,9 +6,9 @@ import * as byline from 'byline'
|
|||
|
||||
import { Repository } from '../../models/repository'
|
||||
import {
|
||||
RebaseContext,
|
||||
RebaseInternalState,
|
||||
RebaseProgressOptions,
|
||||
RebaseProgressSummary,
|
||||
GitRebaseProgress,
|
||||
} from '../../models/rebase'
|
||||
import { IRebaseProgress } from '../../models/progress'
|
||||
import {
|
||||
|
@ -16,6 +16,7 @@ import {
|
|||
AppFileStatusKind,
|
||||
} from '../../models/status'
|
||||
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
|
||||
import { CommitOneLine } from '../../models/commit'
|
||||
|
||||
import { merge } from '../merge'
|
||||
import { formatRebaseValue } from '../rebase'
|
||||
|
@ -25,6 +26,38 @@ import { stageManualConflictResolution } from './stage'
|
|||
import { stageFiles } from './update-index'
|
||||
import { getStatus } from './status'
|
||||
import { getCommitsInRange } from './rev-list'
|
||||
import { Branch } from '../../models/branch'
|
||||
|
||||
/** The app-specific results from attempting to rebase a repository */
|
||||
export enum RebaseResult {
|
||||
/**
|
||||
* Git completed the rebase without reporting any errors, and the caller can
|
||||
* signal success to the user.
|
||||
*/
|
||||
CompletedWithoutError = 'CompletedWithoutError',
|
||||
/**
|
||||
* The rebase encountered conflicts while attempting to rebase, and these
|
||||
* need to be resolved by the user before the rebase can continue.
|
||||
*/
|
||||
ConflictsEncountered = 'ConflictsEncountered',
|
||||
/**
|
||||
* The rebase was not able to continue as tracked files were not staged in
|
||||
* the index.
|
||||
*/
|
||||
OutstandingFilesNotStaged = 'OutstandingFilesNotStaged',
|
||||
/**
|
||||
* The rebase was not attempted because it could not check the status of the
|
||||
* repository. The caller needs to confirm the repository is in a usable
|
||||
* state.
|
||||
*/
|
||||
Aborted = 'Aborted',
|
||||
/**
|
||||
* An unexpected error as part of the rebase flow was caught and handled.
|
||||
*
|
||||
* Check the logs to find the relevant Git details.
|
||||
*/
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the `.git/REBASE_HEAD` file exists in a repository to confirm
|
||||
|
@ -36,16 +69,16 @@ function isRebaseHeadSet(repository: Repository) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Detect and build up the context about the rebase being performed on a
|
||||
* repository. This information is required to help Desktop display information
|
||||
* to the user about the current action as well as the options available.
|
||||
* Get the internal state about the rebase being performed on a repository. This
|
||||
* information is required to help Desktop display information to the user
|
||||
* about the current action as well as the options available.
|
||||
*
|
||||
* Returns `null` if no rebase is detected, or if the expected information
|
||||
* cannot be found in the repository.
|
||||
*/
|
||||
export async function getRebaseContext(
|
||||
export async function getRebaseInternalState(
|
||||
repository: Repository
|
||||
): Promise<RebaseContext | null> {
|
||||
): Promise<RebaseInternalState | null> {
|
||||
const isRebase = await isRebaseHeadSet(repository)
|
||||
|
||||
if (!isRebase) {
|
||||
|
@ -95,18 +128,22 @@ export async function getRebaseContext(
|
|||
}
|
||||
|
||||
/**
|
||||
* Inspect the `.git/rebase-apply` folder and convert the current context into
|
||||
* a progress summary that can be passed into the rebase flow to hydrate the
|
||||
* component state.
|
||||
* Inspect the `.git/rebase-apply` folder and convert the current rebase state
|
||||
* into data that can be provided to the rebase flow to update the application
|
||||
* state.
|
||||
*
|
||||
* This is required when Desktop is not responsible for initiating the rebase:
|
||||
*
|
||||
* - when a rebase outside Desktop encounters conflicts
|
||||
* - when a `git pull --rebase` was run and encounters conflicts
|
||||
*
|
||||
*/
|
||||
export async function getCurrentProgress(
|
||||
export async function getRebaseSnapshot(
|
||||
repository: Repository
|
||||
): Promise<RebaseProgressSummary | null> {
|
||||
): Promise<{
|
||||
progress: GitRebaseProgress
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
} | null> {
|
||||
const rebaseHead = await isRebaseHeadSet(repository)
|
||||
if (!rebaseHead) {
|
||||
return null
|
||||
|
@ -182,13 +219,29 @@ export async function getCurrentProgress(
|
|||
originalBranchTip
|
||||
)
|
||||
|
||||
if (commits.length === 0) {
|
||||
if (commits === null || commits.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// this number starts from 1, but our array of commits starts from 0
|
||||
const nextCommitIndex = next - 1
|
||||
|
||||
const hasValidCommit =
|
||||
commits.length > 0 &&
|
||||
nextCommitIndex >= 0 &&
|
||||
nextCommitIndex <= commits.length
|
||||
|
||||
const currentCommitSummary = hasValidCommit
|
||||
? commits[nextCommitIndex].summary
|
||||
: null
|
||||
|
||||
return {
|
||||
rebasedCommitCount: next,
|
||||
value,
|
||||
progress: {
|
||||
value,
|
||||
rebasedCommitCount: next,
|
||||
totalCommitCount: last,
|
||||
currentCommitSummary,
|
||||
},
|
||||
commits,
|
||||
}
|
||||
}
|
||||
|
@ -234,12 +287,17 @@ class GitRebaseParser {
|
|||
return null
|
||||
}
|
||||
|
||||
const commitSummary = match[1]
|
||||
const currentCommitSummary = match[1]
|
||||
this.rebasedCommitCount++
|
||||
|
||||
const progress = this.rebasedCommitCount / this.totalCommitCount
|
||||
const value = formatRebaseValue(progress)
|
||||
|
||||
// TODO: dig into why we sometimes get an extra progress event reported
|
||||
if (this.rebasedCommitCount > this.totalCommitCount) {
|
||||
this.rebasedCommitCount = this.totalCommitCount
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'rebase',
|
||||
title: `Rebasing commit ${this.rebasedCommitCount} of ${
|
||||
|
@ -248,7 +306,7 @@ class GitRebaseParser {
|
|||
value,
|
||||
rebasedCommitCount: this.rebasedCommitCount,
|
||||
totalCommitCount: this.totalCommitCount,
|
||||
commitSummary,
|
||||
currentCommitSummary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -293,19 +351,38 @@ function configureOptionsForRebase(
|
|||
*/
|
||||
export async function rebase(
|
||||
repository: Repository,
|
||||
baseBranch: string,
|
||||
targetBranch: string,
|
||||
progress?: RebaseProgressOptions
|
||||
baseBranch: Branch,
|
||||
targetBranch: Branch,
|
||||
progressCallback?: (progress: IRebaseProgress) => void
|
||||
): Promise<RebaseResult> {
|
||||
const options = configureOptionsForRebase(
|
||||
{
|
||||
expectedErrors: new Set([GitError.RebaseConflicts]),
|
||||
},
|
||||
progress
|
||||
)
|
||||
const baseOptions: IGitExecutionOptions = {
|
||||
expectedErrors: new Set([GitError.RebaseConflicts]),
|
||||
}
|
||||
|
||||
let options = baseOptions
|
||||
|
||||
if (progressCallback !== undefined) {
|
||||
const commits = await getCommitsInRange(
|
||||
repository,
|
||||
baseBranch.tip.sha,
|
||||
targetBranch.tip.sha
|
||||
)
|
||||
|
||||
if (commits === null) {
|
||||
return RebaseResult.Error
|
||||
}
|
||||
|
||||
const totalCommitCount = commits.length
|
||||
|
||||
options = configureOptionsForRebase(baseOptions, {
|
||||
rebasedCommitCount: 0,
|
||||
totalCommitCount,
|
||||
progressCallback,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await git(
|
||||
['rebase', baseBranch, targetBranch],
|
||||
['rebase', baseBranch.name, targetBranch.name],
|
||||
repository.path,
|
||||
'rebase',
|
||||
options
|
||||
|
@ -319,37 +396,6 @@ export async function abortRebase(repository: Repository) {
|
|||
await git(['rebase', '--abort'], repository.path, 'abortRebase')
|
||||
}
|
||||
|
||||
/** The app-specific results from attempting to rebase a repository */
|
||||
export enum RebaseResult {
|
||||
/**
|
||||
* Git completed the rebase without reporting any errors, and the caller can
|
||||
* signal success to the user.
|
||||
*/
|
||||
CompletedWithoutError = 'CompletedWithoutError',
|
||||
/**
|
||||
* The rebase encountered conflicts while attempting to rebase, and these
|
||||
* need to be resolved by the user before the rebase can continue.
|
||||
*/
|
||||
ConflictsEncountered = 'ConflictsEncountered',
|
||||
/**
|
||||
* The rebase was not able to continue as tracked files were not staged in
|
||||
* the index.
|
||||
*/
|
||||
OutstandingFilesNotStaged = 'OutstandingFilesNotStaged',
|
||||
/**
|
||||
* The rebase was not attempted because it could not check the status of the
|
||||
* repository. The caller needs to confirm the repository is in a usable
|
||||
* state.
|
||||
*/
|
||||
Aborted = 'Aborted',
|
||||
/**
|
||||
* An unexpected error as part of the rebase flow was caught and handled.
|
||||
*
|
||||
* Check the logs to find the relevant Git details.
|
||||
*/
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
function parseRebaseResult(result: IGitResult): RebaseResult {
|
||||
if (result.exitCode === 0) {
|
||||
return RebaseResult.CompletedWithoutError
|
||||
|
@ -378,7 +424,7 @@ export async function continueRebase(
|
|||
repository: Repository,
|
||||
files: ReadonlyArray<WorkingDirectoryFileChange>,
|
||||
manualResolutions: ReadonlyMap<string, ManualConflictResolution> = new Map(),
|
||||
progress?: RebaseProgressOptions
|
||||
progressCallback?: (progress: IRebaseProgress) => void
|
||||
): Promise<RebaseResult> {
|
||||
const trackedFiles = files.filter(f => {
|
||||
return f.status.kind !== AppFileStatusKind.Untracked
|
||||
|
@ -391,7 +437,7 @@ export async function continueRebase(
|
|||
await stageManualConflictResolution(repository, file, resolution)
|
||||
} else {
|
||||
log.error(
|
||||
`couldn't find file ${path} even though there's a manual resolution for it`
|
||||
`[continueRebase] couldn't find file ${path} even though there's a manual resolution for it`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -401,10 +447,9 @@ export async function continueRebase(
|
|||
await stageFiles(repository, otherFiles)
|
||||
|
||||
const status = await getStatus(repository)
|
||||
|
||||
if (status == null) {
|
||||
log.warn(
|
||||
`[rebase] unable to get status after staging changes, skipping any other steps`
|
||||
`[continueRebase] unable to get status after staging changes, skipping any other steps`
|
||||
)
|
||||
return RebaseResult.Aborted
|
||||
}
|
||||
|
@ -418,15 +463,34 @@ export async function continueRebase(
|
|||
f => f.status.kind !== AppFileStatusKind.Untracked
|
||||
)
|
||||
|
||||
const options = configureOptionsForRebase(
|
||||
{
|
||||
expectedErrors: new Set([
|
||||
GitError.RebaseConflicts,
|
||||
GitError.UnresolvedConflicts,
|
||||
]),
|
||||
},
|
||||
progress
|
||||
)
|
||||
const baseOptions: IGitExecutionOptions = {
|
||||
expectedErrors: new Set([
|
||||
GitError.RebaseConflicts,
|
||||
GitError.UnresolvedConflicts,
|
||||
]),
|
||||
}
|
||||
|
||||
let options = baseOptions
|
||||
|
||||
if (progressCallback !== undefined) {
|
||||
const snapshot = await getRebaseSnapshot(repository)
|
||||
|
||||
if (snapshot === null) {
|
||||
log.warn(
|
||||
`[continueRebase] unable to get rebase status, skipping any other steps`
|
||||
)
|
||||
return RebaseResult.Aborted
|
||||
}
|
||||
|
||||
const { progress } = snapshot
|
||||
const { rebasedCommitCount, totalCommitCount } = progress
|
||||
|
||||
options = configureOptionsForRebase(baseOptions, {
|
||||
rebasedCommitCount,
|
||||
totalCommitCount,
|
||||
progressCallback,
|
||||
})
|
||||
}
|
||||
|
||||
if (trackedFilesAfter.length === 0) {
|
||||
log.warn(
|
||||
|
|
|
@ -69,10 +69,10 @@ export async function getRecentBranches(
|
|||
* Returns a map keyed on branch names
|
||||
*
|
||||
* @param repository the repository who's reflog you want to check
|
||||
* @param afterDate the minimum date a checkout has to occur
|
||||
* @param afterDate filters checkouts so that only those occuring on or after this date are returned
|
||||
* @returns map of branch name -> checkout date
|
||||
*/
|
||||
export async function getCheckoutsAfterDate(
|
||||
export async function getBranchCheckouts(
|
||||
repository: Repository,
|
||||
afterDate: Date
|
||||
): Promise<Map<string, Date>> {
|
||||
|
|
|
@ -99,12 +99,15 @@ export async function getBranchAheadBehind(
|
|||
*
|
||||
* This emulates how `git rebase` initially determines what will be applied to
|
||||
* the repository.
|
||||
*
|
||||
* Returns `null` when the rebase is not possible to perform, because of a
|
||||
* missing commit ID
|
||||
*/
|
||||
export async function getCommitsInRange(
|
||||
repository: Repository,
|
||||
baseBranchSha: string,
|
||||
targetBranchSha: string
|
||||
): Promise<ReadonlyArray<CommitOneLine>> {
|
||||
): Promise<ReadonlyArray<CommitOneLine> | null> {
|
||||
const range = revRange(baseBranchSha, targetBranchSha)
|
||||
|
||||
const args = [
|
||||
|
@ -118,7 +121,21 @@ export async function getCommitsInRange(
|
|||
'--',
|
||||
]
|
||||
|
||||
const result = await git(args, repository.path, 'getCommitsInRange')
|
||||
const options = {
|
||||
expectedErrors: new Set<GitError>([GitError.BadRevision]),
|
||||
}
|
||||
|
||||
const result = await git(args, repository.path, 'getCommitsInRange', options)
|
||||
|
||||
if (result.gitError === GitError.BadRevision) {
|
||||
// BadRevision can be raised here if git rev-list is unable to resolve a ref
|
||||
// to a commit ID, so we need to signal to the caller that this rebase is
|
||||
// not possible to perform
|
||||
log.warn(
|
||||
'Unable to rebase these branches because one or both of the refs do not exist in the repository'
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const lines = result.stdout.split('\n')
|
||||
|
||||
|
|
|
@ -1,84 +1,275 @@
|
|||
import { git } from '.'
|
||||
import { GitError as DugiteError } from 'dugite'
|
||||
|
||||
import { Repository } from '../../models/repository'
|
||||
import {
|
||||
IStashEntry,
|
||||
StashedChangesLoadStates,
|
||||
StashedFileChanges,
|
||||
} from '../../models/stash-entry'
|
||||
import { CommittedFileChange } from '../../models/status'
|
||||
|
||||
import { git, GitError } from './core'
|
||||
import { parseChangedFiles } from './log'
|
||||
|
||||
export const DesktopStashEntryMarker = '!!GitHub_Desktop'
|
||||
|
||||
export interface IStashEntry {
|
||||
/** The name of the branch at the time the entry was created. */
|
||||
readonly branchName: string
|
||||
|
||||
/** The SHA of the commit object created as a result of stashing. */
|
||||
readonly stashSha: string
|
||||
}
|
||||
|
||||
/** RegEx for parsing out the stash SHA and message */
|
||||
const stashEntryRe = /^([0-9a-f]{40})@(.+)$/
|
||||
|
||||
/**
|
||||
* RegEx for determining if a stash entry is created by Desktop
|
||||
*
|
||||
* This is done by looking for a magic string with the following
|
||||
* format: `!!GitHub_Desktop<branch@commit>`
|
||||
* format: `!!GitHub_Desktop<branch>`
|
||||
*/
|
||||
const stashEntryMessageRe = /^!!GitHub_Desktop<(.+)@([0-9|a-z|A-Z]{40})>$/
|
||||
const desktopStashEntryMessageRe = /!!GitHub_Desktop<(.+)>$/
|
||||
|
||||
type StashResult = {
|
||||
/** The stash entries created by Desktop */
|
||||
readonly desktopEntries: ReadonlyArray<IStashEntry>
|
||||
|
||||
/**
|
||||
* The total amount of stash entries,
|
||||
* i.e. stash entries created both by Desktop and outside of Desktop
|
||||
*/
|
||||
readonly stashEntryCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of stash entries created by Desktop in the current repository
|
||||
* using the default ordering of refs (which is LIFO ordering),
|
||||
* as well as the total amount of stash entries.
|
||||
*/
|
||||
export async function getDesktopStashEntries(
|
||||
repository: Repository
|
||||
): Promise<ReadonlyArray<IStashEntry>> {
|
||||
const prettyFormat = '%H@%gs'
|
||||
export async function getStashes(repository: Repository): Promise<StashResult> {
|
||||
const delimiter = '1F'
|
||||
const delimiterString = String.fromCharCode(parseInt(delimiter, 16))
|
||||
const format = ['%gd', '%H', '%gs'].join(`%x${delimiter}`)
|
||||
|
||||
const result = await git(
|
||||
['log', '-g', 'refs/stash', `--pretty=${prettyFormat}`],
|
||||
['log', '-g', '-z', `--pretty=${format}`, 'refs/stash'],
|
||||
repository.path,
|
||||
'getStashEntries'
|
||||
'getStashEntries',
|
||||
{
|
||||
successExitCodes: new Set([0, 128]),
|
||||
}
|
||||
)
|
||||
|
||||
if (result.stderr !== '') {
|
||||
//don't really care what the error is right now, but will once dugite is updated
|
||||
throw new Error(result.stderr)
|
||||
// There's no refs/stashes reflog in the repository or it's not
|
||||
// even a repository. In either case we don't care
|
||||
if (result.exitCode === 128) {
|
||||
return { desktopEntries: [], stashEntryCount: 0 }
|
||||
}
|
||||
|
||||
const out = result.stdout
|
||||
const lines = out.split('\n')
|
||||
const desktopStashEntries: Array<IStashEntry> = []
|
||||
const files: StashedFileChanges = {
|
||||
kind: StashedChangesLoadStates.NotLoaded,
|
||||
}
|
||||
|
||||
const stashEntries: Array<IStashEntry> = []
|
||||
for (const line of lines) {
|
||||
const match = stashEntryRe.exec(line)
|
||||
const entries = result.stdout.split('\0').filter(s => s !== '')
|
||||
for (const entry of entries) {
|
||||
const pieces = entry.split(delimiterString)
|
||||
|
||||
if (match == null) {
|
||||
continue
|
||||
if (pieces.length === 3) {
|
||||
const [name, stashSha, message] = pieces
|
||||
const branchName = extractBranchFromMessage(message)
|
||||
|
||||
if (branchName !== null) {
|
||||
desktopStashEntries.push({
|
||||
name,
|
||||
branchName,
|
||||
stashSha,
|
||||
files,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
desktopEntries: desktopStashEntries,
|
||||
stashEntryCount: entries.length - 1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last Desktop created stash entry for the given branch
|
||||
*/
|
||||
export async function getLastDesktopStashEntryForBranch(
|
||||
repository: Repository,
|
||||
branchName: string
|
||||
) {
|
||||
const stash = await getStashes(repository)
|
||||
|
||||
// Since stash objects are returned in a LIFO manner, the first
|
||||
// entry found is guaranteed to be the last entry created
|
||||
return (
|
||||
stash.desktopEntries.find(stash => stash.branchName === branchName) || null
|
||||
)
|
||||
}
|
||||
|
||||
/** Creates a stash entry message that idicates the entry was created by Desktop */
|
||||
export function createDesktopStashMessage(branchName: string) {
|
||||
return `${DesktopStashEntryMarker}<${branchName}>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash the working directory changes for the current branch
|
||||
*/
|
||||
export async function createDesktopStashEntry(
|
||||
repository: Repository,
|
||||
branchName: string
|
||||
): Promise<true> {
|
||||
const message = createDesktopStashMessage(branchName)
|
||||
const args = ['stash', 'push', '--include-untracked', '-m', message]
|
||||
|
||||
const result = await git(args, repository.path, 'createStashEntry', {
|
||||
successExitCodes: new Set<number>([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 message = match[2]
|
||||
const branchName = extractBranchFromMessage(message)
|
||||
// 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
|
||||
|
||||
// if branch name is null, the stash entry isn't using our magic string
|
||||
if (branchName === null) {
|
||||
continue
|
||||
}
|
||||
log.info(
|
||||
`[createDesktopStashEntry] a stash was created successfully but exit code ${
|
||||
result.exitCode
|
||||
} reported. stderr: ${result.stderr}`
|
||||
)
|
||||
}
|
||||
|
||||
stashEntries.push({
|
||||
branchName: branchName,
|
||||
stashSha: match[1],
|
||||
return true
|
||||
}
|
||||
|
||||
async function getStashEntryMatchingSha(repository: Repository, sha: string) {
|
||||
const stash = await getStashes(repository)
|
||||
return stash.desktopEntries.find(e => e.stashSha === sha) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the given stash entry if it exists
|
||||
*
|
||||
* @param stashSha the SHA that identifies the stash entry
|
||||
*/
|
||||
export async function dropDesktopStashEntry(
|
||||
repository: Repository,
|
||||
stashSha: string
|
||||
) {
|
||||
const entryToDelete = await getStashEntryMatchingSha(repository, stashSha)
|
||||
|
||||
if (entryToDelete !== null) {
|
||||
const args = ['stash', 'drop', entryToDelete.name]
|
||||
await git(args, repository.path, 'dropStashEntry')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pops the stash entry identified by matching `stashSha` to its commit hash.
|
||||
*
|
||||
* To see the commit hash of stash entry, run
|
||||
* `git log -g refs/stash --pretty="%nentry: %gd%nsubject: %gs%nhash: %H%n"`
|
||||
* in a repo with some stash entries.
|
||||
*/
|
||||
export async function popStashEntry(
|
||||
repository: Repository,
|
||||
stashSha: string
|
||||
): Promise<void> {
|
||||
// ignoring these git errors for now, this will change when we start
|
||||
// implementing the stash conflict flow
|
||||
const expectedErrors = new Set<DugiteError>([DugiteError.MergeConflicts])
|
||||
const successExitCodes = new Set<number>([0, 1])
|
||||
const stashToPop = await getStashEntryMatchingSha(repository, stashSha)
|
||||
|
||||
if (stashToPop !== null) {
|
||||
const args = ['stash', 'pop', '--quiet', `${stashToPop.name}`]
|
||||
const result = await git(args, repository.path, 'popStashEntry', {
|
||||
expectedErrors,
|
||||
successExitCodes,
|
||||
})
|
||||
}
|
||||
|
||||
return stashEntries
|
||||
// popping a stashes that create conflicts in the working directory
|
||||
// report an exit code of `1` and are not dropped after being applied.
|
||||
// so, we check for this case and drop them manually
|
||||
if (result.exitCode === 1) {
|
||||
if (result.stderr.length > 0) {
|
||||
// rethrow, because anything in stderr should prevent the stash from being popped
|
||||
throw new GitError(result, args)
|
||||
}
|
||||
|
||||
log.info(
|
||||
`[popStashEntry] a stash was popped successfully but exit code ${
|
||||
result.exitCode
|
||||
} reported.`
|
||||
)
|
||||
// bye bye
|
||||
await dropDesktopStashEntry(repository, stashSha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractBranchFromMessage(message: string): string | null {
|
||||
const [, desktopMessage] = message.split(':').map(s => s.trim())
|
||||
const match = stashEntryMessageRe.exec(desktopMessage)
|
||||
if (match === null) {
|
||||
return null
|
||||
const match = desktopStashEntryMessageRe.exec(message)
|
||||
return match === null || match[1].length === 0 ? null : match[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the files that were changed in the given stash commit.
|
||||
*
|
||||
* This is different than `getChangedFiles` because stashes
|
||||
* have _3 parents(!!!)_
|
||||
*/
|
||||
export async function getStashedFiles(
|
||||
repository: Repository,
|
||||
stashSha: string
|
||||
): Promise<ReadonlyArray<CommittedFileChange>> {
|
||||
const [trackedFiles, untrackedFiles] = await Promise.all([
|
||||
getChangedFilesWithinStash(repository, stashSha),
|
||||
getChangedFilesWithinStash(repository, `${stashSha}^3`),
|
||||
])
|
||||
|
||||
const files = new Map<string, CommittedFileChange>()
|
||||
trackedFiles.forEach(x => files.set(x.path, x))
|
||||
untrackedFiles.forEach(x => files.set(x.path, x))
|
||||
return [...files.values()].sort((x, y) => x.path.localeCompare(y.path))
|
||||
}
|
||||
|
||||
/**
|
||||
* Same thing as `getChangedFiles` but with extra handling for 128 exit code
|
||||
* (which happens if the commit's parent is not valid)
|
||||
*
|
||||
* **TODO:** merge this with `getChangedFiles` in `log.ts`
|
||||
*/
|
||||
async function getChangedFilesWithinStash(repository: Repository, sha: string) {
|
||||
// opt-in for rename detection (-M) and copies detection (-C)
|
||||
// this is equivalent to the user configuring 'diff.renames' to 'copies'
|
||||
// NOTE: order here matters - doing -M before -C means copies aren't detected
|
||||
const args = [
|
||||
'log',
|
||||
sha,
|
||||
'-C',
|
||||
'-M',
|
||||
'-m',
|
||||
'-1',
|
||||
'--no-show-signature',
|
||||
'--first-parent',
|
||||
'--name-status',
|
||||
'--format=format:',
|
||||
'-z',
|
||||
'--',
|
||||
]
|
||||
const result = await git(args, repository.path, 'getChangedFilesForStash', {
|
||||
// if this fails, its most likely
|
||||
// because there weren't any untracked files,
|
||||
// and that's okay!
|
||||
successExitCodes: new Set([0, 128]),
|
||||
})
|
||||
if (result.exitCode === 0 && result.stdout.length > 0) {
|
||||
return parseChangedFiles(result.stdout, sha)
|
||||
}
|
||||
|
||||
const branchName = match[1]
|
||||
return branchName.length > 0 ? branchName : null
|
||||
}
|
||||
|
||||
export function createStashMessage(branchName: string, tipSha: string) {
|
||||
return `${DesktopStashEntryMarker}<${branchName}@${tipSha}>`
|
||||
return []
|
||||
}
|
||||
|
|
|
@ -25,9 +25,8 @@ import { IAheadBehind } from '../../models/branch'
|
|||
import { fatalError } from '../../lib/fatal-error'
|
||||
import { isMergeHeadSet } from './merge'
|
||||
import { getBinaryPaths } from './diff'
|
||||
import { getRebaseContext } from './rebase'
|
||||
import { enablePullWithRebase } from '../feature-flag'
|
||||
import { RebaseContext } from '../../models/rebase'
|
||||
import { getRebaseInternalState } from './rebase'
|
||||
import { RebaseInternalState } from '../../models/rebase'
|
||||
|
||||
/**
|
||||
* V8 has a limit on the size of string it can create (~256MB), and unless we want to
|
||||
|
@ -60,7 +59,7 @@ export interface IStatusResult {
|
|||
readonly mergeHeadFound: boolean
|
||||
|
||||
/** details about the rebase operation, if found */
|
||||
readonly rebaseContext: RebaseContext | null
|
||||
readonly rebaseInternalState: RebaseInternalState | null
|
||||
|
||||
/** the absolute path to the repository's working directory */
|
||||
readonly workingDirectory: WorkingDirectoryStatus
|
||||
|
@ -150,6 +149,10 @@ function convertToAppStatus(
|
|||
return fatalError(`Unknown file status ${status}`)
|
||||
}
|
||||
|
||||
// List of known conflicted index entries for a file, extracted from mapStatus
|
||||
// inside `app/src/lib/status-parser.ts` for convenience
|
||||
const conflictStatusCodes = ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']
|
||||
|
||||
/**
|
||||
* Retrieve the status for a given repository,
|
||||
* and fail gracefully if the location is not a Git repository
|
||||
|
@ -196,20 +199,18 @@ export async function getStatus(
|
|||
const headers = parsed.filter(isStatusHeader)
|
||||
const entries = parsed.filter(isStatusEntry)
|
||||
|
||||
let conflictDetails: ConflictFilesDetails
|
||||
|
||||
const mergeHeadFound = await isMergeHeadSet(repository)
|
||||
const rebaseContext = await getRebaseContext(repository)
|
||||
const conflictedFilesInIndex = entries.some(
|
||||
e => conflictStatusCodes.indexOf(e.statusCode) > -1
|
||||
)
|
||||
const rebaseInternalState = await getRebaseInternalState(repository)
|
||||
|
||||
if (enablePullWithRebase()) {
|
||||
conflictDetails = await getConflictDetails(
|
||||
repository,
|
||||
mergeHeadFound,
|
||||
rebaseContext
|
||||
)
|
||||
} else {
|
||||
conflictDetails = await getConflictDetails(repository, mergeHeadFound, null)
|
||||
}
|
||||
const conflictDetails = await getConflictDetails(
|
||||
repository,
|
||||
mergeHeadFound,
|
||||
conflictedFilesInIndex,
|
||||
rebaseInternalState
|
||||
)
|
||||
|
||||
// Map of files keyed on their paths.
|
||||
const files = entries.reduce(
|
||||
|
@ -239,7 +240,7 @@ export async function getStatus(
|
|||
branchAheadBehind,
|
||||
exists: true,
|
||||
mergeHeadFound,
|
||||
rebaseContext,
|
||||
rebaseInternalState,
|
||||
workingDirectory,
|
||||
}
|
||||
}
|
||||
|
@ -357,25 +358,53 @@ 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) {
|
||||
const conflictCountsByPath = await getFilesWithConflictMarkers(
|
||||
repository.path
|
||||
)
|
||||
let binaryFilePaths: ReadonlyArray<string> = []
|
||||
try {
|
||||
// its totally fine if HEAD doesn't exist, which throws an error
|
||||
binaryFilePaths = await getBinaryPaths(repository, 'HEAD')
|
||||
} catch (error) {}
|
||||
|
||||
return {
|
||||
conflictCountsByPath,
|
||||
binaryFilePaths,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* gets the conflicted files count and binary file paths in a given repository.
|
||||
* for computing an `IStatusResult`.
|
||||
*
|
||||
* @param repository to get details from
|
||||
* @param mergeHeadFound whether a merge conflict has been detected
|
||||
* @param rebaseContext details about the current rebase operation (if found)
|
||||
* @param lookForStashConflicts whether it looks like a stash has introduced conflicts
|
||||
* @param rebaseInternalState details about the current rebase operation (if found)
|
||||
*/
|
||||
async function getConflictDetails(
|
||||
repository: Repository,
|
||||
mergeHeadFound: boolean,
|
||||
rebaseContext: RebaseContext | null
|
||||
lookForStashConflicts: boolean,
|
||||
rebaseInternalState: RebaseInternalState | null
|
||||
): Promise<ConflictFilesDetails> {
|
||||
try {
|
||||
if (mergeHeadFound) {
|
||||
return await getMergeConflictDetails(repository)
|
||||
} else if (rebaseContext !== null) {
|
||||
}
|
||||
|
||||
if (rebaseInternalState !== null) {
|
||||
return await getRebaseConflictDetails(repository)
|
||||
}
|
||||
|
||||
if (lookForStashConflicts) {
|
||||
return await getWorkingDirectoryConflictDetails(repository)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'Unexpected error from git operations in getConflictDetails',
|
||||
|
|
7
app/src/lib/globals.d.ts
vendored
7
app/src/lib/globals.d.ts
vendored
|
@ -188,13 +188,6 @@ interface XMLHttpRequest extends XMLHttpRequestEventTarget {
|
|||
}
|
||||
|
||||
declare namespace Electron {
|
||||
interface MenuItem {
|
||||
readonly accelerator?: Electron.Accelerator
|
||||
readonly submenu?: Electron.Menu
|
||||
readonly role?: string
|
||||
readonly type: 'normal' | 'separator' | 'submenu' | 'checkbox' | 'radio'
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
|
|
|
@ -23,7 +23,7 @@ export function generateGravatarUrl(email: string, size: number = 60): string {
|
|||
* endpoint associated with an account.
|
||||
*
|
||||
* This is a workaround for a current limitation with
|
||||
* GitHub Enterprise, where avatar URLs are inaccessible
|
||||
* GitHub Enterprise Server, where avatar URLs are inaccessible
|
||||
* in some scenarios.
|
||||
*
|
||||
* @param avatar_url The canonical avatar to use
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import * as appProxy from '../ui/lib/app-proxy'
|
||||
import { URL } from 'url'
|
||||
|
||||
/** The HTTP methods available. */
|
||||
export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'HEAD'
|
||||
|
@ -87,7 +88,16 @@ export function getAbsoluteUrl(endpoint: string, path: string): string {
|
|||
if (relativePath.startsWith('api/v3/')) {
|
||||
relativePath = relativePath.substr(7)
|
||||
}
|
||||
return encodeURI(`${endpoint}/${relativePath}`)
|
||||
|
||||
// Our API endpoints are a bit sloppy in that they don't typically
|
||||
// include the trailing slash (i.e. we use https://api.github.com for
|
||||
// dotcom and https://ghe.enterprise.local/api/v3 for Enterprise Server when
|
||||
// both of those should really include the trailing slash since that's
|
||||
// the qualified base). We'll work around our past since here by ensuring
|
||||
// that the endpoint ends with a trailing slash.
|
||||
const base = endpoint.endsWith('/') ? endpoint : `${endpoint}/`
|
||||
|
||||
return new URL(relativePath, base).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -17,7 +17,7 @@ export interface IMenuItem {
|
|||
* When specified the click property will be ignored.
|
||||
* See https://electronjs.org/docs/api/menu-item#roles
|
||||
*/
|
||||
readonly role?: string
|
||||
readonly role?: Electron.MenuItemConstructorOptions['role']
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { MenuIDs } from '../main-process/menu'
|
||||
import { MenuIDs } from '../models/menu-ids'
|
||||
import { merge } from './merge'
|
||||
import { IAppState, SelectionType } from '../lib/app-state'
|
||||
import { Repository } from '../models/repository'
|
||||
|
@ -100,6 +100,7 @@ function menuItemStateEqual(state: IMenuItemState, menuItem: MenuItem) {
|
|||
const allMenuIds: ReadonlyArray<MenuIDs> = [
|
||||
'rename-branch',
|
||||
'delete-branch',
|
||||
'discard-all-changes',
|
||||
'preferences',
|
||||
'update-branch',
|
||||
'compare-to-branch',
|
||||
|
@ -149,17 +150,19 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
|
|||
let onNonDefaultBranch = false
|
||||
let onBranch = false
|
||||
let onDetachedHead = false
|
||||
let hasChangedFiles = false
|
||||
let hasDefaultBranch = false
|
||||
let hasPublishedBranch = false
|
||||
let networkActionInProgress = false
|
||||
let tipStateIsUnknown = false
|
||||
let branchIsUnborn = false
|
||||
let rebaseInProgress = false
|
||||
let branchHasStashEntry = false
|
||||
|
||||
if (selectedState && selectedState.type === SelectionType.Repository) {
|
||||
repositorySelected = true
|
||||
|
||||
const branchesState = selectedState.state.branchesState
|
||||
const { branchesState, changesState } = selectedState.state
|
||||
const tip = branchesState.tip
|
||||
const defaultBranch = branchesState.defaultBranch
|
||||
|
||||
|
@ -181,15 +184,17 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
|
|||
}
|
||||
|
||||
hasPublishedBranch = !!tip.branch.upstream
|
||||
branchHasStashEntry = changesState.stashEntry !== null
|
||||
} else {
|
||||
onNonDefaultBranch = true
|
||||
}
|
||||
|
||||
networkActionInProgress = selectedState.state.isPushPullFetchInProgress
|
||||
|
||||
const { conflictState } = selectedState.state.changesState
|
||||
const { conflictState, workingDirectory } = selectedState.state.changesState
|
||||
|
||||
rebaseInProgress = conflictState !== null && conflictState.kind === 'rebase'
|
||||
hasChangedFiles = workingDirectory.files.length > 0
|
||||
}
|
||||
|
||||
// These are IDs for menu items that are entirely _and only_
|
||||
|
@ -258,7 +263,13 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
|
|||
!tipStateIsUnknown && !branchIsUnborn && !rebaseInProgress
|
||||
)
|
||||
|
||||
menuStateBuilder.setEnabled(
|
||||
'discard-all-changes',
|
||||
repositoryActive && hasChangedFiles && !rebaseInProgress
|
||||
)
|
||||
|
||||
menuStateBuilder.setEnabled('compare-to-branch', !onDetachedHead)
|
||||
menuStateBuilder.setEnabled('toggle-stashed-changes', branchHasStashEntry)
|
||||
|
||||
if (
|
||||
selectedState &&
|
||||
|
@ -287,6 +298,7 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
|
|||
menuStateBuilder.disable('create-branch')
|
||||
menuStateBuilder.disable('rename-branch')
|
||||
menuStateBuilder.disable('delete-branch')
|
||||
menuStateBuilder.disable('discard-all-changes')
|
||||
menuStateBuilder.disable('update-branch')
|
||||
menuStateBuilder.disable('merge-branch')
|
||||
menuStateBuilder.disable('rebase-branch')
|
||||
|
@ -295,7 +307,9 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
|
|||
menuStateBuilder.disable('pull')
|
||||
menuStateBuilder.disable('compare-to-branch')
|
||||
menuStateBuilder.disable('compare-on-github')
|
||||
menuStateBuilder.disable('toggle-stashed-changes')
|
||||
}
|
||||
|
||||
return menuStateBuilder
|
||||
}
|
||||
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
import { IRepositoryState, RebaseConflictState } from '../lib/app-state'
|
||||
import {
|
||||
IRepositoryState,
|
||||
RebaseConflictState,
|
||||
IBranchesState,
|
||||
} from '../lib/app-state'
|
||||
import {
|
||||
ChooseBranchesStep,
|
||||
RebaseStep,
|
||||
ShowConflictsStep,
|
||||
} from '../models/rebase-flow-state'
|
||||
import { Branch } from '../models/branch'
|
||||
} from '../models/rebase-flow-step'
|
||||
import { Branch, IAheadBehind } from '../models/branch'
|
||||
import { TipState } from '../models/tip'
|
||||
import { clamp } from './clamp'
|
||||
|
||||
export const initializeNewRebaseFlow = (state: IRepositoryState) => {
|
||||
/**
|
||||
* Setup the rebase flow state when the user neeeds to select a branch as the
|
||||
* base for the operation.
|
||||
*/
|
||||
export function initializeNewRebaseFlow(state: IRepositoryState) {
|
||||
const {
|
||||
defaultBranch,
|
||||
allBranches,
|
||||
|
@ -36,17 +44,21 @@ export const initializeNewRebaseFlow = (state: IRepositoryState) => {
|
|||
return initialState
|
||||
}
|
||||
|
||||
export const initializeRebaseFlowForConflictedRepository = (
|
||||
/**
|
||||
* Setup the rebase flow when rebase conflicts are detected in the repository.
|
||||
*
|
||||
* This indicates a rebase is in progress, and the application needs to guide
|
||||
* the user to resolve conflicts and complete the rebae.
|
||||
*
|
||||
* @param conflictState current set of conflicts
|
||||
*/
|
||||
export function initializeRebaseFlowForConflictedRepository(
|
||||
conflictState: RebaseConflictState
|
||||
) => {
|
||||
const { targetBranch, baseBranch } = conflictState
|
||||
|
||||
): ShowConflictsStep {
|
||||
const initialState: ShowConflictsStep = {
|
||||
kind: RebaseStep.ShowConflicts,
|
||||
targetBranch,
|
||||
baseBranch,
|
||||
conflictState,
|
||||
}
|
||||
|
||||
return initialState
|
||||
}
|
||||
|
||||
|
@ -58,3 +70,30 @@ export const initializeRebaseFlowForConflictedRepository = (
|
|||
export function formatRebaseValue(value: number) {
|
||||
return Math.round(clamp(value, 0, 1) * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Check application state to see whether the action applied to the current
|
||||
* branch should be a force push
|
||||
*/
|
||||
export function isCurrentBranchForcePush(
|
||||
branchesState: IBranchesState,
|
||||
aheadBehind: IAheadBehind | null
|
||||
) {
|
||||
if (aheadBehind === null) {
|
||||
// no tracking branch found
|
||||
return false
|
||||
}
|
||||
|
||||
const { tip, rebasedBranches } = branchesState
|
||||
const { ahead, behind } = aheadBehind
|
||||
|
||||
let branchWasRebased = false
|
||||
if (tip.kind === TipState.Valid) {
|
||||
const localBranchName = tip.branch.nameWithoutRemote
|
||||
const { sha } = tip.branch.tip
|
||||
const foundEntry = rebasedBranches.get(localBranchName)
|
||||
branchWasRebased = foundEntry === sha
|
||||
}
|
||||
|
||||
return branchWasRebased && behind > 0 && ahead > 0
|
||||
}
|
||||
|
|
|
@ -57,12 +57,10 @@ async function getRawShellEnv(): Promise<string | null> {
|
|||
cleanup()
|
||||
}, 5000)
|
||||
|
||||
const options = {
|
||||
child = ChildProcess.spawn(shell, ['-ilc', 'command env'], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', process.stderr],
|
||||
}
|
||||
|
||||
child = ChildProcess.spawn(shell, ['-ilc', 'command env'], options)
|
||||
})
|
||||
|
||||
const buffers: Array<Buffer> = []
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ export enum Shell {
|
|||
Hyper = 'Hyper',
|
||||
iTerm2 = 'iTerm2',
|
||||
PowerShellCore = 'PowerShell Core',
|
||||
Kitty = 'Kitty',
|
||||
}
|
||||
|
||||
export const Default = Shell.Terminal
|
||||
|
@ -30,6 +31,10 @@ export function parse(label: string): Shell {
|
|||
return Shell.PowerShellCore
|
||||
}
|
||||
|
||||
if (label === Shell.Kitty) {
|
||||
return Shell.Kitty
|
||||
}
|
||||
|
||||
return Default
|
||||
}
|
||||
|
||||
|
@ -43,6 +48,8 @@ function getBundleID(shell: Shell): string {
|
|||
return 'co.zeit.hyper'
|
||||
case Shell.PowerShellCore:
|
||||
return 'com.microsoft.powershell'
|
||||
case Shell.Kitty:
|
||||
return 'net.kovidgoyal.kitty'
|
||||
default:
|
||||
return assertNever(shell, `Unknown shell: ${shell}`)
|
||||
}
|
||||
|
@ -66,11 +73,13 @@ export async function getAvailableShells(): Promise<
|
|||
hyperPath,
|
||||
iTermPath,
|
||||
powerShellCorePath,
|
||||
kittyPath,
|
||||
] = await Promise.all([
|
||||
getShellPath(Shell.Terminal),
|
||||
getShellPath(Shell.Hyper),
|
||||
getShellPath(Shell.iTerm2),
|
||||
getShellPath(Shell.PowerShellCore),
|
||||
getShellPath(Shell.Kitty),
|
||||
])
|
||||
|
||||
const shells: Array<IFoundShell<Shell>> = []
|
||||
|
@ -90,6 +99,11 @@ export async function getAvailableShells(): Promise<
|
|||
shells.push({ shell: Shell.PowerShellCore, path: powerShellCorePath })
|
||||
}
|
||||
|
||||
if (kittyPath) {
|
||||
const kittyExecutable = `${kittyPath}/Contents/MacOS/kitty`
|
||||
shells.push({ shell: Shell.Kitty, path: kittyExecutable })
|
||||
}
|
||||
|
||||
return shells
|
||||
}
|
||||
|
||||
|
@ -97,7 +111,16 @@ export function launch(
|
|||
foundShell: IFoundShell<Shell>,
|
||||
path: string
|
||||
): ChildProcess {
|
||||
const bundleID = getBundleID(foundShell.shell)
|
||||
const commandArgs = ['-b', bundleID, path]
|
||||
return spawn('open', commandArgs)
|
||||
if (foundShell.shell === Shell.Kitty) {
|
||||
// kitty does not handle arguments as expected when using `open` with
|
||||
// an existing session but closed window (it reverts to the previous
|
||||
// directory rather than using the new directory directory).
|
||||
//
|
||||
// This workaround launches the internal `kitty` executable which
|
||||
// will open a new window to the desired path.
|
||||
return spawn(foundShell.path, ['--single-instance', '--directory', path])
|
||||
} else {
|
||||
const bundleID = getBundleID(foundShell.shell)
|
||||
return spawn('open', ['-b', bundleID, path])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { pathExists } from 'fs-extra'
|
|||
|
||||
import { assertNever } from '../fatal-error'
|
||||
import { IFoundShell } from './found-shell'
|
||||
import { enableWSLDetection } from '../feature-flag'
|
||||
|
||||
export enum Shell {
|
||||
Cmd = 'Command Prompt',
|
||||
|
@ -13,6 +14,7 @@ export enum Shell {
|
|||
Hyper = 'Hyper',
|
||||
GitBash = 'Git Bash',
|
||||
Cygwin = 'Cygwin',
|
||||
WSL = 'WSL',
|
||||
}
|
||||
|
||||
export const Default = Shell.Cmd
|
||||
|
@ -42,6 +44,10 @@ export function parse(label: string): Shell {
|
|||
return Shell.Cygwin
|
||||
}
|
||||
|
||||
if (label === Shell.WSL) {
|
||||
return Shell.WSL
|
||||
}
|
||||
|
||||
return Default
|
||||
}
|
||||
|
||||
|
@ -95,6 +101,16 @@ export async function getAvailableShells(): Promise<
|
|||
})
|
||||
}
|
||||
|
||||
if (enableWSLDetection()) {
|
||||
const wslPath = await findWSL()
|
||||
if (wslPath != null) {
|
||||
shells.push({
|
||||
shell: Shell.WSL,
|
||||
path: wslPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return shells
|
||||
}
|
||||
|
||||
|
@ -268,6 +284,45 @@ async function findCygwin(): Promise<string | null> {
|
|||
return null
|
||||
}
|
||||
|
||||
async function findWSL(): Promise<string | null> {
|
||||
const system32 = Path.join(
|
||||
process.env.SystemRoot || 'C:\\Windows',
|
||||
'System32'
|
||||
)
|
||||
const wslPath = Path.join(system32, 'wsl.exe')
|
||||
const wslConfigPath = Path.join(system32, 'wslconfig.exe')
|
||||
|
||||
if (!(await pathExists(wslPath))) {
|
||||
log.debug(`[WSL] wsl.exe does not exist at '${wslPath}'`)
|
||||
return null
|
||||
}
|
||||
if (!(await pathExists(wslConfigPath))) {
|
||||
log.debug(
|
||||
`[WSL] found wsl.exe, but wslconfig.exe does not exist at '${wslConfigPath}'`
|
||||
)
|
||||
return null
|
||||
}
|
||||
const exitCode = new Promise<number>((resolve, reject) => {
|
||||
const wslDistros = spawn(wslConfigPath, ['/list'])
|
||||
wslDistros.on('error', reject)
|
||||
wslDistros.on('exit', resolve)
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await exitCode
|
||||
if (result !== 0) {
|
||||
log.debug(
|
||||
`[WSL] found wsl.exe and wslconfig.exe, but no distros are installed. Error Code: ${result}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
return wslPath
|
||||
} catch (err) {
|
||||
log.error(`[WSL] unhandled error when invoking 'wsl /list'`, err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function launch(
|
||||
foundShell: IFoundShell<Shell>,
|
||||
path: string
|
||||
|
@ -312,6 +367,8 @@ export function launch(
|
|||
cwd: path,
|
||||
}
|
||||
)
|
||||
case Shell.WSL:
|
||||
return spawn('START', ['wsl'], { shell: true, cwd: path })
|
||||
case Shell.Cmd:
|
||||
return spawn('START', ['cmd'], { shell: true, cwd: path })
|
||||
default:
|
||||
|
|
|
@ -89,10 +89,10 @@ export interface IDailyMeasures {
|
|||
/** The number of times the user pushes with `--force-with-lease` to GitHub.com */
|
||||
readonly dotcomForcePushCount: number
|
||||
|
||||
/** The number of times the user pushed to a GitHub enterprise instance */
|
||||
/** The number of times the user pushed to a GitHub Enterprise Server instance */
|
||||
readonly enterprisePushCount: number
|
||||
|
||||
/** The number of times the user pushes with `--force-with-lease` to a GitHub Enterprise instance */
|
||||
/** The number of times the user pushes with `--force-with-lease` to a GitHub Enterprise Server instance */
|
||||
readonly enterpriseForcePushCount: number
|
||||
|
||||
/** The number of times the users pushes to a generic remote */
|
||||
|
@ -130,13 +130,19 @@ export interface IDailyMeasures {
|
|||
|
||||
/**
|
||||
* The number of times the user made a commit to a repo hosted on
|
||||
* a GitHub Enterprise instance
|
||||
* a GitHub Enterprise Server instance
|
||||
*/
|
||||
readonly enterpriseCommits: number
|
||||
|
||||
/** The number of time 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 Server repository */
|
||||
readonly commitsToProtectedBranch: number
|
||||
|
||||
/** The number of times the user made a commit to a repository with branch protections enabled */
|
||||
readonly commitsToRepositoryWithBranchProtections: number
|
||||
|
||||
/** The number of times the user dismissed the merge conflicts dialog */
|
||||
readonly mergeConflictsDialogDismissalCount: number
|
||||
|
||||
|
@ -164,17 +170,104 @@ export interface IDailyMeasures {
|
|||
/** The number of times an aborted rebase is detected */
|
||||
readonly rebaseAbortedAfterConflictsCount: number
|
||||
|
||||
/** The number of times a successful rebase is detected */
|
||||
/** The number of times a successful rebase after handling conflicts is detected */
|
||||
readonly rebaseSuccessAfterConflictsCount: number
|
||||
|
||||
/** The number of times a successful rebase without conflicts is detected */
|
||||
readonly rebaseSuccessWithoutConflictsCount: number
|
||||
|
||||
/** The number of times a user performed a pull with `pull.rebase` in config set to `true` */
|
||||
readonly pullWithRebaseCount: number
|
||||
|
||||
/** The number of times a user has pulled with `pull.rebase` unset or set to `false` */
|
||||
readonly pullWithDefaultSettingCount: number
|
||||
|
||||
/**
|
||||
* The number of stash entries created outside of Desktop
|
||||
* in a given 24 hour day
|
||||
*/
|
||||
readonly stashEntriesCreatedOutsideDesktop: number
|
||||
|
||||
/**
|
||||
* The number of times the user is presented with the error
|
||||
* message "Some of your changes would be overwritten"
|
||||
*/
|
||||
readonly errorWhenSwitchingBranchesWithUncommmittedChanges: number
|
||||
|
||||
/** The number of times the user opens the "Rebase current branch" menu item */
|
||||
readonly rebaseCurrentBranchMenuCount: number
|
||||
|
||||
/** The number of times the user views a stash entry after checking out a branch */
|
||||
readonly stashViewedAfterCheckoutCount: number
|
||||
|
||||
/** The number of times the user **doesn't** view a stash entry after checking out a branch */
|
||||
readonly stashNotViewedAfterCheckoutCount: number
|
||||
|
||||
/** The number of times the user elects to stash changes on the current branch */
|
||||
readonly stashCreatedOnCurrentBranchCount: number
|
||||
|
||||
/** The number of times the user elects to take changes to new branch instead of stashing them */
|
||||
readonly changesTakenToNewBranchCount: number
|
||||
|
||||
/** The number of times the user elects to restore an entry from their stash */
|
||||
readonly stashRestoreCount: number
|
||||
|
||||
/** The number of times the user elects to discard a stash entry */
|
||||
readonly stashDiscardCount: number
|
||||
|
||||
/**
|
||||
* The number of times the user views the stash entry as a result
|
||||
* of clicking the "Stashed changes" row directly
|
||||
*/
|
||||
readonly stashViewCount: number
|
||||
|
||||
/** The number of times the user takes no action on a stash entry once viewed */
|
||||
readonly noActionTakenOnStashCount: number
|
||||
/**
|
||||
* The number of times the user has opened their external editor from the
|
||||
* suggested next steps view
|
||||
*/
|
||||
readonly suggestedStepOpenInExternalEditor: number
|
||||
|
||||
/**
|
||||
* The number of times the user has opened their repository in Finder/Explorer
|
||||
* from the suggested next steps view
|
||||
*/
|
||||
readonly suggestedStepOpenWorkingDirectory: number
|
||||
|
||||
/**
|
||||
* The number of times the user has opened their repository on GitHub from the
|
||||
* suggested next steps view
|
||||
*/
|
||||
readonly suggestedStepViewOnGitHub: number
|
||||
|
||||
/**
|
||||
* The number of times the user has used the publish repository action from the
|
||||
* suggested next steps view
|
||||
*/
|
||||
readonly suggestedStepPublishRepository: number
|
||||
|
||||
/**
|
||||
* The number of times the user has used the publish branch action branch from
|
||||
* the suggested next steps view
|
||||
*/
|
||||
readonly suggestedStepPublishBranch: number
|
||||
|
||||
/**
|
||||
* The number of times the user has used the Create PR suggestion
|
||||
* in the suggested next steps view. Note that this number is a
|
||||
* subset of `createPullRequestCount`. I.e. if the Create PR suggestion
|
||||
* is invoked both `suggestedStepCreatePR` and `createPullRequestCount`
|
||||
* will increment whereas if a PR is created from the menu or from
|
||||
* a keyboard shortcut only `createPullRequestCount` will increment.
|
||||
*/
|
||||
readonly suggestedStepCreatePullRequest: number
|
||||
|
||||
/**
|
||||
* The number of times the user has used the view stash action from
|
||||
* the suggested next steps view
|
||||
*/
|
||||
readonly suggestedStepViewStash: number
|
||||
}
|
||||
|
||||
export class StatsDatabase extends Dexie {
|
||||
|
|
|
@ -89,9 +89,29 @@ const DefaultDailyMeasures: IDailyMeasures = {
|
|||
rebaseConflictsDialogReopenedCount: 0,
|
||||
rebaseAbortedAfterConflictsCount: 0,
|
||||
rebaseSuccessAfterConflictsCount: 0,
|
||||
rebaseSuccessWithoutConflictsCount: 0,
|
||||
pullWithRebaseCount: 0,
|
||||
pullWithDefaultSettingCount: 0,
|
||||
stashEntriesCreatedOutsideDesktop: 0,
|
||||
errorWhenSwitchingBranchesWithUncommmittedChanges: 0,
|
||||
rebaseCurrentBranchMenuCount: 0,
|
||||
stashViewedAfterCheckoutCount: 0,
|
||||
stashCreatedOnCurrentBranchCount: 0,
|
||||
stashNotViewedAfterCheckoutCount: 0,
|
||||
changesTakenToNewBranchCount: 0,
|
||||
stashRestoreCount: 0,
|
||||
stashDiscardCount: 0,
|
||||
stashViewCount: 0,
|
||||
noActionTakenOnStashCount: 0,
|
||||
suggestedStepOpenInExternalEditor: 0,
|
||||
suggestedStepOpenWorkingDirectory: 0,
|
||||
suggestedStepViewOnGitHub: 0,
|
||||
suggestedStepPublishRepository: 0,
|
||||
suggestedStepPublishBranch: 0,
|
||||
suggestedStepCreatePullRequest: 0,
|
||||
suggestedStepViewStash: 0,
|
||||
commitsToProtectedBranch: 0,
|
||||
commitsToRepositoryWithBranchProtections: 0,
|
||||
}
|
||||
|
||||
interface IOnboardingStats {
|
||||
|
@ -151,7 +171,7 @@ interface IOnboardingStats {
|
|||
* Time (in seconds) from when the user first launched
|
||||
* the application and entered the welcome wizard until
|
||||
* the user performed their first push of a repository
|
||||
* to GitHub.com or GitHub Enterprise. This metric
|
||||
* to GitHub.com or GitHub Enterprise Server. This metric
|
||||
* does not track pushes to non-GitHub remotes.
|
||||
*/
|
||||
readonly timeToFirstGitHubPush?: number
|
||||
|
@ -233,7 +253,7 @@ interface ICalculatedStats {
|
|||
/** Is the user logged in with a GitHub.com account? */
|
||||
readonly dotComAccount: boolean
|
||||
|
||||
/** Is the user logged in with an Enterprise account? */
|
||||
/** Is the user logged in with an Enterprise Server account? */
|
||||
readonly enterpriseAccount: boolean
|
||||
|
||||
/**
|
||||
|
@ -648,8 +668,8 @@ export class StatsStore implements IStatsStore {
|
|||
/**
|
||||
* Records that the user made a commit using an email address that
|
||||
* was not associated with the user's account on GitHub.com or GitHub
|
||||
* Enterprise, meaning that the commit will not be attributed to the user's
|
||||
* account.
|
||||
* Enterprise Server, meaning that the commit will not be attributed to the
|
||||
* user's account.
|
||||
*/
|
||||
public recordUnattributedCommit(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
|
@ -659,7 +679,7 @@ export class StatsStore implements IStatsStore {
|
|||
|
||||
/**
|
||||
* Records that the user made a commit to a repository hosted on
|
||||
* a GitHub Enterprise instance
|
||||
* a GitHub Enterprise Server instance
|
||||
*/
|
||||
public recordCommitToEnterprise(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
|
@ -674,6 +694,21 @@ export class StatsStore implements IStatsStore {
|
|||
}))
|
||||
}
|
||||
|
||||
/** Record the user made a commit to a protected GitHub or GitHub Enterprise Server repository */
|
||||
public recordCommitToProtectedBranch(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
commitsToProtectedBranch: m.commitsToProtectedBranch + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record the user made a commit to repository which has branch protections enabled */
|
||||
public recordCommitToRepositoryWithBranchProtections(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
commitsToRepositoryWithBranchProtections:
|
||||
m.commitsToRepositoryWithBranchProtections + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Set whether the user has opted out of stats reporting. */
|
||||
public async setOptOut(
|
||||
optOut: boolean,
|
||||
|
@ -698,14 +733,14 @@ export class StatsStore implements IStatsStore {
|
|||
}
|
||||
|
||||
/** Record that user dismissed diverging branch notification */
|
||||
public async recordDivergingBranchBannerDismissal(): Promise<void> {
|
||||
public recordDivergingBranchBannerDismissal(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
divergingBranchBannerDismissal: m.divergingBranchBannerDismissal + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record that user initiated a merge from within the notification banner */
|
||||
public async recordDivergingBranchBannerInitatedMerge(): Promise<void> {
|
||||
public recordDivergingBranchBannerInitatedMerge(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
divergingBranchBannerInitatedMerge:
|
||||
m.divergingBranchBannerInitatedMerge + 1,
|
||||
|
@ -713,7 +748,7 @@ export class StatsStore implements IStatsStore {
|
|||
}
|
||||
|
||||
/** Record that user initiated a compare from within the notification banner */
|
||||
public async recordDivergingBranchBannerInitiatedCompare(): Promise<void> {
|
||||
public recordDivergingBranchBannerInitiatedCompare(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
divergingBranchBannerInitiatedCompare:
|
||||
m.divergingBranchBannerInitiatedCompare + 1,
|
||||
|
@ -724,7 +759,7 @@ export class StatsStore implements IStatsStore {
|
|||
* Record that user initiated a merge after getting to compare view
|
||||
* from within notification banner
|
||||
*/
|
||||
public async recordDivergingBranchBannerInfluencedMerge(): Promise<void> {
|
||||
public recordDivergingBranchBannerInfluencedMerge(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
divergingBranchBannerInfluencedMerge:
|
||||
m.divergingBranchBannerInfluencedMerge + 1,
|
||||
|
@ -732,7 +767,7 @@ export class StatsStore implements IStatsStore {
|
|||
}
|
||||
|
||||
/** Record that the user was shown the notification banner */
|
||||
public async recordDivergingBranchBannerDisplayed(): Promise<void> {
|
||||
public recordDivergingBranchBannerDisplayed(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
divergingBranchBannerDisplayed: m.divergingBranchBannerDisplayed + 1,
|
||||
}))
|
||||
|
@ -766,7 +801,7 @@ export class StatsStore implements IStatsStore {
|
|||
createLocalStorageTimestamp(FirstPushToGitHubAtKey)
|
||||
}
|
||||
|
||||
/** Record that the user pushed to a GitHub Enterprise instance */
|
||||
/** Record that the user pushed to a GitHub Enterprise Server instance */
|
||||
private async recordPushToGitHubEnterprise(
|
||||
options?: PushOptions
|
||||
): Promise<void> {
|
||||
|
@ -801,21 +836,21 @@ export class StatsStore implements IStatsStore {
|
|||
}
|
||||
|
||||
/** Record that the user saw a 'merge conflicts' warning but continued with the merge */
|
||||
public async recordUserProceededWhileLoading(): Promise<void> {
|
||||
public recordUserProceededWhileLoading(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
mergedWithLoadingHintCount: m.mergedWithLoadingHintCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record that the user saw a 'merge conflicts' warning but continued with the merge */
|
||||
public async recordMergeHintSuccessAndUserProceeded(): Promise<void> {
|
||||
public recordMergeHintSuccessAndUserProceeded(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
mergedWithCleanMergeHintCount: m.mergedWithCleanMergeHintCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record that the user saw a 'merge conflicts' warning but continued with the merge */
|
||||
public async recordUserProceededAfterConflictWarning(): Promise<void> {
|
||||
public recordUserProceededAfterConflictWarning(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
mergedWithConflictWarningHintCount:
|
||||
m.mergedWithConflictWarningHintCount + 1,
|
||||
|
@ -825,7 +860,7 @@ export class StatsStore implements IStatsStore {
|
|||
/**
|
||||
* Increments the `mergeConflictsDialogDismissalCount` metric
|
||||
*/
|
||||
public async recordMergeConflictsDialogDismissal(): Promise<void> {
|
||||
public recordMergeConflictsDialogDismissal(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
mergeConflictsDialogDismissalCount:
|
||||
m.mergeConflictsDialogDismissalCount + 1,
|
||||
|
@ -835,7 +870,7 @@ export class StatsStore implements IStatsStore {
|
|||
/**
|
||||
* Increments the `anyConflictsLeftOnMergeConflictsDialogDismissalCount` metric
|
||||
*/
|
||||
public async recordAnyConflictsLeftOnMergeConflictsDialogDismissal(): Promise<
|
||||
public recordAnyConflictsLeftOnMergeConflictsDialogDismissal(): Promise<
|
||||
void
|
||||
> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
|
@ -847,7 +882,7 @@ export class StatsStore implements IStatsStore {
|
|||
/**
|
||||
* Increments the `mergeConflictsDialogReopenedCount` metric
|
||||
*/
|
||||
public async recordMergeConflictsDialogReopened(): Promise<void> {
|
||||
public recordMergeConflictsDialogReopened(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
mergeConflictsDialogReopenedCount:
|
||||
m.mergeConflictsDialogReopenedCount + 1,
|
||||
|
@ -857,7 +892,7 @@ export class StatsStore implements IStatsStore {
|
|||
/**
|
||||
* Increments the `guidedConflictedMergeCompletionCount` metric
|
||||
*/
|
||||
public async recordGuidedConflictedMergeCompletion(): Promise<void> {
|
||||
public recordGuidedConflictedMergeCompletion(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
guidedConflictedMergeCompletionCount:
|
||||
m.guidedConflictedMergeCompletionCount + 1,
|
||||
|
@ -867,7 +902,7 @@ export class StatsStore implements IStatsStore {
|
|||
/**
|
||||
* Increments the `unguidedConflictedMergeCompletionCount` metric
|
||||
*/
|
||||
public async recordUnguidedConflictedMergeCompletion(): Promise<void> {
|
||||
public recordUnguidedConflictedMergeCompletion(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
unguidedConflictedMergeCompletionCount:
|
||||
m.unguidedConflictedMergeCompletionCount + 1,
|
||||
|
@ -877,7 +912,7 @@ export class StatsStore implements IStatsStore {
|
|||
/**
|
||||
* Increments the `createPullRequestCount` metric
|
||||
*/
|
||||
public async recordCreatePullRequest(): Promise<void> {
|
||||
public recordCreatePullRequest(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
createPullRequestCount: m.createPullRequestCount + 1,
|
||||
}))
|
||||
|
@ -886,7 +921,7 @@ export class StatsStore implements IStatsStore {
|
|||
/**
|
||||
* Increments the `rebaseConflictsDialogDismissalCount` metric
|
||||
*/
|
||||
public async recordRebaseConflictsDialogDismissal(): Promise<void> {
|
||||
public recordRebaseConflictsDialogDismissal(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
rebaseConflictsDialogDismissalCount:
|
||||
m.rebaseConflictsDialogDismissalCount + 1,
|
||||
|
@ -894,9 +929,9 @@ export class StatsStore implements IStatsStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Increments the `rebaseConflictsDialogDismissalCount` metric
|
||||
* Increments the `rebaseConflictsDialogReopenedCount` metric
|
||||
*/
|
||||
public async recordRebaseConflictsDialogReopened(): Promise<void> {
|
||||
public recordRebaseConflictsDialogReopened(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
rebaseConflictsDialogReopenedCount:
|
||||
m.rebaseConflictsDialogReopenedCount + 1,
|
||||
|
@ -906,7 +941,7 @@ export class StatsStore implements IStatsStore {
|
|||
/**
|
||||
* Increments the `rebaseAbortedAfterConflictsCount` metric
|
||||
*/
|
||||
public async recordRebaseAbortedAfterConflicts(): Promise<void> {
|
||||
public recordRebaseAbortedAfterConflicts(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
rebaseAbortedAfterConflictsCount: m.rebaseAbortedAfterConflictsCount + 1,
|
||||
}))
|
||||
|
@ -920,10 +955,20 @@ export class StatsStore implements IStatsStore {
|
|||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the `rebaseSuccessWithoutConflictsCount` metric
|
||||
*/
|
||||
public recordRebaseSuccessWithoutConflicts(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
rebaseSuccessWithoutConflictsCount:
|
||||
m.rebaseSuccessWithoutConflictsCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the `rebaseSuccessAfterConflictsCount` metric
|
||||
*/
|
||||
public async recordRebaseSuccessAfterConflicts(): Promise<void> {
|
||||
public recordRebaseSuccessAfterConflicts(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
rebaseSuccessAfterConflictsCount: m.rebaseSuccessAfterConflictsCount + 1,
|
||||
}))
|
||||
|
@ -968,19 +1013,170 @@ export class StatsStore implements IStatsStore {
|
|||
}
|
||||
|
||||
/** Record when a conflicted merge was successfully completed by the user */
|
||||
public async recordMergeSuccessAfterConflicts(): Promise<void> {
|
||||
public recordMergeSuccessAfterConflicts(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
mergeSuccessAfterConflictsCount: m.mergeSuccessAfterConflictsCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record when a conflicted merge was aborted by the user */
|
||||
public async recordMergeAbortedAfterConflicts(): Promise<void> {
|
||||
public recordMergeAbortedAfterConflicts(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
mergeAbortedAfterConflictsCount: m.mergeAbortedAfterConflictsCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record when the user views a stash entry after checking out a branch */
|
||||
public recordStashViewedAfterCheckout(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
stashViewedAfterCheckoutCount: m.stashViewedAfterCheckoutCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record when the user **doesn't** view a stash entry after checking out a branch */
|
||||
public recordStashNotViewedAfterCheckout(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
stashNotViewedAfterCheckoutCount: m.stashNotViewedAfterCheckoutCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record when the user elects to take changes to new branch over stashing */
|
||||
public recordChangesTakenToNewBranch(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
changesTakenToNewBranchCount: m.changesTakenToNewBranchCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record when the user elects to stash changes on the current branch */
|
||||
public recordStashCreatedOnCurrentBranch(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
stashCreatedOnCurrentBranchCount: m.stashCreatedOnCurrentBranchCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record when the user discards a stash entry */
|
||||
public recordStashDiscard(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
stashDiscardCount: m.stashDiscardCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record when the user views a stash entry */
|
||||
public recordStashView(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
stashViewCount: m.stashViewCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record when the user restores a stash entry */
|
||||
public recordStashRestore(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
stashRestoreCount: m.stashRestoreCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record when the user takes no action on the stash entry */
|
||||
public recordNoActionTakenOnStash(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
noActionTakenOnStashCount: m.noActionTakenOnStashCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Record the number of stash entries created outside of Desktop for the day */
|
||||
public addStashEntriesCreatedOutsideDesktop(
|
||||
stashCount: number
|
||||
): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
stashEntriesCreatedOutsideDesktop:
|
||||
m.stashEntriesCreatedOutsideDesktop + stashCount,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the number of times the user experiences the error
|
||||
* "Some of your changes would be overwritten" when switching branches
|
||||
*/
|
||||
public recordErrorWhenSwitchingBranchesWithUncommmittedChanges(): Promise<
|
||||
void
|
||||
> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
errorWhenSwitchingBranchesWithUncommmittedChanges:
|
||||
m.errorWhenSwitchingBranchesWithUncommmittedChanges + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the number of times the user has opened their external editor
|
||||
* from the suggested next steps view
|
||||
*/
|
||||
public recordSuggestedStepOpenInExternalEditor(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
suggestedStepOpenInExternalEditor:
|
||||
m.suggestedStepOpenInExternalEditor + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the number of times the user has opened their repository in
|
||||
* Finder/Explorer from the suggested next steps view
|
||||
*/
|
||||
public recordSuggestedStepOpenWorkingDirectory(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
suggestedStepOpenWorkingDirectory:
|
||||
m.suggestedStepOpenWorkingDirectory + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the number of times the user has opened their repository on
|
||||
* GitHub from the suggested next steps view
|
||||
*/
|
||||
public recordSuggestedStepViewOnGitHub(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
suggestedStepViewOnGitHub: m.suggestedStepViewOnGitHub + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the number of times the user has used the publish repository
|
||||
* action from the suggested next steps view
|
||||
*/
|
||||
public recordSuggestedStepPublishRepository(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
suggestedStepPublishRepository: m.suggestedStepPublishRepository + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the number of times the user has used the publish branch
|
||||
* action branch from the suggested next steps view
|
||||
*/
|
||||
public recordSuggestedStepPublishBranch(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
suggestedStepPublishBranch: m.suggestedStepPublishBranch + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the number of times the user has used the Create PR suggestion
|
||||
* in the suggested next steps view.
|
||||
*/
|
||||
public recordSuggestedStepCreatePullRequest(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
suggestedStepCreatePullRequest: m.suggestedStepCreatePullRequest + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the number of times the user has used the View Stash suggestion
|
||||
* in the suggested next steps view.
|
||||
*/
|
||||
public recordSuggestedStepViewStash(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
suggestedStepViewStash: m.suggestedStepViewStash + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
private onUiActivity = async () => {
|
||||
this.disableUiActivityMonitoring()
|
||||
|
||||
|
|
|
@ -75,7 +75,7 @@ export class AccountsStore extends TypedBaseStore<ReadonlyArray<Account>> {
|
|||
/**
|
||||
* Add the account to the store.
|
||||
*/
|
||||
public async addAccount(account: Account): Promise<void> {
|
||||
public async addAccount(account: Account): Promise<Account | null> {
|
||||
await this.loadingPromise
|
||||
|
||||
let updated = account
|
||||
|
@ -103,12 +103,13 @@ export class AccountsStore extends TypedBaseStore<ReadonlyArray<Account>> {
|
|||
} else {
|
||||
this.emitError(e)
|
||||
}
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
this.accounts = [...this.accounts, updated]
|
||||
|
||||
this.save()
|
||||
return updated
|
||||
}
|
||||
|
||||
/** Refresh all accounts by fetching their latest info from the API. */
|
||||
|
|
|
@ -171,15 +171,16 @@ export class ApiRepositoriesStore extends BaseStore {
|
|||
* the provided account has explicit permissions to access.
|
||||
*/
|
||||
public async loadRepositories(account: Account) {
|
||||
const existingRepositories = this.accountState.get(account)
|
||||
const existingAccount = resolveAccount(account, this.accountState)
|
||||
const existingRepositories = this.accountState.get(existingAccount)
|
||||
|
||||
if (existingRepositories !== undefined && existingRepositories.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
this.updateAccount(account, { loading: true })
|
||||
this.updateAccount(existingAccount, { loading: true })
|
||||
|
||||
const api = API.fromAccount(account)
|
||||
const api = API.fromAccount(existingAccount)
|
||||
const repositories = await api.fetchRepositories()
|
||||
|
||||
if (repositories === null) {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -14,7 +14,7 @@ import {
|
|||
} from '../../models/branch'
|
||||
import { Tip, TipState } from '../../models/tip'
|
||||
import { Commit } from '../../models/commit'
|
||||
import { IRemote } from '../../models/remote'
|
||||
import { IRemote, ForkedRemotePrefix } from '../../models/remote'
|
||||
import { IFetchProgress, IRevertProgress } from '../../models/progress'
|
||||
import {
|
||||
ICommitMessage,
|
||||
|
@ -63,6 +63,7 @@ import {
|
|||
revSymmetricDifference,
|
||||
getSymbolicRef,
|
||||
getConfigValue,
|
||||
removeRemote,
|
||||
} from '../git'
|
||||
import { RetryAction, RetryActionType } from '../../models/retry-actions'
|
||||
import { UpstreamAlreadyExistsError } from './upstream-already-exists-error'
|
||||
|
@ -77,7 +78,10 @@ import { formatCommitMessage } from '../format-commit-message'
|
|||
import { GitAuthor } from '../../models/git-author'
|
||||
import { IGitAccount } from '../../models/git-account'
|
||||
import { BaseStore } from './base-store'
|
||||
import { enablePullWithRebase } from '../feature-flag'
|
||||
import { enableStashing } from '../feature-flag'
|
||||
import { getStashes, getStashedFiles } from '../git/stash'
|
||||
import { IStashEntry, StashedChangesLoadStates } from '../../models/stash-entry'
|
||||
import { PullRequest } from '../../models/pull-request'
|
||||
|
||||
/** The number of commits to load from history per batch. */
|
||||
const CommitBatchSize = 100
|
||||
|
@ -128,6 +132,10 @@ export class GitStore extends BaseStore {
|
|||
|
||||
private _lastFetched: Date | null = null
|
||||
|
||||
private _desktopStashEntries = new Map<string, IStashEntry>()
|
||||
|
||||
private _stashEntryCount = 0
|
||||
|
||||
public constructor(repository: Repository, shell: IAppShell) {
|
||||
super()
|
||||
|
||||
|
@ -270,11 +278,7 @@ export class GitStore extends BaseStore {
|
|||
|
||||
this.refreshDefaultBranch()
|
||||
this.refreshRecentBranches(recentBranchNames)
|
||||
|
||||
// no need to query Git config if this isn't displayed in the UI
|
||||
if (enablePullWithRebase()) {
|
||||
this.checkPullWithRebase()
|
||||
}
|
||||
this.checkPullWithRebase()
|
||||
|
||||
const commits = this._allBranches.map(b => b.tip)
|
||||
|
||||
|
@ -967,6 +971,107 @@ export class GitStore extends BaseStore {
|
|||
throw new Error(`Could not load commit: '${sha}'`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the list of GitHub Desktop created stash entries for the repository
|
||||
*/
|
||||
public async loadStashEntries(): Promise<void> {
|
||||
if (!enableStashing()) {
|
||||
return
|
||||
}
|
||||
|
||||
const map = new Map<string, IStashEntry>()
|
||||
const stash = await getStashes(this.repository)
|
||||
|
||||
for (const entry of stash.desktopEntries) {
|
||||
// we only want the first entry we find for each branch,
|
||||
// so we skip all subsequent ones
|
||||
if (!map.has(entry.branchName)) {
|
||||
const existing = this._desktopStashEntries.get(entry.branchName)
|
||||
|
||||
// If we've already loaded the files for this stash there's
|
||||
// no point in us doing it again. We know the contents haven't
|
||||
// changed since the SHA is the same.
|
||||
if (existing !== undefined && existing.stashSha === entry.stashSha) {
|
||||
map.set(entry.branchName, { ...entry, files: existing.files })
|
||||
} else {
|
||||
map.set(entry.branchName, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._desktopStashEntries = map
|
||||
this._stashEntryCount = stash.stashEntryCount
|
||||
this.emitUpdate()
|
||||
|
||||
this.loadFilesForCurrentStashEntry()
|
||||
}
|
||||
|
||||
/**
|
||||
* A GitHub Desktop created stash entries for the current branch or
|
||||
* null if no entry exists
|
||||
*/
|
||||
public get currentBranchStashEntry() {
|
||||
return this._tip && this._tip.kind === TipState.Valid
|
||||
? this._desktopStashEntries.get(this._tip.branch.name) || null
|
||||
: null
|
||||
}
|
||||
|
||||
/** The total number of stash entries */
|
||||
public get stashEntryCount(): number {
|
||||
return this._stashEntryCount
|
||||
}
|
||||
|
||||
/** The number of stash entries created by Desktop */
|
||||
public get desktopStashEntryCount(): number {
|
||||
return this._desktopStashEntries.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the latest stash entry with a list of files that it changes
|
||||
*/
|
||||
private async loadFilesForCurrentStashEntry() {
|
||||
if (!enableStashing()) {
|
||||
return
|
||||
}
|
||||
|
||||
const stashEntry = this.currentBranchStashEntry
|
||||
|
||||
if (
|
||||
!stashEntry ||
|
||||
stashEntry.files.kind !== StashedChangesLoadStates.NotLoaded
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const { branchName } = stashEntry
|
||||
|
||||
this._desktopStashEntries.set(branchName, {
|
||||
...stashEntry,
|
||||
files: { kind: StashedChangesLoadStates.Loading },
|
||||
})
|
||||
this.emitUpdate()
|
||||
|
||||
const files = await getStashedFiles(this.repository, stashEntry.stashSha)
|
||||
|
||||
// It's possible that we've refreshed the list of stash entries since we
|
||||
// started getStashedFiles. Load the latest entry for the branch and make
|
||||
// sure the SHAs match up.
|
||||
const currentEntry = this._desktopStashEntries.get(branchName)
|
||||
|
||||
if (!currentEntry || currentEntry.stashSha !== stashEntry.stashSha) {
|
||||
return
|
||||
}
|
||||
|
||||
this._desktopStashEntries.set(branchName, {
|
||||
...currentEntry,
|
||||
files: {
|
||||
kind: StashedChangesLoadStates.Loaded,
|
||||
files,
|
||||
},
|
||||
})
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
public async loadRemotes(): Promise<void> {
|
||||
const remotes = await getRemotes(this.repository)
|
||||
this._defaultRemote = findDefaultRemote(remotes)
|
||||
|
@ -1334,4 +1439,21 @@ export class GitStore extends BaseStore {
|
|||
behind: aheadBehind.behind,
|
||||
}
|
||||
}
|
||||
|
||||
public async pruneForkedRemotes(openPRs: ReadonlyArray<PullRequest>) {
|
||||
const remotes = await getRemotes(this.repository)
|
||||
const prRemotes = new Set<string>()
|
||||
|
||||
for (const pr of openPRs) {
|
||||
if (pr.head.gitHubRepository.cloneURL !== null) {
|
||||
prRemotes.add(pr.head.gitHubRepository.cloneURL)
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of remotes) {
|
||||
if (r.name.startsWith(ForkedRemotePrefix) && !prRemotes.has(r.url)) {
|
||||
await removeRemote(this.repository, r.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,11 +4,11 @@ import { Branch } from '../../../models/branch'
|
|||
import { GitStoreCache } from '../git-store-cache'
|
||||
import {
|
||||
getMergedBranches,
|
||||
getCheckoutsAfterDate,
|
||||
getBranchCheckouts,
|
||||
getSymbolicRef,
|
||||
IMergedBranch,
|
||||
formatAsLocalRef,
|
||||
deleteLocalBranch,
|
||||
getBranches,
|
||||
} from '../../git'
|
||||
import { fatalError } from '../../fatal-error'
|
||||
import { RepositoryStateCache } from '../repository-state-cache'
|
||||
|
@ -28,6 +28,31 @@ const ReservedRefs = [
|
|||
'refs/heads/release',
|
||||
]
|
||||
|
||||
/**
|
||||
* Behavior flags for the branch prune execution, to aid with testing and
|
||||
* verifying locally.
|
||||
*/
|
||||
type PruneRuntimeOptions = {
|
||||
/**
|
||||
* By default the branch pruner will only run every 24 hours
|
||||
*
|
||||
* Set this flag to `false` to ignore this check.
|
||||
*/
|
||||
readonly enforcePruneThreshold: boolean
|
||||
/**
|
||||
* By default the branch pruner will also delete the branches it believes can
|
||||
* be pruned safely.
|
||||
*
|
||||
* Set this to `false` to keep these in your repository.
|
||||
*/
|
||||
readonly deleteBranch: boolean
|
||||
}
|
||||
|
||||
const DefaultPruneOptions: PruneRuntimeOptions = {
|
||||
enforcePruneThreshold: true,
|
||||
deleteBranch: true,
|
||||
}
|
||||
|
||||
export class BranchPruner {
|
||||
private timer: number | null = null
|
||||
|
||||
|
@ -48,9 +73,9 @@ export class BranchPruner {
|
|||
)
|
||||
}
|
||||
|
||||
await this.pruneLocalBranches()
|
||||
await this.pruneLocalBranches(DefaultPruneOptions)
|
||||
this.timer = window.setInterval(
|
||||
() => this.pruneLocalBranches(),
|
||||
() => this.pruneLocalBranches(DefaultPruneOptions),
|
||||
BackgroundPruneMinimumInterval
|
||||
)
|
||||
}
|
||||
|
@ -64,31 +89,47 @@ export class BranchPruner {
|
|||
this.timer = null
|
||||
}
|
||||
|
||||
public async testPrune(): Promise<void> {
|
||||
return this.pruneLocalBranches({
|
||||
enforcePruneThreshold: false,
|
||||
deleteBranch: false,
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns a map of canonical refs to their shas */
|
||||
private async findBranchesMergedIntoDefaultBranch(
|
||||
repository: Repository,
|
||||
defaultBranch: Branch
|
||||
): Promise<ReadonlyArray<IMergedBranch>> {
|
||||
): Promise<ReadonlyMap<string, string>> {
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
const mergedBranches = await gitStore.performFailableOperation(() =>
|
||||
getMergedBranches(repository, defaultBranch.name)
|
||||
)
|
||||
|
||||
if (mergedBranches === undefined) {
|
||||
return []
|
||||
return new Map<string, string>()
|
||||
}
|
||||
|
||||
const currentBranchCanonicalRef = await getSymbolicRef(repository, 'HEAD')
|
||||
|
||||
// remove the current branch
|
||||
return currentBranchCanonicalRef === null
|
||||
? mergedBranches
|
||||
: mergedBranches.filter(
|
||||
mb => mb.canonicalRef !== currentBranchCanonicalRef
|
||||
)
|
||||
if (currentBranchCanonicalRef) {
|
||||
mergedBranches.delete(currentBranchCanonicalRef)
|
||||
}
|
||||
|
||||
return mergedBranches
|
||||
}
|
||||
|
||||
private async pruneLocalBranches(): Promise<void> {
|
||||
if (this.repository.gitHubRepository === null) {
|
||||
/**
|
||||
* Prune the local branches for the repository
|
||||
*
|
||||
* @param options configure the behaviour of the branch pruning process
|
||||
*/
|
||||
private async pruneLocalBranches(
|
||||
options: PruneRuntimeOptions
|
||||
): Promise<void> {
|
||||
const { gitHubRepository } = this.repository
|
||||
if (gitHubRepository === null) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -103,18 +144,28 @@ export class BranchPruner {
|
|||
|
||||
// Using type coelescing behavior to deal with Dexie returning `undefined`
|
||||
// for records that haven't been updated with the new field yet
|
||||
if (lastPruneDate != null && threshold.isBefore(lastPruneDate)) {
|
||||
if (
|
||||
options.enforcePruneThreshold &&
|
||||
lastPruneDate != null &&
|
||||
threshold.isBefore(lastPruneDate)
|
||||
) {
|
||||
log.info(
|
||||
`Last prune took place ${moment(lastPruneDate).from(
|
||||
`[BranchPruner] Last prune took place ${moment(lastPruneDate).from(
|
||||
dateNow
|
||||
)} - skipping`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// update the last prune date first thing after we check it!
|
||||
await this.repositoriesStore.updateLastPruneDate(
|
||||
this.repository,
|
||||
Date.now()
|
||||
)
|
||||
|
||||
// Get list of branches that have been merged
|
||||
const { branchesState } = this.repositoriesStateCache.get(this.repository)
|
||||
const { defaultBranch } = branchesState
|
||||
const { defaultBranch, allBranches } = branchesState
|
||||
|
||||
if (defaultBranch === null) {
|
||||
return
|
||||
|
@ -125,8 +176,8 @@ export class BranchPruner {
|
|||
defaultBranch
|
||||
)
|
||||
|
||||
if (mergedBranches.length === 0) {
|
||||
log.info('No branches to prune.')
|
||||
if (mergedBranches.size === 0) {
|
||||
log.info('[BranchPruner] No branches to prune.')
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -134,7 +185,7 @@ export class BranchPruner {
|
|||
const twoWeeksAgo = moment()
|
||||
.subtract(2, 'weeks')
|
||||
.toDate()
|
||||
const recentlyCheckedOutBranches = await getCheckoutsAfterDate(
|
||||
const recentlyCheckedOutBranches = await getBranchCheckouts(
|
||||
this.repository,
|
||||
twoWeeksAgo
|
||||
)
|
||||
|
@ -142,17 +193,31 @@ export class BranchPruner {
|
|||
[...recentlyCheckedOutBranches.keys()].map(formatAsLocalRef)
|
||||
)
|
||||
|
||||
// Create array of branches that can be pruned
|
||||
const candidateBranches = mergedBranches.filter(
|
||||
mb => !ReservedRefs.includes(mb.canonicalRef)
|
||||
)
|
||||
// get the locally cached branches of remotes (ie `remotes/origin/master`)
|
||||
const remoteBranches = (await getBranches(
|
||||
this.repository,
|
||||
`refs/remotes/`
|
||||
)).map(b => formatAsLocalRef(b.name))
|
||||
|
||||
const branchesReadyForPruning = candidateBranches.filter(
|
||||
mb => !recentlyCheckedOutCanonicalRefs.has(mb.canonicalRef)
|
||||
// create list of branches to be pruned
|
||||
const branchesReadyForPruning = Array.from(mergedBranches.keys()).filter(
|
||||
ref => {
|
||||
if (ReservedRefs.includes(ref)) {
|
||||
return false
|
||||
}
|
||||
if (recentlyCheckedOutCanonicalRefs.has(ref)) {
|
||||
return false
|
||||
}
|
||||
const upstreamRef = getUpstreamRefForLocalBranchRef(ref, allBranches)
|
||||
if (upstreamRef === undefined) {
|
||||
return false
|
||||
}
|
||||
return !remoteBranches.includes(upstreamRef)
|
||||
}
|
||||
)
|
||||
|
||||
log.info(
|
||||
`Pruning ${
|
||||
`[BranchPruner] Pruning ${
|
||||
branchesReadyForPruning.length
|
||||
} branches that have been merged into the default branch, ${
|
||||
defaultBranch.name
|
||||
|
@ -162,26 +227,51 @@ export class BranchPruner {
|
|||
const gitStore = this.gitStoreCache.get(this.repository)
|
||||
const branchRefPrefix = `refs/heads/`
|
||||
|
||||
for (const branch of branchesReadyForPruning) {
|
||||
if (!branch.canonicalRef.startsWith(branchRefPrefix)) {
|
||||
for (const branchCanonicalRef of branchesReadyForPruning) {
|
||||
if (!branchCanonicalRef.startsWith(branchRefPrefix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const branchName = branch.canonicalRef.substr(branchRefPrefix.length)
|
||||
const branchName = branchCanonicalRef.substr(branchRefPrefix.length)
|
||||
|
||||
const isDeleted = await gitStore.performFailableOperation(() =>
|
||||
deleteLocalBranch(this.repository, branchName)
|
||||
)
|
||||
if (options.deleteBranch) {
|
||||
const isDeleted = await gitStore.performFailableOperation(() =>
|
||||
deleteLocalBranch(this.repository, branchName)
|
||||
)
|
||||
|
||||
if (isDeleted) {
|
||||
log.info(`Pruned branch ${branchName} (was ${branch.sha})`)
|
||||
if (isDeleted) {
|
||||
log.info(
|
||||
`[BranchPruner] Pruned branch ${branchName} ((was ${mergedBranches.get(
|
||||
branchCanonicalRef
|
||||
)}))`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
log.info(`[BranchPruner] Branch '${branchName}' marked for deletion`)
|
||||
}
|
||||
}
|
||||
|
||||
await this.repositoriesStore.updateLastPruneDate(
|
||||
this.repository,
|
||||
Date.now()
|
||||
)
|
||||
this.onPruneCompleted(this.repository)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ref the canonical ref for a local branch
|
||||
* @param allBranches a list of all branches in the Repository model
|
||||
* @returns the canonical upstream branch ref or undefined if upstream can't be reliably determined
|
||||
*/
|
||||
function getUpstreamRefForLocalBranchRef(
|
||||
ref: string,
|
||||
allBranches: ReadonlyArray<Branch>
|
||||
): string | undefined {
|
||||
const branch = allBranches.find(b => formatAsLocalRef(b.name) === ref)
|
||||
// if we can't find a branch model, we can't determine the ref's upstream
|
||||
if (branch === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const { upstream } = branch
|
||||
// if there's no upstream in the branch, there's nothing to lookup
|
||||
if (upstream === null) {
|
||||
return undefined
|
||||
}
|
||||
return formatAsLocalRef(upstream)
|
||||
}
|
||||
|
|
33
app/src/lib/stores/helpers/find-branch-name.ts
Normal file
33
app/src/lib/stores/helpers/find-branch-name.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Tip, TipState } from '../../../models/tip'
|
||||
import { IRemote } from '../../../models/remote'
|
||||
import { GitHubRepository } from '../../../models/github-repository'
|
||||
import { urlMatchesCloneURL } from '../../repository-matching'
|
||||
|
||||
/**
|
||||
* Function to determine which branch name to use when looking for branch
|
||||
* protection information.
|
||||
*
|
||||
* If the remote branch matches the current `githubRepository` associated with
|
||||
* the repostiory, this will be used. Otherwise we will fall back to using the
|
||||
* branch name as that's a reasonable approximation for what would happen if the
|
||||
* user tries to push the new branch.
|
||||
*/
|
||||
export function findRemoteBranchName(
|
||||
tip: Tip,
|
||||
remote: IRemote | null,
|
||||
gitHubRepository: GitHubRepository
|
||||
): string | null {
|
||||
if (tip.kind !== TipState.Valid) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
tip.branch.upstreamWithoutRemote !== null &&
|
||||
remote !== null &&
|
||||
urlMatchesCloneURL(remote.url, gitHubRepository)
|
||||
) {
|
||||
return tip.branch.upstreamWithoutRemote
|
||||
}
|
||||
|
||||
return tip.branch.nameWithoutRemote
|
||||
}
|
|
@ -1,65 +1,80 @@
|
|||
import { PullRequestStore } from '../pull-request-store'
|
||||
import { Account } from '../../../models/account'
|
||||
import { fatalError } from '../../fatal-error'
|
||||
import { Repository } from '../../../models/repository'
|
||||
import { GitHubRepository } from '../../../models/github-repository'
|
||||
|
||||
//** Interval to check for pull requests */
|
||||
const PullRequestInterval = 1000 * 60 * 10
|
||||
|
||||
enum TimeoutHandles {
|
||||
PullRequest = 'PullRequestHandle',
|
||||
Status = 'StatusHandle',
|
||||
PushedPullRequest = 'PushedPullRequestHandle',
|
||||
}
|
||||
/** Check for new or updated pull requests every 30 minutes */
|
||||
const PullRequestInterval = 30 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Acts as a service for downloading the latest pull request
|
||||
* and status info from GitHub.
|
||||
* Never check for new or updated pull requests more
|
||||
* frequently than every 2 minutes
|
||||
*/
|
||||
const MaxPullRequestRefreshFrequency = 2 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Periodically requests a refresh of the list of open pull requests
|
||||
* for a particular GitHub repository. The intention is for the
|
||||
* updater to only run when the app is in focus. When the updater
|
||||
* is started (in other words when the app is focused) it will
|
||||
* refresh the list of open pull requests as soon as possible while
|
||||
* ensuring that we never update more frequently than the value
|
||||
* indicated by the `MaxPullRequestRefreshFrequency` variable.
|
||||
*/
|
||||
export class PullRequestUpdater {
|
||||
private readonly repository: Repository
|
||||
private readonly account: Account
|
||||
private readonly store: PullRequestStore
|
||||
|
||||
private readonly timeoutHandles = new Map<TimeoutHandles, number>()
|
||||
private isStopped: boolean = true
|
||||
private timeoutId: number | null = null
|
||||
private running = false
|
||||
|
||||
public constructor(
|
||||
repository: Repository,
|
||||
account: Account,
|
||||
pullRequestStore: PullRequestStore
|
||||
) {
|
||||
this.repository = repository
|
||||
this.account = account
|
||||
this.store = pullRequestStore
|
||||
}
|
||||
private readonly repository: GitHubRepository,
|
||||
private readonly account: Account,
|
||||
private readonly store: PullRequestStore
|
||||
) {}
|
||||
|
||||
/** Starts the updater */
|
||||
public start() {
|
||||
if (!this.isStopped) {
|
||||
fatalError(
|
||||
'Cannot start the Pull Request Updater that is already running.'
|
||||
)
|
||||
if (!this.running) {
|
||||
this.running = true
|
||||
this.scheduleTick(MaxPullRequestRefreshFrequency)
|
||||
}
|
||||
}
|
||||
|
||||
private getTimeSinceLastRefresh() {
|
||||
const lastRefreshed = this.store.getLastRefreshed(this.repository)
|
||||
const timeSince =
|
||||
lastRefreshed === undefined ? Infinity : Date.now() - lastRefreshed
|
||||
return timeSince
|
||||
}
|
||||
|
||||
private scheduleTick(timeout: number = PullRequestInterval) {
|
||||
if (this.running) {
|
||||
const due = Math.max(timeout - this.getTimeSinceLastRefresh(), 0)
|
||||
this.timeoutId = window.setTimeout(() => this.tick(), due)
|
||||
}
|
||||
}
|
||||
|
||||
private tick() {
|
||||
if (!this.running) {
|
||||
return
|
||||
}
|
||||
|
||||
this.timeoutHandles.set(
|
||||
TimeoutHandles.PullRequest,
|
||||
this.timeoutId = null
|
||||
if (this.getTimeSinceLastRefresh() < MaxPullRequestRefreshFrequency) {
|
||||
this.scheduleTick()
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.store.fetchAndCachePullRequests(this.repository, this.account)
|
||||
}, PullRequestInterval)
|
||||
)
|
||||
this.store
|
||||
.refreshPullRequests(this.repository, this.account)
|
||||
.catch(() => {})
|
||||
.then(() => this.scheduleTick())
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.isStopped = true
|
||||
|
||||
for (const timeoutHandle of this.timeoutHandles.values()) {
|
||||
window.clearTimeout(timeoutHandle)
|
||||
if (this.running) {
|
||||
if (this.timeoutId !== null) {
|
||||
window.clearTimeout(this.timeoutId)
|
||||
this.timeoutId = null
|
||||
}
|
||||
this.running = false
|
||||
}
|
||||
|
||||
this.timeoutHandles.clear()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,229 +1,334 @@
|
|||
import { PullRequestDatabase, IPullRequest } from '../databases'
|
||||
import mem from 'mem'
|
||||
|
||||
import {
|
||||
PullRequestDatabase,
|
||||
IPullRequest,
|
||||
PullRequestKey,
|
||||
getPullRequestKey,
|
||||
} from '../databases/pull-request-database'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { Account } from '../../models/account'
|
||||
import { API, IAPIPullRequest } from '../api'
|
||||
import { fatalError, forceUnwrap } from '../fatal-error'
|
||||
import { API, IAPIPullRequest, MaxResultsError } from '../api'
|
||||
import { fatalError } from '../fatal-error'
|
||||
import { RepositoriesStore } from './repositories-store'
|
||||
import { PullRequest, PullRequestRef } from '../../models/pull-request'
|
||||
import { TypedBaseStore } from './base-store'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { getRemotes, removeRemote } from '../git'
|
||||
import { IRemote, ForkedRemotePrefix } from '../../models/remote'
|
||||
|
||||
const Decrement = (n: number) => n - 1
|
||||
const Increment = (n: number) => n + 1
|
||||
import { structuralEquals } from '../equality'
|
||||
import { Emitter, Disposable } from 'event-kit'
|
||||
import { APIError } from '../http'
|
||||
|
||||
/** The store for GitHub Pull Requests. */
|
||||
export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
|
||||
private readonly pullRequestDatabase: PullRequestDatabase
|
||||
private readonly repositoryStore: RepositoriesStore
|
||||
private readonly activeFetchCountPerRepository = new Map<number, number>()
|
||||
export class PullRequestStore {
|
||||
protected readonly emitter = new Emitter()
|
||||
private readonly currentRefreshOperations = new Map<number, Promise<void>>()
|
||||
private readonly lastRefreshForRepository = new Map<number, number>()
|
||||
|
||||
public constructor(
|
||||
db: PullRequestDatabase,
|
||||
repositoriesStore: RepositoriesStore
|
||||
) {
|
||||
super()
|
||||
private readonly db: PullRequestDatabase,
|
||||
private readonly repositoryStore: RepositoriesStore
|
||||
) {}
|
||||
|
||||
this.pullRequestDatabase = db
|
||||
this.repositoryStore = repositoriesStore
|
||||
private emitPullRequestsChanged(
|
||||
repository: GitHubRepository,
|
||||
pullRequests: ReadonlyArray<PullRequest>
|
||||
) {
|
||||
this.emitter.emit('onPullRequestsChanged', { repository, pullRequests })
|
||||
}
|
||||
|
||||
/** Register a function to be called when the store updates. */
|
||||
public onPullRequestsChanged(
|
||||
fn: (
|
||||
repository: GitHubRepository,
|
||||
pullRequests: ReadonlyArray<PullRequest>
|
||||
) => void
|
||||
): Disposable {
|
||||
return this.emitter.on('onPullRequestsChanged', value => {
|
||||
const { repository, pullRequests } = value
|
||||
fn(repository, pullRequests)
|
||||
})
|
||||
}
|
||||
|
||||
private emitIsLoadingPullRequests(
|
||||
repository: GitHubRepository,
|
||||
isLoadingPullRequests: boolean
|
||||
) {
|
||||
this.emitter.emit('onIsLoadingPullRequest', {
|
||||
repository,
|
||||
isLoadingPullRequests,
|
||||
})
|
||||
}
|
||||
|
||||
/** Register a function to be called when the store updates. */
|
||||
public onIsLoadingPullRequests(
|
||||
fn: (repository: GitHubRepository, isLoadingPullRequests: boolean) => void
|
||||
): Disposable {
|
||||
return this.emitter.on('onIsLoadingPullRequest', value => {
|
||||
const { repository, isLoadingPullRequests } = value
|
||||
fn(repository, isLoadingPullRequests)
|
||||
})
|
||||
}
|
||||
|
||||
/** Loads all pull requests against the given repository. */
|
||||
public async fetchAndCachePullRequests(
|
||||
repository: Repository,
|
||||
public refreshPullRequests(repo: GitHubRepository, account: Account) {
|
||||
const dbId = repo.dbID
|
||||
|
||||
if (dbId === null) {
|
||||
// This can happen when the `repositoryWithRefreshedGitHubRepository`
|
||||
// method in AppStore fails to retrieve API information about the current
|
||||
// repository either due to the user being signed out or the API failing
|
||||
// to provide a response. There's nothing for us to do when that happens
|
||||
// so instead of crashing we'll bail here.
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const currentOp = this.currentRefreshOperations.get(dbId)
|
||||
|
||||
if (currentOp !== undefined) {
|
||||
return currentOp
|
||||
}
|
||||
|
||||
this.lastRefreshForRepository.set(dbId, Date.now())
|
||||
this.emitIsLoadingPullRequests(repo, true)
|
||||
|
||||
const promise = this.fetchAndStorePullRequests(repo, account)
|
||||
.catch(err => {
|
||||
log.error(`Error refreshing pull requests for '${repo.fullName}'`, err)
|
||||
})
|
||||
.then(() => {
|
||||
this.currentRefreshOperations.delete(dbId)
|
||||
this.emitIsLoadingPullRequests(repo, false)
|
||||
})
|
||||
|
||||
this.currentRefreshOperations.set(dbId, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches pull requests from the API (either all open PRs if it's the
|
||||
* first time fetching for this repository or all updated PRs if not).
|
||||
*
|
||||
* Returns a value indicating whether it's safe to avoid
|
||||
* emitting an event that the store has been updated. In other words, when
|
||||
* this method returns false it's safe to say that nothing has been changed
|
||||
* in the pull requests table.
|
||||
*/
|
||||
private async fetchAndStorePullRequests(
|
||||
repo: GitHubRepository,
|
||||
account: Account
|
||||
): Promise<void> {
|
||||
const githubRepo = forceUnwrap(
|
||||
'Can only refresh pull requests for GitHub repositories',
|
||||
repository.gitHubRepository
|
||||
)
|
||||
const apiClient = API.fromAccount(account)
|
||||
) {
|
||||
const api = API.fromAccount(account)
|
||||
const lastUpdatedAt = await this.db.getLastUpdated(repo)
|
||||
|
||||
this.updateActiveFetchCount(githubRepo, Increment)
|
||||
|
||||
try {
|
||||
const apiResult = await apiClient.fetchPullRequests(
|
||||
githubRepo.owner.login,
|
||||
githubRepo.name,
|
||||
'open'
|
||||
)
|
||||
|
||||
await this.cachePullRequests(apiResult, githubRepo)
|
||||
|
||||
const prs = await this.fetchPullRequestsFromCache(githubRepo)
|
||||
|
||||
await this.pruneForkedRemotes(repository, prs)
|
||||
|
||||
this.emitUpdate(githubRepo)
|
||||
} catch (error) {
|
||||
log.warn(`Error refreshing pull requests for '${repository.name}'`, error)
|
||||
} finally {
|
||||
this.updateActiveFetchCount(githubRepo, Decrement)
|
||||
// If we don't have a lastUpdatedAt that mean we haven't fetched any PRs
|
||||
// for the repository yet which in turn means we only have to fetch the
|
||||
// currently open PRs. If we have fetched before we get all PRs
|
||||
// If we have a lastUpdatedAt that mean we have fetched PRs
|
||||
// for the repository before. If we have fetched before we get all PRs
|
||||
// that have been modified since the last time we fetched so that we
|
||||
// can prune closed issues from our database. Note that since
|
||||
// `api.fetchUpdatedPullRequests` returns all issues modified _at_ or
|
||||
// after the timestamp we give it we will always get at least one issue
|
||||
// back. See `storePullRequests` for details on how that's handled.
|
||||
if (!lastUpdatedAt) {
|
||||
return this.fetchAndStoreOpenPullRequests(api, repo)
|
||||
} else {
|
||||
return this.fetchAndStoreUpdatedPullRequests(api, repo, lastUpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/** Is the store currently fetching the list of open pull requests? */
|
||||
public isFetchingPullRequests(repository: GitHubRepository): boolean {
|
||||
const repoDbId = forceUnwrap(
|
||||
'Cannot fetch PRs for a repository which is not in the database',
|
||||
repository.dbID
|
||||
)
|
||||
const currentCount = this.activeFetchCountPerRepository.get(repoDbId) || 0
|
||||
|
||||
return currentCount > 0
|
||||
}
|
||||
|
||||
/** Gets the pull requests against the given repository. */
|
||||
public async fetchPullRequestsFromCache(
|
||||
private async fetchAndStoreOpenPullRequests(
|
||||
api: API,
|
||||
repository: GitHubRepository
|
||||
): Promise<ReadonlyArray<PullRequest>> {
|
||||
const gitHubRepositoryID = repository.dbID
|
||||
) {
|
||||
const { name, owner } = getNameWithOwner(repository)
|
||||
const open = await api.fetchAllOpenPullRequests(owner, name)
|
||||
await this.storePullRequestsAndEmitUpdate(open, repository)
|
||||
}
|
||||
|
||||
if (gitHubRepositoryID == null) {
|
||||
return fatalError(
|
||||
"Cannot get pull requests for a repository that hasn't been inserted into the database!"
|
||||
private async fetchAndStoreUpdatedPullRequests(
|
||||
api: API,
|
||||
repository: GitHubRepository,
|
||||
lastUpdatedAt: Date
|
||||
) {
|
||||
const { name, owner } = getNameWithOwner(repository)
|
||||
const updated = await api
|
||||
.fetchUpdatedPullRequests(owner, name, lastUpdatedAt)
|
||||
.catch(e =>
|
||||
// Any other error we'll bubble up but these ones we
|
||||
// can handle, see below.
|
||||
e instanceof MaxResultsError || e instanceof APIError
|
||||
? Promise.resolve(null)
|
||||
: Promise.reject(e)
|
||||
)
|
||||
|
||||
if (updated !== null) {
|
||||
return await this.storePullRequestsAndEmitUpdate(updated, repository)
|
||||
} else {
|
||||
// If we fail to load updated pull requests either because
|
||||
// there's too many updated PRs since the last time we
|
||||
// fetched (and it's likely that it'll be much more
|
||||
// efficient to just load the open PRs) or it's because the
|
||||
// API told us we couldn't load PRs (rate limit or permissions
|
||||
// problems). In either case we delete the PRs we've got
|
||||
// for this repo and attempt to load just the open ones.
|
||||
//
|
||||
// This scenario can happen for repositories that are
|
||||
// very active while simultaneously infrequently used
|
||||
// by the user. Think of a very active open source repository
|
||||
// where the user only visits once a year to make a contribution.
|
||||
// It's likely that there's at most a few hundred PRs open but
|
||||
// the number of merged PRs since the last time we fetched could
|
||||
// number in the thousands.
|
||||
await this.db.deleteAllPullRequestsInRepository(repository)
|
||||
await this.fetchAndStoreOpenPullRequests(api, repository)
|
||||
}
|
||||
}
|
||||
|
||||
public getLastRefreshed(repository: GitHubRepository) {
|
||||
return repository.dbID
|
||||
? this.lastRefreshForRepository.get(repository.dbID)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Gets all stored pull requests for the given repository. */
|
||||
public async getAll(repository: GitHubRepository) {
|
||||
if (repository.dbID === null) {
|
||||
// This can happen when the `repositoryWithRefreshedGitHubRepository`
|
||||
// method in AppStore fails to retrieve API information about the current
|
||||
// repository either due to the user being signed out or the API failing
|
||||
// to provide a response. There's nothing for us to do when that happens
|
||||
// so instead of crashing we'll bail here.
|
||||
return []
|
||||
}
|
||||
|
||||
const records = await this.pullRequestDatabase.pullRequests
|
||||
.where('base.repoId')
|
||||
.equals(gitHubRepositoryID)
|
||||
.reverse()
|
||||
.sortBy('number')
|
||||
|
||||
const records = await this.db.getAllPullRequestsInRepository(repository)
|
||||
const result = new Array<PullRequest>()
|
||||
|
||||
for (const record of records) {
|
||||
const repositoryDbId = record.head.repoId
|
||||
let githubRepository: GitHubRepository | null = null
|
||||
// In order to avoid what would otherwise be a very expensive
|
||||
// N+1 (N+2 really) query where we look up the head and base
|
||||
// GitHubRepository from IndexedDB for each pull request we'll memoize
|
||||
// already retrieved GitHubRepository instances.
|
||||
//
|
||||
// This optimization decreased the run time of this method from 6
|
||||
// seconds to just under 26 ms while testing using an internal
|
||||
// repository with 1k+ PRs. Even in the worst-case scenario (i.e
|
||||
// a repository with a huge number of open PRs from forks) this
|
||||
// will reduce the N+2 to N+1.
|
||||
const store = this.repositoryStore
|
||||
const getRepo = mem(store.findGitHubRepositoryByID.bind(store))
|
||||
|
||||
if (repositoryDbId != null) {
|
||||
githubRepository = await this.repositoryStore.findGitHubRepositoryByID(
|
||||
repositoryDbId
|
||||
)
|
||||
for (const record of records) {
|
||||
const headRepository = await getRepo(record.head.repoId)
|
||||
const baseRepository = await getRepo(record.base.repoId)
|
||||
|
||||
if (headRepository === null) {
|
||||
return fatalError("head repository can't be null")
|
||||
}
|
||||
|
||||
// We know the base repo ID can't be null since it's the repository we
|
||||
// fetched the PR from in the first place.
|
||||
const parentRepositoryDbId = forceUnwrap(
|
||||
'A pull request cannot have a null base repo id',
|
||||
record.base.repoId
|
||||
)
|
||||
const parentGitGubRepository: GitHubRepository | null = await this.repositoryStore.findGitHubRepositoryByID(
|
||||
parentRepositoryDbId
|
||||
)
|
||||
const parentGitHubRepository = forceUnwrap(
|
||||
'PR cannot have a null base repo',
|
||||
parentGitGubRepository
|
||||
)
|
||||
|
||||
// We can be certain the PR ID is valid since we just got it from the
|
||||
// database.
|
||||
const pullRequestDbId = forceUnwrap(
|
||||
'PR cannot have a null ID after being retrieved from the database',
|
||||
record.id
|
||||
)
|
||||
if (baseRepository === null) {
|
||||
return fatalError("base repository can't be null")
|
||||
}
|
||||
|
||||
result.push(
|
||||
new PullRequest(
|
||||
pullRequestDbId,
|
||||
new Date(record.createdAt),
|
||||
record.title,
|
||||
record.number,
|
||||
new PullRequestRef(
|
||||
record.head.ref,
|
||||
record.head.sha,
|
||||
githubRepository
|
||||
),
|
||||
new PullRequestRef(
|
||||
record.base.ref,
|
||||
record.base.sha,
|
||||
parentGitHubRepository
|
||||
),
|
||||
new PullRequestRef(record.head.ref, record.head.sha, headRepository),
|
||||
new PullRequestRef(record.base.ref, record.base.sha, baseRepository),
|
||||
record.author
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
// Reversing the results in place manually instead of using
|
||||
// .reverse on the IndexedDB query has been measured to have favorable
|
||||
// performance characteristics for repositories with a lot of pull
|
||||
// requests since it means Dexie is able to leverage the IndexedDB
|
||||
// getAll method as opposed to creating a reverse cursor. Reversing
|
||||
// in place versus unshifting is also dramatically more performant.
|
||||
return result.reverse()
|
||||
}
|
||||
|
||||
private async pruneForkedRemotes(
|
||||
repository: Repository,
|
||||
pullRequests: ReadonlyArray<PullRequest>
|
||||
) {
|
||||
const remotes = await getRemotes(repository)
|
||||
const forkedRemotesToDelete = this.getRemotesToDelete(remotes, pullRequests)
|
||||
|
||||
await this.deleteRemotes(repository, forkedRemotesToDelete)
|
||||
}
|
||||
|
||||
private getRemotesToDelete(
|
||||
remotes: ReadonlyArray<IRemote>,
|
||||
openPullRequests: ReadonlyArray<PullRequest>
|
||||
): ReadonlyArray<IRemote> {
|
||||
const forkedRemotes = remotes.filter(remote =>
|
||||
remote.name.startsWith(ForkedRemotePrefix)
|
||||
)
|
||||
const remotesOfPullRequests = new Set<string>()
|
||||
|
||||
openPullRequests.forEach(pr => {
|
||||
const { gitHubRepository } = pr.head
|
||||
|
||||
if (gitHubRepository != null && gitHubRepository.cloneURL != null) {
|
||||
remotesOfPullRequests.add(gitHubRepository.cloneURL)
|
||||
}
|
||||
})
|
||||
|
||||
const result = forkedRemotes.filter(
|
||||
forkedRemote => !remotesOfPullRequests.has(forkedRemote.url)
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async deleteRemotes(
|
||||
repository: Repository,
|
||||
remotes: ReadonlyArray<IRemote>
|
||||
) {
|
||||
const promises: Array<Promise<void>> = []
|
||||
|
||||
remotes.forEach(r => promises.push(removeRemote(repository, r.name)))
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
private updateActiveFetchCount(
|
||||
repository: GitHubRepository,
|
||||
update: (count: number) => number
|
||||
) {
|
||||
const repoDbId = forceUnwrap(
|
||||
'Cannot fetch PRs for a repository which is not in the database',
|
||||
repository.dbID
|
||||
)
|
||||
const currentCount = this.activeFetchCountPerRepository.get(repoDbId) || 0
|
||||
const newCount = update(currentCount)
|
||||
|
||||
this.activeFetchCountPerRepository.set(repoDbId, newCount)
|
||||
this.emitUpdate(repository)
|
||||
}
|
||||
|
||||
private async cachePullRequests(
|
||||
/**
|
||||
* Stores all pull requests that are open and deletes all that are merged
|
||||
* or closed. Returns a value indicating whether an update notification
|
||||
* has been emitted, see `storePullRequests` for more details.
|
||||
*/
|
||||
private async storePullRequestsAndEmitUpdate(
|
||||
pullRequestsFromAPI: ReadonlyArray<IAPIPullRequest>,
|
||||
repository: GitHubRepository
|
||||
): Promise<void> {
|
||||
const repoDbId = repository.dbID
|
||||
) {
|
||||
if (await this.storePullRequests(pullRequestsFromAPI, repository)) {
|
||||
this.emitPullRequestsChanged(repository, await this.getAll(repository))
|
||||
}
|
||||
}
|
||||
|
||||
if (repoDbId == null) {
|
||||
return fatalError(
|
||||
"Cannot store pull requests for a repository that hasn't been inserted into the database!"
|
||||
)
|
||||
/**
|
||||
* Stores all pull requests that are open and deletes all that are merged
|
||||
* or closed. Returns a value indicating whether it's safe to avoid
|
||||
* emitting an event that the store has been updated. In other words, when
|
||||
* this method returns false it's safe to say that nothing has been changed
|
||||
* in the pull requests table.
|
||||
*/
|
||||
private async storePullRequests(
|
||||
pullRequestsFromAPI: ReadonlyArray<IAPIPullRequest>,
|
||||
repository: GitHubRepository
|
||||
) {
|
||||
if (pullRequestsFromAPI.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const table = this.pullRequestDatabase.pullRequests
|
||||
const prsToInsert = new Array<IPullRequest>()
|
||||
let mostRecentlyUpdated = pullRequestsFromAPI[0].updated_at
|
||||
|
||||
const prsToDelete = new Array<PullRequestKey>()
|
||||
const prsToUpsert = new Array<IPullRequest>()
|
||||
|
||||
// The API endpoint for this PR, i.e api.github.com or a GHE url
|
||||
const { endpoint } = repository
|
||||
const store = this.repositoryStore
|
||||
|
||||
// Upsert will always query the database for a repository. Given that
|
||||
// we've receive these repositories in a batch response from the API
|
||||
// it's pretty unlikely that they'd differ between PRs so we're going
|
||||
// to use the upsert just to ensure that the repo exists in the database
|
||||
// and reuse the same object without going to the database for all that
|
||||
// follow.
|
||||
const upsertRepo = mem(store.upsertGitHubRepository.bind(store), {
|
||||
// The first argument which we're ignoring here is the endpoint
|
||||
// which is constant throughout the lifetime of this function.
|
||||
// The second argument is an `IAPIRepository` which is basically
|
||||
// the raw object that we got from the API which could consist of
|
||||
// more than just the fields we've modelled in the interface. The
|
||||
// only thing we really care about to determine whether the
|
||||
// repository has already been inserted in the database is the clone
|
||||
// url since that's what the upsert method uses as its key.
|
||||
cacheKey: (_, repo) => repo.clone_url,
|
||||
})
|
||||
|
||||
for (const pr of pullRequestsFromAPI) {
|
||||
// We can do this string comparison here rather than convert to date
|
||||
// because ISO8601 is lexicographically sortable
|
||||
if (pr.updated_at > mostRecentlyUpdated) {
|
||||
mostRecentlyUpdated = pr.updated_at
|
||||
}
|
||||
|
||||
// We know the base repo isn't null since that's where we got the PR from
|
||||
// in the first place.
|
||||
if (pr.base.repo === null) {
|
||||
return fatalError('PR cannot have a null base repo')
|
||||
}
|
||||
|
||||
const baseGitHubRepo = await upsertRepo(endpoint, pr.base.repo)
|
||||
|
||||
if (baseGitHubRepo.dbID === null) {
|
||||
return fatalError('PR cannot have a null parent database id')
|
||||
}
|
||||
|
||||
if (pr.state === 'closed') {
|
||||
prsToDelete.push(getPullRequestKey(baseGitHubRepo, pr.number))
|
||||
continue
|
||||
}
|
||||
|
||||
// `pr.head.repo` represents the source of the pull request. It might be
|
||||
// a branch associated with the current repository, or a fork of the
|
||||
// current repository.
|
||||
|
@ -231,71 +336,73 @@ export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
|
|||
// In cases where the user has removed the fork of the repository after
|
||||
// opening a pull request, this can be `null`, and the app will not store
|
||||
// this pull request.
|
||||
|
||||
if (pr.head.repo == null) {
|
||||
log.debug(
|
||||
`Unable to store pull request #${pr.number} for repository ${
|
||||
repository.fullName
|
||||
} as it has no head repository associated with it`
|
||||
)
|
||||
prsToDelete.push(getPullRequestKey(baseGitHubRepo, pr.number))
|
||||
continue
|
||||
}
|
||||
|
||||
const githubRepo = await this.repositoryStore.upsertGitHubRepository(
|
||||
repository.endpoint,
|
||||
pr.head.repo
|
||||
)
|
||||
const headRepo = await upsertRepo(endpoint, pr.head.repo)
|
||||
|
||||
const githubRepoDbId = forceUnwrap(
|
||||
'PR cannot have non-existent repo',
|
||||
githubRepo.dbID
|
||||
)
|
||||
if (headRepo.dbID === null) {
|
||||
return fatalError('PR cannot have non-existent repo')
|
||||
}
|
||||
|
||||
// We know the base repo isn't null since that's where we got the PR from
|
||||
// in the first place.
|
||||
const parentRepo = forceUnwrap(
|
||||
'PR cannot have a null base repo',
|
||||
pr.base.repo
|
||||
)
|
||||
const parentGitHubRepo = await this.repositoryStore.upsertGitHubRepository(
|
||||
repository.endpoint,
|
||||
parentRepo
|
||||
)
|
||||
const parentGitHubRepoDbId = forceUnwrap(
|
||||
'PR cannot have a null parent database id',
|
||||
parentGitHubRepo.dbID
|
||||
)
|
||||
|
||||
prsToInsert.push({
|
||||
prsToUpsert.push({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
createdAt: pr.created_at,
|
||||
updatedAt: pr.updated_at,
|
||||
head: {
|
||||
ref: pr.head.ref,
|
||||
sha: pr.head.sha,
|
||||
repoId: githubRepoDbId,
|
||||
repoId: headRepo.dbID,
|
||||
},
|
||||
base: {
|
||||
ref: pr.base.ref,
|
||||
sha: pr.base.sha,
|
||||
repoId: parentGitHubRepoDbId,
|
||||
repoId: baseGitHubRepo.dbID,
|
||||
},
|
||||
author: pr.user.login,
|
||||
})
|
||||
}
|
||||
|
||||
return this.pullRequestDatabase.transaction('rw', table, async () => {
|
||||
// we need to delete the stales PRs from the db
|
||||
// so we remove all for a repo to avoid having to
|
||||
// do diffing
|
||||
await table
|
||||
.where('base.repoId')
|
||||
.equals(repoDbId)
|
||||
.delete()
|
||||
// When loading only PRs that has changed since the last fetch
|
||||
// we get back all PRs modified _at_ or after the timestamp we give it
|
||||
// meaning we will always get at least one issue back but. This
|
||||
// check detect this particular condition and lets us avoid expensive
|
||||
// branch pruning and updates for a single PR that hasn't actually
|
||||
// been updated.
|
||||
if (prsToDelete.length === 0 && prsToUpsert.length === 1) {
|
||||
const cur = prsToUpsert[0]
|
||||
const prev = await this.db.getPullRequest(repository, cur.number)
|
||||
|
||||
if (prsToInsert.length > 0) {
|
||||
await table.bulkAdd(prsToInsert)
|
||||
if (prev !== undefined && structuralEquals(cur, prev)) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await this.db.transaction(
|
||||
'rw',
|
||||
this.db.pullRequests,
|
||||
this.db.pullRequestsLastUpdated,
|
||||
async () => {
|
||||
await this.db.deletePullRequests(prsToDelete)
|
||||
await this.db.putPullRequests(prsToUpsert)
|
||||
await this.db.setLastUpdated(repository, new Date(mostRecentlyUpdated))
|
||||
}
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function getNameWithOwner(repository: GitHubRepository) {
|
||||
const owner = repository.owner.login
|
||||
const name = repository.name
|
||||
return { name, owner }
|
||||
}
|
||||
|
|
|
@ -2,18 +2,35 @@ import {
|
|||
RepositoriesDatabase,
|
||||
IDatabaseGitHubRepository,
|
||||
IDatabaseOwner,
|
||||
IDatabaseProtectedBranch,
|
||||
} from '../databases/repositories-database'
|
||||
import { Owner } from '../../models/owner'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { fatalError } from '../fatal-error'
|
||||
import { IAPIRepository } from '../api'
|
||||
import { IAPIRepository, IAPIBranch } from '../api'
|
||||
import { BaseStore } from './base-store'
|
||||
import { enableBranchProtectionChecks } from '../feature-flag'
|
||||
|
||||
/** The store for local repositories. */
|
||||
export class RepositoriesStore extends BaseStore {
|
||||
private db: RepositoriesDatabase
|
||||
|
||||
// Key-repo ID, Value-date
|
||||
private lastStashCheckCache = new Map<number, number>()
|
||||
|
||||
/**
|
||||
* Key is the GitHubRepository id, value is the protected branch count reported
|
||||
* by the GitHub API.
|
||||
*/
|
||||
private branchProtectionSettingsFoundCache = new Map<number, boolean>()
|
||||
|
||||
/**
|
||||
* Key is the lookup by the GitHubRepository id and branch name, value is the
|
||||
* flag whether this branch is considered protected by the GitHub API
|
||||
*/
|
||||
private protectionEnabledForBranchCache = new Map<string, boolean>()
|
||||
|
||||
public constructor(db: RepositoriesDatabase) {
|
||||
super()
|
||||
|
||||
|
@ -147,6 +164,7 @@ export class RepositoriesStore extends BaseStore {
|
|||
path,
|
||||
gitHubRepositoryID: null,
|
||||
missing: false,
|
||||
lastStashCheckDate: null,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -181,11 +199,16 @@ export class RepositoriesStore extends BaseStore {
|
|||
const gitHubRepositoryID = repository.gitHubRepository
|
||||
? repository.gitHubRepository.dbID
|
||||
: null
|
||||
const oldRecord = await this.db.repositories.get(repoID)
|
||||
const lastStashCheckDate =
|
||||
oldRecord !== undefined ? oldRecord.lastStashCheckDate : null
|
||||
|
||||
await this.db.repositories.put({
|
||||
id: repository.id,
|
||||
path: repository.path,
|
||||
missing,
|
||||
gitHubRepositoryID,
|
||||
lastStashCheckDate,
|
||||
})
|
||||
|
||||
this.emitUpdate()
|
||||
|
@ -213,11 +236,16 @@ export class RepositoriesStore extends BaseStore {
|
|||
const gitHubRepositoryID = repository.gitHubRepository
|
||||
? repository.gitHubRepository.dbID
|
||||
: null
|
||||
const oldRecord = await this.db.repositories.get(repoID)
|
||||
const lastStashCheckDate =
|
||||
oldRecord !== undefined ? oldRecord.lastStashCheckDate : null
|
||||
|
||||
await this.db.repositories.put({
|
||||
id: repository.id,
|
||||
missing: false,
|
||||
path: path,
|
||||
path,
|
||||
gitHubRepositoryID,
|
||||
lastStashCheckDate,
|
||||
})
|
||||
|
||||
this.emitUpdate()
|
||||
|
@ -230,6 +258,69 @@ export class RepositoriesStore extends BaseStore {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last time the repository was checked for stash entries
|
||||
*
|
||||
* @param repository The repository in which to update the last stash check date for
|
||||
* @param date The date and time in which the last stash check took place; defaults to
|
||||
* the current time
|
||||
*/
|
||||
public async updateLastStashCheckDate(
|
||||
repository: Repository,
|
||||
date: number = Date.now()
|
||||
): Promise<void> {
|
||||
const repoID = repository.id
|
||||
if (repoID === 0) {
|
||||
return fatalError(
|
||||
'`updateLastStashCheckDate` can only update the last stash check date for a repository which has been added to the database.'
|
||||
)
|
||||
}
|
||||
|
||||
await this.db.repositories.update(repoID, {
|
||||
lastStashCheckDate: date,
|
||||
})
|
||||
|
||||
this.lastStashCheckCache.set(repoID, date)
|
||||
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last time the repository was checked for stash entries
|
||||
*
|
||||
* @param repository The repository in which to update the last stash check date for
|
||||
*/
|
||||
public async getLastStashCheckDate(
|
||||
repository: Repository
|
||||
): Promise<number | null> {
|
||||
const repoID = repository.id
|
||||
if (!repoID) {
|
||||
return fatalError(
|
||||
'`getLastStashCheckDate` - can only retrieve the last stash check date for a repositories that have been stored in the database.'
|
||||
)
|
||||
}
|
||||
|
||||
let lastCheckDate = this.lastStashCheckCache.get(repoID) || null
|
||||
if (lastCheckDate !== null) {
|
||||
return lastCheckDate
|
||||
}
|
||||
|
||||
const record = await this.db.repositories.get(repoID)
|
||||
|
||||
if (record === undefined) {
|
||||
return fatalError(
|
||||
`'getLastStashCheckDate' - unable to find repository with ID: ${repoID}`
|
||||
)
|
||||
}
|
||||
|
||||
lastCheckDate = record.lastStashCheckDate
|
||||
if (lastCheckDate !== null) {
|
||||
this.lastStashCheckCache.set(repoID, lastCheckDate)
|
||||
}
|
||||
|
||||
return lastCheckDate
|
||||
}
|
||||
|
||||
private async putOwner(endpoint: string, login: string): Promise<Owner> {
|
||||
login = login.toLowerCase()
|
||||
|
||||
|
@ -336,6 +427,69 @@ export class RepositoriesStore extends BaseStore {
|
|||
)
|
||||
}
|
||||
|
||||
/** Add or update the branch protections associated with a GitHub repository. */
|
||||
public async updateBranchProtections(
|
||||
gitHubRepository: GitHubRepository,
|
||||
protectedBranches: ReadonlyArray<IAPIBranch>
|
||||
): Promise<void> {
|
||||
if (!enableBranchProtectionChecks()) {
|
||||
return
|
||||
}
|
||||
|
||||
const dbID = gitHubRepository.dbID
|
||||
if (!dbID) {
|
||||
return fatalError(
|
||||
'`updateBranchProtections` can only update a GitHub repository for a repository which has been added to the database.'
|
||||
)
|
||||
}
|
||||
|
||||
await this.db.transaction('rw', this.db.protectedBranches, async () => {
|
||||
// This update flow is organized into two stages:
|
||||
//
|
||||
// - update the in-memory cache
|
||||
// - update the underyling database state
|
||||
//
|
||||
// This should ensure any stale values are not being used, and avoids
|
||||
// the need to query the database while the results are in memory.
|
||||
|
||||
const prefix = getKeyPrefix(dbID)
|
||||
|
||||
for (const key of this.protectionEnabledForBranchCache.keys()) {
|
||||
// invalidate any cached entries belonging to this repository
|
||||
if (key.startsWith(prefix)) {
|
||||
this.protectionEnabledForBranchCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const branchRecords = protectedBranches.map<IDatabaseProtectedBranch>(
|
||||
b => ({
|
||||
repoId: dbID,
|
||||
name: b.name,
|
||||
})
|
||||
)
|
||||
|
||||
// update cached values to avoid database lookup
|
||||
for (const item of branchRecords) {
|
||||
const key = getKey(dbID, item.name)
|
||||
this.protectionEnabledForBranchCache.set(key, true)
|
||||
}
|
||||
|
||||
await this.db.protectedBranches
|
||||
.where('repoId')
|
||||
.equals(dbID)
|
||||
.delete()
|
||||
|
||||
const protectionsFound = branchRecords.length > 0
|
||||
this.branchProtectionSettingsFoundCache.set(dbID, protectionsFound)
|
||||
|
||||
if (branchRecords.length > 0) {
|
||||
await this.db.protectedBranches.bulkAdd(branchRecords)
|
||||
}
|
||||
})
|
||||
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set's the last time the repository was checked for pruning
|
||||
*
|
||||
|
@ -408,4 +562,97 @@ export class RepositoriesStore extends BaseStore {
|
|||
|
||||
return record!.lastPruneDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the branch protection information for a repository from the database
|
||||
* and cache the results in memory
|
||||
*/
|
||||
private async loadAndCacheBranchProtection(dbID: number) {
|
||||
// query the database to find any protected branches
|
||||
const branches = await this.db.protectedBranches
|
||||
.where('repoId')
|
||||
.equals(dbID)
|
||||
.toArray()
|
||||
|
||||
const branchProtectionsFound = branches.length > 0
|
||||
this.branchProtectionSettingsFoundCache.set(dbID, branchProtectionsFound)
|
||||
|
||||
// fill the retrieved records into the per-branch cache
|
||||
for (const branch of branches) {
|
||||
const key = getKey(dbID, branch.name)
|
||||
this.protectionEnabledForBranchCache.set(key, true)
|
||||
}
|
||||
|
||||
return branchProtectionsFound
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any branch protection settings are enabled for the repository
|
||||
* through the GitHub API.
|
||||
*/
|
||||
public async hasBranchProtectionsConfigured(
|
||||
gitHubRepository: GitHubRepository
|
||||
): Promise<boolean> {
|
||||
if (gitHubRepository.dbID === null) {
|
||||
return fatalError(
|
||||
'unable to get protected branches, GitHub repository has a null dbID'
|
||||
)
|
||||
}
|
||||
|
||||
const { dbID } = gitHubRepository
|
||||
const branchProtectionsFound = this.branchProtectionSettingsFoundCache.get(
|
||||
dbID
|
||||
)
|
||||
|
||||
if (branchProtectionsFound === undefined) {
|
||||
return this.loadAndCacheBranchProtection(dbID)
|
||||
}
|
||||
|
||||
return branchProtectionsFound
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given branch for the repository is protected through the
|
||||
* GitHub API.
|
||||
*/
|
||||
public async isBranchProtectedOnRemote(
|
||||
gitHubRepository: GitHubRepository,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
if (gitHubRepository.dbID === null) {
|
||||
return fatalError(
|
||||
'unable to get protected branches, GitHub repository has a null dbID'
|
||||
)
|
||||
}
|
||||
|
||||
const { dbID } = gitHubRepository
|
||||
const key = getKey(dbID, branchName)
|
||||
|
||||
const cachedProtectionValue = this.protectionEnabledForBranchCache.get(key)
|
||||
if (cachedProtectionValue === true) {
|
||||
return cachedProtectionValue
|
||||
}
|
||||
|
||||
const databaseValue = await this.db.protectedBranches.get([
|
||||
dbID,
|
||||
branchName,
|
||||
])
|
||||
|
||||
// if no row found, this means no protection is found for the branch
|
||||
const value = databaseValue !== undefined
|
||||
|
||||
this.protectionEnabledForBranchCache.set(key, value)
|
||||
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the key for the branch protection cache */
|
||||
function getKey(dbID: number, branchName: string) {
|
||||
return `${getKeyPrefix(dbID)}${branchName}`
|
||||
}
|
||||
|
||||
/** Compute the key prefix for the branch protection cache */
|
||||
function getKeyPrefix(dbID: number) {
|
||||
return `${dbID}-`
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ import {
|
|||
IRepositoryState,
|
||||
RepositorySectionTab,
|
||||
ICommitSelection,
|
||||
IRebaseState,
|
||||
ChangesSelectionKind,
|
||||
} from '../app-state'
|
||||
import { ComparisonCache } from '../comparison-cache'
|
||||
import { IGitHubUser } from '../databases'
|
||||
|
@ -97,6 +99,17 @@ export class RepositoryStateCache {
|
|||
return { branchesState: newState }
|
||||
})
|
||||
}
|
||||
|
||||
public updateRebaseState<K extends keyof IRebaseState>(
|
||||
repository: Repository,
|
||||
fn: (branchesState: IRebaseState) => Pick<IRebaseState, K>
|
||||
) {
|
||||
this.update(repository, state => {
|
||||
const { rebaseState } = state
|
||||
const newState = merge(rebaseState, fn(rebaseState))
|
||||
return { rebaseState: newState }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialRepositoryState(): IRepositoryState {
|
||||
|
@ -111,12 +124,17 @@ function getInitialRepositoryState(): IRepositoryState {
|
|||
workingDirectory: WorkingDirectoryStatus.fromFiles(
|
||||
new Array<WorkingDirectoryFileChange>()
|
||||
),
|
||||
selectedFileIDs: [],
|
||||
diff: null,
|
||||
selection: {
|
||||
kind: ChangesSelectionKind.WorkingDirectory,
|
||||
selectedFileIDs: [],
|
||||
diff: null,
|
||||
},
|
||||
commitMessage: DefaultCommitMessage,
|
||||
coAuthors: [],
|
||||
showCoAuthoredBy: false,
|
||||
conflictState: null,
|
||||
stashEntry: null,
|
||||
currentBranchProtected: false,
|
||||
},
|
||||
selectedSection: RepositorySectionTab.Changes,
|
||||
branchesState: {
|
||||
|
@ -145,6 +163,12 @@ function getInitialRepositoryState(): IRepositoryState {
|
|||
defaultBranch: null,
|
||||
inferredComparisonBranch: { branch: null, aheadBehind: null },
|
||||
},
|
||||
rebaseState: {
|
||||
step: null,
|
||||
progress: null,
|
||||
commits: null,
|
||||
userHasResolvedConflicts: false,
|
||||
},
|
||||
commitAuthor: null,
|
||||
gitHubUsers: new Map<string, IGitHubUser>(),
|
||||
commitLookup: new Map<string, Commit>(),
|
||||
|
@ -157,7 +181,5 @@ function getInitialRepositoryState(): IRepositoryState {
|
|||
checkoutProgress: null,
|
||||
pushPullFetchProgress: null,
|
||||
revertProgress: null,
|
||||
branchFilterText: '',
|
||||
pullRequestFilterText: '',
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ 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.`
|
||||
const EnterpriseTooOldMessage = `The GitHub Enterprise Server version does not support GitHub Desktop. Talk to your server's administrator about upgrading to the latest version of GitHub Enterprise Server.`
|
||||
|
||||
/**
|
||||
* An enumeration of the possible steps that the sign in
|
||||
|
@ -79,8 +79,8 @@ export interface ISignInState {
|
|||
|
||||
/**
|
||||
* 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
|
||||
* This is the initial step in the Enterprise Server sign in
|
||||
* flow and is not present when signing in to GitHub.com
|
||||
*/
|
||||
export interface IEndpointEntryState extends ISignInState {
|
||||
readonly kind: SignInStep.EndpointEntry
|
||||
|
@ -91,7 +91,7 @@ export interface IEndpointEntryState extends ISignInState {
|
|||
* 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.
|
||||
* signing in to a GitHub Enterprise Server instance.
|
||||
*/
|
||||
export interface IAuthenticationState extends ISignInState {
|
||||
readonly kind: SignInStep.Authentication
|
||||
|
@ -100,15 +100,16 @@ export interface IAuthenticationState extends ISignInState {
|
|||
* The URL to the host which we're currently authenticating
|
||||
* against. This will be either https://api.github.com when
|
||||
* signing in against GitHub.com or a user-specified
|
||||
* URL when signing in against a GitHub Enterprise instance.
|
||||
* URL when signing in against a GitHub Enterprise Server
|
||||
* instance.
|
||||
*/
|
||||
readonly endpoint: string
|
||||
|
||||
/**
|
||||
* A value indicating whether or not the endpoint supports
|
||||
* basic authentication (i.e. username and password). All
|
||||
* GitHub Enterprise instances support OAuth (or web flow
|
||||
* sign-in).
|
||||
* GitHub Enterprise Server instances support OAuth (or web
|
||||
* flow sign-in).
|
||||
*/
|
||||
readonly supportsBasicAuth: boolean
|
||||
|
||||
|
@ -122,8 +123,8 @@ export interface IAuthenticationState extends ISignInState {
|
|||
* 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.
|
||||
* and GitHub Enterprise Server when the user has enabled two
|
||||
* factor authentication on the host.
|
||||
*/
|
||||
export interface ITwoFactorAuthenticationState extends ISignInState {
|
||||
readonly kind: SignInStep.TwoFactorAuthentication
|
||||
|
@ -132,7 +133,8 @@ export interface ITwoFactorAuthenticationState extends ISignInState {
|
|||
* The URL to the host which we're currently authenticating
|
||||
* against. This will be either https://api.github.com when
|
||||
* signing in against GitHub.com or a user-specified
|
||||
* URL when signing in against a GitHub Enterprise instance.
|
||||
* URL when signing in against a GitHub Enterprise Server
|
||||
* instance.
|
||||
*/
|
||||
readonly endpoint: string
|
||||
|
||||
|
@ -187,7 +189,7 @@ interface IAuthenticationEvent {
|
|||
|
||||
/**
|
||||
* A store encapsulating all logic related to signing in a user
|
||||
* to GitHub.com, or a GitHub Enterprise instance.
|
||||
* to GitHub.com, or a GitHub Enterprise Server instance.
|
||||
*/
|
||||
export class SignInStore extends TypedBaseStore<SignInState | null> {
|
||||
private state: SignInState | null = null
|
||||
|
@ -240,7 +242,7 @@ export class SignInStore extends TypedBaseStore<SignInState | null> {
|
|||
}
|
||||
} else {
|
||||
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.`
|
||||
`Unable to authenticate with the GitHub Enterprise Server instance. Verify that the URL is correct, that your GitHub Enterprise Server instance is running version ${minimumSupportedEnterpriseVersion} or later, that you have an internet connection and try again.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -438,9 +440,9 @@ export class SignInStore extends TypedBaseStore<SignInState | null> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Initiate a sign in flow for a GitHub Enterprise instance. This will
|
||||
* put the store in the EndpointEntry step ready to receive the url
|
||||
* to the enterprise instance.
|
||||
* Initiate a sign in flow for a GitHub Enterprise Server instance.
|
||||
* This will put the store in the EndpointEntry step ready to
|
||||
* receive the url to the enterprise instance.
|
||||
*/
|
||||
public beginEnterpriseSignIn() {
|
||||
this.setState({
|
||||
|
@ -482,11 +484,11 @@ export class SignInStore extends TypedBaseStore<SignInState | null> {
|
|||
let error = e
|
||||
if (e.name === InvalidURLErrorName) {
|
||||
error = new Error(
|
||||
`The GitHub Enterprise instance address doesn't appear to be a valid URL. We're expecting something like https://github.example.com.`
|
||||
`The GitHub Enterprise Server instance address doesn't appear to be a valid URL. We're expecting something like https://github.example.com.`
|
||||
)
|
||||
} else if (e.name === InvalidProtocolErrorName) {
|
||||
error = new Error(
|
||||
'Unsupported protocol. Only http or https is supported when authenticating with GitHub Enterprise instances.'
|
||||
'Unsupported protocol. Only http or https is supported when authenticating with GitHub Enterprise Server instances.'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -10,11 +10,14 @@ import {
|
|||
isMergeConflictState,
|
||||
isRebaseConflictState,
|
||||
RebaseConflictState,
|
||||
ChangesSelection,
|
||||
ChangesSelectionKind,
|
||||
} from '../../app-state'
|
||||
import { DiffSelectionType, IDiff } from '../../../models/diff'
|
||||
import { DiffSelectionType } from '../../../models/diff'
|
||||
import { caseInsensitiveCompare } from '../../compare'
|
||||
import { IStatsStore } from '../../stats/stats-store'
|
||||
import { ManualConflictResolution } from '../../../models/manual-conflict-resolution'
|
||||
import { assertNever } from '../../fatal-error'
|
||||
|
||||
/**
|
||||
* Internal shape of the return value from this response because the compiler
|
||||
|
@ -23,8 +26,7 @@ import { ManualConflictResolution } from '../../../models/manual-conflict-resolu
|
|||
*/
|
||||
type ChangedFilesResult = {
|
||||
readonly workingDirectory: WorkingDirectoryStatus
|
||||
readonly selectedFileIDs: string[]
|
||||
readonly diff: IDiff | null
|
||||
readonly selection: ChangesSelection
|
||||
}
|
||||
|
||||
export function updateChangedFiles(
|
||||
|
@ -62,19 +64,6 @@ export function updateChangedFiles(
|
|||
// lookups using .find on the mergedFiles array.
|
||||
const mergedFileIds = new Set(mergedFiles.map(x => x.id))
|
||||
|
||||
// The previously selected files might not be available in the working
|
||||
// directory any more due to having been committed or discarded so we'll
|
||||
// do a pass over and filter out any selected files that aren't available.
|
||||
let selectedFileIDs = state.selectedFileIDs.filter(id =>
|
||||
mergedFileIds.has(id)
|
||||
)
|
||||
|
||||
// Select the first file if we don't have anything selected and we
|
||||
// have something to select.
|
||||
if (selectedFileIDs.length === 0 && mergedFiles.length > 0) {
|
||||
selectedFileIDs = [mergedFiles[0].id]
|
||||
}
|
||||
|
||||
// The file selection could have changed if the previously selected files
|
||||
// are no longer selectable (they were discarded or committed) but if they
|
||||
// were not changed we can reuse the diff. Note, however that we only render
|
||||
|
@ -83,17 +72,46 @@ export function updateChangedFiles(
|
|||
// diff we had, if not we'll clear it.
|
||||
const workingDirectory = WorkingDirectoryStatus.fromFiles(mergedFiles)
|
||||
|
||||
const diff =
|
||||
selectedFileIDs.length === 1 &&
|
||||
state.selectedFileIDs.length === 1 &&
|
||||
state.selectedFileIDs[0] === selectedFileIDs[0]
|
||||
? state.diff
|
||||
: null
|
||||
const selectionKind = state.selection.kind
|
||||
if (state.selection.kind === ChangesSelectionKind.WorkingDirectory) {
|
||||
// The previously selected files might not be available in the working
|
||||
// directory any more due to having been committed or discarded so we'll
|
||||
// do a pass over and filter out any selected files that aren't available.
|
||||
let selectedFileIDs = state.selection.selectedFileIDs.filter(id =>
|
||||
mergedFileIds.has(id)
|
||||
)
|
||||
|
||||
return {
|
||||
workingDirectory,
|
||||
selectedFileIDs,
|
||||
diff,
|
||||
// Select the first file if we don't have anything selected and we
|
||||
// have something to select.
|
||||
if (selectedFileIDs.length === 0 && mergedFiles.length > 0) {
|
||||
selectedFileIDs = [mergedFiles[0].id]
|
||||
}
|
||||
|
||||
const diff =
|
||||
selectedFileIDs.length === 1 &&
|
||||
state.selection.selectedFileIDs.length === 1 &&
|
||||
state.selection.selectedFileIDs[0] === selectedFileIDs[0]
|
||||
? state.selection.diff
|
||||
: null
|
||||
|
||||
return {
|
||||
workingDirectory,
|
||||
selection: {
|
||||
kind: ChangesSelectionKind.WorkingDirectory,
|
||||
selectedFileIDs,
|
||||
diff,
|
||||
},
|
||||
}
|
||||
} else if (state.selection.kind === ChangesSelectionKind.Stash) {
|
||||
return {
|
||||
workingDirectory,
|
||||
selection: state.selection,
|
||||
}
|
||||
} else {
|
||||
return assertNever(
|
||||
state.selection,
|
||||
`Unknown selection kind ${selectionKind}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -117,7 +135,7 @@ function getConflictState(
|
|||
}
|
||||
}
|
||||
|
||||
if (status.rebaseContext !== null) {
|
||||
if (status.rebaseInternalState !== null) {
|
||||
const { currentTip } = status
|
||||
if (currentTip == null) {
|
||||
return null
|
||||
|
@ -127,7 +145,7 @@ function getConflictState(
|
|||
targetBranch,
|
||||
originalBranchTip,
|
||||
baseBranchTip,
|
||||
} = status.rebaseContext
|
||||
} = status.rebaseInternalState
|
||||
|
||||
return {
|
||||
kind: 'rebase',
|
||||
|
@ -215,12 +233,11 @@ function performEffectsForRebaseStateChange(
|
|||
) {
|
||||
const previousTip = prevConflictState.originalBranchTip
|
||||
|
||||
if (
|
||||
const previousTipChanged =
|
||||
previousTip !== currentTip &&
|
||||
currentBranch === prevConflictState.targetBranch
|
||||
) {
|
||||
statsStore.recordRebaseSuccessAfterConflicts()
|
||||
} else {
|
||||
|
||||
if (!previousTipChanged) {
|
||||
statsStore.recordRebaseAbortedAfterConflicts()
|
||||
}
|
||||
}
|
||||
|
@ -277,3 +294,45 @@ export function updateConflictState(
|
|||
|
||||
return newConflictState
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the partial state needed to update ChangesState selection property
|
||||
* when a user or external constraints require us to do so.
|
||||
*
|
||||
* @param state The current changes state
|
||||
* @param files An array of files to select when showing the working directory.
|
||||
* If undefined this method will preserve the previously selected
|
||||
* files or pick the first changed file if no selection exists.
|
||||
*/
|
||||
export function selectWorkingDirectoryFiles(
|
||||
state: IChangesState,
|
||||
files?: ReadonlyArray<WorkingDirectoryFileChange>
|
||||
): Pick<IChangesState, 'selection'> {
|
||||
let selectedFileIDs: Array<string>
|
||||
|
||||
if (files === undefined) {
|
||||
if (state.selection.kind === ChangesSelectionKind.WorkingDirectory) {
|
||||
// No files provided, just a desire to make sure selection is
|
||||
// working directory. If it already is there's nothing for us to do.
|
||||
return { selection: state.selection }
|
||||
} else if (state.workingDirectory.files.length > 0) {
|
||||
// No files provided and the current selection is stash, pick the
|
||||
// first file we've got.
|
||||
selectedFileIDs = [state.workingDirectory.files[0].id]
|
||||
} else {
|
||||
// Not much to do here. No files provided, nothing in the
|
||||
// working directory.
|
||||
selectedFileIDs = new Array<string>()
|
||||
}
|
||||
} else {
|
||||
selectedFileIDs = files.map(x => x.id)
|
||||
}
|
||||
|
||||
return {
|
||||
selection: {
|
||||
kind: ChangesSelectionKind.WorkingDirectory as ChangesSelectionKind.WorkingDirectory,
|
||||
selectedFileIDs,
|
||||
diff: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,11 +4,11 @@ import { getDotComAPIEndpoint } from './api'
|
|||
|
||||
/**
|
||||
* Best-effort attempt to figure out if this commit was committed using
|
||||
* the web flow on GitHub.com or GitHub Enterprise. Web flow
|
||||
* the web flow on GitHub.com or GitHub Enterprise Server. Web flow
|
||||
* commits (such as PR merges) will have a special GitHub committer
|
||||
* with a noreply email address.
|
||||
*
|
||||
* For GitHub.com we can be spot on but for GitHub Enterprise it's
|
||||
* For GitHub.com we can be spot on but for GitHub Enterprise Server it's
|
||||
* possible we could fail if they've set up a custom smtp host
|
||||
* that doesn't correspond to the hostname.
|
||||
*/
|
||||
|
|
|
@ -45,7 +45,12 @@ export function registerWindowStateChangedEvents(
|
|||
window.on('unmaximize', () => sendWindowStateEvent(window, 'normal'))
|
||||
window.on('restore', () => sendWindowStateEvent(window, 'normal'))
|
||||
window.on('hide', () => sendWindowStateEvent(window, 'hidden'))
|
||||
window.on('show', () => sendWindowStateEvent(window, 'normal'))
|
||||
window.on('show', () => {
|
||||
// because the app can be maximized before being closed - which will restore it
|
||||
// maximized on the next launch - this function should inspect the current state
|
||||
// rather than always assume it is a 'normal' launch
|
||||
sendWindowStateEvent(window, getWindowState(window))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -8,8 +8,7 @@ import { ILaunchStats } from '../lib/stats'
|
|||
import { menuFromElectronMenu } from '../models/app-menu'
|
||||
import { now } from './now'
|
||||
import * as path from 'path'
|
||||
|
||||
let windowStateKeeper: any | null = null
|
||||
import * as windowStateKeeper from 'electron-window-state'
|
||||
|
||||
export class AppWindow {
|
||||
private window: Electron.BrowserWindow
|
||||
|
@ -22,13 +21,6 @@ export class AppWindow {
|
|||
private minHeight = 660
|
||||
|
||||
public constructor() {
|
||||
if (!windowStateKeeper) {
|
||||
// `electron-window-state` requires Electron's `screen` module, which can
|
||||
// only be required after the app has emitted `ready`. So require it
|
||||
// lazily.
|
||||
windowStateKeeper = require('electron-window-state')
|
||||
}
|
||||
|
||||
const savedWindowState = windowStateKeeper({
|
||||
defaultWidth: this.minWidth,
|
||||
defaultHeight: this.minHeight,
|
||||
|
@ -51,6 +43,7 @@ export class AppWindow {
|
|||
disableBlinkFeatures: 'Auxclick',
|
||||
// Enable, among other things, the ResizeObserver
|
||||
experimentalFeatures: true,
|
||||
nodeIntegration: true,
|
||||
},
|
||||
acceptFirstMouse: true,
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ export class CrashWindow {
|
|||
// process but our components which relies on ResizeObserver should
|
||||
// be able to degrade gracefully.
|
||||
experimentalFeatures: false,
|
||||
nodeIntegration: true,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -3,13 +3,10 @@ import '../lib/logging/main/install'
|
|||
import { app, Menu, ipcMain, BrowserWindow, shell } from 'electron'
|
||||
import * as Fs from 'fs'
|
||||
|
||||
import { MenuLabelsEvent } from '../models/menu-labels'
|
||||
|
||||
import { AppWindow } from './app-window'
|
||||
import {
|
||||
buildDefaultMenu,
|
||||
MenuEvent,
|
||||
MenuLabels,
|
||||
getAllMenuItems,
|
||||
} from './menu'
|
||||
import { buildDefaultMenu, MenuEvent, getAllMenuItems } from './menu'
|
||||
import { shellNeedsPatching, updateEnvironmentForProcess } from '../lib/shell'
|
||||
import { parseAppURL } from '../lib/parse-app-url'
|
||||
import { handleSquirrelEvent } from './squirrel-updater'
|
||||
|
@ -61,10 +58,23 @@ function handleUncaughtException(error: Error) {
|
|||
showUncaughtException(isLaunchError, error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the number of seconds the app has been running
|
||||
*/
|
||||
function getUptimeInSeconds() {
|
||||
return (now() - launchTime) / 1000
|
||||
}
|
||||
|
||||
function getExtraErrorContext(): Record<string, string> {
|
||||
return {
|
||||
uptime: getUptimeInSeconds().toFixed(3),
|
||||
time: new Date().toString(),
|
||||
}
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (error: Error) => {
|
||||
error = withSourceMappedStack(error)
|
||||
|
||||
reportError(error)
|
||||
reportError(error, getExtraErrorContext())
|
||||
handleUncaughtException(error)
|
||||
})
|
||||
|
||||
|
@ -236,11 +246,18 @@ app.on('ready', () => {
|
|||
|
||||
createWindow()
|
||||
|
||||
Menu.setApplicationMenu(buildDefaultMenu({}))
|
||||
Menu.setApplicationMenu(
|
||||
buildDefaultMenu({
|
||||
selectedShell: null,
|
||||
selectedExternalEditor: null,
|
||||
askForConfirmationOnRepositoryRemoval: false,
|
||||
askForConfirmationOnForcePush: false,
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.on(
|
||||
'update-preferred-app-menu-item-labels',
|
||||
(event: Electron.IpcMessageEvent, labels: MenuLabels) => {
|
||||
(event: Electron.IpcMessageEvent, labels: MenuLabelsEvent) => {
|
||||
// The current application menu is mutable and we frequently
|
||||
// change whether particular items are enabled or not through
|
||||
// the update-menu-state IPC event. This menu that we're creating
|
||||
|
@ -443,7 +460,10 @@ app.on('ready', () => {
|
|||
event: Electron.IpcMessageEvent,
|
||||
{ error, extra }: { error: Error; extra: { [key: string]: string } }
|
||||
) => {
|
||||
reportError(error, extra)
|
||||
reportError(error, {
|
||||
...getExtraErrorContext(),
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -7,20 +7,22 @@ import { ensureDir } from 'fs-extra'
|
|||
|
||||
import { log } from '../log'
|
||||
import { openDirectorySafe } from '../shell'
|
||||
import { enableRebaseDialog } from '../../lib/feature-flag'
|
||||
import { enableRebaseDialog, enableStashing } from '../../lib/feature-flag'
|
||||
import { MenuLabelsEvent } from '../../models/menu-labels'
|
||||
import { DefaultEditorLabel } from '../../ui/lib/context-menu'
|
||||
|
||||
const defaultEditorLabel = __DARWIN__
|
||||
? 'Open in External Editor'
|
||||
: 'Open in external editor'
|
||||
const defaultShellLabel = __DARWIN__
|
||||
? 'Open in Terminal'
|
||||
: 'Open in Command Prompt'
|
||||
const defaultPullRequestLabel = __DARWIN__
|
||||
const createPullRequestLabel = __DARWIN__
|
||||
? 'Create Pull Request'
|
||||
: 'Create &pull request'
|
||||
const defaultBranchNameDefaultValue = __DARWIN__
|
||||
? 'Default Branch'
|
||||
: 'default branch'
|
||||
const showPullRequestLabel = __DARWIN__
|
||||
? 'Show Pull Request'
|
||||
: 'Show &pull request'
|
||||
const defaultBranchNameValue = __DARWIN__ ? 'Default Branch' : 'default branch'
|
||||
const confirmRepositoryRemovalLabel = __DARWIN__ ? 'Remove…' : '&Remove…'
|
||||
const repositoryRemovalLabel = __DARWIN__ ? 'Remove' : '&Remove'
|
||||
|
||||
enum ZoomDirection {
|
||||
Reset,
|
||||
|
@ -28,21 +30,34 @@ enum ZoomDirection {
|
|||
Out,
|
||||
}
|
||||
|
||||
export type MenuLabels = {
|
||||
editorLabel?: string
|
||||
shellLabel?: string
|
||||
pullRequestLabel?: string
|
||||
defaultBranchName?: string
|
||||
}
|
||||
|
||||
export function buildDefaultMenu({
|
||||
editorLabel = defaultEditorLabel,
|
||||
shellLabel = defaultShellLabel,
|
||||
pullRequestLabel = defaultPullRequestLabel,
|
||||
defaultBranchName = defaultBranchNameDefaultValue,
|
||||
}: MenuLabels): Electron.Menu {
|
||||
selectedExternalEditor,
|
||||
selectedShell,
|
||||
askForConfirmationOnForcePush,
|
||||
askForConfirmationOnRepositoryRemoval,
|
||||
hasCurrentPullRequest = false,
|
||||
defaultBranchName = defaultBranchNameValue,
|
||||
isForcePushForCurrentRepository = false,
|
||||
isStashedChangesVisible = false,
|
||||
}: MenuLabelsEvent): Electron.Menu {
|
||||
defaultBranchName = truncateWithEllipsis(defaultBranchName, 25)
|
||||
|
||||
const removeRepoLabel = askForConfirmationOnRepositoryRemoval
|
||||
? confirmRepositoryRemovalLabel
|
||||
: repositoryRemovalLabel
|
||||
|
||||
const pullRequestLabel = hasCurrentPullRequest
|
||||
? showPullRequestLabel
|
||||
: createPullRequestLabel
|
||||
|
||||
const shellLabel =
|
||||
selectedShell === null ? defaultShellLabel : `Open in ${selectedShell}`
|
||||
|
||||
const editorLabel =
|
||||
selectedExternalEditor === null
|
||||
? DefaultEditorLabel
|
||||
: `Open in ${selectedExternalEditor}`
|
||||
|
||||
const template = new Array<Electron.MenuItemConstructorOptions>()
|
||||
const separator: Electron.MenuItemConstructorOptions = { type: 'separator' }
|
||||
|
||||
|
@ -120,7 +135,11 @@ export function buildDefaultMenu({
|
|||
click: emit('show-preferences'),
|
||||
},
|
||||
separator,
|
||||
{ role: 'quit' }
|
||||
{
|
||||
role: 'quit',
|
||||
label: 'E&xit',
|
||||
accelerator: 'Alt+F4',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -140,6 +159,13 @@ export function buildDefaultMenu({
|
|||
accelerator: 'CmdOrCtrl+A',
|
||||
click: emit('select-all'),
|
||||
},
|
||||
separator,
|
||||
{
|
||||
id: 'find',
|
||||
label: __DARWIN__ ? 'Find' : '&Find',
|
||||
accelerator: 'CmdOrCtrl+F',
|
||||
click: emit('find-text'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
@ -177,6 +203,15 @@ export function buildDefaultMenu({
|
|||
accelerator: 'CmdOrCtrl+G',
|
||||
click: emit('go-to-commit-message'),
|
||||
},
|
||||
{
|
||||
label: getStashedChangesLabel(isStashedChangesVisible),
|
||||
id: 'toggle-stashed-changes',
|
||||
accelerator: 'Ctrl+H',
|
||||
click: isStashedChangesVisible
|
||||
? emit('hide-stashed-changes')
|
||||
: emit('show-stashed-changes'),
|
||||
visible: enableStashing(),
|
||||
},
|
||||
{
|
||||
label: __DARWIN__ ? 'Toggle Full Screen' : 'Toggle &full screen',
|
||||
role: 'togglefullscreen',
|
||||
|
@ -230,15 +265,22 @@ export function buildDefaultMenu({
|
|||
],
|
||||
})
|
||||
|
||||
const pushLabel = getPushLabel(
|
||||
isForcePushForCurrentRepository,
|
||||
askForConfirmationOnForcePush
|
||||
)
|
||||
|
||||
const pushEventType = isForcePushForCurrentRepository ? 'force-push' : 'push'
|
||||
|
||||
template.push({
|
||||
label: __DARWIN__ ? 'Repository' : '&Repository',
|
||||
id: 'repository',
|
||||
submenu: [
|
||||
{
|
||||
id: 'push',
|
||||
label: __DARWIN__ ? 'Push' : 'P&ush',
|
||||
label: pushLabel,
|
||||
accelerator: 'CmdOrCtrl+P',
|
||||
click: emit('push'),
|
||||
click: emit(pushEventType),
|
||||
},
|
||||
{
|
||||
id: 'pull',
|
||||
|
@ -247,9 +289,9 @@ export function buildDefaultMenu({
|
|||
click: emit('pull'),
|
||||
},
|
||||
{
|
||||
label: __DARWIN__ ? 'Remove' : '&Remove',
|
||||
label: removeRepoLabel,
|
||||
id: 'remove-repository',
|
||||
accelerator: 'CmdOrCtrl+Delete',
|
||||
accelerator: 'CmdOrCtrl+Backspace',
|
||||
click: emit('remove-repository'),
|
||||
},
|
||||
separator,
|
||||
|
@ -313,6 +355,13 @@ export function buildDefaultMenu({
|
|||
click: emit('delete-branch'),
|
||||
},
|
||||
separator,
|
||||
{
|
||||
label: __DARWIN__ ? 'Discard All Changes…' : 'Discard all changes…',
|
||||
id: 'discard-all-changes',
|
||||
accelerator: 'CmdOrCtrl+Shift+Backspace',
|
||||
click: emit('discard-all-changes'),
|
||||
},
|
||||
separator,
|
||||
{
|
||||
label: __DARWIN__
|
||||
? `Update from ${defaultBranchName}`
|
||||
|
@ -396,6 +445,15 @@ export function buildDefaultMenu({
|
|||
},
|
||||
}
|
||||
|
||||
const showKeyboardShortcuts: Electron.MenuItemConstructorOptions = {
|
||||
label: __DARWIN__ ? 'Show Keyboard Shortcuts' : 'Show keyboard shortcuts',
|
||||
click() {
|
||||
shell.openExternal(
|
||||
'https://help.github.com/en/desktop/getting-started-with-github-desktop/keyboard-shortcuts-in-github-desktop'
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const showLogsLabel = __DARWIN__
|
||||
? 'Show Logs in Finder'
|
||||
: __WIN32__
|
||||
|
@ -420,6 +478,7 @@ export function buildDefaultMenu({
|
|||
submitIssueItem,
|
||||
contactSupportItem,
|
||||
showUserGuides,
|
||||
showKeyboardShortcuts,
|
||||
showLogsItem,
|
||||
]
|
||||
|
||||
|
@ -444,6 +503,10 @@ export function buildDefaultMenu({
|
|||
click: emit('show-release-notes-popup'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Prune branches',
|
||||
click: emit('test-prune-branches'),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -473,6 +536,29 @@ export function buildDefaultMenu({
|
|||
return Menu.buildFromTemplate(template)
|
||||
}
|
||||
|
||||
function getPushLabel(
|
||||
isForcePushForCurrentRepository: boolean,
|
||||
askForConfirmationOnForcePush: boolean
|
||||
): string {
|
||||
if (!isForcePushForCurrentRepository) {
|
||||
return __DARWIN__ ? 'Push' : 'P&ush'
|
||||
}
|
||||
|
||||
if (askForConfirmationOnForcePush) {
|
||||
return __DARWIN__ ? 'Force Push…' : 'Force P&ush…'
|
||||
}
|
||||
|
||||
return __DARWIN__ ? 'Force Push' : 'Force P&ush'
|
||||
}
|
||||
|
||||
function getStashedChangesLabel(isStashedChangesVisible: boolean): string {
|
||||
if (isStashedChangesVisible) {
|
||||
return __DARWIN__ ? 'Hide Stashed Changes' : 'H&ide stashed changes'
|
||||
}
|
||||
|
||||
return __DARWIN__ ? 'Show Stashed Changes' : 'Sho&w stashed changes'
|
||||
}
|
||||
|
||||
type ClickHandler = (
|
||||
menuItem: Electron.MenuItem,
|
||||
browserWindow: Electron.BrowserWindow,
|
||||
|
@ -525,29 +611,27 @@ function zoom(direction: ZoomDirection): ClickHandler {
|
|||
webContents.setZoomFactor(1)
|
||||
webContents.send('zoom-factor-changed', 1)
|
||||
} else {
|
||||
webContents.getZoomFactor(rawZoom => {
|
||||
const zoomFactors =
|
||||
direction === ZoomDirection.In ? ZoomInFactors : ZoomOutFactors
|
||||
const rawZoom = webContents.getZoomFactor()
|
||||
const zoomFactors =
|
||||
direction === ZoomDirection.In ? ZoomInFactors : ZoomOutFactors
|
||||
|
||||
// So the values that we get from getZoomFactor are floating point
|
||||
// precision numbers from chromium that don't always round nicely so
|
||||
// we'll have to do a little trick to figure out which of our supported
|
||||
// zoom factors the value is referring to.
|
||||
const currentZoom = findClosestValue(zoomFactors, rawZoom)
|
||||
// So the values that we get from getZoomFactor are floating point
|
||||
// precision numbers from chromium that don't always round nicely so
|
||||
// we'll have to do a little trick to figure out which of our supported
|
||||
// zoom factors the value is referring to.
|
||||
const currentZoom = findClosestValue(zoomFactors, rawZoom)
|
||||
|
||||
const nextZoomLevel = zoomFactors.find(f =>
|
||||
direction === ZoomDirection.In ? f > currentZoom : f < currentZoom
|
||||
)
|
||||
const nextZoomLevel = zoomFactors.find(f =>
|
||||
direction === ZoomDirection.In ? f > currentZoom : f < currentZoom
|
||||
)
|
||||
|
||||
// If we couldn't find a zoom level (likely due to manual manipulation
|
||||
// of the zoom factor in devtools) we'll just snap to the closest valid
|
||||
// factor we've got.
|
||||
const newZoom =
|
||||
nextZoomLevel === undefined ? currentZoom : nextZoomLevel
|
||||
// If we couldn't find a zoom level (likely due to manual manipulation
|
||||
// of the zoom factor in devtools) we'll just snap to the closest valid
|
||||
// factor we've got.
|
||||
const newZoom = nextZoomLevel === undefined ? currentZoom : nextZoomLevel
|
||||
|
||||
webContents.setZoomFactor(newZoom)
|
||||
webContents.send('zoom-factor-changed', newZoom)
|
||||
})
|
||||
webContents.setZoomFactor(newZoom)
|
||||
webContents.send('zoom-factor-changed', newZoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
export * from './build-default-menu'
|
||||
export * from './ensure-item-ids'
|
||||
export * from './menu-event'
|
||||
export * from './menu-ids'
|
||||
export * from './crash-menu'
|
||||
export * from './get-all-menu-items'
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export type MenuEvent =
|
||||
| 'push'
|
||||
| 'force-push'
|
||||
| 'pull'
|
||||
| 'show-changes'
|
||||
| 'show-history'
|
||||
|
@ -10,6 +11,7 @@ export type MenuEvent =
|
|||
| 'create-repository'
|
||||
| 'rename-branch'
|
||||
| 'delete-branch'
|
||||
| 'discard-all-changes'
|
||||
| 'show-preferences'
|
||||
| 'choose-repository'
|
||||
| 'open-working-directory'
|
||||
|
@ -30,3 +32,7 @@ export type MenuEvent =
|
|||
| 'open-external-editor'
|
||||
| 'select-all'
|
||||
| 'show-release-notes-popup'
|
||||
| 'show-stashed-changes'
|
||||
| 'hide-stashed-changes'
|
||||
| 'test-prune-branches'
|
||||
| 'find-text'
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { getDotComAPIEndpoint, IAPIEmail } from '../lib/api'
|
||||
|
||||
/**
|
||||
* A GitHub account, representing the user found on GitHub The Website or GitHub Enterprise.
|
||||
* A GitHub account, representing the user found on GitHub The Website or GitHub Enterprise Server.
|
||||
*
|
||||
* This contains a token that will be used for operations that require authentication.
|
||||
*/
|
||||
|
@ -15,7 +15,7 @@ export class Account {
|
|||
* Create an instance of an account
|
||||
*
|
||||
* @param login The login name for this account
|
||||
* @param endpoint The server for this account - GitHub or a GitHub Enterprise instance
|
||||
* @param endpoint The server for this account - GitHub or a GitHub Enterprise Server instance
|
||||
* @param token The access token used to perform operations on behalf of this account
|
||||
* @param emails The current list of email addresses associated with the account
|
||||
* @param avatarURL The profile URL to render for this account
|
||||
|
|
|
@ -162,6 +162,24 @@ function getAccessKey(text: string): string | null {
|
|||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
/** Workaround for missing type information on Electron.MenuItem.type */
|
||||
function parseMenuItem(
|
||||
type: string
|
||||
): 'normal' | 'separator' | 'submenu' | 'checkbox' | 'radio' {
|
||||
switch (type) {
|
||||
case 'normal':
|
||||
case 'separator':
|
||||
case 'submenu':
|
||||
case 'checkbox':
|
||||
case 'radio':
|
||||
return type
|
||||
default:
|
||||
throw new Error(
|
||||
`Unable to parse string ${type} to a valid menu item type`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of one of the types in the MenuItem type union based
|
||||
* on an Electron MenuItem instance. Will recurse through all sub menus and
|
||||
|
@ -182,8 +200,10 @@ function menuItemFromElectronMenuItem(menuItem: Electron.MenuItem): MenuItem {
|
|||
const accelerator = getAccelerator(menuItem)
|
||||
const accessKey = getAccessKey(menuItem.label)
|
||||
|
||||
const type = parseMenuItem(menuItem.type)
|
||||
|
||||
// normal, separator, submenu, checkbox or radio.
|
||||
switch (menuItem.type) {
|
||||
switch (type) {
|
||||
case 'normal':
|
||||
return {
|
||||
id,
|
||||
|
@ -230,10 +250,7 @@ function menuItemFromElectronMenuItem(menuItem: Electron.MenuItem): MenuItem {
|
|||
accessKey,
|
||||
}
|
||||
default:
|
||||
return assertNever(
|
||||
menuItem.type,
|
||||
`Unknown menu item type ${menuItem.type}`
|
||||
)
|
||||
return assertNever(type, `Unknown menu item type ${type}`)
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
|
|
@ -19,7 +19,7 @@ export interface IAuthor {
|
|||
readonly email: string
|
||||
|
||||
/**
|
||||
* The GitHub.com or GitHub Enterprise login for
|
||||
* The GitHub.com or GitHub Enterprise Server login for
|
||||
* this author or null if that information is not
|
||||
* available.
|
||||
*/
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
/** A list of menu ids associated with the main menu in GitHub Desktop */
|
||||
export type MenuIDs =
|
||||
| 'rename-branch'
|
||||
| 'delete-branch'
|
||||
| 'discard-all-changes'
|
||||
| 'preferences'
|
||||
| 'update-branch'
|
||||
| 'merge-branch'
|
||||
|
@ -28,3 +30,4 @@ export type MenuIDs =
|
|||
| 'about'
|
||||
| 'create-pull-request'
|
||||
| 'compare-to-branch'
|
||||
| 'toggle-stashed-changes'
|
52
app/src/models/menu-labels.ts
Normal file
52
app/src/models/menu-labels.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { Shell } from '../lib/shells'
|
||||
import { ExternalEditor } from '../lib/editors'
|
||||
|
||||
export type MenuLabelsEvent = {
|
||||
/**
|
||||
* Specify the user's selected shell to display in the menu.
|
||||
*
|
||||
* Specify `null` to indicate that it is not known currently, which will
|
||||
* default to a placeholder based on the current platform.
|
||||
*/
|
||||
readonly selectedShell: Shell | null
|
||||
|
||||
/**
|
||||
* Specify the user's selected editor to display in the menu.
|
||||
*
|
||||
* Specify `null` to indicate that it is not known currently, which will
|
||||
* default to a placeholder based on the current platform.
|
||||
*/
|
||||
readonly selectedExternalEditor: ExternalEditor | null
|
||||
|
||||
/**
|
||||
* Has the use enabled "Show confirmation dialog before force pushing"?
|
||||
*/
|
||||
readonly askForConfirmationOnForcePush: boolean
|
||||
|
||||
/**
|
||||
* Has the use enabled "Show confirmation dialog before removing repositories"?
|
||||
*/
|
||||
readonly askForConfirmationOnRepositoryRemoval: boolean
|
||||
|
||||
/**
|
||||
* Specify the default branch associated with the current repository.
|
||||
*
|
||||
* Omit this value to indicate that the default branch is unknown.
|
||||
*/
|
||||
readonly defaultBranchName?: string
|
||||
|
||||
/**
|
||||
* Is the current branch in a state where it can be force pushed to the remote?
|
||||
*/
|
||||
readonly isForcePushForCurrentRepository?: boolean
|
||||
|
||||
/**
|
||||
* Specify whether a pull request is associated with the current branch.
|
||||
*/
|
||||
readonly hasCurrentPullRequest?: boolean
|
||||
|
||||
/**
|
||||
* Specify whether a stashed change is accessible in the current branch.
|
||||
*/
|
||||
readonly isStashedChangesVisible?: boolean
|
||||
}
|
|
@ -7,7 +7,7 @@ import { RetryAction } from './retry-actions'
|
|||
import { WorkingDirectoryFileChange } from './status'
|
||||
import { PreferencesTab } from './preferences'
|
||||
import { ICommitContext } from './commit'
|
||||
import { RebaseFlowState } from './rebase-flow-state'
|
||||
import { IStashEntry } from './stash-entry'
|
||||
|
||||
export enum PopupType {
|
||||
RenameBranch = 1,
|
||||
|
@ -44,9 +44,11 @@ export enum PopupType {
|
|||
UsageReportingChanges,
|
||||
CommitConflictsWarning,
|
||||
PushNeedsPull,
|
||||
LocalChangesOverwritten,
|
||||
RebaseFlow,
|
||||
ConfirmForcePush,
|
||||
StashAndSwitchBranch,
|
||||
ConfirmOverwriteStash,
|
||||
ConfirmDiscardStash,
|
||||
}
|
||||
|
||||
export type Popup =
|
||||
|
@ -80,6 +82,13 @@ export type Popup =
|
|||
| {
|
||||
type: PopupType.CreateBranch
|
||||
repository: Repository
|
||||
|
||||
/**
|
||||
* A flag to indicate the user clicked the "switch branch" link when they
|
||||
* saw the prompt about the current branch being protected.
|
||||
*/
|
||||
handleProtectedBranchWarning?: boolean
|
||||
|
||||
initialName?: string
|
||||
}
|
||||
| { type: PopupType.SignIn }
|
||||
|
@ -162,13 +171,6 @@ export type Popup =
|
|||
type: PopupType.PushNeedsPull
|
||||
repository: Repository
|
||||
}
|
||||
| {
|
||||
type: PopupType.LocalChangesOverwritten
|
||||
/** repository user is checking out in */
|
||||
repository: Repository
|
||||
retryAction: RetryAction
|
||||
overwrittenFiles: ReadonlyArray<string>
|
||||
}
|
||||
| {
|
||||
type: PopupType.ConfirmForcePush
|
||||
repository: Repository
|
||||
|
@ -177,5 +179,19 @@ export type Popup =
|
|||
| {
|
||||
type: PopupType.RebaseFlow
|
||||
repository: Repository
|
||||
initialState: RebaseFlowState
|
||||
}
|
||||
| {
|
||||
type: PopupType.StashAndSwitchBranch
|
||||
repository: Repository
|
||||
branchToCheckout: Branch
|
||||
}
|
||||
| {
|
||||
type: PopupType.ConfirmOverwriteStash
|
||||
repository: Repository
|
||||
branchToCheckout: Branch
|
||||
}
|
||||
| {
|
||||
type: PopupType.ConfirmDiscardStash
|
||||
repository: Repository
|
||||
stash: IStashEntry
|
||||
}
|
||||
|
|
|
@ -103,7 +103,7 @@ export interface IRevertProgress extends IProgress {
|
|||
export interface IRebaseProgress extends IProgress {
|
||||
readonly kind: 'rebase'
|
||||
/** The summary of the commit applied to the base branch */
|
||||
readonly commitSummary: string
|
||||
readonly currentCommitSummary: string
|
||||
/** The number of commits currently rebased onto the base branch */
|
||||
readonly rebasedCommitCount: number
|
||||
/** The toal number of commits to rebase on top of the current branch */
|
||||
|
|
|
@ -11,7 +11,7 @@ export class PullRequestRef {
|
|||
public constructor(
|
||||
public readonly ref: string,
|
||||
public readonly sha: string,
|
||||
public readonly gitHubRepository: GitHubRepository | null
|
||||
public readonly gitHubRepository: GitHubRepository
|
||||
) {}
|
||||
}
|
||||
|
||||
|
@ -24,7 +24,6 @@ export interface ICommitStatus {
|
|||
|
||||
export class PullRequest {
|
||||
/**
|
||||
* @param id The database ID.
|
||||
* @param created The date on which the PR was created.
|
||||
* @param status The status of the PR. This will be `null` if we haven't looked up its
|
||||
* status yet or if an error occurred while looking it up.
|
||||
|
@ -35,7 +34,6 @@ export class PullRequest {
|
|||
* @param author The author's login.
|
||||
*/
|
||||
public constructor(
|
||||
public readonly id: number,
|
||||
public readonly created: Date,
|
||||
public readonly title: string,
|
||||
public readonly pullRequestNumber: number,
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import { Branch } from './branch'
|
||||
import { RebaseConflictState } from '../lib/app-state'
|
||||
import { CommitOneLine } from './commit'
|
||||
|
||||
/** Union type representing the possible states of the rebase flow */
|
||||
export type RebaseFlowState =
|
||||
export type RebaseFlowStep =
|
||||
| ChooseBranchesStep
|
||||
| WarnForcePushStep
|
||||
| ShowProgressStep
|
||||
| ShowConflictsStep
|
||||
| HideConflictsStep
|
||||
|
@ -19,6 +22,15 @@ export const enum RebaseStep {
|
|||
* conflicts.
|
||||
*/
|
||||
ChooseBranch = 'ChooseBranch',
|
||||
/**
|
||||
* The initial state of a rebase - the user choosing the start point.
|
||||
*
|
||||
* This is not encountered if the user tries to 'pull with rebase' and
|
||||
* encounters conflicts, because the rebase happens as part of the pull
|
||||
* operation and the only remaining work for the user is to resolve any
|
||||
* conflicts.
|
||||
*/
|
||||
WarnForcePush = 'WarnForcePush',
|
||||
/**
|
||||
* After the user chooses which branch to use as the base branch for the
|
||||
* rebase, the progress view is shown indicating how the rebase work is
|
||||
|
@ -69,6 +81,13 @@ export type ChooseBranchesStep = {
|
|||
readonly initialBranch?: Branch
|
||||
}
|
||||
|
||||
export type WarnForcePushStep = {
|
||||
readonly kind: RebaseStep.WarnForcePush
|
||||
readonly baseBranch: Branch
|
||||
readonly targetBranch: Branch
|
||||
readonly commits: ReadonlyArray<CommitOneLine>
|
||||
}
|
||||
|
||||
/** Shape of data to show progress of the current rebase */
|
||||
export type ShowProgressStep = {
|
||||
readonly kind: RebaseStep.ShowProgress
|
||||
|
@ -80,14 +99,13 @@ export type ShowProgressStep = {
|
|||
* want to defer the rebase action until after _something_ is shown to the
|
||||
* user.
|
||||
*/
|
||||
readonly rebaseAction?: () => Promise<void>
|
||||
readonly rebaseAction: (() => Promise<void>) | null
|
||||
}
|
||||
|
||||
/** Shape of data to show conflicts that need to be resolved by the user */
|
||||
export type ShowConflictsStep = {
|
||||
readonly kind: RebaseStep.ShowConflicts
|
||||
readonly targetBranch: string
|
||||
readonly baseBranch?: string
|
||||
readonly conflictState: RebaseConflictState
|
||||
}
|
||||
|
||||
/** Shape of data to track when user hides conflicts dialog */
|
||||
|
@ -98,8 +116,7 @@ export type HideConflictsStep = {
|
|||
/** Shape of data to use when confirming user should abort rebase */
|
||||
export type ConfirmAbortStep = {
|
||||
readonly kind: RebaseStep.ConfirmAbort
|
||||
readonly targetBranch: string
|
||||
readonly baseBranch?: string
|
||||
readonly conflictState: RebaseConflictState
|
||||
}
|
||||
|
||||
/** Shape of data to track when rebase has completed successfully */
|
|
@ -2,9 +2,22 @@ import { IRebaseProgress } from './progress'
|
|||
import { ComputedAction } from './computed-action'
|
||||
import { CommitOneLine } from './commit'
|
||||
|
||||
export type RebaseContext = {
|
||||
/**
|
||||
* Rebase internal state used to track how and where the rebase is applied to
|
||||
* the repository.
|
||||
*/
|
||||
export type RebaseInternalState = {
|
||||
/** The branch containing commits that should be rebased */
|
||||
readonly targetBranch: string
|
||||
/**
|
||||
* The commit ID of the base branch, to be used as a starting point for
|
||||
* the rebase.
|
||||
*/
|
||||
readonly baseBranchTip: string
|
||||
/**
|
||||
* The commit ID of the target branch at the start of the rebase, which points
|
||||
* to the original commit history.
|
||||
*/
|
||||
readonly originalBranchTip: string
|
||||
}
|
||||
|
||||
|
@ -43,13 +56,22 @@ export type RebasePreview =
|
|||
| RebaseNotSupported
|
||||
| RebaseLoading
|
||||
|
||||
export type RebaseProgressSummary = {
|
||||
/** A numeric value between 0 and 1 representing the rebase progress */
|
||||
/** Represents the progress of a Git rebase operation to be shown to the user */
|
||||
export type GitRebaseProgress = {
|
||||
/** A numeric value between 0 and 1 representing the percent completed */
|
||||
readonly value: number
|
||||
/** Track the current number of commits rebased across dialogs and states */
|
||||
/** The current number of commits rebased as part of this operation */
|
||||
readonly rebasedCommitCount: number
|
||||
/** The commit summary associated with the current commit (if known) */
|
||||
readonly commitSummary?: string
|
||||
/** The list of known commits that will be rebased onto the base branch */
|
||||
readonly commits: ReadonlyArray<CommitOneLine>
|
||||
/** The commit summary associated with the current commit (if found) */
|
||||
readonly currentCommitSummary: string | null
|
||||
/** The count of known commits that will be rebased onto the base branch */
|
||||
readonly totalCommitCount: number
|
||||
}
|
||||
|
||||
/** Represents a snapshot of the rebase state from the Git repository */
|
||||
export type GitRebaseSnapshot = {
|
||||
/** The sequence of commits that are used in the rebase */
|
||||
readonly commits: ReadonlyArray<CommitOneLine>
|
||||
/** The progress of the operation */
|
||||
readonly progress: GitRebaseProgress
|
||||
}
|
||||
|
|
|
@ -14,3 +14,19 @@ export interface IRemote {
|
|||
readonly name: string
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a value indicating whether two remotes can be considered
|
||||
* structurally equivalent to each other.
|
||||
*/
|
||||
export function remoteEquals(x: IRemote | null, y: IRemote | null) {
|
||||
if (x === y) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (x === null || y === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return x.name === y.name && x.url === y.url
|
||||
}
|
||||
|
|
|
@ -15,23 +15,38 @@ function getBaseName(path: string): string {
|
|||
return baseName
|
||||
}
|
||||
|
||||
/** Base type for a directory you can run git commands successfully */
|
||||
export type WorkingTree = {
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
/** A local repository. */
|
||||
export class Repository {
|
||||
public readonly name: string
|
||||
/**
|
||||
* The main working tree (what we commonly
|
||||
* think of as the repository's working directory)
|
||||
*/
|
||||
private readonly mainWorkTree: WorkingTree
|
||||
|
||||
/**
|
||||
* @param path The working directory of this repository
|
||||
* @param missing Was the repository missing on disk last we checked?
|
||||
*/
|
||||
public constructor(
|
||||
public readonly path: string,
|
||||
path: string,
|
||||
public readonly id: number,
|
||||
public readonly gitHubRepository: GitHubRepository | null,
|
||||
public readonly missing: boolean
|
||||
) {
|
||||
this.mainWorkTree = { path }
|
||||
this.name = (gitHubRepository && gitHubRepository.name) || getBaseName(path)
|
||||
}
|
||||
|
||||
public get path(): string {
|
||||
return this.mainWorkTree.path
|
||||
}
|
||||
|
||||
/**
|
||||
* A hash of the properties of the object.
|
||||
*
|
||||
|
@ -44,6 +59,12 @@ export class Repository {
|
|||
}
|
||||
}
|
||||
|
||||
/** A worktree linked to a main working tree (aka `Repository`) */
|
||||
export type LinkedWorkTree = WorkingTree & {
|
||||
/** The sha of the head commit in this work tree */
|
||||
readonly head: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapshot for the local state for a given repository
|
||||
*/
|
||||
|
|
41
app/src/models/stash-entry.ts
Normal file
41
app/src/models/stash-entry.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { CommittedFileChange } from './status'
|
||||
|
||||
export interface IStashEntry {
|
||||
/** The name of the entry i.e., `stash@{0}` */
|
||||
readonly name: string
|
||||
|
||||
/** The name of the branch at the time the entry was created. */
|
||||
readonly branchName: string
|
||||
|
||||
/** The SHA of the commit object created as a result of stashing. */
|
||||
readonly stashSha: string
|
||||
|
||||
/** The list of files this stash touches */
|
||||
readonly files: StashedFileChanges
|
||||
}
|
||||
|
||||
/** Whether file changes for a stash entry are loaded or not */
|
||||
export enum StashedChangesLoadStates {
|
||||
NotLoaded = 'NotLoaded',
|
||||
Loading = 'Loading',
|
||||
Loaded = 'Loaded',
|
||||
}
|
||||
|
||||
/**
|
||||
* The status of stashed file changes
|
||||
*
|
||||
* When the status us `Loaded` all the files associated
|
||||
* with the stash are made available.
|
||||
*/
|
||||
export type StashedFileChanges =
|
||||
| {
|
||||
readonly kind:
|
||||
| StashedChangesLoadStates.NotLoaded
|
||||
| StashedChangesLoadStates.Loading
|
||||
}
|
||||
| {
|
||||
readonly kind: StashedChangesLoadStates.Loaded
|
||||
readonly files: ReadonlyArray<CommittedFileChange>
|
||||
}
|
||||
|
||||
export type StashCallback = (stashEntry: IStashEntry) => Promise<void>
|
|
@ -70,7 +70,7 @@ export type ManualConflict = {
|
|||
/** Union of potential conflict scenarios the application should handle */
|
||||
export type ConflictedFileStatus = ConflictsWithMarkers | ManualConflict
|
||||
|
||||
/** Custom typeguard to differentiate ConflictsWithMarkers from other Conflict types */
|
||||
/** Custom typeguard to differentiate Conflict files from other types */
|
||||
export function isConflictedFileStatus(
|
||||
appFileStatus: AppFileStatus
|
||||
): appFileStatus is ConflictedFileStatus {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Branch } from './branch'
|
||||
import { assertNever } from '../lib/fatal-error'
|
||||
|
||||
export enum TipState {
|
||||
Unknown = 'Unknown',
|
||||
|
@ -44,3 +45,36 @@ export type Tip =
|
|||
| IUnbornRepository
|
||||
| IDetachedHead
|
||||
| IValidBranch
|
||||
|
||||
/**
|
||||
* Gets a value indicating whether two Tip instances refer to the
|
||||
* same canonical Git state.
|
||||
*/
|
||||
export function tipEquals(x: Tip, y: Tip) {
|
||||
if (x === y) {
|
||||
return true
|
||||
}
|
||||
|
||||
const kind = x.kind
|
||||
switch (x.kind) {
|
||||
case TipState.Unknown:
|
||||
return x.kind === y.kind
|
||||
case TipState.Unborn:
|
||||
return x.kind === y.kind && x.ref === y.ref
|
||||
case TipState.Detached:
|
||||
return x.kind === y.kind && x.currentSha === y.currentSha
|
||||
case TipState.Valid:
|
||||
return x.kind === y.kind && branchEquals(x.branch, y.branch)
|
||||
default:
|
||||
return assertNever(x, `Unknown tip state ${kind}`)
|
||||
}
|
||||
}
|
||||
|
||||
function branchEquals(x: Branch, y: Branch) {
|
||||
return (
|
||||
x.type === y.type &&
|
||||
x.tip.sha === y.tip.sha &&
|
||||
x.remote === y.remote &&
|
||||
x.upstream === y.upstream
|
||||
)
|
||||
}
|
||||
|
|
22
app/src/models/uncommitted-changes-strategy.ts
Normal file
22
app/src/models/uncommitted-changes-strategy.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { IStashEntry } from './stash-entry'
|
||||
|
||||
export enum UncommittedChangesStrategyKind {
|
||||
AskForConfirmation = 'AskForConfirmation',
|
||||
StashOnCurrentBranch = 'StashOnCurrentBranch',
|
||||
MoveToNewBranch = 'MoveToNewBranch',
|
||||
}
|
||||
|
||||
export type UncommittedChangesStrategy =
|
||||
| { kind: UncommittedChangesStrategyKind.AskForConfirmation }
|
||||
| { kind: UncommittedChangesStrategyKind.StashOnCurrentBranch }
|
||||
| {
|
||||
kind: UncommittedChangesStrategyKind.MoveToNewBranch
|
||||
transientStashEntry: IStashEntry | null
|
||||
}
|
||||
|
||||
export const askToStash: UncommittedChangesStrategy = {
|
||||
kind: UncommittedChangesStrategyKind.AskForConfirmation,
|
||||
}
|
||||
export const stashOnCurrentBranch: UncommittedChangesStrategy = {
|
||||
kind: UncommittedChangesStrategyKind.StashOnCurrentBranch,
|
||||
}
|
|
@ -174,10 +174,11 @@ export class AddExistingRepository extends React.Component<
|
|||
}
|
||||
|
||||
private showFilePicker = async () => {
|
||||
const directory: string[] | null = remote.dialog.showOpenDialog({
|
||||
const window = remote.getCurrentWindow()
|
||||
const directory = remote.dialog.showOpenDialog(window, {
|
||||
properties: ['createDirectory', 'openDirectory'],
|
||||
})
|
||||
if (!directory) {
|
||||
if (directory === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -120,6 +120,8 @@ export class CreateRepository extends React.Component<
|
|||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
window.addEventListener('focus', this.onWindowFocus)
|
||||
|
||||
const gitIgnoreNames = await getGitIgnoreNames()
|
||||
this.setState({ gitIgnoreNames })
|
||||
|
||||
|
@ -129,9 +131,7 @@ export class CreateRepository extends React.Component<
|
|||
const isRepository = await isGitRepository(this.state.path)
|
||||
this.setState({ isRepository })
|
||||
|
||||
await this.updateReadMeExists(this.state.path, this.state.name)
|
||||
|
||||
window.addEventListener('focus', this.onWindowFocus)
|
||||
this.updateReadMeExists(this.state.path, this.state.name)
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
|
@ -139,18 +139,21 @@ export class CreateRepository extends React.Component<
|
|||
}
|
||||
|
||||
private onPathChanged = async (path: string) => {
|
||||
const isRepository = await isGitRepository(path)
|
||||
await this.updateReadMeExists(path, this.state.name)
|
||||
this.setState({ path, isValidPath: null })
|
||||
|
||||
this.setState({ isRepository, path, isValidPath: null })
|
||||
const isRepository = await isGitRepository(path)
|
||||
|
||||
// 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 ? { isRepository } : null))
|
||||
|
||||
this.updateReadMeExists(path, this.state.name)
|
||||
}
|
||||
|
||||
private onNameChanged = async (name: string) => {
|
||||
if (enableReadmeOverwriteWarning()) {
|
||||
await this.updateReadMeExists(this.state.path, name)
|
||||
}
|
||||
|
||||
private onNameChanged = (name: string) => {
|
||||
this.setState({ name })
|
||||
this.updateReadMeExists(this.state.path, name)
|
||||
}
|
||||
|
||||
private onDescriptionChanged = (description: string) => {
|
||||
|
@ -158,11 +161,12 @@ export class CreateRepository extends React.Component<
|
|||
}
|
||||
|
||||
private showFilePicker = async () => {
|
||||
const directory: string[] | null = remote.dialog.showOpenDialog({
|
||||
const window = remote.getCurrentWindow()
|
||||
const directory = remote.dialog.showOpenDialog(window, {
|
||||
properties: ['createDirectory', 'openDirectory'],
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
if (directory === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -173,9 +177,15 @@ export class CreateRepository extends React.Component<
|
|||
}
|
||||
|
||||
private async updateReadMeExists(path: string, name: string) {
|
||||
if (!enableReadmeOverwriteWarning()) {
|
||||
return
|
||||
}
|
||||
|
||||
const fullPath = Path.join(path, sanitizedRepositoryName(name), 'README.md')
|
||||
const readMeExists = await FSE.pathExists(fullPath)
|
||||
this.setState({ readMeExists })
|
||||
|
||||
// Only update readMeExists if the path is still the same
|
||||
this.setState(state => (state.path === path ? { readMeExists } : null))
|
||||
}
|
||||
|
||||
private resolveRepositoryRoot = async (): Promise<string> => {
|
||||
|
@ -232,7 +242,11 @@ export class CreateRepository extends React.Component<
|
|||
|
||||
if (this.state.createWithReadme) {
|
||||
try {
|
||||
await writeDefaultReadme(fullPath, this.state.name)
|
||||
await writeDefaultReadme(
|
||||
fullPath,
|
||||
this.state.name,
|
||||
this.state.description
|
||||
)
|
||||
} catch (e) {
|
||||
log.error(`createRepository: unable to write README at ${fullPath}`, e)
|
||||
this.props.dispatcher.postError(e)
|
||||
|
@ -580,11 +594,9 @@ export class CreateRepository extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private onWindowFocus = async () => {
|
||||
private onWindowFocus = () => {
|
||||
// Verify whether or not a README.md file exists at the chosen directory
|
||||
// in case one has been added or removed and the warning can be displayed.
|
||||
if (enableReadmeOverwriteWarning()) {
|
||||
await this.updateReadMeExists(this.state.path, this.state.name)
|
||||
}
|
||||
this.updateReadMeExists(this.state.path, this.state.name)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,10 @@ import * as Path from 'path'
|
|||
|
||||
const DefaultReadmeName = 'README.md'
|
||||
|
||||
function defaultReadmeContents(name: string): string {
|
||||
return `# ${name}\n`
|
||||
function defaultReadmeContents(name: string, description?: string): string {
|
||||
return description !== undefined
|
||||
? `# ${name}\n ${description}\n`
|
||||
: `# ${name}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -12,9 +14,10 @@ function defaultReadmeContents(name: string): string {
|
|||
*/
|
||||
export async function writeDefaultReadme(
|
||||
path: string,
|
||||
name: string
|
||||
name: string,
|
||||
description?: string
|
||||
): Promise<void> {
|
||||
const fullPath = Path.join(path, DefaultReadmeName)
|
||||
const contents = defaultReadmeContents(name)
|
||||
const contents = defaultReadmeContents(name, description)
|
||||
await writeFile(fullPath, contents)
|
||||
}
|
||||
|
|
|
@ -93,13 +93,16 @@ import { PopupType, Popup } from '../models/popup'
|
|||
import { OversizedFiles } from './changes/oversized-files-warning'
|
||||
import { UsageStatsChange } from './usage-stats-change'
|
||||
import { PushNeedsPullWarning } from './push-needs-pull'
|
||||
import { LocalChangesOverwrittenWarning } from './local-changes-overwritten'
|
||||
import { RebaseFlow, ConfirmForcePush } from './rebase'
|
||||
import {
|
||||
initializeNewRebaseFlow,
|
||||
initializeRebaseFlowForConflictedRepository,
|
||||
isCurrentBranchForcePush,
|
||||
} from '../lib/rebase'
|
||||
import { BannerType } from '../models/banner'
|
||||
import { StashAndSwitchBranch } from './stash-changes/stash-and-switch-branch-dialog'
|
||||
import { OverwriteStash } from './stash-changes/overwrite-stashed-changes-dialog'
|
||||
import { ConfirmDiscardStashDialog } from './stashing/confirm-discard-stash'
|
||||
|
||||
const MinuteInMilliseconds = 1000 * 60
|
||||
const HourInMilliseconds = MinuteInMilliseconds * 60
|
||||
|
@ -187,10 +190,10 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
const initialTimeout = window.setTimeout(async () => {
|
||||
window.clearTimeout(initialTimeout)
|
||||
|
||||
await this.props.appStore.refreshAllIndicators()
|
||||
await this.props.appStore.refreshAllSidebarIndicators()
|
||||
|
||||
this.updateIntervalHandle = window.setInterval(() => {
|
||||
this.props.appStore.refreshAllIndicators()
|
||||
this.props.appStore.refreshAllSidebarIndicators()
|
||||
}, UpdateRepositoryIndicatorInterval)
|
||||
}, InitialRepositoryIndicatorTimeout)
|
||||
})
|
||||
|
@ -291,6 +294,8 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
switch (name) {
|
||||
case 'push':
|
||||
return this.push()
|
||||
case 'force-push':
|
||||
return this.push({ forceWithLease: true })
|
||||
case 'pull':
|
||||
return this.pull()
|
||||
case 'show-changes':
|
||||
|
@ -313,25 +318,23 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
return this.renameBranch()
|
||||
case 'delete-branch':
|
||||
return this.deleteBranch()
|
||||
case 'discard-all-changes':
|
||||
return this.discardAllChanges()
|
||||
case 'show-preferences':
|
||||
return this.props.dispatcher.showPopup({ type: PopupType.Preferences })
|
||||
case 'open-working-directory':
|
||||
return this.openCurrentRepositoryWorkingDirectory()
|
||||
case 'update-branch': {
|
||||
case 'update-branch':
|
||||
this.props.dispatcher.recordMenuInitiatedUpdate()
|
||||
return this.updateBranch()
|
||||
}
|
||||
case 'compare-to-branch': {
|
||||
case 'compare-to-branch':
|
||||
return this.showHistory(true)
|
||||
}
|
||||
case 'merge-branch': {
|
||||
case 'merge-branch':
|
||||
this.props.dispatcher.recordMenuInitiatedMerge()
|
||||
return this.mergeBranch()
|
||||
}
|
||||
case 'rebase-branch': {
|
||||
case 'rebase-branch':
|
||||
this.props.dispatcher.recordMenuInitiatedRebase()
|
||||
return this.showRebaseDialog()
|
||||
}
|
||||
case 'show-repository-settings':
|
||||
return this.showRepositorySettings()
|
||||
case 'view-repository-on-github':
|
||||
|
@ -348,9 +351,8 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
return this.boomtown()
|
||||
case 'go-to-commit-message':
|
||||
return this.goToCommitMessage()
|
||||
case 'open-pull-request': {
|
||||
case 'open-pull-request':
|
||||
return this.openPullRequest()
|
||||
}
|
||||
case 'install-cli':
|
||||
return this.props.dispatcher.installCLI()
|
||||
case 'open-external-editor':
|
||||
|
@ -359,6 +361,14 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
return this.selectAll()
|
||||
case 'show-release-notes-popup':
|
||||
return this.showFakeReleaseNotesPopup()
|
||||
case 'show-stashed-changes':
|
||||
return this.showStashedChanges()
|
||||
case 'hide-stashed-changes':
|
||||
return this.hideStashedChanges()
|
||||
case 'test-prune-branches':
|
||||
return this.testPruneBranches()
|
||||
case 'find-text':
|
||||
return this.findText()
|
||||
}
|
||||
|
||||
return assertNever(name, `Unknown menu event name: ${name}`)
|
||||
|
@ -419,6 +429,14 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
}
|
||||
}
|
||||
|
||||
private testPruneBranches() {
|
||||
if (!__DEV__) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.appStore._testPruneBranches()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the 'select-all' menu event, dispatches
|
||||
* a custom DOM event originating from the element which
|
||||
|
@ -440,6 +458,28 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the 'find-text' menu event, dispatches
|
||||
* a custom DOM event originating from the element which
|
||||
* currently has keyboard focus (or the document if no element
|
||||
* has focus). Components have a chance to intercept this
|
||||
* event and implement their own 'find-text' logic. One
|
||||
* example of this custom event is the text diff which
|
||||
* will trigger a search dialog when seeing this event.
|
||||
*/
|
||||
private findText() {
|
||||
const event = new CustomEvent('find-text', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
|
||||
if (document.activeElement != null) {
|
||||
document.activeElement.dispatchEvent(event)
|
||||
} else {
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
private boomtown() {
|
||||
setImmediate(() => {
|
||||
throw new Error('Boomtown!')
|
||||
|
@ -467,18 +507,14 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
}
|
||||
|
||||
private getDotComAccount(): Account | null {
|
||||
const state = this.props.appStore.getState()
|
||||
const accounts = state.accounts
|
||||
const dotComAccount = accounts.find(
|
||||
const dotComAccount = this.state.accounts.find(
|
||||
a => a.endpoint === getDotComAPIEndpoint()
|
||||
)
|
||||
return dotComAccount || null
|
||||
}
|
||||
|
||||
private getEnterpriseAccount(): Account | null {
|
||||
const state = this.props.appStore.getState()
|
||||
const accounts = state.accounts
|
||||
const enterpriseAccount = accounts.find(
|
||||
const enterpriseAccount = this.state.accounts.find(
|
||||
a => a.endpoint !== getDotComAPIEndpoint()
|
||||
)
|
||||
return enterpriseAccount || null
|
||||
|
@ -599,6 +635,24 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
}
|
||||
}
|
||||
|
||||
private discardAllChanges() {
|
||||
const state = this.state.selectedState
|
||||
|
||||
if (state == null || state.type !== SelectionType.Repository) {
|
||||
return
|
||||
}
|
||||
|
||||
const { workingDirectory } = state.state.changesState
|
||||
|
||||
this.props.dispatcher.showPopup({
|
||||
type: PopupType.ConfirmDiscardChanges,
|
||||
repository: state.repository,
|
||||
files: workingDirectory.files,
|
||||
showDiscardChangesSetting: false,
|
||||
discardingAllChanges: true,
|
||||
})
|
||||
}
|
||||
|
||||
private showAddLocalRepo = () => {
|
||||
return this.props.dispatcher.showPopup({ type: PopupType.AddRepository })
|
||||
}
|
||||
|
@ -694,13 +748,17 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
return this.props.dispatcher.showFoldout({ type: FoldoutType.Branch })
|
||||
}
|
||||
|
||||
private push() {
|
||||
private push(options?: { forceWithLease: boolean }) {
|
||||
const state = this.state.selectedState
|
||||
if (state == null || state.type !== SelectionType.Repository) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.dispatcher.push(state.repository)
|
||||
if (options && options.forceWithLease) {
|
||||
this.props.dispatcher.confirmOrForcePush(state.repository)
|
||||
} else {
|
||||
this.props.dispatcher.push(state.repository)
|
||||
}
|
||||
}
|
||||
|
||||
private async pull() {
|
||||
|
@ -712,6 +770,24 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
this.props.dispatcher.pull(state.repository)
|
||||
}
|
||||
|
||||
private showStashedChanges() {
|
||||
const state = this.state.selectedState
|
||||
if (state == null || state.type !== SelectionType.Repository) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.dispatcher.selectStashedFile(state.repository)
|
||||
}
|
||||
|
||||
private hideStashedChanges() {
|
||||
const state = this.state.selectedState
|
||||
if (state == null || state.type !== SelectionType.Repository) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.dispatcher.hideStashedChanges(state.repository)
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
document.ondragover = e => {
|
||||
if (e.dataTransfer != null) {
|
||||
|
@ -764,7 +840,12 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
}
|
||||
|
||||
if (shouldRenderApplicationMenu()) {
|
||||
if (event.key === 'Alt') {
|
||||
if (event.key === 'Shift' && event.altKey) {
|
||||
this.props.dispatcher.setAccessKeyHighlightState(false)
|
||||
} else if (event.key === 'Alt') {
|
||||
if (event.shiftKey) {
|
||||
return
|
||||
}
|
||||
// Immediately close the menu if open and the user hits Alt. This is
|
||||
// a Windows convention.
|
||||
if (
|
||||
|
@ -945,12 +1026,13 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
|
||||
const repositoryState = this.props.repositoryStateManager.get(repository)
|
||||
|
||||
const initialState = initializeNewRebaseFlow(repositoryState)
|
||||
const initialStep = initializeNewRebaseFlow(repositoryState)
|
||||
|
||||
this.props.dispatcher.setRebaseFlowStep(repository, initialStep)
|
||||
|
||||
this.props.dispatcher.showPopup({
|
||||
type: PopupType.RebaseFlow,
|
||||
repository,
|
||||
initialState,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1127,12 +1209,18 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
|
||||
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 (
|
||||
<RenameBranch
|
||||
key="rename-branch"
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={popup.repository}
|
||||
branch={popup.branch}
|
||||
stash={stash}
|
||||
/>
|
||||
)
|
||||
case PopupType.DeleteBranch:
|
||||
|
@ -1187,7 +1275,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
}
|
||||
confirmForcePush={this.state.askForConfirmationOnForcePush}
|
||||
selectedExternalEditor={this.state.selectedExternalEditor}
|
||||
optOutOfUsageTracking={this.props.appStore.getStatsOptOut()}
|
||||
optOutOfUsageTracking={this.state.optOutOfUsageTracking}
|
||||
enterpriseAccount={this.getEnterpriseAccount()}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
selectedShell={this.state.selectedShell}
|
||||
|
@ -1299,6 +1387,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
onDismissed={this.onPopupDismissed}
|
||||
dispatcher={this.props.dispatcher}
|
||||
initialName={popup.initialName || ''}
|
||||
handleProtectedBranchWarning={popup.handleProtectedBranchWarning}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -1356,16 +1445,23 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
case PopupType.RemoveRepository:
|
||||
return (
|
||||
<ConfirmRemoveRepository
|
||||
key="confirm-remove-repository"
|
||||
repository={popup.repository}
|
||||
onConfirmation={this.onConfirmRepoRemoval}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
/>
|
||||
)
|
||||
case PopupType.TermsAndConditions:
|
||||
return <TermsAndConditions onDismissed={this.onPopupDismissed} />
|
||||
return (
|
||||
<TermsAndConditions
|
||||
key="terms-and-conditions"
|
||||
onDismissed={this.onPopupDismissed}
|
||||
/>
|
||||
)
|
||||
case PopupType.PushBranchCommits:
|
||||
return (
|
||||
<PushBranchCommits
|
||||
key="push-branch-commits"
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={popup.repository}
|
||||
branch={popup.branch}
|
||||
|
@ -1375,10 +1471,16 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
/>
|
||||
)
|
||||
case PopupType.CLIInstalled:
|
||||
return <CLIInstalled onDismissed={this.onPopupDismissed} />
|
||||
return (
|
||||
<CLIInstalled
|
||||
key="cli-installed"
|
||||
onDismissed={this.onPopupDismissed}
|
||||
/>
|
||||
)
|
||||
case PopupType.GenericGitAuthentication:
|
||||
return (
|
||||
<GenericGitAuthentication
|
||||
key="generic-git-authentication"
|
||||
hostname={popup.hostname}
|
||||
onDismiss={this.onPopupDismissed}
|
||||
onSave={this.onSaveCredentials}
|
||||
|
@ -1411,6 +1513,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
case PopupType.InitializeLFS:
|
||||
return (
|
||||
<InitializeLFS
|
||||
key="initialize-lfs"
|
||||
repositories={popup.repositories}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
onInitialize={this.initializeLFS}
|
||||
|
@ -1419,6 +1522,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
case PopupType.LFSAttributeMismatch:
|
||||
return (
|
||||
<AttributeMismatch
|
||||
key="lsf-attribute-mismatch"
|
||||
onDismissed={this.onPopupDismissed}
|
||||
onUpdateExistingFilters={this.updateExistingLFSFilters}
|
||||
/>
|
||||
|
@ -1426,6 +1530,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
case PopupType.UpstreamAlreadyExists:
|
||||
return (
|
||||
<UpstreamAlreadyExists
|
||||
key="upstream-already-exists"
|
||||
repository={popup.repository}
|
||||
existingRemote={popup.existingRemote}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
|
@ -1436,6 +1541,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
case PopupType.ReleaseNotes:
|
||||
return (
|
||||
<ReleaseNotes
|
||||
key="release-notes"
|
||||
emoji={this.state.emoji}
|
||||
newRelease={popup.newRelease}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
|
@ -1444,6 +1550,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
case PopupType.DeletePullRequest:
|
||||
return (
|
||||
<DeletePullRequest
|
||||
key="delete-pull-request"
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={popup.repository}
|
||||
branch={popup.branch}
|
||||
|
@ -1471,6 +1578,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
|
||||
return (
|
||||
<MergeConflictsDialog
|
||||
key="merge-conflicts-dialog"
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={popup.repository}
|
||||
workingDirectory={workingDirectory}
|
||||
|
@ -1487,6 +1595,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
case PopupType.OversizedFiles:
|
||||
return (
|
||||
<OversizedFiles
|
||||
key="oversized-files"
|
||||
oversizedFiles={popup.oversizedFiles}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
dispatcher={this.props.dispatcher}
|
||||
|
@ -1513,6 +1622,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
|
||||
return (
|
||||
<AbortMergeWarning
|
||||
key="abort-merge-warning"
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={popup.repository}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
|
@ -1524,6 +1634,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
case PopupType.UsageReportingChanges:
|
||||
return (
|
||||
<UsageStatsChange
|
||||
key="usage-stats-change"
|
||||
onOpenUsageDataUrl={this.openUsageDataUrl}
|
||||
onDismissed={this.onUsageReportingDismissed}
|
||||
/>
|
||||
|
@ -1531,6 +1642,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
case PopupType.CommitConflictsWarning:
|
||||
return (
|
||||
<CommitConflictsWarning
|
||||
key="commit-conflicts-warning"
|
||||
dispatcher={this.props.dispatcher}
|
||||
files={popup.files}
|
||||
repository={popup.repository}
|
||||
|
@ -1541,34 +1653,14 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
case PopupType.PushNeedsPull:
|
||||
return (
|
||||
<PushNeedsPullWarning
|
||||
key="push-needs-pull"
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={popup.repository}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
/>
|
||||
)
|
||||
case PopupType.LocalChangesOverwritten: {
|
||||
const { selectedState } = this.state
|
||||
if (
|
||||
selectedState === null ||
|
||||
selectedState.type !== SelectionType.Repository
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { workingDirectory } = selectedState.state.changesState
|
||||
return (
|
||||
<LocalChangesOverwrittenWarning
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={popup.repository}
|
||||
retryAction={popup.retryAction}
|
||||
overwrittenFiles={popup.overwrittenFiles}
|
||||
workingDirectory={workingDirectory}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.RebaseFlow: {
|
||||
const { selectedState } = this.state
|
||||
const { selectedState, emoji } = this.state
|
||||
|
||||
if (
|
||||
selectedState === null ||
|
||||
|
@ -1577,10 +1669,9 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
return null
|
||||
}
|
||||
|
||||
const { initialState } = popup
|
||||
|
||||
const { changesState } = selectedState.state
|
||||
const { changesState, rebaseState } = selectedState.state
|
||||
const { workingDirectory, conflictState } = changesState
|
||||
const { progress, step, userHasResolvedConflicts } = rebaseState
|
||||
|
||||
if (conflictState !== null && conflictState.kind === 'merge') {
|
||||
log.warn(
|
||||
|
@ -1589,18 +1680,31 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
return null
|
||||
}
|
||||
|
||||
if (step === null) {
|
||||
log.warn(
|
||||
'[App] invalid state encountered - rebase flow should not be active when step is null'
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RebaseFlow
|
||||
key="rebase-flow"
|
||||
repository={popup.repository}
|
||||
openFileInExternalEditor={this.openFileInExternalEditor}
|
||||
dispatcher={this.props.dispatcher}
|
||||
onFlowEnded={this.onRebaseFlowEnded}
|
||||
initialState={initialState}
|
||||
workingDirectory={workingDirectory}
|
||||
conflictState={conflictState}
|
||||
progress={progress}
|
||||
step={step}
|
||||
userHasResolvedConflicts={userHasResolvedConflicts}
|
||||
askForConfirmationOnForcePush={
|
||||
this.state.askForConfirmationOnForcePush
|
||||
}
|
||||
resolvedExternalEditor={this.state.resolvedExternalEditor}
|
||||
openRepositoryInShell={this.openCurrentRepositoryInShell}
|
||||
onShowRebaseConflictsBanner={this.onShowRebaseConflictsBanner}
|
||||
emoji={emoji}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -1609,6 +1713,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
|
||||
return (
|
||||
<ConfirmForcePush
|
||||
key="confirm-force-push"
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={popup.repository}
|
||||
upstreamBranch={popup.upstreamBranch}
|
||||
|
@ -1617,6 +1722,58 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.StashAndSwitchBranch: {
|
||||
const { repository, branchToCheckout } = popup
|
||||
const {
|
||||
branchesState,
|
||||
changesState,
|
||||
} = this.props.repositoryStateManager.get(repository)
|
||||
const { tip } = branchesState
|
||||
|
||||
if (tip.kind !== TipState.Valid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentBranch = tip.branch
|
||||
const hasAssociatedStash = changesState.stashEntry !== null
|
||||
|
||||
return (
|
||||
<StashAndSwitchBranch
|
||||
key="stash-and-switch-branch"
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={popup.repository}
|
||||
currentBranch={currentBranch}
|
||||
branchToCheckout={branchToCheckout}
|
||||
hasAssociatedStash={hasAssociatedStash}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.ConfirmOverwriteStash: {
|
||||
const { repository, branchToCheckout: branchToCheckout } = popup
|
||||
return (
|
||||
<OverwriteStash
|
||||
key="overwite-stash"
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={repository}
|
||||
branchToCheckout={branchToCheckout}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.ConfirmDiscardStash: {
|
||||
const { repository, stash } = popup
|
||||
|
||||
return (
|
||||
<ConfirmDiscardStashDialog
|
||||
key="confirm-discard-stash-dialog"
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={repository}
|
||||
stash={stash}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return assertNever(popup, `Unknown popup type: ${popup}`)
|
||||
}
|
||||
|
@ -1629,7 +1786,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
this.props.dispatcher.setBanner({
|
||||
type: BannerType.RebaseConflictsFound,
|
||||
targetBranch,
|
||||
onOpenDialog: () => {
|
||||
onOpenDialog: async () => {
|
||||
const { changesState } = this.props.repositoryStateManager.get(
|
||||
repository
|
||||
)
|
||||
|
@ -1641,21 +1798,26 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
)
|
||||
return
|
||||
}
|
||||
const initialState = initializeRebaseFlowForConflictedRepository(
|
||||
|
||||
await this.props.dispatcher.setRebaseProgressFromState(repository)
|
||||
|
||||
const initialStep = initializeRebaseFlowForConflictedRepository(
|
||||
conflictState
|
||||
)
|
||||
|
||||
this.props.dispatcher.setRebaseFlowStep(repository, initialStep)
|
||||
|
||||
this.props.dispatcher.showPopup({
|
||||
type: PopupType.RebaseFlow,
|
||||
repository,
|
||||
initialState,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private onRebaseFlowEnded = () => {
|
||||
private onRebaseFlowEnded = (repository: Repository) => {
|
||||
this.props.dispatcher.closePopup()
|
||||
this.props.dispatcher.endRebaseFlow(repository)
|
||||
}
|
||||
|
||||
private onUsageReportingDismissed = (optOut: boolean) => {
|
||||
|
@ -1924,18 +2086,14 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
const rebaseInProgress =
|
||||
conflictState !== null && conflictState.kind === 'rebase'
|
||||
|
||||
const { pullWithRebase, tip, rebasedBranches } = state.branchesState
|
||||
const { aheadBehind, branchesState } = state
|
||||
const { pullWithRebase, tip } = branchesState
|
||||
|
||||
if (tip.kind === TipState.Valid && tip.branch.remote !== null) {
|
||||
remoteName = tip.branch.remote
|
||||
}
|
||||
let branchWasRebased = false
|
||||
if (tip.kind === TipState.Valid) {
|
||||
const localBranchName = tip.branch.nameWithoutRemote
|
||||
const { sha } = tip.branch.tip
|
||||
const foundEntry = rebasedBranches.get(localBranchName)
|
||||
branchWasRebased = foundEntry === sha
|
||||
}
|
||||
|
||||
const isForcePush = isCurrentBranchForcePush(branchesState, aheadBehind)
|
||||
|
||||
return (
|
||||
<PushPullButton
|
||||
|
@ -1949,7 +2107,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
tipState={tip.kind}
|
||||
pullWithRebase={pullWithRebase}
|
||||
rebaseInProgress={rebaseInProgress}
|
||||
branchWasRebased={branchWasRebased}
|
||||
isForcePush={isForcePush}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -2019,8 +2177,14 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
}
|
||||
|
||||
const currentFoldout = this.state.currentFoldout
|
||||
const isOpen =
|
||||
!!currentFoldout && currentFoldout.type === FoldoutType.Branch
|
||||
|
||||
let isOpen = false
|
||||
let handleProtectedBranchWarning: boolean | undefined
|
||||
|
||||
if (currentFoldout !== null && currentFoldout.type === FoldoutType.Branch) {
|
||||
isOpen = true
|
||||
handleProtectedBranchWarning = currentFoldout.handleProtectedBranchWarning
|
||||
}
|
||||
|
||||
const repository = selection.repository
|
||||
const branchesState = selection.state.branchesState
|
||||
|
@ -2036,6 +2200,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
pullRequests={branchesState.openPullRequests}
|
||||
currentPullRequest={branchesState.currentPullRequest}
|
||||
isLoadingPullRequests={branchesState.isLoadingPullRequests}
|
||||
handleProtectedBranchWarning={handleProtectedBranchWarning}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -2141,6 +2306,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
emoji={state.emoji}
|
||||
sidebarWidth={state.sidebarWidth}
|
||||
commitSummaryWidth={state.commitSummaryWidth}
|
||||
stashedFilesWidth={state.stashedFilesWidth}
|
||||
issuesStore={this.props.issuesStore}
|
||||
gitHubUserStore={this.props.gitHubUserStore}
|
||||
onViewCommitOnGitHub={this.onViewCommitOnGitHub}
|
||||
|
@ -2178,7 +2344,8 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
return (
|
||||
<Welcome
|
||||
dispatcher={this.props.dispatcher}
|
||||
appStore={this.props.appStore}
|
||||
optOut={this.state.optOutOfUsageTracking}
|
||||
accounts={this.state.accounts}
|
||||
signInState={this.state.signInState}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -18,7 +18,7 @@ export class RebaseConflictsBanner extends React.Component<
|
|||
IRebaseConflictsBannerProps,
|
||||
{}
|
||||
> {
|
||||
private openDialog = () => {
|
||||
private openDialog = async () => {
|
||||
this.props.onDismissed()
|
||||
this.props.onOpenDialog()
|
||||
this.props.dispatcher.recordRebaseConflictsDialogReopened()
|
||||
|
|
|
@ -12,6 +12,7 @@ import { TabBar } from '../tab-bar'
|
|||
import { CloneableRepositoryFilterList } from '../clone-repository/cloneable-repository-filter-list'
|
||||
import { IAPIRepository } from '../../lib/api'
|
||||
import { assertNever } from '../../lib/fatal-error'
|
||||
import { ClickSource } from '../lib/list'
|
||||
|
||||
interface IBlankSlateProps {
|
||||
/** A function to call when the user chooses to create a repository. */
|
||||
|
@ -26,11 +27,11 @@ interface IBlankSlateProps {
|
|||
/** The logged in account for GitHub.com. */
|
||||
readonly dotComAccount: Account | null
|
||||
|
||||
/** The logged in account for GitHub Enterprise. */
|
||||
/** The logged in account for GitHub Enterprise Server. */
|
||||
readonly enterpriseAccount: Account | null
|
||||
|
||||
/**
|
||||
* A map keyed on a user account (GitHub.com or GitHub Enterprise)
|
||||
* A map keyed on a user account (GitHub.com or GitHub Enterprise Server)
|
||||
* containing an object with repositories that the authenticated
|
||||
* user has explicit permission (:read, :write, or :admin) to access
|
||||
* as well as information about whether the list of repositories
|
||||
|
@ -141,15 +142,24 @@ export class BlankSlateView extends React.Component<
|
|||
this.ensureRepositoriesForAccount(this.getSelectedAccount())
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IBlankSlateProps) {
|
||||
this.ensureRepositoriesForAccount(this.getSelectedAccount())
|
||||
public componentDidUpdate(
|
||||
prevProps: IBlankSlateProps,
|
||||
prevState: IBlankSlateState
|
||||
) {
|
||||
if (
|
||||
prevProps.dotComAccount !== this.props.dotComAccount ||
|
||||
prevProps.enterpriseAccount !== this.props.enterpriseAccount ||
|
||||
prevState.selectedTab !== this.state.selectedTab
|
||||
) {
|
||||
this.ensureRepositoriesForAccount(this.getSelectedAccount())
|
||||
}
|
||||
}
|
||||
|
||||
private ensureRepositoriesForAccount(account: Account | null) {
|
||||
if (account !== null) {
|
||||
const accountState = this.props.apiRepositories.get(account)
|
||||
|
||||
if (accountState === undefined || accountState.repositories === null) {
|
||||
if (accountState === undefined) {
|
||||
this.props.onRefreshRepositories(account)
|
||||
}
|
||||
}
|
||||
|
@ -217,12 +227,19 @@ export class BlankSlateView extends React.Component<
|
|||
repositories={repositories}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
onFilterTextChanged={this.onFilterTextChanged}
|
||||
onItemClicked={this.onItemClicked}
|
||||
/>
|
||||
{this.renderCloneSelectedRepositoryButton(selectedItem)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
private onItemClicked = (repository: IAPIRepository, source: ClickSource) => {
|
||||
if (source.kind === 'keyboard' && source.event.key === 'Enter') {
|
||||
this.onCloneSelectedRepository()
|
||||
}
|
||||
}
|
||||
|
||||
private renderCloneSelectedRepositoryButton(
|
||||
selectedItem: IAPIRepository | null
|
||||
) {
|
||||
|
@ -291,7 +308,7 @@ export class BlankSlateView extends React.Component<
|
|||
return (
|
||||
<TabBar selectedIndex={selectedIndex} onTabClicked={this.onTabClicked}>
|
||||
<span>GitHub.com</span>
|
||||
<span>Enterprise</span>
|
||||
<span>GitHub Enterprise Server</span>
|
||||
</TabBar>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
BranchGroupIdentifier,
|
||||
} from './group-branches'
|
||||
import { NoBranches } from './no-branches'
|
||||
import { SelectionDirection } from '../lib/list'
|
||||
|
||||
const RowHeight = 30
|
||||
|
||||
|
@ -161,9 +162,9 @@ export class BranchList extends React.Component<
|
|||
this.setState(createState(nextProps))
|
||||
}
|
||||
|
||||
public selectFirstItem(focus: boolean = false) {
|
||||
public selectNextItem(focus: boolean = false, direction: SelectionDirection) {
|
||||
if (this.branchFilterList !== null) {
|
||||
this.branchFilterList.selectFirstItem(focus)
|
||||
this.branchFilterList.selectNextItem(focus, direction)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,12 @@ import { PullRequestList } from './pull-request-list'
|
|||
import { IBranchListItem } from './group-branches'
|
||||
import { renderDefaultBranch } from './branch-renderer'
|
||||
import { IMatches } from '../../lib/fuzzy-find'
|
||||
import { startTimer } from '../lib/timing'
|
||||
import {
|
||||
UncommittedChangesStrategyKind,
|
||||
UncommittedChangesStrategy,
|
||||
askToStash,
|
||||
} from '../../models/uncommitted-changes-strategy'
|
||||
|
||||
interface IBranchesContainerProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
|
@ -31,14 +37,15 @@ interface IBranchesContainerProps {
|
|||
readonly currentBranch: Branch | null
|
||||
readonly recentBranches: ReadonlyArray<Branch>
|
||||
readonly pullRequests: ReadonlyArray<PullRequest>
|
||||
readonly branchFilterText: string
|
||||
readonly pullRequestFilterText: string
|
||||
|
||||
/** The pull request associated with the current branch. */
|
||||
readonly currentPullRequest: PullRequest | null
|
||||
|
||||
/** Are we currently loading pull requests? */
|
||||
readonly isLoadingPullRequests: boolean
|
||||
|
||||
/** Was this component launched from the "Protected Branch" warning message? */
|
||||
readonly handleProtectedBranchWarning?: boolean
|
||||
}
|
||||
|
||||
interface IBranchesContainerState {
|
||||
|
@ -59,8 +66,8 @@ export class BranchesContainer extends React.Component<
|
|||
this.state = {
|
||||
selectedBranch: props.currentBranch,
|
||||
selectedPullRequest: props.currentPullRequest,
|
||||
branchFilterText: props.branchFilterText,
|
||||
pullRequestFilterText: props.pullRequestFilterText,
|
||||
branchFilterText: '',
|
||||
pullRequestFilterText: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,18 +102,21 @@ export class BranchesContainer extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private renderOpenPullRequestsBubble() {
|
||||
const { pullRequests } = this.props
|
||||
|
||||
if (pullRequests.length > 0) {
|
||||
return <span className="count">{pullRequests.length}</span>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private renderTabBar() {
|
||||
if (!this.props.repository.gitHubRepository) {
|
||||
return null
|
||||
}
|
||||
|
||||
let countElement = null
|
||||
if (this.props.pullRequests) {
|
||||
countElement = (
|
||||
<span className="count">{this.props.pullRequests.length}</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TabBar
|
||||
onTabClicked={this.onTabClicked}
|
||||
|
@ -115,8 +125,7 @@ export class BranchesContainer extends React.Component<
|
|||
<span>Branches</span>
|
||||
<span className="pull-request-tab">
|
||||
{__DARWIN__ ? 'Pull Requests' : 'Pull requests'}
|
||||
|
||||
{countElement}
|
||||
{this.renderOpenPullRequestsBubble()}
|
||||
</span>
|
||||
</TabBar>
|
||||
)
|
||||
|
@ -233,10 +242,27 @@ export class BranchesContainer extends React.Component<
|
|||
private onBranchItemClick = (branch: Branch) => {
|
||||
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
|
||||
|
||||
const currentBranch = this.props.currentBranch
|
||||
const {
|
||||
currentBranch,
|
||||
repository,
|
||||
handleProtectedBranchWarning,
|
||||
} = this.props
|
||||
|
||||
if (currentBranch == null || currentBranch.name !== branch.name) {
|
||||
this.props.dispatcher.checkoutBranch(this.props.repository, branch)
|
||||
const timer = startTimer('checkout branch from list', repository)
|
||||
|
||||
// if the user arrived at this dialog from the Protected Branch flow
|
||||
// we should bypass the "Switch Branch" flow and get out of the user's way
|
||||
const strategy: UncommittedChangesStrategy = handleProtectedBranchWarning
|
||||
? {
|
||||
kind: UncommittedChangesStrategyKind.MoveToNewBranch,
|
||||
transientStashEntry: null,
|
||||
}
|
||||
: askToStash
|
||||
|
||||
this.props.dispatcher
|
||||
.checkoutBranch(repository, branch, strategy)
|
||||
.then(() => timer.done())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -249,10 +275,13 @@ export class BranchesContainer extends React.Component<
|
|||
}
|
||||
|
||||
private onCreateBranchWithName = (name: string) => {
|
||||
const { repository, handleProtectedBranchWarning } = this.props
|
||||
|
||||
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
|
||||
this.props.dispatcher.showPopup({
|
||||
type: PopupType.CreateBranch,
|
||||
repository: this.props.repository,
|
||||
repository,
|
||||
handleProtectedBranchWarning,
|
||||
initialName: name,
|
||||
})
|
||||
}
|
||||
|
@ -278,22 +307,14 @@ export class BranchesContainer extends React.Component<
|
|||
|
||||
private onPullRequestClicked = (pullRequest: PullRequest) => {
|
||||
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
|
||||
this.props.dispatcher.checkoutPullRequest(
|
||||
this.props.repository,
|
||||
pullRequest
|
||||
const timer = startTimer(
|
||||
'checkout pull request from list',
|
||||
this.props.repository
|
||||
)
|
||||
this.props.dispatcher
|
||||
.checkoutPullRequest(this.props.repository, pullRequest)
|
||||
.then(() => timer.done())
|
||||
|
||||
this.onPullRequestSelectionChanged(pullRequest)
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.props.dispatcher.setBranchFilterText(
|
||||
this.props.repository,
|
||||
this.state.branchFilterText
|
||||
)
|
||||
this.props.dispatcher.setPullRequestFilterText(
|
||||
this.props.repository,
|
||||
this.state.pullRequestFilterText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ interface IBlankSlateActionProps {
|
|||
* A callback which is invoked when the user clicks
|
||||
* or activates the action using their keyboard.
|
||||
*/
|
||||
readonly onClick: () => void
|
||||
readonly onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
|
||||
/**
|
||||
* The type of action, currently supported actions are
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { AppFileStatus } from '../../models/status'
|
||||
import { PathLabel } from '../lib/path-label'
|
||||
import { Octicon, iconForStatus } from '../octicons'
|
||||
import { Checkbox, CheckboxValue } from '../lib/checkbox'
|
||||
import { mapStatus } from '../../lib/status'
|
||||
import { WorkingDirectoryFileChange } from '../../models/status'
|
||||
|
||||
interface IChangedFileProps {
|
||||
readonly id: string
|
||||
readonly path: string
|
||||
readonly status: AppFileStatus
|
||||
readonly file: WorkingDirectoryFileChange
|
||||
readonly include: boolean | null
|
||||
readonly availableWidth: number
|
||||
readonly disableSelection: boolean
|
||||
|
@ -17,9 +15,7 @@ interface IChangedFileProps {
|
|||
|
||||
/** Callback called when user right-clicks on an item */
|
||||
readonly onContextMenu: (
|
||||
id: string,
|
||||
path: string,
|
||||
status: AppFileStatus,
|
||||
file: WorkingDirectoryFileChange,
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => void
|
||||
}
|
||||
|
@ -28,7 +24,7 @@ interface IChangedFileProps {
|
|||
export class ChangedFile extends React.Component<IChangedFileProps, {}> {
|
||||
private handleCheckboxChange = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
const include = event.currentTarget.checked
|
||||
this.props.onIncludeChanged(this.props.path, include)
|
||||
this.props.onIncludeChanged(this.props.file.path, include)
|
||||
}
|
||||
|
||||
private get checkboxValue(): CheckboxValue {
|
||||
|
@ -42,7 +38,7 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
const status = this.props.status
|
||||
const { status, path } = this.props.file
|
||||
const fileStatus = mapStatus(status)
|
||||
|
||||
const listItemPadding = 10 * 2
|
||||
|
@ -70,8 +66,8 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
|
|||
/>
|
||||
|
||||
<PathLabel
|
||||
path={this.props.path}
|
||||
status={this.props.status}
|
||||
path={path}
|
||||
status={status}
|
||||
availableWidth={availablePathWidth}
|
||||
/>
|
||||
|
||||
|
@ -85,11 +81,6 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
|
|||
}
|
||||
|
||||
private onContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
this.props.onContextMenu(
|
||||
this.props.id,
|
||||
this.props.path,
|
||||
this.props.status,
|
||||
event
|
||||
)
|
||||
this.props.onContextMenu(this.props.file, event)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ import { Dispatcher } from '../dispatcher'
|
|||
import { IMenuItem } from '../../lib/menu-item'
|
||||
import { revealInFileManager } from '../../lib/app-shell'
|
||||
import {
|
||||
AppFileStatus,
|
||||
WorkingDirectoryStatus,
|
||||
WorkingDirectoryFileChange,
|
||||
AppFileStatusKind,
|
||||
|
@ -35,12 +34,63 @@ import { basename } from 'path'
|
|||
import { ICommitContext } from '../../models/commit'
|
||||
import { RebaseConflictState } from '../../lib/app-state'
|
||||
import { ContinueRebase } from './continue-rebase'
|
||||
import { enablePullWithRebase } from '../../lib/feature-flag'
|
||||
import { enableStashing } from '../../lib/feature-flag'
|
||||
import { Octicon, OcticonSymbol } from '../octicons'
|
||||
import { IStashEntry } from '../../models/stash-entry'
|
||||
import * as classNames from 'classnames'
|
||||
|
||||
const RowHeight = 29
|
||||
const StashIcon = new OcticonSymbol(
|
||||
16,
|
||||
16,
|
||||
'M3.002 15H15V4c.51 0 1 .525 1 .996V15c0 .471-.49 1-1 1H4.002c-.51 ' +
|
||||
'0-1-.529-1-1zm-2-2H13V2c.51 0 1 .525 1 .996V13c0 .471-.49 1-1 ' +
|
||||
'1H2.002c-.51 0-1-.529-1-1zm10.14-13A.86.86 0 0 1 12 .857v10.286a.86.86 ' +
|
||||
'0 0 1-.857.857H.857A.86.86 0 0 1 0 11.143V.857A.86.86 0 0 1 .857 0h10.286zM11 ' +
|
||||
'11V1H1v10h10zM3 6c0-1.66 1.34-3 3-3s3 1.34 3 3-1.34 3-3 3-3-1.34-3-3z'
|
||||
)
|
||||
|
||||
const GitIgnoreFileName = '.gitignore'
|
||||
|
||||
/** Compute the 'Include All' checkbox value from the repository state */
|
||||
function getIncludeAllValue(
|
||||
workingDirectory: WorkingDirectoryStatus,
|
||||
rebaseConflictState: RebaseConflictState | null
|
||||
) {
|
||||
if (rebaseConflictState !== null) {
|
||||
if (workingDirectory.files.length === 0) {
|
||||
// the current commit will be skipped in the rebase
|
||||
return CheckboxValue.Off
|
||||
}
|
||||
|
||||
// untracked files will be skipped by the rebase, so we need to ensure that
|
||||
// the "Include All" checkbox matches this state
|
||||
const onlyUntrackedFilesFound = workingDirectory.files.every(
|
||||
f => f.status.kind === AppFileStatusKind.Untracked
|
||||
)
|
||||
|
||||
if (onlyUntrackedFilesFound) {
|
||||
return CheckboxValue.Off
|
||||
}
|
||||
|
||||
const onlyTrackedFilesFound = workingDirectory.files.every(
|
||||
f => f.status.kind !== AppFileStatusKind.Untracked
|
||||
)
|
||||
|
||||
// show "Mixed" if we have a mixture of tracked and untracked changes
|
||||
return onlyTrackedFilesFound ? CheckboxValue.On : CheckboxValue.Mixed
|
||||
}
|
||||
|
||||
const { includeAll } = workingDirectory
|
||||
if (includeAll === true) {
|
||||
return CheckboxValue.On
|
||||
} else if (includeAll === false) {
|
||||
return CheckboxValue.Off
|
||||
} else {
|
||||
return CheckboxValue.Mixed
|
||||
}
|
||||
}
|
||||
|
||||
interface IChangesListProps {
|
||||
readonly repository: Repository
|
||||
readonly workingDirectory: WorkingDirectoryStatus
|
||||
|
@ -53,9 +103,9 @@ interface IChangesListProps {
|
|||
readonly onDiscardChanges: (file: WorkingDirectoryFileChange) => void
|
||||
readonly askForConfirmationOnDiscardChanges: boolean
|
||||
readonly focusCommitMessage: boolean
|
||||
readonly onDiscardAllChanges: (
|
||||
readonly onDiscardChangesFromFiles: (
|
||||
files: ReadonlyArray<WorkingDirectoryFileChange>,
|
||||
isDiscardingAllChanges?: boolean
|
||||
isDiscardingAllChanges: boolean
|
||||
) => void
|
||||
|
||||
/** Callback that fires on page scroll to pass the new scrollTop location */
|
||||
|
@ -75,6 +125,7 @@ interface IChangesListProps {
|
|||
readonly dispatcher: Dispatcher
|
||||
readonly availableWidth: number
|
||||
readonly isCommitting: boolean
|
||||
readonly currentBranchProtected: boolean
|
||||
|
||||
/**
|
||||
* Click event handler passed directly to the onRowClick prop of List, see
|
||||
|
@ -113,6 +164,10 @@ interface IChangesListProps {
|
|||
* @param fullPath The full path to the file on disk
|
||||
*/
|
||||
readonly onOpenInExternalEditor: (fullPath: string) => void
|
||||
|
||||
readonly stashEntry: IStashEntry | null
|
||||
|
||||
readonly isShowingStashEntry: boolean
|
||||
}
|
||||
|
||||
interface IChangesState {
|
||||
|
@ -166,7 +221,15 @@ export class ChangesList extends React.Component<
|
|||
}
|
||||
|
||||
private renderRow = (row: number): JSX.Element => {
|
||||
const file = this.props.workingDirectory.files[row]
|
||||
const {
|
||||
workingDirectory,
|
||||
rebaseConflictState,
|
||||
isCommitting,
|
||||
onIncludeChanged,
|
||||
availableWidth,
|
||||
} = this.props
|
||||
|
||||
const file = workingDirectory.files[row]
|
||||
const selection = file.selection.getSelectionType()
|
||||
|
||||
const includeAll =
|
||||
|
@ -176,34 +239,31 @@ export class ChangesList extends React.Component<
|
|||
? false
|
||||
: null
|
||||
|
||||
const include =
|
||||
rebaseConflictState !== null
|
||||
? file.status.kind !== AppFileStatusKind.Untracked
|
||||
: includeAll
|
||||
|
||||
const disableSelection = isCommitting || rebaseConflictState !== null
|
||||
|
||||
return (
|
||||
<ChangedFile
|
||||
id={file.id}
|
||||
path={file.path}
|
||||
status={file.status}
|
||||
include={includeAll}
|
||||
file={file}
|
||||
include={include}
|
||||
key={file.id}
|
||||
onContextMenu={this.onItemContextMenu}
|
||||
onIncludeChanged={this.props.onIncludeChanged}
|
||||
availableWidth={this.props.availableWidth}
|
||||
disableSelection={this.props.isCommitting}
|
||||
onIncludeChanged={onIncludeChanged}
|
||||
availableWidth={availableWidth}
|
||||
disableSelection={disableSelection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private get includeAllValue(): CheckboxValue {
|
||||
const includeAll = this.props.workingDirectory.includeAll
|
||||
if (includeAll === true) {
|
||||
return CheckboxValue.On
|
||||
} else if (includeAll === false) {
|
||||
return CheckboxValue.Off
|
||||
} else {
|
||||
return CheckboxValue.Mixed
|
||||
}
|
||||
}
|
||||
|
||||
private onDiscardAllChanges = () => {
|
||||
this.props.onDiscardAllChanges(this.props.workingDirectory.files)
|
||||
this.props.onDiscardChangesFromFiles(
|
||||
this.props.workingDirectory.files,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
private onDiscardChanges = (files: ReadonlyArray<string>) => {
|
||||
|
@ -232,7 +292,10 @@ export class ChangesList extends React.Component<
|
|||
const discardingAllChanges =
|
||||
modifiedFiles.length === workingDirectory.files.length
|
||||
|
||||
this.props.onDiscardAllChanges(modifiedFiles, discardingAllChanges)
|
||||
this.props.onDiscardChangesFromFiles(
|
||||
modifiedFiles,
|
||||
discardingAllChanges
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -253,6 +316,11 @@ export class ChangesList extends React.Component<
|
|||
private onContextMenu = (event: React.MouseEvent<any>) => {
|
||||
event.preventDefault()
|
||||
|
||||
// need to preserve the working directory state while dealing with conflicts
|
||||
if (this.props.rebaseConflictState !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
const items: IMenuItem[] = [
|
||||
{
|
||||
label: __DARWIN__ ? 'Discard All Changes…' : 'Discard all changes…',
|
||||
|
@ -264,27 +332,73 @@ export class ChangesList extends React.Component<
|
|||
showContextualMenu(items)
|
||||
}
|
||||
|
||||
private onItemContextMenu = (
|
||||
id: string,
|
||||
path: string,
|
||||
status: AppFileStatus,
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
event.preventDefault()
|
||||
private getDiscardChangesMenuItem = (
|
||||
paths: ReadonlyArray<string>
|
||||
): IMenuItem => {
|
||||
return {
|
||||
label: this.getDiscardChangesMenuItemLabel(paths),
|
||||
action: () => this.onDiscardChanges(paths),
|
||||
}
|
||||
}
|
||||
|
||||
private getCopyPathMenuItem = (
|
||||
file: WorkingDirectoryFileChange
|
||||
): IMenuItem => {
|
||||
return {
|
||||
label: CopyFilePathLabel,
|
||||
action: () => {
|
||||
const fullPath = Path.join(this.props.repository.path, file.path)
|
||||
clipboard.writeText(fullPath)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private getRevealInFileManagerMenuItem = (
|
||||
file: WorkingDirectoryFileChange
|
||||
): IMenuItem => {
|
||||
return {
|
||||
label: RevealInFileManagerLabel,
|
||||
action: () => revealInFileManager(this.props.repository, file.path),
|
||||
enabled: file.status.kind !== AppFileStatusKind.Deleted,
|
||||
}
|
||||
}
|
||||
|
||||
private getOpenInExternalEditorMenuItem = (
|
||||
file: WorkingDirectoryFileChange,
|
||||
enabled: boolean
|
||||
): IMenuItem => {
|
||||
const { externalEditorLabel, repository } = this.props
|
||||
|
||||
const openInExternalEditor = externalEditorLabel
|
||||
? `Open in ${externalEditorLabel}`
|
||||
: DefaultEditorLabel
|
||||
|
||||
return {
|
||||
label: openInExternalEditor,
|
||||
action: () => {
|
||||
const fullPath = Path.join(repository.path, file.path)
|
||||
this.props.onOpenInExternalEditor(fullPath)
|
||||
},
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultContextMenu(
|
||||
file: WorkingDirectoryFileChange
|
||||
): ReadonlyArray<IMenuItem> {
|
||||
const { id, path, status } = file
|
||||
|
||||
const extension = Path.extname(path)
|
||||
const isSafeExtension = isSafeFileExtension(extension)
|
||||
const openInExternalEditor = this.props.externalEditorLabel
|
||||
? `Open in ${this.props.externalEditorLabel}`
|
||||
: DefaultEditorLabel
|
||||
|
||||
const wd = this.props.workingDirectory
|
||||
const { workingDirectory, selectedFileIDs } = this.props
|
||||
|
||||
const selectedFiles = new Array<WorkingDirectoryFileChange>()
|
||||
const paths = new Array<string>()
|
||||
const extensions = new Set<string>()
|
||||
|
||||
const addItemToArray = (fileID: string) => {
|
||||
const newFile = wd.findFileWithID(fileID)
|
||||
const newFile = workingDirectory.findFileWithID(fileID)
|
||||
if (newFile) {
|
||||
selectedFiles.push(newFile)
|
||||
paths.push(newFile.path)
|
||||
|
@ -296,10 +410,10 @@ export class ChangesList extends React.Component<
|
|||
}
|
||||
}
|
||||
|
||||
if (this.props.selectedFileIDs.includes(id)) {
|
||||
if (selectedFileIDs.includes(id)) {
|
||||
// user has selected a file inside an existing selection
|
||||
// -> context menu entries should be applied to all selected files
|
||||
this.props.selectedFileIDs.forEach(addItemToArray)
|
||||
selectedFileIDs.forEach(addItemToArray)
|
||||
} else {
|
||||
// this is outside their previous selection
|
||||
// -> context menu entries should be applied to just this file
|
||||
|
@ -307,14 +421,7 @@ export class ChangesList extends React.Component<
|
|||
}
|
||||
|
||||
const items: IMenuItem[] = [
|
||||
{
|
||||
label: this.getDiscardChangesMenuItemLabel(paths),
|
||||
action: () => this.onDiscardChanges(paths),
|
||||
},
|
||||
{
|
||||
label: __DARWIN__ ? 'Discard All Changes…' : 'Discard all changes…',
|
||||
action: () => this.onDiscardAllChanges(),
|
||||
},
|
||||
this.getDiscardChangesMenuItem(paths),
|
||||
{ type: 'separator' },
|
||||
]
|
||||
if (paths.length === 1) {
|
||||
|
@ -354,35 +461,66 @@ export class ChangesList extends React.Component<
|
|||
})
|
||||
})
|
||||
|
||||
const enabled = isSafeExtension && status.kind !== AppFileStatusKind.Deleted
|
||||
|
||||
items.push(
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: CopyFilePathLabel,
|
||||
action: () => {
|
||||
const fullPath = Path.join(this.props.repository.path, path)
|
||||
clipboard.writeText(fullPath)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: RevealInFileManagerLabel,
|
||||
action: () => revealInFileManager(this.props.repository, path),
|
||||
enabled: status.kind !== AppFileStatusKind.Deleted,
|
||||
},
|
||||
{
|
||||
label: openInExternalEditor,
|
||||
action: () => {
|
||||
const fullPath = Path.join(this.props.repository.path, path)
|
||||
this.props.onOpenInExternalEditor(fullPath)
|
||||
},
|
||||
enabled: isSafeExtension && status.kind !== AppFileStatusKind.Deleted,
|
||||
},
|
||||
this.getCopyPathMenuItem(file),
|
||||
this.getRevealInFileManagerMenuItem(file),
|
||||
this.getOpenInExternalEditorMenuItem(file, enabled),
|
||||
{
|
||||
label: OpenWithDefaultProgramLabel,
|
||||
action: () => this.props.onOpenItem(path),
|
||||
enabled: isSafeExtension && status.kind !== AppFileStatusKind.Deleted,
|
||||
enabled,
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private getRebaseContextMenu(
|
||||
file: WorkingDirectoryFileChange
|
||||
): ReadonlyArray<IMenuItem> {
|
||||
const { path, status } = file
|
||||
|
||||
const extension = Path.extname(path)
|
||||
const isSafeExtension = isSafeFileExtension(extension)
|
||||
|
||||
const items = new Array<IMenuItem>()
|
||||
|
||||
if (file.status.kind === AppFileStatusKind.Untracked) {
|
||||
items.push(this.getDiscardChangesMenuItem([file.path]), {
|
||||
type: 'separator',
|
||||
})
|
||||
}
|
||||
|
||||
const enabled = isSafeExtension && status.kind !== AppFileStatusKind.Deleted
|
||||
|
||||
items.push(
|
||||
this.getCopyPathMenuItem(file),
|
||||
this.getRevealInFileManagerMenuItem(file),
|
||||
this.getOpenInExternalEditorMenuItem(file, enabled),
|
||||
{
|
||||
label: OpenWithDefaultProgramLabel,
|
||||
action: () => this.props.onOpenItem(path),
|
||||
enabled,
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private onItemContextMenu = (
|
||||
file: WorkingDirectoryFileChange,
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
event.preventDefault()
|
||||
|
||||
const items =
|
||||
this.props.rebaseConflictState === null
|
||||
? this.getDefaultContextMenu(file)
|
||||
: this.getRebaseContextMenu(file)
|
||||
|
||||
showContextualMenu(items)
|
||||
}
|
||||
|
||||
|
@ -417,23 +555,43 @@ export class ChangesList extends React.Component<
|
|||
}
|
||||
|
||||
private renderCommitMessageForm = (): JSX.Element => {
|
||||
if (this.props.rebaseConflictState !== null && enablePullWithRebase()) {
|
||||
const {
|
||||
rebaseConflictState,
|
||||
workingDirectory,
|
||||
repository,
|
||||
dispatcher,
|
||||
isCommitting,
|
||||
currentBranchProtected,
|
||||
} = this.props
|
||||
|
||||
if (rebaseConflictState !== null) {
|
||||
const hasUntrackedChanges = workingDirectory.files.some(
|
||||
f => f.status.kind === AppFileStatusKind.Untracked
|
||||
)
|
||||
|
||||
return (
|
||||
<ContinueRebase
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={this.props.repository}
|
||||
rebaseConflictState={this.props.rebaseConflictState}
|
||||
workingDirectory={this.props.workingDirectory}
|
||||
isCommitting={this.props.isCommitting}
|
||||
dispatcher={dispatcher}
|
||||
repository={repository}
|
||||
rebaseConflictState={rebaseConflictState}
|
||||
workingDirectory={workingDirectory}
|
||||
isCommitting={isCommitting}
|
||||
hasUntrackedChanges={hasUntrackedChanges}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fileCount = this.props.workingDirectory.files.length
|
||||
const fileCount = workingDirectory.files.length
|
||||
|
||||
const includeAllValue = getIncludeAllValue(
|
||||
workingDirectory,
|
||||
rebaseConflictState
|
||||
)
|
||||
|
||||
const anyFilesSelected =
|
||||
fileCount > 0 && this.includeAllValue !== CheckboxValue.Off
|
||||
const filesSelected = this.props.workingDirectory.files.filter(
|
||||
fileCount > 0 && includeAllValue !== CheckboxValue.Off
|
||||
|
||||
const filesSelected = workingDirectory.files.filter(
|
||||
f => f.selection.getSelectionType() !== DiffSelectionType.None
|
||||
)
|
||||
const singleFileCommit = filesSelected.length === 1
|
||||
|
@ -445,12 +603,12 @@ export class ChangesList extends React.Component<
|
|||
gitHubUser={this.props.gitHubUser}
|
||||
commitAuthor={this.props.commitAuthor}
|
||||
anyFilesSelected={anyFilesSelected}
|
||||
repository={this.props.repository}
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={repository}
|
||||
dispatcher={dispatcher}
|
||||
commitMessage={this.props.commitMessage}
|
||||
focusCommitMessage={this.props.focusCommitMessage}
|
||||
autocompletionProviders={this.props.autocompletionProviders}
|
||||
isCommitting={this.props.isCommitting}
|
||||
isCommitting={isCommitting}
|
||||
showCoAuthoredBy={this.props.showCoAuthoredBy}
|
||||
coAuthors={this.props.coAuthors}
|
||||
placeholder={this.getPlaceholderMessage(
|
||||
|
@ -458,27 +616,77 @@ export class ChangesList extends React.Component<
|
|||
singleFileCommit
|
||||
)}
|
||||
singleFileCommit={singleFileCommit}
|
||||
key={this.props.repository.id}
|
||||
key={repository.id}
|
||||
currentBranchProtected={currentBranchProtected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private onStashEntryClicked = () => {
|
||||
const { isShowingStashEntry, dispatcher, repository } = this.props
|
||||
|
||||
if (isShowingStashEntry) {
|
||||
dispatcher.selectWorkingDirectoryFiles(repository)
|
||||
|
||||
// If the button is clicked, that implies the stash was not restored or discarded
|
||||
dispatcher.recordNoActionTakenOnStash()
|
||||
} else {
|
||||
dispatcher.selectStashedFile(repository)
|
||||
dispatcher.recordStashView()
|
||||
}
|
||||
}
|
||||
|
||||
private renderStashedChanges() {
|
||||
if (!enableStashing()) {
|
||||
return null
|
||||
}
|
||||
if (this.props.stashEntry === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const className = classNames(
|
||||
'stashed-changes-button',
|
||||
this.props.isShowingStashEntry ? 'selected' : null
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
onClick={this.onStashEntryClicked}
|
||||
tabIndex={0}
|
||||
aria-selected={this.props.isShowingStashEntry}
|
||||
>
|
||||
<Octicon className="stack-icon" symbol={StashIcon} />
|
||||
<div className="text">Stashed Changes</div>
|
||||
<Octicon symbol={OcticonSymbol.chevronRight} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const fileCount = this.props.workingDirectory.files.length
|
||||
const filesPlural = fileCount === 1 ? 'file' : 'files'
|
||||
const filesDescription = `${fileCount} changed ${filesPlural}`
|
||||
const includeAllValue = getIncludeAllValue(
|
||||
this.props.workingDirectory,
|
||||
this.props.rebaseConflictState
|
||||
)
|
||||
|
||||
const disableAllCheckbox =
|
||||
fileCount === 0 ||
|
||||
this.props.isCommitting ||
|
||||
this.props.rebaseConflictState !== null
|
||||
|
||||
return (
|
||||
<div className="changes-list-container file-list">
|
||||
<div className="header" onContextMenu={this.onContextMenu}>
|
||||
<Checkbox
|
||||
label={filesDescription}
|
||||
value={this.includeAllValue}
|
||||
value={includeAllValue}
|
||||
onChange={this.onIncludeAllChanged}
|
||||
disabled={fileCount === 0 || this.props.isCommitting}
|
||||
disabled={disableAllCheckbox}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<List
|
||||
id="changes-list"
|
||||
rowCount={this.props.workingDirectory.files.length}
|
||||
|
@ -492,6 +700,7 @@ export class ChangesList extends React.Component<
|
|||
onScroll={this.onScroll}
|
||||
setScrollTop={this.props.changesListScrollTop}
|
||||
/>
|
||||
{this.renderStashedChanges()}
|
||||
{this.renderCommitMessageForm()}
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -22,6 +22,9 @@ import { Octicon, OcticonSymbol } from '../octicons'
|
|||
import { IAuthor } from '../../models/author'
|
||||
import { IMenuItem } from '../../lib/menu-item'
|
||||
import { ICommitContext } from '../../models/commit'
|
||||
import { startTimer } from '../lib/timing'
|
||||
import { ProtectedBranchWarning } from './protected-branch-warning'
|
||||
import { enableBranchProtectionWarningFlow } from '../../lib/feature-flag'
|
||||
|
||||
const addAuthorIcon = new OcticonSymbol(
|
||||
12,
|
||||
|
@ -46,6 +49,7 @@ interface ICommitMessageProps {
|
|||
readonly isCommitting: boolean
|
||||
readonly placeholder: string
|
||||
readonly singleFileCommit: boolean
|
||||
readonly currentBranchProtected: boolean
|
||||
|
||||
/**
|
||||
* Whether or not to show a field for adding co-authors to
|
||||
|
@ -118,7 +122,10 @@ export class CommitMessage extends React.Component<
|
|||
public componentWillUnmount() {
|
||||
// We're unmounting, likely due to the user switching to the history tab.
|
||||
// Let's persist our commit message in the dispatcher.
|
||||
this.props.dispatcher.setCommitMessage(this.props.repository, this.state)
|
||||
this.props.dispatcher.setCommitMessage(this.props.repository, {
|
||||
summary: this.state.summary,
|
||||
description: this.state.description,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -214,7 +221,9 @@ export class CommitMessage extends React.Component<
|
|||
trailers,
|
||||
}
|
||||
|
||||
const timer = startTimer('create commit', this.props.repository)
|
||||
const commitCreated = await this.props.onCreateCommit(commitContext)
|
||||
timer.done()
|
||||
|
||||
if (commitCreated) {
|
||||
this.clearCommitMessage()
|
||||
|
@ -433,6 +442,22 @@ export class CommitMessage extends React.Component<
|
|||
return <div className={className}>{this.renderCoAuthorToggleButton()}</div>
|
||||
}
|
||||
|
||||
private renderProtectedBranchWarning = (branch: string) => {
|
||||
if (!enableBranchProtectionWarningFlow()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { currentBranchProtected, dispatcher } = this.props
|
||||
|
||||
if (!currentBranchProtected) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedBranchWarning currentBranch={branch} dispatcher={dispatcher} />
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const branchName = this.props.branch ? this.props.branch : 'master'
|
||||
|
||||
|
@ -495,6 +520,8 @@ export class CommitMessage extends React.Component<
|
|||
|
||||
{this.renderCoAuthorInput()}
|
||||
|
||||
{this.renderProtectedBranchWarning(branchName)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="commit-button"
|
||||
|
|
|
@ -13,16 +13,17 @@ interface IContinueRebaseProps {
|
|||
readonly workingDirectory: WorkingDirectoryStatus
|
||||
readonly rebaseConflictState: RebaseConflictState
|
||||
readonly isCommitting: boolean
|
||||
readonly hasUntrackedChanges: boolean
|
||||
}
|
||||
|
||||
export class ContinueRebase extends React.Component<IContinueRebaseProps, {}> {
|
||||
private onSubmit = async () => {
|
||||
const { manualResolutions } = this.props.rebaseConflictState
|
||||
const { rebaseConflictState } = this.props
|
||||
|
||||
await this.props.dispatcher.continueRebase(
|
||||
this.props.repository,
|
||||
this.props.workingDirectory,
|
||||
manualResolutions
|
||||
rebaseConflictState
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -46,8 +47,16 @@ export class ContinueRebase extends React.Component<IContinueRebaseProps, {}> {
|
|||
|
||||
const loading = this.props.isCommitting ? <Loading /> : undefined
|
||||
|
||||
const warnAboutUntrackedFiles = this.props.hasUntrackedChanges ? (
|
||||
<div className="warning-untracked-files">
|
||||
Untracked files will be excluded
|
||||
</div>
|
||||
) : (
|
||||
undefined
|
||||
)
|
||||
|
||||
return (
|
||||
<div id="continue-rebase" role="group">
|
||||
<div id="continue-rebase">
|
||||
<Button
|
||||
type="submit"
|
||||
className="commit-button"
|
||||
|
@ -58,6 +67,8 @@ export class ContinueRebase extends React.Component<IContinueRebaseProps, {}> {
|
|||
{loading}
|
||||
<span>{loading !== undefined ? 'Rebasing' : 'Continue rebase'}</span>
|
||||
</Button>
|
||||
|
||||
{warnAboutUntrackedFiles}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import { BlankslateAction } from './blankslate-action'
|
||||
import { MenuIDs } from '../../main-process/menu'
|
||||
import { MenuIDs } from '../../models/menu-ids'
|
||||
import { executeMenuItemById } from '../main-process-proxy'
|
||||
|
||||
interface IMenuBackedBlankSlateActionProps {
|
||||
|
@ -49,6 +49,16 @@ interface IMenuBackedBlankSlateActionProps {
|
|||
* clickable.
|
||||
*/
|
||||
readonly disabled?: boolean
|
||||
|
||||
/**
|
||||
* A callback which is invoked when the user clicks
|
||||
* or activates the action using their keyboard.
|
||||
*
|
||||
* In order to suppress the menu backed action from being invoked
|
||||
* consumers of this event will need to suppress the default behavior
|
||||
* by calling `e.preventDefault`.
|
||||
*/
|
||||
readonly onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -81,7 +91,13 @@ export class MenuBackedBlankslateAction extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private onClick = () => {
|
||||
executeMenuItemById(this.props.menuItemId)
|
||||
private onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (this.props.onClick !== undefined) {
|
||||
this.props.onClick(e)
|
||||
}
|
||||
|
||||
if (!e.defaultPrevented) {
|
||||
executeMenuItemById(this.props.menuItemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,8 +4,11 @@ import * as ReactCSSTransitionReplace from 'react-css-transition-replace'
|
|||
import { encodePathAsUrl } from '../../lib/path'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
import { enableNoChangesCreatePRBlankslateAction } from '../../lib/feature-flag'
|
||||
import { MenuIDs } from '../../main-process/menu'
|
||||
import {
|
||||
enableNoChangesCreatePRBlankslateAction,
|
||||
enableStashing,
|
||||
} from '../../lib/feature-flag'
|
||||
import { MenuIDs } from '../../models/menu-ids'
|
||||
import { IMenu, MenuItem } from '../../models/app-menu'
|
||||
import memoizeOne from 'memoize-one'
|
||||
import { getPlatformSpecificNameOrSymbolForModifier } from '../../lib/menu-item'
|
||||
|
@ -16,6 +19,9 @@ import { TipState, IValidBranch } from '../../models/tip'
|
|||
import { Ref } from '../lib/ref'
|
||||
import { IAheadBehind } from '../../models/branch'
|
||||
import { IRemote } from '../../models/remote'
|
||||
import { isCurrentBranchForcePush } from '../../lib/rebase'
|
||||
import { StashedChangesLoadStates } from '../../models/stash-entry'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
|
||||
function formatMenuItemLabel(text: string) {
|
||||
if (__WIN32__ || __LINUX__) {
|
||||
|
@ -33,6 +39,8 @@ function formatParentMenuLabel(menuItem: IMenuItemInfo) {
|
|||
const PaperStackImage = encodePathAsUrl(__dirname, 'static/paper-stack.svg')
|
||||
|
||||
interface INoChangesProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
|
||||
/**
|
||||
* The currently selected repository
|
||||
*/
|
||||
|
@ -209,7 +217,8 @@ export class NoChanges extends React.Component<
|
|||
private renderMenuBackedAction(
|
||||
itemId: MenuIDs,
|
||||
title: string,
|
||||
description?: string | JSX.Element
|
||||
description?: string | JSX.Element,
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
) {
|
||||
const menuItem = this.getMenuItemInfo(itemId)
|
||||
|
||||
|
@ -226,6 +235,7 @@ export class NoChanges extends React.Component<
|
|||
menuItemId={itemId}
|
||||
buttonText={formatMenuItemLabel(menuItem.label)}
|
||||
disabled={!menuItem.enabled}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -235,10 +245,15 @@ export class NoChanges extends React.Component<
|
|||
|
||||
return this.renderMenuBackedAction(
|
||||
'open-working-directory',
|
||||
`View the files of your repository in ${fileManager}`
|
||||
`View the files of your repository in ${fileManager}`,
|
||||
undefined,
|
||||
this.onShowInFileManagerClicked
|
||||
)
|
||||
}
|
||||
|
||||
private onShowInFileManagerClicked = () =>
|
||||
this.props.dispatcher.recordSuggestedStepOpenWorkingDirectory()
|
||||
|
||||
private renderViewOnGitHub() {
|
||||
const isGitHub = this.props.repository.gitHubRepository !== null
|
||||
|
||||
|
@ -248,10 +263,15 @@ export class NoChanges extends React.Component<
|
|||
|
||||
return this.renderMenuBackedAction(
|
||||
'view-repository-on-github',
|
||||
`Open the repository page on GitHub in your browser`
|
||||
`Open the repository page on GitHub in your browser`,
|
||||
undefined,
|
||||
this.onViewOnGitHubClicked
|
||||
)
|
||||
}
|
||||
|
||||
private onViewOnGitHubClicked = () =>
|
||||
this.props.dispatcher.recordSuggestedStepViewOnGitHub()
|
||||
|
||||
private openPreferences = () => {
|
||||
executeMenuItemById('preferences')
|
||||
}
|
||||
|
@ -287,9 +307,17 @@ export class NoChanges extends React.Component<
|
|||
</>
|
||||
)
|
||||
|
||||
return this.renderMenuBackedAction(itemId, title, description)
|
||||
return this.renderMenuBackedAction(
|
||||
itemId,
|
||||
title,
|
||||
description,
|
||||
this.onOpenInExternalEditorClicked
|
||||
)
|
||||
}
|
||||
|
||||
private onOpenInExternalEditorClicked = () =>
|
||||
this.props.dispatcher.recordSuggestedStepOpenInExternalEditor()
|
||||
|
||||
private renderRemoteAction() {
|
||||
const { remote, aheadBehind, branchesState } = this.props.repositoryState
|
||||
const { tip, defaultBranch, currentPullRequest } = branchesState
|
||||
|
@ -307,6 +335,14 @@ export class NoChanges extends React.Component<
|
|||
return this.renderPublishBranchAction(tip)
|
||||
}
|
||||
|
||||
const isForcePush = isCurrentBranchForcePush(branchesState, aheadBehind)
|
||||
if (isForcePush) {
|
||||
// do not render an action currently after the rebase has completed, as
|
||||
// the default behaviour is currently to pull in changes from the tracking
|
||||
// branch which will could potentially lead to a more confusing history
|
||||
return null
|
||||
}
|
||||
|
||||
if (aheadBehind.behind > 0) {
|
||||
return this.renderPullBranchAction(tip, remote, aheadBehind)
|
||||
}
|
||||
|
@ -329,6 +365,65 @@ export class NoChanges extends React.Component<
|
|||
return null
|
||||
}
|
||||
|
||||
private renderViewStashAction() {
|
||||
if (!enableStashing()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { changesState, branchesState } = this.props.repositoryState
|
||||
|
||||
const { tip } = branchesState
|
||||
if (tip.kind !== TipState.Valid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { stashEntry } = changesState
|
||||
if (stashEntry === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (stashEntry.files.kind !== StashedChangesLoadStates.Loaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const numChanges = stashEntry.files.files.length
|
||||
const description = (
|
||||
<>
|
||||
You have {numChanges} {numChanges === 1 ? 'change' : 'changes'} in
|
||||
progress that you have not yet committed.
|
||||
</>
|
||||
)
|
||||
const discoverabilityContent = (
|
||||
<>
|
||||
When a stash exists, access it at the bottom of the Changes tab to the
|
||||
left.
|
||||
</>
|
||||
)
|
||||
const itemId: MenuIDs = 'toggle-stashed-changes'
|
||||
const menuItem = this.getMenuItemInfo(itemId)
|
||||
if (menuItem === undefined) {
|
||||
log.error(`Could not find matching menu item for ${itemId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuBackedBlankslateAction
|
||||
key="view-stash-action"
|
||||
title="View your stashed changes"
|
||||
menuItemId={itemId}
|
||||
description={description}
|
||||
discoverabilityContent={discoverabilityContent}
|
||||
buttonText="View stash"
|
||||
type="primary"
|
||||
disabled={menuItem !== null && !menuItem.enabled}
|
||||
onClick={this.onViewStashClicked}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private onViewStashClicked = () =>
|
||||
this.props.dispatcher.recordSuggestedStepViewStash()
|
||||
|
||||
private renderPublishRepositoryAction() {
|
||||
// This is a bit confusing, there's no dedicated
|
||||
// publish menu item, the 'Push' menu item will initiate
|
||||
|
@ -359,10 +454,14 @@ export class NoChanges extends React.Component<
|
|||
menuItemId={itemId}
|
||||
type="primary"
|
||||
disabled={!menuItem.enabled}
|
||||
onClick={this.onPublishRepositoryClicked}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private onPublishRepositoryClicked = () =>
|
||||
this.props.dispatcher.recordSuggestedStepPublishRepository()
|
||||
|
||||
private renderPublishBranchAction(tip: IValidBranch) {
|
||||
// This is a bit confusing, there's no dedicated
|
||||
// publish branch menu item, the 'Push' menu item will initiate
|
||||
|
@ -404,10 +503,14 @@ export class NoChanges extends React.Component<
|
|||
buttonText="Publish branch"
|
||||
type="primary"
|
||||
disabled={!menuItem.enabled}
|
||||
onClick={this.onPublishBranchClicked}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private onPublishBranchClicked = () =>
|
||||
this.props.dispatcher.recordSuggestedStepPublishBranch()
|
||||
|
||||
private renderPullBranchAction(
|
||||
tip: IValidBranch,
|
||||
remote: IRemote,
|
||||
|
@ -479,7 +582,7 @@ export class NoChanges extends React.Component<
|
|||
<>
|
||||
You have{' '}
|
||||
{aheadBehind.ahead === 1 ? 'one local commit' : 'local commits'} waiting
|
||||
to be pushed to {isGitHub ? 'GitHub' : 'the remote'}
|
||||
to be pushed to {isGitHub ? 'GitHub' : 'the remote'}.
|
||||
</>
|
||||
)
|
||||
|
||||
|
@ -540,10 +643,14 @@ export class NoChanges extends React.Component<
|
|||
discoverabilityContent={this.renderDiscoverabilityElements(menuItem)}
|
||||
type="primary"
|
||||
disabled={!menuItem.enabled}
|
||||
onClick={this.onCreatePullRequestClicked}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private onCreatePullRequestClicked = () =>
|
||||
this.props.dispatcher.recordSuggestedStepCreatePullRequest()
|
||||
|
||||
private renderActions() {
|
||||
return (
|
||||
<>
|
||||
|
@ -558,7 +665,7 @@ export class NoChanges extends React.Component<
|
|||
transitionEnterTimeout={750}
|
||||
transitionLeaveTimeout={500}
|
||||
>
|
||||
{this.renderRemoteAction()}
|
||||
{this.renderViewStashAction() || this.renderRemoteAction()}
|
||||
</ReactCSSTransitionReplace>
|
||||
<div className="actions">
|
||||
{this.renderOpenInExternalEditor()}
|
||||
|
@ -590,8 +697,8 @@ export class NoChanges extends React.Component<
|
|||
<div className="text">
|
||||
<h1>No local changes</h1>
|
||||
<p>
|
||||
You have no uncommitted changes in your repository! Here are
|
||||
some friendly suggestions for what to do next.
|
||||
There are no uncommitted changes for this repository. Here are
|
||||
some actions you may find useful:
|
||||
</p>
|
||||
</div>
|
||||
<img src={PaperStackImage} className="blankslate-image" />
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue