diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index b359d10533..c9a249428b 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.1.4 if: | startsWith(github.ref, 'refs/heads/releases/') && !contains(github.ref, 'test') with: diff --git a/README.md b/README.md index b572740774..6a2a683452 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,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 +84,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/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 1f0e0d42f7..08ead91bc3 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -170,6 +170,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 +197,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 +236,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 @@ -965,4 +974,7 @@ export interface IPullRequestState { * diff between the latest commit and the earliest commits parent. */ readonly commitSelection: ICommitSelection + + /** 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/linux.ts b/app/src/lib/editors/linux.ts index 84806820d7..de7953eb3d 100644 --- a/app/src/lib/editors/linux.ts +++ b/app/src/lib/editors/linux.ts @@ -62,6 +62,14 @@ 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'], + }, ] async function getAvailablePath(paths: string[]): Promise { diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index dccc1a383d..f411e68b1a 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -110,5 +110,5 @@ export function enableSubmoduleDiff(): boolean { /** Should we enable starting pull requests? */ export function enableStartingPullRequests(): boolean { - return enableDevelopmentFeatures() + 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/stores/app-store.ts b/app/src/lib/stores/app-store.ts index f128b4d750..afed3ee5c9 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -323,15 +323,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 +353,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' @@ -424,6 +432,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 +446,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 +454,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 @@ -901,6 +913,7 @@ export class AppStore extends TypedBaseStore { 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 +926,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 +934,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, @@ -1426,17 +1441,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 +1453,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 +1467,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, @@ -1951,8 +1970,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 +1998,11 @@ export class AppStore extends TypedBaseStore { confirmDiscardChangesPermanentlyDefault ) + this.confirmDiscardStash = getBoolean( + confirmDiscardStashKey, + confirmDiscardStashDefault + ) + this.askForConfirmationOnForcePush = getBoolean( confirmForcePushKey, askForConfirmationOnForcePushDefault @@ -2011,6 +2040,10 @@ export class AppStore extends TypedBaseStore { hideWhitespaceInHistoryDiffKey, false ) + this.hideWhitespaceInPullRequestDiff = getBoolean( + hideWhitespaceInPullRequestDiffKey, + false + ) this.commitSpellcheckEnabled = getBoolean( commitSpellcheckEnabledKey, commitSpellcheckEnabledDefault @@ -2077,6 +2110,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 { @@ -5193,6 +5261,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 +5356,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) @@ -7146,27 +7236,36 @@ export class AppStore extends TypedBaseStore { if (defaultBranch === null || tip.kind !== TipState.Valid) { return } - const currentBranch = tip.branch + this._initializePullRequestPreview(repository, defaultBranch, currentBranch) + } + + private async _initializePullRequestPreview( + repository: Repository, + baseBranch: Branch, + currentBranch: Branch + ) { + const { branchesState, localCommitSHAs } = + this.repositoryStateCache.get(repository) 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 @@ -7175,25 +7274,64 @@ 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] ) } + + const { allBranches, recentBranches, defaultBranch } = branchesState + const { imageDiffType, selectedExternalEditor, showSideBySideDiff } = + this.getState() + + this._showPopup({ + type: PopupType.StartPullRequest, + allBranches, + currentBranch, + defaultBranch, + imageDiffType, + recentBranches, + repository, + externalEditorLabel: selectedExternalEditor ?? undefined, + nonLocalCommitSHA: + commitSHAs.length > 0 && !localCommitSHAs.includes(commitSHAs[0]) + ? commitSHAs[0] + : null, + showSideBySideDiff, + }) } public async _changePullRequestFileSelection( @@ -7223,6 +7361,7 @@ export class AppStore extends TypedBaseStore { diff: null, }) ) + this.emitUpdate() if (commitSHAs.length === 0) { @@ -7240,7 +7379,7 @@ export class AppStore extends TypedBaseStore { file, baseBranch.name, currentBranch.name, - this.hideWhitespaceInHistoryDiff, + this.hideWhitespaceInPullRequestDiff, commitSHAs[0] ) )) ?? null @@ -7263,6 +7402,66 @@ 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() + } + ) + } } /** diff --git a/app/src/main-process/menu/build-default-menu.ts b/app/src/main-process/menu/build-default-menu.ts index b01d63f30c..5da9141700 100644 --- a/app/src/main-process/menu/build-default-menu.ts +++ b/app/src/main-process/menu/build-default-menu.ts @@ -640,7 +640,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/models/popup.ts b/app/src/models/popup.ts index e8ab5268fa..57cc87c866 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' @@ -362,4 +362,13 @@ export type Popup = } | { type: PopupType.StartPullRequest + allBranches: ReadonlyArray + currentBranch: Branch + defaultBranch: Branch | null + externalEditorLabel?: string + imageDiffType: ImageDiffType + recentBranches: ReadonlyArray + repository: Repository + nonLocalCommitSHA: string | null + showSideBySideDiff: boolean } diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 7ae6ef43b2..8a97914cbf 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, @@ -158,6 +157,8 @@ 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' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -1481,6 +1482,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} @@ -1849,6 +1851,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 { + allBranches, + currentBranch, + defaultBranch, + imageDiffType, + externalEditorLabel, + nonLocalCommitSHA, + recentBranches, + repository, + showSideBySideDiff, + } = popup return ( { currentBranch={currentBranch} defaultBranch={defaultBranch} dispatcher={this.props.dispatcher} + fileListWidth={pullRequestFilesListWidth} + hideWhitespaceInDiff={hideWhitespaceInPullRequestDiff} + imageDiffType={imageDiffType} + nonLocalCommitSHA={nonLocalCommitSHA} pullRequestState={pullRequestState} recentBranches={recentBranches} repository={repository} + externalEditorLabel={externalEditorLabel} + showSideBySideDiff={showSideBySideDiff} onDismissed={onPopupDismissedFn} /> ) @@ -2282,6 +2303,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 @@ -2955,6 +2988,9 @@ export class App extends React.Component { askForConfirmationOnDiscardChanges={ state.askForConfirmationOnDiscardChanges } + askForConfirmationOnDiscardStash={ + state.askForConfirmationOnDiscardStash + } accounts={state.accounts} externalEditorLabel={externalEditorLabel} resolvedExternalEditor={state.resolvedExternalEditor} @@ -3049,22 +3085,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-select.tsx b/app/src/ui/branches/branch-select.tsx index a3cd692189..e79b5c6742 100644 --- a/app/src/ui/branches/branch-select.tsx +++ b/app/src/ui/branches/branch-select.tsx @@ -1,19 +1,12 @@ 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 @@ -43,10 +36,8 @@ interface IBranchSelectProps { } interface IBranchSelectState { - readonly showBranchDropdown: boolean readonly selectedBranch: Branch | null readonly filterText: string - readonly dropdownListHeight: number } /** @@ -56,67 +47,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 +73,32 @@ export class BranchSelect extends React.Component< this.setState({ filterText }) } - public renderBranchDropdown() { - if (!this.state.showBranchDropdown) { - return - } - + public render() { const { currentBranch, defaultBranch, recentBranches, allBranches } = this.props - const { filterText, selectedBranch, dropdownListHeight } = this.state + const { filterText, selectedBranch } = this.state return ( - -
- Choose a base branch - -
-
- -
-
- ) - } - - public render() { - return ( -
- - {this.renderBranchDropdown()} -
+ +
) } } 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 ( 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/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 8e73e48909..897940c089 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -2122,6 +2122,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) @@ -2338,6 +2351,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) } @@ -3963,9 +3980,33 @@ 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) } } diff --git a/app/src/ui/history/commit-summary.tsx b/app/src/ui/history/commit-summary.tsx index 2182f63011..af82a4156e 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' @@ -505,7 +504,7 @@ export class CommitSummary extends React.Component< title="Diff Options" > { + 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/multi-commit-operation/base-multi-commit-operation.tsx b/app/src/ui/multi-commit-operation/base-multi-commit-operation.tsx index aed59470f6..e3266ea7fe 100644 --- a/app/src/ui/multi-commit-operation/base-multi-commit-operation.tsx +++ b/app/src/ui/multi-commit-operation/base-multi-commit-operation.tsx @@ -93,6 +93,8 @@ export abstract class BaseMultiCommitOperation extends React.Component{targetBranch.name} : null} ) + + this.props.dispatcher.closePopup(PopupType.MultiCommitOperation) return dispatcher.onConflictsFoundBanner( repository, operationDescription, 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..4a470907dd 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 @@ -35,6 +43,25 @@ interface IOpenPullRequestDialogProps { */ readonly recentBranches: 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 + /** Called to dismiss the dialog */ readonly onDismissed: () => void } @@ -47,6 +74,11 @@ export class OpenPullRequestDialog extends React.Component { + const { repository } = this.props + this.props.dispatcher.updatePullRequestBaseBranch(repository, branch) + } + private renderHeader() { const { currentBranch, @@ -64,22 +96,107 @@ export class OpenPullRequestDialog extends React.Component ) } private renderContent() { - return
Content
+ return ( +
+ {this.renderNoChanges()} + {this.renderFilesChanged()} +
+ ) + } + + private renderFilesChanged() { + const { + dispatcher, + externalEditorLabel, + hideWhitespaceInDiff, + imageDiffType, + pullRequestState, + repository, + fileListWidth, + nonLocalCommitSHA, + } = this.props + const { commitSelection } = pullRequestState + 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 + 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 renderFooter() { + const { mergeStatus, commitSHAs } = this.props.pullRequestState + const gitHubRepository = this.props.repository.gitHubRepository + const isEnterprise = + gitHubRepository && gitHubRepository.endpoint !== getDotComAPIEndpoint() + const buttonTitle = `Create pull request on GitHub${ + isEnterprise ? ' Enterprise' : '' + }.` + return ( + ) @@ -93,8 +210,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..b9e376387b 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 @@ -30,6 +30,9 @@ interface IOpenPullRequestDialogHeaderProps { /** 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. @@ -54,6 +57,7 @@ export class OpenPullRequestDialogHeader extends React.Component< allBranches, recentBranches, commitCount, + onBranchChange, onDismissed, } = this.props const commits = `${commitCount} commit${commitCount > 1 ? 's' : ''}` @@ -74,6 +78,7 @@ export class OpenPullRequestDialogHeader extends React.Component< currentBranch={currentBranch} allBranches={allBranches} recentBranches={recentBranches} + onChange={onBranchChange} />{' '} 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} /> + @@ -355,6 +356,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} 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 + } />
.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/changelog.json b/changelog.json index 65f6ee4838..fd08326d8d 100644 --- a/changelog.json +++ b/changelog.json @@ -1,6 +1,12 @@ { "releases": { "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/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()) {