Merge branch 'development' into releases/3.1.2

This commit is contained in:
Sergio Padrino 2022-10-20 10:39:33 +02:00 committed by GitHub
commit 9a89aa1eed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1350 additions and 283 deletions

View file

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

View file

@ -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 There are several community-supported package managers that can be used to
install GitHub Desktop: install GitHub Desktop:
- Windows users can install using [Chocolatey](https://chocolatey.org/) package manager: - 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`
`c:\> choco install github-desktop`
- macOS users can install using [Homebrew](https://brew.sh/) package manager: - macOS users can install using [Homebrew](https://brew.sh/) package manager:
`$ brew install --cask github` `$ 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. 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 ## More Resources
See [desktop.github.com](https://desktop.github.com) for more product-oriented See [desktop.github.com](https://desktop.github.com) for more product-oriented

View file

@ -170,6 +170,9 @@ export interface IAppState {
/** The width of the files list in the stash view */ /** The width of the files list in the stash view */
readonly stashedFilesWidth: IConstrainedValue 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 * Used to highlight access keys throughout the app when the
* Alt key is pressed. Only applicable on non-macOS platforms. * Alt key is pressed. Only applicable on non-macOS platforms.
@ -194,6 +197,9 @@ export interface IAppState {
/** Whether we should show a confirmation dialog */ /** Whether we should show a confirmation dialog */
readonly askForConfirmationOnDiscardChangesPermanently: boolean 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? */ /** Should the app prompt the user to confirm a force push? */
readonly askForConfirmationOnForcePush: boolean readonly askForConfirmationOnForcePush: boolean
@ -230,6 +236,9 @@ export interface IAppState {
/** Whether we should hide white space changes in history diff */ /** Whether we should hide white space changes in history diff */
readonly hideWhitespaceInHistoryDiff: boolean 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 */ /** Whether we should show side by side diffs */
readonly showSideBySideDiff: boolean readonly showSideBySideDiff: boolean
@ -965,4 +974,7 @@ export interface IPullRequestState {
* diff between the latest commit and the earliest commits parent. * diff between the latest commit and the earliest commits parent.
*/ */
readonly commitSelection: ICommitSelection readonly commitSelection: ICommitSelection
/** The result of merging the pull request branch into the base branch */
readonly mergeStatus: MergeTreeResult | null
} }

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

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

View file

@ -62,6 +62,14 @@ const editors: ILinuxExternalEditor[] = [
name: 'Lite XL', name: 'Lite XL',
paths: ['/usr/bin/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<string | null> { async function getAvailablePath(paths: string[]): Promise<string | null> {

View file

@ -110,5 +110,5 @@ export function enableSubmoduleDiff(): boolean {
/** Should we enable starting pull requests? */ /** Should we enable starting pull requests? */
export function enableStartingPullRequests(): boolean { export function enableStartingPullRequests(): boolean {
return enableDevelopmentFeatures() return enableBetaFeatures()
} }

View file

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

View file

@ -323,15 +323,20 @@ const commitSummaryWidthConfigKey: string = 'commit-summary-width'
const defaultStashedFilesWidth: number = 250 const defaultStashedFilesWidth: number = 250
const stashedFilesWidthConfigKey: string = 'stashed-files-width' const stashedFilesWidthConfigKey: string = 'stashed-files-width'
const defaultPullRequestFileListWidth: number = 250
const pullRequestFileListConfigKey: string = 'pull-request-files-width'
const askToMoveToApplicationsFolderDefault: boolean = true const askToMoveToApplicationsFolderDefault: boolean = true
const confirmRepoRemovalDefault: boolean = true const confirmRepoRemovalDefault: boolean = true
const confirmDiscardChangesDefault: boolean = true const confirmDiscardChangesDefault: boolean = true
const confirmDiscardChangesPermanentlyDefault: boolean = true const confirmDiscardChangesPermanentlyDefault: boolean = true
const confirmDiscardStashDefault: boolean = true
const askForConfirmationOnForcePushDefault = true const askForConfirmationOnForcePushDefault = true
const confirmUndoCommitDefault: boolean = true const confirmUndoCommitDefault: boolean = true
const askToMoveToApplicationsFolderKey: string = 'askToMoveToApplicationsFolder' const askToMoveToApplicationsFolderKey: string = 'askToMoveToApplicationsFolder'
const confirmRepoRemovalKey: string = 'confirmRepoRemoval' const confirmRepoRemovalKey: string = 'confirmRepoRemoval'
const confirmDiscardChangesKey: string = 'confirmDiscardChanges' const confirmDiscardChangesKey: string = 'confirmDiscardChanges'
const confirmDiscardStashKey: string = 'confirmDiscardStash'
const confirmDiscardChangesPermanentlyKey: string = const confirmDiscardChangesPermanentlyKey: string =
'confirmDiscardChangesPermanentlyKey' 'confirmDiscardChangesPermanentlyKey'
const confirmForcePushKey: string = 'confirmForcePush' const confirmForcePushKey: string = 'confirmForcePush'
@ -348,6 +353,9 @@ const hideWhitespaceInChangesDiffDefault = false
const hideWhitespaceInChangesDiffKey = 'hide-whitespace-in-changes-diff' const hideWhitespaceInChangesDiffKey = 'hide-whitespace-in-changes-diff'
const hideWhitespaceInHistoryDiffDefault = false const hideWhitespaceInHistoryDiffDefault = false
const hideWhitespaceInHistoryDiffKey = 'hide-whitespace-in-diff' const hideWhitespaceInHistoryDiffKey = 'hide-whitespace-in-diff'
const hideWhitespaceInPullRequestDiffDefault = false
const hideWhitespaceInPullRequestDiffKey =
'hide-whitespace-in-pull-request-diff'
const commitSpellcheckEnabledDefault = true const commitSpellcheckEnabledDefault = true
const commitSpellcheckEnabledKey = 'commit-spellcheck-enabled' const commitSpellcheckEnabledKey = 'commit-spellcheck-enabled'
@ -424,6 +432,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private sidebarWidth = constrain(defaultSidebarWidth) private sidebarWidth = constrain(defaultSidebarWidth)
private commitSummaryWidth = constrain(defaultCommitSummaryWidth) private commitSummaryWidth = constrain(defaultCommitSummaryWidth)
private stashedFilesWidth = constrain(defaultStashedFilesWidth) private stashedFilesWidth = constrain(defaultStashedFilesWidth)
private pullRequestFileListWidth = constrain(defaultPullRequestFileListWidth)
private windowState: WindowState | null = null private windowState: WindowState | null = null
private windowZoomFactor: number = 1 private windowZoomFactor: number = 1
@ -437,6 +446,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private confirmDiscardChanges: boolean = confirmDiscardChangesDefault private confirmDiscardChanges: boolean = confirmDiscardChangesDefault
private confirmDiscardChangesPermanently: boolean = private confirmDiscardChangesPermanently: boolean =
confirmDiscardChangesPermanentlyDefault confirmDiscardChangesPermanentlyDefault
private confirmDiscardStash: boolean = confirmDiscardStashDefault
private askForConfirmationOnForcePush = askForConfirmationOnForcePushDefault private askForConfirmationOnForcePush = askForConfirmationOnForcePushDefault
private confirmUndoCommit: boolean = confirmUndoCommitDefault private confirmUndoCommit: boolean = confirmUndoCommitDefault
private imageDiffType: ImageDiffType = imageDiffTypeDefault private imageDiffType: ImageDiffType = imageDiffTypeDefault
@ -444,6 +454,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
hideWhitespaceInChangesDiffDefault hideWhitespaceInChangesDiffDefault
private hideWhitespaceInHistoryDiff: boolean = private hideWhitespaceInHistoryDiff: boolean =
hideWhitespaceInHistoryDiffDefault hideWhitespaceInHistoryDiffDefault
private hideWhitespaceInPullRequestDiff: boolean =
hideWhitespaceInPullRequestDiffDefault
/** Whether or not the spellchecker is enabled for commit summary and description */ /** Whether or not the spellchecker is enabled for commit summary and description */
private commitSpellcheckEnabled: boolean = commitSpellcheckEnabledDefault private commitSpellcheckEnabled: boolean = commitSpellcheckEnabledDefault
private showSideBySideDiff: boolean = ShowSideBySideDiffDefault private showSideBySideDiff: boolean = ShowSideBySideDiffDefault
@ -901,6 +913,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
sidebarWidth: this.sidebarWidth, sidebarWidth: this.sidebarWidth,
commitSummaryWidth: this.commitSummaryWidth, commitSummaryWidth: this.commitSummaryWidth,
stashedFilesWidth: this.stashedFilesWidth, stashedFilesWidth: this.stashedFilesWidth,
pullRequestFilesListWidth: this.pullRequestFileListWidth,
appMenuState: this.appMenu ? this.appMenu.openMenus : [], appMenuState: this.appMenu ? this.appMenu.openMenus : [],
highlightAccessKeys: this.highlightAccessKeys, highlightAccessKeys: this.highlightAccessKeys,
isUpdateAvailableBannerVisible: this.isUpdateAvailableBannerVisible, isUpdateAvailableBannerVisible: this.isUpdateAvailableBannerVisible,
@ -913,6 +926,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
askForConfirmationOnDiscardChanges: this.confirmDiscardChanges, askForConfirmationOnDiscardChanges: this.confirmDiscardChanges,
askForConfirmationOnDiscardChangesPermanently: askForConfirmationOnDiscardChangesPermanently:
this.confirmDiscardChangesPermanently, this.confirmDiscardChangesPermanently,
askForConfirmationOnDiscardStash: this.confirmDiscardStash,
askForConfirmationOnForcePush: this.askForConfirmationOnForcePush, askForConfirmationOnForcePush: this.askForConfirmationOnForcePush,
askForConfirmationOnUndoCommit: this.confirmUndoCommit, askForConfirmationOnUndoCommit: this.confirmUndoCommit,
uncommittedChangesStrategy: this.uncommittedChangesStrategy, uncommittedChangesStrategy: this.uncommittedChangesStrategy,
@ -920,6 +934,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
imageDiffType: this.imageDiffType, imageDiffType: this.imageDiffType,
hideWhitespaceInChangesDiff: this.hideWhitespaceInChangesDiff, hideWhitespaceInChangesDiff: this.hideWhitespaceInChangesDiff,
hideWhitespaceInHistoryDiff: this.hideWhitespaceInHistoryDiff, hideWhitespaceInHistoryDiff: this.hideWhitespaceInHistoryDiff,
hideWhitespaceInPullRequestDiff: this.hideWhitespaceInPullRequestDiff,
showSideBySideDiff: this.showSideBySideDiff, showSideBySideDiff: this.showSideBySideDiff,
selectedShell: this.selectedShell, selectedShell: this.selectedShell,
repositoryFilterText: this.repositoryFilterText, repositoryFilterText: this.repositoryFilterText,
@ -1426,17 +1441,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
} }
if (tip.kind === TipState.Valid && aheadBehind.behind > 0) { if (tip.kind === TipState.Valid && aheadBehind.behind > 0) {
const mergeTreePromise = promiseWithMinimumTimeout( this.currentMergeTreePromise = this.setupMergabilityPromise(
() => determineMergeability(repository, tip.branch, action.branch), repository,
500 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 => { .then(mergeStatus => {
this.repositoryStateCache.updateCompareState(repository, () => ({ this.repositoryStateCache.updateCompareState(repository, () => ({
mergeStatus, mergeStatus,
@ -1444,16 +1453,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.emitUpdate() this.emitUpdate()
}) })
.finally(() => {
const cleanup = () => { this.currentMergeTreePromise = null
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
return this.currentMergeTreePromise return this.currentMergeTreePromise
} else { } else {
@ -1465,6 +1467,23 @@ export class AppStore extends TypedBaseStore<IAppState> {
} }
} }
private setupMergabilityPromise(
repository: Repository,
baseBranch: Branch,
compareBranch: Branch
) {
return promiseWithMinimumTimeout(
() => determineMergeability(repository, baseBranch, compareBranch),
500
).catch(err => {
log.warn(
`Error occurred while trying to merge ${baseBranch.name} (${baseBranch.tip.sha}) and ${compareBranch.name} (${compareBranch.tip.sha})`,
err
)
return null
})
}
/** This shouldn't be called directly. See `Dispatcher`. */ /** This shouldn't be called directly. See `Dispatcher`. */
public _updateCompareForm<K extends keyof ICompareFormUpdate>( public _updateCompareForm<K extends keyof ICompareFormUpdate>(
repository: Repository, repository: Repository,
@ -1951,8 +1970,13 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.stashedFilesWidth = constrain( this.stashedFilesWidth = constrain(
getNumber(stashedFilesWidthConfigKey, defaultStashedFilesWidth) getNumber(stashedFilesWidthConfigKey, defaultStashedFilesWidth)
) )
this.pullRequestFileListWidth = constrain(
getNumber(pullRequestFileListConfigKey, defaultPullRequestFileListWidth)
)
this.updateResizableConstraints() this.updateResizableConstraints()
// TODO: Initiliaze here for now... maybe move to dialog mounting
this.updatePullRequestResizableConstraints()
this.askToMoveToApplicationsFolderSetting = getBoolean( this.askToMoveToApplicationsFolderSetting = getBoolean(
askToMoveToApplicationsFolderKey, askToMoveToApplicationsFolderKey,
@ -1974,6 +1998,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
confirmDiscardChangesPermanentlyDefault confirmDiscardChangesPermanentlyDefault
) )
this.confirmDiscardStash = getBoolean(
confirmDiscardStashKey,
confirmDiscardStashDefault
)
this.askForConfirmationOnForcePush = getBoolean( this.askForConfirmationOnForcePush = getBoolean(
confirmForcePushKey, confirmForcePushKey,
askForConfirmationOnForcePushDefault askForConfirmationOnForcePushDefault
@ -2011,6 +2040,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
hideWhitespaceInHistoryDiffKey, hideWhitespaceInHistoryDiffKey,
false false
) )
this.hideWhitespaceInPullRequestDiff = getBoolean(
hideWhitespaceInPullRequestDiffKey,
false
)
this.commitSpellcheckEnabled = getBoolean( this.commitSpellcheckEnabled = getBoolean(
commitSpellcheckEnabledKey, commitSpellcheckEnabledKey,
commitSpellcheckEnabledDefault commitSpellcheckEnabledDefault
@ -2077,6 +2110,41 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.stashedFilesWidth = constrain(this.stashedFilesWidth, 100, filesMax) 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( private updateSelectedExternalEditor(
selectedEditor: string | null selectedEditor: string | null
): Promise<void> { ): Promise<void> {
@ -5193,6 +5261,15 @@ export class AppStore extends TypedBaseStore<IAppState> {
return Promise.resolve() return Promise.resolve()
} }
public _setConfirmDiscardStashSetting(value: boolean): Promise<void> {
this.confirmDiscardStash = value
setBoolean(confirmDiscardStashKey, value)
this.emitUpdate()
return Promise.resolve()
}
public _setConfirmForcePushSetting(value: boolean): Promise<void> { public _setConfirmForcePushSetting(value: boolean): Promise<void> {
this.askForConfirmationOnForcePush = value this.askForConfirmationOnForcePush = value
setBoolean(confirmForcePushKey, value) setBoolean(confirmForcePushKey, value)
@ -5279,6 +5356,19 @@ export class AppStore extends TypedBaseStore<IAppState> {
} }
} }
public _setHideWhitespaceInPullRequestDiff(
hideWhitespaceInDiff: boolean,
repository: Repository,
file: CommittedFileChange | null
) {
setBoolean(hideWhitespaceInPullRequestDiffKey, hideWhitespaceInDiff)
this.hideWhitespaceInPullRequestDiff = hideWhitespaceInDiff
if (file !== null) {
this._changePullRequestFileSelection(repository, file)
}
}
public _setShowSideBySideDiff(showSideBySideDiff: boolean) { public _setShowSideBySideDiff(showSideBySideDiff: boolean) {
if (showSideBySideDiff !== this.showSideBySideDiff) { if (showSideBySideDiff !== this.showSideBySideDiff) {
setShowSideBySideDiff(showSideBySideDiff) setShowSideBySideDiff(showSideBySideDiff)
@ -7146,27 +7236,36 @@ export class AppStore extends TypedBaseStore<IAppState> {
if (defaultBranch === null || tip.kind !== TipState.Valid) { if (defaultBranch === null || tip.kind !== TipState.Valid) {
return return
} }
const currentBranch = tip.branch 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 gitStore = this.gitStoreCache.get(repository)
const pullRequestCommits = await gitStore.getCommitsBetweenBranches( const pullRequestCommits = await gitStore.getCommitsBetweenBranches(
defaultBranch, baseBranch,
currentBranch 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. // A user may compare two branches with no changes between them.
const emptyChangeSet = { files: [], linesAdded: 0, linesDeleted: 0 } const emptyChangeSet = { files: [], linesAdded: 0, linesDeleted: 0 }
const changesetData = const changesetData =
commitSHAs.length > 0 commitsBetweenBranches.length > 0
? await gitStore.performFailableOperation(() => ? await gitStore.performFailableOperation(() =>
getBranchMergeBaseChangedFiles( getBranchMergeBaseChangedFiles(
repository, repository,
defaultBranch.name, baseBranch.name,
currentBranch.name, currentBranch.name,
commitSHAs[0] commitsBetweenBranches[0]
) )
) )
: emptyChangeSet : emptyChangeSet
@ -7175,25 +7274,64 @@ export class AppStore extends TypedBaseStore<IAppState> {
return 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, { this.repositoryStateCache.initializePullRequestState(repository, {
baseBranch: defaultBranch, baseBranch,
commitSHAs, commitSHAs,
commitSelection: { commitSelection: {
shas: commitSHAs, shas: commitSHAs,
shasInDiff: commitSHAs, shasInDiff: commitSHAs,
isContiguous: true, isContiguous: true,
changesetData, changesetData: changesetData ?? emptyChangeSet,
file: null, file: null,
diff: 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( await this._changePullRequestFileSelection(
repository, repository,
changesetData.files[0] 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( public async _changePullRequestFileSelection(
@ -7223,6 +7361,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
diff: null, diff: null,
}) })
) )
this.emitUpdate() this.emitUpdate()
if (commitSHAs.length === 0) { if (commitSHAs.length === 0) {
@ -7240,7 +7379,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
file, file,
baseBranch.name, baseBranch.name,
currentBranch.name, currentBranch.name,
this.hideWhitespaceInHistoryDiff, this.hideWhitespaceInPullRequestDiff,
commitSHAs[0] commitSHAs[0]
) )
)) ?? null )) ?? null
@ -7263,6 +7402,66 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.emitUpdate() this.emitUpdate()
} }
public _setPullRequestFileListWidth(width: number): Promise<void> {
this.pullRequestFileListWidth = {
...this.pullRequestFileListWidth,
value: width,
}
setNumber(pullRequestFileListConfigKey, width)
this.updatePullRequestResizableConstraints()
this.emitUpdate()
return Promise.resolve()
}
public _resetPullRequestFileListWidth(): Promise<void> {
this.pullRequestFileListWidth = {
...this.pullRequestFileListWidth,
value: defaultPullRequestFileListWidth,
}
localStorage.removeItem(pullRequestFileListConfigKey)
this.updatePullRequestResizableConstraints()
this.emitUpdate()
return Promise.resolve()
}
public _updatePullRequestBaseBranch(
repository: Repository,
baseBranch: Branch
) {
const { branchesState, pullRequestState } =
this.repositoryStateCache.get(repository)
const { tip } = branchesState
if (tip.kind !== TipState.Valid) {
return
}
if (pullRequestState === null) {
// This would mean the user submitted PR after requesting base branch
// update.
return
}
this._initializePullRequestPreview(repository, baseBranch, tip.branch)
}
private setupPRMergeTreePromise(
repository: Repository,
baseBranch: Branch,
compareBranch: Branch
) {
this.setupMergabilityPromise(repository, baseBranch, compareBranch).then(
(mergeStatus: MergeTreeResult | null) => {
this.repositoryStateCache.updatePullRequestState(repository, () => ({
mergeStatus,
}))
this.emitUpdate()
}
)
}
} }
/** /**

View file

@ -640,7 +640,7 @@ function emit(name: MenuEvent): ClickHandler {
} }
/** The zoom steps that we support, these factors must sorted */ /** 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() const ZoomOutFactors = ZoomInFactors.slice().reverse()
/** /**

View file

@ -14,7 +14,7 @@ import { Commit, CommitOneLine, ICommitContext } from './commit'
import { IStashEntry } from './stash-entry' import { IStashEntry } from './stash-entry'
import { Account } from '../models/account' import { Account } from '../models/account'
import { Progress } from './progress' import { Progress } from './progress'
import { ITextDiff, DiffSelection } from './diff' import { ITextDiff, DiffSelection, ImageDiffType } from './diff'
import { RepositorySettingsTab } from '../ui/repository-settings/repository-settings' import { RepositorySettingsTab } from '../ui/repository-settings/repository-settings'
import { ICommitMessage } from './commit-message' import { ICommitMessage } from './commit-message'
import { IAuthor } from './author' import { IAuthor } from './author'
@ -362,4 +362,13 @@ export type Popup =
} }
| { | {
type: PopupType.StartPullRequest type: PopupType.StartPullRequest
allBranches: ReadonlyArray<Branch>
currentBranch: Branch
defaultBranch: Branch | null
externalEditorLabel?: string
imageDiffType: ImageDiffType
recentBranches: ReadonlyArray<Branch>
repository: Repository
nonLocalCommitSHA: string | null
showSideBySideDiff: boolean
} }

View file

@ -1,5 +1,4 @@
import * as React from 'react' import * as React from 'react'
import * as crypto from 'crypto'
import { TransitionGroup, CSSTransition } from 'react-transition-group' import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { import {
IAppState, IAppState,
@ -158,6 +157,8 @@ import { SSHUserPassword } from './ssh/ssh-user-password'
import { showContextualMenu } from '../lib/menu-item' import { showContextualMenu } from '../lib/menu-item'
import { UnreachableCommitsDialog } from './history/unreachable-commits-dialog' import { UnreachableCommitsDialog } from './history/unreachable-commits-dialog'
import { OpenPullRequestDialog } from './open-pull-request/open-pull-request-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 MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 60 const HourInMilliseconds = MinuteInMilliseconds * 60
@ -1481,6 +1482,7 @@ export class App extends React.Component<IAppProps, IAppState> {
confirmDiscardChangesPermanently={ confirmDiscardChangesPermanently={
this.state.askForConfirmationOnDiscardChangesPermanently this.state.askForConfirmationOnDiscardChangesPermanently
} }
confirmDiscardStash={this.state.askForConfirmationOnDiscardStash}
confirmForcePush={this.state.askForConfirmationOnForcePush} confirmForcePush={this.state.askForConfirmationOnForcePush}
confirmUndoCommit={this.state.askForConfirmationOnUndoCommit} confirmUndoCommit={this.state.askForConfirmationOnUndoCommit}
uncommittedChangesStrategy={this.state.uncommittedChangesStrategy} uncommittedChangesStrategy={this.state.uncommittedChangesStrategy}
@ -1849,6 +1851,9 @@ export class App extends React.Component<IAppProps, IAppState> {
<ConfirmDiscardStashDialog <ConfirmDiscardStashDialog
key="confirm-discard-stash-dialog" key="confirm-discard-stash-dialog"
dispatcher={this.props.dispatcher} dispatcher={this.props.dispatcher}
askForConfirmationOnDiscardStash={
this.state.askForConfirmationOnDiscardStash
}
repository={repository} repository={repository}
stash={stash} stash={stash}
onDismissed={onPopupDismissedFn} onDismissed={onPopupDismissedFn}
@ -2243,25 +2248,35 @@ export class App extends React.Component<IAppProps, IAppState> {
) )
} }
case PopupType.StartPullRequest: { case PopupType.StartPullRequest: {
const { selectedState } = this.state // Intentionally chose to get the current pull request state on
if ( // rerender because state variables such as file selection change
selectedState == null || // via the dispatcher.
selectedState.type !== SelectionType.Repository 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 return null
} }
const { state: repoState, repository } = selectedState const { pullRequestFilesListWidth, hideWhitespaceInPullRequestDiff } =
const { pullRequestState, branchesState } = repoState this.state
if (
pullRequestState === null || const {
branchesState.tip.kind !== TipState.Valid allBranches,
) { currentBranch,
return null defaultBranch,
} imageDiffType,
const { allBranches, recentBranches, defaultBranch, tip } = externalEditorLabel,
branchesState nonLocalCommitSHA,
const currentBranch = tip.branch recentBranches,
repository,
showSideBySideDiff,
} = popup
return ( return (
<OpenPullRequestDialog <OpenPullRequestDialog
@ -2270,9 +2285,15 @@ export class App extends React.Component<IAppProps, IAppState> {
currentBranch={currentBranch} currentBranch={currentBranch}
defaultBranch={defaultBranch} defaultBranch={defaultBranch}
dispatcher={this.props.dispatcher} dispatcher={this.props.dispatcher}
fileListWidth={pullRequestFilesListWidth}
hideWhitespaceInDiff={hideWhitespaceInPullRequestDiff}
imageDiffType={imageDiffType}
nonLocalCommitSHA={nonLocalCommitSHA}
pullRequestState={pullRequestState} pullRequestState={pullRequestState}
recentBranches={recentBranches} recentBranches={recentBranches}
repository={repository} repository={repository}
externalEditorLabel={externalEditorLabel}
showSideBySideDiff={showSideBySideDiff}
onDismissed={onPopupDismissedFn} onDismissed={onPopupDismissedFn}
/> />
) )
@ -2282,6 +2303,18 @@ export class App extends React.Component<IAppProps, IAppState> {
} }
} }
private getPullRequestState() {
const { selectedState } = this.state
if (
selectedState == null ||
selectedState.type !== SelectionType.Repository
) {
return null
}
return selectedState.state.pullRequestState
}
private getWarnForcePushDialogOnBegin( private getWarnForcePushDialogOnBegin(
onBegin: () => void, onBegin: () => void,
onPopupDismissedFn: () => void onPopupDismissedFn: () => void
@ -2955,6 +2988,9 @@ export class App extends React.Component<IAppProps, IAppState> {
askForConfirmationOnDiscardChanges={ askForConfirmationOnDiscardChanges={
state.askForConfirmationOnDiscardChanges state.askForConfirmationOnDiscardChanges
} }
askForConfirmationOnDiscardStash={
state.askForConfirmationOnDiscardStash
}
accounts={state.accounts} accounts={state.accounts}
externalEditorLabel={externalEditorLabel} externalEditorLabel={externalEditorLabel}
resolvedExternalEditor={state.resolvedExternalEditor} resolvedExternalEditor={state.resolvedExternalEditor}
@ -3049,22 +3085,17 @@ export class App extends React.Component<IAppProps, IAppState> {
return return
} }
const baseURL = repository.gitHubRepository.htmlURL const commitURL = createCommitURL(
repository.gitHubRepository,
SHA,
filePath
)
let fileSuffix = '' if (commitURL === null) {
if (filePath != null) { return
const fileHash = crypto
.createHash('sha256')
.update(filePath)
.digest('hex')
fileSuffix = '#diff-' + fileHash
} }
if (baseURL) { this.props.dispatcher.openInBrowser(commitURL)
this.props.dispatcher.openInBrowser(
`${baseURL}/commit/${SHA}${fileSuffix}`
)
}
} }
private onBranchDeleted = (repository: Repository) => { private onBranchDeleted = (repository: Repository) => {

View file

@ -1,19 +1,12 @@
import * as React from 'react' import * as React from 'react'
import { IMatches } from '../../lib/fuzzy-find' import { IMatches } from '../../lib/fuzzy-find'
import { Branch } from '../../models/branch' import { Branch } from '../../models/branch'
import { Button } from '../lib/button'
import { ClickSource } from '../lib/list' import { ClickSource } from '../lib/list'
import { Popover } from '../lib/popover' import { PopoverDropdown } from '../lib/popover-dropdown'
import { Ref } from '../lib/ref'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { BranchList } from './branch-list' import { BranchList } from './branch-list'
import { renderDefaultBranch } from './branch-renderer' import { renderDefaultBranch } from './branch-renderer'
import { IBranchListItem } from './group-branches' import { IBranchListItem } from './group-branches'
const defaultDropdownListHeight = 300
const maxDropdownListHeight = 500
interface IBranchSelectProps { interface IBranchSelectProps {
/** The initially selected branch. */ /** The initially selected branch. */
readonly branch: Branch readonly branch: Branch
@ -43,10 +36,8 @@ interface IBranchSelectProps {
} }
interface IBranchSelectState { interface IBranchSelectState {
readonly showBranchDropdown: boolean
readonly selectedBranch: Branch | null readonly selectedBranch: Branch | null
readonly filterText: string readonly filterText: string
readonly dropdownListHeight: number
} }
/** /**
@ -56,67 +47,25 @@ export class BranchSelect extends React.Component<
IBranchSelectProps, IBranchSelectProps,
IBranchSelectState IBranchSelectState
> { > {
private invokeButtonRef: HTMLButtonElement | null = null private popoverRef = React.createRef<PopoverDropdown>()
public constructor(props: IBranchSelectProps) { public constructor(props: IBranchSelectProps) {
super(props) super(props)
this.state = { this.state = {
showBranchDropdown: false,
selectedBranch: props.branch, selectedBranch: props.branch,
filterText: '', 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) => { private renderBranch = (item: IBranchListItem, matches: IMatches) => {
return renderDefaultBranch(item, matches, this.props.currentBranch) return renderDefaultBranch(item, matches, this.props.currentBranch)
} }
private onItemClick = (branch: Branch, source: ClickSource) => { private onItemClick = (branch: Branch, source: ClickSource) => {
source.event.preventDefault() source.event.preventDefault()
this.setState({ showBranchDropdown: false, selectedBranch: branch }) this.popoverRef.current?.closePopover()
this.setState({ selectedBranch: branch })
this.props.onChange?.(branch) this.props.onChange?.(branch)
} }
@ -124,67 +73,32 @@ export class BranchSelect extends React.Component<
this.setState({ filterText }) this.setState({ filterText })
} }
public renderBranchDropdown() { public render() {
if (!this.state.showBranchDropdown) {
return
}
const { currentBranch, defaultBranch, recentBranches, allBranches } = const { currentBranch, defaultBranch, recentBranches, allBranches } =
this.props this.props
const { filterText, selectedBranch, dropdownListHeight } = this.state const { filterText, selectedBranch } = this.state
return ( return (
<Popover <PopoverDropdown
className="branch-select-dropdown" contentTitle="Choose a base branch"
onClickOutside={this.closeBranchDropdown} buttonContent={selectedBranch?.name ?? ''}
label="base:"
ref={this.popoverRef}
> >
<div className="branch-select-dropdown-header"> <BranchList
Choose a base branch allBranches={allBranches}
<button currentBranch={currentBranch}
className="close" defaultBranch={defaultBranch}
onClick={this.closeBranchDropdown} recentBranches={recentBranches}
aria-label="close" filterText={filterText}
> onFilterTextChanged={this.onFilterTextChanged}
<Octicon symbol={OcticonSymbol.x} /> selectedBranch={selectedBranch}
</button> canCreateNewBranch={false}
</div> renderBranch={this.renderBranch}
<div onItemClick={this.onItemClick}
className="branch-select-dropdown-list" />
style={{ height: `${dropdownListHeight}px` }} </PopoverDropdown>
>
<BranchList
allBranches={allBranches}
currentBranch={currentBranch}
defaultBranch={defaultBranch}
recentBranches={recentBranches}
filterText={filterText}
onFilterTextChanged={this.onFilterTextChanged}
selectedBranch={selectedBranch}
canCreateNewBranch={false}
renderBranch={this.renderBranch}
onItemClick={this.onItemClick}
/>
</div>
</Popover>
)
}
public render() {
return (
<div className="branch-select-component">
<Button
onClick={this.toggleBranchDropdown}
onButtonRef={this.onInvokeButtonRef}
>
<Ref>
<span className="base-label">base:</span>
{this.state.selectedBranch?.name}
<Octicon symbol={OcticonSymbol.triangleDown} />
</Ref>
</Button>
{this.renderBranchDropdown()}
</div>
) )
} }
} }

View file

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

View file

@ -351,7 +351,7 @@ export class CommitMessage extends React.Component<
if ( if (
isShortcutKey && isShortcutKey &&
event.key === 'Enter' && event.key === 'Enter' &&
this.canCommit() && (this.canCommit() || this.canAmend()) &&
this.canExcecuteCommitShortcut() this.canExcecuteCommitShortcut()
) { ) {
this.createCommit() this.createCommit()

View file

@ -3,6 +3,7 @@ import * as React from 'react'
import { Dispatcher } from '../dispatcher' import { Dispatcher } from '../dispatcher'
import { getDefaultDir, setDefaultDir } from '../lib/default-dir' import { getDefaultDir, setDefaultDir } from '../lib/default-dir'
import { Account } from '../../models/account' import { Account } from '../../models/account'
import { FoldoutType } from '../../lib/app-state'
import { import {
IRepositoryIdentifier, IRepositoryIdentifier,
parseRepositoryIdentifier, parseRepositoryIdentifier,
@ -728,6 +729,7 @@ export class CloneRepository extends React.Component<
const { url, defaultBranch } = cloneInfo const { url, defaultBranch } = cloneInfo
this.props.dispatcher.closeFoldout(FoldoutType.Repository)
try { try {
this.cloneImpl(url.trim(), path, defaultBranch) this.cloneImpl(url.trim(), path, defaultBranch)
} catch (e) { } catch (e) {

View file

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

View file

@ -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 */ /** Change the side by side diff setting */
public onShowSideBySideDiffChanged(showSideBySideDiff: boolean) { public onShowSideBySideDiffChanged(showSideBySideDiff: boolean) {
return this.appStore._setShowSideBySideDiff(showSideBySideDiff) return this.appStore._setShowSideBySideDiff(showSideBySideDiff)
@ -2338,6 +2351,10 @@ export class Dispatcher {
await this.appStore._loadStatus(repository) await this.appStore._loadStatus(repository)
} }
public setConfirmDiscardStashSetting(value: boolean) {
return this.appStore._setConfirmDiscardStashSetting(value)
}
public setConfirmForcePushSetting(value: boolean) { public setConfirmForcePushSetting(value: boolean) {
return this.appStore._setConfirmForcePushSetting(value) return this.appStore._setConfirmForcePushSetting(value)
} }
@ -3963,9 +3980,33 @@ export class Dispatcher {
public startPullRequest(repository: Repository) { public startPullRequest(repository: Repository) {
this.appStore._startPullRequest(repository) this.appStore._startPullRequest(repository)
}
this.showPopup({ /**
type: PopupType.StartPullRequest, * Change the selected changed file of the current pull request state.
}) */
public changePullRequestFileSelection(
repository: Repository,
file: CommittedFileChange
): Promise<void> {
return this.appStore._changePullRequestFileSelection(repository, file)
}
/**
* Set the width of the file list column in the pull request files changed
*/
public setPullRequestFileListWidth(width: number): Promise<void> {
return this.appStore._setPullRequestFileListWidth(width)
}
/**
* Reset the width of the file list column in the pull request files changed
*/
public resetPullRequestFileListWidth(): Promise<void> {
return this.appStore._resetPullRequestFileListWidth()
}
public updatePullRequestBaseBranch(repository: Repository, branch: Branch) {
this.appStore._updatePullRequestBaseBranch(repository, branch)
} }
} }

View file

@ -12,7 +12,6 @@ import { CommitAttribution } from '../lib/commit-attribution'
import { Tokenizer, TokenResult } from '../../lib/text-token-parser' import { Tokenizer, TokenResult } from '../../lib/text-token-parser'
import { wrapRichTextCommitMessage } from '../../lib/wrap-rich-text-commit-message' import { wrapRichTextCommitMessage } from '../../lib/wrap-rich-text-commit-message'
import { DiffOptions } from '../diff/diff-options' import { DiffOptions } from '../diff/diff-options'
import { RepositorySectionTab } from '../../lib/app-state'
import { IChangesetData } from '../../lib/git' import { IChangesetData } from '../../lib/git'
import { TooltippedContent } from '../lib/tooltipped-content' import { TooltippedContent } from '../lib/tooltipped-content'
import { AppFileStatusKind } from '../../models/status' import { AppFileStatusKind } from '../../models/status'
@ -505,7 +504,7 @@ export class CommitSummary extends React.Component<
title="Diff Options" title="Diff Options"
> >
<DiffOptions <DiffOptions
sourceTab={RepositorySectionTab.History} isInteractiveDiff={false}
hideWhitespaceChanges={this.props.hideWhitespaceInDiff} hideWhitespaceChanges={this.props.hideWhitespaceInDiff}
onHideWhitespaceChangesChanged={ onHideWhitespaceChangesChanged={
this.props.onHideWhitespaceInDiffChanged this.props.onHideWhitespaceInDiffChanged

View file

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

View file

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

View file

@ -93,6 +93,8 @@ export abstract class BaseMultiCommitOperation extends React.Component<IMultiCom
{targetBranch !== null ? <strong>{targetBranch.name}</strong> : null} {targetBranch !== null ? <strong>{targetBranch.name}</strong> : null}
</> </>
) )
this.props.dispatcher.closePopup(PopupType.MultiCommitOperation)
return dispatcher.onConflictsFoundBanner( return dispatcher.onConflictsFoundBanner(
repository, repository,
operationDescription, operationDescription,

View file

@ -1,10 +1,18 @@
import * as React from 'react' 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 { Branch } from '../../models/branch'
import { ImageDiffType } from '../../models/diff'
import { Repository } from '../../models/repository' import { Repository } from '../../models/repository'
import { DialogFooter, OkCancelButtonGroup, Dialog } from '../dialog' import { DialogFooter, OkCancelButtonGroup, Dialog } from '../dialog'
import { Dispatcher } from '../dispatcher' 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 { 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 { interface IOpenPullRequestDialogProps {
readonly repository: Repository readonly repository: Repository
@ -35,6 +43,25 @@ interface IOpenPullRequestDialogProps {
*/ */
readonly recentBranches: ReadonlyArray<Branch> readonly recentBranches: ReadonlyArray<Branch>
/** Whether we should display side by side diffs. */
readonly showSideBySideDiff: boolean
/** Whether we should hide whitespace in diff. */
readonly hideWhitespaceInDiff: boolean
/** The type of image diff to display. */
readonly imageDiffType: ImageDiffType
/** Label for selected external editor */
readonly externalEditorLabel?: string
/** Width to use for the files list pane in the files changed view */
readonly fileListWidth: IConstrainedValue
/** If the latest commit of the pull request is not local, this will contain
* it's SHA */
readonly nonLocalCommitSHA: string | null
/** Called to dismiss the dialog */ /** Called to dismiss the dialog */
readonly onDismissed: () => void readonly onDismissed: () => void
} }
@ -47,6 +74,11 @@ export class OpenPullRequestDialog extends React.Component<IOpenPullRequestDialo
this.props.dispatcher.recordCreatePullRequest() this.props.dispatcher.recordCreatePullRequest()
} }
private onBranchChange = (branch: Branch) => {
const { repository } = this.props
this.props.dispatcher.updatePullRequestBaseBranch(repository, branch)
}
private renderHeader() { private renderHeader() {
const { const {
currentBranch, currentBranch,
@ -64,22 +96,107 @@ export class OpenPullRequestDialog extends React.Component<IOpenPullRequestDialo
allBranches={allBranches} allBranches={allBranches}
recentBranches={recentBranches} recentBranches={recentBranches}
commitCount={commitSHAs?.length ?? 0} commitCount={commitSHAs?.length ?? 0}
onBranchChange={this.onBranchChange}
onDismissed={this.props.onDismissed} onDismissed={this.props.onDismissed}
/> />
) )
} }
private renderContent() { private renderContent() {
return <div>Content</div> return (
<div className="open-pull-request-content">
{this.renderNoChanges()}
{this.renderFilesChanged()}
</div>
)
}
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 (
<PullRequestFilesChanged
diff={diff}
dispatcher={dispatcher}
externalEditorLabel={externalEditorLabel}
fileListWidth={fileListWidth}
files={files}
hideWhitespaceInDiff={hideWhitespaceInDiff}
imageDiffType={imageDiffType}
nonLocalCommitSHA={nonLocalCommitSHA}
selectedFile={file}
showSideBySideDiff={this.props.showSideBySideDiff}
repository={repository}
/>
)
}
private renderNoChanges() {
const { pullRequestState, currentBranch } = this.props
const { commitSelection, baseBranch, mergeStatus } = pullRequestState
const { shas } = commitSelection
if (shas.length !== 0) {
return
}
const hasMergeBase = mergeStatus?.kind !== ComputedAction.Invalid
const message = hasMergeBase ? (
<>
<Ref>{baseBranch.name}</Ref> is up to date with all commits from{' '}
<Ref>{currentBranch.name}</Ref>.
</>
) : (
<>
<Ref>{baseBranch.name}</Ref> and <Ref>{currentBranch.name}</Ref> are
entirely different commit histories.
</>
)
return (
<div className="open-pull-request-no-changes">
<div>
<Octicon symbol={OcticonSymbol.gitPullRequest} />
<h3>There are no changes.</h3>
{message}
</div>
</div>
)
} }
private renderFooter() { 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 ( return (
<DialogFooter> <DialogFooter>
<PullRequestMergeStatus mergeStatus={mergeStatus} />
<OkCancelButtonGroup <OkCancelButtonGroup
okButtonText="Create Pull Request" okButtonText={
okButtonTitle="Create pull request on GitHub." __DARWIN__ ? 'Create Pull Request' : 'Create pull request'
}
okButtonTitle={buttonTitle}
cancelButtonText="Cancel" cancelButtonText="Cancel"
okButtonDisabled={commitSHAs === null || commitSHAs.length === 0}
/> />
</DialogFooter> </DialogFooter>
) )
@ -93,8 +210,7 @@ export class OpenPullRequestDialog extends React.Component<IOpenPullRequestDialo
onDismissed={this.props.onDismissed} onDismissed={this.props.onDismissed}
> >
{this.renderHeader()} {this.renderHeader()}
<div className="content">{this.renderContent()}</div> {this.renderContent()}
{this.renderFooter()} {this.renderFooter()}
</Dialog> </Dialog>
) )

View file

@ -30,6 +30,9 @@ interface IOpenPullRequestDialogHeaderProps {
/** The count of commits of the pull request */ /** The count of commits of the pull request */
readonly commitCount: number 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 * Event triggered when the dialog is dismissed by the user in the
* ways described in the dismissable prop. * ways described in the dismissable prop.
@ -54,6 +57,7 @@ export class OpenPullRequestDialogHeader extends React.Component<
allBranches, allBranches,
recentBranches, recentBranches,
commitCount, commitCount,
onBranchChange,
onDismissed, onDismissed,
} = this.props } = this.props
const commits = `${commitCount} commit${commitCount > 1 ? 's' : ''}` const commits = `${commitCount} commit${commitCount > 1 ? 's' : ''}`
@ -74,6 +78,7 @@ export class OpenPullRequestDialogHeader extends React.Component<
currentBranch={currentBranch} currentBranch={currentBranch}
allBranches={allBranches} allBranches={allBranches}
recentBranches={recentBranches} recentBranches={recentBranches}
onChange={onBranchChange}
/>{' '} />{' '}
from <Ref>{currentBranch.name}</Ref>. from <Ref>{currentBranch.name}</Ref>.
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

@ -50,6 +50,7 @@ interface IRepositoryViewProps {
readonly hideWhitespaceInHistoryDiff: boolean readonly hideWhitespaceInHistoryDiff: boolean
readonly showSideBySideDiff: boolean readonly showSideBySideDiff: boolean
readonly askForConfirmationOnDiscardChanges: boolean readonly askForConfirmationOnDiscardChanges: boolean
readonly askForConfirmationOnDiscardStash: boolean
readonly focusCommitMessage: boolean readonly focusCommitMessage: boolean
readonly commitSpellcheckEnabled: boolean readonly commitSpellcheckEnabled: boolean
readonly accounts: ReadonlyArray<Account> readonly accounts: ReadonlyArray<Account>
@ -355,6 +356,9 @@ export class RepositoryView extends React.Component<
fileListWidth={this.props.stashedFilesWidth} fileListWidth={this.props.stashedFilesWidth}
repository={this.props.repository} repository={this.props.repository}
dispatcher={this.props.dispatcher} dispatcher={this.props.dispatcher}
askForConfirmationOnDiscardStash={
this.props.askForConfirmationOnDiscardStash
}
isWorkingTreeClean={isWorkingTreeClean} isWorkingTreeClean={isWorkingTreeClean}
showSideBySideDiff={this.props.showSideBySideDiff} showSideBySideDiff={this.props.showSideBySideDiff}
onOpenBinaryFile={this.onOpenBinaryFile} onOpenBinaryFile={this.onOpenBinaryFile}

View file

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

View file

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

View file

@ -27,6 +27,9 @@ interface IStashDiffViewerProps {
readonly repository: Repository readonly repository: Repository
readonly dispatcher: Dispatcher 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. */ /** Whether we should display side by side diffs. */
readonly showSideBySideDiff: boolean readonly showSideBySideDiff: boolean
@ -113,6 +116,9 @@ export class StashDiffViewer extends React.PureComponent<IStashDiffViewerProps>
repository={repository} repository={repository}
dispatcher={dispatcher} dispatcher={dispatcher}
isWorkingTreeClean={isWorkingTreeClean} isWorkingTreeClean={isWorkingTreeClean}
askForConfirmationOnDiscardStash={
this.props.askForConfirmationOnDiscardStash
}
/> />
<div className="commit-details"> <div className="commit-details">
<Resizable <Resizable

View file

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

View file

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

View file

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

View file

@ -0,0 +1,23 @@
.pull-request-files-changed {
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 {
height: 500px;
display: flex;
}
.file-list {
border-right: var(--base-border);
}
}

View file

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

View file

@ -1,4 +1,7 @@
.open-pull-request { .open-pull-request {
width: 850px;
max-width: none;
header.dialog-header { header.dialog-header {
padding-bottom: var(--spacing); padding-bottom: var(--spacing);
@ -15,4 +18,25 @@
padding: var(--spacing-half); padding: var(--spacing-half);
} }
} }
.open-pull-request-content {
padding: var(--spacing);
}
.open-pull-request-no-changes {
height: 100%;
display: flex;
align-items: center;
text-align: center;
justify-content: center;
padding: var(--spacing-double);
}
.dialog-footer {
flex-direction: row;
}
.pull-request-merge-status {
flex-grow: 1;
}
} }

View file

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

View file

@ -575,9 +575,38 @@ describe('git/diff', () => {
'feature-branch', 'feature-branch',
'irrelevantToTest' 'irrelevantToTest'
) )
expect(changesetData).not.toBeNull()
if (changesetData === null) {
return
}
expect(changesetData.files).toHaveLength(1) expect(changesetData.files).toHaveLength(1)
expect(changesetData.files[0].path).toBe('feature.md') 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', () => { describe('getBranchMergeBaseDiff', () => {

View file

@ -1,6 +1,12 @@
{ {
"releases": { "releases": {
"3.1.2": ["[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": [ "3.1.1": [
"[Fixed] App correctly remembers undo commit prompt setting - #15408" "[Fixed] App correctly remembers undo commit prompt setting - #15408"
], ],

View file

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

View file

@ -102,7 +102,11 @@ function packageWindows() {
} }
if (shouldMakeDelta()) { 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()) { if (isAppveyor() || isGitHubActions()) {