Merge branch 'development' into releases/3.1.3

This commit is contained in:
Sergio Padrino 2023-01-05 15:50:43 +01:00
commit ce4fbb259e
98 changed files with 4046 additions and 969 deletions

View file

@ -37,7 +37,7 @@ jobs:
private_key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }}
- name: Create Release Pull Request
uses: peter-evans/create-pull-request@v4.1.1
uses: peter-evans/create-pull-request@v4.2.3
if: |
startsWith(github.ref, 'refs/heads/releases/') && !contains(github.ref, 'test')
with:

View file

@ -4,7 +4,17 @@
GitHub app. It is written in [TypeScript](https://www.typescriptlang.org) and
uses [React](https://reactjs.org/).
![GitHub Desktop screenshot - Windows](https://cloud.githubusercontent.com/assets/359239/26094502/a1f56d02-3a5d-11e7-8799-23c7ba5e5106.png)
<picture>
<source
srcset="https://user-images.githubusercontent.com/634063/202742848-63fa1488-6254-49b5-af7c-96a6b50ea8af.png"
media="(prefers-color-scheme: dark)"
/>
<img
width="1072"
src="https://user-images.githubusercontent.com/634063/202742985-bb3b3b94-8aca-404a-8d8a-fd6a6f030672.png"
alt="A screenshot of the GitHub Desktop application showing changes being viewed and committed with two attributed co-authors"
/>
</picture>
## Where can I get it?
@ -41,8 +51,7 @@ The release notes for the latest beta versions are available [here](https://desk
There are several community-supported package managers that can be used to
install GitHub Desktop:
- Windows users can install using [Chocolatey](https://chocolatey.org/) package manager:
`c:\> choco install github-desktop`
- Windows users can install using [winget](https://docs.microsoft.com/en-us/windows/package-manager/winget/) `c:/> winget install github-desktop` or [Chocolatey](https://chocolatey.org/) `c:\> choco install github-desktop`
- macOS users can install using [Homebrew](https://brew.sh/) package manager:
`$ brew install --cask github`
@ -85,6 +94,10 @@ resources relevant to the project.
If you're looking for something to work on, check out the [help wanted](https://github.com/desktop/desktop/issues?q=is%3Aissue+is%3Aopen+label%3A%22help%20wanted%22) label.
## Building Desktop
To get your development environment set up for building Desktop, see [setup.md](./docs/contributing/setup.md).
## More Resources
See [desktop.github.com](https://desktop.github.com) for more product-oriented

11
SECURITY.md Normal file
View file

@ -0,0 +1,11 @@
GitHub takes the security of our software products and services seriously, including the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub).
If you believe you have found a security vulnerability in this GitHub-owned open source repository, you can report it to us in one of two ways.
If the vulnerability you have found is *not* [in scope for the GitHub Bug Bounty Program](https://bounty.github.com/#scope) or if you do not wish to be considered for a bounty reward, please report the issue to us directly using [private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability).
If the vulnerability you have found is [in scope for the GitHub Bug Bounty Program](https://bounty.github.com/#scope) and you would like for your finding to be considered for a bounty reward, please submit the vulnerability to us through [HackerOne](https://hackerone.com/github) in order to be eligible to receive a bounty award.
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Thanks for helping make GitHub safe for everyone.

View file

@ -148,6 +148,7 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
'.h': 'text/x-c',
'.cpp': 'text/x-c++src',
'.hpp': 'text/x-c++src',
'.ino': 'text/x-c++src',
'.kt': 'text/x-kotlin',
},
},

View file

@ -1053,11 +1053,14 @@ export class API {
public async fetchCombinedRefStatus(
owner: string,
name: string,
ref: string
ref: string,
reloadCache: boolean = false
): Promise<IAPIRefStatus | null> {
const safeRef = encodeURIComponent(ref)
const path = `repos/${owner}/${name}/commits/${safeRef}/status?per_page=100`
const response = await this.request('GET', path)
const response = await this.request('GET', path, {
reloadCache,
})
try {
return await parsedResponse<IAPIRefStatus>(response)
@ -1076,7 +1079,8 @@ export class API {
public async fetchRefCheckRuns(
owner: string,
name: string,
ref: string
ref: string,
reloadCache: boolean = false
): Promise<IAPIRefCheckRuns | null> {
const safeRef = encodeURIComponent(ref)
const path = `repos/${owner}/${name}/commits/${safeRef}/check-runs?per_page=100`
@ -1084,7 +1088,10 @@ export class API {
Accept: 'application/vnd.github.antiope-preview+json',
}
const response = await this.request('GET', path, { customHeaders: headers })
const response = await this.request('GET', path, {
customHeaders: headers,
reloadCache,
})
try {
return await parsedResponse<IAPIRefCheckRuns>(response)

View file

@ -11,7 +11,10 @@ import { IMenu } from '../models/app-menu'
import { IRemote } from '../models/remote'
import { CloneRepositoryTab } from '../models/clone-repository-tab'
import { BranchesTab } from '../models/branches-tab'
import { PullRequest } from '../models/pull-request'
import {
PullRequest,
PullRequestSuggestedNextAction,
} from '../models/pull-request'
import { IAuthor } from '../models/author'
import { MergeTreeResult } from '../models/merge'
import { ICommitMessage } from '../models/commit-message'
@ -22,7 +25,6 @@ import {
ICloneProgress,
IMultiCommitOperationProgress,
} from '../models/progress'
import { Popup } from '../models/popup'
import { SignInState } from './stores/sign-in-store'
@ -47,6 +49,7 @@ import {
MultiCommitOperationStep,
} from '../models/multi-commit-operation'
import { IChangesetData } from './git'
import { Popup } from '../models/popup'
export enum SelectionType {
Repository,
@ -116,6 +119,7 @@ export interface IAppState {
readonly showWelcomeFlow: boolean
readonly focusCommitMessage: boolean
readonly currentPopup: Popup | null
readonly allPopups: ReadonlyArray<Popup>
readonly currentFoldout: Foldout | null
readonly currentBanner: Banner | null
@ -145,7 +149,7 @@ export interface IAppState {
*/
readonly appMenuState: ReadonlyArray<IMenu>
readonly errors: ReadonlyArray<Error>
readonly errorCount: number
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
readonly emoji: Map<string, string>
@ -170,6 +174,9 @@ export interface IAppState {
/** The width of the files list in the stash view */
readonly stashedFilesWidth: IConstrainedValue
/** The width of the files list in the pull request files changed view */
readonly pullRequestFilesListWidth: IConstrainedValue
/**
* Used to highlight access keys throughout the app when the
* Alt key is pressed. Only applicable on non-macOS platforms.
@ -194,6 +201,9 @@ export interface IAppState {
/** Whether we should show a confirmation dialog */
readonly askForConfirmationOnDiscardChangesPermanently: boolean
/** Should the app prompt the user to confirm a discard stash */
readonly askForConfirmationOnDiscardStash: boolean
/** Should the app prompt the user to confirm a force push? */
readonly askForConfirmationOnForcePush: boolean
@ -230,6 +240,9 @@ export interface IAppState {
/** Whether we should hide white space changes in history diff */
readonly hideWhitespaceInHistoryDiff: boolean
/** Whether we should hide white space changes in the pull request diff */
readonly hideWhitespaceInPullRequestDiff: boolean
/** Whether we should show side by side diffs */
readonly showSideBySideDiff: boolean
@ -302,6 +315,11 @@ export interface IAppState {
* Whether or not the user enabled high-signal notifications.
*/
readonly notificationsEnabled: boolean
/** The users last chosen pull request suggested next action. */
readonly pullRequestSuggestedNextAction:
| PullRequestSuggestedNextAction
| undefined
}
export enum FoldoutType {
@ -950,7 +968,7 @@ export interface IPullRequestState {
* The base branch of a a pull request - the branch the currently checked out
* branch would merge into
*/
readonly baseBranch: Branch
readonly baseBranch: Branch | null
/** The SHAs of commits of the pull request */
readonly commitSHAs: ReadonlyArray<string> | null
@ -964,5 +982,8 @@ export interface IPullRequestState {
* repositories commit selection where the diff of all commits represents the
* diff between the latest commit and the earliest commits parent.
*/
readonly commitSelection: ICommitSelection
readonly commitSelection: ICommitSelection | null
/** The result of merging the pull request branch into the base branch */
readonly mergeStatus: MergeTreeResult | null
}

24
app/src/lib/commit-url.ts Normal file
View file

@ -0,0 +1,24 @@
import * as crypto from 'crypto'
import { GitHubRepository } from '../models/github-repository'
/** Method to create the url for viewing a commit on dotcom */
export function createCommitURL(
gitHubRepository: GitHubRepository,
SHA: string,
filePath?: string
): string | null {
const baseURL = gitHubRepository.htmlURL
if (baseURL === null) {
return null
}
if (filePath === undefined) {
return `${baseURL}/commit/${SHA}`
}
const fileHash = crypto.createHash('sha256').update(filePath).digest('hex')
const fileSuffix = '#diff-' + fileHash
return `${baseURL}/commit/${SHA}${fileSuffix}`
}

View file

@ -45,7 +45,7 @@ const editors: IDarwinExternalEditor[] = [
},
{
name: 'VSCodium',
bundleIdentifiers: ['com.visualstudio.code.oss'],
bundleIdentifiers: ['com.visualstudio.code.oss', 'com.vscodium'],
},
{
name: 'Sublime Text',

View file

@ -62,6 +62,18 @@ const editors: ILinuxExternalEditor[] = [
name: 'Lite XL',
paths: ['/usr/bin/lite-xl'],
},
{
name: 'Jetbrains PhpStorm',
paths: ['/snap/bin/phpstorm'],
},
{
name: 'Jetbrains WebStorm',
paths: ['/snap/bin/webstorm'],
},
{
name: 'Emacs',
paths: ['/snap/bin/emacs', '/usr/local/bin/emacs', '/usr/bin/emacs'],
},
]
async function getAvailablePath(paths: string[]): Promise<string | null> {

View file

@ -46,7 +46,7 @@ export function enableWSLDetection(): boolean {
* Should we use the new diff viewer for unified diffs?
*/
export function enableExperimentalDiffViewer(): boolean {
return false
return enableBetaFeatures()
}
/**
@ -110,5 +110,15 @@ export function enableSubmoduleDiff(): boolean {
/** Should we enable starting pull requests? */
export function enableStartingPullRequests(): boolean {
return enableDevelopmentFeatures()
return enableBetaFeatures()
}
/** Should we enable starting pull requests? */
export function enableStackedPopups(): boolean {
return enableBetaFeatures()
}
/** Should we enable mechanism to prevent closing while the app is updating? */
export function enablePreventClosingWhileUpdating(): boolean {
return enableBetaFeatures()
}

View file

@ -254,7 +254,7 @@ export async function getBranchMergeBaseChangedFiles(
baseBranchName: string,
comparisonBranchName: string,
latestComparisonBranchCommitRef: string
): Promise<IChangesetData> {
): Promise<IChangesetData | null> {
const baseArgs = [
'diff',
'--merge-base',
@ -268,22 +268,26 @@ export async function getBranchMergeBaseChangedFiles(
'--',
]
const result = await git(
baseArgs,
repository.path,
'getBranchMergeBaseChangedFiles'
)
const mergeBaseCommit = await getMergeBase(
repository,
baseBranchName,
comparisonBranchName
)
if (mergeBaseCommit === null) {
return null
}
const result = await git(
baseArgs,
repository.path,
'getBranchMergeBaseChangedFiles'
)
return parseRawLogWithNumstat(
result.combinedOutput,
`${latestComparisonBranchCommitRef}`,
mergeBaseCommit ?? NullTreeSHA
mergeBaseCommit
)
}

View file

@ -162,6 +162,8 @@ interface Window {
interface HTMLDialogElement {
showModal: () => void
close: (returnValue?: string | undefined) => void
open: boolean
}
/**
* Obtain the number of elements of a tuple type

View file

@ -46,6 +46,8 @@ export type RequestChannels = {
'menu-event': (name: MenuEvent) => void
log: (level: LogLevel, message: string) => void
'will-quit': () => void
'will-quit-even-if-updating': () => void
'cancel-quitting': () => void
'crash-ready': () => void
'crash-quit': () => void
'window-state-changed': (windowState: WindowState) => void
@ -63,6 +65,7 @@ export type RequestChannels = {
blur: () => void
'update-accounts': (accounts: ReadonlyArray<EndpointToken>) => void
'quit-and-install-updates': () => void
'quit-app': () => void
'minimize-window': () => void
'maximize-window': () => void
'unmaximize-window': () => void
@ -77,6 +80,7 @@ export type RequestChannels = {
'focus-window': () => void
'notification-event': NotificationCallback<DesktopAliveEvent>
'set-window-zoom-factor': (zoomFactor: number) => void
'show-installing-update': () => void
}
/**

View file

@ -11,6 +11,7 @@ import { updateMenuState as ipcUpdateMenuState } from '../ui/main-process-proxy'
import { AppMenu, MenuItem } from '../models/app-menu'
import { hasConflictedFiles } from './status'
import { findContributionTargetDefaultBranch } from './branch'
import { enableStartingPullRequests } from './feature-flag'
export interface IMenuItemState {
readonly enabled?: boolean
@ -135,6 +136,7 @@ const allMenuIds: ReadonlyArray<MenuIDs> = [
'clone-repository',
'about',
'create-pull-request',
...(enableStartingPullRequests() ? ['preview-pull-request' as MenuIDs] : []),
'squash-and-merge-branch',
]
@ -291,6 +293,13 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
'create-pull-request',
isHostedOnGitHub && !branchIsUnborn && !onDetachedHead
)
if (enableStartingPullRequests()) {
menuStateBuilder.setEnabled(
'preview-pull-request',
!branchIsUnborn && !onDetachedHead && isHostedOnGitHub
)
}
menuStateBuilder.setEnabled(
'push',
!branchIsUnborn && !onDetachedHead && !networkActionInProgress
@ -330,7 +339,9 @@ function getRepositoryMenuBuilder(state: IAppState): MenuStateBuilder {
menuStateBuilder.disable('view-repository-on-github')
menuStateBuilder.disable('create-pull-request')
if (enableStartingPullRequests()) {
menuStateBuilder.disable('preview-pull-request')
}
if (
selectedState &&
selectedState.type === SelectionType.MissingRepository

View file

@ -4,7 +4,6 @@ import {
conflictSteps,
MultiCommitOperationStepKind,
} from '../models/multi-commit-operation'
import { Popup, PopupType } from '../models/popup'
import { TipState } from '../models/tip'
import { IMultiCommitOperationState, IRepositoryState } from './app-state'
@ -39,12 +38,11 @@ export function getMultiCommitOperationChooseBranchStep(
}
export function isConflictsFlow(
currentPopup: Popup | null,
isMultiCommitOperationPopupOpen: boolean,
multiCommitOperationState: IMultiCommitOperationState | null
): boolean {
return (
currentPopup !== null &&
currentPopup.type === PopupType.MultiCommitOperation &&
isMultiCommitOperationPopupOpen &&
multiCommitOperationState !== null &&
conflictSteps.includes(multiCommitOperationState.step.kind)
)

View file

@ -0,0 +1,204 @@
import { Popup, PopupType } from '../models/popup'
import { enableStackedPopups } from './feature-flag'
import { sendNonFatalException } from './helpers/non-fatal-exception'
import { uuid } from './uuid'
/**
* The limit of how many popups allowed in the stack. Working under the
* assumption that a user should only be dealing with a couple of popups at a
* time, if a user hits the limit this would indicate a problem.
*/
const defaultPopupStackLimit = 50
/**
* The popup manager is to manage the stack of currently open popups.
*
* Popup Flow Notes:
* 1. We have many types of popups. We only support opening one popup type at a
* time with the exception of PopupType.Error. If the app is to produce
* multiple errors, we want the user to be able to be informed of all them.
* 2. Error popups are viewed first ahead of any other popup types. Otherwise,
* popups ordered by last on last off.
* 3. There are custom error handling popups that are not categorized as errors:
* - When a error is captured in the app, we use the dispatcher method
* 'postError` to run through all the error handlers defined in
* `errorHandler.ts`.
* - If a custom error handler picks the error up, it handles it in a custom
* way. Commonly, it users the dispatcher to open a popup specific to the
* error - likely to allow interaction with the user. This is not an error
* popup.
* - Otherwise, the error is captured by the `defaultErrorHandler` defined
* in `errorHandler.ts` which simply dispatches to `presentError`. This
* method requests ends up in the app-store to add a popup of type `Error`
* to the stack. Then, it is rendered as a popup with the AppError
* component.
* - The AppError component additionally does some custom error handling for
* cloning errors and for author errors. But, most errors are just
* displayed as error text with a ok button.
*/
export class PopupManager {
private popupStack: ReadonlyArray<Popup> = []
public constructor(private readonly popupLimit = defaultPopupStackLimit) {}
/**
* Returns the last popup in the stack.
*
* The stack is sorted such that:
* If there are error popups, it returns the last popup of type error,
* otherwise returns the first non-error type popup.
*/
public get currentPopup(): Popup | null {
return this.popupStack.at(-1) ?? null
}
/**
* Returns all the popups in the stack.
*
* The stack is sorted such that:
* If there are error popups, they will be the last on the stack.
*/
public get allPopups(): ReadonlyArray<Popup> {
return this.popupStack
}
/**
* Returns whether there are any popups in the stack.
*/
public get isAPopupOpen(): boolean {
return this.currentPopup !== null
}
/**
* Returns an array of all popups in the stack of the provided type.
**/
public getPopupsOfType(popupType: PopupType): ReadonlyArray<Popup> {
return this.popupStack.filter(p => p.type === popupType)
}
/**
* Returns whether there are any popups of a given type in the stack.
*/
public areTherePopupsOfType(popupType: PopupType): boolean {
return this.popupStack.some(p => p.type === popupType)
}
/**
* Adds a popup to the stack.
* - The popup will be given a unique id and returned.
* - It will not add multiple popups of the same type onto the stack
* - NB: Error types are the only duplicates allowed
**/
public addPopup(popupToAdd: Popup): Popup {
if (popupToAdd.type === PopupType.Error) {
return this.addErrorPopup(popupToAdd.error)
}
const existingPopup = this.getPopupsOfType(popupToAdd.type)
const popup = { id: uuid(), ...popupToAdd }
if (!enableStackedPopups()) {
this.popupStack = [popup, ...this.getPopupsOfType(PopupType.Error)]
return popup
}
if (existingPopup.length > 0) {
log.warn(
`Attempted to add a popup of already existing type - ${popupToAdd.type}.`
)
return popupToAdd
}
this.insertBeforeErrorPopups(popup)
this.checkStackLength()
return popup
}
/** Adds a non-Error type popup before any error popups. */
private insertBeforeErrorPopups(popup: Popup) {
if (this.popupStack.at(-1)?.type !== PopupType.Error) {
this.popupStack = this.popupStack.concat(popup)
return
}
const errorPopups = this.getPopupsOfType(PopupType.Error)
const nonErrorPopups = this.popupStack.filter(
p => p.type !== PopupType.Error
)
this.popupStack = [...nonErrorPopups, popup, ...errorPopups]
}
/*
* Adds an Error Popup to the stack
* - The popup will be given a unique id.
* - Multiple popups of a type error.
**/
public addErrorPopup(error: Error): Popup {
const popup: Popup = { id: uuid(), type: PopupType.Error, error }
this.popupStack = this.popupStack.concat(popup)
this.checkStackLength()
return popup
}
private checkStackLength() {
if (this.popupStack.length > this.popupLimit) {
// Remove the oldest
const oldest = this.popupStack[0]
sendNonFatalException(
'TooManyPopups',
new Error(
`Max number of ${this.popupLimit} popups reached while adding popup of type ${this.currentPopup?.type}. Removing last popup from the stack -> type ${oldest.type} `
)
)
this.popupStack = this.popupStack.slice(1)
}
}
/**
* Updates a popup in the stack and returns it.
* - It uses the popup id to find and update the popup.
*/
public updatePopup(popupToUpdate: Popup) {
if (popupToUpdate.id === undefined) {
log.warn(`Attempted to update a popup without an id.`)
return
}
const index = this.popupStack.findIndex(p => p.id === popupToUpdate.id)
if (index < 0) {
log.warn(`Attempted to update a popup not in the stack.`)
return
}
this.popupStack = [
...this.popupStack.slice(0, index),
popupToUpdate,
...this.popupStack.slice(index + 1),
]
}
/**
* Removes a popup based on it's id.
*/
public removePopup(popup: Popup) {
if (popup.id === undefined) {
log.warn(`Attempted to remove a popup without an id.`)
return
}
this.popupStack = this.popupStack.filter(p => p.id !== popup.id)
}
/**
* Removes any popup of the given type from the stack
*/
public removePopupByType(popupType: PopupType) {
this.popupStack = this.popupStack.filter(p => p.type !== popupType)
}
/**
* Removes popup from the stack by it's id
*/
public removePopupById(popupId: string) {
this.popupStack = this.popupStack.filter(p => p.id !== popupId)
}
}

View file

@ -3,6 +3,26 @@ import { IAheadBehind } from '../models/branch'
import { TipState } from '../models/tip'
import { clamp } from './clamp'
/** Represents the force-push availability state of a branch. */
export enum ForcePushBranchState {
/** The branch cannot be force-pushed (it hasn't diverged from its upstream) */
NotAvailable,
/**
* The branch can be force-pushed, but the user didn't do any operation that
* we consider should be followed by a force-push, like rebasing or amending a
* pushed commit.
*/
Available,
/**
* The branch can be force-pushed, and the user did some operation that we
* consider should be followed by a force-push, like rebasing or amending a
* pushed commit.
*/
Recommended,
}
/**
* Format rebase percentage to ensure it's a value between 0 and 1, but to also
* constrain it to two significant figures, avoiding the remainder that comes
@ -16,17 +36,23 @@ export function formatRebaseValue(value: number) {
* Check application state to see whether the action applied to the current
* branch should be a force push
*/
export function isCurrentBranchForcePush(
export function getCurrentBranchForcePushState(
branchesState: IBranchesState,
aheadBehind: IAheadBehind | null
) {
): ForcePushBranchState {
if (aheadBehind === null) {
// no tracking branch found
return false
return ForcePushBranchState.NotAvailable
}
const { ahead, behind } = aheadBehind
if (behind === 0 || ahead === 0) {
// no a diverged branch to force push
return ForcePushBranchState.NotAvailable
}
const { tip, forcePushBranches } = branchesState
const { ahead, behind } = aheadBehind
let canForcePushBranch = false
if (tip.kind === TipState.Valid) {
@ -36,5 +62,7 @@ export function isCurrentBranchForcePush(
canForcePushBranch = foundEntry === sha
}
return canForcePushBranch && behind > 0 && ahead > 0
return canForcePushBranch
? ForcePushBranchState.Recommended
: ForcePushBranchState.Available
}

View file

@ -146,9 +146,16 @@ export interface IDailyMeasures {
/** The number of times the user committed a conflicted merge outside the merge conflicts dialog */
readonly unguidedConflictedMergeCompletionCount: number
/** The number of times the user is taken to the create pull request page on dotcom */
/** The number of times the user is taken to the create pull request page on dotcom including.
*
* NB - This metric tracks all times including when
* `createPullRequestFromPreviewCount` this is tracked.
* */
readonly createPullRequestCount: number
/** The number of times the user is taken to the create pull request page on dotcom from the preview dialog */
readonly createPullRequestFromPreviewCount: number
/** The number of times the rebase conflicts dialog is dismissed */
readonly rebaseConflictsDialogDismissalCount: number
@ -467,6 +474,18 @@ export interface IDailyMeasures {
/** The number of "checks failed" notifications the user received */
readonly checksFailedNotificationCount: number
/**
* The number of "checks failed" notifications the user received for a recent
* repository other than the selected one.
*/
readonly checksFailedNotificationFromRecentRepoCount: number
/**
* The number of "checks failed" notifications the user received for a
* non-recent repository other than the selected one.
*/
readonly checksFailedNotificationFromNonRecentRepoCount: number
/** The number of "checks failed" notifications the user clicked */
readonly checksFailedNotificationClicked: number
@ -485,6 +504,18 @@ export interface IDailyMeasures {
*/
readonly checksFailedDialogRerunChecksCount: number
/**
* The number of PR review notifications the user received for a recent
* repository other than the selected one.
*/
readonly pullRequestReviewNotificationFromRecentRepoCount: number
/**
* The number of PR review notifications the user received for a non-recent
* repository other than the selected one.
*/
readonly pullRequestReviewNotificationFromNonRecentRepoCount: number
/** The number of "approved PR" notifications the user received */
readonly pullRequestReviewApprovedNotificationCount: number
@ -541,6 +572,9 @@ export interface IDailyMeasures {
/** The number of times the user opens a submodule repository from its diff */
readonly openSubmoduleFromDiffCount: number
/** The number of times a user has opened the preview pull request dialog */
readonly previewedPullRequestCount: number
}
export class StatsDatabase extends Dexie {

View file

@ -112,6 +112,7 @@ const DefaultDailyMeasures: IDailyMeasures = {
guidedConflictedMergeCompletionCount: 0,
unguidedConflictedMergeCompletionCount: 0,
createPullRequestCount: 0,
createPullRequestFromPreviewCount: 0,
rebaseConflictsDialogDismissalCount: 0,
rebaseConflictsDialogReopenedCount: 0,
rebaseAbortedAfterConflictsCount: 0,
@ -195,10 +196,14 @@ const DefaultDailyMeasures: IDailyMeasures = {
viewsCheckJobStepOnline: 0,
rerunsChecks: 0,
checksFailedNotificationCount: 0,
checksFailedNotificationFromRecentRepoCount: 0,
checksFailedNotificationFromNonRecentRepoCount: 0,
checksFailedNotificationClicked: 0,
checksFailedDialogOpenCount: 0,
checksFailedDialogSwitchToPullRequestCount: 0,
checksFailedDialogRerunChecksCount: 0,
pullRequestReviewNotificationFromRecentRepoCount: 0,
pullRequestReviewNotificationFromNonRecentRepoCount: 0,
pullRequestReviewApprovedNotificationCount: 0,
pullRequestReviewApprovedNotificationClicked: 0,
pullRequestReviewApprovedDialogSwitchToPullRequestCount: 0,
@ -215,6 +220,7 @@ const DefaultDailyMeasures: IDailyMeasures = {
submoduleDiffViewedFromChangesListCount: 0,
submoduleDiffViewedFromHistoryCount: 0,
openSubmoduleFromDiffCount: 0,
previewedPullRequestCount: 0,
}
interface IOnboardingStats {
@ -1071,6 +1077,16 @@ export class StatsStore implements IStatsStore {
}))
}
/**
* Increments the `createPullRequestFromPreviewCount` metric
*/
public recordCreatePullRequestFromPreview(): Promise<void> {
return this.updateDailyMeasures(m => ({
createPullRequestFromPreviewCount:
m.createPullRequestFromPreviewCount + 1,
}))
}
/**
* Increments the `rebaseConflictsDialogDismissalCount` metric
*/
@ -1776,6 +1792,20 @@ export class StatsStore implements IStatsStore {
}))
}
public recordChecksFailedNotificationFromRecentRepo(): Promise<void> {
return this.updateDailyMeasures(m => ({
checksFailedNotificationFromRecentRepoCount:
m.checksFailedNotificationFromRecentRepoCount + 1,
}))
}
public recordChecksFailedNotificationFromNonRecentRepo(): Promise<void> {
return this.updateDailyMeasures(m => ({
checksFailedNotificationFromNonRecentRepoCount:
m.checksFailedNotificationFromNonRecentRepoCount + 1,
}))
}
public recordChecksFailedNotificationClicked(): Promise<void> {
return this.updateDailyMeasures(m => ({
checksFailedNotificationClicked: m.checksFailedNotificationClicked + 1,
@ -1845,6 +1875,20 @@ export class StatsStore implements IStatsStore {
return `pullRequestReview${infixMap[reviewType]}${suffix}`
}
public recordPullRequestReviewNotiificationFromRecentRepo(): Promise<void> {
return this.updateDailyMeasures(m => ({
pullRequestReviewNotificationFromRecentRepoCount:
m.pullRequestReviewNotificationFromRecentRepoCount + 1,
}))
}
public recordPullRequestReviewNotiificationFromNonRecentRepo(): Promise<void> {
return this.updateDailyMeasures(m => ({
pullRequestReviewNotificationFromNonRecentRepoCount:
m.pullRequestReviewNotificationFromNonRecentRepoCount + 1,
}))
}
// Generic method to record stats related to Pull Request review notifications.
private recordPullRequestReviewStat(
reviewType: ValidNotificationPullRequestReviewState,
@ -1949,6 +1993,15 @@ export class StatsStore implements IStatsStore {
log.error(`Error reporting opt ${direction}:`, e)
}
}
/**
* Increments the `previewedPullRequestCount` metric
*/
public recordPreviewedPullRequest(): Promise<void> {
return this.updateDailyMeasures(m => ({
previewedPullRequestCount: m.previewedPullRequestCount + 1,
}))
}
}
/**

View file

@ -8,6 +8,7 @@ import {
PullRequestCoordinator,
RepositoriesStore,
SignInStore,
UpstreamRemoteName,
} from '.'
import { Account } from '../../models/account'
import { AppMenu, IMenu } from '../../models/app-menu'
@ -29,7 +30,11 @@ import {
GitHubRepository,
hasWritePermission,
} from '../../models/github-repository'
import { PullRequest } from '../../models/pull-request'
import {
defaultPullRequestSuggestedNextAction,
PullRequest,
PullRequestSuggestedNextAction,
} from '../../models/pull-request'
import {
forkPullRequestRemoteName,
IRemote,
@ -42,6 +47,7 @@ import {
isRepositoryWithGitHubRepository,
RepositoryWithGitHubRepository,
getNonForkGitHubRepository,
isForkedRepositoryContributingToParent,
} from '../../models/repository'
import {
CommittedFileChange,
@ -77,6 +83,10 @@ import {
updatePreferredAppMenuItemLabels,
updateAccounts,
setWindowZoomFactor,
onShowInstallingUpdate,
sendWillQuitEvenIfUpdatingSync,
quitApp,
sendCancelQuittingSync,
} from '../../ui/main-process-proxy'
import {
API,
@ -180,7 +190,7 @@ import {
matchExistingRepository,
urlMatchesRemote,
} from '../repository-matching'
import { isCurrentBranchForcePush } from '../rebase'
import { ForcePushBranchState, getCurrentBranchForcePushState } from '../rebase'
import { RetryAction, RetryActionType } from '../../models/retry-actions'
import {
Default as DefaultShell,
@ -304,6 +314,7 @@ import { offsetFromNow } from '../offset-from'
import { findContributionTargetDefaultBranch } from '../branch'
import { ValidNotificationPullRequestReview } from '../valid-notification-pull-request-review'
import { determineMergeability } from '../git/merge-tree'
import { PopupManager } from '../popup-manager'
const LastSelectedRepositoryIDKey = 'last-selected-repository-id'
@ -323,15 +334,20 @@ const commitSummaryWidthConfigKey: string = 'commit-summary-width'
const defaultStashedFilesWidth: number = 250
const stashedFilesWidthConfigKey: string = 'stashed-files-width'
const defaultPullRequestFileListWidth: number = 250
const pullRequestFileListConfigKey: string = 'pull-request-files-width'
const askToMoveToApplicationsFolderDefault: boolean = true
const confirmRepoRemovalDefault: boolean = true
const confirmDiscardChangesDefault: boolean = true
const confirmDiscardChangesPermanentlyDefault: boolean = true
const confirmDiscardStashDefault: boolean = true
const askForConfirmationOnForcePushDefault = true
const confirmUndoCommitDefault: boolean = true
const askToMoveToApplicationsFolderKey: string = 'askToMoveToApplicationsFolder'
const confirmRepoRemovalKey: string = 'confirmRepoRemoval'
const confirmDiscardChangesKey: string = 'confirmDiscardChanges'
const confirmDiscardStashKey: string = 'confirmDiscardStash'
const confirmDiscardChangesPermanentlyKey: string =
'confirmDiscardChangesPermanentlyKey'
const confirmForcePushKey: string = 'confirmForcePush'
@ -348,6 +364,9 @@ const hideWhitespaceInChangesDiffDefault = false
const hideWhitespaceInChangesDiffKey = 'hide-whitespace-in-changes-diff'
const hideWhitespaceInHistoryDiffDefault = false
const hideWhitespaceInHistoryDiffKey = 'hide-whitespace-in-diff'
const hideWhitespaceInPullRequestDiffDefault = false
const hideWhitespaceInPullRequestDiffKey =
'hide-whitespace-in-pull-request-diff'
const commitSpellcheckEnabledDefault = true
const commitSpellcheckEnabledKey = 'commit-spellcheck-enabled'
@ -370,6 +389,9 @@ const MaxInvalidFoldersToDisplay = 3
const lastThankYouKey = 'version-and-users-of-last-thank-you'
const customThemeKey = 'custom-theme-key'
const pullRequestSuggestedNextActionKey =
'pull-request-suggested-next-action-key'
export class AppStore extends TypedBaseStore<IAppState> {
private readonly gitStoreCache: GitStoreCache
@ -388,10 +410,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
private showWelcomeFlow = false
private focusCommitMessage = false
private currentPopup: Popup | null = null
private currentFoldout: Foldout | null = null
private currentBanner: Banner | null = null
private errors: ReadonlyArray<Error> = new Array<Error>()
private emitQueued = false
private readonly localRepositoryStateLookup = new Map<
@ -424,6 +444,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private sidebarWidth = constrain(defaultSidebarWidth)
private commitSummaryWidth = constrain(defaultCommitSummaryWidth)
private stashedFilesWidth = constrain(defaultStashedFilesWidth)
private pullRequestFileListWidth = constrain(defaultPullRequestFileListWidth)
private windowState: WindowState | null = null
private windowZoomFactor: number = 1
@ -437,6 +458,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private confirmDiscardChanges: boolean = confirmDiscardChangesDefault
private confirmDiscardChangesPermanently: boolean =
confirmDiscardChangesPermanentlyDefault
private confirmDiscardStash: boolean = confirmDiscardStashDefault
private askForConfirmationOnForcePush = askForConfirmationOnForcePushDefault
private confirmUndoCommit: boolean = confirmUndoCommitDefault
private imageDiffType: ImageDiffType = imageDiffTypeDefault
@ -444,6 +466,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
hideWhitespaceInChangesDiffDefault
private hideWhitespaceInHistoryDiff: boolean =
hideWhitespaceInHistoryDiffDefault
private hideWhitespaceInPullRequestDiff: boolean =
hideWhitespaceInPullRequestDiffDefault
/** Whether or not the spellchecker is enabled for commit summary and description */
private commitSpellcheckEnabled: boolean = commitSpellcheckEnabledDefault
private showSideBySideDiff: boolean = ShowSideBySideDiffDefault
@ -488,6 +512,13 @@ export class AppStore extends TypedBaseStore<IAppState> {
private lastThankYou: ILastThankYou | undefined
private showCIStatusPopover: boolean = false
/** A service for managing the stack of open popups */
private popupManager = new PopupManager()
private pullRequestSuggestedNextAction:
| PullRequestSuggestedNextAction
| undefined = undefined
public constructor(
private readonly gitHubUserStore: GitHubUserStore,
private readonly cloningRepositoriesStore: CloningRepositoriesStore,
@ -570,6 +601,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.notificationsStore.onPullRequestReviewSubmitNotification(
this.onPullRequestReviewSubmitNotification
)
onShowInstallingUpdate(this.onShowInstallingUpdate)
}
private initializeWindowState = async () => {
@ -627,7 +660,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
// If there is a currently open popup, don't do anything here. Since the
// app can only show one popup at a time, we don't want to close the current
// one in favor of the error we're about to show.
if (this.currentPopup !== null) {
if (this.popupManager.isAPopupOpen) {
return
}
@ -640,6 +673,12 @@ export class AppStore extends TypedBaseStore<IAppState> {
})
}
private onShowInstallingUpdate = () => {
this._showPopup({
type: PopupType.InstallingUpdate,
})
}
/** Figure out what step of the tutorial the user needs to do next */
private async updateCurrentTutorialStep(
repository: Repository
@ -892,15 +931,17 @@ export class AppStore extends TypedBaseStore<IAppState> {
appIsFocused: this.appIsFocused,
selectedState: this.getSelectedState(),
signInState: this.signInStore.getState(),
currentPopup: this.currentPopup,
currentPopup: this.popupManager.currentPopup,
allPopups: this.popupManager.allPopups,
currentFoldout: this.currentFoldout,
errors: this.errors,
errorCount: this.popupManager.getPopupsOfType(PopupType.Error).length,
showWelcomeFlow: this.showWelcomeFlow,
focusCommitMessage: this.focusCommitMessage,
emoji: this.emoji,
sidebarWidth: this.sidebarWidth,
commitSummaryWidth: this.commitSummaryWidth,
stashedFilesWidth: this.stashedFilesWidth,
pullRequestFilesListWidth: this.pullRequestFileListWidth,
appMenuState: this.appMenu ? this.appMenu.openMenus : [],
highlightAccessKeys: this.highlightAccessKeys,
isUpdateAvailableBannerVisible: this.isUpdateAvailableBannerVisible,
@ -913,6 +954,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
askForConfirmationOnDiscardChanges: this.confirmDiscardChanges,
askForConfirmationOnDiscardChangesPermanently:
this.confirmDiscardChangesPermanently,
askForConfirmationOnDiscardStash: this.confirmDiscardStash,
askForConfirmationOnForcePush: this.askForConfirmationOnForcePush,
askForConfirmationOnUndoCommit: this.confirmUndoCommit,
uncommittedChangesStrategy: this.uncommittedChangesStrategy,
@ -920,6 +962,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
imageDiffType: this.imageDiffType,
hideWhitespaceInChangesDiff: this.hideWhitespaceInChangesDiff,
hideWhitespaceInHistoryDiff: this.hideWhitespaceInHistoryDiff,
hideWhitespaceInPullRequestDiff: this.hideWhitespaceInPullRequestDiff,
showSideBySideDiff: this.showSideBySideDiff,
selectedShell: this.selectedShell,
repositoryFilterText: this.repositoryFilterText,
@ -939,6 +982,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
lastThankYou: this.lastThankYou,
showCIStatusPopover: this.showCIStatusPopover,
notificationsEnabled: getNotificationsEnabled(),
pullRequestSuggestedNextAction: this.pullRequestSuggestedNextAction,
}
}
@ -1426,17 +1470,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
if (tip.kind === TipState.Valid && aheadBehind.behind > 0) {
const mergeTreePromise = promiseWithMinimumTimeout(
() => determineMergeability(repository, tip.branch, action.branch),
500
this.currentMergeTreePromise = this.setupMergabilityPromise(
repository,
tip.branch,
action.branch
)
.catch(err => {
log.warn(
`Error occurred while trying to merge ${tip.branch.name} (${tip.branch.tip.sha}) and ${action.branch.name} (${action.branch.tip.sha})`,
err
)
return null
})
.then(mergeStatus => {
this.repositoryStateCache.updateCompareState(repository, () => ({
mergeStatus,
@ -1444,16 +1482,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.emitUpdate()
})
const cleanup = () => {
this.currentMergeTreePromise = null
}
// TODO: when we have Promise.prototype.finally available we
// should use that here to make this intent clearer
mergeTreePromise.then(cleanup, cleanup)
this.currentMergeTreePromise = mergeTreePromise
.finally(() => {
this.currentMergeTreePromise = null
})
return this.currentMergeTreePromise
} else {
@ -1465,6 +1496,23 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
}
private setupMergabilityPromise(
repository: Repository,
baseBranch: Branch,
compareBranch: Branch
) {
return promiseWithMinimumTimeout(
() => determineMergeability(repository, baseBranch, compareBranch),
500
).catch(err => {
log.warn(
`Error occurred while trying to merge ${baseBranch.name} (${baseBranch.tip.sha}) and ${compareBranch.name} (${compareBranch.tip.sha})`,
err
)
return null
})
}
/** This shouldn't be called directly. See `Dispatcher`. */
public _updateCompareForm<K extends keyof ICompareFormUpdate>(
repository: Repository,
@ -1717,6 +1765,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
)
setNumberArray(RecentRepositoriesKey, slicedRecentRepositories)
this.recentRepositories = slicedRecentRepositories
this.notificationsStore.setRecentRepositories(
this.repositories.filter(r => this.recentRepositories.includes(r.id))
)
this.emitUpdate()
}
@ -1951,8 +2002,13 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.stashedFilesWidth = constrain(
getNumber(stashedFilesWidthConfigKey, defaultStashedFilesWidth)
)
this.pullRequestFileListWidth = constrain(
getNumber(pullRequestFileListConfigKey, defaultPullRequestFileListWidth)
)
this.updateResizableConstraints()
// TODO: Initiliaze here for now... maybe move to dialog mounting
this.updatePullRequestResizableConstraints()
this.askToMoveToApplicationsFolderSetting = getBoolean(
askToMoveToApplicationsFolderKey,
@ -1974,6 +2030,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
confirmDiscardChangesPermanentlyDefault
)
this.confirmDiscardStash = getBoolean(
confirmDiscardStashKey,
confirmDiscardStashDefault
)
this.askForConfirmationOnForcePush = getBoolean(
confirmForcePushKey,
askForConfirmationOnForcePushDefault
@ -2011,6 +2072,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
hideWhitespaceInHistoryDiffKey,
false
)
this.hideWhitespaceInPullRequestDiff = getBoolean(
hideWhitespaceInPullRequestDiffKey,
false
)
this.commitSpellcheckEnabled = getBoolean(
commitSpellcheckEnabledKey,
commitSpellcheckEnabledDefault
@ -2034,6 +2099,12 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.lastThankYou = getObject<ILastThankYou>(lastThankYouKey)
this.pullRequestSuggestedNextAction =
getEnum(
pullRequestSuggestedNextActionKey,
PullRequestSuggestedNextAction
) ?? defaultPullRequestSuggestedNextAction
this.emitUpdateNow()
this.accountsStore.refresh()
@ -2077,6 +2148,41 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.stashedFilesWidth = constrain(this.stashedFilesWidth, 100, filesMax)
}
/**
* Calculate the constraints of the resizable pane in the pull request dialog
* whenever the window dimensions change.
*/
private updatePullRequestResizableConstraints() {
// TODO: Get width of PR dialog -> determine if we will have default width
// for pr dialog. The goal is for it expand to fill some percent of
// available window so it will change on window resize. We may have some max
// value and min value of where to derive a default is we cannot obtain the
// width for some reason (like initialization nad no pr dialog is open)
// Thoughts -> ß
// 1. Use dialog id to grab dialog if exists, else use default
// 2. Pass dialog width up when and call this contrainst on dialog mounting
// to initialize and subscribe to window resize inside dialog to be able
// to pass up dialog width on window resize.
// Get the width of the dialog
const available = 850
const dialogPadding = 20
// This is a pretty silly width for a diff but it will fit ~9 chars per line
// in unified mode after subtracting the width of the unified gutter and ~4
// chars per side in split diff mode. No one would want to use it this way
// but it doesn't break the layout and it allows users to temporarily
// maximize the width of the file list to see long path names.
const diffPaneMinWidth = 150
const filesListMax = available - dialogPadding - diffPaneMinWidth
this.pullRequestFileListWidth = constrain(
this.pullRequestFileListWidth,
100,
filesListMax
)
}
private updateSelectedExternalEditor(
selectedEditor: string | null
): Promise<void> {
@ -2163,10 +2269,12 @@ export class AppStore extends TypedBaseStore<IAppState> {
?.name ?? undefined
}
const isForcePushForCurrentRepository = isCurrentBranchForcePush(
branchesState,
aheadBehind
)
// From the menu, we'll offer to force-push whenever it's possible, regardless
// of whether or not the user performed any action we know would be followed
// by a force-push.
const isForcePushForCurrentRepository =
getCurrentBranchForcePushState(branchesState, aheadBehind) !==
ForcePushBranchState.NotAvailable
const isStashedChangesVisible =
changesState.selection.kind === ChangesSelectionKind.Stash
@ -2440,7 +2548,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
if (
displayingBanner ||
isConflictsFlow(this.currentPopup, multiCommitOperationState)
isConflictsFlow(
this.popupManager.areTherePopupsOfType(PopupType.MultiCommitOperation),
multiCommitOperationState
)
) {
return
}
@ -2530,7 +2641,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
const { multiCommitOperationState } = state
if (
userIsStartingMultiCommitOperation(
this.currentPopup,
this.popupManager.currentPopup,
multiCommitOperationState
)
) {
@ -3412,32 +3523,45 @@ export class AppStore extends TypedBaseStore<IAppState> {
/** This shouldn't be called directly. See `Dispatcher`. */
public async _showPopup(popup: Popup): Promise<void> {
this._closePopup()
// Always close the app menu when showing a pop up. This is only
// applicable on Windows where we draw a custom app menu.
this._closeFoldout(FoldoutType.AppMenu)
this.currentPopup = popup
this.popupManager.addPopup(popup)
this.emitUpdate()
}
/** This shouldn't be called directly. See `Dispatcher`. */
public _closePopup(popupType?: PopupType) {
const currentPopup = this.currentPopup
if (currentPopup == null) {
const currentPopup = this.popupManager.currentPopup
if (currentPopup === null) {
return
}
if (popupType !== undefined && currentPopup.type !== popupType) {
if (popupType === undefined) {
this.popupManager.removePopup(currentPopup)
} else {
if (currentPopup.type !== popupType) {
return
}
if (currentPopup.type === PopupType.CloneRepository) {
this._completeOpenInDesktop(() => Promise.resolve(null))
}
this.popupManager.removePopupByType(popupType)
}
this.emitUpdate()
}
/** This shouldn't be called directly. See `Dispatcher`. */
public _closePopupById(popupId: string) {
if (this.popupManager.currentPopup === null) {
return
}
if (currentPopup.type === PopupType.CloneRepository) {
this._completeOpenInDesktop(() => Promise.resolve(null))
}
this.currentPopup = null
this.popupManager.removePopupById(popupId)
this.emitUpdate()
}
@ -3859,17 +3983,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
/** This shouldn't be called directly. See `Dispatcher`. */
public _pushError(error: Error): Promise<void> {
const newErrors = Array.from(this.errors)
newErrors.push(error)
this.errors = newErrors
this.emitUpdate()
return Promise.resolve()
}
/** This shouldn't be called directly. See `Dispatcher`. */
public _clearError(error: Error): Promise<void> {
this.errors = this.errors.filter(e => e !== error)
this.popupManager.addErrorPopup(error)
this.emitUpdate()
return Promise.resolve()
@ -5193,6 +5307,15 @@ export class AppStore extends TypedBaseStore<IAppState> {
return Promise.resolve()
}
public _setConfirmDiscardStashSetting(value: boolean): Promise<void> {
this.confirmDiscardStash = value
setBoolean(confirmDiscardStashKey, value)
this.emitUpdate()
return Promise.resolve()
}
public _setConfirmForcePushSetting(value: boolean): Promise<void> {
this.askForConfirmationOnForcePush = value
setBoolean(confirmForcePushKey, value)
@ -5279,6 +5402,19 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
}
public _setHideWhitespaceInPullRequestDiff(
hideWhitespaceInDiff: boolean,
repository: Repository,
file: CommittedFileChange | null
) {
setBoolean(hideWhitespaceInPullRequestDiffKey, hideWhitespaceInDiff)
this.hideWhitespaceInPullRequestDiff = hideWhitespaceInDiff
if (file !== null) {
this._changePullRequestFileSelection(repository, file)
}
}
public _setShowSideBySideDiff(showSideBySideDiff: boolean) {
if (showSideBySideDiff !== this.showSideBySideDiff) {
setShowSideBySideDiff(showSideBySideDiff)
@ -5825,7 +5961,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
await this._openInBrowser(url.toString())
}
public async _createPullRequest(repository: Repository): Promise<void> {
public async _createPullRequest(
repository: Repository,
baseBranch?: Branch
): Promise<void> {
const gitHubRepository = repository.gitHubRepository
if (!gitHubRepository) {
return
@ -5838,24 +5977,28 @@ export class AppStore extends TypedBaseStore<IAppState> {
return
}
const branch = tip.branch
const compareBranch = tip.branch
const aheadBehind = state.aheadBehind
if (aheadBehind == null) {
this._showPopup({
type: PopupType.PushBranchCommits,
repository,
branch,
branch: compareBranch,
})
} else if (aheadBehind.ahead > 0) {
this._showPopup({
type: PopupType.PushBranchCommits,
repository,
branch,
branch: compareBranch,
unPushedCommits: aheadBehind.ahead,
})
} else {
await this._openCreatePullRequestInBrowser(repository, branch)
await this._openCreatePullRequestInBrowser(
repository,
compareBranch,
baseBranch
)
}
}
@ -5950,15 +6093,38 @@ export class AppStore extends TypedBaseStore<IAppState> {
public async _openCreatePullRequestInBrowser(
repository: Repository,
branch: Branch
compareBranch: Branch,
baseBranch?: Branch
): Promise<void> {
const gitHubRepository = repository.gitHubRepository
if (!gitHubRepository) {
return
}
const urlEncodedBranchName = encodeURIComponent(branch.nameWithoutRemote)
const baseURL = `${gitHubRepository.htmlURL}/pull/new/${urlEncodedBranchName}`
const { parent, owner, name, htmlURL } = gitHubRepository
const isForkContributingToParent =
isForkedRepositoryContributingToParent(repository)
const baseForkPreface =
isForkContributingToParent && parent !== null
? `${parent.owner.login}:${parent.name}:`
: ''
const encodedBaseBranch =
baseBranch !== undefined
? baseForkPreface +
encodeURIComponent(baseBranch.nameWithoutRemote) +
'...'
: ''
const compareForkPreface = isForkContributingToParent
? `${owner.login}:${name}:`
: ''
const encodedCompareBranch =
compareForkPreface + encodeURIComponent(compareBranch.nameWithoutRemote)
const compareString = `${encodedBaseBranch}${encodedCompareBranch}`
const baseURL = `${htmlURL}/pull/new/${compareString}`
await this._openInBrowser(baseURL)
@ -6407,13 +6573,13 @@ export class AppStore extends TypedBaseStore<IAppState> {
path,
(title, value, description) => {
if (
this.currentPopup !== null &&
this.currentPopup.type === PopupType.CreateTutorialRepository
this.popupManager.currentPopup?.type ===
PopupType.CreateTutorialRepository
) {
this.currentPopup = {
...this.currentPopup,
this.popupManager.updatePopup({
...this.popupManager.currentPopup,
progress: { kind: 'generic', title, value, description },
}
})
this.emitUpdate()
}
}
@ -7161,33 +7327,47 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
public async _startPullRequest(repository: Repository) {
const { branchesState } = this.repositoryStateCache.get(repository)
const { defaultBranch, tip } = branchesState
const { tip, defaultBranch } =
this.repositoryStateCache.get(repository).branchesState
if (defaultBranch === null || tip.kind !== TipState.Valid) {
if (tip.kind !== TipState.Valid) {
// Shouldn't even be able to get here if so - just a type check
return
}
const currentBranch = tip.branch
this._initializePullRequestPreview(repository, defaultBranch, currentBranch)
}
private async _initializePullRequestPreview(
repository: Repository,
baseBranch: Branch | null,
currentBranch: Branch
) {
if (baseBranch === null) {
this.showPullRequestPopupNoBaseBranch(repository, currentBranch)
return
}
const gitStore = this.gitStoreCache.get(repository)
const pullRequestCommits = await gitStore.getCommitsBetweenBranches(
defaultBranch,
baseBranch,
currentBranch
)
const commitSHAs = pullRequestCommits.map(c => c.sha)
const commitsBetweenBranches = pullRequestCommits.map(c => c.sha)
// A user may compare two branches with no changes between them.
const emptyChangeSet = { files: [], linesAdded: 0, linesDeleted: 0 }
const changesetData =
commitSHAs.length > 0
commitsBetweenBranches.length > 0
? await gitStore.performFailableOperation(() =>
getBranchMergeBaseChangedFiles(
repository,
defaultBranch.name,
baseBranch.name,
currentBranch.name,
commitSHAs[0]
commitsBetweenBranches[0]
)
)
: emptyChangeSet
@ -7196,25 +7376,113 @@ export class AppStore extends TypedBaseStore<IAppState> {
return
}
const hasMergeBase = changesetData !== null
// We don't care how many commits exist on the unrelated history that
// can't be merged.
const commitSHAs = hasMergeBase ? commitsBetweenBranches : []
this.repositoryStateCache.initializePullRequestState(repository, {
baseBranch: defaultBranch,
baseBranch,
commitSHAs,
commitSelection: {
shas: commitSHAs,
shasInDiff: commitSHAs,
isContiguous: true,
changesetData,
changesetData: changesetData ?? emptyChangeSet,
file: null,
diff: null,
},
mergeStatus:
commitSHAs.length > 0 || !hasMergeBase
? {
kind: hasMergeBase
? ComputedAction.Loading
: ComputedAction.Invalid,
}
: null,
})
if (changesetData.files.length > 0) {
this.emitUpdate()
if (commitSHAs.length > 0) {
this.setupPRMergeTreePromise(repository, baseBranch, currentBranch)
}
if (changesetData !== null && changesetData.files.length > 0) {
await this._changePullRequestFileSelection(
repository,
changesetData.files[0]
)
}
this.showPullRequestPopup(repository, currentBranch, commitSHAs)
}
public showPullRequestPopupNoBaseBranch(
repository: Repository,
currentBranch: Branch
) {
this.repositoryStateCache.initializePullRequestState(repository, {
baseBranch: null,
commitSHAs: null,
commitSelection: null,
mergeStatus: null,
})
this.emitUpdate()
this.showPullRequestPopup(repository, currentBranch, [])
}
public showPullRequestPopup(
repository: Repository,
currentBranch: Branch,
commitSHAs: ReadonlyArray<string>
) {
if (this.popupManager.areTherePopupsOfType(PopupType.StartPullRequest)) {
return
}
this.statsStore.recordPreviewedPullRequest()
const { branchesState, localCommitSHAs } =
this.repositoryStateCache.get(repository)
const { allBranches, recentBranches, defaultBranch, currentPullRequest } =
branchesState
const gitStore = this.gitStoreCache.get(repository)
/* We only want branches that are also on dotcom such that, when we ask a
* user to create a pull request, the base branch also exists on dotcom.
*/
const remote = isForkedRepositoryContributingToParent(repository)
? UpstreamRemoteName
: gitStore.defaultRemote?.name
const prBaseBranches = allBranches.filter(
b => b.upstreamRemoteName === remote || b.remoteName === remote
)
const prRecentBaseBranches = recentBranches.filter(
b => b.upstreamRemoteName === remote || b.remoteName === remote
)
const { imageDiffType, selectedExternalEditor, showSideBySideDiff } =
this.getState()
const nonLocalCommitSHA =
commitSHAs.length > 0 && !localCommitSHAs.includes(commitSHAs[0])
? commitSHAs[0]
: null
this._showPopup({
type: PopupType.StartPullRequest,
prBaseBranches,
prRecentBaseBranches,
currentBranch,
defaultBranch,
imageDiffType,
repository,
externalEditorLabel: selectedExternalEditor ?? undefined,
nonLocalCommitSHA,
showSideBySideDiff,
currentBranchHasPullRequest: currentPullRequest !== null,
})
}
public async _changePullRequestFileSelection(
@ -7233,7 +7501,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
const currentBranch = branchesState.tip.branch
const { baseBranch, commitSHAs } = pullRequestState
if (commitSHAs === null) {
if (commitSHAs === null || baseBranch === null) {
return
}
@ -7244,6 +7512,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
diff: null,
})
)
this.emitUpdate()
if (commitSHAs.length === 0) {
@ -7261,7 +7530,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
file,
baseBranch.name,
currentBranch.name,
this.hideWhitespaceInHistoryDiff,
this.hideWhitespaceInPullRequestDiff,
commitSHAs[0]
)
)) ?? null
@ -7284,6 +7553,88 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.emitUpdate()
}
public _setPullRequestFileListWidth(width: number): Promise<void> {
this.pullRequestFileListWidth = {
...this.pullRequestFileListWidth,
value: width,
}
setNumber(pullRequestFileListConfigKey, width)
this.updatePullRequestResizableConstraints()
this.emitUpdate()
return Promise.resolve()
}
public _resetPullRequestFileListWidth(): Promise<void> {
this.pullRequestFileListWidth = {
...this.pullRequestFileListWidth,
value: defaultPullRequestFileListWidth,
}
localStorage.removeItem(pullRequestFileListConfigKey)
this.updatePullRequestResizableConstraints()
this.emitUpdate()
return Promise.resolve()
}
public _updatePullRequestBaseBranch(
repository: Repository,
baseBranch: Branch
) {
const { branchesState, pullRequestState } =
this.repositoryStateCache.get(repository)
const { tip } = branchesState
if (tip.kind !== TipState.Valid) {
return
}
if (pullRequestState === null) {
// This would mean the user submitted PR after requesting base branch
// update.
return
}
this._initializePullRequestPreview(repository, baseBranch, tip.branch)
}
private setupPRMergeTreePromise(
repository: Repository,
baseBranch: Branch,
compareBranch: Branch
) {
this.setupMergabilityPromise(repository, baseBranch, compareBranch).then(
(mergeStatus: MergeTreeResult | null) => {
this.repositoryStateCache.updatePullRequestState(repository, () => ({
mergeStatus,
}))
this.emitUpdate()
}
)
}
public _quitApp(evenIfUpdating: boolean) {
if (evenIfUpdating) {
sendWillQuitEvenIfUpdatingSync()
}
quitApp()
}
public _cancelQuittingApp() {
sendCancelQuittingSync()
}
public _setPullRequestSuggestedNextAction(
value: PullRequestSuggestedNextAction
) {
this.pullRequestSuggestedNextAction = value
localStorage.setItem(pullRequestSuggestedNextActionKey, value)
this.emitUpdate()
}
}
/**

View file

@ -2,8 +2,11 @@ import {
Repository,
isRepositoryWithGitHubRepository,
RepositoryWithGitHubRepository,
isRepositoryWithForkedGitHubRepository,
getForkContributionTarget,
} from '../../models/repository'
import { PullRequest } from '../../models/pull-request'
import { ForkContributionTarget } from '../../models/workflow-preferences'
import { getPullRequestCommitRef, PullRequest } from '../../models/pull-request'
import { API, APICheckConclusion } from '../api'
import {
createCombinedCheckFromChecks,
@ -66,11 +69,13 @@ export function getNotificationsEnabled() {
*/
export class NotificationsStore {
private repository: RepositoryWithGitHubRepository | null = null
private recentRepositories: ReadonlyArray<Repository> = []
private onChecksFailedCallback: OnChecksFailedCallback | null = null
private onPullRequestReviewSubmitCallback: OnPullRequestReviewSubmitCallback | null =
null
private cachedCommits: Map<string, Commit> = new Map()
private skipCommitShas: Set<string> = new Set()
private skipCheckRuns: Set<number> = new Set()
public constructor(
private readonly accountsStore: AccountsStore,
@ -121,6 +126,15 @@ export class NotificationsStore {
return
}
if (!this.isValidRepositoryForEvent(repository, event)) {
if (this.isRecentRepositoryEvent(event)) {
this.statsStore.recordPullRequestReviewNotiificationFromRecentRepo()
} else {
this.statsStore.recordPullRequestReviewNotiificationFromNonRecentRepo()
}
return
}
const pullRequests = await this.pullRequestCoordinator.getAllPullRequests(
repository
)
@ -134,16 +148,17 @@ export class NotificationsStore {
return
}
const { gitHubRepository } = repository
const api = await this.getAPIForRepository(gitHubRepository)
// PR reviews must be retrieved from the repository the PR belongs to
const pullsRepository = this.getContributingRepository(repository)
const api = await this.getAPIForRepository(pullsRepository)
if (api === null) {
return
}
const review = await api.fetchPullRequestReview(
gitHubRepository.owner.login,
gitHubRepository.name,
pullsRepository.owner.login,
pullsRepository.name,
pullRequest.pullRequestNumber.toString(),
event.review_id
)
@ -192,6 +207,15 @@ export class NotificationsStore {
return
}
if (!this.isValidRepositoryForEvent(repository, event)) {
if (this.isRecentRepositoryEvent(event)) {
this.statsStore.recordChecksFailedNotificationFromRecentRepo()
} else {
this.statsStore.recordChecksFailedNotificationFromNonRecentRepo()
}
return
}
const pullRequests = await this.pullRequestCoordinator.getAllPullRequests(
repository
)
@ -234,11 +258,29 @@ export class NotificationsStore {
return
}
const checks = await this.getChecksForRef(repository, pullRequest.head.ref)
// Checks must be retrieved from the repository the PR belongs to
const checksRepository = this.getContributingRepository(repository)
const checks = await this.getChecksForRef(
checksRepository,
getPullRequestCommitRef(pullRequest.pullRequestNumber)
)
if (checks === null) {
return
}
// Make sure we haven't shown a notification for the check runs of this
// check suite already.
// If one of more jobs are re-run, the check suite will have the same ID
// but different check runs.
const checkSuiteCheckRunIds = checks.flatMap(check =>
check.checkSuiteId === event.check_suite_id ? check.id : []
)
if (checkSuiteCheckRunIds.every(id => this.skipCheckRuns.has(id))) {
return
}
const numberOfFailedChecks = checks.filter(
check => check.conclusion === APICheckConclusion.Failure
).length
@ -250,6 +292,12 @@ export class NotificationsStore {
return
}
// Ignore any remaining notification for check runs that started along
// with this one.
for (const check of checks) {
this.skipCheckRuns.add(check.id)
}
const pluralChecks =
numberOfFailedChecks === 1 ? 'check was' : 'checks were'
@ -283,14 +331,78 @@ export class NotificationsStore {
this.statsStore.recordChecksFailedNotificationShown()
}
private getContributingRepository(
repository: RepositoryWithGitHubRepository
) {
const isForkContributingToParent =
isRepositoryWithForkedGitHubRepository(repository) &&
getForkContributionTarget(repository) === ForkContributionTarget.Parent
return isForkContributingToParent
? repository.gitHubRepository.parent
: repository.gitHubRepository
}
private isValidRepositoryForEvent(
repository: RepositoryWithGitHubRepository,
event: DesktopAliveEvent
) {
// If it's a fork and set to contribute to the parent repository, try to
// match the parent repository.
if (
isRepositoryWithForkedGitHubRepository(repository) &&
getForkContributionTarget(repository) === ForkContributionTarget.Parent
) {
const parentRepository = repository.gitHubRepository.parent
return (
parentRepository.owner.login === event.owner &&
parentRepository.name === event.repo
)
}
const ghRepository = repository.gitHubRepository
return (
ghRepository.owner.login === event.owner &&
ghRepository.name === event.repo
)
}
private isRecentRepositoryEvent(event: DesktopAliveEvent) {
return this.recentRepositories.some(
r =>
isRepositoryWithGitHubRepository(r) &&
this.isValidRepositoryForEvent(r, event)
)
}
/**
* Makes the store to keep track of the currently selected repository. Only
* notifications for the currently selected repository will be shown.
*/
public selectRepository(repository: Repository) {
if (repository.hash === this.repository?.hash) {
return
}
this.repository = isRepositoryWithGitHubRepository(repository)
? repository
: null
this.resetCache()
}
private resetCache() {
this.cachedCommits.clear()
this.skipCommitShas.clear()
this.skipCheckRuns.clear()
}
/**
* For stats purposes, we need to know which are the recent repositories. This
* will allow the notification store when a notification is related to one of
* these repositories.
*/
public setRecentRepositories(repositories: ReadonlyArray<Repository>) {
this.recentRepositories = repositories
}
private async getAccountForRepository(repository: GitHubRepository) {
@ -310,22 +422,20 @@ export class NotificationsStore {
return API.fromAccount(account)
}
private async getChecksForRef(
repository: RepositoryWithGitHubRepository,
ref: string
) {
const { gitHubRepository } = repository
const { owner, name } = gitHubRepository
private async getChecksForRef(repository: GitHubRepository, ref: string) {
const { owner, name } = repository
const api = await this.getAPIForRepository(gitHubRepository)
const api = await this.getAPIForRepository(repository)
if (api === null) {
return null
}
// Hit these API endpoints reloading the cache to make sure we have the
// latest data at the time the notification is received.
const [statuses, checkRuns] = await Promise.all([
api.fetchCombinedRefStatus(owner.login, name, ref),
api.fetchRefCheckRuns(owner.login, name, ref),
api.fetchCombinedRefStatus(owner.login, name, ref, true),
api.fetchRefCheckRuns(owner.login, name, ref, true),
])
const checks = new Array<IRefCheck>()

View file

@ -286,7 +286,8 @@ export class RepositoryStateCache {
}
const oldState = pullRequestState.commitSelection
const commitSelection = merge(oldState, fn(oldState))
const commitSelection =
oldState === null ? null : merge(oldState, fn(oldState))
this.updatePullRequestState(repository, () => ({
commitSelection,
}))

View file

@ -26,6 +26,7 @@ import {
terminateDesktopNotifications,
} from './notifications'
import { addTrustedIPCSender } from './trusted-ipc-sender'
import { enablePreventClosingWhileUpdating } from '../lib/feature-flag'
export class AppWindow {
private window: Electron.BrowserWindow
@ -33,6 +34,7 @@ export class AppWindow {
private _loadTime: number | null = null
private _rendererReadyTime: number | null = null
private isDownloadingUpdate: boolean = false
private minWidth = 960
private minHeight = 660
@ -86,6 +88,7 @@ export class AppWindow {
this.shouldMaximizeOnShow = savedWindowState.isMaximized
let quitting = false
let quittingEvenIfUpdating = false
app.on('before-quit', () => {
quitting = true
})
@ -95,7 +98,40 @@ export class AppWindow {
event.returnValue = true
})
ipcMain.on('will-quit-even-if-updating', event => {
quitting = true
quittingEvenIfUpdating = true
event.returnValue = true
})
ipcMain.on('cancel-quitting', event => {
quitting = false
quittingEvenIfUpdating = false
event.returnValue = true
})
this.window.on('close', e => {
// On macOS, closing the window doesn't mean the app is quitting. If the
// app is updating, we will prevent the window from closing only when the
// app is also quitting.
if (
enablePreventClosingWhileUpdating() &&
(!__DARWIN__ || quitting) &&
!quittingEvenIfUpdating &&
this.isDownloadingUpdate
) {
e.preventDefault()
ipcWebContents.send(this.window.webContents, 'show-installing-update')
// Make sure the window is visible, so the user can see why we're
// preventing the app from quitting. This is important on macOS, where
// the window could be hidden/closed when the user tries to quit.
// It could also happen on Windows if the user quits the app from the
// task bar while it's in the background.
this.show()
return
}
// on macOS, when the user closes the window we really just hide it. This
// lets us activate quickly and keep all our interesting logic in the
// renderer.
@ -104,9 +140,9 @@ export class AppWindow {
// https://github.com/desktop/desktop/issues/12838
if (this.window.isFullScreen()) {
this.window.setFullScreen(false)
this.window.once('leave-full-screen', () => app.hide())
this.window.once('leave-full-screen', () => this.window.hide())
} else {
app.hide()
this.window.hide()
}
return
}
@ -213,7 +249,7 @@ export class AppWindow {
return !!this.loadTime && !!this.rendererReadyTime
}
public onClose(fn: () => void) {
public onClosed(fn: () => void) {
this.window.on('closed', fn)
}
@ -344,10 +380,12 @@ export class AppWindow {
public setupAutoUpdater() {
autoUpdater.on('error', (error: Error) => {
this.isDownloadingUpdate = false
ipcWebContents.send(this.window.webContents, 'auto-updater-error', error)
})
autoUpdater.on('checking-for-update', () => {
this.isDownloadingUpdate = false
ipcWebContents.send(
this.window.webContents,
'auto-updater-checking-for-update'
@ -355,6 +393,7 @@ export class AppWindow {
})
autoUpdater.on('update-available', () => {
this.isDownloadingUpdate = true
ipcWebContents.send(
this.window.webContents,
'auto-updater-update-available'
@ -362,6 +401,7 @@ export class AppWindow {
})
autoUpdater.on('update-not-available', () => {
this.isDownloadingUpdate = false
ipcWebContents.send(
this.window.webContents,
'auto-updater-update-not-available'
@ -369,6 +409,7 @@ export class AppWindow {
})
autoUpdater.on('update-downloaded', () => {
this.isDownloadingUpdate = false
ipcWebContents.send(
this.window.webContents,
'auto-updater-update-downloaded'

View file

@ -490,6 +490,8 @@ app.on('ready', () => {
mainWindow?.quitAndInstallUpdate()
)
ipcMain.on('quit-app', () => app.quit())
ipcMain.on('minimize-window', () => mainWindow?.minimizeWindow())
ipcMain.on('maximize-window', () => mainWindow?.maximizeWindow())
@ -738,7 +740,7 @@ function createWindow() {
}
}
window.onClose(() => {
window.onClosed(() => {
mainWindow = null
if (!__DARWIN__ && !preventQuit) {
app.quit()

View file

@ -285,6 +285,12 @@ export function buildDefaultMenu({
accelerator: 'CmdOrCtrl+Shift+P',
click: emit('pull'),
},
{
id: 'fetch',
label: __DARWIN__ ? 'Fetch' : '&Fetch',
accelerator: 'CmdOrCtrl+Shift+T',
click: emit('fetch'),
},
{
label: removeRepoLabel,
id: 'remove-repository',
@ -428,12 +434,12 @@ export function buildDefaultMenu({
},
]
if (!hasCurrentPullRequest && enableStartingPullRequests()) {
if (enableStartingPullRequests()) {
branchSubmenu.push({
label: __DARWIN__ ? 'Start Pull Request' : 'Start pull request',
id: 'start-pull-request',
label: __DARWIN__ ? 'Preview Pull Request' : 'Preview pull request',
id: 'preview-pull-request',
accelerator: 'CmdOrCtrl+Alt+P',
click: emit('start-pull-request'),
click: emit('preview-pull-request'),
})
}
@ -551,6 +557,10 @@ export function buildDefaultMenu({
label: 'Pull Request Check Run Failed',
click: emit('pull-request-check-run-failed'),
},
{
label: 'Show App Error',
click: emit('show-app-error'),
},
],
},
{
@ -640,7 +650,7 @@ function emit(name: MenuEvent): ClickHandler {
}
/** The zoom steps that we support, these factors must sorted */
const ZoomInFactors = [1, 1.1, 1.25, 1.5, 1.75, 2]
const ZoomInFactors = [0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2]
const ZoomOutFactors = ZoomInFactors.slice().reverse()
/**

View file

@ -2,6 +2,7 @@ export type MenuEvent =
| 'push'
| 'force-push'
| 'pull'
| 'fetch'
| 'show-changes'
| 'show-history'
| 'add-local-repository'
@ -42,4 +43,5 @@ export type MenuEvent =
| 'find-text'
| 'create-issue-in-repository-on-github'
| 'pull-request-check-run-failed'
| 'start-pull-request'
| 'preview-pull-request'
| 'show-app-error'

View file

@ -35,4 +35,4 @@ export type MenuIDs =
| 'compare-to-branch'
| 'toggle-stashed-changes'
| 'create-issue-in-repository-on-github'
| 'start-pull-request'
| 'preview-pull-request'

View file

@ -14,7 +14,7 @@ import { Commit, CommitOneLine, ICommitContext } from './commit'
import { IStashEntry } from './stash-entry'
import { Account } from '../models/account'
import { Progress } from './progress'
import { ITextDiff, DiffSelection } from './diff'
import { ITextDiff, DiffSelection, ImageDiffType } from './diff'
import { RepositorySettingsTab } from '../ui/repository-settings/repository-settings'
import { ICommitMessage } from './commit-message'
import { IAuthor } from './author'
@ -24,72 +24,81 @@ import { ValidNotificationPullRequestReview } from '../lib/valid-notification-pu
import { UnreachableCommitsTab } from '../ui/history/unreachable-commits-dialog'
export enum PopupType {
RenameBranch = 1,
DeleteBranch,
DeleteRemoteBranch,
ConfirmDiscardChanges,
Preferences,
RepositorySettings,
AddRepository,
CreateRepository,
CloneRepository,
CreateBranch,
SignIn,
About,
InstallGit,
PublishRepository,
Acknowledgements,
UntrustedCertificate,
RemoveRepository,
TermsAndConditions,
PushBranchCommits,
CLIInstalled,
GenericGitAuthentication,
ExternalEditorFailed,
OpenShellFailed,
InitializeLFS,
LFSAttributeMismatch,
UpstreamAlreadyExists,
ReleaseNotes,
DeletePullRequest,
OversizedFiles,
CommitConflictsWarning,
PushNeedsPull,
ConfirmForcePush,
StashAndSwitchBranch,
ConfirmOverwriteStash,
ConfirmDiscardStash,
CreateTutorialRepository,
ConfirmExitTutorial,
PushRejectedDueToMissingWorkflowScope,
SAMLReauthRequired,
CreateFork,
CreateTag,
DeleteTag,
LocalChangesOverwritten,
ChooseForkSettings,
ConfirmDiscardSelection,
MoveToApplicationsFolder,
ChangeRepositoryAlias,
ThankYou,
CommitMessage,
MultiCommitOperation,
WarnLocalChangesBeforeUndo,
WarningBeforeReset,
InvalidatedToken,
AddSSHHost,
SSHKeyPassphrase,
SSHUserPassword,
PullRequestChecksFailed,
CICheckRunRerun,
WarnForcePush,
DiscardChangesRetry,
PullRequestReview,
UnreachableCommits,
StartPullRequest,
RenameBranch = 'RenameBranch',
DeleteBranch = 'DeleteBranch',
DeleteRemoteBranch = 'DeleteRemoteBranch',
ConfirmDiscardChanges = 'ConfirmDiscardChanges',
Preferences = 'Preferences',
RepositorySettings = 'RepositorySettings',
AddRepository = 'AddRepository',
CreateRepository = 'CreateRepository',
CloneRepository = 'CloneRepository',
CreateBranch = 'CreateBranch',
SignIn = 'SignIn',
About = 'About',
InstallGit = 'InstallGit',
PublishRepository = 'PublishRepository',
Acknowledgements = 'Acknowledgements',
UntrustedCertificate = 'UntrustedCertificate',
RemoveRepository = 'RemoveRepository',
TermsAndConditions = 'TermsAndConditions',
PushBranchCommits = 'PushBranchCommits',
CLIInstalled = 'CLIInstalled',
GenericGitAuthentication = 'GenericGitAuthentication',
ExternalEditorFailed = 'ExternalEditorFailed',
OpenShellFailed = 'OpenShellFailed',
InitializeLFS = 'InitializeLFS',
LFSAttributeMismatch = 'LFSAttributeMismatch',
UpstreamAlreadyExists = 'UpstreamAlreadyExists',
ReleaseNotes = 'ReleaseNotes',
DeletePullRequest = 'DeletePullRequest',
OversizedFiles = 'OversizedFiles',
CommitConflictsWarning = 'CommitConflictsWarning',
PushNeedsPull = 'PushNeedsPull',
ConfirmForcePush = 'ConfirmForcePush',
StashAndSwitchBranch = 'StashAndSwitchBranch',
ConfirmOverwriteStash = 'ConfirmOverwriteStash',
ConfirmDiscardStash = 'ConfirmDiscardStash',
CreateTutorialRepository = 'CreateTutorialRepository',
ConfirmExitTutorial = 'ConfirmExitTutorial',
PushRejectedDueToMissingWorkflowScope = 'PushRejectedDueToMissingWorkflowScope',
SAMLReauthRequired = 'SAMLReauthRequired',
CreateFork = 'CreateFork',
CreateTag = 'CreateTag',
DeleteTag = 'DeleteTag',
LocalChangesOverwritten = 'LocalChangesOverwritten',
ChooseForkSettings = 'ChooseForkSettings',
ConfirmDiscardSelection = 'ConfirmDiscardSelection',
MoveToApplicationsFolder = 'MoveToApplicationsFolder',
ChangeRepositoryAlias = 'ChangeRepositoryAlias',
ThankYou = 'ThankYou',
CommitMessage = 'CommitMessage',
MultiCommitOperation = 'MultiCommitOperation',
WarnLocalChangesBeforeUndo = 'WarnLocalChangesBeforeUndo',
WarningBeforeReset = 'WarningBeforeReset',
InvalidatedToken = 'InvalidatedToken',
AddSSHHost = 'AddSSHHost',
SSHKeyPassphrase = 'SSHKeyPassphrase',
SSHUserPassword = 'SSHUserPassword',
PullRequestChecksFailed = 'PullRequestChecksFailed',
CICheckRunRerun = 'CICheckRunRerun',
WarnForcePush = 'WarnForcePush',
DiscardChangesRetry = 'DiscardChangesRetry',
PullRequestReview = 'PullRequestReview',
UnreachableCommits = 'UnreachableCommits',
StartPullRequest = 'StartPullRequest',
Error = 'Error',
InstallingUpdate = 'InstallingUpdate',
}
export type Popup =
interface IBasePopup {
/**
* Unique id of the popup that it receives upon adding to the stack.
*/
readonly id?: string
}
export type PopupDetail =
| { type: PopupType.RenameBranch; repository: Repository; branch: Branch }
| {
type: PopupType.DeleteBranch
@ -362,4 +371,23 @@ export type Popup =
}
| {
type: PopupType.StartPullRequest
prBaseBranches: ReadonlyArray<Branch>
currentBranch: Branch
defaultBranch: Branch | null
externalEditorLabel?: string
imageDiffType: ImageDiffType
prRecentBaseBranches: ReadonlyArray<Branch>
repository: Repository
nonLocalCommitSHA: string | null
showSideBySideDiff: boolean
currentBranchHasPullRequest: boolean
}
| {
type: PopupType.Error
error: Error
}
| {
type: PopupType.InstallingUpdate
}
export type Popup = IBasePopup & PopupDetail

View file

@ -41,3 +41,12 @@ export class PullRequest {
public readonly body: string
) {}
}
/** The types of pull request suggested next actions */
export enum PullRequestSuggestedNextAction {
PreviewPullRequest = 'PreviewPullRequest',
CreatePullRequest = 'CreatePullRequest',
}
export const defaultPullRequestSuggestedNextAction =
PullRequestSuggestedNextAction.PreviewPullRequest

View file

@ -213,3 +213,15 @@ export function getForkContributionTarget(
? repository.workflowPreferences.forkContributionTarget
: ForkContributionTarget.Parent
}
/**
* Returns whether the fork is contributing to the parent
*/
export function isForkedRepositoryContributingToParent(
repository: Repository
): boolean {
return (
isRepositoryWithForkedGitHubRepository(repository) &&
getForkContributionTarget(repository) === ForkContributionTarget.Parent
)
}

View file

@ -16,6 +16,7 @@ import { RelativeTime } from '../relative-time'
import { assertNever } from '../../lib/fatal-error'
import { ReleaseNotesUri } from '../lib/releases'
import { encodePathAsUrl } from '../../lib/path'
import { isTopMostDialog } from '../dialog/is-top-most'
const logoPath = __DARWIN__
? 'static/logo-64x64@2x.png'
@ -54,6 +55,9 @@ interface IAboutProps {
/** A function to call when the user wants to see Terms and Conditions. */
readonly onShowTermsAndConditions: () => void
/** Whether the dialog is the top most in the dialog stack */
readonly isTopMost: boolean
}
interface IAboutState {
@ -67,6 +71,16 @@ interface IAboutState {
*/
export class About extends React.Component<IAboutProps, IAboutState> {
private updateStoreEventHandle: Disposable | null = null
private checkIsTopMostDialog = isTopMostDialog(
() => {
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('keyup', this.onKeyUp)
},
() => {
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('keyup', this.onKeyUp)
}
)
public constructor(props: IAboutProps) {
super(props)
@ -86,8 +100,11 @@ export class About extends React.Component<IAboutProps, IAboutState> {
this.onUpdateStateChanged
)
this.setState({ updateState: updateStore.state })
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('keyup', this.onKeyUp)
this.checkIsTopMostDialog(this.props.isTopMost)
}
public componentDidUpdate(): void {
this.checkIsTopMostDialog(this.props.isTopMost)
}
public componentWillUnmount() {
@ -95,8 +112,7 @@ export class About extends React.Component<IAboutProps, IAboutState> {
this.updateStoreEventHandle.dispose()
this.updateStoreEventHandle = null
}
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('keyup', this.onKeyUp)
this.checkIsTopMostDialog(false)
}
private onKeyDown = (event: KeyboardEvent) => {

View file

@ -11,6 +11,7 @@ import * as OcticonSymbol from '../octicons/octicons.generated'
import { LinkButton } from '../lib/link-button'
import { PopupType } from '../../models/popup'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { FoldoutType } from '../../lib/app-state'
import untildify from 'untildify'
import { showOpenDialog } from '../main-process-proxy'
@ -265,6 +266,7 @@ export class AddExistingRepository extends React.Component<
const repositories = await dispatcher.addRepositories([resolvedPath])
if (repositories.length > 0) {
dispatcher.closeFoldout(FoldoutType.Repository)
dispatcher.selectRepository(repositories[0])
dispatcher.recordAddExistingRepository()
}

View file

@ -34,7 +34,9 @@ import { showOpenDialog } from '../main-process-proxy'
import { pathExists } from '../lib/path-exists'
import { mkdir } from 'fs/promises'
import { directoryExists } from '../../lib/directory-exists'
import { FoldoutType } from '../../lib/app-state'
import { join } from 'path'
import { isTopMostDialog } from '../dialog/is-top-most'
/** The sentinel value used to indicate no gitignore should be used. */
const NoGitIgnoreValue = 'None'
@ -70,6 +72,9 @@ interface ICreateRepositoryProps {
/** Prefills path input so user doesn't have to. */
readonly initialPath?: string
/** Whether the dialog is the top most in the dialog stack */
readonly isTopMost: boolean
}
interface ICreateRepositoryState {
@ -114,6 +119,16 @@ export class CreateRepository extends React.Component<
ICreateRepositoryProps,
ICreateRepositoryState
> {
private checkIsTopMostDialog = isTopMostDialog(
() => {
this.updateReadMeExists(this.state.path, this.state.name)
window.addEventListener('focus', this.onWindowFocus)
},
() => {
window.removeEventListener('focus', this.onWindowFocus)
}
)
public constructor(props: ICreateRepositoryProps) {
super(props)
@ -144,7 +159,7 @@ export class CreateRepository extends React.Component<
}
public async componentDidMount() {
window.addEventListener('focus', this.onWindowFocus)
this.checkIsTopMostDialog(this.props.isTopMost)
const gitIgnoreNames = await getGitIgnoreNames()
const licenses = await getLicenses()
@ -157,8 +172,12 @@ export class CreateRepository extends React.Component<
this.updateReadMeExists(path, this.state.name)
}
public componentWillUnmount() {
window.removeEventListener('focus', this.onWindowFocus)
public componentDidUpdate(): void {
this.checkIsTopMostDialog(this.props.isTopMost)
}
public componentWillUnmount(): void {
this.checkIsTopMostDialog(false)
}
private initializePath = async () => {
@ -391,6 +410,7 @@ export class CreateRepository extends React.Component<
this.updateDefaultDirectory()
this.props.dispatcher.closeFoldout(FoldoutType.Repository)
this.props.dispatcher.selectRepository(repository)
this.props.dispatcher.recordCreateRepository()
this.props.onDismissed()

View file

@ -9,7 +9,6 @@ import {
import { dialogTransitionTimeout } from './app'
import { GitError, isAuthFailureError } from '../lib/git/core'
import { Popup, PopupType } from '../models/popup'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { OkCancelButtonGroup } from './dialog/ok-cancel-button-group'
import { ErrorWithMetadata } from '../lib/error-with-metadata'
import { RetryActionType, RetryAction } from '../models/retry-actions'
@ -18,14 +17,11 @@ import memoizeOne from 'memoize-one'
import { parseCarriageReturn } from '../lib/parse-carriage-return'
interface IAppErrorProps {
/** The list of queued, app-wide, errors */
readonly errors: ReadonlyArray<Error>
/** The error to be displayed */
readonly error: Error
/**
* A callback which is used whenever a particular error
* has been shown to, and been dismissed by, the user.
*/
readonly onClearError: (error: Error) => void
/** Called to dismiss the dialog */
readonly onDismissed: () => void
readonly onShowPopup: (popupType: Popup) => void | undefined
readonly onRetryAction: (retryAction: RetryAction) => void
}
@ -53,13 +49,13 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
public constructor(props: IAppErrorProps) {
super(props)
this.state = {
error: props.errors[0] || null,
error: props.error,
disabled: false,
}
}
public componentWillReceiveProps(nextProps: IAppErrorProps) {
const error = nextProps.errors[0] || null
const error = nextProps.error
// We keep the currently shown error until it has disappeared
// from the first spot in the application error queue.
@ -68,23 +64,8 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
}
}
private onDismissed = () => {
const currentError = this.state.error
if (currentError !== null) {
this.setState({ error: null, disabled: true })
// Give some time for the dialog to nicely transition
// out before we clear the error and, potentially, deal
// with the next error in the queue.
window.setTimeout(() => {
this.props.onClearError(currentError)
}, dialogTransitionTimeout.exit)
}
}
private showPreferencesDialog = () => {
this.onDismissed()
this.props.onDismissed()
//This is a hacky solution to resolve multiple dialog windows
//being open at the same time.
@ -95,7 +76,7 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
private onRetryAction = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
this.onDismissed()
this.props.onDismissed()
const { error } = this.state
@ -128,36 +109,6 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
return 'Error'
}
private renderDialog() {
const error = this.state.error
if (!error) {
return null
}
return (
<Dialog
id="app-error"
type="error"
key="error"
title={this.getTitle(error)}
dismissable={false}
onSubmit={this.onDismissed}
onDismissed={this.onDismissed}
disabled={this.state.disabled}
className={
isRawGitError(this.state.error) ? 'raw-git-error' : undefined
}
>
<DialogContent onRef={this.onDialogContentRef}>
{this.renderErrorMessage(error)}
{this.renderContentAfterErrorMessage(error)}
</DialogContent>
{this.renderFooter(error)}
</Dialog>
)
}
private renderContentAfterErrorMessage(error: Error) {
if (!isErrorWithMetaData(error)) {
return undefined
@ -207,7 +158,7 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
private onCloseButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
this.onDismissed()
this.props.onDismissed()
}
private renderFooter(error: Error) {
@ -257,16 +208,32 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
}
public render() {
const dialogContent = this.renderDialog()
const error = this.state.error
if (!error) {
return null
}
return (
<TransitionGroup>
{dialogContent && (
<CSSTransition classNames="modal" timeout={dialogTransitionTimeout}>
{dialogContent}
</CSSTransition>
)}
</TransitionGroup>
<Dialog
id="app-error"
type="error"
key="error"
title={this.getTitle(error)}
dismissable={false}
onSubmit={this.props.onDismissed}
onDismissed={this.props.onDismissed}
disabled={this.state.disabled}
className={
isRawGitError(this.state.error) ? 'raw-git-error' : undefined
}
>
<DialogContent onRef={this.onDialogContentRef}>
{this.renderErrorMessage(error)}
{this.renderContentAfterErrorMessage(error)}
</DialogContent>
{this.renderFooter(error)}
</Dialog>
)
}
}

View file

@ -1,5 +1,4 @@
import * as React from 'react'
import * as crypto from 'crypto'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import {
IAppState,
@ -14,6 +13,7 @@ import { assertNever } from '../lib/fatal-error'
import { shell } from '../lib/app-shell'
import { updateStore, UpdateStatus } from './lib/update-store'
import { RetryAction } from '../models/retry-actions'
import { FetchType } from '../models/fetch'
import { shouldRenderApplicationMenu } from './lib/features'
import { matchExistingRepository } from '../lib/repository-matching'
import { getDotComAPIEndpoint } from '../lib/api'
@ -93,7 +93,10 @@ import { RepositoryStateCache } from '../lib/stores/repository-state-cache'
import { PopupType, Popup } from '../models/popup'
import { OversizedFiles } from './changes/oversized-files-warning'
import { PushNeedsPullWarning } from './push-needs-pull'
import { isCurrentBranchForcePush } from '../lib/rebase'
import {
ForcePushBranchState,
getCurrentBranchForcePushState,
} from '../lib/rebase'
import { Banner, BannerType } from '../models/banner'
import { StashAndSwitchBranch } from './stash-changes/stash-and-switch-branch-dialog'
import { OverwriteStash } from './stash-changes/overwrite-stashed-changes-dialog'
@ -158,6 +161,12 @@ import { SSHUserPassword } from './ssh/ssh-user-password'
import { showContextualMenu } from '../lib/menu-item'
import { UnreachableCommitsDialog } from './history/unreachable-commits-dialog'
import { OpenPullRequestDialog } from './open-pull-request/open-pull-request-dialog'
import { sendNonFatalException } from '../lib/helpers/non-fatal-exception'
import { createCommitURL } from '../lib/commit-url'
import { uuid } from '../lib/uuid'
import { InstallingUpdate } from './installing-update/installing-update'
import { enableStackedPopups } from '../lib/feature-flag'
import { DialogStackContext } from './dialog'
const MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 60
@ -214,7 +223,7 @@ export class App extends React.Component<IAppProps, IAppState> {
* modal dialog such as the preferences, or an error dialog.
*/
private get isShowingModal() {
return this.state.currentPopup !== null || this.state.errors.length > 0
return this.state.currentPopup !== null
}
/**
@ -222,8 +231,8 @@ export class App extends React.Component<IAppProps, IAppState> {
* passed popupType, so it can be used in render() without creating
* multiple instances when the component gets re-rendered.
*/
private getOnPopupDismissedFn = memoizeOne((popupType: PopupType) => {
return () => this.onPopupDismissed(popupType)
private getOnPopupDismissedFn = memoizeOne((popupId: string) => {
return () => this.onPopupDismissed(popupId)
})
public constructor(props: IAppProps) {
@ -278,7 +287,14 @@ export class App extends React.Component<IAppProps, IAppState> {
updateStore.onError(error => {
log.error(`Error checking for updates`, error)
this.props.dispatcher.postError(error)
// It is possible to obtain an error with no message. This was found to be
// the case on a windows instance where there was not space on the hard
// drive to download the installer. In this case, we want to override the
// error message so the user is not given a blank dialog.
const hasErrorMsg = error.message.trim().length > 0
this.props.dispatcher.postError(
hasErrorMsg ? error : new Error('Checking for updates failed.')
)
})
ipcRenderer.on('launch-timing-stats', (_, stats) => {
@ -346,7 +362,7 @@ export class App extends React.Component<IAppProps, IAppState> {
private onMenuEvent(name: MenuEvent): any {
// Don't react to menu events when an error dialog is shown.
if (this.state.errors.length) {
if (name !== 'show-app-error' && this.state.errorCount > 1) {
return
}
@ -357,6 +373,8 @@ export class App extends React.Component<IAppProps, IAppState> {
return this.push({ forceWithLease: true })
case 'pull':
return this.pull()
case 'fetch':
return this.fetch()
case 'show-changes':
return this.showChanges()
case 'show-history':
@ -421,7 +439,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return this.goToCommitMessage()
case 'open-pull-request':
return this.openPullRequest()
case 'start-pull-request':
case 'preview-pull-request':
return this.startPullRequest()
case 'install-cli':
return this.props.dispatcher.installCLI()
@ -443,6 +461,10 @@ export class App extends React.Component<IAppProps, IAppState> {
return this.findText()
case 'pull-request-check-run-failed':
return this.testPullRequestCheckRunFailed()
case 'show-app-error':
return this.props.dispatcher.postError(
new Error('Test Error - to use default error handler' + uuid())
)
default:
return assertNever(name, `Unknown menu event name: ${name}`)
}
@ -948,6 +970,15 @@ export class App extends React.Component<IAppProps, IAppState> {
this.props.dispatcher.pull(state.repository)
}
private async fetch() {
const state = this.state.selectedState
if (state == null || state.type !== SelectionType.Repository) {
return
}
this.props.dispatcher.fetch(state.repository, FetchType.UserInitiatedTask)
}
private showStashedChanges() {
const state = this.state.selectedState
if (state == null || state.type !== SelectionType.Repository) {
@ -1352,8 +1383,8 @@ export class App extends React.Component<IAppProps, IAppState> {
)
}
private onPopupDismissed = (popupType: PopupType) => {
return this.props.dispatcher.closePopup(popupType)
private onPopupDismissed = (popupId: string) => {
return this.props.dispatcher.closePopupById(popupId)
}
private onContinueWithUntrustedCertificate = (
@ -1368,19 +1399,44 @@ export class App extends React.Component<IAppProps, IAppState> {
private onUpdateAvailableDismissed = () =>
this.props.dispatcher.setUpdateBannerVisibility(false)
private currentPopupContent(): JSX.Element | null {
// Hide any dialogs while we're displaying an error
if (this.state.errors.length) {
private allPopupContent(): JSX.Element | null {
let { allPopups } = this.state
if (!enableStackedPopups() && this.state.currentPopup !== null) {
allPopups = [this.state.currentPopup]
}
if (allPopups.length === 0) {
return null
}
const popup = this.state.currentPopup
return (
<>
{allPopups.map(popup => {
const isTopMost = this.state.currentPopup?.id === popup.id
return (
<DialogStackContext.Provider key={popup.id} value={{ isTopMost }}>
{this.popupContent(popup, isTopMost)}
</DialogStackContext.Provider>
)
})}
</>
)
}
if (!popup) {
private popupContent(popup: Popup, isTopMost: boolean): JSX.Element | null {
if (popup.id === undefined) {
// Should not be possible... but if it does we want to know about it.
sendNonFatalException(
'PopupNoId',
new Error(
`Attempted to open a popup of type '${popup.type}' without an Id`
)
)
return null
}
const onPopupDismissedFn = this.getOnPopupDismissedFn(popup.type)
const onPopupDismissedFn = this.getOnPopupDismissedFn(popup.id)
switch (popup.type) {
case PopupType.RenameBranch:
@ -1481,6 +1537,7 @@ export class App extends React.Component<IAppProps, IAppState> {
confirmDiscardChangesPermanently={
this.state.askForConfirmationOnDiscardChangesPermanently
}
confirmDiscardStash={this.state.askForConfirmationOnDiscardStash}
confirmForcePush={this.state.askForConfirmationOnForcePush}
confirmUndoCommit={this.state.askForConfirmationOnUndoCommit}
uncommittedChangesStrategy={this.state.uncommittedChangesStrategy}
@ -1542,6 +1599,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
initialPath={popup.path}
isTopMost={isTopMost}
/>
)
case PopupType.CloneRepository:
@ -1557,6 +1615,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onTabSelected={this.onCloneRepositoriesTabSelected}
apiRepositories={this.state.apiRepositories}
onRefreshRepositories={this.onRefreshRepositories}
isTopMost={isTopMost}
/>
)
case PopupType.CreateBranch: {
@ -1617,6 +1676,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onCheckForNonStaggeredUpdates={this.onCheckForNonStaggeredUpdates}
onShowAcknowledgements={this.showAcknowledgements}
onShowTermsAndConditions={this.showTermsAndConditions}
isTopMost={isTopMost}
/>
)
case PopupType.PublishRepository:
@ -1849,6 +1909,9 @@ export class App extends React.Component<IAppProps, IAppState> {
<ConfirmDiscardStashDialog
key="confirm-discard-stash-dialog"
dispatcher={this.props.dispatcher}
askForConfirmationOnDiscardStash={
this.state.askForConfirmationOnDiscardStash
}
repository={repository}
stash={stash}
onDismissed={onPopupDismissedFn}
@ -2243,36 +2306,73 @@ export class App extends React.Component<IAppProps, IAppState> {
)
}
case PopupType.StartPullRequest: {
const { selectedState } = this.state
if (
selectedState == null ||
selectedState.type !== SelectionType.Repository
) {
// Intentionally chose to get the current pull request state on
// rerender because state variables such as file selection change
// via the dispatcher.
const pullRequestState = this.getPullRequestState()
if (pullRequestState === null) {
// This shouldn't happen..
sendNonFatalException(
'FailedToStartPullRequest',
new Error(
'Failed to start pull request because pull request state was null'
)
)
return null
}
const { state: repoState, repository } = selectedState
const { pullRequestState, branchesState } = repoState
if (
pullRequestState === null ||
branchesState.tip.kind !== TipState.Valid
) {
return null
}
const { allBranches, recentBranches, defaultBranch, tip } =
branchesState
const currentBranch = tip.branch
const { pullRequestFilesListWidth, hideWhitespaceInPullRequestDiff } =
this.state
const {
prBaseBranches,
currentBranch,
defaultBranch,
imageDiffType,
externalEditorLabel,
nonLocalCommitSHA,
prRecentBaseBranches,
repository,
showSideBySideDiff,
currentBranchHasPullRequest,
} = popup
return (
<OpenPullRequestDialog
key="open-pull-request"
allBranches={allBranches}
prBaseBranches={prBaseBranches}
currentBranch={currentBranch}
defaultBranch={defaultBranch}
dispatcher={this.props.dispatcher}
fileListWidth={pullRequestFilesListWidth}
hideWhitespaceInDiff={hideWhitespaceInPullRequestDiff}
imageDiffType={imageDiffType}
nonLocalCommitSHA={nonLocalCommitSHA}
pullRequestState={pullRequestState}
recentBranches={recentBranches}
prRecentBaseBranches={prRecentBaseBranches}
repository={repository}
externalEditorLabel={externalEditorLabel}
showSideBySideDiff={showSideBySideDiff}
currentBranchHasPullRequest={currentBranchHasPullRequest}
onDismissed={onPopupDismissedFn}
/>
)
}
case PopupType.Error: {
return (
<AppError
error={popup.error}
onDismissed={onPopupDismissedFn}
onShowPopup={this.showPopup}
onRetryAction={this.onRetryAction}
/>
)
}
case PopupType.InstallingUpdate: {
return (
<InstallingUpdate
key="installing-update"
dispatcher={this.props.dispatcher}
onDismissed={onPopupDismissedFn}
/>
)
@ -2282,6 +2382,18 @@ export class App extends React.Component<IAppProps, IAppState> {
}
}
private getPullRequestState() {
const { selectedState } = this.state
if (
selectedState == null ||
selectedState.type !== SelectionType.Repository
) {
return null
}
return selectedState.state.pullRequestState
}
private getWarnForcePushDialogOnBegin(
onBegin: () => void,
onPopupDismissedFn: () => void
@ -2375,8 +2487,8 @@ export class App extends React.Component<IAppProps, IAppState> {
this.props.dispatcher.showPopup({ type: PopupType.TermsAndConditions })
}
private renderPopup() {
const popupContent = this.currentPopupContent()
private renderPopups() {
const popupContent = this.allPopupContent()
return (
<TransitionGroup>
@ -2430,8 +2542,6 @@ export class App extends React.Component<IAppProps, IAppState> {
return <FullScreenInfo windowState={this.state.windowState} />
}
private clearError = (error: Error) => this.props.dispatcher.clearError(error)
private onConfirmDiscardChangesChanged = (value: boolean) => {
this.props.dispatcher.setConfirmDiscardChangesSetting(value)
}
@ -2440,17 +2550,6 @@ export class App extends React.Component<IAppProps, IAppState> {
this.props.dispatcher.setConfirmDiscardChangesPermanentlySetting(value)
}
private renderAppError() {
return (
<AppError
errors={this.state.errors}
onClearError={this.clearError}
onShowPopup={this.showPopup}
onRetryAction={this.onRetryAction}
/>
)
}
private onRetryAction = (retryAction: RetryAction) => {
this.props.dispatcher.performRetry(retryAction)
}
@ -2477,8 +2576,7 @@ export class App extends React.Component<IAppProps, IAppState> {
{this.renderToolbar()}
{this.renderBanner()}
{this.renderRepository()}
{this.renderPopup()}
{this.renderAppError()}
{this.renderPopups()}
{this.renderDragElement()}
</div>
)
@ -2702,7 +2800,9 @@ export class App extends React.Component<IAppProps, IAppState> {
remoteName = tip.branch.upstreamRemoteName
}
const isForcePush = isCurrentBranchForcePush(branchesState, aheadBehind)
const isForcePush =
getCurrentBranchForcePushState(branchesState, aheadBehind) ===
ForcePushBranchState.Recommended
return (
<PushPullButton
@ -2955,6 +3055,9 @@ export class App extends React.Component<IAppProps, IAppState> {
askForConfirmationOnDiscardChanges={
state.askForConfirmationOnDiscardChanges
}
askForConfirmationOnDiscardStash={
state.askForConfirmationOnDiscardStash
}
accounts={state.accounts}
externalEditorLabel={externalEditorLabel}
resolvedExternalEditor={state.resolvedExternalEditor}
@ -2967,6 +3070,7 @@ export class App extends React.Component<IAppProps, IAppState> {
aheadBehindStore={this.props.aheadBehindStore}
commitSpellcheckEnabled={this.state.commitSpellcheckEnabled}
onCherryPick={this.startCherryPickWithoutBranch}
pullRequestSuggestedNextAction={state.pullRequestSuggestedNextAction}
/>
)
} else if (selectedState.type === SelectionType.CloningRepository) {
@ -3049,22 +3153,17 @@ export class App extends React.Component<IAppProps, IAppState> {
return
}
const baseURL = repository.gitHubRepository.htmlURL
const commitURL = createCommitURL(
repository.gitHubRepository,
SHA,
filePath
)
let fileSuffix = ''
if (filePath != null) {
const fileHash = crypto
.createHash('sha256')
.update(filePath)
.digest('hex')
fileSuffix = '#diff-' + fileHash
if (commitURL === null) {
return
}
if (baseURL) {
this.props.dispatcher.openInBrowser(
`${baseURL}/commit/${SHA}${fileSuffix}`
)
}
this.props.dispatcher.openInBrowser(commitURL)
}
private onBranchDeleted = (repository: Repository) => {

View file

@ -110,6 +110,9 @@ interface IBranchListProps {
/** Called to render content before/above the branches filter and list. */
readonly renderPreList?: () => JSX.Element | null
/** Optional: No branches message */
readonly noBranchesMessage?: string | JSX.Element
}
interface IBranchListState {
@ -249,6 +252,7 @@ export class BranchList extends React.Component<
<NoBranches
onCreateNewBranch={this.onCreateNewBranch}
canCreateNewBranch={this.props.canCreateNewBranch}
noBranchesMessage={this.props.noBranchesMessage}
/>
)
}

View file

@ -1,22 +1,15 @@
import * as React from 'react'
import { IMatches } from '../../lib/fuzzy-find'
import { Branch } from '../../models/branch'
import { Button } from '../lib/button'
import { ClickSource } from '../lib/list'
import { Popover } from '../lib/popover'
import { Ref } from '../lib/ref'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { PopoverDropdown } from '../lib/popover-dropdown'
import { BranchList } from './branch-list'
import { renderDefaultBranch } from './branch-renderer'
import { IBranchListItem } from './group-branches'
const defaultDropdownListHeight = 300
const maxDropdownListHeight = 500
interface IBranchSelectProps {
/** The initially selected branch. */
readonly branch: Branch
readonly branch: Branch | null
/**
* See IBranchesState.defaultBranch
@ -40,13 +33,14 @@ interface IBranchSelectProps {
/** Called when the user changes the selected branch. */
readonly onChange?: (branch: Branch) => void
/** Optional: No branches message */
readonly noBranchesMessage?: string | JSX.Element
}
interface IBranchSelectState {
readonly showBranchDropdown: boolean
readonly selectedBranch: Branch | null
readonly filterText: string
readonly dropdownListHeight: number
}
/**
@ -56,67 +50,25 @@ export class BranchSelect extends React.Component<
IBranchSelectProps,
IBranchSelectState
> {
private invokeButtonRef: HTMLButtonElement | null = null
private popoverRef = React.createRef<PopoverDropdown>()
public constructor(props: IBranchSelectProps) {
super(props)
this.state = {
showBranchDropdown: false,
selectedBranch: props.branch,
filterText: '',
dropdownListHeight: defaultDropdownListHeight,
}
}
public componentDidMount() {
this.calculateDropdownListHeight()
}
public componentDidUpdate() {
this.calculateDropdownListHeight()
}
private calculateDropdownListHeight = () => {
if (this.invokeButtonRef === null) {
return
}
const windowHeight = window.innerHeight
const bottomOfButton = this.invokeButtonRef.getBoundingClientRect().bottom
const listHeaderHeight = 75
const calcMaxHeight = Math.round(
windowHeight - bottomOfButton - listHeaderHeight
)
const dropdownListHeight =
calcMaxHeight > maxDropdownListHeight
? maxDropdownListHeight
: calcMaxHeight
if (dropdownListHeight !== this.state.dropdownListHeight) {
this.setState({ dropdownListHeight })
}
}
private onInvokeButtonRef = (buttonRef: HTMLButtonElement | null) => {
this.invokeButtonRef = buttonRef
}
private toggleBranchDropdown = () => {
this.setState({ showBranchDropdown: !this.state.showBranchDropdown })
}
private closeBranchDropdown = () => {
this.setState({ showBranchDropdown: false })
}
private renderBranch = (item: IBranchListItem, matches: IMatches) => {
return renderDefaultBranch(item, matches, this.props.currentBranch)
}
private onItemClick = (branch: Branch, source: ClickSource) => {
source.event.preventDefault()
this.setState({ showBranchDropdown: false, selectedBranch: branch })
this.popoverRef.current?.closePopover()
this.setState({ selectedBranch: branch })
this.props.onChange?.(branch)
}
@ -124,67 +76,38 @@ export class BranchSelect extends React.Component<
this.setState({ filterText })
}
public renderBranchDropdown() {
if (!this.state.showBranchDropdown) {
return
}
const { currentBranch, defaultBranch, recentBranches, allBranches } =
this.props
const { filterText, selectedBranch, dropdownListHeight } = this.state
return (
<Popover
className="branch-select-dropdown"
onClickOutside={this.closeBranchDropdown}
>
<div className="branch-select-dropdown-header">
Choose a base branch
<button
className="close"
onClick={this.closeBranchDropdown}
aria-label="close"
>
<Octicon symbol={OcticonSymbol.x} />
</button>
</div>
<div
className="branch-select-dropdown-list"
style={{ height: `${dropdownListHeight}px` }}
>
<BranchList
allBranches={allBranches}
currentBranch={currentBranch}
defaultBranch={defaultBranch}
recentBranches={recentBranches}
filterText={filterText}
onFilterTextChanged={this.onFilterTextChanged}
selectedBranch={selectedBranch}
canCreateNewBranch={false}
renderBranch={this.renderBranch}
onItemClick={this.onItemClick}
/>
</div>
</Popover>
)
}
public render() {
const {
currentBranch,
defaultBranch,
recentBranches,
allBranches,
noBranchesMessage,
} = this.props
const { filterText, selectedBranch } = this.state
return (
<div className="branch-select-component">
<Button
onClick={this.toggleBranchDropdown}
onButtonRef={this.onInvokeButtonRef}
>
<Ref>
<span className="base-label">base:</span>
{this.state.selectedBranch?.name}
<Octicon symbol={OcticonSymbol.triangleDown} />
</Ref>
</Button>
{this.renderBranchDropdown()}
</div>
<PopoverDropdown
contentTitle="Choose a base branch"
buttonContent={selectedBranch?.name ?? ''}
label="base:"
ref={this.popoverRef}
>
<BranchList
allBranches={allBranches}
currentBranch={currentBranch}
defaultBranch={defaultBranch}
recentBranches={recentBranches}
filterText={filterText}
onFilterTextChanged={this.onFilterTextChanged}
selectedBranch={selectedBranch}
canCreateNewBranch={false}
renderBranch={this.renderBranch}
onItemClick={this.onItemClick}
noBranchesMessage={noBranchesMessage}
/>
</PopoverDropdown>
)
}
}

View file

@ -12,6 +12,8 @@ interface INoBranchesProps {
readonly onCreateNewBranch: () => void
/** True to display the UI elements for creating a new branch, false to hide them */
readonly canCreateNewBranch: boolean
/** Optional: No branches message */
readonly noBranchesMessage?: string | JSX.Element
}
export class NoBranches extends React.Component<INoBranchesProps> {
@ -43,7 +45,11 @@ export class NoBranches extends React.Component<INoBranchesProps> {
)
}
return <div className="no-branches">Sorry, I can't find that branch</div>
return (
<div className="no-branches">
{this.props.noBranchesMessage ?? "Sorry, I can't find that branch"}
</div>
)
}
private renderShortcut() {

View file

@ -6,7 +6,6 @@ import { Octicon, iconForStatus } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { mapStatus } from '../../lib/status'
import { DiffOptions } from '../diff/diff-options'
import { RepositorySectionTab } from '../../lib/app-state'
interface IChangedFileDetailsProps {
readonly path: string
@ -61,7 +60,7 @@ export class ChangedFileDetails extends React.Component<
return (
<DiffOptions
sourceTab={RepositorySectionTab.Changes}
isInteractiveDiff={true}
onHideWhitespaceChangesChanged={
this.props.onHideWhitespaceInDiffChanged
}

View file

@ -13,12 +13,21 @@ 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 {
ForcePushBranchState,
getCurrentBranchForcePushState,
} from '../../lib/rebase'
import { StashedChangesLoadStates } from '../../models/stash-entry'
import { Dispatcher } from '../dispatcher'
import { SuggestedActionGroup } from '../suggested-actions'
import { PreferencesTab } from '../../models/preferences'
import { PopupType } from '../../models/popup'
import {
DropdownSuggestedAction,
IDropdownSuggestedActionOption,
} from '../suggested-actions/dropdown-suggested-action'
import { PullRequestSuggestedNextAction } from '../../models/pull-request'
import { enableStartingPullRequests } from '../../lib/feature-flag'
function formatMenuItemLabel(text: string) {
if (__WIN32__ || __LINUX__) {
@ -68,6 +77,9 @@ interface INoChangesProps {
* opening the repository in an external editor.
*/
readonly isExternalEditorAvailable: boolean
/** The user's preference of pull request suggested next action to use **/
readonly pullRequestSuggestedNextAction?: PullRequestSuggestedNextAction
}
/**
@ -341,7 +353,9 @@ export class NoChanges extends React.Component<
return this.renderPublishBranchAction(tip)
}
const isForcePush = isCurrentBranchForcePush(branchesState, aheadBehind)
const isForcePush =
getCurrentBranchForcePushState(branchesState, aheadBehind) ===
ForcePushBranchState.Recommended
if (isForcePush) {
// do not render an action currently after the rebase has completed, as
// the default behaviour is currently to pull in changes from the tracking
@ -632,12 +646,16 @@ export class NoChanges extends React.Component<
)
}
private renderCreatePullRequestAction(tip: IValidBranch) {
const itemId: MenuIDs = 'create-pull-request'
const menuItem = this.getMenuItemInfo(itemId)
private onPullRequestSuggestedActionChanged = (
action: PullRequestSuggestedNextAction
) => {
this.props.dispatcher.setPullRequestSuggestedNextAction(action)
}
if (menuItem === undefined) {
log.error(`Could not find matching menu item for ${itemId}`)
private renderCreatePullRequestAction(tip: IValidBranch) {
const createMenuItem = this.getMenuItemInfo('create-pull-request')
if (createMenuItem === undefined) {
log.error(`Could not find matching menu item for 'create-pull-request'`)
return null
}
@ -652,17 +670,69 @@ export class NoChanges extends React.Component<
const title = `Create a Pull Request from your current branch`
const buttonText = `Create Pull Request`
if (!enableStartingPullRequests()) {
return (
<MenuBackedSuggestedAction
key="create-pr-action"
title={title}
menuItemId={'create-pull-request'}
description={description}
buttonText={buttonText}
discoverabilityContent={this.renderDiscoverabilityElements(
createMenuItem
)}
type="primary"
disabled={!createMenuItem.enabled}
onClick={this.onCreatePullRequestClicked}
/>
)
}
const previewPullMenuItem = this.getMenuItemInfo('preview-pull-request')
if (previewPullMenuItem === undefined) {
log.error(`Could not find matching menu item for 'preview-pull-request'`)
return null
}
const createPullRequestAction: IDropdownSuggestedActionOption<PullRequestSuggestedNextAction> =
{
title,
label: buttonText,
description,
value: PullRequestSuggestedNextAction.CreatePullRequest,
menuItemId: 'create-pull-request',
discoverabilityContent:
this.renderDiscoverabilityElements(createMenuItem),
disabled: !createMenuItem.enabled,
onClick: this.onCreatePullRequestClicked,
}
const previewPullRequestAction: IDropdownSuggestedActionOption<PullRequestSuggestedNextAction> =
{
title: `Preview the Pull Request from your current branch`,
label: 'Preview Pull Request',
description: (
<>
The current branch (<Ref>{tip.branch.name}</Ref>) is already
published to GitHub. Preview the changes this pull request will have
before proposing your changes.
</>
),
value: PullRequestSuggestedNextAction.PreviewPullRequest,
menuItemId: 'preview-pull-request',
discoverabilityContent:
this.renderDiscoverabilityElements(previewPullMenuItem),
disabled: !previewPullMenuItem.enabled,
}
return (
<MenuBackedSuggestedAction
key="create-pr-action"
title={title}
menuItemId={itemId}
description={description}
buttonText={buttonText}
discoverabilityContent={this.renderDiscoverabilityElements(menuItem)}
type="primary"
disabled={!menuItem.enabled}
onClick={this.onCreatePullRequestClicked}
<DropdownSuggestedAction
key="pull-request-action"
className="pull-request-action"
suggestedActions={[previewPullRequestAction, createPullRequestAction]}
selectedActionValue={this.props.pullRequestSuggestedNextAction}
onSuggestedActionChanged={this.onPullRequestSuggestedActionChanged}
/>
)
}

View file

@ -3,6 +3,7 @@ import * as React from 'react'
import { Dispatcher } from '../dispatcher'
import { getDefaultDir, setDefaultDir } from '../lib/default-dir'
import { Account } from '../../models/account'
import { FoldoutType } from '../../lib/app-state'
import {
IRepositoryIdentifier,
parseRepositoryIdentifier,
@ -23,6 +24,7 @@ import { ClickSource } from '../lib/list'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { showOpenDialog, showSaveDialog } from '../main-process-proxy'
import { readdir } from 'fs/promises'
import { isTopMostDialog } from '../dialog/is-top-most'
interface ICloneRepositoryProps {
readonly dispatcher: Dispatcher
@ -64,6 +66,9 @@ interface ICloneRepositoryProps {
* available for cloning.
*/
readonly onRefreshRepositories: (account: Account) => void
/** Whether the dialog is the top most in the dialog stack */
readonly isTopMost: boolean
}
interface ICloneRepositoryState {
@ -147,6 +152,16 @@ export class CloneRepository extends React.Component<
ICloneRepositoryProps,
ICloneRepositoryState
> {
private checkIsTopMostDialog = isTopMostDialog(
() => {
this.validatePath()
window.addEventListener('focus', this.onWindowFocus)
},
() => {
window.removeEventListener('focus', this.onWindowFocus)
}
)
public constructor(props: ICloneRepositoryProps) {
super(props)
@ -191,6 +206,8 @@ export class CloneRepository extends React.Component<
if (prevProps.initialURL !== this.props.initialURL) {
this.updateUrl(this.props.initialURL || '')
}
this.checkIsTopMostDialog(this.props.isTopMost)
}
public componentDidMount() {
@ -199,7 +216,11 @@ export class CloneRepository extends React.Component<
this.updateUrl(initialURL)
}
window.addEventListener('focus', this.onWindowFocus)
this.checkIsTopMostDialog(this.props.isTopMost)
}
public componentWillUnmount(): void {
this.checkIsTopMostDialog(false)
}
private initializePath = async () => {
@ -223,10 +244,6 @@ export class CloneRepository extends React.Component<
this.updateUrl(selectedTabState.url)
}
public componentWillUnmount() {
window.removeEventListener('focus', this.onWindowFocus)
}
public render() {
const { error } = this.getSelectedTabState()
return (
@ -728,6 +745,7 @@ export class CloneRepository extends React.Component<
const { url, defaultBranch } = cloneInfo
this.props.dispatcher.closeFoldout(FoldoutType.Repository)
try {
this.cloneImpl(url.trim(), path, defaultBranch)
} catch (e) {

View file

@ -3,6 +3,32 @@ import classNames from 'classnames'
import { DialogHeader } from './header'
import { createUniqueId, releaseUniqueId } from '../lib/id-pool'
import { getTitleBarHeight } from '../window/title-bar'
import { isTopMostDialog } from './is-top-most'
export interface IDialogStackContext {
/** Whether or not this dialog is the top most one in the stack to be
* interacted with by the user. This will also determine if event listeners
* will be active or not. */
isTopMost: boolean
}
/**
* The DialogStackContext is used to communicate between the `Dialog` and the
* `App` information that is mostly unique to the `Dialog` component such as
* whether it is at the top of the popup stack. Some, but not the vast majority,
* custom popup components in between may also utilize this to enable and
* disable event listeners in response to changes in whether it is the top most
* popup.
*
* NB *** React.Context is not the preferred method of passing data to child
* components for this code base. We are choosing to use it here as implementing
* prop drilling would be extremely tedious and would lead to adding `Dialog`
* props on 60+ components that would not otherwise use them. ***
*
*/
export const DialogStackContext = React.createContext<IDialogStackContext>({
isTopMost: false,
})
/**
* The time (in milliseconds) from when the dialog is mounted
@ -138,6 +164,18 @@ interface IDialogState {
* out of the dialog without first dismissing it.
*/
export class Dialog extends React.Component<IDialogProps, IDialogState> {
public static contextType = DialogStackContext
public declare context: React.ContextType<typeof DialogStackContext>
private checkIsTopMostDialog = isTopMostDialog(
() => {
this.onDialogIsTopMost()
},
() => {
this.onDialogIsNotTopMost()
}
)
private dialogElement: HTMLDialogElement | null = null
private dismissGraceTimeoutId?: number
@ -214,6 +252,13 @@ export class Dialog extends React.Component<IDialogProps, IDialogState> {
private onDismissGraceTimer = () => {
this.setState({ isAppearing: false })
this.dialogElement?.dispatchEvent(
new CustomEvent('dialog-appeared', {
bubbles: true,
cancelable: false,
})
)
}
private isDismissable() {
@ -242,11 +287,17 @@ export class Dialog extends React.Component<IDialogProps, IDialogState> {
}
public componentDidMount() {
if (!this.dialogElement) {
this.checkIsTopMostDialog(this.context.isTopMost)
}
protected onDialogIsTopMost() {
if (this.dialogElement == null) {
return
}
this.dialogElement.showModal()
if (!this.dialogElement.open) {
this.dialogElement.showModal()
}
// Provide an event that components can subscribe to in order to perform
// tasks such as re-layout after the dialog is visible
@ -268,6 +319,20 @@ export class Dialog extends React.Component<IDialogProps, IDialogState> {
window.addEventListener('resize', this.scheduleResizeEvent)
}
protected onDialogIsNotTopMost() {
if (this.dialogElement !== null && this.dialogElement.open) {
this.dialogElement?.close()
}
this.clearDismissGraceTimeout()
window.removeEventListener('focus', this.onWindowFocus)
document.removeEventListener('mouseup', this.onDocumentMouseUp)
this.resizeObserver.disconnect()
window.removeEventListener('resize', this.scheduleResizeEvent)
}
/**
* Attempts to move keyboard focus to the first _suitable_ child of the
* dialog.
@ -418,23 +483,19 @@ export class Dialog extends React.Component<IDialogProps, IDialogState> {
}
public componentWillUnmount() {
this.clearDismissGraceTimeout()
if (this.state.titleId) {
releaseUniqueId(this.state.titleId)
}
window.removeEventListener('focus', this.onWindowFocus)
document.removeEventListener('mouseup', this.onDocumentMouseUp)
this.resizeObserver.disconnect()
window.removeEventListener('resize', this.scheduleResizeEvent)
this.checkIsTopMostDialog(false)
}
public componentDidUpdate() {
public componentDidUpdate(prevProps: IDialogProps) {
if (!this.props.title && this.state.titleId) {
this.updateTitleId()
}
this.checkIsTopMostDialog(this.context.isTopMost)
}
private onDialogCancel = (e: Event | React.SyntheticEvent) => {

View file

@ -0,0 +1,17 @@
import memoizeOne from 'memoize-one'
/** This method is a memoizedOne for a consistent means of handling when the
* isTopMost property of the `DialogStackContext` changes in the various popups
* that consume it. */
export function isTopMostDialog(
onDialogIsTopMost: () => void,
onDialogIsNotTopMost: () => void
) {
return memoizeOne((isTopMost: boolean) => {
if (isTopMost) {
onDialogIsTopMost()
} else {
onDialogIsNotTopMost()
}
})
}

View file

@ -164,6 +164,14 @@ export class CodeMirrorHost extends React.Component<ICodeMirrorHostProps, {}> {
CodeMirrorHost.updateDoc(this.codeMirror, this.props.value)
this.resizeObserver.observe(this.codeMirror.getWrapperElement())
if (this.wrapper !== null && this.wrapper.closest('dialog') !== null) {
document.addEventListener('dialog-appeared', this.onDialogAppeared)
}
}
private onDialogAppeared = () => {
requestAnimationFrame(this.onResized)
}
private onSwapDoc = (cm: Editor, oldDoc: Doc) => {
@ -199,6 +207,7 @@ export class CodeMirrorHost extends React.Component<ICodeMirrorHostProps, {}> {
}
this.resizeObserver.disconnect()
document.removeEventListener('dialog-show', this.onDialogAppeared)
}
public componentDidUpdate(prevProps: ICodeMirrorHostProps) {

View file

@ -4,14 +4,13 @@ import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { RadioButton } from '../lib/radio-button'
import { Popover, PopoverCaretPosition } from '../lib/popover'
import { RepositorySectionTab } from '../../lib/app-state'
interface IDiffOptionsProps {
readonly sourceTab: RepositorySectionTab
readonly isInteractiveDiff: boolean
readonly hideWhitespaceChanges: boolean
readonly onHideWhitespaceChangesChanged: (
hideWhitespaceChanges: boolean
) => Promise<void>
) => void
readonly showSideBySideDiff: boolean
readonly onShowSideBySideDiffChanged: (showSideBySideDiff: boolean) => void
@ -144,7 +143,7 @@ export class DiffOptions extends React.Component<
__DARWIN__ ? 'Hide Whitespace Changes' : 'Hide whitespace changes'
}
/>
{this.props.sourceTab === RepositorySectionTab.Changes && (
{this.props.isInteractiveDiff && (
<p className="secondary-text">
Interacting with individual lines or hunks will be disabled while
hiding whitespace.

View file

@ -363,6 +363,16 @@ export class SideBySideDiffRow extends React.Component<
throw new Error(`Unexpected expansion type ${expansionType}`)
}
/**
* This method returns the width of a line gutter in pixels. For unified diffs
* the gutter contains the line number of both before and after sides, whereas
* for side-by-side diffs the gutter contains the line number of only one side.
*/
private get lineGutterWidth() {
const { showSideBySideDiff, lineNumberWidth } = this.props
return showSideBySideDiff ? lineNumberWidth : lineNumberWidth * 2
}
private renderHunkExpansionHandle(
hunkIndex: number,
expansionType: DiffHunkExpansionType
@ -372,7 +382,7 @@ export class SideBySideDiffRow extends React.Component<
<div
className="hunk-expansion-handle"
onContextMenu={this.props.onContextMenuExpandHunk}
style={{ width: this.props.lineNumberWidth }}
style={{ width: this.lineGutterWidth }}
>
<span></span>
</div>
@ -389,7 +399,7 @@ export class SideBySideDiffRow extends React.Component<
<div
className="hunk-expansion-handle selectable hoverable"
onClick={elementInfo.handler}
style={{ width: this.props.lineNumberWidth }}
style={{ width: this.lineGutterWidth }}
onContextMenu={this.props.onContextMenuExpandHunk}
>
<TooltippedContent
@ -426,6 +436,12 @@ export class SideBySideDiffRow extends React.Component<
return null
}
// In unified mode, the hunk handle left position depends on the line gutter
// width.
const style: React.CSSProperties = this.props.showSideBySideDiff
? {}
: { left: this.lineGutterWidth }
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
@ -434,6 +450,7 @@ export class SideBySideDiffRow extends React.Component<
onMouseLeave={this.onMouseLeaveHunk}
onClick={this.onClickHunk}
onContextMenu={this.onContextMenuHunk}
style={style}
></div>
)
}
@ -452,10 +469,7 @@ export class SideBySideDiffRow extends React.Component<
) {
if (!this.props.isDiffSelectable || isSelected === undefined) {
return (
<div
className="line-number"
style={{ width: this.props.lineNumberWidth }}
>
<div className="line-number" style={{ width: this.lineGutterWidth }}>
{lineNumbers.map((lineNumber, index) => (
<span key={index}>{lineNumber}</span>
))}
@ -470,7 +484,7 @@ export class SideBySideDiffRow extends React.Component<
'line-selected': isSelected,
hover: this.props.isHunkHovered,
})}
style={{ width: this.props.lineNumberWidth }}
style={{ width: this.lineGutterWidth }}
onMouseDown={this.onMouseDownLineNumber}
onContextMenu={this.onContextMenuLineNumber}
>
@ -493,7 +507,7 @@ export class SideBySideDiffRow extends React.Component<
const style: React.CSSProperties = {
[column === DiffColumn.Before ? 'marginRight' : 'marginLeft']:
this.props.lineNumberWidth + 10,
this.lineGutterWidth + 10,
marginTop: -10,
}

View file

@ -256,7 +256,7 @@ export class SideBySideDiff extends React.Component<
: [DiffLineType.Add, DiffLineType.Context]
: [DiffLineType.Add, DiffLineType.Delete, DiffLineType.Context]
const contents = this.props.diff.hunks
const contents = this.state.diff.hunks
.flatMap(h =>
h.lines
.filter(line => lineTypes.includes(line.type))

View file

@ -68,7 +68,10 @@ import { FetchType } from '../../models/fetch'
import { GitHubRepository } from '../../models/github-repository'
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
import { Popup, PopupType } from '../../models/popup'
import { PullRequest } from '../../models/pull-request'
import {
PullRequest,
PullRequestSuggestedNextAction,
} from '../../models/pull-request'
import {
Repository,
RepositoryWithGitHubRepository,
@ -384,6 +387,13 @@ export class Dispatcher {
return this.appStore._closePopup(popupType)
}
/**
* Close the popup with given id.
*/
public closePopupById(popupId: string) {
return this.appStore._closePopupById(popupId)
}
/** Show the foldout. This will close any current popup. */
public showFoldout(foldout: Foldout): Promise<void> {
return this.appStore._showFoldout(foldout)
@ -765,11 +775,6 @@ export class Dispatcher {
return this.appStore._pushError(error)
}
/** Clear the given error. */
public clearError(error: Error): Promise<void> {
return this.appStore._clearError(error)
}
/**
* Clone a missing repository to the previous path, and update it's
* state in the repository list if the clone completes without error.
@ -2122,6 +2127,19 @@ export class Dispatcher {
)
}
/** Change the hide whitespace in pull request diff setting */
public onHideWhitespaceInPullRequestDiffChanged(
hideWhitespaceInDiff: boolean,
repository: Repository,
file: CommittedFileChange | null = null
) {
this.appStore._setHideWhitespaceInPullRequestDiff(
hideWhitespaceInDiff,
repository,
file
)
}
/** Change the side by side diff setting */
public onShowSideBySideDiffChanged(showSideBySideDiff: boolean) {
return this.appStore._setShowSideBySideDiff(showSideBySideDiff)
@ -2175,8 +2193,11 @@ export class Dispatcher {
* openCreatePullRequestInBrowser method which immediately opens the
* create pull request page without showing a dialog.
*/
public createPullRequest(repository: Repository): Promise<void> {
return this.appStore._createPullRequest(repository)
public createPullRequest(
repository: Repository,
baseBranch?: Branch
): Promise<void> {
return this.appStore._createPullRequest(repository, baseBranch)
}
/**
@ -2338,6 +2359,10 @@ export class Dispatcher {
await this.appStore._loadStatus(repository)
}
public setConfirmDiscardStashSetting(value: boolean) {
return this.appStore._setConfirmDiscardStashSetting(value)
}
public setConfirmForcePushSetting(value: boolean) {
return this.appStore._setConfirmForcePushSetting(value)
}
@ -2435,6 +2460,10 @@ export class Dispatcher {
return this.statsStore.recordCreatePullRequest()
}
public recordCreatePullRequestFromPreview() {
return this.statsStore.recordCreatePullRequestFromPreview()
}
public recordWelcomeWizardInitiated() {
return this.statsStore.recordWelcomeWizardInitiated()
}
@ -3963,9 +3992,63 @@ export class Dispatcher {
public startPullRequest(repository: Repository) {
this.appStore._startPullRequest(repository)
}
this.showPopup({
type: PopupType.StartPullRequest,
})
/**
* Change the selected changed file of the current pull request state.
*/
public changePullRequestFileSelection(
repository: Repository,
file: CommittedFileChange
): Promise<void> {
return this.appStore._changePullRequestFileSelection(repository, file)
}
/**
* Set the width of the file list column in the pull request files changed
*/
public setPullRequestFileListWidth(width: number): Promise<void> {
return this.appStore._setPullRequestFileListWidth(width)
}
/**
* Reset the width of the file list column in the pull request files changed
*/
public resetPullRequestFileListWidth(): Promise<void> {
return this.appStore._resetPullRequestFileListWidth()
}
public updatePullRequestBaseBranch(repository: Repository, branch: Branch) {
this.appStore._updatePullRequestBaseBranch(repository, branch)
}
/**
* Attempts to quit the app if it's not updating, unless requested to quit
* even if it is updating.
*
* @param evenIfUpdating Whether to quit even if the app is updating.
*/
public quitApp(evenIfUpdating: boolean) {
this.appStore._quitApp(evenIfUpdating)
}
/**
* Cancels quitting the app. This could be needed if, on macOS, the user tries
* to quit the app while an update is in progress, but then after being
* informed about the issues that could cause they decided to not close the
* app yet.
*/
public cancelQuittingApp() {
this.appStore._cancelQuittingApp()
}
/**
* Sets the user's preference for which pull request suggested next action to
* use
*/
public setPullRequestSuggestedNextAction(
value: PullRequestSuggestedNextAction
) {
return this.appStore._setPullRequestSuggestedNextAction(value)
}
}

View file

@ -4,7 +4,7 @@ import { Button } from './lib/button'
import { Octicon } from './octicons'
import * as OcticonSymbol from './octicons/octicons.generated'
export interface IDropdownSelectButtonOption {
export interface IDropdownSelectButtonOption<T extends string> {
/** The select option header label. */
readonly label?: string | JSX.Element
@ -12,15 +12,15 @@ export interface IDropdownSelectButtonOption {
readonly description?: string | JSX.Element
/** The select option's value */
readonly value?: string
readonly value: T
}
interface IDropdownSelectButtonProps {
interface IDropdownSelectButtonProps<T extends string> {
/** The selection button options */
readonly options: ReadonlyArray<IDropdownSelectButtonOption>
readonly options: ReadonlyArray<IDropdownSelectButtonOption<T>>
/** The selection option value */
readonly selectedValue?: string
readonly selectedValue?: T
/** Whether or not the button is enabled */
readonly disabled?: boolean
@ -30,22 +30,22 @@ interface IDropdownSelectButtonProps {
/** Callback for when the button selection changes*/
readonly onSelectChange?: (
selectedOption: IDropdownSelectButtonOption
selectedOption: IDropdownSelectButtonOption<T>
) => void
/** Callback for when button is selected option button is clicked */
readonly onSubmit?: (
event: React.MouseEvent<HTMLButtonElement>,
selectedOption: IDropdownSelectButtonOption
selectedOption: IDropdownSelectButtonOption<T>
) => void
}
interface IDropdownSelectButtonState {
interface IDropdownSelectButtonState<T extends string> {
/** Whether the options are rendered */
readonly showButtonOptions: boolean
/** The currently selected option */
readonly selectedOption: IDropdownSelectButtonOption | null
readonly selectedOption: IDropdownSelectButtonOption<T> | null
/**
* The adjusting position of the options popover. This is calculated based
@ -54,14 +54,16 @@ interface IDropdownSelectButtonState {
readonly optionsPositionBottom?: string
}
export class DropdownSelectButton extends React.Component<
IDropdownSelectButtonProps,
IDropdownSelectButtonState
export class DropdownSelectButton<
T extends string = string
> extends React.Component<
IDropdownSelectButtonProps<T>,
IDropdownSelectButtonState<T>
> {
private invokeButtonRef: HTMLButtonElement | null = null
private optionsContainerRef: HTMLDivElement | null = null
public constructor(props: IDropdownSelectButtonProps) {
public constructor(props: IDropdownSelectButtonProps<T>) {
super(props)
this.state = {
@ -89,9 +91,64 @@ export class DropdownSelectButton extends React.Component<
}
}
public componentDidMount() {
window.addEventListener('keydown', this.onKeyDown)
}
public componentWillUnmount() {
window.removeEventListener('keydown', this.onKeyDown)
}
private onKeyDown = (event: KeyboardEvent) => {
const { key } = event
if (this.state.showButtonOptions && key === 'Escape') {
this.setState({ showButtonOptions: false })
return
}
if (
!this.state.showButtonOptions ||
!['ArrowUp', 'ArrowDown'].includes(key)
) {
return
}
const buttons = this.optionsContainerRef?.querySelectorAll(
'.dropdown-select-button-options .button-component'
)
if (buttons === undefined) {
return
}
const foundCurrentIndex = [...buttons].findIndex(b =>
b.className.includes('focus')
)
let focusedOptionIndex = -1
if (foundCurrentIndex !== -1) {
if (key === 'ArrowUp') {
focusedOptionIndex =
foundCurrentIndex !== 0
? foundCurrentIndex - 1
: this.props.options.length - 1
} else {
focusedOptionIndex =
foundCurrentIndex !== this.props.options.length - 1
? foundCurrentIndex + 1
: 0
}
} else {
focusedOptionIndex = key === 'ArrowUp' ? this.props.options.length - 1 : 0
}
const button = buttons?.item(focusedOptionIndex) as HTMLButtonElement
button?.focus()
}
private getSelectedOption(
selectedValue: string | undefined
): IDropdownSelectButtonOption | null {
selectedValue: T | undefined
): IDropdownSelectButtonOption<T> | null {
const { options } = this.props
if (options.length === 0) {
return null
@ -104,8 +161,10 @@ export class DropdownSelectButton extends React.Component<
return selectedOption
}
private onSelectionChange = (selectedOption: IDropdownSelectButtonOption) => {
return (_event: React.MouseEvent<HTMLLIElement, MouseEvent>) => {
private onSelectionChange = (
selectedOption: IDropdownSelectButtonOption<T>
) => {
return (_event?: React.MouseEvent<HTMLElement, MouseEvent>) => {
this.setState({ selectedOption, showButtonOptions: false })
const { onSelectChange } = this.props
@ -127,7 +186,7 @@ export class DropdownSelectButton extends React.Component<
this.optionsContainerRef = ref
}
private renderSelectedIcon(option: IDropdownSelectButtonOption) {
private renderSelectedIcon(option: IDropdownSelectButtonOption<T>) {
const { selectedOption } = this.state
if (selectedOption === null || option.value !== selectedOption.value) {
return
@ -141,6 +200,16 @@ export class DropdownSelectButton extends React.Component<
)
}
private renderOption = (o: IDropdownSelectButtonOption<T>) => {
return (
<Button key={o.value} onClick={this.onSelectionChange(o)}>
{this.renderSelectedIcon(o)}
<div className="option-title">{o.label}</div>
<div className="option-description">{o.description}</div>
</Button>
)
}
private renderSplitButtonOptions() {
if (!this.state.showButtonOptions) {
return
@ -150,22 +219,14 @@ export class DropdownSelectButton extends React.Component<
const { optionsPositionBottom: bottom } = this.state
const openClass = bottom !== undefined ? 'open-top' : 'open-bottom'
const classes = classNames('dropdown-select-button-options', openClass)
return (
<div
className={classes}
style={{ bottom }}
ref={this.onOptionsContainerRef}
>
<ul>
{options.map(o => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<li key={o.value} onClick={this.onSelectionChange(o)}>
{this.renderSelectedIcon(o)}
<div className="option-title">{o.label}</div>
<div className="option-description">{o.description}</div>
</li>
))}
</ul>
{options.map(o => this.renderOption(o))}
</div>
)
}
@ -199,23 +260,25 @@ export class DropdownSelectButton extends React.Component<
// method.
return (
<div className={containerClasses}>
<Button
className="invoke-button"
disabled={disabled}
type="submit"
tooltip={this.props.tooltip}
onButtonRef={this.onInvokeButtonRef}
onClick={this.onSubmit}
>
{selectedOption.label}
</Button>
<Button
className={dropdownClasses}
onClick={this.openSplitButtonDropdown}
type="button"
>
<Octicon symbol={OcticonSymbol.triangleDown} />
</Button>
<div className="dropdown-button-wrappers">
<Button
className="invoke-button"
disabled={disabled}
type="submit"
tooltip={this.props.tooltip}
onButtonRef={this.onInvokeButtonRef}
onClick={this.onSubmit}
>
{selectedOption.label}
</Button>
<Button
className={dropdownClasses}
onClick={this.openSplitButtonDropdown}
type="button"
>
<Octicon symbol={OcticonSymbol.triangleDown} />
</Button>
</div>
{this.renderSplitButtonOptions()}
</div>
)

View file

@ -219,6 +219,10 @@ export class CommitListItem extends React.PureComponent<
clipboard.writeText(this.props.commit.sha)
}
private onCopyTags = () => {
clipboard.writeText(this.props.commit.tags.join(' '))
}
private onViewOnGitHub = () => {
if (this.props.onViewCommitOnGitHub) {
this.props.onViewCommitOnGitHub(this.props.commit.sha)
@ -341,7 +345,10 @@ export class CommitListItem extends React.PureComponent<
deleteTagsMenuItem
)
}
const darwinTagsLabel =
this.props.commit.tags.length > 1 ? 'Copy Tags' : 'Copy Tag'
const windowTagsLabel =
this.props.commit.tags.length > 1 ? 'Copy tags' : 'Copy tag'
items.push(
{
label: __DARWIN__ ? 'Cherry-pick Commit…' : 'Cherry-pick commit…',
@ -353,6 +360,11 @@ export class CommitListItem extends React.PureComponent<
label: 'Copy SHA',
action: this.onCopySHA,
},
{
label: __DARWIN__ ? darwinTagsLabel : windowTagsLabel,
action: this.onCopyTags,
enabled: this.props.commit.tags.length > 0,
},
{
label: viewOnGitHubLabel,
action: this.onViewOnGitHub,

View file

@ -12,7 +12,6 @@ import { CommitAttribution } from '../lib/commit-attribution'
import { Tokenizer, TokenResult } from '../../lib/text-token-parser'
import { wrapRichTextCommitMessage } from '../../lib/wrap-rich-text-commit-message'
import { DiffOptions } from '../diff/diff-options'
import { RepositorySectionTab } from '../../lib/app-state'
import { IChangesetData } from '../../lib/git'
import { TooltippedContent } from '../lib/tooltipped-content'
import { AppFileStatusKind } from '../../models/status'
@ -431,7 +430,10 @@ export class CommitSummary extends React.Component<
aria-label="SHA"
>
<Octicon symbol={OcticonSymbol.gitCommit} />
<TooltippedCommitSHA className="sha" commit={selectedCommits[0]} />
<TooltippedCommitSHA
className="selectable"
commit={selectedCommits[0]}
/>
</li>
)
}
@ -505,7 +507,7 @@ export class CommitSummary extends React.Component<
title="Diff Options"
>
<DiffOptions
sourceTab={RepositorySectionTab.History}
isInteractiveDiff={false}
hideWhitespaceChanges={this.props.hideWhitespaceInDiff}
onHideWhitespaceChangesChanged={
this.props.onHideWhitespaceInDiffChanged
@ -642,7 +644,7 @@ export class CommitSummary extends React.Component<
<Octicon symbol={OcticonSymbol.tag} />
</span>
<span className="tags">{tags.join(', ')}</span>
<span className="tags selectable">{tags.join(', ')}</span>
</li>
)
}

View file

@ -96,25 +96,24 @@ export class MergeCallToActionWithConflicts extends React.Component<
})
}
private onOperationChange = (option: IDropdownSelectButtonOption) => {
const value = option.value as MultiCommitOperationKind
this.setState({ selectedOperation: value })
if (value === MultiCommitOperationKind.Rebase) {
private onOperationChange = (
option: IDropdownSelectButtonOption<MultiCommitOperationKind>
) => {
this.setState({ selectedOperation: option.value })
if (option.value === MultiCommitOperationKind.Rebase) {
this.updateRebasePreview(this.props.comparisonBranch)
}
}
private onOperationInvoked = async (
event: React.MouseEvent<HTMLButtonElement>,
selectedOption: IDropdownSelectButtonOption
selectedOption: IDropdownSelectButtonOption<MultiCommitOperationKind>
) => {
event.preventDefault()
const { dispatcher, repository } = this.props
await this.dispatchOperation(
selectedOption.value as MultiCommitOperationKind
)
await this.dispatchOperation(selectedOption.value)
dispatcher.executeCompare(repository, {
kind: HistoryTabMode.History,

View file

@ -168,8 +168,8 @@ const sendErrorWithContext = (
extra.windowZoomFactor = `${currentState.windowZoomFactor}`
}
if (currentState.errors.length > 0) {
extra.activeAppErrors = `${currentState.errors.length}`
if (currentState.errorCount > 0) {
extra.activeAppErrors = `${currentState.errorCount}`
}
extra.repositoryCount = `${currentState.repositories.length}`

View file

@ -0,0 +1,96 @@
import * as React from 'react'
import { Row } from '../lib/row'
import {
Dialog,
DialogContent,
OkCancelButtonGroup,
DialogFooter,
} from '../dialog'
import { updateStore, IUpdateState, UpdateStatus } from '../lib/update-store'
import { Disposable } from 'event-kit'
import { DialogHeader } from '../dialog/header'
import { Dispatcher } from '../dispatcher'
interface IInstallingUpdateProps {
/**
* Event triggered when the dialog is dismissed by the user in the
* ways described in the Dialog component's dismissable prop.
*/
readonly onDismissed: () => void
readonly dispatcher: Dispatcher
}
/**
* A dialog that presents information about the
* running application such as name and version.
*/
export class InstallingUpdate extends React.Component<IInstallingUpdateProps> {
private updateStoreEventHandle: Disposable | null = null
private onUpdateStateChanged = (updateState: IUpdateState) => {
// If the update is not being downloaded (`UpdateStatus.UpdateAvailable`),
// i.e. if it's already downloaded or not available, close the window.
if (updateState.status !== UpdateStatus.UpdateAvailable) {
this.props.dispatcher.quitApp(false)
}
}
public componentDidMount() {
this.updateStoreEventHandle = updateStore.onDidChange(
this.onUpdateStateChanged
)
// Manually update the state to ensure we're in sync with the store
this.onUpdateStateChanged(updateStore.state)
}
public componentWillUnmount() {
if (this.updateStoreEventHandle) {
this.updateStoreEventHandle.dispose()
this.updateStoreEventHandle = null
}
// This will ensure the app doesn't try to quit after the update is
// installed once the dialog is closed (explicitly or implicitly, by
// opening another dialog on top of this one).
this.props.dispatcher.cancelQuittingApp()
}
private onQuitAnywayButtonClicked = () => {
this.props.dispatcher.quitApp(true)
}
public render() {
return (
<Dialog
id="installing-update"
onSubmit={this.props.onDismissed}
dismissable={false}
type="warning"
>
<DialogHeader
title={__DARWIN__ ? 'Installing Update…' : 'Installing update…'}
loading={true}
dismissable={true}
onDismissed={this.props.onDismissed}
/>
<DialogContent>
<Row className="updating-message">
Do not close GitHub Desktop while the update is in progress. Closing
now may break your installation.
</Row>
</DialogContent>
<DialogFooter>
<OkCancelButtonGroup
okButtonText={__DARWIN__ ? 'Quit Anyway' : 'Quit anyway'}
onOkButtonClick={this.onQuitAnywayButtonClicked}
onCancelButtonClick={this.props.onDismissed}
destructive={true}
/>
</DialogFooter>
</Dialog>
)
}
}

View file

@ -51,10 +51,28 @@ export class Draggable extends React.Component<IDraggableProps> {
this.dragElement = document.getElementById('dragElement')
}
/**
* A user can drag a commit if they are holding down the left mouse button or
* event.button === 0
*
* Exceptions:
* - macOS allow emulating a right click by holding down the ctrl and left
* mouse button.
* - user can not drag during a shift click
*
* All other MouseEvent.button values are:
* 2: right button/pen barrel button
* 1: middle button
* X1, X2: mouse back/forward buttons
* 5: pen eraser
* -1: No button changed
*
* Ref: https://www.w3.org/TR/pointerevents/#the-button-property
*
* */
private canDragCommit(event: React.MouseEvent<HTMLDivElement>): boolean {
// right clicks or shift clicks
const isSpecialClick =
event.button === 2 ||
event.button !== 0 ||
(__DARWIN__ && event.button === 0 && event.ctrlKey) ||
event.shiftKey

View file

@ -24,7 +24,7 @@ interface IListRowProps {
readonly selected?: boolean
/** callback to fire when the DOM element is created */
readonly onRef?: (element: HTMLDivElement | null) => void
readonly onRowRef?: (index: number, element: HTMLDivElement | null) => void
/** callback to fire when the row receives a mouseover event */
readonly onRowMouseOver: (index: number, e: React.MouseEvent<any>) => void
@ -41,6 +41,18 @@ interface IListRowProps {
/** callback to fire when the row receives a keyboard event */
readonly onRowKeyDown: (index: number, e: React.KeyboardEvent<any>) => void
/** called when the row (or any of its descendants) receives focus */
readonly onRowFocus?: (
index: number,
e: React.FocusEvent<HTMLDivElement>
) => void
/** called when the row (and all of its descendants) loses focus */
readonly onRowBlur?: (
index: number,
e: React.FocusEvent<HTMLDivElement>
) => void
/**
* Whether or not this list row is going to be selectable either through
* keyboard navigation, pointer clicks, or both. This is used to determine
@ -53,6 +65,10 @@ interface IListRowProps {
}
export class ListRow extends React.Component<IListRowProps, {}> {
private onRef = (elem: HTMLDivElement | null) => {
this.props.onRowRef?.(this.props.rowIndex, elem)
}
private onRowMouseOver = (e: React.MouseEvent<HTMLDivElement>) => {
this.props.onRowMouseOver(this.props.rowIndex, e)
}
@ -73,6 +89,14 @@ export class ListRow extends React.Component<IListRowProps, {}> {
this.props.onRowKeyDown(this.props.rowIndex, e)
}
private onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
this.props.onRowFocus?.(this.props.rowIndex, e)
}
private onBlur = (e: React.FocusEvent<HTMLDivElement>) => {
this.props.onRowBlur?.(this.props.rowIndex, e)
}
public render() {
const selected = this.props.selected
const className = classNames(
@ -102,13 +126,15 @@ export class ListRow extends React.Component<IListRowProps, {}> {
role={role}
className={className}
tabIndex={this.props.tabIndex}
ref={this.props.onRef}
ref={this.onRef}
onMouseOver={this.onRowMouseOver}
onMouseDown={this.onRowMouseDown}
onMouseUp={this.onRowMouseUp}
onClick={this.onRowClick}
onKeyDown={this.onRowKeyDown}
style={style}
onFocus={this.onFocus}
onBlur={this.onBlur}
>
{this.props.children}
</div>

View file

@ -269,6 +269,8 @@ export class List extends React.Component<IListProps, IListState> {
private fakeScroll: HTMLDivElement | null = null
private focusRow = -1
private readonly rowRefs = new Map<number, HTMLDivElement>()
/**
* The style prop for our child Grid. We keep this here in order
* to not create a new object on each render and thus forcing
@ -567,6 +569,15 @@ export class List extends React.Component<IListProps, IListState> {
}
}
private onFocusWithinChanged = (focusWithin: boolean) => {
// So the grid lost focus (we manually focus the grid if the focused list
// item is unmounted) so we mustn't attempt to refocus the previously
// focused list item if it scrolls back into view.
if (!focusWithin) {
this.focusRow = -1
}
}
private toggleSelection = (event: React.KeyboardEvent<any>) => {
this.props.selectedRows.forEach(row => {
if (!this.props.onRowClick) {
@ -586,19 +597,26 @@ export class List extends React.Component<IListProps, IListState> {
})
}
private onRowFocus = (index: number, e: React.FocusEvent<HTMLDivElement>) => {
this.focusRow = index
}
private onRowBlur = (index: number, e: React.FocusEvent<HTMLDivElement>) => {
if (this.focusRow === index) {
this.focusRow = -1
}
}
private onRowMouseOver = (row: number, event: React.MouseEvent<any>) => {
if (this.props.selectOnHover && this.canSelectRow(row)) {
if (
this.props.selectedRows.includes(row) &&
this.props.onSelectionChanged
) {
this.props.onSelectionChanged([row], { kind: 'hover', event })
if (!this.props.selectedRows.includes(row)) {
this.props.onSelectionChanged?.([row], { kind: 'hover', event })
// By calling scrollRowToVisible we ensure that hovering over a partially
// visible item at the top or bottom of the list scrolls it into view but
// more importantly `scrollRowToVisible` automatically manages focus so
// using it here allows us to piggy-back on its focus-preserving magic
// even though we could theoretically live without scrolling
this.scrollRowToVisible(row)
this.scrollRowToVisible(row, this.props.focusOnHover !== false)
}
}
}
@ -720,10 +738,14 @@ export class List extends React.Component<IListProps, IListState> {
this.scrollRowToVisible(row)
}
private scrollRowToVisible(row: number) {
private scrollRowToVisible(row: number, moveFocus = true) {
if (this.grid !== null) {
this.grid.scrollToCell({ rowIndex: row, columnIndex: 0 })
this.focusRow = row
if (moveFocus) {
this.focusRow = row
this.rowRefs.get(row)?.focus({ preventScroll: true })
}
}
}
@ -804,12 +826,27 @@ export class List extends React.Component<IListProps, IListState> {
}
}
private onFocusedItemRef = (element: HTMLDivElement | null) => {
if (this.props.focusOnHover !== false && element !== null) {
element.focus()
private onRowRef = (rowIndex: number, element: HTMLDivElement | null) => {
if (element === null) {
this.rowRefs.delete(rowIndex)
} else {
this.rowRefs.set(rowIndex, element)
}
this.focusRow = -1
if (rowIndex === this.focusRow) {
// The currently focused row is going being unmounted so we'll move focus
// programmatically to the grid so that keyboard navigation still works
if (element === null) {
const grid = ReactDOM.findDOMNode(this.grid)
if (grid instanceof HTMLElement) {
grid.focus({ preventScroll: true })
}
} else {
// A previously focused row is being mounted again, we'll move focus
// back to it
element.focus({ preventScroll: true })
}
}
}
private getCustomRowClassNames = (rowIndex: number) => {
@ -836,17 +873,12 @@ export class List extends React.Component<IListProps, IListState> {
const selected = this.props.selectedRows.indexOf(rowIndex) !== -1
const customClasses = this.getCustomRowClassNames(rowIndex)
const focused = rowIndex === this.focusRow
// An unselectable row shouldn't be focusable
let tabIndex: number | undefined = undefined
if (selectable) {
tabIndex = selected && this.props.selectedRows[0] === rowIndex ? 0 : -1
}
// We only need to keep a reference to the focused element
const ref = focused ? this.onFocusedItemRef : undefined
const row = this.props.rowRenderer(rowIndex)
const element =
@ -870,7 +902,7 @@ export class List extends React.Component<IListProps, IListState> {
<ListRow
key={params.key}
id={id}
onRef={ref}
onRowRef={this.onRowRef}
rowCount={this.props.rowCount}
rowIndex={rowIndex}
selected={selected}
@ -880,6 +912,8 @@ export class List extends React.Component<IListProps, IListState> {
onRowMouseDown={this.onRowMouseDown}
onRowMouseUp={this.onRowMouseUp}
onRowMouseOver={this.onRowMouseOver}
onRowFocus={this.onRowFocus}
onRowBlur={this.onRowBlur}
style={params.style}
tabIndex={tabIndex}
children={element}
@ -978,6 +1012,7 @@ export class List extends React.Component<IListProps, IListState> {
<FocusContainer
className="list-focus-container"
onKeyDown={this.onFocusContainerKeyDown}
onFocusWithinChanged={this.onFocusWithinChanged}
>
<Grid
aria-label={''}

View file

@ -0,0 +1,133 @@
import * as React from 'react'
import { Button } from './button'
import { Popover, PopoverCaretPosition } from './popover'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import classNames from 'classnames'
const defaultPopoverContentHeight = 300
const maxPopoverContentHeight = 500
interface IPopoverDropdownProps {
readonly className?: string
readonly contentTitle: string
readonly buttonContent: JSX.Element | string
readonly label: string
}
interface IPopoverDropdownState {
readonly showPopover: boolean
readonly popoverContentHeight: number
}
/**
* A dropdown component for displaying a dropdown button that opens
* a popover to display contents relative to the button content.
*/
export class PopoverDropdown extends React.Component<
IPopoverDropdownProps,
IPopoverDropdownState
> {
private invokeButtonRef: HTMLButtonElement | null = null
public constructor(props: IPopoverDropdownProps) {
super(props)
this.state = {
showPopover: false,
popoverContentHeight: defaultPopoverContentHeight,
}
}
public componentDidMount() {
this.calculateDropdownListHeight()
}
public componentDidUpdate() {
this.calculateDropdownListHeight()
}
private calculateDropdownListHeight = () => {
if (this.invokeButtonRef === null) {
return
}
const windowHeight = window.innerHeight
const bottomOfButton = this.invokeButtonRef.getBoundingClientRect().bottom
const listHeaderHeight = 75
const calcMaxHeight = Math.round(
windowHeight - bottomOfButton - listHeaderHeight
)
const popoverContentHeight =
calcMaxHeight > maxPopoverContentHeight
? maxPopoverContentHeight
: calcMaxHeight
if (popoverContentHeight !== this.state.popoverContentHeight) {
this.setState({ popoverContentHeight })
}
}
private onInvokeButtonRef = (buttonRef: HTMLButtonElement | null) => {
this.invokeButtonRef = buttonRef
}
private togglePopover = () => {
this.setState({ showPopover: !this.state.showPopover })
}
public closePopover = () => {
this.setState({ showPopover: false })
}
private renderPopover() {
if (!this.state.showPopover) {
return
}
const { contentTitle } = this.props
const { popoverContentHeight } = this.state
const contentStyle = { height: `${popoverContentHeight}px` }
return (
<Popover
className="popover-dropdown-popover"
caretPosition={PopoverCaretPosition.TopLeft}
onClickOutside={this.closePopover}
>
<div className="popover-dropdown-header">
{contentTitle}
<button
className="close"
onClick={this.closePopover}
aria-label="close"
>
<Octicon symbol={OcticonSymbol.x} />
</button>
</div>
<div className="popover-dropdown-content" style={contentStyle}>
{this.props.children}
</div>
</Popover>
)
}
public render() {
const { className, buttonContent, label } = this.props
const cn = classNames('popover-dropdown-component', className)
return (
<div className={cn}>
<Button
onClick={this.togglePopover}
onButtonRef={this.onInvokeButtonRef}
>
<span className="popover-dropdown-button-label">{label}</span>
<span className="button-content">{buttonContent}</span>
<Octicon symbol={OcticonSymbol.triangleDown} />
</Button>
{this.renderPopover()}
</div>
)
}
}

View file

@ -299,7 +299,13 @@ export class Tooltip<T extends TooltipTarget> extends React.Component<
}
}
private updateMouseRect = (event: MouseEvent) => {
this.mouseRect = new DOMRect(event.clientX - 10, event.clientY - 10, 20, 20)
}
private onTargetMouseEnter = (event: MouseEvent) => {
this.updateMouseRect(event)
this.mouseOverTarget = true
this.cancelHideTooltip()
if (!this.state.show) {
@ -308,7 +314,7 @@ export class Tooltip<T extends TooltipTarget> extends React.Component<
}
private onTargetMouseMove = (event: MouseEvent) => {
this.mouseRect = new DOMRect(event.clientX - 10, event.clientY - 10, 20, 20)
this.updateMouseRect(event)
}
private onTargetMouseDown = (event: MouseEvent) => {

View file

@ -7,7 +7,9 @@ import { RebasePreview } from '../../models/rebase'
import { Repository } from '../../models/repository'
import { IDropdownSelectButtonOption } from '../dropdown-select-button'
export function getMergeOptions(): ReadonlyArray<IDropdownSelectButtonOption> {
export function getMergeOptions(): ReadonlyArray<
IDropdownSelectButtonOption<MultiCommitOperationKind>
> {
return [
{
label: 'Create a merge commit',

View file

@ -164,6 +164,9 @@ export const checkForUpdates = invokeProxy('check-for-updates', 1)
/** Tell the main process to quit the app and install updates */
export const quitAndInstallUpdate = sendProxy('quit-and-install-updates', 0)
/** Tell the main process to quit the app */
export const quitApp = sendProxy('quit-app', 0)
/** Subscribes to auto updater error events originating from the main process */
export function onAutoUpdaterError(
errorHandler: (evt: Electron.IpcRendererEvent, error: Error) => void
@ -200,6 +203,12 @@ export function onNativeThemeUpdated(eventHandler: () => void) {
ipcRenderer.on('native-theme-updated', eventHandler)
}
/** Subscribes to the "show installing update dialog" event originating from the
* main process */
export function onShowInstallingUpdate(eventHandler: () => void) {
ipcRenderer.on('show-installing-update', eventHandler)
}
/** Tell the main process to set the native theme source */
export const setNativeThemeSource = sendProxy('set-native-theme-source', 1)
@ -273,6 +282,29 @@ export function sendWillQuitSync() {
ipcRenderer.sendSync('will-quit')
}
/**
* Tell the main process that we're going to quit, even if the app is installing
* an update. This means it should allow the window to close.
*
* This event is sent synchronously to avoid any races with subsequent calls
* that would tell the app to quit.
*/
export function sendWillQuitEvenIfUpdatingSync() {
// eslint-disable-next-line no-sync
ipcRenderer.sendSync('will-quit-even-if-updating')
}
/**
* Tell the main process that the user cancelled quitting.
*
* This event is sent synchronously to avoid any races with subsequent calls
* that would tell the app to quit.
*/
export function sendCancelQuittingSync() {
// eslint-disable-next-line no-sync
ipcRenderer.sendSync('cancel-quitting')
}
/**
* Tell the main process to move the application to the application folder
*/

View file

@ -161,11 +161,12 @@ export abstract class BaseChooseBranchDialog extends React.Component<
return currentBranch === defaultBranch ? null : defaultBranch
}
private onOperationChange = (option: IDropdownSelectButtonOption) => {
const value = option.value as MultiCommitOperationKind
private onOperationChange = (
option: IDropdownSelectButtonOption<MultiCommitOperationKind>
) => {
const { dispatcher, repository } = this.props
const { selectedBranch } = this.state
switch (value) {
switch (option.value) {
case MultiCommitOperationKind.Merge:
dispatcher.startMergeBranchOperation(repository, false, selectedBranch)
break
@ -179,7 +180,7 @@ export abstract class BaseChooseBranchDialog extends React.Component<
case MultiCommitOperationKind.Reorder:
break
default:
assertNever(value, `Unknown operation value: ${option.value}`)
assertNever(option.value, `Unknown operation value: ${option.value}`)
}
}

View file

@ -1,10 +1,18 @@
import * as React from 'react'
import { IPullRequestState } from '../../lib/app-state'
import { IConstrainedValue, IPullRequestState } from '../../lib/app-state'
import { getDotComAPIEndpoint } from '../../lib/api'
import { Branch } from '../../models/branch'
import { ImageDiffType } from '../../models/diff'
import { Repository } from '../../models/repository'
import { DialogFooter, OkCancelButtonGroup, Dialog } from '../dialog'
import { Dispatcher } from '../dispatcher'
import { Ref } from '../lib/ref'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { OpenPullRequestDialogHeader } from './open-pull-request-header'
import { PullRequestFilesChanged } from './pull-request-files-changed'
import { PullRequestMergeStatus } from './pull-request-merge-status'
import { ComputedAction } from '../../models/computed-action'
interface IOpenPullRequestDialogProps {
readonly repository: Repository
@ -26,14 +34,42 @@ interface IOpenPullRequestDialogProps {
readonly defaultBranch: Branch | null
/**
* See IBranchesState.allBranches
* Branches in the repo with the repo's default remote
*
* We only want branches that are also on dotcom such that, when we ask a user
* to create a pull request, the base branch also exists on dotcom.
*/
readonly allBranches: ReadonlyArray<Branch>
readonly prBaseBranches: ReadonlyArray<Branch>
/**
* See IBranchesState.recentBranches
* Recent branches with the repo's default remote
*
* We only want branches that are also on dotcom such that, when we ask a user
* to create a pull request, the base branch also exists on dotcom.
*/
readonly recentBranches: ReadonlyArray<Branch>
readonly prRecentBaseBranches: ReadonlyArray<Branch>
/** Whether we should display side by side diffs. */
readonly showSideBySideDiff: boolean
/** Whether we should hide whitespace in diff. */
readonly hideWhitespaceInDiff: boolean
/** The type of image diff to display. */
readonly imageDiffType: ImageDiffType
/** Label for selected external editor */
readonly externalEditorLabel?: string
/** Width to use for the files list pane in the files changed view */
readonly fileListWidth: IConstrainedValue
/** If the latest commit of the pull request is not local, this will contain
* it's SHA */
readonly nonLocalCommitSHA: string | null
/** Whether the current branch already has a pull request*/
readonly currentBranchHasPullRequest: boolean
/** Called to dismiss the dialog */
readonly onDismissed: () => void
@ -42,9 +78,24 @@ interface IOpenPullRequestDialogProps {
/** The component for start a pull request. */
export class OpenPullRequestDialog extends React.Component<IOpenPullRequestDialogProps> {
private onCreatePullRequest = () => {
this.props.dispatcher.createPullRequest(this.props.repository)
// TODO: create pr from dialog pr stat?
this.props.dispatcher.recordCreatePullRequest()
const { currentBranchHasPullRequest, dispatcher, repository, onDismissed } =
this.props
if (currentBranchHasPullRequest) {
dispatcher.showPullRequest(repository)
} else {
const { baseBranch } = this.props.pullRequestState
dispatcher.createPullRequest(repository, baseBranch ?? undefined)
dispatcher.recordCreatePullRequest()
dispatcher.recordCreatePullRequestFromPreview()
}
onDismissed()
}
private onBranchChange = (branch: Branch) => {
const { repository } = this.props
this.props.dispatcher.updatePullRequestBaseBranch(repository, branch)
}
private renderHeader() {
@ -52,8 +103,8 @@ export class OpenPullRequestDialog extends React.Component<IOpenPullRequestDialo
currentBranch,
pullRequestState,
defaultBranch,
allBranches,
recentBranches,
prBaseBranches,
prRecentBaseBranches,
} = this.props
const { baseBranch, commitSHAs } = pullRequestState
return (
@ -61,25 +112,152 @@ export class OpenPullRequestDialog extends React.Component<IOpenPullRequestDialo
baseBranch={baseBranch}
currentBranch={currentBranch}
defaultBranch={defaultBranch}
allBranches={allBranches}
recentBranches={recentBranches}
prBaseBranches={prBaseBranches}
prRecentBaseBranches={prRecentBaseBranches}
commitCount={commitSHAs?.length ?? 0}
onBranchChange={this.onBranchChange}
onDismissed={this.props.onDismissed}
/>
)
}
private renderContent() {
return <div>Content</div>
return (
<div className="open-pull-request-content">
{this.renderNoChanges()}
{this.renderNoDefaultBranch()}
{this.renderFilesChanged()}
</div>
)
}
private renderFilesChanged() {
const {
dispatcher,
externalEditorLabel,
hideWhitespaceInDiff,
imageDiffType,
pullRequestState,
repository,
fileListWidth,
nonLocalCommitSHA,
} = this.props
const { commitSelection } = pullRequestState
if (commitSelection === null) {
// type checking - will render no default branch message
return
}
const { diff, file, changesetData, shas } = commitSelection
const { files } = changesetData
if (shas.length === 0) {
return
}
return (
<PullRequestFilesChanged
diff={diff}
dispatcher={dispatcher}
externalEditorLabel={externalEditorLabel}
fileListWidth={fileListWidth}
files={files}
hideWhitespaceInDiff={hideWhitespaceInDiff}
imageDiffType={imageDiffType}
nonLocalCommitSHA={nonLocalCommitSHA}
selectedFile={file}
showSideBySideDiff={this.props.showSideBySideDiff}
repository={repository}
/>
)
}
private renderNoChanges() {
const { pullRequestState, currentBranch } = this.props
const { commitSelection, baseBranch, mergeStatus } = pullRequestState
if (commitSelection === null || baseBranch === null) {
// type checking - will render no default branch message
return
}
const { shas } = commitSelection
if (shas.length !== 0) {
return
}
const hasMergeBase = mergeStatus?.kind !== ComputedAction.Invalid
const message = hasMergeBase ? (
<>
<Ref>{baseBranch.name}</Ref> is up to date with all commits from{' '}
<Ref>{currentBranch.name}</Ref>.
</>
) : (
<>
<Ref>{baseBranch.name}</Ref> and <Ref>{currentBranch.name}</Ref> are
entirely different commit histories.
</>
)
return (
<div className="open-pull-request-message">
<div>
<Octicon symbol={OcticonSymbol.gitPullRequest} />
<h3>There are no changes.</h3>
{message}
</div>
</div>
)
}
private renderNoDefaultBranch() {
const { baseBranch } = this.props.pullRequestState
if (baseBranch !== null) {
return
}
return (
<div className="open-pull-request-message">
<div>
<Octicon symbol={OcticonSymbol.gitPullRequest} />
<h3>Could not find a default branch to compare against.</h3>
Select a base branch above.
</div>
</div>
)
}
private renderFooter() {
const { currentBranchHasPullRequest, pullRequestState, repository } =
this.props
const { mergeStatus, commitSHAs } = pullRequestState
const gitHubRepository = repository.gitHubRepository
const isEnterprise =
gitHubRepository && gitHubRepository.endpoint !== getDotComAPIEndpoint()
const viewCreate = currentBranchHasPullRequest ? 'View' : ' Create'
const buttonTitle = `${viewCreate} pull request on GitHub${
isEnterprise ? ' Enterprise' : ''
}.`
const okButton = (
<>
{currentBranchHasPullRequest && (
<Octicon symbol={OcticonSymbol.linkExternal} />
)}
{__DARWIN__
? `${viewCreate} Pull Request`
: `${viewCreate} pull request`}
</>
)
return (
<DialogFooter>
<PullRequestMergeStatus mergeStatus={mergeStatus} />
<OkCancelButtonGroup
okButtonText="Create Pull Request"
okButtonTitle="Create pull request on GitHub."
okButtonText={okButton}
okButtonTitle={buttonTitle}
cancelButtonText="Cancel"
okButtonDisabled={commitSHAs === null || commitSHAs.length === 0}
/>
</DialogFooter>
)
@ -93,8 +271,7 @@ export class OpenPullRequestDialog extends React.Component<IOpenPullRequestDialo
onDismissed={this.props.onDismissed}
>
{this.renderHeader()}
<div className="content">{this.renderContent()}</div>
{this.renderContent()}
{this.renderFooter()}
</Dialog>
)

View file

@ -2,12 +2,12 @@ import * as React from 'react'
import { Branch } from '../../models/branch'
import { BranchSelect } from '../branches/branch-select'
import { DialogHeader } from '../dialog/header'
import { createUniqueId } from '../lib/id-pool'
import { createUniqueId, releaseUniqueId } from '../lib/id-pool'
import { Ref } from '../lib/ref'
interface IOpenPullRequestDialogHeaderProps {
/** The base branch of the pull request */
readonly baseBranch: Branch
readonly baseBranch: Branch | null
/** The branch of the pull request */
readonly currentBranch: Branch
@ -18,18 +18,27 @@ interface IOpenPullRequestDialogHeaderProps {
readonly defaultBranch: Branch | null
/**
* See IBranchesState.allBranches
* Branches in the repo with the repo's default remote
*
* We only want branches that are also on dotcom such that, when we ask a user
* to create a pull request, the base branch also exists on dotcom.
*/
readonly allBranches: ReadonlyArray<Branch>
readonly prBaseBranches: ReadonlyArray<Branch>
/**
* See IBranchesState.recentBranches
* Recent branches with the repo's default remote
*
* We only want branches that are also on dotcom such that, when we ask a user
* to create a pull request, the base branch also exists on dotcom.
*/
readonly recentBranches: ReadonlyArray<Branch>
readonly prRecentBaseBranches: ReadonlyArray<Branch>
/** The count of commits of the pull request */
readonly commitCount: number
/** When the branch selection changes */
readonly onBranchChange: (branch: Branch) => void
/**
* Event triggered when the dialog is dismissed by the user in the
* ways described in the dismissable prop.
@ -37,23 +46,43 @@ interface IOpenPullRequestDialogHeaderProps {
readonly onDismissed?: () => void
}
interface IOpenPullRequestDialogHeaderState {
/**
* An id for the h1 element that contains the title of this dialog. Used to
* aid in accessibility by allowing the h1 to be referenced in an
* aria-labeledby/aria-describedby attributed. Undefined if the dialog does
* not have a title or the component has not yet been mounted.
*/
readonly titleId: string
}
/**
* A header component for the open pull request dialog. Made to house the
* base branch dropdown and merge details common to all pull request views.
*/
export class OpenPullRequestDialogHeader extends React.Component<
IOpenPullRequestDialogHeaderProps,
{}
IOpenPullRequestDialogHeaderState
> {
public constructor(props: IOpenPullRequestDialogHeaderProps) {
super(props)
this.state = { titleId: createUniqueId(`Dialog_Open_Pull_Request`) }
}
public componentWillUnmount() {
releaseUniqueId(this.state.titleId)
}
public render() {
const title = __DARWIN__ ? 'Open a Pull Request' : 'Open a pull request'
const {
baseBranch,
currentBranch,
defaultBranch,
allBranches,
recentBranches,
prBaseBranches,
prRecentBaseBranches,
commitCount,
onBranchChange,
onDismissed,
} = this.props
const commits = `${commitCount} commit${commitCount > 1 ? 's' : ''}`
@ -61,7 +90,7 @@ export class OpenPullRequestDialogHeader extends React.Component<
return (
<DialogHeader
title={title}
titleId={createUniqueId(`Dialog_${title}_${title}`)}
titleId={this.state.titleId}
dismissable={true}
onDismissed={onDismissed}
>
@ -72,8 +101,15 @@ export class OpenPullRequestDialogHeader extends React.Component<
branch={baseBranch}
defaultBranch={defaultBranch}
currentBranch={currentBranch}
allBranches={allBranches}
recentBranches={recentBranches}
allBranches={prBaseBranches}
recentBranches={prRecentBaseBranches}
onChange={onBranchChange}
noBranchesMessage={
<>
Sorry, I can't find that remote branch. <br />
You can only open pull requests against remote branches.
</>
}
/>{' '}
from <Ref>{currentBranch.name}</Ref>.
</div>

View file

@ -0,0 +1,307 @@
import * as React from 'react'
import * as Path from 'path'
import { IDiff, ImageDiffType } from '../../models/diff'
import { Repository } from '../../models/repository'
import { CommittedFileChange } from '../../models/status'
import { SeamlessDiffSwitcher } from '../diff/seamless-diff-switcher'
import { Dispatcher } from '../dispatcher'
import { openFile } from '../lib/open-file'
import { Resizable } from '../resizable'
import { FileList } from '../history/file-list'
import { IMenuItem, showContextualMenu } from '../../lib/menu-item'
import { pathExists } from '../lib/path-exists'
import {
CopyFilePathLabel,
CopyRelativeFilePathLabel,
DefaultEditorLabel,
isSafeFileExtension,
OpenWithDefaultProgramLabel,
RevealInFileManagerLabel,
} from '../lib/context-menu'
import { revealInFileManager } from '../../lib/app-shell'
import { clipboard } from 'electron'
import { IConstrainedValue } from '../../lib/app-state'
import { clamp } from '../../lib/clamp'
import { getDotComAPIEndpoint } from '../../lib/api'
import { createCommitURL } from '../../lib/commit-url'
import { DiffOptions } from '../diff/diff-options'
interface IPullRequestFilesChangedProps {
readonly repository: Repository
readonly dispatcher: Dispatcher
/** The file whose diff should be displayed. */
readonly selectedFile: CommittedFileChange | null
/** The files changed in the pull request. */
readonly files: ReadonlyArray<CommittedFileChange>
/** The diff that should be rendered */
readonly diff: IDiff | null
/** The type of image diff to display. */
readonly imageDiffType: ImageDiffType
/** Whether we should display side by side diffs. */
readonly showSideBySideDiff: boolean
/** Whether we should hide whitespace in diff. */
readonly hideWhitespaceInDiff: boolean
/** Label for selected external editor */
readonly externalEditorLabel?: string
/** Width to use for the files list pane */
readonly fileListWidth: IConstrainedValue
/** If the latest commit of the pull request is not local, this will contain
* it's SHA */
readonly nonLocalCommitSHA: string | null
}
interface IPullRequestFilesChangedState {
readonly showSideBySideDiff: boolean
}
/**
* A component for viewing the file changes for a pull request.
*/
export class PullRequestFilesChanged extends React.Component<
IPullRequestFilesChangedProps,
IPullRequestFilesChangedState
> {
public constructor(props: IPullRequestFilesChangedProps) {
super(props)
this.state = { showSideBySideDiff: props.showSideBySideDiff }
}
private onOpenFile = (path: string) => {
const fullPath = Path.join(this.props.repository.path, path)
this.onOpenBinaryFile(fullPath)
}
/**
* Opens a binary file in an the system-assigned application for
* said file type.
*/
private onOpenBinaryFile = (fullPath: string) => {
openFile(fullPath, this.props.dispatcher)
}
/** Called when the user changes the hide whitespace in diffs setting. */
private onHideWhitespaceInDiffChanged = (hideWhitespaceInDiff: boolean) => {
const { selectedFile } = this.props
return this.props.dispatcher.onHideWhitespaceInPullRequestDiffChanged(
hideWhitespaceInDiff,
this.props.repository,
selectedFile
)
}
private onShowSideBySideDiffChanged = (showSideBySideDiff: boolean) => {
this.setState({ showSideBySideDiff })
}
private onDiffOptionsOpened = () => {
this.props.dispatcher.recordDiffOptionsViewed()
}
/**
* Called when the user is viewing an image diff and requests
* to change the diff presentation mode.
*/
private onChangeImageDiffType = (imageDiffType: ImageDiffType) => {
this.props.dispatcher.changeImageDiffType(imageDiffType)
}
private onFileListResize = (width: number) => {
this.props.dispatcher.setPullRequestFileListWidth(width)
}
private onFileListSizeReset = () => {
this.props.dispatcher.resetPullRequestFileListWidth()
}
private onViewOnGitHub = (file: CommittedFileChange) => {
const { nonLocalCommitSHA, repository, dispatcher } = this.props
const { gitHubRepository } = repository
if (gitHubRepository === null || nonLocalCommitSHA === null) {
return
}
const commitURL = createCommitURL(
gitHubRepository,
nonLocalCommitSHA,
file.path
)
if (commitURL === null) {
return
}
dispatcher.openInBrowser(commitURL)
}
private onFileContextMenu = async (
file: CommittedFileChange,
event: React.MouseEvent<HTMLDivElement>
) => {
event.preventDefault()
const { repository } = this.props
const fullPath = Path.join(repository.path, file.path)
const fileExistsOnDisk = await pathExists(fullPath)
if (!fileExistsOnDisk) {
showContextualMenu([
{
label: __DARWIN__
? 'File Does Not Exist on Disk'
: 'File does not exist on disk',
enabled: false,
},
])
return
}
const { externalEditorLabel, dispatcher } = this.props
const extension = Path.extname(file.path)
const isSafeExtension = isSafeFileExtension(extension)
const openInExternalEditor =
externalEditorLabel !== undefined
? `Open in ${externalEditorLabel}`
: DefaultEditorLabel
const items: IMenuItem[] = [
{
label: RevealInFileManagerLabel,
action: () => revealInFileManager(repository, file.path),
enabled: fileExistsOnDisk,
},
{
label: openInExternalEditor,
action: () => dispatcher.openInExternalEditor(fullPath),
enabled: fileExistsOnDisk,
},
{
label: OpenWithDefaultProgramLabel,
action: () => this.onOpenFile(file.path),
enabled: isSafeExtension && fileExistsOnDisk,
},
{ type: 'separator' },
{
label: CopyFilePathLabel,
action: () => clipboard.writeText(fullPath),
},
{
label: CopyRelativeFilePathLabel,
action: () => clipboard.writeText(Path.normalize(file.path)),
},
{ type: 'separator' },
]
const { nonLocalCommitSHA } = this.props
const { gitHubRepository } = repository
const isEnterprise =
gitHubRepository && gitHubRepository.endpoint !== getDotComAPIEndpoint()
items.push({
label: `View on GitHub${isEnterprise ? ' Enterprise' : ''}`,
action: () => this.onViewOnGitHub(file),
enabled: nonLocalCommitSHA !== null && gitHubRepository !== null,
})
showContextualMenu(items)
}
private onFileSelected = (file: CommittedFileChange) => {
this.props.dispatcher.changePullRequestFileSelection(
this.props.repository,
file
)
}
private renderHeader() {
const { hideWhitespaceInDiff } = this.props
const { showSideBySideDiff } = this.state
return (
<div className="files-changed-header">
<div className="commits-displayed">
Showing changes from all commits
</div>
<DiffOptions
isInteractiveDiff={false}
hideWhitespaceChanges={hideWhitespaceInDiff}
onHideWhitespaceChangesChanged={this.onHideWhitespaceInDiffChanged}
showSideBySideDiff={showSideBySideDiff}
onShowSideBySideDiffChanged={this.onShowSideBySideDiffChanged}
onDiffOptionsOpened={this.onDiffOptionsOpened}
/>
</div>
)
}
private renderFileList() {
const { files, selectedFile, fileListWidth } = this.props
return (
<Resizable
width={fileListWidth.value}
minimumWidth={fileListWidth.min}
maximumWidth={fileListWidth.max}
onResize={this.onFileListResize}
onReset={this.onFileListSizeReset}
>
<FileList
files={files}
onSelectedFileChanged={this.onFileSelected}
selectedFile={selectedFile}
availableWidth={clamp(fileListWidth)}
onContextMenu={this.onFileContextMenu}
/>
</Resizable>
)
}
private renderDiff() {
const { selectedFile } = this.props
if (selectedFile === null) {
return
}
const { diff, repository, imageDiffType, hideWhitespaceInDiff } = this.props
const { showSideBySideDiff } = this.state
return (
<SeamlessDiffSwitcher
repository={repository}
imageDiffType={imageDiffType}
file={selectedFile}
diff={diff}
readOnly={true}
hideWhitespaceInDiff={hideWhitespaceInDiff}
showSideBySideDiff={showSideBySideDiff}
onOpenBinaryFile={this.onOpenBinaryFile}
onChangeImageDiffType={this.onChangeImageDiffType}
onHideWhitespaceInDiffChanged={this.onHideWhitespaceInDiffChanged}
/>
)
}
public render() {
return (
<div className="pull-request-files-changed">
{this.renderHeader()}
<div className="files-diff-viewer">
{this.renderFileList()}
{this.renderDiff()}
</div>
</div>
)
}
}

View file

@ -0,0 +1,68 @@
import * as React from 'react'
import { assertNever } from '../../lib/fatal-error'
import { ComputedAction } from '../../models/computed-action'
import { MergeTreeResult } from '../../models/merge'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
interface IPullRequestMergeStatusProps {
/** The result of merging the pull request branch into the base branch */
readonly mergeStatus: MergeTreeResult | null
}
/** The component to display message about the result of merging the pull
* request. */
export class PullRequestMergeStatus extends React.Component<IPullRequestMergeStatusProps> {
private getMergeStatusDescription = () => {
const { mergeStatus } = this.props
if (mergeStatus === null) {
return ''
}
const { kind } = mergeStatus
switch (kind) {
case ComputedAction.Loading:
return (
<span className="pr-merge-status-loading">
<strong>Checking mergeability&hellip;</strong> Dont worry, you can
still create the pull request.
</span>
)
case ComputedAction.Invalid:
return (
<span className="pr-merge-status-invalid">
<strong>Error checking merge status.</strong> Unable to merge
unrelated histories in this repository
</span>
)
case ComputedAction.Clean:
return (
<span className="pr-merge-status-clean">
<strong>
<Octicon symbol={OcticonSymbol.check} /> Able to merge.
</strong>{' '}
These branches can be automatically merged.
</span>
)
case ComputedAction.Conflicts:
return (
<span className="pr-merge-status-conflicts">
<strong>
<Octicon symbol={OcticonSymbol.x} /> Can't automatically merge.
</strong>{' '}
Dont worry, you can still create the pull request.
</span>
)
default:
return assertNever(kind, `Unknown merge status kind of ${kind}.`)
}
}
public render() {
return (
<div className="pull-request-merge-status">
{this.getMergeStatusDescription()}
</div>
)
}
}

View file

@ -54,6 +54,7 @@ interface IPreferencesProps {
readonly confirmRepositoryRemoval: boolean
readonly confirmDiscardChanges: boolean
readonly confirmDiscardChangesPermanently: boolean
readonly confirmDiscardStash: boolean
readonly confirmForcePush: boolean
readonly confirmUndoCommit: boolean
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
@ -79,6 +80,7 @@ interface IPreferencesState {
readonly confirmRepositoryRemoval: boolean
readonly confirmDiscardChanges: boolean
readonly confirmDiscardChangesPermanently: boolean
readonly confirmDiscardStash: boolean
readonly confirmForcePush: boolean
readonly confirmUndoCommit: boolean
readonly uncommittedChangesStrategy: UncommittedChangesStrategy
@ -121,6 +123,7 @@ export class Preferences extends React.Component<
confirmRepositoryRemoval: false,
confirmDiscardChanges: false,
confirmDiscardChangesPermanently: false,
confirmDiscardStash: false,
confirmForcePush: false,
confirmUndoCommit: false,
uncommittedChangesStrategy: defaultUncommittedChangesStrategy,
@ -178,6 +181,7 @@ export class Preferences extends React.Component<
confirmDiscardChanges: this.props.confirmDiscardChanges,
confirmDiscardChangesPermanently:
this.props.confirmDiscardChangesPermanently,
confirmDiscardStash: this.props.confirmDiscardStash,
confirmForcePush: this.props.confirmForcePush,
confirmUndoCommit: this.props.confirmUndoCommit,
uncommittedChangesStrategy: this.props.uncommittedChangesStrategy,
@ -333,12 +337,14 @@ export class Preferences extends React.Component<
confirmDiscardChangesPermanently={
this.state.confirmDiscardChangesPermanently
}
confirmDiscardStash={this.state.confirmDiscardStash}
confirmForcePush={this.state.confirmForcePush}
confirmUndoCommit={this.state.confirmUndoCommit}
onConfirmRepositoryRemovalChanged={
this.onConfirmRepositoryRemovalChanged
}
onConfirmDiscardChangesChanged={this.onConfirmDiscardChangesChanged}
onConfirmDiscardStashChanged={this.onConfirmDiscardStashChanged}
onConfirmForcePushChanged={this.onConfirmForcePushChanged}
onConfirmDiscardChangesPermanentlyChanged={
this.onConfirmDiscardChangesPermanentlyChanged
@ -410,6 +416,10 @@ export class Preferences extends React.Component<
this.setState({ confirmDiscardChanges: value })
}
private onConfirmDiscardStashChanged = (value: boolean) => {
this.setState({ confirmDiscardStash: value })
}
private onConfirmDiscardChangesPermanentlyChanged = (value: boolean) => {
this.setState({ confirmDiscardChangesPermanently: value })
}
@ -562,6 +572,10 @@ export class Preferences extends React.Component<
this.state.confirmForcePush
)
await this.props.dispatcher.setConfirmDiscardStashSetting(
this.state.confirmDiscardStash
)
await this.props.dispatcher.setConfirmUndoCommitSetting(
this.state.confirmUndoCommit
)

View file

@ -6,10 +6,12 @@ interface IPromptsPreferencesProps {
readonly confirmRepositoryRemoval: boolean
readonly confirmDiscardChanges: boolean
readonly confirmDiscardChangesPermanently: boolean
readonly confirmDiscardStash: boolean
readonly confirmForcePush: boolean
readonly confirmUndoCommit: boolean
readonly onConfirmDiscardChangesChanged: (checked: boolean) => void
readonly onConfirmDiscardChangesPermanentlyChanged: (checked: boolean) => void
readonly onConfirmDiscardStashChanged: (checked: boolean) => void
readonly onConfirmRepositoryRemovalChanged: (checked: boolean) => void
readonly onConfirmForcePushChanged: (checked: boolean) => void
readonly onConfirmUndoCommitChanged: (checked: boolean) => void
@ -19,6 +21,7 @@ interface IPromptsPreferencesState {
readonly confirmRepositoryRemoval: boolean
readonly confirmDiscardChanges: boolean
readonly confirmDiscardChangesPermanently: boolean
readonly confirmDiscardStash: boolean
readonly confirmForcePush: boolean
readonly confirmUndoCommit: boolean
}
@ -35,6 +38,7 @@ export class Prompts extends React.Component<
confirmDiscardChanges: this.props.confirmDiscardChanges,
confirmDiscardChangesPermanently:
this.props.confirmDiscardChangesPermanently,
confirmDiscardStash: this.props.confirmDiscardStash,
confirmForcePush: this.props.confirmForcePush,
confirmUndoCommit: this.props.confirmUndoCommit,
}
@ -58,6 +62,15 @@ export class Prompts extends React.Component<
this.props.onConfirmDiscardChangesPermanentlyChanged(value)
}
private onConfirmDiscardStashChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
const value = event.currentTarget.checked
this.setState({ confirmDiscardStash: value })
this.props.onConfirmDiscardStashChanged(value)
}
private onConfirmForcePushChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
@ -116,6 +129,15 @@ export class Prompts extends React.Component<
}
onChange={this.onConfirmDiscardChangesPermanentlyChanged}
/>
<Checkbox
label="Discarding stash"
value={
this.state.confirmDiscardStash
? CheckboxValue.On
: CheckboxValue.Off
}
onChange={this.onConfirmDiscardStashChanged}
/>
<Checkbox
label="Force pushing"
value={

View file

@ -193,10 +193,14 @@ const renderAheadBehindIndicator = (aheadBehind: IAheadBehind) => {
'its tracked branch.'
return (
<div className="ahead-behind" title={aheadBehindTooltip}>
<TooltippedContent
className="ahead-behind"
tagName="div"
tooltip={aheadBehindTooltip}
>
{ahead > 0 && <Octicon symbol={OcticonSymbol.arrowUp} />}
{behind > 0 && <Octicon symbol={OcticonSymbol.arrowDown} />}
</div>
</TooltippedContent>
)
}

View file

@ -32,6 +32,7 @@ import { AheadBehindStore } from '../lib/stores/ahead-behind-store'
import { dragAndDropManager } from '../lib/drag-and-drop-manager'
import { DragType } from '../models/drag-drop'
import { clamp } from '../lib/clamp'
import { PullRequestSuggestedNextAction } from '../models/pull-request'
interface IRepositoryViewProps {
readonly repository: Repository
@ -49,6 +50,7 @@ interface IRepositoryViewProps {
readonly hideWhitespaceInHistoryDiff: boolean
readonly showSideBySideDiff: boolean
readonly askForConfirmationOnDiscardChanges: boolean
readonly askForConfirmationOnDiscardStash: boolean
readonly focusCommitMessage: boolean
readonly commitSpellcheckEnabled: boolean
readonly accounts: ReadonlyArray<Account>
@ -91,6 +93,9 @@ interface IRepositoryViewProps {
repository: Repository,
commits: ReadonlyArray<CommitOneLine>
) => void
/** The user's preference of pull request suggested next action to use **/
readonly pullRequestSuggestedNextAction?: PullRequestSuggestedNextAction
}
interface IRepositoryViewState {
@ -350,6 +355,9 @@ export class RepositoryView extends React.Component<
fileListWidth={this.props.stashedFilesWidth}
repository={this.props.repository}
dispatcher={this.props.dispatcher}
askForConfirmationOnDiscardStash={
this.props.askForConfirmationOnDiscardStash
}
isWorkingTreeClean={isWorkingTreeClean}
showSideBySideDiff={this.props.showSideBySideDiff}
onOpenBinaryFile={this.onOpenBinaryFile}
@ -461,6 +469,9 @@ export class RepositoryView extends React.Component<
this.props.externalEditorLabel !== undefined
}
dispatcher={this.props.dispatcher}
pullRequestSuggestedNextAction={
this.props.pullRequestSuggestedNextAction
}
/>
)
}

View file

@ -5,16 +5,19 @@ import { Dispatcher } from '../dispatcher'
import { Row } from '../lib/row'
import { IStashEntry } from '../../models/stash-entry'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { Checkbox, CheckboxValue } from '../lib/checkbox'
interface IConfirmDiscardStashProps {
readonly dispatcher: Dispatcher
readonly repository: Repository
readonly stash: IStashEntry
readonly askForConfirmationOnDiscardStash: boolean
readonly onDismissed: () => void
}
interface IConfirmDiscardStashState {
readonly isDiscarding: boolean
readonly confirmDiscardStash: boolean
}
/**
* Dialog to confirm dropping a stash
@ -28,6 +31,7 @@ export class ConfirmDiscardStashDialog extends React.Component<
this.state = {
isDiscarding: false,
confirmDiscardStash: props.askForConfirmationOnDiscardStash,
}
}
@ -46,6 +50,17 @@ export class ConfirmDiscardStashDialog extends React.Component<
>
<DialogContent>
<Row>Are you sure you want to discard these stashed changes?</Row>
<Row>
<Checkbox
label="Do not show this message again"
value={
this.state.confirmDiscardStash
? CheckboxValue.Off
: CheckboxValue.On
}
onChange={this.onAskForConfirmationOnDiscardStashChanged}
/>
</Row>
</DialogContent>
<DialogFooter>
<OkCancelButtonGroup destructive={true} okButtonText="Discard" />
@ -54,6 +69,14 @@ export class ConfirmDiscardStashDialog extends React.Component<
)
}
private onAskForConfirmationOnDiscardStashChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
const value = !event.currentTarget.checked
this.setState({ confirmDiscardStash: value })
}
private onSubmit = async () => {
const { dispatcher, repository, stash, onDismissed } = this.props
@ -62,6 +85,7 @@ export class ConfirmDiscardStashDialog extends React.Component<
})
try {
dispatcher.setConfirmDiscardStashSetting(this.state.confirmDiscardStash)
await dispatcher.dropStash(repository, stash)
} finally {
this.setState({

View file

@ -11,11 +11,13 @@ interface IStashDiffHeaderProps {
readonly stashEntry: IStashEntry
readonly repository: Repository
readonly dispatcher: Dispatcher
readonly askForConfirmationOnDiscardStash: boolean
readonly isWorkingTreeClean: boolean
}
interface IStashDiffHeaderState {
readonly isRestoring: boolean
readonly isDiscarding: boolean
}
/**
@ -31,12 +33,13 @@ export class StashDiffHeader extends React.Component<
this.state = {
isRestoring: false,
isDiscarding: false,
}
}
public render() {
const { isWorkingTreeClean } = this.props
const { isRestoring } = this.state
const { isRestoring, isDiscarding } = this.state
return (
<div className="header">
@ -44,10 +47,12 @@ export class StashDiffHeader extends React.Component<
<div className="row">
<OkCancelButtonGroup
okButtonText="Restore"
okButtonDisabled={isRestoring || !isWorkingTreeClean}
okButtonDisabled={
isRestoring || !isWorkingTreeClean || isDiscarding
}
onOkButtonClick={this.onRestoreClick}
cancelButtonText="Discard"
cancelButtonDisabled={isRestoring}
cancelButtonDisabled={isRestoring || isDiscarding}
onCancelButtonClick={this.onDiscardClick}
/>
{this.renderExplanatoryText()}
@ -80,13 +85,33 @@ export class StashDiffHeader extends React.Component<
)
}
private onDiscardClick = () => {
const { dispatcher, repository, stashEntry } = this.props
dispatcher.showPopup({
type: PopupType.ConfirmDiscardStash,
stash: stashEntry,
private onDiscardClick = async () => {
const {
dispatcher,
repository,
})
stashEntry,
askForConfirmationOnDiscardStash,
} = this.props
if (!askForConfirmationOnDiscardStash) {
this.setState({
isDiscarding: true,
})
try {
await dispatcher.dropStash(repository, stashEntry)
} finally {
this.setState({
isDiscarding: false,
})
}
} else {
dispatcher.showPopup({
type: PopupType.ConfirmDiscardStash,
stash: stashEntry,
repository,
})
}
}
private onRestoreClick = async () => {

View file

@ -27,6 +27,9 @@ interface IStashDiffViewerProps {
readonly repository: Repository
readonly dispatcher: Dispatcher
/** Should the app propt the user to confirm a discard stash */
readonly askForConfirmationOnDiscardStash: boolean
/** Whether we should display side by side diffs. */
readonly showSideBySideDiff: boolean
@ -113,6 +116,9 @@ export class StashDiffViewer extends React.PureComponent<IStashDiffViewerProps>
repository={repository}
dispatcher={dispatcher}
isWorkingTreeClean={isWorkingTreeClean}
askForConfirmationOnDiscardStash={
this.props.askForConfirmationOnDiscardStash
}
/>
<div className="commit-details">
<Resizable

View file

@ -0,0 +1,170 @@
import * as React from 'react'
import {
DropdownSelectButton,
IDropdownSelectButtonOption,
} from '../dropdown-select-button'
import { MenuIDs } from '../../models/menu-ids'
import { executeMenuItemById } from '../main-process-proxy'
import { sendNonFatalException } from '../../lib/helpers/non-fatal-exception'
import classNames from 'classnames'
export interface IDropdownSuggestedActionOption<T extends string>
extends IDropdownSelectButtonOption<T> {
/**
* The title, or "header" text for a suggested
* action.
*/
readonly title?: string
/**
* A text or set of elements used to present information
* to the user about how and where to access the action
* outside of the suggested action.
*/
readonly discoverabilityContent?: string | JSX.Element
/**
* A callback which is invoked when the user clicks
* or activates the action using their keyboard.
*/
readonly onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
/**
* Whether or not the action should be disabled. Disabling
* the action means that the button will no longer be
* clickable.
*/
readonly disabled?: boolean
/**
* An image to illustrate what this component's action does
*/
readonly image?: JSX.Element
/**
* The id of the menu item backing this action.
* When the action is invoked the menu item specified
* by this id will be executed.
*/
readonly menuItemId?: MenuIDs
}
export interface IDropdownSuggestedActionProps<T extends string> {
/** The possible suggested next actions to select from
*
* This component assumes this is not an empty array.
*/
readonly suggestedActions: ReadonlyArray<IDropdownSuggestedActionOption<T>>
/** The value of the selected next action to initialize the component with */
readonly selectedActionValue?: T
readonly onSuggestedActionChanged: (action: T) => void
/**
* An optional additional class name to set in order to be able to apply
* specific styles to the dropdown suggested next action
*/
readonly className?: string
}
interface IDropdownSuggestedActionState<T extends string> {
readonly selectedAction: IDropdownSuggestedActionOption<T>
}
export class DropdownSuggestedAction<T extends string> extends React.Component<
IDropdownSuggestedActionProps<T>,
IDropdownSuggestedActionState<T>
> {
public constructor(props: IDropdownSuggestedActionProps<T>) {
super(props)
const { selectedActionValue, suggestedActions } = props
const firstAction = suggestedActions[0]
const selectedAction =
selectedActionValue !== undefined
? suggestedActions.find(
a => a.value === this.props.selectedActionValue
) ?? firstAction
: firstAction
this.state = {
selectedAction,
}
}
private onActionSelectionChange = (
option: IDropdownSelectButtonOption<T>
) => {
const selectedAction = this.props.suggestedActions.find(
a => a.value === option.value
)
if (selectedAction === undefined) {
return
}
this.setState({ selectedAction })
this.props.onSuggestedActionChanged(selectedAction.value)
}
private onActionSubmitted = (e: React.MouseEvent<HTMLButtonElement>) => {
const { onClick, menuItemId } = this.state.selectedAction
onClick?.(e)
if (!e.defaultPrevented && menuItemId !== undefined) {
executeMenuItemById(menuItemId)
}
}
public render() {
const { selectedAction } = this.state
if (selectedAction === undefined) {
// Shouldn't happen .. but if it did we don't want to crash app and tell dev what is up
sendNonFatalException(
'NoSuggestedActionsProvided',
new Error(
'The DropdownSuggestedActions component was provided an empty array. It requires an array of at least one item.'
)
)
return null
}
const {
description,
image,
discoverabilityContent,
disabled,
value,
title,
} = selectedAction
const className = classNames(
'suggested-action',
'primary',
this.props.className
)
return (
<div className={className}>
{image && <div className="image-wrapper">{image}</div>}
<div className="text-wrapper">
<h2>{title}</h2>
{description && <p className="description">{description}</p>}
{discoverabilityContent && (
<p className="discoverability">{discoverabilityContent}</p>
)}
</div>
<DropdownSelectButton<T>
selectedValue={value}
options={this.props.suggestedActions.map(({ label, value }) => ({
label,
value,
}))}
disabled={disabled}
onSelectChange={this.onActionSelectionChange}
onSubmit={this.onActionSubmitted}
/>
</div>
)
}
}

View file

@ -99,4 +99,7 @@
@import 'ui/_pull-request-quick-view';
@import 'ui/discard-changes-retry';
@import 'ui/_git-email-not-found-warning';
@import 'ui/_branch-select.scss';
@import 'ui/_branch-select';
@import 'ui/_popover-dropdown';
@import 'ui/_pull-request-files-changed';
@import 'ui/_pull-request-merge-status';

View file

@ -1,66 +0,0 @@
.branch-select-component {
display: inline-flex;
.base-label {
font-weight: var(--font-weight-semibold);
color: var(--text-secondary-color);
margin: 0 var(--spacing-half);
}
button {
border: none;
background-color: inherit;
border: none;
padding: 0;
margin: 0;
font-style: normal;
font-family: var(--font-family-monospace);
.ref-component {
padding: 1px;
}
&.button-component {
overflow: visible;
&:hover,
&:focus {
border: none;
box-shadow: none;
.ref-component {
border-color: var(--path-segment-background-focus);
box-shadow: 0 0 0 1px var(--path-segment-background-focus);
}
}
}
}
.ref-component {
display: inline-flex;
align-items: center;
}
.branch-select-dropdown {
position: absolute;
min-height: 200px;
width: 365px;
padding: 0;
margin-top: 25px;
.branch-select-dropdown-header {
padding: var(--spacing);
font-weight: var(--font-weight-semibold);
display: flex;
border-bottom: var(--base-border);
.close {
margin-right: 0;
}
}
.branch-select-dropdown-list {
display: flex;
}
}
}

View file

@ -21,12 +21,16 @@
@import 'dialogs/ci-check-run-rerun';
@import 'dialogs/unreachable-commits';
@import 'dialogs/open-pull-request';
@import 'dialogs/installing-update';
// The styles herein attempt to follow a flow where margins are only applied
// to the bottom of elements (with the exception of the last child). This to
// allow easy layout using generalized components and elements such as <Row>
// and <p>.
dialog {
display: flex;
flex-direction: column;
overflow: unset;
// These are the 24px versions of the alert and stop octicons
// from oction v10.0.0
@ -125,14 +129,28 @@ dialog {
opacity: 1;
}
&:not([open]) {
display: none;
}
// The dialog embeds a fieldset as the first child of the form element
// in order to be able to disable all form elements and buttons in one
// swoop. This resets all styles for that fieldset.
& > form > fieldset {
border: 0;
margin: 0;
padding: 0;
min-width: 0;
& > form {
min-height: 0;
height: 100%;
& > fieldset {
border: 0;
margin: 0;
padding: 0;
min-width: 0;
min-height: 0;
max-height: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
}
.dialog-header {

View file

@ -1,6 +1,10 @@
.dropdown-select-button {
position: relative;
.dropdown-button-wrappers {
display: flex;
}
&.open-bottom {
.invoke-button {
border-bottom-left-radius: 0;
@ -34,19 +38,16 @@
}
.invoke-button {
width: 88%;
display: inline;
border-bottom-right-radius: 0;
border-top-right-radius: 0;
margin-right: 0;
float: left;
height: 30px;
// counter balances center for the 12% dropdown button
padding-left: 12% !important;
flex-grow: 1;
}
.dropdown-button {
width: 12%;
min-width: 30px;
padding: var(--spacing-half);
margin: 0;
border-bottom-left-radius: 0;
@ -85,29 +86,50 @@
box-shadow: var(--base-box-shadow);
width: 99.9%;
ul {
padding: 0;
margin: 0;
padding: 0;
margin: 0;
li {
list-style-type: none;
padding: var(--spacing) var(--spacing-double);
padding-left: var(--spacing-triple);
border-bottom: 1px solid var(--box-border-color);
:first-child.button-component {
border-top: 1px solid var(--box-border-color);
}
.button-component {
padding: var(--spacing) var(--spacing-double);
padding-left: var(--spacing-triple);
border-bottom: 1px solid var(--box-border-color);
height: auto;
padding: var(--spacing) var(--spacing-double);
padding-left: var(--spacing-triple);
border-bottom: 1px solid var(--box-border-color);
text-align: inherit;
white-space: normal;
border-radius: 0;
line-height: 1.5;
margin: 0;
border-right: 0;
border-left: 0;
border-top: 0;
width: 100%;
&:hover,
&:focus {
outline: none;
color: var(--box-selected-active-text-color);
background-color: var(--box-selected-active-background-color);
.option-description {
color: var(--text-secondary-color);
font-size: var(--font-size-sm);
}
.selected-option-indicator {
position: absolute;
left: var(--spacing);
color: var(--box-selected-active-text-color);
}
}
li:hover {
background-color: var(--box-selected-background-color);
.option-description {
color: var(--text-secondary-color);
font-size: var(--font-size-sm);
}
.selected-option-indicator {
position: absolute;
left: var(--spacing);
}
}
}

View file

@ -0,0 +1,36 @@
.popover-dropdown-component {
display: inline-flex;
.button-content,
.popover-dropdown-button-label {
font-weight: var(--font-weight-semibold);
}
.popover-dropdown-button-label {
color: var(--text-secondary-color);
margin: 0 var(--spacing-half);
}
.popover-dropdown-popover {
position: absolute;
min-height: 200px;
width: 365px;
padding: 0;
margin-top: 25px;
.popover-dropdown-header {
padding: var(--spacing);
font-weight: var(--font-weight-semibold);
display: flex;
border-bottom: var(--base-border);
.close {
margin-right: 0;
}
}
.popover-dropdown-content {
display: flex;
}
}
}

View file

@ -0,0 +1,29 @@
.pull-request-files-changed {
display: flex;
flex-direction: column;
min-height: 0;
flex-grow: 1;
border: var(--base-border);
border-radius: var(--border-radius);
.files-changed-header {
padding: var(--spacing);
border-bottom: var(--base-border);
display: flex;
.commits-displayed {
flex-grow: 1;
}
}
.files-diff-viewer {
display: flex;
min-height: 0;
flex-grow: 1;
}
.file-list {
border-right: var(--base-border);
}
}

View file

@ -0,0 +1,31 @@
.pull-request-merge-status {
flex-grow: 1;
color: var(--text-secondary-color);
.octicon {
vertical-align: text-bottom;
}
strong {
font-weight: var(--font-weight-semibold);
}
.pr-merge-status-loading {
strong {
color: var(--file-warning-color);
}
}
.pr-merge-status-invalid,
.pr-merge-status-conflicts {
strong {
color: var(--status-error-color);
}
}
.pr-merge-status-clean {
strong {
color: var(--status-success-color);
}
}
}

View file

@ -193,6 +193,12 @@
background: var(--list-item-selected-active-badge-background-color);
color: var(--list-item-selected-active-badge-color);
}
.change-indicator-wrapper {
.octicon {
color: var(--text-color);
}
}
}
}

View file

@ -314,6 +314,8 @@
}
&.unified-diff {
--line-gutter-right-border-width: 4px;
.row {
.before,
.after {
@ -323,12 +325,27 @@
}
.hunk-handle {
left: 100px;
// `left` depends on the line number length at runtime
transform: translateX(calc(-50% + var(--line-gutter-right-border-width) / 2));
}
&.hunk-info .line-number {
background: var(--diff-hunk-gutter-background-color);
border-color: var(--diff-hunk-border-color);
&.hunk-info {
.line-number {
background: var(--diff-hunk-gutter-background-color);
border-color: var(--diff-hunk-border-color);
}
.hunk-expansion-handle {
background: var(--diff-hunk-gutter-background-color);
border-right-width: 1px;
border-right-style: solid;
border-color: var(--diff-hunk-gutter-background-color);
align-self: stretch;
align-items: center;
&.selectable:hover {
border-color: var(--diff-hover-background-color);
}
}
}
.line-number {
@ -349,8 +366,13 @@
}
}
&.editable .row .line-number {
border-right-width: 4px;
&.editable .row {
.line-number {
border-right-width: var(--line-gutter-right-border-width);
}
.hunk-expansion-handle {
border-right-width: var(--line-gutter-right-border-width);
}
}
}

View file

@ -45,6 +45,14 @@
max-width: 600px;
}
.pull-request-action {
.dropdown-select-button {
.invoke-button {
min-width: 150px;
}
}
}
/** Lessen the padding at 1.5x zoom and above **/
@media screen and (max-width: 640px) {
padding: var(--spacing-double);

View file

@ -0,0 +1,7 @@
#installing-update {
max-width: 400px;
.updating-message {
align-items: center;
}
}

View file

@ -1,4 +1,9 @@
.open-pull-request {
width: 100%;
height: 100%;
max-width: calc(100% - var(--spacing-double) * 4);
max-height: calc(100% - var(--spacing-double) * 4);
header.dialog-header {
padding-bottom: var(--spacing);
@ -15,4 +20,35 @@
padding: var(--spacing-half);
}
}
.open-pull-request-content {
padding: var(--spacing);
display: flex;
flex-direction: column;
min-height: 0;
flex-grow: 1;
}
.open-pull-request-message {
height: 100%;
display: flex;
align-items: center;
text-align: center;
justify-content: center;
padding: var(--spacing-double);
}
.dialog-footer {
flex-direction: row;
.button-group {
.octicon {
margin-right: var(--spacing-half);
}
}
}
.pull-request-merge-status {
flex-grow: 1;
}
}

View file

@ -175,7 +175,7 @@
vertical-align: bottom; // For some reason, `bottom` places the text in the middle
}
.sha {
.selectable {
user-select: text;
}

View file

@ -11,6 +11,7 @@ body > .tooltip,
max-width: 300px;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
background-color: var(--tooltip-background-color);

View file

@ -575,9 +575,38 @@ describe('git/diff', () => {
'feature-branch',
'irrelevantToTest'
)
expect(changesetData).not.toBeNull()
if (changesetData === null) {
return
}
expect(changesetData.files).toHaveLength(1)
expect(changesetData.files[0].path).toBe('feature.md')
})
it('returns null for unrelated histories', async () => {
// create a second branch that's orphaned from our current branch
await GitProcess.exec(
['checkout', '--orphan', 'orphaned-branch'],
repository.path
)
// add a commit to this new branch
await GitProcess.exec(
['commit', '--allow-empty', '-m', `first commit on gh-pages`],
repository.path
)
const changesetData = await getBranchMergeBaseChangedFiles(
repository,
'master',
'feature-branch',
'irrelevantToTest'
)
expect(changesetData).toBeNull()
})
})
describe('getBranchMergeBaseDiff', () => {

View file

@ -0,0 +1,318 @@
import { PopupManager } from '../../src/lib/popup-manager'
import { Account } from '../../src/models/account'
import { Popup, PopupType } from '../../src/models/popup'
let mockId = 0
jest.mock('../../src/lib/uuid', () => {
return { uuid: () => mockId++ }
})
describe('PopupManager', () => {
let popupManager = new PopupManager()
beforeEach(() => {
popupManager = new PopupManager()
mockId = 0
})
describe('currentPopup', () => {
it('returns null when no popups added', () => {
const currentPopup = popupManager.currentPopup
expect(currentPopup).toBeNull()
})
it('returns last added non-error popup', () => {
const popupAbout: Popup = { type: PopupType.About }
const popupSignIn: Popup = { type: PopupType.SignIn }
popupManager.addPopup(popupAbout)
popupManager.addPopup(popupSignIn)
const currentPopup = popupManager.currentPopup
expect(currentPopup).not.toBeNull()
expect(currentPopup?.type).toBe(PopupType.SignIn)
})
it('returns last added error popup', () => {
const popupAbout: Popup = { type: PopupType.About }
const popupSignIn: Popup = { type: PopupType.SignIn }
popupManager.addPopup(popupAbout)
popupManager.addErrorPopup(new Error('an error'))
popupManager.addPopup(popupSignIn)
const currentPopup = popupManager.currentPopup
expect(currentPopup).not.toBeNull()
expect(currentPopup?.type).toBe(PopupType.Error)
})
})
describe('isAPopupOpen', () => {
it('returns false when no popups added', () => {
const isAPopupOpen = popupManager.isAPopupOpen
expect(isAPopupOpen).toBeFalse()
})
it('returns last added popup', () => {
const popupAbout: Popup = { type: PopupType.About }
popupManager.addPopup(popupAbout)
const isAPopupOpen = popupManager.isAPopupOpen
expect(isAPopupOpen).toBeTrue()
})
})
describe('getPopupsOfType', () => {
it('returns popups of a given type', () => {
const popupAbout: Popup = { type: PopupType.About }
const popupSignIn: Popup = { type: PopupType.SignIn }
popupManager.addPopup(popupAbout)
popupManager.addPopup(popupSignIn)
const aboutPopups = popupManager.getPopupsOfType(PopupType.About)
expect(aboutPopups).toBeArrayOfSize(1)
expect(aboutPopups.at(0)?.type).toBe(PopupType.About)
})
it('returns empty array if none exist of given type', () => {
const popupAbout: Popup = { type: PopupType.About }
popupManager.addPopup(popupAbout)
const signInPopups = popupManager.getPopupsOfType(PopupType.SignIn)
expect(signInPopups).toBeArrayOfSize(0)
})
})
describe('areTherePopupsOfType', () => {
it('returns true if popup of type exists', () => {
const popupAbout: Popup = { type: PopupType.About }
popupManager.addPopup(popupAbout)
const areThereAboutPopups = popupManager.areTherePopupsOfType(
PopupType.About
)
expect(areThereAboutPopups).toBeTrue()
})
it('returns false if there are no popups of that type', () => {
const popupAbout: Popup = { type: PopupType.About }
popupManager.addPopup(popupAbout)
const areThereSignInPopups = popupManager.areTherePopupsOfType(
PopupType.SignIn
)
expect(areThereSignInPopups).toBeFalse()
})
})
describe('addPopup', () => {
it('adds a popup to the stack', () => {
const popup: Popup = { type: PopupType.About }
popupManager.addPopup(popup)
const popupsOfType = popupManager.getPopupsOfType(PopupType.About)
const currentPopup = popupManager.currentPopup
expect(popupsOfType).toBeArrayOfSize(1)
expect(currentPopup).not.toBeNull()
expect(currentPopup?.type).toBe(PopupType.About)
expect(currentPopup?.id).toBe(0)
})
it('does not add multiple popups of the same kind to the stack', () => {
const popup: Popup = { type: PopupType.About }
popupManager.addPopup(popup)
popupManager.addPopup(popup)
const popupsOfType = popupManager.getPopupsOfType(PopupType.About)
expect(popupsOfType).toBeArrayOfSize(1)
})
it('adds multiple popups of different types', () => {
const popupAbout: Popup = { type: PopupType.About }
const popupSignIn: Popup = { type: PopupType.SignIn }
popupManager.addPopup(popupAbout)
popupManager.addPopup(popupSignIn)
const aboutPopups = popupManager.getPopupsOfType(PopupType.About)
const signInPoups = popupManager.getPopupsOfType(PopupType.SignIn)
expect(aboutPopups).toBeArrayOfSize(1)
expect(signInPoups).toBeArrayOfSize(1)
expect(aboutPopups.at(0)?.type).toBe(PopupType.About)
expect(signInPoups.at(0)?.type).toBe(PopupType.SignIn)
})
it('trims oldest popup when limit is reached', () => {
popupManager = new PopupManager(2)
const popupAbout: Popup = { type: PopupType.About }
const popupSignIn: Popup = { type: PopupType.SignIn }
const popupTermsAndConditions: Popup = {
type: PopupType.TermsAndConditions,
}
popupManager.addPopup(popupAbout)
popupManager.addPopup(popupSignIn)
popupManager.addPopup(popupTermsAndConditions)
const aboutPopups = popupManager.getPopupsOfType(PopupType.About)
const signInPoups = popupManager.getPopupsOfType(PopupType.SignIn)
const termsAndConditionsPoups = popupManager.getPopupsOfType(
PopupType.TermsAndConditions
)
expect(aboutPopups).toBeArrayOfSize(0)
expect(signInPoups).toBeArrayOfSize(1)
expect(termsAndConditionsPoups).toBeArrayOfSize(1)
expect(signInPoups.at(0)?.type).toBe(PopupType.SignIn)
expect(termsAndConditionsPoups.at(0)?.type).toBe(
PopupType.TermsAndConditions
)
})
})
describe('addErrorPopup', () => {
it('adds a popup of type error to the stack', () => {
popupManager.addErrorPopup(new Error('an error'))
const popupsOfType = popupManager.getPopupsOfType(PopupType.Error)
const currentPopup = popupManager.currentPopup
expect(popupsOfType).toBeArrayOfSize(1)
expect(currentPopup).not.toBeNull()
expect(currentPopup?.type).toBe(PopupType.Error)
expect(currentPopup?.id).toBe(0)
})
it('adds multiple popups of type error to the stack', () => {
popupManager.addErrorPopup(new Error('an error'))
popupManager.addErrorPopup(new Error('an error'))
const popupsOfType = popupManager.getPopupsOfType(PopupType.Error)
expect(popupsOfType).toBeArrayOfSize(2)
})
it('trims oldest popup when limit is reached', () => {
const limit = 2
popupManager = new PopupManager(limit)
popupManager.addErrorPopup(new Error('an error'))
popupManager.addErrorPopup(new Error('an error'))
popupManager.addErrorPopup(new Error('an error'))
popupManager.addErrorPopup(new Error('an error'))
const errorPopups = popupManager.getPopupsOfType(PopupType.Error)
expect(errorPopups).toBeArrayOfSize(limit)
})
})
describe('updatePopup', () => {
it('updates the given popup', () => {
const mockAccount = new Account('test', '', 'deadbeef', [], '', 1, '')
const popupTutorial: Popup = {
type: PopupType.CreateTutorialRepository,
account: mockAccount,
}
const tutorialPopup = popupManager.addPopup(popupTutorial)
// Just so update spreader notation will work
if (tutorialPopup.type !== PopupType.CreateTutorialRepository) {
return
}
const updatedPopup: Popup = {
...tutorialPopup,
progress: {
kind: 'generic',
value: 5,
},
}
popupManager.updatePopup(updatedPopup)
const result = popupManager.getPopupsOfType(
PopupType.CreateTutorialRepository
)
expect(result).toBeArrayOfSize(1)
const resultingPopup = result.at(0)
// Would fail first expect if not
if (resultingPopup === undefined) {
return
}
expect(resultingPopup.type).toBe(PopupType.CreateTutorialRepository)
if (resultingPopup.type !== PopupType.CreateTutorialRepository) {
return
}
expect(resultingPopup.progress).toBeDefined()
expect(resultingPopup.progress?.kind).toBe('generic')
expect(resultingPopup.progress?.value).toBe(5)
})
})
describe('removePopup', () => {
it('deletes popup when give a popup with an id', () => {
const popupAbout: Popup = popupManager.addPopup({ type: PopupType.About })
popupManager.addPopup({
type: PopupType.SignIn,
})
popupManager.removePopup(popupAbout)
const aboutPopups = popupManager.getPopupsOfType(PopupType.About)
expect(aboutPopups).toBeArrayOfSize(0)
const signInPopups = popupManager.getPopupsOfType(PopupType.SignIn)
expect(signInPopups).toBeArrayOfSize(1)
})
it('does not remove popups by type', () => {
popupManager.addPopup({ type: PopupType.About })
popupManager.addPopup({
type: PopupType.SignIn,
})
popupManager.removePopup({ type: PopupType.About })
const aboutPopups = popupManager.getPopupsOfType(PopupType.About)
expect(aboutPopups).toBeArrayOfSize(1)
const signInPopups = popupManager.getPopupsOfType(PopupType.SignIn)
expect(signInPopups).toBeArrayOfSize(1)
})
})
describe('removePopupByType', () => {
it('removes the popups of a given type', () => {
popupManager.addPopup({ type: PopupType.About })
popupManager.addPopup({
type: PopupType.SignIn,
})
popupManager.removePopupByType(PopupType.About)
const aboutPopups = popupManager.getPopupsOfType(PopupType.About)
expect(aboutPopups).toBeArrayOfSize(0)
const signInPopups = popupManager.getPopupsOfType(PopupType.SignIn)
expect(signInPopups).toBeArrayOfSize(1)
})
})
describe('removePopupById', () => {
it('removes the popup by its id', () => {
const popupAbout: Popup = popupManager.addPopup({ type: PopupType.About })
popupManager.addPopup({
type: PopupType.SignIn,
})
expect(popupAbout.id).toBeDefined()
if (popupAbout.id === undefined) {
return
}
popupManager.removePopupById(popupAbout.id)
const aboutPopups = popupManager.getPopupsOfType(PopupType.About)
expect(aboutPopups).toBeArrayOfSize(0)
const signInPopups = popupManager.getPopupsOfType(PopupType.SignIn)
expect(signInPopups).toBeArrayOfSize(1)
})
})
})

View file

@ -119,9 +119,9 @@ babel-runtime@^6.26.0:
regenerator-runtime "^0.11.0"
balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
bl@^3.0.0:
version "3.0.1"
@ -131,9 +131,9 @@ bl@^3.0.0:
readable-stream "^3.0.1"
brace-expansion@^1.1.7:
version "1.1.8"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292"
integrity sha1-wHshHHyVLsH479Uad+8NHTmQopI=
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
@ -243,7 +243,7 @@ compare-versions@^3.6.0:
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
console-control-strings@^1.0.0, console-control-strings@~1.1.0:
version "1.1.0"
@ -854,9 +854,9 @@ mimic-response@^3.1.0:
integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
dependencies:
brace-expansion "^1.1.7"

View file

@ -6,7 +6,53 @@
"[Fixed] Close 'Resolve conflicts before Rebase' dialog will not disable menu items - #13081. Thanks @angusdev!",
"[Fixed] Fix commit shortcut (Ctrl/Cmd + Enter) while amending a commit - #15445"
],
"3.1.3-beta4": [
"[Fixed] Hide window instead of hiding the app on macOS - #15511. Thanks @angusdev!",
"[Fixed] Only left mouse clicks invoke dragging in the commit list - #15313",
"[Fixed] Ensure selected list items stay selected when scrolling - #2957",
"[Fixed] Stick to one tooltip at a time in the repository list - #15583",
"[Fixed] Preview Pull Request opens when there is not a local default branch - #15704",
"[Fixed] Preview Pull Request suggested next action available on first app open without interaction - #15703",
"[Improved] Ability to copy tag names from the commit list - #15137. Thanks @Shivareddy-Aluri!",
"[Improved] Stacked popups remember their state when hidden due to another popup opening - #15668",
"[Improved] Create pull request from pull request preview opens to compare against the user's selected base branch - #15706",
"[Improved] On Preview Pull Request dialog, submit button closes the dialog - #15695"
],
"3.1.3-beta3": [
"[Fixed] Using the key command of 'Shift' + 'ArrowDown' in the commit list adds the next commit to the current selection - #15536",
"[Fixed] Notifications of Pull Request reviews are displayed for forked repositories - #15580",
"[Fixed] Notifications when checks of a Pull Request fail are displayed for forked repositories - #15422",
"[Improved] User can preview a Pull Request from the suggested next actions. - #15588",
"[Improved] The dropdown selection component is keyboard navigable - #15620",
"[Improved] 'Preview Pull Request' menu item availability is consistent with other menu items - #15590",
"[Improved] The diff view now highlights Arduino's `.ino` files as C++ source - #15555. Thanks @j-f1!",
"[Improved] Close repository list after creating or adding repositories - #15508. Thanks @angusdev!",
"[Improved] Always show an error message when an update fails - #15530",
"[Improved] Popups are stacked. Opening a popup will not discard an existing popup - #15496"
],
"3.1.3-beta2": [
"[Added] Enable menu option to Force-push branches that have diverged - #15211",
"[Added] Add menu option to Fetch the current repository at any time - #7805",
"[Added] Support VSCodium as an external editor - #15348. Thanks @daniel-ciaglia!",
"[Fixed] Prevent closing the GitHub Desktop while it's being updated - #7055, #5197",
"[Fixed] Notifications are shown only when they are relevant to the current repository - #15487",
"[Fixed] Disable reorder, squashing, cherry-picking while an action of this type is in progress. - #15468",
"[Fixed] Fix repository change indicator not visible if selected and in focus - #7651. Thanks @angusdev!",
"[Fixed] Close 'Resolve conflicts before Rebase' dialog will not disable menu items - #13081. Thanks @angusdev!",
"[Fixed] Tooltips are positioned properly if mouse is not moved - #13636. Thanks @angusdev!",
"[Fixed] Fix tooltips of long commit author emails not breaking properly - #15424. Thanks @angusdev!",
"[Fixed] Clone repository progress bar no longer hidden by repository list - #11953. Thanks @angusdev!",
"[Fixed] Fix commit shortcut (Ctrl/Cmd + Enter) while amending a commit - #15445",
"[Improved] Pull request preview dialog width and height is responsive - #15500"
],
"3.1.3-beta1": ["[Improved] Upgrade embedded Git to 2.35.5"],
"3.1.2": ["[Improved] Upgrade embedded Git to 2.35.5"],
"3.1.2-beta1": [
"[Added] You can preview the changes a pull request from your current branch would make - #11517",
"[Fixed] App correctly remembers undo commit prompt setting - #15408",
"[Improved] Add support for zooming out at the 67%, 75%, 80% and 90% zoom levels - #15401. Thanks @sathvikrijo!",
"[Improved] Add option to disable discard stash confirmation - #15379. Thanks @tsvetilian-ty!"
],
"3.1.1": [
"[Fixed] App correctly remembers undo commit prompt setting - #15408"
],

View file

@ -19,13 +19,13 @@ versions look similar to the below output:
```shellsession
$ node -v
v10.15.4
v16.13.0
$ yarn -v
1.15.2
1.21.1
$ python --version
Python 2.7.15
Python 3.9.x
```
There are also [additional resources](tooling.md) to configure your favorite

View file

@ -4,11 +4,11 @@
To authenticate against GitLab repositories you will need to create a personal access token.
1. Go to your GitLab account and select **Settings** in the user profile dropdown.
1. Go to your GitLab account and select **Edit Profile** in the user profile dropdown.
![](https://user-images.githubusercontent.com/721500/54834720-1f468a00-4c97-11e9-9a0f-4c92224064d0.png)
![](https://user-images.githubusercontent.com/721500/206245864-025fedb1-88e5-4c58-84dd-0d4b24eff76d.png)
2. Select **Access tokens**
2. In the left sidebar, select **Access tokens**
3. Under **Add a personal access token** choose a name and set an expiration date for your token.

View file

@ -307,6 +307,9 @@ These editors are currently supported:
- [Neovim](https://neovim.io/)
- [Code](https://github.com/elementary/code)
- [Lite XL](https://lite-xl.com/)
- [JetBrains PHPStorm](https://www.jetbrains.com/phpstorm/)
- [JetBrains WebStorm](https://www.jetbrains.com/webstorm/)
- [Emacs](https://www.gnu.org/software/emacs/)
These are defined in a list at the top of the file:

View file

@ -73,7 +73,7 @@
"eslint-plugin-json": "^2.1.1",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "7.26.1",
"express": "^4.15.0",
"express": "^4.17.3",
"fake-indexeddb": "^2.0.4",
"file-loader": "^6.2.0",
"front-matter": "^2.3.0",

View file

@ -102,7 +102,11 @@ function packageWindows() {
}
if (shouldMakeDelta()) {
options.remoteReleases = getUpdatesURL()
const url = new URL(getUpdatesURL())
// Make sure Squirrel.Windows isn't affected by partially or completely
// disabled releases.
url.searchParams.set('bypassStaggeredRelease', '1')
options.remoteReleases = url.toString()
}
if (isAppveyor() || isGitHubActions()) {

329
yarn.lock
View file

@ -1811,13 +1811,13 @@ abab@^2.0.3, abab@^2.0.5:
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==
accepts@~1.3.7:
version "1.3.7"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
accepts@~1.3.8:
version "1.3.8"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
dependencies:
mime-types "~2.1.24"
negotiator "0.6.2"
mime-types "~2.1.34"
negotiator "0.6.3"
acorn-globals@^6.0.0:
version "6.0.0"
@ -2445,11 +2445,6 @@ bcrypt-pbkdf@^1.0.0:
dependencies:
tweetnacl "^0.14.3"
big.js@^3.1.3:
version "3.2.0"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e"
integrity sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==
big.js@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
@ -2477,21 +2472,21 @@ bluebird@^3.5.5:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
body-parser@1.19.0:
version "1.19.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
body-parser@1.19.2:
version "1.19.2"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.2.tgz#4714ccd9c157d44797b8b5607d72c0b89952f26e"
integrity sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw==
dependencies:
bytes "3.1.0"
bytes "3.1.2"
content-type "~1.0.4"
debug "2.6.9"
depd "~1.1.2"
http-errors "1.7.2"
http-errors "1.8.1"
iconv-lite "0.4.24"
on-finished "~2.3.0"
qs "6.7.0"
raw-body "2.4.0"
type-is "~1.6.17"
qs "6.9.7"
raw-body "2.4.3"
type-is "~1.6.18"
boolbase@^1.0.0:
version "1.0.0"
@ -2648,10 +2643,10 @@ builtin-modules@^1.0.0:
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
integrity sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=
bytes@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
bytes@3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
cache-base@^1.0.1:
version "1.0.1"
@ -3019,12 +3014,12 @@ console-polyfill@^0.3.0:
resolved "https://registry.yarnpkg.com/console-polyfill/-/console-polyfill-0.3.0.tgz#84900902a18c47a5eba932be75fa44d23e8af861"
integrity sha512-w+JSDZS7XML43Xnwo2x5O5vxB0ID7T5BdqDtyqT6uiCAX2kZAgcWxNaGqT97tZfSHzfOcvrfsDAodKcJ3UvnXQ==
content-disposition@0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
content-disposition@0.5.4:
version "0.5.4"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
dependencies:
safe-buffer "5.1.2"
safe-buffer "5.2.1"
content-type@~1.0.4:
version "1.0.4"
@ -3048,10 +3043,10 @@ cookie-signature@1.0.6:
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
cookie@0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
cookie@0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
copy-descriptor@^0.1.0:
version "0.1.1"
@ -3210,55 +3205,20 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
dependencies:
ms "2.0.0"
debug@4, debug@^4.3.4:
debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
dependencies:
ms "2.1.2"
debug@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
dependencies:
ms "2.0.0"
debug@^3.2.7:
debug@^3.1.0, debug@^3.2.7:
version "3.2.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
dependencies:
ms "^2.1.1"
debug@^4.0.1:
version "4.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.0.tgz#373687bffa678b38b1cd91f861b63850035ddc87"
integrity sha512-heNPJUJIqC+xB6ayLAMHaIrmN9HKa7aQO8MGqKpvCA+uJYVcvR6l5kgdrhRuwPFHU7P5/A1w0BjByPHwpfTDKg==
dependencies:
ms "^2.1.1"
debug@^4.1.0, debug@^4.1.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
dependencies:
ms "2.1.2"
debug@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1"
integrity sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==
dependencies:
ms "2.1.2"
debug@^4.3.2, debug@^4.3.3:
version "4.3.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664"
integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==
dependencies:
ms "2.1.2"
debuglog@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
@ -3275,9 +3235,9 @@ decimal.js@^10.2.1:
integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==
decode-uri-component@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
version "0.2.2"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==
decompress-response@^3.3.0:
version "3.3.0"
@ -3677,11 +3637,6 @@ emoji-regex@^9.2.2:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
emojis-list@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k=
emojis-list@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
@ -4346,17 +4301,17 @@ expect@^26.6.2:
jest-message-util "^26.6.2"
jest-regex-util "^26.0.0"
express@^4.15.0:
version "4.17.0"
resolved "https://registry.yarnpkg.com/express/-/express-4.17.0.tgz#288af62228a73f4c8ea2990ba3b791bb87cd4438"
integrity sha512-1Z7/t3Z5ZnBG252gKUPyItc4xdeaA0X934ca2ewckAsVsw9EG71i++ZHZPYnus8g/s5Bty8IMpSVEuRkmwwPRQ==
express@^4.17.3:
version "4.17.3"
resolved "https://registry.yarnpkg.com/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1"
integrity sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg==
dependencies:
accepts "~1.3.7"
accepts "~1.3.8"
array-flatten "1.1.1"
body-parser "1.19.0"
content-disposition "0.5.3"
body-parser "1.19.2"
content-disposition "0.5.4"
content-type "~1.0.4"
cookie "0.4.0"
cookie "0.4.2"
cookie-signature "1.0.6"
debug "2.6.9"
depd "~1.1.2"
@ -4370,13 +4325,13 @@ express@^4.15.0:
on-finished "~2.3.0"
parseurl "~1.3.3"
path-to-regexp "0.1.7"
proxy-addr "~2.0.5"
qs "6.7.0"
proxy-addr "~2.0.7"
qs "6.9.7"
range-parser "~1.2.1"
safe-buffer "5.1.2"
send "0.17.1"
serve-static "1.14.1"
setprototypeof "1.1.1"
safe-buffer "5.2.1"
send "0.17.2"
serve-static "1.14.2"
setprototypeof "1.2.0"
statuses "~1.5.0"
type-is "~1.6.18"
utils-merge "1.0.1"
@ -4634,10 +4589,10 @@ form-data@~2.3.2:
combined-stream "1.0.6"
mime-types "^2.1.12"
forwarded@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
forwarded@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
fragment-cache@^0.2.1:
version "0.2.1"
@ -5227,16 +5182,16 @@ http-cache-semantics@^4.0.0:
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
http-errors@1.7.2, http-errors@~1.7.2:
version "1.7.2"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
http-errors@1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
dependencies:
depd "~1.1.2"
inherits "2.0.3"
setprototypeof "1.1.1"
inherits "2.0.4"
setprototypeof "1.2.0"
statuses ">= 1.5.0 < 2"
toidentifier "1.0.0"
toidentifier "1.0.1"
http-proxy-agent@^4.0.1:
version "4.0.1"
@ -5342,16 +5297,11 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
inherits@2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
ini@^1.3.4:
version "1.3.8"
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
@ -5381,10 +5331,10 @@ interpret@^1.0.1:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.4.tgz#820cdd588b868ffb191a809506d6c9c8f212b1b0"
integrity sha1-ggzdWIuGj/sZGoCVBtbJyPISsbA=
ipaddr.js@1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
is-accessor-descriptor@^0.1.6:
version "0.1.6"
@ -6459,15 +6409,10 @@ json5@2.x, json5@^2.1.2:
dependencies:
minimist "^1.2.5"
json5@^0.5.0:
version "0.5.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821"
integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=
json5@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==
version "1.0.2"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==
dependencies:
minimist "^1.2.0"
@ -6691,13 +6636,13 @@ loader-runner@^4.2.0:
integrity sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==
loader-utils@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd"
integrity sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0=
version "1.4.2"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3"
integrity sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==
dependencies:
big.js "^3.1.3"
emojis-list "^2.0.0"
json5 "^0.5.0"
big.js "^5.2.2"
emojis-list "^3.0.0"
json5 "^1.0.1"
loader-utils@^2.0.0:
version "2.0.0"
@ -7031,7 +6976,7 @@ mime-db@~1.36.0:
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.36.0.tgz#5020478db3c7fe93aad7bbcc4dcf869c43363397"
integrity sha512-L+xvyD9MkoYMXb1jAmzI/lWYAxAMCPvIBSWur0PZ5nOf5euahRLVqH//FKW9mWp2lkqUgYiXPgkzfMUFi4zVDw==
mime-types@^2.1.12:
mime-types@^2.1.12, mime-types@~2.1.34:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
@ -7112,20 +7057,10 @@ minimist@0.0.8:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
minimist@^1.1.1, minimist@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
minimist@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
minimist@^1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6:
version "1.2.7"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
mixin-deep@^1.2.0:
version "1.2.0"
@ -7164,17 +7099,12 @@ ms@2.0.0:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
ms@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@^2.1.1:
ms@2.1.3, ms@^2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
@ -7207,10 +7137,10 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
negotiator@0.6.2:
version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
negotiator@0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
neo-async@^2.6.2:
version "2.6.2"
@ -7982,13 +7912,13 @@ proto-list@~1.2.1:
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=
proxy-addr@~2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34"
integrity sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==
proxy-addr@~2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
dependencies:
forwarded "~0.1.2"
ipaddr.js "1.9.0"
forwarded "0.2.0"
ipaddr.js "1.9.1"
prr@~0.0.0:
version "0.0.0"
@ -8030,15 +7960,15 @@ pupa@^2.0.1:
dependencies:
escape-goat "^2.0.0"
qs@6.7.0:
version "6.7.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
qs@6.9.7:
version "6.9.7"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe"
integrity sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==
qs@~6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
version "6.5.3"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad"
integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==
querystring@^0.2.0:
version "0.2.0"
@ -8069,13 +7999,13 @@ range-parser@^1.2.1, range-parser@~1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
raw-body@2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.3.tgz#8f80305d11c2a0a545c2d9d89d7a0286fcead43c"
integrity sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g==
dependencies:
bytes "3.1.0"
http-errors "1.7.2"
bytes "3.1.2"
http-errors "1.8.1"
iconv-lite "0.4.24"
unpipe "1.0.0"
@ -8531,21 +8461,16 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
safe-buffer@5.1.2, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-buffer@^5.0.1, safe-buffer@^5.1.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
integrity sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==
safe-buffer@~5.2.0:
safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-regex@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
@ -8686,10 +8611,10 @@ semver@^7.3.7:
dependencies:
lru-cache "^6.0.0"
send@0.17.1:
version "0.17.1"
resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
send@0.17.2:
version "0.17.2"
resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820"
integrity sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==
dependencies:
debug "2.6.9"
depd "~1.1.2"
@ -8698,9 +8623,9 @@ send@0.17.1:
escape-html "~1.0.3"
etag "~1.8.1"
fresh "0.5.2"
http-errors "~1.7.2"
http-errors "1.8.1"
mime "1.6.0"
ms "2.1.1"
ms "2.1.3"
on-finished "~2.3.0"
range-parser "~1.2.1"
statuses "~1.5.0"
@ -8719,15 +8644,15 @@ serialize-javascript@^6.0.0:
dependencies:
randombytes "^2.1.0"
serve-static@1.14.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
serve-static@1.14.2:
version "1.14.2"
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.2.tgz#722d6294b1d62626d41b43a013ece4598d292bfa"
integrity sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ==
dependencies:
encodeurl "~1.0.2"
escape-html "~1.0.3"
parseurl "~1.3.3"
send "0.17.1"
send "0.17.2"
set-blocking@^2.0.0:
version "2.0.0"
@ -8771,10 +8696,10 @@ setimmediate@^1.0.5:
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=
setprototypeof@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
setprototypeof@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
shallow-clone@^3.0.0:
version "3.0.1"
@ -9487,10 +9412,10 @@ to-space-case@^1.0.0:
dependencies:
to-no-case "^1.0.0"
toidentifier@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
toidentifier@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
totalist@^1.0.0:
version "1.1.0"
@ -9681,7 +9606,7 @@ type-fest@^0.8.1:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
type-is@~1.6.17, type-is@~1.6.18:
type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==