diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index b359d10533..c5dfb6fdb1 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -37,7 +37,7 @@ jobs: private_key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }} - name: Create Release Pull Request - uses: peter-evans/create-pull-request@v4.1.1 + uses: peter-evans/create-pull-request@v4.2.3 if: | startsWith(github.ref, 'refs/heads/releases/') && !contains(github.ref, 'test') with: diff --git a/README.md b/README.md index b572740774..f4d7975147 100644 --- a/README.md +++ b/README.md @@ -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) + + + A screenshot of the GitHub Desktop application showing changes being viewed and committed with two attributed co-authors + ## 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 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..d9ca9342c3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +GitHub takes the security of our software products and services seriously, including the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). + +If you believe you have found a security vulnerability in this GitHub-owned open source repository, you can report it to us in one of two ways. + +If the vulnerability you have found is *not* [in scope for the GitHub Bug Bounty Program](https://bounty.github.com/#scope) or if you do not wish to be considered for a bounty reward, please report the issue to us directly using [private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability). + +If the vulnerability you have found is [in scope for the GitHub Bug Bounty Program](https://bounty.github.com/#scope) and you would like for your finding to be considered for a bounty reward, please submit the vulnerability to us through [HackerOne](https://hackerone.com/github) in order to be eligible to receive a bounty award. + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Thanks for helping make GitHub safe for everyone. diff --git a/app/src/highlighter/index.ts b/app/src/highlighter/index.ts index 63885be640..5643535db4 100644 --- a/app/src/highlighter/index.ts +++ b/app/src/highlighter/index.ts @@ -148,6 +148,7 @@ const extensionModes: ReadonlyArray = [ '.h': 'text/x-c', '.cpp': 'text/x-c++src', '.hpp': 'text/x-c++src', + '.ino': 'text/x-c++src', '.kt': 'text/x-kotlin', }, }, diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index a965ae1a93..bc17c4bcd8 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -1053,11 +1053,14 @@ export class API { public async fetchCombinedRefStatus( owner: string, name: string, - ref: string + ref: string, + reloadCache: boolean = false ): Promise { const safeRef = encodeURIComponent(ref) const path = `repos/${owner}/${name}/commits/${safeRef}/status?per_page=100` - const response = await this.request('GET', path) + const response = await this.request('GET', path, { + reloadCache, + }) try { return await parsedResponse(response) @@ -1076,7 +1079,8 @@ export class API { public async fetchRefCheckRuns( owner: string, name: string, - ref: string + ref: string, + reloadCache: boolean = false ): Promise { const safeRef = encodeURIComponent(ref) const path = `repos/${owner}/${name}/commits/${safeRef}/check-runs?per_page=100` @@ -1084,7 +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(response) diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 1f0e0d42f7..0e9525456d 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -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 readonly currentFoldout: Foldout | null readonly currentBanner: Banner | null @@ -145,7 +149,7 @@ export interface IAppState { */ readonly appMenuState: ReadonlyArray - readonly errors: ReadonlyArray + readonly errorCount: number /** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */ readonly emoji: Map @@ -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 | 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 } diff --git a/app/src/lib/commit-url.ts b/app/src/lib/commit-url.ts new file mode 100644 index 0000000000..8dd293c8be --- /dev/null +++ b/app/src/lib/commit-url.ts @@ -0,0 +1,24 @@ +import * as crypto from 'crypto' +import { GitHubRepository } from '../models/github-repository' + +/** Method to create the url for viewing a commit on dotcom */ +export function createCommitURL( + gitHubRepository: GitHubRepository, + SHA: string, + filePath?: string +): string | null { + const baseURL = gitHubRepository.htmlURL + + if (baseURL === null) { + return null + } + + if (filePath === undefined) { + return `${baseURL}/commit/${SHA}` + } + + const fileHash = crypto.createHash('sha256').update(filePath).digest('hex') + const fileSuffix = '#diff-' + fileHash + + return `${baseURL}/commit/${SHA}${fileSuffix}` +} diff --git a/app/src/lib/editors/darwin.ts b/app/src/lib/editors/darwin.ts index a13813951b..b9690c82c5 100644 --- a/app/src/lib/editors/darwin.ts +++ b/app/src/lib/editors/darwin.ts @@ -45,7 +45,7 @@ const editors: IDarwinExternalEditor[] = [ }, { name: 'VSCodium', - bundleIdentifiers: ['com.visualstudio.code.oss'], + bundleIdentifiers: ['com.visualstudio.code.oss', 'com.vscodium'], }, { name: 'Sublime Text', diff --git a/app/src/lib/editors/linux.ts b/app/src/lib/editors/linux.ts index 84806820d7..0670548b44 100644 --- a/app/src/lib/editors/linux.ts +++ b/app/src/lib/editors/linux.ts @@ -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 { diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index dccc1a383d..67c445ba3e 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -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() } diff --git a/app/src/lib/git/diff.ts b/app/src/lib/git/diff.ts index 8878a0af03..0f7f0fcb4d 100644 --- a/app/src/lib/git/diff.ts +++ b/app/src/lib/git/diff.ts @@ -254,7 +254,7 @@ export async function getBranchMergeBaseChangedFiles( baseBranchName: string, comparisonBranchName: string, latestComparisonBranchCommitRef: string -): Promise { +): Promise { 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 ) } diff --git a/app/src/lib/globals.d.ts b/app/src/lib/globals.d.ts index ea4b3c0416..e1d4aa4a04 100644 --- a/app/src/lib/globals.d.ts +++ b/app/src/lib/globals.d.ts @@ -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 diff --git a/app/src/lib/ipc-shared.ts b/app/src/lib/ipc-shared.ts index 07ffdb7cc0..a8d4b3328a 100644 --- a/app/src/lib/ipc-shared.ts +++ b/app/src/lib/ipc-shared.ts @@ -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) => 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 'set-window-zoom-factor': (zoomFactor: number) => void + 'show-installing-update': () => void } /** diff --git a/app/src/lib/menu-update.ts b/app/src/lib/menu-update.ts index 9f0c644679..8e86919515 100644 --- a/app/src/lib/menu-update.ts +++ b/app/src/lib/menu-update.ts @@ -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 = [ '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 diff --git a/app/src/lib/multi-commit-operation.ts b/app/src/lib/multi-commit-operation.ts index 897c2909e2..c1799a1dd1 100644 --- a/app/src/lib/multi-commit-operation.ts +++ b/app/src/lib/multi-commit-operation.ts @@ -4,7 +4,6 @@ import { conflictSteps, MultiCommitOperationStepKind, } from '../models/multi-commit-operation' -import { Popup, PopupType } from '../models/popup' import { TipState } from '../models/tip' import { IMultiCommitOperationState, IRepositoryState } from './app-state' @@ -39,12 +38,11 @@ export function getMultiCommitOperationChooseBranchStep( } export function isConflictsFlow( - currentPopup: Popup | null, + isMultiCommitOperationPopupOpen: boolean, multiCommitOperationState: IMultiCommitOperationState | null ): boolean { return ( - currentPopup !== null && - currentPopup.type === PopupType.MultiCommitOperation && + isMultiCommitOperationPopupOpen && multiCommitOperationState !== null && conflictSteps.includes(multiCommitOperationState.step.kind) ) diff --git a/app/src/lib/popup-manager.ts b/app/src/lib/popup-manager.ts new file mode 100644 index 0000000000..6afb2449a6 --- /dev/null +++ b/app/src/lib/popup-manager.ts @@ -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 = [] + + public constructor(private readonly popupLimit = defaultPopupStackLimit) {} + + /** + * Returns the last popup in the stack. + * + * The stack is sorted such that: + * If there are error popups, it returns the last popup of type error, + * otherwise returns the first non-error type popup. + */ + public get currentPopup(): Popup | null { + return this.popupStack.at(-1) ?? null + } + + /** + * Returns all the popups in the stack. + * + * The stack is sorted such that: + * If there are error popups, they will be the last on the stack. + */ + public get allPopups(): ReadonlyArray { + return this.popupStack + } + + /** + * Returns whether there are any popups in the stack. + */ + public get isAPopupOpen(): boolean { + return this.currentPopup !== null + } + + /** + * Returns an array of all popups in the stack of the provided type. + **/ + public getPopupsOfType(popupType: PopupType): ReadonlyArray { + return this.popupStack.filter(p => p.type === popupType) + } + + /** + * Returns whether there are any popups of a given type in the stack. + */ + public areTherePopupsOfType(popupType: PopupType): boolean { + return this.popupStack.some(p => p.type === popupType) + } + + /** + * Adds a popup to the stack. + * - The popup will be given a unique id and returned. + * - It will not add multiple popups of the same type onto the stack + * - NB: Error types are the only duplicates allowed + **/ + public addPopup(popupToAdd: Popup): Popup { + if (popupToAdd.type === PopupType.Error) { + return this.addErrorPopup(popupToAdd.error) + } + + const existingPopup = this.getPopupsOfType(popupToAdd.type) + + const popup = { id: uuid(), ...popupToAdd } + if (!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) + } +} diff --git a/app/src/lib/rebase.ts b/app/src/lib/rebase.ts index 6ab9a6ddb1..2f8e68179f 100644 --- a/app/src/lib/rebase.ts +++ b/app/src/lib/rebase.ts @@ -3,6 +3,26 @@ import { IAheadBehind } from '../models/branch' import { TipState } from '../models/tip' import { clamp } from './clamp' +/** Represents the force-push availability state of a branch. */ +export enum ForcePushBranchState { + /** The branch cannot be force-pushed (it hasn't diverged from its upstream) */ + NotAvailable, + + /** + * The branch can be force-pushed, but the user didn't do any operation that + * we consider should be followed by a force-push, like rebasing or amending a + * pushed commit. + */ + Available, + + /** + * The branch can be force-pushed, and the user did some operation that we + * consider should be followed by a force-push, like rebasing or amending a + * pushed commit. + */ + Recommended, +} + /** * Format rebase percentage to ensure it's a value between 0 and 1, but to also * constrain it to two significant figures, avoiding the remainder that comes @@ -16,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 } diff --git a/app/src/lib/stats/stats-database.ts b/app/src/lib/stats/stats-database.ts index 7f597e1404..8d3958a562 100644 --- a/app/src/lib/stats/stats-database.ts +++ b/app/src/lib/stats/stats-database.ts @@ -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 { diff --git a/app/src/lib/stats/stats-store.ts b/app/src/lib/stats/stats-store.ts index 1cb452a67c..dc5e3e3b7e 100644 --- a/app/src/lib/stats/stats-store.ts +++ b/app/src/lib/stats/stats-store.ts @@ -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 { + return this.updateDailyMeasures(m => ({ + createPullRequestFromPreviewCount: + m.createPullRequestFromPreviewCount + 1, + })) + } + /** * Increments the `rebaseConflictsDialogDismissalCount` metric */ @@ -1776,6 +1792,20 @@ export class StatsStore implements IStatsStore { })) } + public recordChecksFailedNotificationFromRecentRepo(): Promise { + return this.updateDailyMeasures(m => ({ + checksFailedNotificationFromRecentRepoCount: + m.checksFailedNotificationFromRecentRepoCount + 1, + })) + } + + public recordChecksFailedNotificationFromNonRecentRepo(): Promise { + return this.updateDailyMeasures(m => ({ + checksFailedNotificationFromNonRecentRepoCount: + m.checksFailedNotificationFromNonRecentRepoCount + 1, + })) + } + public recordChecksFailedNotificationClicked(): Promise { return this.updateDailyMeasures(m => ({ checksFailedNotificationClicked: m.checksFailedNotificationClicked + 1, @@ -1845,6 +1875,20 @@ export class StatsStore implements IStatsStore { return `pullRequestReview${infixMap[reviewType]}${suffix}` } + public recordPullRequestReviewNotiificationFromRecentRepo(): Promise { + return this.updateDailyMeasures(m => ({ + pullRequestReviewNotificationFromRecentRepoCount: + m.pullRequestReviewNotificationFromRecentRepoCount + 1, + })) + } + + public recordPullRequestReviewNotiificationFromNonRecentRepo(): Promise { + 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 { + return this.updateDailyMeasures(m => ({ + previewedPullRequestCount: m.previewedPullRequestCount + 1, + })) + } } /** diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 8b6e1088bf..6a2e8b5260 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -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 { private readonly gitStoreCache: GitStoreCache @@ -388,10 +410,8 @@ export class AppStore extends TypedBaseStore { private showWelcomeFlow = false private focusCommitMessage = false - private currentPopup: Popup | null = null private currentFoldout: Foldout | null = null private currentBanner: Banner | null = null - private errors: ReadonlyArray = new Array() private emitQueued = false private readonly localRepositoryStateLookup = new Map< @@ -424,6 +444,7 @@ export class AppStore extends TypedBaseStore { 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 { 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 { 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 { 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 { this.notificationsStore.onPullRequestReviewSubmitNotification( this.onPullRequestReviewSubmitNotification ) + + onShowInstallingUpdate(this.onShowInstallingUpdate) } private initializeWindowState = async () => { @@ -627,7 +660,7 @@ export class AppStore extends TypedBaseStore { // 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 { }) } + 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 { 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 { 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 { 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 { lastThankYou: this.lastThankYou, showCIStatusPopover: this.showCIStatusPopover, notificationsEnabled: getNotificationsEnabled(), + pullRequestSuggestedNextAction: this.pullRequestSuggestedNextAction, } } @@ -1426,17 +1470,11 @@ export class AppStore extends TypedBaseStore { } if (tip.kind === TipState.Valid && aheadBehind.behind > 0) { - const mergeTreePromise = promiseWithMinimumTimeout( - () => determineMergeability(repository, tip.branch, action.branch), - 500 + this.currentMergeTreePromise = this.setupMergabilityPromise( + repository, + tip.branch, + action.branch ) - .catch(err => { - log.warn( - `Error occurred while trying to merge ${tip.branch.name} (${tip.branch.tip.sha}) and ${action.branch.name} (${action.branch.tip.sha})`, - err - ) - return null - }) .then(mergeStatus => { this.repositoryStateCache.updateCompareState(repository, () => ({ mergeStatus, @@ -1444,16 +1482,9 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() }) - - const cleanup = () => { - this.currentMergeTreePromise = null - } - - // TODO: when we have Promise.prototype.finally available we - // should use that here to make this intent clearer - mergeTreePromise.then(cleanup, cleanup) - - this.currentMergeTreePromise = mergeTreePromise + .finally(() => { + this.currentMergeTreePromise = null + }) return this.currentMergeTreePromise } else { @@ -1465,6 +1496,23 @@ export class AppStore extends TypedBaseStore { } } + private setupMergabilityPromise( + repository: Repository, + baseBranch: Branch, + compareBranch: Branch + ) { + return promiseWithMinimumTimeout( + () => determineMergeability(repository, baseBranch, compareBranch), + 500 + ).catch(err => { + log.warn( + `Error occurred while trying to merge ${baseBranch.name} (${baseBranch.tip.sha}) and ${compareBranch.name} (${compareBranch.tip.sha})`, + err + ) + return null + }) + } + /** This shouldn't be called directly. See `Dispatcher`. */ public _updateCompareForm( repository: Repository, @@ -1717,6 +1765,9 @@ export class AppStore extends TypedBaseStore { ) setNumberArray(RecentRepositoriesKey, slicedRecentRepositories) this.recentRepositories = slicedRecentRepositories + this.notificationsStore.setRecentRepositories( + this.repositories.filter(r => this.recentRepositories.includes(r.id)) + ) this.emitUpdate() } @@ -1951,8 +2002,13 @@ export class AppStore extends TypedBaseStore { 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 { confirmDiscardChangesPermanentlyDefault ) + this.confirmDiscardStash = getBoolean( + confirmDiscardStashKey, + confirmDiscardStashDefault + ) + this.askForConfirmationOnForcePush = getBoolean( confirmForcePushKey, askForConfirmationOnForcePushDefault @@ -2011,6 +2072,10 @@ export class AppStore extends TypedBaseStore { hideWhitespaceInHistoryDiffKey, false ) + this.hideWhitespaceInPullRequestDiff = getBoolean( + hideWhitespaceInPullRequestDiffKey, + false + ) this.commitSpellcheckEnabled = getBoolean( commitSpellcheckEnabledKey, commitSpellcheckEnabledDefault @@ -2034,6 +2099,12 @@ export class AppStore extends TypedBaseStore { this.lastThankYou = getObject(lastThankYouKey) + this.pullRequestSuggestedNextAction = + getEnum( + pullRequestSuggestedNextActionKey, + PullRequestSuggestedNextAction + ) ?? defaultPullRequestSuggestedNextAction + this.emitUpdateNow() this.accountsStore.refresh() @@ -2077,6 +2148,41 @@ export class AppStore extends TypedBaseStore { 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 { @@ -2163,10 +2269,12 @@ export class AppStore extends TypedBaseStore { ?.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 { if ( displayingBanner || - isConflictsFlow(this.currentPopup, multiCommitOperationState) + isConflictsFlow( + this.popupManager.areTherePopupsOfType(PopupType.MultiCommitOperation), + multiCommitOperationState + ) ) { return } @@ -2530,7 +2641,7 @@ export class AppStore extends TypedBaseStore { const { multiCommitOperationState } = state if ( userIsStartingMultiCommitOperation( - this.currentPopup, + this.popupManager.currentPopup, multiCommitOperationState ) ) { @@ -3412,32 +3523,45 @@ export class AppStore extends TypedBaseStore { /** This shouldn't be called directly. See `Dispatcher`. */ public async _showPopup(popup: Popup): Promise { - this._closePopup() - // Always close the app menu when showing a pop up. This is only // applicable on Windows where we draw a custom app menu. this._closeFoldout(FoldoutType.AppMenu) - this.currentPopup = popup + this.popupManager.addPopup(popup) this.emitUpdate() } /** This shouldn't be called directly. See `Dispatcher`. */ public _closePopup(popupType?: PopupType) { - const currentPopup = this.currentPopup - if (currentPopup == null) { + const currentPopup = this.popupManager.currentPopup + if (currentPopup === null) { return } - if (popupType !== undefined && currentPopup.type !== popupType) { + 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 { /** This shouldn't be called directly. See `Dispatcher`. */ public _pushError(error: Error): Promise { - const newErrors = Array.from(this.errors) - newErrors.push(error) - this.errors = newErrors - this.emitUpdate() - - return Promise.resolve() - } - - /** This shouldn't be called directly. See `Dispatcher`. */ - public _clearError(error: Error): Promise { - this.errors = this.errors.filter(e => e !== error) + this.popupManager.addErrorPopup(error) this.emitUpdate() return Promise.resolve() @@ -5193,6 +5307,15 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + public _setConfirmDiscardStashSetting(value: boolean): Promise { + this.confirmDiscardStash = value + + setBoolean(confirmDiscardStashKey, value) + this.emitUpdate() + + return Promise.resolve() + } + public _setConfirmForcePushSetting(value: boolean): Promise { this.askForConfirmationOnForcePush = value setBoolean(confirmForcePushKey, value) @@ -5279,6 +5402,19 @@ export class AppStore extends TypedBaseStore { } } + public _setHideWhitespaceInPullRequestDiff( + hideWhitespaceInDiff: boolean, + repository: Repository, + file: CommittedFileChange | null + ) { + setBoolean(hideWhitespaceInPullRequestDiffKey, hideWhitespaceInDiff) + this.hideWhitespaceInPullRequestDiff = hideWhitespaceInDiff + + if (file !== null) { + this._changePullRequestFileSelection(repository, file) + } + } + public _setShowSideBySideDiff(showSideBySideDiff: boolean) { if (showSideBySideDiff !== this.showSideBySideDiff) { setShowSideBySideDiff(showSideBySideDiff) @@ -5825,7 +5961,10 @@ export class AppStore extends TypedBaseStore { await this._openInBrowser(url.toString()) } - public async _createPullRequest(repository: Repository): Promise { + public async _createPullRequest( + repository: Repository, + baseBranch?: Branch + ): Promise { const gitHubRepository = repository.gitHubRepository if (!gitHubRepository) { return @@ -5838,24 +5977,28 @@ export class AppStore extends TypedBaseStore { return } - const branch = tip.branch + const compareBranch = tip.branch const aheadBehind = state.aheadBehind if (aheadBehind == null) { this._showPopup({ type: PopupType.PushBranchCommits, repository, - branch, + branch: compareBranch, }) } else if (aheadBehind.ahead > 0) { this._showPopup({ type: PopupType.PushBranchCommits, repository, - branch, + branch: compareBranch, unPushedCommits: aheadBehind.ahead, }) } else { - await this._openCreatePullRequestInBrowser(repository, branch) + await this._openCreatePullRequestInBrowser( + repository, + compareBranch, + baseBranch + ) } } @@ -5950,15 +6093,38 @@ export class AppStore extends TypedBaseStore { public async _openCreatePullRequestInBrowser( repository: Repository, - branch: Branch + compareBranch: Branch, + baseBranch?: Branch ): Promise { const gitHubRepository = repository.gitHubRepository if (!gitHubRepository) { return } - const urlEncodedBranchName = encodeURIComponent(branch.nameWithoutRemote) - const baseURL = `${gitHubRepository.htmlURL}/pull/new/${urlEncodedBranchName}` + const { parent, owner, name, htmlURL } = gitHubRepository + const isForkContributingToParent = + isForkedRepositoryContributingToParent(repository) + + 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 { 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 { } 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 { 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 + ) { + 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 { 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 { diff: null, }) ) + this.emitUpdate() if (commitSHAs.length === 0) { @@ -7261,7 +7530,7 @@ export class AppStore extends TypedBaseStore { file, baseBranch.name, currentBranch.name, - this.hideWhitespaceInHistoryDiff, + this.hideWhitespaceInPullRequestDiff, commitSHAs[0] ) )) ?? null @@ -7284,6 +7553,88 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } + + public _setPullRequestFileListWidth(width: number): Promise { + this.pullRequestFileListWidth = { + ...this.pullRequestFileListWidth, + value: width, + } + setNumber(pullRequestFileListConfigKey, width) + this.updatePullRequestResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _resetPullRequestFileListWidth(): Promise { + this.pullRequestFileListWidth = { + ...this.pullRequestFileListWidth, + value: defaultPullRequestFileListWidth, + } + localStorage.removeItem(pullRequestFileListConfigKey) + this.updatePullRequestResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _updatePullRequestBaseBranch( + repository: Repository, + baseBranch: Branch + ) { + const { branchesState, pullRequestState } = + this.repositoryStateCache.get(repository) + const { tip } = branchesState + + if (tip.kind !== TipState.Valid) { + return + } + + if (pullRequestState === null) { + // This would mean the user submitted PR after requesting base branch + // update. + return + } + + this._initializePullRequestPreview(repository, baseBranch, tip.branch) + } + + private setupPRMergeTreePromise( + repository: Repository, + baseBranch: Branch, + compareBranch: Branch + ) { + this.setupMergabilityPromise(repository, baseBranch, compareBranch).then( + (mergeStatus: MergeTreeResult | null) => { + this.repositoryStateCache.updatePullRequestState(repository, () => ({ + mergeStatus, + })) + this.emitUpdate() + } + ) + } + + public _quitApp(evenIfUpdating: boolean) { + if (evenIfUpdating) { + sendWillQuitEvenIfUpdatingSync() + } + + quitApp() + } + + public _cancelQuittingApp() { + sendCancelQuittingSync() + } + + public _setPullRequestSuggestedNextAction( + value: PullRequestSuggestedNextAction + ) { + this.pullRequestSuggestedNextAction = value + + localStorage.setItem(pullRequestSuggestedNextActionKey, value) + + this.emitUpdate() + } } /** diff --git a/app/src/lib/stores/notifications-store.ts b/app/src/lib/stores/notifications-store.ts index e88b6922c4..b5c61ab29d 100644 --- a/app/src/lib/stores/notifications-store.ts +++ b/app/src/lib/stores/notifications-store.ts @@ -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 = [] private onChecksFailedCallback: OnChecksFailedCallback | null = null private onPullRequestReviewSubmitCallback: OnPullRequestReviewSubmitCallback | null = null private cachedCommits: Map = new Map() private skipCommitShas: Set = new Set() + private skipCheckRuns: Set = 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) { + 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() diff --git a/app/src/lib/stores/repository-state-cache.ts b/app/src/lib/stores/repository-state-cache.ts index 79cbc11db0..929ad44dcb 100644 --- a/app/src/lib/stores/repository-state-cache.ts +++ b/app/src/lib/stores/repository-state-cache.ts @@ -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, })) diff --git a/app/src/main-process/app-window.ts b/app/src/main-process/app-window.ts index 073509acac..0a8e7299d9 100644 --- a/app/src/main-process/app-window.ts +++ b/app/src/main-process/app-window.ts @@ -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' diff --git a/app/src/main-process/main.ts b/app/src/main-process/main.ts index 114480145f..640c3a4414 100644 --- a/app/src/main-process/main.ts +++ b/app/src/main-process/main.ts @@ -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() diff --git a/app/src/main-process/menu/build-default-menu.ts b/app/src/main-process/menu/build-default-menu.ts index b01d63f30c..30e1cdb5bb 100644 --- a/app/src/main-process/menu/build-default-menu.ts +++ b/app/src/main-process/menu/build-default-menu.ts @@ -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() /** diff --git a/app/src/main-process/menu/menu-event.ts b/app/src/main-process/menu/menu-event.ts index 235ff73e2b..f71512cd71 100644 --- a/app/src/main-process/menu/menu-event.ts +++ b/app/src/main-process/menu/menu-event.ts @@ -2,6 +2,7 @@ export type MenuEvent = | 'push' | 'force-push' | 'pull' + | 'fetch' | 'show-changes' | 'show-history' | 'add-local-repository' @@ -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' diff --git a/app/src/models/menu-ids.ts b/app/src/models/menu-ids.ts index ebcb73d8c4..fd7449b1f6 100644 --- a/app/src/models/menu-ids.ts +++ b/app/src/models/menu-ids.ts @@ -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' diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index e8ab5268fa..7b21a8ca24 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -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 + currentBranch: Branch + defaultBranch: Branch | null + externalEditorLabel?: string + imageDiffType: ImageDiffType + prRecentBaseBranches: ReadonlyArray + repository: Repository + nonLocalCommitSHA: string | null + showSideBySideDiff: boolean + currentBranchHasPullRequest: boolean } + | { + type: PopupType.Error + error: Error + } + | { + type: PopupType.InstallingUpdate + } + +export type Popup = IBasePopup & PopupDetail diff --git a/app/src/models/pull-request.ts b/app/src/models/pull-request.ts index 47dd05355a..76c186c551 100644 --- a/app/src/models/pull-request.ts +++ b/app/src/models/pull-request.ts @@ -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 diff --git a/app/src/models/repository.ts b/app/src/models/repository.ts index e299d051bd..74b9a89b9e 100644 --- a/app/src/models/repository.ts +++ b/app/src/models/repository.ts @@ -213,3 +213,15 @@ export function getForkContributionTarget( ? repository.workflowPreferences.forkContributionTarget : ForkContributionTarget.Parent } + +/** + * Returns whether the fork is contributing to the parent + */ +export function isForkedRepositoryContributingToParent( + repository: Repository +): boolean { + return ( + isRepositoryWithForkedGitHubRepository(repository) && + getForkContributionTarget(repository) === ForkContributionTarget.Parent + ) +} diff --git a/app/src/ui/about/about.tsx b/app/src/ui/about/about.tsx index b7d8854f2e..ad5ecf5de2 100644 --- a/app/src/ui/about/about.tsx +++ b/app/src/ui/about/about.tsx @@ -16,6 +16,7 @@ import { RelativeTime } from '../relative-time' import { assertNever } from '../../lib/fatal-error' import { ReleaseNotesUri } from '../lib/releases' import { encodePathAsUrl } from '../../lib/path' +import { 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 { 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 { 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 { this.updateStoreEventHandle.dispose() this.updateStoreEventHandle = null } - window.removeEventListener('keydown', this.onKeyDown) - window.removeEventListener('keyup', this.onKeyUp) + this.checkIsTopMostDialog(false) } private onKeyDown = (event: KeyboardEvent) => { diff --git a/app/src/ui/add-repository/add-existing-repository.tsx b/app/src/ui/add-repository/add-existing-repository.tsx index 1084a2d578..3ea7882230 100644 --- a/app/src/ui/add-repository/add-existing-repository.tsx +++ b/app/src/ui/add-repository/add-existing-repository.tsx @@ -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() } diff --git a/app/src/ui/add-repository/create-repository.tsx b/app/src/ui/add-repository/create-repository.tsx index 08855c642a..0d9ce9e80c 100644 --- a/app/src/ui/add-repository/create-repository.tsx +++ b/app/src/ui/add-repository/create-repository.tsx @@ -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() diff --git a/app/src/ui/app-error.tsx b/app/src/ui/app-error.tsx index b5b91d068f..427b78f3c0 100644 --- a/app/src/ui/app-error.tsx +++ b/app/src/ui/app-error.tsx @@ -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 + /** 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 { public constructor(props: IAppErrorProps) { super(props) this.state = { - error: props.errors[0] || null, + error: props.error, disabled: false, } } public componentWillReceiveProps(nextProps: IAppErrorProps) { - const error = nextProps.errors[0] || null + const error = nextProps.error // We keep the currently shown error until it has disappeared // from the first spot in the application error queue. @@ -68,23 +64,8 @@ export class AppError extends React.Component { } } - private onDismissed = () => { - const currentError = this.state.error - - if (currentError !== null) { - this.setState({ error: null, disabled: true }) - - // Give some time for the dialog to nicely transition - // out before we clear the error and, potentially, deal - // with the next error in the queue. - window.setTimeout(() => { - this.props.onClearError(currentError) - }, dialogTransitionTimeout.exit) - } - } - private showPreferencesDialog = () => { - this.onDismissed() + this.props.onDismissed() //This is a hacky solution to resolve multiple dialog windows //being open at the same time. @@ -95,7 +76,7 @@ export class AppError extends React.Component { private onRetryAction = (event: React.MouseEvent) => { event.preventDefault() - this.onDismissed() + this.props.onDismissed() const { error } = this.state @@ -128,36 +109,6 @@ export class AppError extends React.Component { return 'Error' } - private renderDialog() { - const error = this.state.error - - if (!error) { - return null - } - - return ( - - - {this.renderErrorMessage(error)} - {this.renderContentAfterErrorMessage(error)} - - {this.renderFooter(error)} - - ) - } - private renderContentAfterErrorMessage(error: Error) { if (!isErrorWithMetaData(error)) { return undefined @@ -207,7 +158,7 @@ export class AppError extends React.Component { private onCloseButtonClick = (e: React.MouseEvent) => { e.preventDefault() - this.onDismissed() + this.props.onDismissed() } private renderFooter(error: Error) { @@ -257,16 +208,32 @@ export class AppError extends React.Component { } public render() { - const dialogContent = this.renderDialog() + const error = this.state.error + + if (!error) { + return null + } return ( - - {dialogContent && ( - - {dialogContent} - - )} - + + + {this.renderErrorMessage(error)} + {this.renderContentAfterErrorMessage(error)} + + {this.renderFooter(error)} + ) } } diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 7ae6ef43b2..04935d73be 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -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 { * 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 { * 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 { 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 { 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 { 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 { 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 { 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 { 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 { ) } - 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 { 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 ( + + {this.popupContent(popup, isTopMost)} + + ) + })} + + ) + } - if (!popup) { + private popupContent(popup: Popup, isTopMost: boolean): JSX.Element | null { + if (popup.id === undefined) { + // Should not be possible... but if it does we want to know about it. + sendNonFatalException( + 'PopupNoId', + new Error( + `Attempted to open a popup of type '${popup.type}' without an Id` + ) + ) return null } - const onPopupDismissedFn = this.getOnPopupDismissedFn(popup.type) + const onPopupDismissedFn = this.getOnPopupDismissedFn(popup.id) switch (popup.type) { case PopupType.RenameBranch: @@ -1481,6 +1537,7 @@ export class App extends React.Component { 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 { onDismissed={onPopupDismissedFn} dispatcher={this.props.dispatcher} initialPath={popup.path} + isTopMost={isTopMost} /> ) case PopupType.CloneRepository: @@ -1557,6 +1615,7 @@ export class App extends React.Component { 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 { onCheckForNonStaggeredUpdates={this.onCheckForNonStaggeredUpdates} onShowAcknowledgements={this.showAcknowledgements} onShowTermsAndConditions={this.showTermsAndConditions} + isTopMost={isTopMost} /> ) case PopupType.PublishRepository: @@ -1849,6 +1909,9 @@ export class App extends React.Component { { ) } 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 ( + ) + } + case PopupType.Error: { + return ( + + ) + } + case PopupType.InstallingUpdate: { + return ( + ) @@ -2282,6 +2382,18 @@ export class App extends React.Component { } } + 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 { this.props.dispatcher.showPopup({ type: PopupType.TermsAndConditions }) } - private renderPopup() { - const popupContent = this.currentPopupContent() + private renderPopups() { + const popupContent = this.allPopupContent() return ( @@ -2430,8 +2542,6 @@ export class App extends React.Component { return } - private clearError = (error: Error) => this.props.dispatcher.clearError(error) - private onConfirmDiscardChangesChanged = (value: boolean) => { this.props.dispatcher.setConfirmDiscardChangesSetting(value) } @@ -2440,17 +2550,6 @@ export class App extends React.Component { this.props.dispatcher.setConfirmDiscardChangesPermanentlySetting(value) } - private renderAppError() { - return ( - - ) - } - private onRetryAction = (retryAction: RetryAction) => { this.props.dispatcher.performRetry(retryAction) } @@ -2477,8 +2576,7 @@ export class App extends React.Component { {this.renderToolbar()} {this.renderBanner()} {this.renderRepository()} - {this.renderPopup()} - {this.renderAppError()} + {this.renderPopups()} {this.renderDragElement()} ) @@ -2702,7 +2800,9 @@ export class App extends React.Component { remoteName = tip.branch.upstreamRemoteName } - const isForcePush = isCurrentBranchForcePush(branchesState, aheadBehind) + const isForcePush = + getCurrentBranchForcePushState(branchesState, aheadBehind) === + ForcePushBranchState.Recommended return ( { askForConfirmationOnDiscardChanges={ state.askForConfirmationOnDiscardChanges } + askForConfirmationOnDiscardStash={ + state.askForConfirmationOnDiscardStash + } accounts={state.accounts} externalEditorLabel={externalEditorLabel} resolvedExternalEditor={state.resolvedExternalEditor} @@ -2967,6 +3070,7 @@ export class App extends React.Component { 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 { 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) => { diff --git a/app/src/ui/branches/branch-list.tsx b/app/src/ui/branches/branch-list.tsx index a3db3b365c..8240d6f292 100644 --- a/app/src/ui/branches/branch-list.tsx +++ b/app/src/ui/branches/branch-list.tsx @@ -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< ) } diff --git a/app/src/ui/branches/branch-select.tsx b/app/src/ui/branches/branch-select.tsx index a3cd692189..19fc160374 100644 --- a/app/src/ui/branches/branch-select.tsx +++ b/app/src/ui/branches/branch-select.tsx @@ -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() 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 ( - -
- Choose a base branch - -
-
- -
-
- ) - } - public render() { + const { + currentBranch, + defaultBranch, + recentBranches, + allBranches, + noBranchesMessage, + } = this.props + + const { filterText, selectedBranch } = this.state + return ( -
- - {this.renderBranchDropdown()} -
+ + + ) } } diff --git a/app/src/ui/branches/no-branches.tsx b/app/src/ui/branches/no-branches.tsx index da0a736d02..d2c269e4f0 100644 --- a/app/src/ui/branches/no-branches.tsx +++ b/app/src/ui/branches/no-branches.tsx @@ -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 { @@ -43,7 +45,11 @@ export class NoBranches extends React.Component { ) } - return
Sorry, I can't find that branch
+ return ( +
+ {this.props.noBranchesMessage ?? "Sorry, I can't find that branch"} +
+ ) } private renderShortcut() { diff --git a/app/src/ui/changes/changed-file-details.tsx b/app/src/ui/changes/changed-file-details.tsx index b47b7d2918..cab1ce5d52 100644 --- a/app/src/ui/changes/changed-file-details.tsx +++ b/app/src/ui/changes/changed-file-details.tsx @@ -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 ( { + 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 ( + + ) + } + + const previewPullMenuItem = this.getMenuItemInfo('preview-pull-request') + + if (previewPullMenuItem === undefined) { + log.error(`Could not find matching menu item for 'preview-pull-request'`) + return null + } + + const createPullRequestAction: IDropdownSuggestedActionOption = + { + title, + label: buttonText, + description, + value: PullRequestSuggestedNextAction.CreatePullRequest, + menuItemId: 'create-pull-request', + discoverabilityContent: + this.renderDiscoverabilityElements(createMenuItem), + disabled: !createMenuItem.enabled, + onClick: this.onCreatePullRequestClicked, + } + + const previewPullRequestAction: IDropdownSuggestedActionOption = + { + title: `Preview the Pull Request from your current branch`, + label: 'Preview Pull Request', + description: ( + <> + The current branch ({tip.branch.name}) is already + published to GitHub. Preview the changes this pull request will have + before proposing your changes. + + ), + value: PullRequestSuggestedNextAction.PreviewPullRequest, + menuItemId: 'preview-pull-request', + discoverabilityContent: + this.renderDiscoverabilityElements(previewPullMenuItem), + disabled: !previewPullMenuItem.enabled, + } + return ( - ) } diff --git a/app/src/ui/clone-repository/clone-repository.tsx b/app/src/ui/clone-repository/clone-repository.tsx index d89646a093..dc70a90970 100644 --- a/app/src/ui/clone-repository/clone-repository.tsx +++ b/app/src/ui/clone-repository/clone-repository.tsx @@ -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) { diff --git a/app/src/ui/dialog/dialog.tsx b/app/src/ui/dialog/dialog.tsx index 9f9c27b1a1..ff7b39598d 100644 --- a/app/src/ui/dialog/dialog.tsx +++ b/app/src/ui/dialog/dialog.tsx @@ -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({ + 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 { + public static contextType = DialogStackContext + public declare context: React.ContextType + + 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 { 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 { } 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 { 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 { } 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) => { diff --git a/app/src/ui/dialog/is-top-most.tsx b/app/src/ui/dialog/is-top-most.tsx new file mode 100644 index 0000000000..c6247d817a --- /dev/null +++ b/app/src/ui/dialog/is-top-most.tsx @@ -0,0 +1,17 @@ +import memoizeOne from 'memoize-one' + +/** This method is a memoizedOne for a consistent means of handling when the + * isTopMost property of the `DialogStackContext` changes in the various popups + * that consume it. */ +export function isTopMostDialog( + onDialogIsTopMost: () => void, + onDialogIsNotTopMost: () => void +) { + return memoizeOne((isTopMost: boolean) => { + if (isTopMost) { + onDialogIsTopMost() + } else { + onDialogIsNotTopMost() + } + }) +} diff --git a/app/src/ui/diff/code-mirror-host.tsx b/app/src/ui/diff/code-mirror-host.tsx index d840a34274..80d7e04566 100644 --- a/app/src/ui/diff/code-mirror-host.tsx +++ b/app/src/ui/diff/code-mirror-host.tsx @@ -164,6 +164,14 @@ export class CodeMirrorHost extends React.Component { 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 { } this.resizeObserver.disconnect() + document.removeEventListener('dialog-show', this.onDialogAppeared) } public componentDidUpdate(prevProps: ICodeMirrorHostProps) { diff --git a/app/src/ui/diff/diff-options.tsx b/app/src/ui/diff/diff-options.tsx index adf6861243..9196dd9317 100644 --- a/app/src/ui/diff/diff-options.tsx +++ b/app/src/ui/diff/diff-options.tsx @@ -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 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 && (

Interacting with individual lines or hunks will be disabled while hiding whitespace. diff --git a/app/src/ui/diff/side-by-side-diff-row.tsx b/app/src/ui/diff/side-by-side-diff-row.tsx index 1223f7215c..2a3d75902c 100644 --- a/app/src/ui/diff/side-by-side-diff-row.tsx +++ b/app/src/ui/diff/side-by-side-diff-row.tsx @@ -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<

@@ -389,7 +399,7 @@ export class SideBySideDiffRow extends React.Component<
) } @@ -452,10 +469,7 @@ export class SideBySideDiffRow extends React.Component< ) { if (!this.props.isDiffSelectable || isSelected === undefined) { return ( -
+
{lineNumbers.map((lineNumber, index) => ( {lineNumber} ))} @@ -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, } diff --git a/app/src/ui/diff/side-by-side-diff.tsx b/app/src/ui/diff/side-by-side-diff.tsx index b3d026c12f..94b594dc0b 100644 --- a/app/src/ui/diff/side-by-side-diff.tsx +++ b/app/src/ui/diff/side-by-side-diff.tsx @@ -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)) diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 8e73e48909..c1bf11fcb1 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -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 { 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 { - 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 { - return this.appStore._createPullRequest(repository) + public createPullRequest( + repository: Repository, + baseBranch?: Branch + ): Promise { + 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 { + return this.appStore._changePullRequestFileSelection(repository, file) + } + + /** + * Set the width of the file list column in the pull request files changed + */ + public setPullRequestFileListWidth(width: number): Promise { + return this.appStore._setPullRequestFileListWidth(width) + } + + /** + * Reset the width of the file list column in the pull request files changed + */ + public resetPullRequestFileListWidth(): Promise { + return this.appStore._resetPullRequestFileListWidth() + } + + public updatePullRequestBaseBranch(repository: Repository, branch: Branch) { + this.appStore._updatePullRequestBaseBranch(repository, branch) + } + + /** + * Attempts to quit the app if it's not updating, unless requested to quit + * even if it is updating. + * + * @param evenIfUpdating Whether to quit even if the app is updating. + */ + public quitApp(evenIfUpdating: boolean) { + this.appStore._quitApp(evenIfUpdating) + } + + /** + * Cancels quitting the app. This could be needed if, on macOS, the user tries + * to quit the app while an update is in progress, but then after being + * informed about the issues that could cause they decided to not close the + * app yet. + */ + public cancelQuittingApp() { + this.appStore._cancelQuittingApp() + } + + /** + * Sets the user's preference for which pull request suggested next action to + * use + */ + public setPullRequestSuggestedNextAction( + value: PullRequestSuggestedNextAction + ) { + return this.appStore._setPullRequestSuggestedNextAction(value) } } diff --git a/app/src/ui/dropdown-select-button.tsx b/app/src/ui/dropdown-select-button.tsx index f81aced6b0..5a57950c43 100644 --- a/app/src/ui/dropdown-select-button.tsx +++ b/app/src/ui/dropdown-select-button.tsx @@ -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 { /** 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 { /** The selection button options */ - readonly options: ReadonlyArray + readonly options: ReadonlyArray> /** 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 ) => void /** Callback for when button is selected option button is clicked */ readonly onSubmit?: ( event: React.MouseEvent, - selectedOption: IDropdownSelectButtonOption + selectedOption: IDropdownSelectButtonOption ) => void } -interface IDropdownSelectButtonState { +interface IDropdownSelectButtonState { /** Whether the options are rendered */ readonly showButtonOptions: boolean /** The currently selected option */ - readonly selectedOption: IDropdownSelectButtonOption | null + readonly selectedOption: IDropdownSelectButtonOption | 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, + IDropdownSelectButtonState > { private invokeButtonRef: HTMLButtonElement | null = null private optionsContainerRef: HTMLDivElement | null = null - public constructor(props: IDropdownSelectButtonProps) { + public constructor(props: IDropdownSelectButtonProps) { 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 | 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) => { + private onSelectionChange = ( + selectedOption: IDropdownSelectButtonOption + ) => { + return (_event?: React.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) { 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) => { + return ( + + ) + } + 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 (
-
    - {options.map(o => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -
  • - {this.renderSelectedIcon(o)} -
    {o.label}
    -
    {o.description}
    -
  • - ))} -
+ {options.map(o => this.renderOption(o))}
) } @@ -199,23 +260,25 @@ export class DropdownSelectButton extends React.Component< // method. return (
- - +
+ + +
{this.renderSplitButtonOptions()}
) diff --git a/app/src/ui/history/commit-list-item.tsx b/app/src/ui/history/commit-list-item.tsx index dc4d4bb4be..d35a725b02 100644 --- a/app/src/ui/history/commit-list-item.tsx +++ b/app/src/ui/history/commit-list-item.tsx @@ -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, diff --git a/app/src/ui/history/commit-summary.tsx b/app/src/ui/history/commit-summary.tsx index 2182f63011..904f195617 100644 --- a/app/src/ui/history/commit-summary.tsx +++ b/app/src/ui/history/commit-summary.tsx @@ -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" > - + ) } @@ -505,7 +507,7 @@ export class CommitSummary extends React.Component< title="Diff Options" > - {tags.join(', ')} + {tags.join(', ')} ) } diff --git a/app/src/ui/history/merge-call-to-action-with-conflicts.tsx b/app/src/ui/history/merge-call-to-action-with-conflicts.tsx index 060bfe31b7..f717bba781 100644 --- a/app/src/ui/history/merge-call-to-action-with-conflicts.tsx +++ b/app/src/ui/history/merge-call-to-action-with-conflicts.tsx @@ -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 + ) => { + this.setState({ selectedOperation: option.value }) + if (option.value === MultiCommitOperationKind.Rebase) { this.updateRebasePreview(this.props.comparisonBranch) } } private onOperationInvoked = async ( event: React.MouseEvent, - selectedOption: IDropdownSelectButtonOption + selectedOption: IDropdownSelectButtonOption ) => { 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, diff --git a/app/src/ui/index.tsx b/app/src/ui/index.tsx index 2d883d5606..caa09e662f 100644 --- a/app/src/ui/index.tsx +++ b/app/src/ui/index.tsx @@ -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}` diff --git a/app/src/ui/installing-update/installing-update.tsx b/app/src/ui/installing-update/installing-update.tsx new file mode 100644 index 0000000000..64531fc39d --- /dev/null +++ b/app/src/ui/installing-update/installing-update.tsx @@ -0,0 +1,96 @@ +import * as React from 'react' + +import { Row } from '../lib/row' +import { + Dialog, + DialogContent, + OkCancelButtonGroup, + DialogFooter, +} from '../dialog' +import { updateStore, IUpdateState, UpdateStatus } from '../lib/update-store' +import { Disposable } from 'event-kit' +import { DialogHeader } from '../dialog/header' +import { Dispatcher } from '../dispatcher' + +interface IInstallingUpdateProps { + /** + * Event triggered when the dialog is dismissed by the user in the + * ways described in the Dialog component's dismissable prop. + */ + readonly onDismissed: () => void + + readonly dispatcher: Dispatcher +} + +/** + * A dialog that presents information about the + * running application such as name and version. + */ +export class InstallingUpdate extends React.Component { + private updateStoreEventHandle: Disposable | null = null + + private onUpdateStateChanged = (updateState: IUpdateState) => { + // If the update is not being downloaded (`UpdateStatus.UpdateAvailable`), + // i.e. if it's already downloaded or not available, close the window. + if (updateState.status !== UpdateStatus.UpdateAvailable) { + this.props.dispatcher.quitApp(false) + } + } + + public componentDidMount() { + this.updateStoreEventHandle = updateStore.onDidChange( + this.onUpdateStateChanged + ) + + // Manually update the state to ensure we're in sync with the store + this.onUpdateStateChanged(updateStore.state) + } + + public componentWillUnmount() { + if (this.updateStoreEventHandle) { + this.updateStoreEventHandle.dispose() + this.updateStoreEventHandle = null + } + + // This will ensure the app doesn't try to quit after the update is + // installed once the dialog is closed (explicitly or implicitly, by + // opening another dialog on top of this one). + this.props.dispatcher.cancelQuittingApp() + } + + private onQuitAnywayButtonClicked = () => { + this.props.dispatcher.quitApp(true) + } + + public render() { + return ( + + + + + Do not close GitHub Desktop while the update is in progress. Closing + now may break your installation. + + + + + + + ) + } +} diff --git a/app/src/ui/lib/draggable.tsx b/app/src/ui/lib/draggable.tsx index 5357e927d5..5dbe91712a 100644 --- a/app/src/ui/lib/draggable.tsx +++ b/app/src/ui/lib/draggable.tsx @@ -51,10 +51,28 @@ export class Draggable extends React.Component { this.dragElement = document.getElementById('dragElement') } + /** + * A user can drag a commit if they are holding down the left mouse button or + * event.button === 0 + * + * Exceptions: + * - macOS allow emulating a right click by holding down the ctrl and left + * mouse button. + * - user can not drag during a shift click + * + * All other MouseEvent.button values are: + * 2: right button/pen barrel button + * 1: middle button + * X1, X2: mouse back/forward buttons + * 5: pen eraser + * -1: No button changed + * + * Ref: https://www.w3.org/TR/pointerevents/#the-button-property + * + * */ private canDragCommit(event: React.MouseEvent): boolean { - // right clicks or shift clicks const isSpecialClick = - event.button === 2 || + event.button !== 0 || (__DARWIN__ && event.button === 0 && event.ctrlKey) || event.shiftKey diff --git a/app/src/ui/lib/list/list-row.tsx b/app/src/ui/lib/list/list-row.tsx index dc0c762b56..d1e219913b 100644 --- a/app/src/ui/lib/list/list-row.tsx +++ b/app/src/ui/lib/list/list-row.tsx @@ -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) => void @@ -41,6 +41,18 @@ interface IListRowProps { /** callback to fire when the row receives a keyboard event */ readonly onRowKeyDown: (index: number, e: React.KeyboardEvent) => void + /** called when the row (or any of its descendants) receives focus */ + readonly onRowFocus?: ( + index: number, + e: React.FocusEvent + ) => void + + /** called when the row (and all of its descendants) loses focus */ + readonly onRowBlur?: ( + index: number, + e: React.FocusEvent + ) => 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 { + private onRef = (elem: HTMLDivElement | null) => { + this.props.onRowRef?.(this.props.rowIndex, elem) + } + private onRowMouseOver = (e: React.MouseEvent) => { this.props.onRowMouseOver(this.props.rowIndex, e) } @@ -73,6 +89,14 @@ export class ListRow extends React.Component { this.props.onRowKeyDown(this.props.rowIndex, e) } + private onFocus = (e: React.FocusEvent) => { + this.props.onRowFocus?.(this.props.rowIndex, e) + } + + private onBlur = (e: React.FocusEvent) => { + 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 { 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}
diff --git a/app/src/ui/lib/list/list.tsx b/app/src/ui/lib/list/list.tsx index 4ff4c06fa9..bed01393b2 100644 --- a/app/src/ui/lib/list/list.tsx +++ b/app/src/ui/lib/list/list.tsx @@ -269,6 +269,8 @@ export class List extends React.Component { private fakeScroll: HTMLDivElement | null = null private focusRow = -1 + private readonly rowRefs = new Map() + /** * The style prop for our child Grid. We keep this here in order * to not create a new object on each render and thus forcing @@ -567,6 +569,15 @@ export class List extends React.Component { } } + private onFocusWithinChanged = (focusWithin: boolean) => { + // So the grid lost focus (we manually focus the grid if the focused list + // item is unmounted) so we mustn't attempt to refocus the previously + // focused list item if it scrolls back into view. + if (!focusWithin) { + this.focusRow = -1 + } + } + private toggleSelection = (event: React.KeyboardEvent) => { this.props.selectedRows.forEach(row => { if (!this.props.onRowClick) { @@ -586,19 +597,26 @@ export class List extends React.Component { }) } + private onRowFocus = (index: number, e: React.FocusEvent) => { + this.focusRow = index + } + + private onRowBlur = (index: number, e: React.FocusEvent) => { + if (this.focusRow === index) { + this.focusRow = -1 + } + } + private onRowMouseOver = (row: number, event: React.MouseEvent) => { 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 { 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 { } } - 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 { 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 { { 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 { { + 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 ( + +
+ {contentTitle} + +
+
+ {this.props.children} +
+
+ ) + } + + public render() { + const { className, buttonContent, label } = this.props + const cn = classNames('popover-dropdown-component', className) + + return ( +
+ + {this.renderPopover()} +
+ ) + } +} diff --git a/app/src/ui/lib/tooltip.tsx b/app/src/ui/lib/tooltip.tsx index 6e4b05abbf..a724608423 100644 --- a/app/src/ui/lib/tooltip.tsx +++ b/app/src/ui/lib/tooltip.tsx @@ -299,7 +299,13 @@ export class Tooltip 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 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) => { diff --git a/app/src/ui/lib/update-branch.ts b/app/src/ui/lib/update-branch.ts index 4c32641667..49bb59c8e2 100644 --- a/app/src/ui/lib/update-branch.ts +++ b/app/src/ui/lib/update-branch.ts @@ -7,7 +7,9 @@ import { RebasePreview } from '../../models/rebase' import { Repository } from '../../models/repository' import { IDropdownSelectButtonOption } from '../dropdown-select-button' -export function getMergeOptions(): ReadonlyArray { +export function getMergeOptions(): ReadonlyArray< + IDropdownSelectButtonOption +> { return [ { label: 'Create a merge commit', diff --git a/app/src/ui/main-process-proxy.ts b/app/src/ui/main-process-proxy.ts index 710c04378c..7d43837163 100644 --- a/app/src/ui/main-process-proxy.ts +++ b/app/src/ui/main-process-proxy.ts @@ -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 */ diff --git a/app/src/ui/multi-commit-operation/choose-branch/base-choose-branch-dialog.tsx b/app/src/ui/multi-commit-operation/choose-branch/base-choose-branch-dialog.tsx index dedda9490c..1b31d8deb3 100644 --- a/app/src/ui/multi-commit-operation/choose-branch/base-choose-branch-dialog.tsx +++ b/app/src/ui/multi-commit-operation/choose-branch/base-choose-branch-dialog.tsx @@ -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 + ) => { 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}`) } } diff --git a/app/src/ui/open-pull-request/open-pull-request-dialog.tsx b/app/src/ui/open-pull-request/open-pull-request-dialog.tsx index 391ddbbf60..44cc1628ba 100644 --- a/app/src/ui/open-pull-request/open-pull-request-dialog.tsx +++ b/app/src/ui/open-pull-request/open-pull-request-dialog.tsx @@ -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 + readonly prBaseBranches: ReadonlyArray /** - * 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 + readonly prRecentBaseBranches: ReadonlyArray + + /** 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 { 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 ) } private renderContent() { - return
Content
+ return ( +
+ {this.renderNoChanges()} + {this.renderNoDefaultBranch()} + {this.renderFilesChanged()} +
+ ) + } + + 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 ( + + ) + } + + 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 ? ( + <> + {baseBranch.name} is up to date with all commits from{' '} + {currentBranch.name}. + + ) : ( + <> + {baseBranch.name} and {currentBranch.name} are + entirely different commit histories. + + ) + return ( +
+
+ +

There are no changes.

+ {message} +
+
+ ) + } + + private renderNoDefaultBranch() { + const { baseBranch } = this.props.pullRequestState + + if (baseBranch !== null) { + return + } + + return ( +
+
+ +

Could not find a default branch to compare against.

+ Select a base branch above. +
+
+ ) } 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 && ( + + )} + {__DARWIN__ + ? `${viewCreate} Pull Request` + : `${viewCreate} pull request`} + + ) + return ( + + ) @@ -93,8 +271,7 @@ export class OpenPullRequestDialog extends React.Component {this.renderHeader()} -
{this.renderContent()}
- + {this.renderContent()} {this.renderFooter()} ) diff --git a/app/src/ui/open-pull-request/open-pull-request-header.tsx b/app/src/ui/open-pull-request/open-pull-request-header.tsx index e6b6045308..5b0b30273c 100644 --- a/app/src/ui/open-pull-request/open-pull-request-header.tsx +++ b/app/src/ui/open-pull-request/open-pull-request-header.tsx @@ -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 + readonly prBaseBranches: ReadonlyArray /** - * 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 + readonly prRecentBaseBranches: ReadonlyArray /** 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 ( @@ -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.
+ You can only open pull requests against remote branches. + + } />{' '} from {currentBranch.name}.
diff --git a/app/src/ui/open-pull-request/pull-request-files-changed.tsx b/app/src/ui/open-pull-request/pull-request-files-changed.tsx new file mode 100644 index 0000000000..188759104e --- /dev/null +++ b/app/src/ui/open-pull-request/pull-request-files-changed.tsx @@ -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 + + /** 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 + ) => { + 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 ( +
+
+ Showing changes from all commits +
+ +
+ ) + } + + private renderFileList() { + const { files, selectedFile, fileListWidth } = this.props + + return ( + + + + ) + } + + private renderDiff() { + const { selectedFile } = this.props + + if (selectedFile === null) { + return + } + + const { diff, repository, imageDiffType, hideWhitespaceInDiff } = this.props + + const { showSideBySideDiff } = this.state + + return ( + + ) + } + + public render() { + return ( +
+ {this.renderHeader()} +
+ {this.renderFileList()} + {this.renderDiff()} +
+
+ ) + } +} diff --git a/app/src/ui/open-pull-request/pull-request-merge-status.tsx b/app/src/ui/open-pull-request/pull-request-merge-status.tsx new file mode 100644 index 0000000000..beebe4ddfe --- /dev/null +++ b/app/src/ui/open-pull-request/pull-request-merge-status.tsx @@ -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 { + private getMergeStatusDescription = () => { + const { mergeStatus } = this.props + if (mergeStatus === null) { + return '' + } + + const { kind } = mergeStatus + switch (kind) { + case ComputedAction.Loading: + return ( + + Checking mergeability… Don’t worry, you can + still create the pull request. + + ) + case ComputedAction.Invalid: + return ( + + Error checking merge status. Unable to merge + unrelated histories in this repository + + ) + case ComputedAction.Clean: + return ( + + + Able to merge. + {' '} + These branches can be automatically merged. + + ) + case ComputedAction.Conflicts: + return ( + + + Can't automatically merge. + {' '} + Don’t worry, you can still create the pull request. + + ) + default: + return assertNever(kind, `Unknown merge status kind of ${kind}.`) + } + } + + public render() { + return ( +
+ {this.getMergeStatusDescription()} +
+ ) + } +} diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index c66de88fc0..e4616e9474 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -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 ) diff --git a/app/src/ui/preferences/prompts.tsx b/app/src/ui/preferences/prompts.tsx index 30ee4d83fd..ef65ec2738 100644 --- a/app/src/ui/preferences/prompts.tsx +++ b/app/src/ui/preferences/prompts.tsx @@ -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 + ) => { + const value = event.currentTarget.checked + + this.setState({ confirmDiscardStash: value }) + this.props.onConfirmDiscardStashChanged(value) + } + private onConfirmForcePushChanged = ( event: React.FormEvent ) => { @@ -116,6 +129,15 @@ export class Prompts extends React.Component< } onChange={this.onConfirmDiscardChangesPermanentlyChanged} /> + { 'its tracked branch.' return ( -
+ {ahead > 0 && } {behind > 0 && } -
+ ) } diff --git a/app/src/ui/repository.tsx b/app/src/ui/repository.tsx index 7adc738394..2b0d4f8284 100644 --- a/app/src/ui/repository.tsx +++ b/app/src/ui/repository.tsx @@ -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 @@ -91,6 +93,9 @@ interface IRepositoryViewProps { repository: Repository, commits: ReadonlyArray ) => 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 + } /> ) } diff --git a/app/src/ui/stashing/confirm-discard-stash.tsx b/app/src/ui/stashing/confirm-discard-stash.tsx index 29917743a1..9a338e2d9b 100644 --- a/app/src/ui/stashing/confirm-discard-stash.tsx +++ b/app/src/ui/stashing/confirm-discard-stash.tsx @@ -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< > Are you sure you want to discard these stashed changes? + + + @@ -54,6 +69,14 @@ export class ConfirmDiscardStashDialog extends React.Component< ) } + private onAskForConfirmationOnDiscardStashChanged = ( + event: React.FormEvent + ) => { + 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({ diff --git a/app/src/ui/stashing/stash-diff-header.tsx b/app/src/ui/stashing/stash-diff-header.tsx index b631a559ba..c9b8bbbacd 100644 --- a/app/src/ui/stashing/stash-diff-header.tsx +++ b/app/src/ui/stashing/stash-diff-header.tsx @@ -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 (
@@ -44,10 +47,12 @@ export class StashDiffHeader extends React.Component<
{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 () => { diff --git a/app/src/ui/stashing/stash-diff-viewer.tsx b/app/src/ui/stashing/stash-diff-viewer.tsx index bc3b467556..fc9baecaba 100644 --- a/app/src/ui/stashing/stash-diff-viewer.tsx +++ b/app/src/ui/stashing/stash-diff-viewer.tsx @@ -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 repository={repository} dispatcher={dispatcher} isWorkingTreeClean={isWorkingTreeClean} + askForConfirmationOnDiscardStash={ + this.props.askForConfirmationOnDiscardStash + } />
+ extends IDropdownSelectButtonOption { + /** + * 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) => 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 { + /** The possible suggested next actions to select from + * + * This component assumes this is not an empty array. + */ + readonly suggestedActions: ReadonlyArray> + + /** 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 { + readonly selectedAction: IDropdownSuggestedActionOption +} + +export class DropdownSuggestedAction extends React.Component< + IDropdownSuggestedActionProps, + IDropdownSuggestedActionState +> { + public constructor(props: IDropdownSuggestedActionProps) { + 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 + ) => { + 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) => { + 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 ( +
+ {image &&
{image}
} +
+

{title}

+ {description &&

{description}

} + {discoverabilityContent && ( +

{discoverabilityContent}

+ )} +
+ + selectedValue={value} + options={this.props.suggestedActions.map(({ label, value }) => ({ + label, + value, + }))} + disabled={disabled} + onSelectChange={this.onActionSelectionChange} + onSubmit={this.onActionSubmitted} + /> +
+ ) + } +} diff --git a/app/styles/_ui.scss b/app/styles/_ui.scss index 7b65c88757..1122e15c7d 100644 --- a/app/styles/_ui.scss +++ b/app/styles/_ui.scss @@ -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'; diff --git a/app/styles/ui/_branch-select.scss b/app/styles/ui/_branch-select.scss index 38c9a62fc8..e69de29bb2 100644 --- a/app/styles/ui/_branch-select.scss +++ b/app/styles/ui/_branch-select.scss @@ -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; - } - } -} diff --git a/app/styles/ui/_dialog.scss b/app/styles/ui/_dialog.scss index f9e5550a87..3669ea9fd1 100644 --- a/app/styles/ui/_dialog.scss +++ b/app/styles/ui/_dialog.scss @@ -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 // and

. 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 { diff --git a/app/styles/ui/_dropdown-select-button.scss b/app/styles/ui/_dropdown-select-button.scss index a4d25cec09..c720e05432 100644 --- a/app/styles/ui/_dropdown-select-button.scss +++ b/app/styles/ui/_dropdown-select-button.scss @@ -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); } } } diff --git a/app/styles/ui/_popover-dropdown.scss b/app/styles/ui/_popover-dropdown.scss new file mode 100644 index 0000000000..6b8b20dafb --- /dev/null +++ b/app/styles/ui/_popover-dropdown.scss @@ -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; + } + } +} diff --git a/app/styles/ui/_pull-request-files-changed.scss b/app/styles/ui/_pull-request-files-changed.scss new file mode 100644 index 0000000000..55d72dadbd --- /dev/null +++ b/app/styles/ui/_pull-request-files-changed.scss @@ -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); + } +} diff --git a/app/styles/ui/_pull-request-merge-status.scss b/app/styles/ui/_pull-request-merge-status.scss new file mode 100644 index 0000000000..f9d3377ae6 --- /dev/null +++ b/app/styles/ui/_pull-request-merge-status.scss @@ -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); + } + } +} diff --git a/app/styles/ui/_repository-list.scss b/app/styles/ui/_repository-list.scss index 044b1b8aa3..6adfa2d5b6 100644 --- a/app/styles/ui/_repository-list.scss +++ b/app/styles/ui/_repository-list.scss @@ -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); + } + } } } diff --git a/app/styles/ui/_side-by-side-diff.scss b/app/styles/ui/_side-by-side-diff.scss index b16bcb85c2..ba805c7fe2 100644 --- a/app/styles/ui/_side-by-side-diff.scss +++ b/app/styles/ui/_side-by-side-diff.scss @@ -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); + } } } diff --git a/app/styles/ui/changes/_changes-interstitial.scss b/app/styles/ui/changes/_changes-interstitial.scss index b147fc15fc..d07cb1783c 100644 --- a/app/styles/ui/changes/_changes-interstitial.scss +++ b/app/styles/ui/changes/_changes-interstitial.scss @@ -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); diff --git a/app/styles/ui/dialogs/_installing-update.scss b/app/styles/ui/dialogs/_installing-update.scss new file mode 100644 index 0000000000..07e424e165 --- /dev/null +++ b/app/styles/ui/dialogs/_installing-update.scss @@ -0,0 +1,7 @@ +#installing-update { + max-width: 400px; + + .updating-message { + align-items: center; + } +} diff --git a/app/styles/ui/dialogs/_open-pull-request.scss b/app/styles/ui/dialogs/_open-pull-request.scss index b981ddd470..7922f42237 100644 --- a/app/styles/ui/dialogs/_open-pull-request.scss +++ b/app/styles/ui/dialogs/_open-pull-request.scss @@ -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; + } } diff --git a/app/styles/ui/history/_commit-summary.scss b/app/styles/ui/history/_commit-summary.scss index 0a025c2af2..5518f52ad2 100644 --- a/app/styles/ui/history/_commit-summary.scss +++ b/app/styles/ui/history/_commit-summary.scss @@ -175,7 +175,7 @@ vertical-align: bottom; // For some reason, `bottom` places the text in the middle } - .sha { + .selectable { user-select: text; } diff --git a/app/styles/ui/window/_tooltips.scss b/app/styles/ui/window/_tooltips.scss index 97282eda85..27f84b7439 100644 --- a/app/styles/ui/window/_tooltips.scss +++ b/app/styles/ui/window/_tooltips.scss @@ -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); diff --git a/app/test/unit/git/diff-test.ts b/app/test/unit/git/diff-test.ts index 4ba2f5d8fd..8e1a2f198d 100644 --- a/app/test/unit/git/diff-test.ts +++ b/app/test/unit/git/diff-test.ts @@ -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', () => { diff --git a/app/test/unit/popup-manager-test.ts b/app/test/unit/popup-manager-test.ts new file mode 100644 index 0000000000..85eee5759f --- /dev/null +++ b/app/test/unit/popup-manager-test.ts @@ -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) + }) + }) +}) diff --git a/app/yarn.lock b/app/yarn.lock index 4458395bd4..cc5a1aa065 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -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" diff --git a/changelog.json b/changelog.json index 0efc6ecb4c..8ab4453b72 100644 --- a/changelog.json +++ b/changelog.json @@ -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" ], diff --git a/docs/contributing/setup.md b/docs/contributing/setup.md index 2b1f3d7020..05a6819e98 100644 --- a/docs/contributing/setup.md +++ b/docs/contributing/setup.md @@ -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 diff --git a/docs/integrations/gitlab.md b/docs/integrations/gitlab.md index 0cba8d3d42..04103e9178 100644 --- a/docs/integrations/gitlab.md +++ b/docs/integrations/gitlab.md @@ -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. diff --git a/docs/technical/editor-integration.md b/docs/technical/editor-integration.md index 095c4cbc08..b0a0480d68 100644 --- a/docs/technical/editor-integration.md +++ b/docs/technical/editor-integration.md @@ -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: diff --git a/package.json b/package.json index 1212703dc9..7c345c3e80 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/script/package.ts b/script/package.ts index 7f8566adb2..8cfd8f03c7 100644 --- a/script/package.ts +++ b/script/package.ts @@ -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()) { diff --git a/yarn.lock b/yarn.lock index 8ee974004b..fa2dcf36f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==