mirror of
https://github.com/desktop/desktop
synced 2024-10-05 23:59:33 +00:00
Merge branch 'development' into releases/2.6.6
This commit is contained in:
commit
e534559871
|
@ -1,8 +1,8 @@
|
|||
# [GitHub Desktop](https://desktop.github.com)
|
||||
|
||||
GitHub Desktop is an open source [Electron](https://electron.atom.io)-based
|
||||
GitHub Desktop is an open source [Electron](https://www.electronjs.org/)-based
|
||||
GitHub app. It is written in [TypeScript](http://www.typescriptlang.org) and
|
||||
uses [React](https://facebook.github.io/react/).
|
||||
uses [React](https://reactjs.org/).
|
||||
|
||||
![GitHub Desktop screenshot - Windows](https://cloud.githubusercontent.com/assets/359239/26094502/a1f56d02-3a5d-11e7-8799-23c7ba5e5106.png)
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
Progress,
|
||||
ICheckoutProgress,
|
||||
ICloneProgress,
|
||||
ICherryPickProgress,
|
||||
} from '../models/progress'
|
||||
import { Popup } from '../models/popup'
|
||||
|
||||
|
@ -37,6 +38,7 @@ import { RebaseFlowStep } from '../models/rebase-flow-step'
|
|||
import { IStashEntry } from '../models/stash-entry'
|
||||
import { TutorialStep } from '../models/tutorial-step'
|
||||
import { UncommittedChangesStrategy } from '../models/uncommitted-changes-strategy'
|
||||
import { CherryPickFlowStep } from '../models/cherry-pick'
|
||||
|
||||
export enum SelectionType {
|
||||
Repository,
|
||||
|
@ -249,6 +251,11 @@ export interface IAppState {
|
|||
* Whether or not the app should use spell check on commit summary and description
|
||||
*/
|
||||
readonly commitSpellcheckEnabled: boolean
|
||||
|
||||
/**
|
||||
* Whether or not the user has been introduced to the cherry pick feature
|
||||
*/
|
||||
readonly hasShownCherryPickIntro: boolean
|
||||
}
|
||||
|
||||
export enum FoldoutType {
|
||||
|
@ -351,13 +358,16 @@ export function isRebaseConflictState(
|
|||
}
|
||||
|
||||
/**
|
||||
* Conflicts can occur during a rebase or a merge.
|
||||
* Conflicts can occur during a rebase, merge, or cherry pick.
|
||||
*
|
||||
* Callers should inspect the `kind` field to determine the kind of conflict
|
||||
* that is occurring, as this will then provide additional information specific
|
||||
* to the conflict, to help with resolving the issue.
|
||||
*/
|
||||
export type ConflictState = MergeConflictState | RebaseConflictState
|
||||
export type ConflictState =
|
||||
| MergeConflictState
|
||||
| RebaseConflictState
|
||||
| CherryPickConflictState
|
||||
|
||||
export interface IRepositoryState {
|
||||
readonly commitSelection: ICommitSelection
|
||||
|
@ -430,6 +440,9 @@ export interface IRepositoryState {
|
|||
readonly revertProgress: IRevertProgress | null
|
||||
|
||||
readonly localTags: Map<string, string> | null
|
||||
|
||||
/** State associated with a cherry pick being performed */
|
||||
readonly cherryPickState: ICherryPickState
|
||||
}
|
||||
|
||||
export interface IBranchesState {
|
||||
|
@ -522,8 +535,8 @@ export interface IRebaseState {
|
|||
}
|
||||
|
||||
export interface ICommitSelection {
|
||||
/** The commit currently selected in the app */
|
||||
readonly sha: string | null
|
||||
/** The commits currently selected in the app */
|
||||
readonly shas: ReadonlyArray<string>
|
||||
|
||||
/** The list of files associated with the current commit */
|
||||
readonly changedFiles: ReadonlyArray<CommittedFileChange>
|
||||
|
@ -724,3 +737,59 @@ export interface ICompareToBranch {
|
|||
* An action to send to the application store to update the compare state
|
||||
*/
|
||||
export type CompareAction = IViewHistory | ICompareToBranch
|
||||
|
||||
/** State associated with a cherry pick being performed on a repository */
|
||||
export interface ICherryPickState {
|
||||
/**
|
||||
* The current step of the flow the user should see.
|
||||
*
|
||||
* `null` indicates that there is no cherry pick underway.
|
||||
*/
|
||||
readonly step: CherryPickFlowStep | null
|
||||
|
||||
/**
|
||||
* The underlying Git information associated with the current cherry pick
|
||||
*
|
||||
* This will be set to `null` when no target branch has been selected to
|
||||
* initiate the rebase.
|
||||
*/
|
||||
readonly progress: ICherryPickProgress | null
|
||||
|
||||
/**
|
||||
* Whether the user has done work to resolve any conflicts as part of this
|
||||
* cherry pick.
|
||||
*/
|
||||
readonly userHasResolvedConflicts: boolean
|
||||
|
||||
/**
|
||||
* The sha of the target branch tip before cherry pick initiated.
|
||||
*
|
||||
* This will be set to null if no cherry pick has been initiated.
|
||||
*/
|
||||
readonly targetBranchUndoSha: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores information about a cherry pick conflict when it occurs
|
||||
*/
|
||||
export type CherryPickConflictState = {
|
||||
readonly kind: 'cherryPick'
|
||||
|
||||
/**
|
||||
* Manual resolutions chosen by the user for conflicted files to be applied
|
||||
* before continuing the cherry pick.
|
||||
*/
|
||||
readonly manualResolutions: Map<string, ManualConflictResolution>
|
||||
|
||||
/**
|
||||
* The branch chosen by the user to copy the cherry picked commits to
|
||||
*/
|
||||
readonly targetBranchName: string
|
||||
}
|
||||
|
||||
/** Guard function for checking conflicts are from a rebase */
|
||||
export function isCherryPickConflictState(
|
||||
conflictStatus: ConflictState
|
||||
): conflictStatus is CherryPickConflictState {
|
||||
return conflictStatus.kind === 'cherryPick'
|
||||
}
|
||||
|
|
|
@ -153,5 +153,5 @@ export function enableUnhandledRejectionReporting(): boolean {
|
|||
* Should we allow cherry picking
|
||||
*/
|
||||
export function enableCherryPicking(): boolean {
|
||||
return false // enableBetaFeatures()
|
||||
return enableDevelopmentFeatures()
|
||||
}
|
||||
|
|
|
@ -17,6 +17,8 @@ import { ChildProcess } from 'child_process'
|
|||
import { round } from '../../ui/lib/round'
|
||||
import byline from 'byline'
|
||||
import { ICherryPickSnapshot } from '../../models/cherry-pick'
|
||||
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
|
||||
import { stageManualConflictResolution } from './stage'
|
||||
|
||||
/** The app-specific results from attempting to cherry pick commits*/
|
||||
export enum CherryPickResult {
|
||||
|
@ -36,11 +38,13 @@ export enum CherryPickResult {
|
|||
*/
|
||||
OutstandingFilesNotStaged = 'OutstandingFilesNotStaged',
|
||||
/**
|
||||
* The cherry pick was not attempted because it could not check the status of
|
||||
* the repository. The caller needs to confirm the repository is in a usable
|
||||
* state.
|
||||
* The cherry pick was not attempted:
|
||||
* - it could not check the status of the repository.
|
||||
* - there was an invalid revision range provided.
|
||||
* - there were uncommitted changes present.
|
||||
* - there were errors in checkout the target branch
|
||||
*/
|
||||
Aborted = 'Aborted',
|
||||
UnableToStart = 'UnableToStart',
|
||||
/**
|
||||
* An unexpected error as part of the cherry pick flow was caught and handled.
|
||||
*
|
||||
|
@ -149,7 +153,7 @@ export async function cherryPick(
|
|||
`Unable to cherry pick these branches
|
||||
because one or both of the refs do not exist in the repository`
|
||||
)
|
||||
return CherryPickResult.Error
|
||||
return CherryPickResult.UnableToStart
|
||||
}
|
||||
|
||||
baseOptions = await configureOptionsWithCallBack(
|
||||
|
@ -159,8 +163,12 @@ export async function cherryPick(
|
|||
)
|
||||
}
|
||||
|
||||
// --keep-redundant-commits follows pattern of making sure someone cherry
|
||||
// picked commit summaries appear in target branch history even tho they may
|
||||
// be empty. This flag also results in the ability to cherry pick empty
|
||||
// commits (thus, --allow-empty is not required.)
|
||||
const result = await git(
|
||||
['cherry-pick', revisionRange],
|
||||
['cherry-pick', revisionRange, '--keep-redundant-commits'],
|
||||
repository.path,
|
||||
'cherry pick',
|
||||
baseOptions
|
||||
|
@ -203,8 +211,7 @@ function parseCherryPickResult(result: IGitResult): CherryPickResult {
|
|||
export async function getCherryPickSnapshot(
|
||||
repository: Repository
|
||||
): Promise<ICherryPickSnapshot | null> {
|
||||
const cherryPickHead = readCherryPickHead(repository)
|
||||
if (cherryPickHead === null) {
|
||||
if (!isCherryPickHeadFound(repository)) {
|
||||
// If there no cherry pick head, there is no cherry pick in progress.
|
||||
return null
|
||||
}
|
||||
|
@ -276,6 +283,7 @@ export async function getCherryPickSnapshot(
|
|||
}
|
||||
|
||||
const count = commits.length - remainingShas.length
|
||||
const commitSummaryIndex = count > 0 ? count - 1 : 0
|
||||
return {
|
||||
progress: {
|
||||
kind: 'cherryPick',
|
||||
|
@ -283,9 +291,11 @@ export async function getCherryPickSnapshot(
|
|||
value: round(count / commits.length, 2),
|
||||
cherryPickCommitCount: count,
|
||||
totalCommitCount: commits.length,
|
||||
currentCommitSummary: commits[count - 1].summary ?? '',
|
||||
currentCommitSummary: commits[commitSummaryIndex].summary ?? '',
|
||||
},
|
||||
remainingCommits: commits.slice(count, commits.length),
|
||||
commits,
|
||||
targetBranchUndoSha: firstSha,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -303,13 +313,28 @@ export async function getCherryPickSnapshot(
|
|||
export async function continueCherryPick(
|
||||
repository: Repository,
|
||||
files: ReadonlyArray<WorkingDirectoryFileChange>,
|
||||
manualResolutions: ReadonlyMap<string, ManualConflictResolution> = new Map(),
|
||||
progressCallback?: (progress: ICherryPickProgress) => void
|
||||
): Promise<CherryPickResult> {
|
||||
// only stage files related to cherry pick
|
||||
const trackedFiles = files.filter(f => {
|
||||
return f.status.kind !== AppFileStatusKind.Untracked
|
||||
})
|
||||
await stageFiles(repository, trackedFiles)
|
||||
|
||||
// apply conflict resolutions
|
||||
for (const [path, resolution] of manualResolutions) {
|
||||
const file = files.find(f => f.path === path)
|
||||
if (file === undefined) {
|
||||
log.error(
|
||||
`[continueCherryPick] couldn't find file ${path} even though there's a manual resolution for it`
|
||||
)
|
||||
continue
|
||||
}
|
||||
await stageManualConflictResolution(repository, file, resolution)
|
||||
}
|
||||
|
||||
const otherFiles = trackedFiles.filter(f => !manualResolutions.has(f.path))
|
||||
await stageFiles(repository, otherFiles)
|
||||
|
||||
const status = await getStatus(repository)
|
||||
if (status == null) {
|
||||
|
@ -317,13 +342,12 @@ export async function continueCherryPick(
|
|||
`[continueCherryPick] unable to get status after staging changes,
|
||||
skipping any other steps`
|
||||
)
|
||||
return CherryPickResult.Aborted
|
||||
return CherryPickResult.UnableToStart
|
||||
}
|
||||
|
||||
// make sure cherry pick is still in progress to continue
|
||||
const cherryPickCurrentCommit = await readCherryPickHead(repository)
|
||||
if (cherryPickCurrentCommit === null) {
|
||||
return CherryPickResult.Aborted
|
||||
if (await !isCherryPickHeadFound(repository)) {
|
||||
return CherryPickResult.UnableToStart
|
||||
}
|
||||
|
||||
let options: IGitExecutionOptions = {
|
||||
|
@ -343,7 +367,7 @@ export async function continueCherryPick(
|
|||
log.warn(
|
||||
`[continueCherryPick] unable to get cherry pick status, skipping other steps`
|
||||
)
|
||||
return CherryPickResult.Aborted
|
||||
return CherryPickResult.UnableToStart
|
||||
}
|
||||
options = configureOptionsWithCallBack(
|
||||
options,
|
||||
|
@ -352,8 +376,33 @@ export async function continueCherryPick(
|
|||
)
|
||||
}
|
||||
|
||||
const trackedFilesAfter = status.workingDirectory.files.filter(
|
||||
f => f.status.kind !== AppFileStatusKind.Untracked
|
||||
)
|
||||
|
||||
if (trackedFilesAfter.length === 0) {
|
||||
log.warn(
|
||||
`[cherryPick] no tracked changes to commit, continuing cherry pick but skipping this commit`
|
||||
)
|
||||
|
||||
// This commits the empty commit so that the cherry picked commit still
|
||||
// shows up in the target branches history.
|
||||
const result = await git(
|
||||
['commit', '--allow-empty'],
|
||||
repository.path,
|
||||
'continueCherryPickSkipCurrentCommit',
|
||||
options
|
||||
)
|
||||
|
||||
return parseCherryPickResult(result)
|
||||
}
|
||||
|
||||
// --keep-redundant-commits follows pattern of making sure someone cherry
|
||||
// picked commit summaries appear in target branch history even tho they may
|
||||
// be empty. This flag also results in the ability to cherry pick empty
|
||||
// commits (thus, --allow-empty is not required.)
|
||||
const result = await git(
|
||||
['cherry-pick', '--continue'],
|
||||
['cherry-pick', '--continue', '--keep-redundant-commits'],
|
||||
repository.path,
|
||||
'continueCherryPick',
|
||||
options
|
||||
|
@ -368,29 +417,24 @@ export async function abortCherryPick(repository: Repository) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Attempt to read the `.git/CHERRY_PICK_HEAD` file inside a repository to confirm
|
||||
* the cherry pick is still active.
|
||||
* Check if the `.git/CHERRY_PICK_HEAD` file exists
|
||||
*/
|
||||
async function readCherryPickHead(
|
||||
export async function isCherryPickHeadFound(
|
||||
repository: Repository
|
||||
): Promise<string | null> {
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const cherryPickHead = Path.join(
|
||||
const cherryPickHeadPath = Path.join(
|
||||
repository.path,
|
||||
'.git',
|
||||
'CHERRY_PICK_HEAD'
|
||||
)
|
||||
const cherryPickCurrentCommitOutput = await FSE.readFile(
|
||||
cherryPickHead,
|
||||
'utf8'
|
||||
)
|
||||
return cherryPickCurrentCommitOutput.trim()
|
||||
return FSE.pathExists(cherryPickHeadPath)
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[cherryPick] a problem was encountered reading .git/CHERRY_PICK_HEAD,
|
||||
so it is unsafe to continue cherry picking`,
|
||||
err
|
||||
)
|
||||
return null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -87,6 +87,7 @@ const imageFileExtensions = new Set([
|
|||
'.ico',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.avif',
|
||||
])
|
||||
|
||||
/**
|
||||
|
@ -299,6 +300,9 @@ function getMediaType(extension: string) {
|
|||
if (extension === '.bmp') {
|
||||
return 'image/bmp'
|
||||
}
|
||||
if (extension === '.avif') {
|
||||
return 'image/avif'
|
||||
}
|
||||
|
||||
// fallback value as per the spec
|
||||
return 'text/plain'
|
||||
|
|
|
@ -27,6 +27,7 @@ import { isMergeHeadSet } from './merge'
|
|||
import { getBinaryPaths } from './diff'
|
||||
import { getRebaseInternalState } from './rebase'
|
||||
import { RebaseInternalState } from '../../models/rebase'
|
||||
import { isCherryPickHeadFound } from './cherry-pick'
|
||||
|
||||
/**
|
||||
* V8 has a limit on the size of string it can create (~256MB), and unless we want to
|
||||
|
@ -61,6 +62,9 @@ export interface IStatusResult {
|
|||
/** details about the rebase operation, if found */
|
||||
readonly rebaseInternalState: RebaseInternalState | null
|
||||
|
||||
/** true if repository is in cherry pick state */
|
||||
readonly isCherryPickingHeadFound: boolean
|
||||
|
||||
/** the absolute path to the repository's working directory */
|
||||
readonly workingDirectory: WorkingDirectoryStatus
|
||||
}
|
||||
|
@ -229,6 +233,8 @@ export async function getStatus(
|
|||
|
||||
const workingDirectory = WorkingDirectoryStatus.fromFiles([...files.values()])
|
||||
|
||||
const isCherryPickingHeadFound = await isCherryPickHeadFound(repository)
|
||||
|
||||
return {
|
||||
currentBranch,
|
||||
currentTip,
|
||||
|
@ -238,6 +244,7 @@ export async function getStatus(
|
|||
mergeHeadFound,
|
||||
rebaseInternalState,
|
||||
workingDirectory,
|
||||
isCherryPickingHeadFound,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -60,6 +60,7 @@ import {
|
|||
IFetchProgress,
|
||||
IRevertProgress,
|
||||
IRebaseProgress,
|
||||
ICherryPickProgress,
|
||||
} from '../../models/progress'
|
||||
import { Popup, PopupType } from '../../models/popup'
|
||||
import { IGitAccount } from '../../models/git-account'
|
||||
|
@ -99,12 +100,15 @@ import {
|
|||
RepositorySectionTab,
|
||||
SelectionType,
|
||||
MergeConflictState,
|
||||
isMergeConflictState,
|
||||
RebaseConflictState,
|
||||
IRebaseState,
|
||||
IRepositoryState,
|
||||
ChangesSelectionKind,
|
||||
ChangesWorkingDirectorySelection,
|
||||
isRebaseConflictState,
|
||||
isCherryPickConflictState,
|
||||
isMergeConflictState,
|
||||
CherryPickConflictState,
|
||||
} from '../app-state'
|
||||
import {
|
||||
findEditorOrDefault,
|
||||
|
@ -150,6 +154,9 @@ import {
|
|||
deleteLocalBranch,
|
||||
deleteRemoteBranch,
|
||||
fastForwardBranches,
|
||||
revRangeInclusive,
|
||||
GitResetMode,
|
||||
reset,
|
||||
} from '../git'
|
||||
import {
|
||||
installGlobalLFSFilters,
|
||||
|
@ -266,6 +273,18 @@ import {
|
|||
getShowSideBySideDiff,
|
||||
setShowSideBySideDiff,
|
||||
} from '../../ui/lib/diff-mode'
|
||||
import {
|
||||
CherryPickFlowStep,
|
||||
CherryPickStepKind,
|
||||
} from '../../models/cherry-pick'
|
||||
import {
|
||||
abortCherryPick,
|
||||
cherryPick,
|
||||
CherryPickResult,
|
||||
continueCherryPick,
|
||||
getCherryPickSnapshot,
|
||||
isCherryPickHeadFound,
|
||||
} from '../git/cherry-pick'
|
||||
|
||||
const LastSelectedRepositoryIDKey = 'last-selected-repository-id'
|
||||
|
||||
|
@ -321,6 +340,8 @@ const InitialRepositoryIndicatorTimeout = 2 * 60 * 1000
|
|||
|
||||
const MaxInvalidFoldersToDisplay = 3
|
||||
|
||||
const hasShownCherryPickIntroKey = 'has-shown-cherry-pick-intro'
|
||||
|
||||
export class AppStore extends TypedBaseStore<IAppState> {
|
||||
private readonly gitStoreCache: GitStoreCache
|
||||
|
||||
|
@ -421,6 +442,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
private currentOnboardingTutorialStep = TutorialStep.NotApplicable
|
||||
private readonly tutorialAssessor: OnboardingTutorialAssessor
|
||||
|
||||
/**
|
||||
* Whether or not the user has been introduced to the cherry pick feature
|
||||
*/
|
||||
private hasShownCherryPickIntro: boolean = false
|
||||
|
||||
public constructor(
|
||||
private readonly gitHubUserStore: GitHubUserStore,
|
||||
private readonly cloningRepositoriesStore: CloningRepositoriesStore,
|
||||
|
@ -767,6 +793,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
currentOnboardingTutorialStep: this.currentOnboardingTutorialStep,
|
||||
repositoryIndicatorsEnabled: this.repositoryIndicatorsEnabled,
|
||||
commitSpellcheckEnabled: this.commitSpellcheckEnabled,
|
||||
hasShownCherryPickIntro: this.hasShownCherryPickIntro,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -935,7 +962,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
|
||||
private clearSelectedCommit(repository: Repository) {
|
||||
this.repositoryStateCache.updateCommitSelection(repository, () => ({
|
||||
sha: null,
|
||||
shas: [],
|
||||
file: null,
|
||||
changedFiles: [],
|
||||
diff: null,
|
||||
|
@ -945,16 +972,19 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _changeCommitSelection(
|
||||
repository: Repository,
|
||||
sha: string
|
||||
shas: ReadonlyArray<string>
|
||||
): Promise<void> {
|
||||
const { commitSelection } = this.repositoryStateCache.get(repository)
|
||||
|
||||
if (commitSelection.sha === sha) {
|
||||
if (
|
||||
commitSelection.shas.length === shas.length &&
|
||||
commitSelection.shas.every((sha, i) => sha === shas[i])
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.repositoryStateCache.updateCommitSelection(repository, () => ({
|
||||
sha,
|
||||
shas,
|
||||
file: null,
|
||||
changedFiles: [],
|
||||
diff: null,
|
||||
|
@ -968,7 +998,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
commitSHAs: ReadonlyArray<string>
|
||||
) {
|
||||
const state = this.repositoryStateCache.get(repository)
|
||||
let selectedSHA = state.commitSelection.sha
|
||||
let selectedSHA =
|
||||
state.commitSelection.shas.length === 1
|
||||
? state.commitSelection.shas[0]
|
||||
: null
|
||||
if (selectedSHA != null) {
|
||||
const index = commitSHAs.findIndex(sha => sha === selectedSHA)
|
||||
if (index < 0) {
|
||||
|
@ -979,8 +1012,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}
|
||||
}
|
||||
|
||||
if (selectedSHA == null && commitSHAs.length > 0) {
|
||||
this._changeCommitSelection(repository, commitSHAs[0])
|
||||
if (state.commitSelection.shas.length === 0 && commitSHAs.length > 0) {
|
||||
this._changeCommitSelection(repository, [commitSHAs[0]])
|
||||
this._loadChangedFilesForCurrentSelection(repository)
|
||||
}
|
||||
}
|
||||
|
@ -1236,14 +1269,15 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
): Promise<void> {
|
||||
const state = this.repositoryStateCache.get(repository)
|
||||
const { commitSelection } = state
|
||||
const currentSHA = commitSelection.sha
|
||||
if (currentSHA == null) {
|
||||
const currentSHAs = commitSelection.shas
|
||||
if (currentSHAs.length !== 1) {
|
||||
// if none or multiple, we don't display a diff
|
||||
return
|
||||
}
|
||||
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
const changedFiles = await gitStore.performFailableOperation(() =>
|
||||
getChangedFiles(repository, currentSHA)
|
||||
getChangedFiles(repository, currentSHAs[0])
|
||||
)
|
||||
if (!changedFiles) {
|
||||
return
|
||||
|
@ -1252,7 +1286,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
// The selection could have changed between when we started loading the
|
||||
// changed files and we finished. We might wanna store the changed files per
|
||||
// SHA/path.
|
||||
if (currentSHA !== state.commitSelection.sha) {
|
||||
if (
|
||||
commitSelection.shas.length !== currentSHAs.length ||
|
||||
commitSelection.shas[0] !== currentSHAs[0]
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1297,9 +1334,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
this.emitUpdate()
|
||||
|
||||
const stateBeforeLoad = this.repositoryStateCache.get(repository)
|
||||
const sha = stateBeforeLoad.commitSelection.sha
|
||||
const shas = stateBeforeLoad.commitSelection.shas
|
||||
|
||||
if (!sha) {
|
||||
if (shas.length === 0) {
|
||||
if (__DEV__) {
|
||||
throw new Error(
|
||||
"No currently selected sha yet we've been asked to switch file selection"
|
||||
|
@ -1309,19 +1346,22 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}
|
||||
}
|
||||
|
||||
// We do not get a diff when multiple commits selected
|
||||
if (shas.length > 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const diff = await getCommitDiff(
|
||||
repository,
|
||||
file,
|
||||
sha,
|
||||
shas[0],
|
||||
this.hideWhitespaceInDiff
|
||||
)
|
||||
|
||||
const stateAfterLoad = this.repositoryStateCache.get(repository)
|
||||
|
||||
const { shas: shasAfter } = stateAfterLoad.commitSelection
|
||||
// A whole bunch of things could have happened since we initiated the diff load
|
||||
if (
|
||||
stateAfterLoad.commitSelection.sha !== stateBeforeLoad.commitSelection.sha
|
||||
) {
|
||||
if (shasAfter.length !== shas.length || shasAfter[0] !== shas[0]) {
|
||||
return
|
||||
}
|
||||
if (!stateAfterLoad.commitSelection.file) {
|
||||
|
@ -1712,6 +1752,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}
|
||||
})
|
||||
|
||||
this.hasShownCherryPickIntro = getBoolean(hasShownCherryPickIntroKey, false)
|
||||
|
||||
this.emitUpdateNow()
|
||||
|
||||
this.accountsStore.refresh()
|
||||
|
@ -1881,6 +1923,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}))
|
||||
|
||||
this.updateRebaseFlowConflictsIfFound(repository)
|
||||
this.updateCherryPickFlowConflictsIfFound(repository)
|
||||
|
||||
if (this.selectedRepository === repository) {
|
||||
this._triggerConflictsFlow(repository)
|
||||
|
@ -1902,7 +1945,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
)
|
||||
const { conflictState } = changesState
|
||||
|
||||
if (conflictState === null || isMergeConflictState(conflictState)) {
|
||||
if (conflictState === null || !isRebaseConflictState(conflictState)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1929,6 +1972,32 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push changes from latest conflicts into current cherry pick flow step, if needed
|
||||
* - i.e. - multiple instance of running in to conflicts
|
||||
*/
|
||||
private updateCherryPickFlowConflictsIfFound(repository: Repository) {
|
||||
const { changesState, cherryPickState } = this.repositoryStateCache.get(
|
||||
repository
|
||||
)
|
||||
const { conflictState } = changesState
|
||||
|
||||
if (conflictState === null || !isCherryPickConflictState(conflictState)) {
|
||||
return
|
||||
}
|
||||
|
||||
const { step } = cherryPickState
|
||||
if (step === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (step.kind === CherryPickStepKind.ShowConflicts) {
|
||||
this.repositoryStateCache.updateCherryPickState(repository, () => ({
|
||||
step: { ...step, conflictState },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
private async _triggerConflictsFlow(repository: Repository) {
|
||||
const state = this.repositoryStateCache.get(repository)
|
||||
const { conflictState } = state.changesState
|
||||
|
@ -1938,10 +2007,12 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
return
|
||||
}
|
||||
|
||||
if (conflictState.kind === 'merge') {
|
||||
if (isMergeConflictState(conflictState)) {
|
||||
await this.showMergeConflictsDialog(repository, conflictState)
|
||||
} else if (conflictState.kind === 'rebase') {
|
||||
} else if (isRebaseConflictState(conflictState)) {
|
||||
await this.showRebaseConflictsDialog(repository, conflictState)
|
||||
} else if (isCherryPickConflictState(conflictState)) {
|
||||
await this.showCherryPickConflictsDialog(repository, conflictState)
|
||||
} else {
|
||||
assertNever(conflictState, `Unsupported conflict kind`)
|
||||
}
|
||||
|
@ -3977,7 +4048,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
|
||||
const { commitSelection } = this.repositoryStateCache.get(repository)
|
||||
|
||||
if (commitSelection.sha === commit.sha) {
|
||||
if (
|
||||
commitSelection.shas.length > 0 &&
|
||||
commitSelection.shas.find(sha => sha === commit.sha) !== undefined
|
||||
) {
|
||||
this.clearSelectedCommit(repository)
|
||||
}
|
||||
|
||||
|
@ -5480,8 +5554,17 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}
|
||||
})
|
||||
|
||||
// update rebase flow state after choosing manual resolution
|
||||
this.updateRebaseStateAfterManualResolution(repository)
|
||||
this.updateCherryPickStateAfterManualResolution(repository)
|
||||
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the rebase flow conflict step state as the manual resolutions
|
||||
* have been changed.
|
||||
*/
|
||||
private updateRebaseStateAfterManualResolution(repository: Repository) {
|
||||
const currentState = this.repositoryStateCache.get(repository)
|
||||
|
||||
const { changesState, rebaseState } = currentState
|
||||
|
@ -5498,8 +5581,33 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
step: { ...step, conflictState },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
this.emitUpdate()
|
||||
/**
|
||||
* Updates the cherry pick flow conflict step state as the manual resolutions
|
||||
* have been changed.
|
||||
*/
|
||||
private updateCherryPickStateAfterManualResolution(
|
||||
repository: Repository
|
||||
): void {
|
||||
const currentState = this.repositoryStateCache.get(repository)
|
||||
|
||||
const { changesState, cherryPickState } = currentState
|
||||
const { conflictState } = changesState
|
||||
const { step } = cherryPickState
|
||||
|
||||
if (
|
||||
conflictState === null ||
|
||||
step === null ||
|
||||
!isCherryPickConflictState(conflictState) ||
|
||||
step.kind !== CherryPickStepKind.ShowConflicts
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.repositoryStateCache.updateCherryPickState(repository, () => ({
|
||||
step: { ...step, conflictState },
|
||||
}))
|
||||
}
|
||||
|
||||
private async createStashAndDropPreviousEntry(
|
||||
|
@ -5681,6 +5789,369 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
this._closePopup(PopupType.CreateTutorialRepository)
|
||||
}
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _setCherryPickFlowStep(
|
||||
repository: Repository,
|
||||
step: CherryPickFlowStep
|
||||
): Promise<void> {
|
||||
this.repositoryStateCache.updateCherryPickState(repository, () => ({
|
||||
step,
|
||||
}))
|
||||
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public _initializeCherryPickProgress(
|
||||
repository: Repository,
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
) {
|
||||
if (commits.length === 0) {
|
||||
// This shouldn't happen... but in case throw error.
|
||||
throw new Error(
|
||||
'Unable to initialize cherry pick progress. No commits provided.'
|
||||
)
|
||||
}
|
||||
|
||||
this.repositoryStateCache.updateCherryPickState(repository, () => {
|
||||
return {
|
||||
progress: {
|
||||
kind: 'cherryPick',
|
||||
title: `Cherry picking commit 1 of ${commits.length} commits`,
|
||||
value: 0,
|
||||
cherryPickCommitCount: 1,
|
||||
totalCommitCount: commits.length,
|
||||
currentCommitSummary: commits[0].summary,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _cherryPick(
|
||||
repository: Repository,
|
||||
targetBranch: Branch,
|
||||
commits: ReadonlyArray<CommitOneLine>,
|
||||
sourceBranch: Branch | null
|
||||
): Promise<CherryPickResult> {
|
||||
if (commits.length === 0) {
|
||||
log.warn('[_cherryPick] - Unable to cherry pick. No commits provided.')
|
||||
return CherryPickResult.UnableToStart
|
||||
}
|
||||
let result: CherryPickResult | null | undefined
|
||||
|
||||
result = this.checkForUncommittedChangesBeforeCherryPick(
|
||||
repository,
|
||||
targetBranch,
|
||||
commits,
|
||||
sourceBranch
|
||||
)
|
||||
|
||||
if (result !== null) {
|
||||
return result
|
||||
}
|
||||
|
||||
result = await this.checkoutTargetBranchForCherryPick(
|
||||
repository,
|
||||
targetBranch
|
||||
)
|
||||
|
||||
if (result !== null) {
|
||||
return result
|
||||
}
|
||||
|
||||
const progressCallback = (progress: ICherryPickProgress) => {
|
||||
this.repositoryStateCache.updateCherryPickState(repository, () => ({
|
||||
progress,
|
||||
}))
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
let revisionRange: string
|
||||
if (commits.length === 1) {
|
||||
revisionRange = commits[0].sha
|
||||
} else {
|
||||
const earliestCommit = commits[commits.length - 1]
|
||||
revisionRange = revRangeInclusive(earliestCommit.sha, commits[0].sha)
|
||||
}
|
||||
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
result = await gitStore.performFailableOperation(() =>
|
||||
cherryPick(repository, revisionRange, progressCallback)
|
||||
)
|
||||
|
||||
return result || CherryPickResult.Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for uncommitted changes before cherry pick
|
||||
*
|
||||
* If uncommitted changes exist, ask user to stash and return
|
||||
* CherryPickResult.UnableToStart.
|
||||
*
|
||||
* If no uncommitted changes, return null.
|
||||
*/
|
||||
private checkForUncommittedChangesBeforeCherryPick(
|
||||
repository: Repository,
|
||||
targetBranch: Branch,
|
||||
commits: ReadonlyArray<CommitOneLine>,
|
||||
sourceBranch: Branch | null
|
||||
): CherryPickResult | null {
|
||||
const { changesState } = this.repositoryStateCache.get(repository)
|
||||
const hasChanges = changesState.workingDirectory.files.length > 0
|
||||
if (!hasChanges) {
|
||||
return null
|
||||
}
|
||||
|
||||
this._showPopup({
|
||||
type: PopupType.LocalChangesOverwritten,
|
||||
repository,
|
||||
retryAction: {
|
||||
type: RetryActionType.CherryPick,
|
||||
repository,
|
||||
targetBranch,
|
||||
commits,
|
||||
sourceBranch,
|
||||
},
|
||||
files: changesState.workingDirectory.files.map(f => f.path),
|
||||
})
|
||||
|
||||
return CherryPickResult.UnableToStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to checkout target branch of cherry pick operation
|
||||
*
|
||||
* If unable to checkout, return CherryPickResult.UnableToStart
|
||||
* Otherwise, return null.
|
||||
*/
|
||||
private async checkoutTargetBranchForCherryPick(
|
||||
repository: Repository,
|
||||
targetBranch: Branch
|
||||
): Promise<CherryPickResult | null> {
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
|
||||
const checkoutSuccessful = await this.withAuthenticatingUser(
|
||||
repository,
|
||||
(r, account) => {
|
||||
return gitStore.performFailableOperation(() =>
|
||||
checkoutBranch(repository, account, targetBranch)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return checkoutSuccessful === true ? null : CherryPickResult.UnableToStart
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _abortCherryPick(
|
||||
repository: Repository,
|
||||
sourceBranch: Branch | null
|
||||
): Promise<void> {
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
|
||||
await gitStore.performFailableOperation(() => abortCherryPick(repository))
|
||||
|
||||
this.checkoutBranchIfNotNull(repository, sourceBranch)
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public _endCherryPickFlow(repository: Repository): void {
|
||||
this.repositoryStateCache.updateCherryPickState(repository, () => ({
|
||||
step: null,
|
||||
progress: null,
|
||||
userHasResolvedConflicts: false,
|
||||
}))
|
||||
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public _setCherryPickTargetBranchUndoSha(
|
||||
repository: Repository,
|
||||
sha: string
|
||||
): void {
|
||||
// An update is not emitted here because there is no need
|
||||
// to trigger a re-render at this point. (storing for later)
|
||||
this.repositoryStateCache.updateCherryPickState(repository, () => ({
|
||||
targetBranchUndoSha: sha,
|
||||
}))
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public _setCherryPickConflictsResolved(repository: Repository) {
|
||||
// an update is not emitted here because there is no need
|
||||
// to trigger a re-render at this point
|
||||
|
||||
this.repositoryStateCache.updateCherryPickState(repository, () => ({
|
||||
userHasResolvedConflicts: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _continueCherryPick(
|
||||
repository: Repository,
|
||||
files: ReadonlyArray<WorkingDirectoryFileChange>,
|
||||
manualResolutions: ReadonlyMap<string, ManualConflictResolution>
|
||||
): Promise<CherryPickResult> {
|
||||
const progressCallback = (progress: ICherryPickProgress) => {
|
||||
this.repositoryStateCache.updateCherryPickState(repository, () => ({
|
||||
progress,
|
||||
}))
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
const result = await gitStore.performFailableOperation(() =>
|
||||
continueCherryPick(repository, files, manualResolutions, progressCallback)
|
||||
)
|
||||
|
||||
return result || CherryPickResult.Error
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _setCherryPickProgressFromState(repository: Repository) {
|
||||
const snapshot = await getCherryPickSnapshot(repository)
|
||||
if (snapshot === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const { progress, targetBranchUndoSha } = snapshot
|
||||
|
||||
this.repositoryStateCache.updateCherryPickState(repository, () => ({
|
||||
progress,
|
||||
targetBranchUndoSha,
|
||||
}))
|
||||
}
|
||||
|
||||
/** display the cherry pick flow, if not already in this flow */
|
||||
private async showCherryPickConflictsDialog(
|
||||
repository: Repository,
|
||||
conflictState: CherryPickConflictState
|
||||
) {
|
||||
const alreadyInFlow =
|
||||
this.currentPopup !== null &&
|
||||
this.currentPopup.type === PopupType.CherryPick
|
||||
|
||||
if (alreadyInFlow) {
|
||||
return
|
||||
}
|
||||
|
||||
const displayingBanner =
|
||||
this.currentBanner !== null &&
|
||||
this.currentBanner.type === BannerType.CherryPickConflictsFound
|
||||
|
||||
if (displayingBanner) {
|
||||
return
|
||||
}
|
||||
|
||||
await this._setCherryPickProgressFromState(repository)
|
||||
|
||||
this._setCherryPickFlowStep(repository, {
|
||||
kind: CherryPickStepKind.ShowConflicts,
|
||||
conflictState,
|
||||
})
|
||||
|
||||
const snapshot = await getCherryPickSnapshot(repository)
|
||||
|
||||
if (snapshot === null) {
|
||||
log.warn(
|
||||
`[showCherryPickConflictsDialog] unable to get cherry pick status from git, unable to continue`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this._showPopup({
|
||||
type: PopupType.CherryPick,
|
||||
repository,
|
||||
commits: snapshot?.commits,
|
||||
sourceBranch: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public _dismissCherryPickIntro() {
|
||||
setBoolean(hasShownCherryPickIntroKey, true)
|
||||
this.hasShownCherryPickIntro = true
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _clearCherryPickingHead(
|
||||
repository: Repository,
|
||||
sourceBranch: Branch | null
|
||||
): Promise<void> {
|
||||
if (!isCherryPickHeadFound(repository)) {
|
||||
return
|
||||
}
|
||||
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
await gitStore.performFailableOperation(() => abortCherryPick(repository))
|
||||
|
||||
this.checkoutBranchIfNotNull(repository, sourceBranch)
|
||||
|
||||
return this._refreshRepository(repository)
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _undoCherryPick(
|
||||
repository: Repository,
|
||||
targetBranchName: string,
|
||||
sourceBranch: Branch | null,
|
||||
countCherryPicked: number
|
||||
): Promise<void> {
|
||||
const { branchesState } = this.repositoryStateCache.get(repository)
|
||||
const { tip } = branchesState
|
||||
if (tip.kind !== TipState.Valid || tip.branch.name !== targetBranchName) {
|
||||
log.warn(
|
||||
'[undoCherryPick] - Could not undo cherry pick. User no longer on target branch.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
cherryPickState: { targetBranchUndoSha },
|
||||
} = this.repositoryStateCache.get(repository)
|
||||
|
||||
if (targetBranchUndoSha === null) {
|
||||
log.warn('[undoCherryPick] - Could not determine target branch undo sha')
|
||||
return
|
||||
}
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
await gitStore.performFailableOperation(() =>
|
||||
reset(repository, GitResetMode.Hard, targetBranchUndoSha)
|
||||
)
|
||||
|
||||
this.checkoutBranchIfNotNull(repository, sourceBranch)
|
||||
|
||||
const banner: Banner = {
|
||||
type: BannerType.CherryPickUndone,
|
||||
targetBranchName,
|
||||
countCherryPicked,
|
||||
}
|
||||
this._setBanner(banner)
|
||||
|
||||
return this._refreshRepository(repository)
|
||||
}
|
||||
|
||||
private async checkoutBranchIfNotNull(
|
||||
repository: Repository,
|
||||
sourceBranch: Branch | null
|
||||
) {
|
||||
if (sourceBranch === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
await this.withAuthenticatingUser(repository, async (r, account) => {
|
||||
await gitStore.performFailableOperation(() =>
|
||||
checkoutBranch(repository, account, sourceBranch)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
ICommitSelection,
|
||||
IRebaseState,
|
||||
ChangesSelectionKind,
|
||||
ICherryPickState,
|
||||
} from '../app-state'
|
||||
import { merge } from '../merge'
|
||||
import { DefaultCommitMessage } from '../../models/commit-message'
|
||||
|
@ -101,12 +102,23 @@ export class RepositoryStateCache {
|
|||
return { rebaseState: newState }
|
||||
})
|
||||
}
|
||||
|
||||
public updateCherryPickState<K extends keyof ICherryPickState>(
|
||||
repository: Repository,
|
||||
fn: (state: ICherryPickState) => Pick<ICherryPickState, K>
|
||||
) {
|
||||
this.update(repository, state => {
|
||||
const { cherryPickState } = state
|
||||
const newState = merge(cherryPickState, fn(cherryPickState))
|
||||
return { cherryPickState: newState }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialRepositoryState(): IRepositoryState {
|
||||
return {
|
||||
commitSelection: {
|
||||
sha: null,
|
||||
shas: [],
|
||||
file: null,
|
||||
changedFiles: new Array<CommittedFileChange>(),
|
||||
diff: null,
|
||||
|
@ -170,5 +182,11 @@ function getInitialRepositoryState(): IRepositoryState {
|
|||
checkoutProgress: null,
|
||||
pushPullFetchProgress: null,
|
||||
revertProgress: null,
|
||||
cherryPickState: {
|
||||
step: null,
|
||||
progress: null,
|
||||
userHasResolvedConflicts: false,
|
||||
targetBranchUndoSha: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -157,6 +157,18 @@ function getConflictState(
|
|||
}
|
||||
}
|
||||
|
||||
if (status.isCherryPickingHeadFound) {
|
||||
const { currentBranch: targetBranchName } = status
|
||||
if (targetBranchName == null) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
kind: 'cherryPick',
|
||||
manualResolutions,
|
||||
targetBranchName,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@ export class TrampolineServer {
|
|||
this.server.listen(0, '127.0.0.1', async () => {
|
||||
// Replace the error handler
|
||||
this.server.removeAllListeners('error')
|
||||
this.server.on('error', error => this.onError(error))
|
||||
this.server.on('error', this.onError)
|
||||
|
||||
resolve()
|
||||
})
|
||||
|
@ -125,6 +125,8 @@ export class TrampolineServer {
|
|||
socket.pipe(split2(/\0/)).on('data', data => {
|
||||
this.onDataReceived(socket, parser, data)
|
||||
})
|
||||
|
||||
socket.on('error', this.onError)
|
||||
}
|
||||
|
||||
private onDataReceived(
|
||||
|
@ -177,7 +179,7 @@ export class TrampolineServer {
|
|||
}
|
||||
}
|
||||
|
||||
private onError(error: Error) {
|
||||
private onError = (error: Error) => {
|
||||
sendNonFatalException('trampolineServer', error)
|
||||
this.close()
|
||||
}
|
||||
|
|
|
@ -6,6 +6,9 @@ export enum BannerType {
|
|||
SuccessfulRebase = 'SuccessfulRebase',
|
||||
RebaseConflictsFound = 'RebaseConflictsFound',
|
||||
BranchAlreadyUpToDate = 'BranchAlreadyUpToDate',
|
||||
SuccessfulCherryPick = 'SuccessfulCherryPick',
|
||||
CherryPickConflictsFound = 'CherryPickConflictsFound',
|
||||
CherryPickUndone = 'CherryPickUndone',
|
||||
}
|
||||
|
||||
export type Banner =
|
||||
|
@ -44,3 +47,26 @@ export type Banner =
|
|||
/** name of the branch we merged into `ourBranch` */
|
||||
readonly theirBranch?: string
|
||||
}
|
||||
| {
|
||||
readonly type: BannerType.SuccessfulCherryPick
|
||||
/** name of the branch that was cherry picked to */
|
||||
readonly targetBranchName: string
|
||||
/** number of commits cherry picked */
|
||||
readonly countCherryPicked: number
|
||||
/** callback to run when user clicks undo link in banner */
|
||||
readonly onUndoCherryPick: () => void
|
||||
}
|
||||
| {
|
||||
readonly type: BannerType.CherryPickConflictsFound
|
||||
/** name of the branch that the commits are being cherry picked onto */
|
||||
readonly targetBranchName: string
|
||||
/** callback to run when user clicks on link in banner text */
|
||||
readonly onOpenConflictsDialog: () => void
|
||||
}
|
||||
| {
|
||||
readonly type: BannerType.CherryPickUndone
|
||||
/** name of the branch that the commits were cherry picked onto */
|
||||
readonly targetBranchName: string
|
||||
/** number of commits cherry picked */
|
||||
readonly countCherryPicked: number
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { CherryPickConflictState } from '../lib/app-state'
|
||||
import { Branch } from './branch'
|
||||
import { CommitOneLine } from './commit'
|
||||
import { ICherryPickProgress } from './progress'
|
||||
|
||||
|
@ -5,6 +7,112 @@ import { ICherryPickProgress } from './progress'
|
|||
export interface ICherryPickSnapshot {
|
||||
/** The sequence of commits remaining to be cherry picked */
|
||||
readonly remainingCommits: ReadonlyArray<CommitOneLine>
|
||||
/** The sequence of commits being cherry picked */
|
||||
readonly commits: ReadonlyArray<CommitOneLine>
|
||||
/** The progress of the operation */
|
||||
readonly progress: ICherryPickProgress
|
||||
/** The sha of the target branch tip before cherry pick initiated. */
|
||||
readonly targetBranchUndoSha: string
|
||||
}
|
||||
|
||||
/** Union type representing the possible states of the cherry pick flow */
|
||||
export type CherryPickFlowStep =
|
||||
| ChooseTargetBranchesStep
|
||||
| ShowProgressStep
|
||||
| ShowConflictsStep
|
||||
| CommitsChosenStep
|
||||
| HideConflictsStep
|
||||
| ConfirmAbortStep
|
||||
|
||||
export const enum CherryPickStepKind {
|
||||
/**
|
||||
* An initial state of a cherry pick.
|
||||
*
|
||||
* This is where the user has started dragging a commit or set of commits but
|
||||
* has not yet dropped them on a branch or an area to launch to choose branch
|
||||
* dialog.
|
||||
*/
|
||||
CommitsChosen = 'CommitsChosen',
|
||||
|
||||
/**
|
||||
* An initial state of a cherry pick.
|
||||
*
|
||||
* This is where the user picks which is the target of the cherry pick.
|
||||
* This step will be skipped when cherry pick is initiated through
|
||||
* drag and drop onto a specific branch. But, it will be the first step
|
||||
* if the cherry pick is initiated through the context menu.
|
||||
*/
|
||||
ChooseTargetBranch = 'ChooseTargetBranch',
|
||||
|
||||
/**
|
||||
* After the user chooses the target branch of the cherry pick, the
|
||||
* progress view shows the cherry pick is progressing.
|
||||
*
|
||||
* This should be the default view when there are no conflicts to address.
|
||||
*/
|
||||
ShowProgress = 'ShowProgress',
|
||||
|
||||
/**
|
||||
* The cherry pick has encountered conflicts that need resolved. This will be
|
||||
* shown as a list of files and the conflict state.
|
||||
*
|
||||
* Once the conflicts are resolved, the user can continue the cherry pick and
|
||||
* the view will switch back to `ShowProgress`.
|
||||
*/
|
||||
ShowConflicts = 'ShowConflicts',
|
||||
|
||||
/**
|
||||
* The user may wish to leave the conflict dialog and view the files in
|
||||
* the Changes tab to get a better context. In this situation, the application
|
||||
* will show a banner to indicate this context and help the user return to the
|
||||
* conflicted list.
|
||||
*/
|
||||
HideConflicts = 'HideConflicts',
|
||||
|
||||
/**
|
||||
* If the user attempts to abort the in-progress cherry pick and the user has
|
||||
* resolved conflicts, the application should ask the user to confirm that
|
||||
* they wish to abort.
|
||||
*/
|
||||
ConfirmAbort = 'ConfirmAbort',
|
||||
}
|
||||
|
||||
/** Shape of data needed to choose the base branch for a cherry pick */
|
||||
export type ChooseTargetBranchesStep = {
|
||||
readonly kind: CherryPickStepKind.ChooseTargetBranch
|
||||
readonly defaultBranch: Branch | null
|
||||
readonly currentBranch: Branch
|
||||
readonly allBranches: ReadonlyArray<Branch>
|
||||
readonly recentBranches: ReadonlyArray<Branch>
|
||||
}
|
||||
|
||||
/** Shape of data to show progress of the current cherry pick */
|
||||
export type ShowProgressStep = {
|
||||
readonly kind: CherryPickStepKind.ShowProgress
|
||||
}
|
||||
|
||||
/** Shape of data to show conflicts that need to be resolved by the user */
|
||||
export type ShowConflictsStep = {
|
||||
readonly kind: CherryPickStepKind.ShowConflicts
|
||||
conflictState: CherryPickConflictState
|
||||
}
|
||||
|
||||
/** Shape of data to track when user hides conflicts dialog */
|
||||
export type HideConflictsStep = {
|
||||
readonly kind: CherryPickStepKind.HideConflicts
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of data for when a user has chosen commits to cherry pick but not yet
|
||||
* selected a target branch.
|
||||
*/
|
||||
export type CommitsChosenStep = {
|
||||
readonly kind: CherryPickStepKind.CommitsChosen
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
}
|
||||
|
||||
/** Shape of data to use when confirming user should abort cherry pick */
|
||||
export type ConfirmAbortStep = {
|
||||
readonly kind: CherryPickStepKind.ConfirmAbort
|
||||
readonly conflictState: CherryPickConflictState
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { IRemote } from './remote'
|
|||
import { RetryAction } from './retry-actions'
|
||||
import { WorkingDirectoryFileChange } from './status'
|
||||
import { PreferencesTab } from './preferences'
|
||||
import { ICommitContext } from './commit'
|
||||
import { CommitOneLine, ICommitContext } from './commit'
|
||||
import { IStashEntry } from './stash-entry'
|
||||
import { Account } from '../models/account'
|
||||
import { Progress } from './progress'
|
||||
|
@ -273,5 +273,6 @@ export type Popup =
|
|||
| {
|
||||
type: PopupType.CherryPick
|
||||
repository: Repository
|
||||
commitSha: string
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
sourceBranch: Branch | null
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Repository } from './repository'
|
||||
import { CloneOptions } from './clone-options'
|
||||
import { Branch } from './branch'
|
||||
import { CommitOneLine } from './commit'
|
||||
|
||||
/** The types of actions that can be retried. */
|
||||
export enum RetryActionType {
|
||||
|
@ -11,6 +12,7 @@ export enum RetryActionType {
|
|||
Checkout,
|
||||
Merge,
|
||||
Rebase,
|
||||
CherryPick,
|
||||
}
|
||||
|
||||
/** The retriable actions and their associated data. */
|
||||
|
@ -42,3 +44,10 @@ export type RetryAction =
|
|||
baseBranch: Branch
|
||||
targetBranch: Branch
|
||||
}
|
||||
| {
|
||||
type: RetryActionType.CherryPick
|
||||
repository: Repository
|
||||
targetBranch: Branch
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
sourceBranch: Branch | null
|
||||
}
|
||||
|
|
|
@ -8,6 +8,9 @@ import {
|
|||
FoldoutType,
|
||||
SelectionType,
|
||||
HistoryTabMode,
|
||||
ICherryPickState,
|
||||
isRebaseConflictState,
|
||||
isCherryPickConflictState,
|
||||
} from '../lib/app-state'
|
||||
import { Dispatcher } from './dispatcher'
|
||||
import { AppStore, GitHubUserStore, IssuesStore } from '../lib/stores'
|
||||
|
@ -120,7 +123,14 @@ import { DiscardSelection } from './discard-changes/discard-selection-dialog'
|
|||
import { LocalChangesOverwrittenDialog } from './local-changes-overwritten/local-changes-overwritten-dialog'
|
||||
import memoizeOne from 'memoize-one'
|
||||
import { AheadBehindStore } from '../lib/stores/ahead-behind-store'
|
||||
import { CherryPickFlow } from './cherry-pick/cherry-pick-flow'
|
||||
import {
|
||||
CherryPickStepKind,
|
||||
ChooseTargetBranchesStep,
|
||||
} from '../models/cherry-pick'
|
||||
import { getAccountForRepository } from '../lib/get-account-for-repository'
|
||||
import { CommitOneLine } from '../models/commit'
|
||||
import { WorkingDirectoryStatus } from '../models/status'
|
||||
|
||||
const MinuteInMilliseconds = 1000 * 60
|
||||
const HourInMilliseconds = MinuteInMilliseconds * 60
|
||||
|
@ -1991,9 +2001,47 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
files={popup.files}
|
||||
/>
|
||||
)
|
||||
case PopupType.CherryPick:
|
||||
// TODO: Create Cherry Pick Branch Dialog
|
||||
return null
|
||||
case PopupType.CherryPick: {
|
||||
const cherryPickState = this.getCherryPickState()
|
||||
const workingDirectory = this.getWorkingDirectory()
|
||||
if (
|
||||
cherryPickState === null ||
|
||||
cherryPickState.step == null ||
|
||||
workingDirectory === null
|
||||
) {
|
||||
log.warn(
|
||||
`[App] Invalid state encountered:
|
||||
cherry pick flow should not be active when step is null,
|
||||
the selected app state is not a repository state,
|
||||
or cannot obtain the working directory.`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const { step, progress, userHasResolvedConflicts } = cherryPickState
|
||||
|
||||
return (
|
||||
<CherryPickFlow
|
||||
key="cherry-pick-flow"
|
||||
repository={popup.repository}
|
||||
dispatcher={this.props.dispatcher}
|
||||
onDismissed={onPopupDismissedFn}
|
||||
step={step}
|
||||
emoji={this.state.emoji}
|
||||
progress={progress}
|
||||
commits={popup.commits}
|
||||
openFileInExternalEditor={this.openFileInExternalEditor}
|
||||
workingDirectory={workingDirectory}
|
||||
userHasResolvedConflicts={userHasResolvedConflicts}
|
||||
resolvedExternalEditor={this.state.resolvedExternalEditor}
|
||||
openRepositoryInShell={this.openCurrentRepositoryInShell}
|
||||
sourceBranch={popup.sourceBranch}
|
||||
onShowCherryPickConflictsBanner={
|
||||
this.onShowCherryPickConflictsBanner
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return assertNever(popup, `Unknown popup type: ${popup}`)
|
||||
}
|
||||
|
@ -2026,9 +2074,9 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
)
|
||||
const { conflictState } = changesState
|
||||
|
||||
if (conflictState === null || conflictState.kind === 'merge') {
|
||||
if (conflictState === null || !isRebaseConflictState(conflictState)) {
|
||||
log.debug(
|
||||
`[App.onShowRebaseConflictsBanner] no conflict state found, ignoring...`
|
||||
`[App.onShowRebaseConflictsBanner] no rebase conflict state found, ignoring...`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
@ -2587,6 +2635,8 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
isShowingFoldout={this.state.currentFoldout !== null}
|
||||
aheadBehindStore={this.props.aheadBehindStore}
|
||||
commitSpellcheckEnabled={this.state.commitSpellcheckEnabled}
|
||||
onCherryPick={this.startCherryPickWithoutBranch}
|
||||
hasShownCherryPickIntro={this.state.hasShownCherryPickIntro}
|
||||
/>
|
||||
)
|
||||
} else if (selectedState.type === SelectionType.CloningRepository) {
|
||||
|
@ -2687,6 +2737,126 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
private isTutorialPaused() {
|
||||
return this.state.currentOnboardingTutorialStep === TutorialStep.Paused
|
||||
}
|
||||
|
||||
/**
|
||||
* When starting cherry pick from context menu, we need to initialize the
|
||||
* cherry pick state flow step with the ChooseTargetBranch as opposed
|
||||
* to drag and drop which will start at the ShowProgress step.
|
||||
*
|
||||
* Step initialization must be done before and outside of the
|
||||
* `currentPopupContent` method because it is a rendering method that is
|
||||
* re-run on every update. It will just keep showing the step initialized
|
||||
* there otherwise - not allowing for other flow steps.
|
||||
*/
|
||||
private startCherryPickWithoutBranch = (
|
||||
repository: Repository,
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
) => {
|
||||
const repositoryState = this.props.repositoryStateManager.get(repository)
|
||||
|
||||
const {
|
||||
defaultBranch,
|
||||
allBranches,
|
||||
recentBranches,
|
||||
tip,
|
||||
} = repositoryState.branchesState
|
||||
let currentBranch: Branch | null = null
|
||||
|
||||
if (tip.kind === TipState.Valid) {
|
||||
currentBranch = tip.branch
|
||||
} else {
|
||||
throw new Error(
|
||||
'Tip is not in a valid state, which is required to start the cherry pick flow'
|
||||
)
|
||||
}
|
||||
|
||||
const initialStep: ChooseTargetBranchesStep = {
|
||||
kind: CherryPickStepKind.ChooseTargetBranch,
|
||||
defaultBranch,
|
||||
currentBranch,
|
||||
allBranches,
|
||||
recentBranches,
|
||||
}
|
||||
|
||||
this.props.dispatcher.setCherryPickFlowStep(repository, initialStep)
|
||||
|
||||
this.showPopup({
|
||||
type: PopupType.CherryPick,
|
||||
repository,
|
||||
commits,
|
||||
sourceBranch: currentBranch,
|
||||
})
|
||||
}
|
||||
|
||||
private getCherryPickState(): ICherryPickState | null {
|
||||
const { selectedState } = this.state
|
||||
if (
|
||||
selectedState === null ||
|
||||
selectedState.type !== SelectionType.Repository
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { cherryPickState } = selectedState.state
|
||||
return cherryPickState
|
||||
}
|
||||
|
||||
private onShowCherryPickConflictsBanner = (
|
||||
repository: Repository,
|
||||
targetBranchName: string,
|
||||
sourceBranch: Branch | null,
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
) => {
|
||||
this.props.dispatcher.setCherryPickFlowStep(repository, {
|
||||
kind: CherryPickStepKind.HideConflicts,
|
||||
})
|
||||
|
||||
this.props.dispatcher.setBanner({
|
||||
type: BannerType.CherryPickConflictsFound,
|
||||
targetBranchName,
|
||||
onOpenConflictsDialog: async () => {
|
||||
const { changesState } = this.props.repositoryStateManager.get(
|
||||
repository
|
||||
)
|
||||
const { conflictState } = changesState
|
||||
|
||||
if (
|
||||
conflictState === null ||
|
||||
!isCherryPickConflictState(conflictState)
|
||||
) {
|
||||
log.debug(
|
||||
`[App.onShowCherryPickConflictsBanner] no cherry pick conflict state found, ignoring...`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await this.props.dispatcher.setCherryPickProgressFromState(repository)
|
||||
|
||||
this.props.dispatcher.setCherryPickFlowStep(repository, {
|
||||
kind: CherryPickStepKind.ShowConflicts,
|
||||
conflictState,
|
||||
})
|
||||
|
||||
this.props.dispatcher.showPopup({
|
||||
type: PopupType.CherryPick,
|
||||
repository,
|
||||
commits,
|
||||
sourceBranch,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private getWorkingDirectory(): WorkingDirectoryStatus | null {
|
||||
const { selectedState } = this.state
|
||||
if (
|
||||
selectedState === null ||
|
||||
selectedState.type !== SelectionType.Repository
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return selectedState.state.changesState.workingDirectory
|
||||
}
|
||||
}
|
||||
|
||||
function NoRepositorySelected() {
|
||||
|
|
48
app/src/ui/banners/cherry-pick-conflicts-banner.tsx
Normal file
48
app/src/ui/banners/cherry-pick-conflicts-banner.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import * as React from 'react'
|
||||
import { Octicon, OcticonSymbol } from '../octicons'
|
||||
import { Banner } from './banner'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
|
||||
interface ICherryPickConflictsBannerProps {
|
||||
/** branch the user is rebasing into */
|
||||
readonly targetBranchName: string
|
||||
/** callback to fire when the dialog should be reopened */
|
||||
readonly onOpenConflictsDialog: () => void
|
||||
/** callback to fire to dismiss the banner */
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
export class CherryPickConflictsBanner extends React.Component<
|
||||
ICherryPickConflictsBannerProps,
|
||||
{}
|
||||
> {
|
||||
private openDialog = async () => {
|
||||
this.props.onDismissed()
|
||||
this.props.onOpenConflictsDialog()
|
||||
}
|
||||
|
||||
private onDismissed = () => {
|
||||
log.warn(
|
||||
`[CherryPickConflictsBanner] this is not dismissable by default unless the user clicks on the link`
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<Banner
|
||||
id="cherry-pick-conflicts-banner"
|
||||
dismissable={false}
|
||||
onDismissed={this.onDismissed}
|
||||
>
|
||||
<Octicon className="alert-icon" symbol={OcticonSymbol.alert} />
|
||||
<div className="banner-message">
|
||||
<span>
|
||||
Resolve conflicts to continue cherry picking onto{' '}
|
||||
<strong>{this.props.targetBranchName}</strong>.
|
||||
</span>
|
||||
<LinkButton onClick={this.openDialog}>View conflicts</LinkButton>
|
||||
</div>
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
}
|
33
app/src/ui/banners/cherry-pick-undone.tsx
Normal file
33
app/src/ui/banners/cherry-pick-undone.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import * as React from 'react'
|
||||
import { Octicon, OcticonSymbol } from '../octicons'
|
||||
import { Banner } from './banner'
|
||||
|
||||
interface ICherryPickUndoneBannerProps {
|
||||
readonly targetBranchName: string
|
||||
readonly countCherryPicked: number
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
export class CherryPickUndone extends React.Component<
|
||||
ICherryPickUndoneBannerProps,
|
||||
{}
|
||||
> {
|
||||
public render() {
|
||||
const { countCherryPicked, targetBranchName, onDismissed } = this.props
|
||||
const pluralized = countCherryPicked === 1 ? 'commit' : 'commits'
|
||||
return (
|
||||
<Banner id="cherry-pick-undone" timeout={5000} onDismissed={onDismissed}>
|
||||
<div className="green-circle">
|
||||
<Octicon className="check-icon" symbol={OcticonSymbol.check} />
|
||||
</div>
|
||||
<div className="banner-message">
|
||||
<span>
|
||||
Cherry pick undone. Successfully removed the {countCherryPicked}
|
||||
{' copied '}
|
||||
{pluralized} from <strong>{targetBranchName}</strong>.
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -11,6 +11,9 @@ import { SuccessfulMerge } from './successful-merge'
|
|||
import { RebaseConflictsBanner } from './rebase-conflicts-banner'
|
||||
import { SuccessfulRebase } from './successful-rebase'
|
||||
import { BranchAlreadyUpToDate } from './branch-already-up-to-date-banner'
|
||||
import { SuccessfulCherryPick } from './successful-cherry-pick'
|
||||
import { CherryPickConflictsBanner } from './cherry-pick-conflicts-banner'
|
||||
import { CherryPickUndone } from './cherry-pick-undone'
|
||||
|
||||
export function renderBanner(
|
||||
banner: Banner,
|
||||
|
@ -63,7 +66,35 @@ export function renderBanner(
|
|||
theirBranch={banner.theirBranch}
|
||||
onDismissed={onDismissed}
|
||||
key={'branch-already-up-to-date'}
|
||||
></BranchAlreadyUpToDate>
|
||||
/>
|
||||
)
|
||||
case BannerType.SuccessfulCherryPick:
|
||||
return (
|
||||
<SuccessfulCherryPick
|
||||
key="successful-cherry-pick"
|
||||
targetBranchName={banner.targetBranchName}
|
||||
countCherryPicked={banner.countCherryPicked}
|
||||
onDismissed={onDismissed}
|
||||
onUndoCherryPick={banner.onUndoCherryPick}
|
||||
/>
|
||||
)
|
||||
case BannerType.CherryPickConflictsFound:
|
||||
return (
|
||||
<CherryPickConflictsBanner
|
||||
targetBranchName={banner.targetBranchName}
|
||||
onOpenConflictsDialog={banner.onOpenConflictsDialog}
|
||||
onDismissed={onDismissed}
|
||||
key={'cherry-pick-conflicts'}
|
||||
/>
|
||||
)
|
||||
case BannerType.CherryPickUndone:
|
||||
return (
|
||||
<CherryPickUndone
|
||||
key="cherry-pick-undone"
|
||||
targetBranchName={banner.targetBranchName}
|
||||
countCherryPicked={banner.countCherryPicked}
|
||||
onDismissed={onDismissed}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return assertNever(banner, `Unknown popup type: ${banner}`)
|
||||
|
|
45
app/src/ui/banners/successful-cherry-pick.tsx
Normal file
45
app/src/ui/banners/successful-cherry-pick.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import * as React from 'react'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
import { Octicon, OcticonSymbol } from '../octicons'
|
||||
import { Banner } from './banner'
|
||||
|
||||
interface ISuccessfulCherryPickBannerProps {
|
||||
readonly targetBranchName: string
|
||||
readonly countCherryPicked: number
|
||||
readonly onDismissed: () => void
|
||||
readonly onUndoCherryPick: () => void
|
||||
}
|
||||
|
||||
export class SuccessfulCherryPick extends React.Component<
|
||||
ISuccessfulCherryPickBannerProps,
|
||||
{}
|
||||
> {
|
||||
private undo = () => {
|
||||
this.props.onDismissed()
|
||||
this.props.onUndoCherryPick()
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { countCherryPicked, onDismissed, targetBranchName } = this.props
|
||||
|
||||
const pluralized = countCherryPicked === 1 ? 'commit' : 'commits'
|
||||
return (
|
||||
<Banner
|
||||
id="successful-cherry-pick"
|
||||
timeout={7500}
|
||||
onDismissed={onDismissed}
|
||||
>
|
||||
<div className="green-circle">
|
||||
<Octicon className="check-icon" symbol={OcticonSymbol.check} />
|
||||
</div>
|
||||
<div className="banner-message">
|
||||
<span>
|
||||
Successfully copied {countCherryPicked} {pluralized} to{' '}
|
||||
<strong>{targetBranchName}</strong>.{' '}
|
||||
<LinkButton onClick={this.undo}>Undo</LinkButton>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -7,6 +7,8 @@ import { Octicon, OcticonSymbol } from '../octicons'
|
|||
import { HighlightText } from '../lib/highlight-text'
|
||||
import { showContextualMenu } from '../main-process-proxy'
|
||||
import { IMenuItem } from '../../lib/menu-item'
|
||||
import { String } from 'aws-sdk/clients/apigateway'
|
||||
import classNames from 'classnames'
|
||||
|
||||
interface IBranchListItemProps {
|
||||
/** The name of the branch */
|
||||
|
@ -27,10 +29,25 @@ interface IBranchListItemProps {
|
|||
readonly onRenameBranch?: (branchName: string) => void
|
||||
|
||||
readonly onDeleteBranch?: (branchName: string) => void
|
||||
|
||||
readonly onDropOntoBranch?: (branchName: String) => void
|
||||
}
|
||||
|
||||
interface IBranchListItemState {
|
||||
readonly isDraggedOver: boolean
|
||||
}
|
||||
|
||||
/** The branch component. */
|
||||
export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
|
||||
export class BranchListItem extends React.Component<
|
||||
IBranchListItemProps,
|
||||
IBranchListItemState
|
||||
> {
|
||||
public constructor(props: IBranchListItemProps) {
|
||||
super(props)
|
||||
|
||||
this.state = { isDraggedOver: false }
|
||||
}
|
||||
|
||||
private onContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
|
@ -65,6 +82,26 @@ export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
|
|||
showContextualMenu(items)
|
||||
}
|
||||
|
||||
private onDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
this.setState({ isDraggedOver: true })
|
||||
}
|
||||
|
||||
private onDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
this.setState({ isDraggedOver: false })
|
||||
}
|
||||
|
||||
private onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
private onDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
const { onDropOntoBranch, name } = this.props
|
||||
|
||||
if (onDropOntoBranch !== undefined) {
|
||||
onDropOntoBranch(name)
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const lastCommitDate = this.props.lastCommitDate
|
||||
const isCurrentBranch = this.props.isCurrentBranch
|
||||
|
@ -77,8 +114,20 @@ export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
|
|||
: lastCommitDate
|
||||
? lastCommitDate.toString()
|
||||
: ''
|
||||
|
||||
const className = classNames('branches-list-item', {
|
||||
'dragged-over': this.state.isDraggedOver,
|
||||
})
|
||||
|
||||
return (
|
||||
<div onContextMenu={this.onContextMenu} className="branches-list-item">
|
||||
<div
|
||||
onContextMenu={this.onContextMenu}
|
||||
className={className}
|
||||
onDragLeave={this.onDragLeave}
|
||||
onDragEnter={this.onDragEnter}
|
||||
onDragOver={this.onDragOver}
|
||||
onDrop={this.onDrop}
|
||||
>
|
||||
<Octicon className="icon" symbol={icon} />
|
||||
<div className="name" title={name}>
|
||||
<HighlightText text={name} highlight={this.props.matches.title} />
|
||||
|
|
|
@ -11,7 +11,8 @@ export function renderDefaultBranch(
|
|||
matches: IMatches,
|
||||
currentBranch: Branch | null,
|
||||
onRenameBranch?: (branchName: string) => void,
|
||||
onDeleteBranch?: (branchName: string) => void
|
||||
onDeleteBranch?: (branchName: string) => void,
|
||||
onDropOntoBranch?: (branchName: string) => void
|
||||
): JSX.Element {
|
||||
const branch = item.branch
|
||||
const commit = branch.tip
|
||||
|
@ -25,6 +26,7 @@ export function renderDefaultBranch(
|
|||
matches={matches}
|
||||
onRenameBranch={onRenameBranch}
|
||||
onDeleteBranch={onDeleteBranch}
|
||||
onDropOntoBranch={onDropOntoBranch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -151,7 +151,8 @@ export class BranchesContainer extends React.Component<
|
|||
matches,
|
||||
this.props.currentBranch,
|
||||
this.onRenameBranch,
|
||||
this.onDeleteBranch
|
||||
this.onDeleteBranch,
|
||||
this.onDropOntoBranch
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -309,4 +310,30 @@ export class BranchesContainer extends React.Component<
|
|||
existsOnRemote: branch.upstreamRemoteName !== null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Method is to handle when something is dragged and dropped onto a branch
|
||||
* in the branch dropdown.
|
||||
*
|
||||
* Currently this is being implemented with cherry picking. But, this could be
|
||||
* expanded if we ever dropped something else on a branch; in which case,
|
||||
* we would likely have to check the app state to see what action is being
|
||||
* performed. As this branch container is not being used anywhere except
|
||||
* for the branch dropdown, we are not going to pass the repository state down
|
||||
* during this implementation.
|
||||
*/
|
||||
private onDropOntoBranch = (branchName: string) => {
|
||||
const branch = this.props.allBranches.find(b => b.name === branchName)
|
||||
if (branch === undefined) {
|
||||
log.warn(
|
||||
'[branches-container] - Branch name of branch dropped on does not exist.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.props.dispatcher.startCherryPickWithBranch(
|
||||
this.props.repository,
|
||||
branch
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -122,6 +122,8 @@ export class CommitMessage extends React.Component<
|
|||
private descriptionTextArea: HTMLTextAreaElement | null = null
|
||||
private descriptionTextAreaScrollDebounceId: number | null = null
|
||||
|
||||
private coAuthorInputRef = React.createRef<AuthorInput>()
|
||||
|
||||
public constructor(props: ICommitMessageProps) {
|
||||
super(props)
|
||||
const { commitMessage } = this.props
|
||||
|
@ -181,6 +183,14 @@ export class CommitMessage extends React.Component<
|
|||
|
||||
if (this.props.focusCommitMessage) {
|
||||
this.focusSummary()
|
||||
} else if (
|
||||
prevProps.showCoAuthoredBy === false &&
|
||||
this.isCoAuthorInputVisible &&
|
||||
// The co-author input could be also shown when switching between repos,
|
||||
// but in that case we don't want to give the focus to the input.
|
||||
prevProps.repository.id === this.props.repository.id
|
||||
) {
|
||||
this.coAuthorInputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -345,6 +355,7 @@ export class CommitMessage extends React.Component<
|
|||
|
||||
return (
|
||||
<AuthorInput
|
||||
ref={this.coAuthorInputRef}
|
||||
onAuthorsUpdated={this.onCoAuthorsUpdated}
|
||||
authors={this.props.coAuthors}
|
||||
autoCompleteProvider={autocompletionProvider}
|
||||
|
|
214
app/src/ui/cherry-pick/cherry-pick-conflicts-dialog.tsx
Normal file
214
app/src/ui/cherry-pick/cherry-pick-conflicts-dialog.tsx
Normal file
|
@ -0,0 +1,214 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
WorkingDirectoryStatus,
|
||||
WorkingDirectoryFileChange,
|
||||
} from '../../models/status'
|
||||
import { Repository } from '../../models/repository'
|
||||
|
||||
import {
|
||||
getUnmergedFiles,
|
||||
getConflictedFiles,
|
||||
isConflictedFile,
|
||||
getResolvedFiles,
|
||||
} from '../../lib/status'
|
||||
import {
|
||||
renderUnmergedFilesSummary,
|
||||
renderShellLink,
|
||||
renderAllResolved,
|
||||
} from '../lib/conflicts/render-functions'
|
||||
import { renderUnmergedFile } from '../lib/conflicts/unmerged-file'
|
||||
|
||||
import {
|
||||
DialogContent,
|
||||
Dialog,
|
||||
DialogFooter,
|
||||
OkCancelButtonGroup,
|
||||
} from '../dialog'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import { ShowConflictsStep } from '../../models/cherry-pick'
|
||||
|
||||
interface ICherryPickConflictsDialogProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly repository: Repository
|
||||
|
||||
readonly step: ShowConflictsStep
|
||||
|
||||
readonly userHasResolvedConflicts: boolean
|
||||
readonly workingDirectory: WorkingDirectoryStatus
|
||||
|
||||
// For display in manual resolution context menu
|
||||
readonly sourceBranchName: string | null
|
||||
|
||||
readonly onDismissed: () => void
|
||||
readonly onContinueCherryPick: (step: ShowConflictsStep) => void
|
||||
readonly onAbortCherryPick: (step: ShowConflictsStep) => void
|
||||
readonly showCherryPickConflictsBanner: (step: ShowConflictsStep) => void
|
||||
|
||||
readonly openFileInExternalEditor: (path: string) => void
|
||||
readonly resolvedExternalEditor: string | null
|
||||
readonly openRepositoryInShell: (repository: Repository) => void
|
||||
}
|
||||
|
||||
interface ICherryPickConflictsDialogState {
|
||||
readonly isAborting: boolean
|
||||
}
|
||||
|
||||
export class CherryPickConflictsDialog extends React.Component<
|
||||
ICherryPickConflictsDialogProps,
|
||||
ICherryPickConflictsDialogState
|
||||
> {
|
||||
public constructor(props: ICherryPickConflictsDialogProps) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
isAborting: false,
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
const {
|
||||
workingDirectory,
|
||||
step,
|
||||
userHasResolvedConflicts,
|
||||
dispatcher,
|
||||
repository,
|
||||
} = this.props
|
||||
|
||||
// skip this work once we know conflicts have been resolved
|
||||
if (userHasResolvedConflicts) {
|
||||
return
|
||||
}
|
||||
|
||||
const { conflictState } = step
|
||||
const { manualResolutions } = conflictState
|
||||
|
||||
const resolvedConflicts = getResolvedFiles(
|
||||
workingDirectory,
|
||||
manualResolutions
|
||||
)
|
||||
|
||||
if (resolvedConflicts.length > 0) {
|
||||
dispatcher.setCherryPickConflictsResolved(repository)
|
||||
}
|
||||
}
|
||||
|
||||
private onCancel = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
this.setState({ isAborting: true })
|
||||
|
||||
this.props.onAbortCherryPick(this.props.step)
|
||||
|
||||
this.setState({ isAborting: false })
|
||||
}
|
||||
|
||||
private onDismissed = () => {
|
||||
this.props.onDismissed()
|
||||
this.props.showCherryPickConflictsBanner(this.props.step)
|
||||
}
|
||||
|
||||
private onSubmit = async () => {
|
||||
this.props.onContinueCherryPick(this.props.step)
|
||||
}
|
||||
|
||||
private openThisRepositoryInShell = () =>
|
||||
this.props.openRepositoryInShell(this.props.repository)
|
||||
|
||||
private renderUnmergedFiles(
|
||||
files: ReadonlyArray<WorkingDirectoryFileChange>
|
||||
) {
|
||||
const {
|
||||
resolvedExternalEditor,
|
||||
openFileInExternalEditor,
|
||||
repository,
|
||||
dispatcher,
|
||||
step,
|
||||
sourceBranchName,
|
||||
} = this.props
|
||||
|
||||
const {
|
||||
manualResolutions,
|
||||
targetBranchName: theirBranch,
|
||||
} = step.conflictState
|
||||
|
||||
return (
|
||||
<ul className="unmerged-file-statuses">
|
||||
{files.map(f =>
|
||||
isConflictedFile(f.status)
|
||||
? renderUnmergedFile({
|
||||
path: f.path,
|
||||
status: f.status,
|
||||
resolvedExternalEditor,
|
||||
openFileInExternalEditor,
|
||||
repository,
|
||||
dispatcher,
|
||||
manualResolution: manualResolutions.get(f.path),
|
||||
theirBranch,
|
||||
ourBranch:
|
||||
sourceBranchName !== null ? sourceBranchName : undefined,
|
||||
})
|
||||
: null
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
private renderContent(
|
||||
unmergedFiles: ReadonlyArray<WorkingDirectoryFileChange>,
|
||||
conflictedFilesCount: number
|
||||
): JSX.Element {
|
||||
if (unmergedFiles.length === 0) {
|
||||
return renderAllResolved()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderUnmergedFilesSummary(conflictedFilesCount)}
|
||||
{this.renderUnmergedFiles(unmergedFiles)}
|
||||
{renderShellLink(this.openThisRepositoryInShell)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { workingDirectory, step } = this.props
|
||||
const { manualResolutions } = step.conflictState
|
||||
|
||||
const unmergedFiles = getUnmergedFiles(workingDirectory)
|
||||
const conflictedFilesCount = getConflictedFiles(
|
||||
workingDirectory,
|
||||
manualResolutions
|
||||
).length
|
||||
|
||||
const tooltipString =
|
||||
conflictedFilesCount > 0
|
||||
? 'Resolve all conflicts before continuing'
|
||||
: undefined
|
||||
|
||||
const ok = __DARWIN__ ? 'Continue Cherry Pick' : 'Continue cherry pick'
|
||||
const cancel = __DARWIN__ ? 'Abort Cherry Pick' : 'Abort cherry pick'
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id="cherry-pick-conflicts-list"
|
||||
onDismissed={this.onDismissed}
|
||||
title="Resolve conflicts before cherry picking"
|
||||
onSubmit={this.onSubmit}
|
||||
>
|
||||
<DialogContent>
|
||||
{this.renderContent(unmergedFiles, conflictedFilesCount)}
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<OkCancelButtonGroup
|
||||
okButtonText={ok}
|
||||
okButtonDisabled={conflictedFilesCount > 0}
|
||||
okButtonTitle={tooltipString}
|
||||
cancelButtonText={cancel}
|
||||
cancelButtonDisabled={this.state.isAborting}
|
||||
onCancelButtonClick={this.onCancel}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
233
app/src/ui/cherry-pick/cherry-pick-flow.tsx
Normal file
233
app/src/ui/cherry-pick/cherry-pick-flow.tsx
Normal file
|
@ -0,0 +1,233 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { assertNever } from '../../lib/fatal-error'
|
||||
import { Branch } from '../../models/branch'
|
||||
import {
|
||||
CherryPickFlowStep,
|
||||
CherryPickStepKind,
|
||||
ConfirmAbortStep,
|
||||
ShowConflictsStep,
|
||||
} from '../../models/cherry-pick'
|
||||
import { ICherryPickProgress } from '../../models/progress'
|
||||
|
||||
import { Repository } from '../../models/repository'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import { ChooseTargetBranchDialog } from './choose-target-branch'
|
||||
import { CherryPickProgressDialog } from './cherry-pick-progress-dialog'
|
||||
import { CommitOneLine } from '../../models/commit'
|
||||
import { CherryPickConflictsDialog } from './cherry-pick-conflicts-dialog'
|
||||
import { WorkingDirectoryStatus } from '../../models/status'
|
||||
import { getResolvedFiles } from '../../lib/status'
|
||||
import { ConfirmCherryPickAbortDialog } from './confirm-cherry-pick-abort-dialog'
|
||||
|
||||
interface ICherryPickFlowProps {
|
||||
readonly repository: Repository
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly step: CherryPickFlowStep
|
||||
readonly commits: ReadonlyArray<CommitOneLine>
|
||||
readonly progress: ICherryPickProgress | null
|
||||
readonly emoji: Map<string, string>
|
||||
|
||||
/**
|
||||
* The branch the commits come from - needed so abort can switch back to it
|
||||
*
|
||||
* This can be null because if a cherry pick is started outside of Desktop
|
||||
* it is difficult to obtain the sourceBranch.
|
||||
*/
|
||||
readonly sourceBranch: Branch | null
|
||||
|
||||
/** Properties required for conflict flow step. */
|
||||
readonly workingDirectory: WorkingDirectoryStatus
|
||||
readonly userHasResolvedConflicts: boolean
|
||||
|
||||
/**
|
||||
* Callbacks for the conflict selection components to let the user jump out
|
||||
* to their preferred editor.
|
||||
*/
|
||||
readonly openFileInExternalEditor: (path: string) => void
|
||||
readonly resolvedExternalEditor: string | null
|
||||
readonly openRepositoryInShell: (repository: Repository) => void
|
||||
|
||||
readonly onDismissed: () => void
|
||||
|
||||
/**
|
||||
* Callback to hide the cherry pick flow and show a banner about the current
|
||||
* state of conflicts.
|
||||
*/
|
||||
readonly onShowCherryPickConflictsBanner: (
|
||||
repository: Repository,
|
||||
targetBranchName: string,
|
||||
sourceBranch: Branch | null,
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
) => void
|
||||
}
|
||||
|
||||
/** A component for initiating and performing a cherry pick. */
|
||||
export class CherryPickFlow extends React.Component<ICherryPickFlowProps> {
|
||||
private onFlowEnded = () => {
|
||||
this.props.onDismissed()
|
||||
}
|
||||
|
||||
private onCherryPick = (targetBranch: Branch) => {
|
||||
const { dispatcher, repository, commits, sourceBranch } = this.props
|
||||
dispatcher.cherryPick(repository, targetBranch, commits, sourceBranch)
|
||||
}
|
||||
|
||||
private onContinueCherryPick = (step: ShowConflictsStep) => {
|
||||
const {
|
||||
dispatcher,
|
||||
repository,
|
||||
workingDirectory,
|
||||
commits,
|
||||
sourceBranch,
|
||||
} = this.props
|
||||
dispatcher.continueCherryPick(
|
||||
repository,
|
||||
workingDirectory.files,
|
||||
step.conflictState,
|
||||
commits.length,
|
||||
sourceBranch
|
||||
)
|
||||
}
|
||||
|
||||
private onAbortCherryPick = (step: ShowConflictsStep) => {
|
||||
const {
|
||||
dispatcher,
|
||||
repository,
|
||||
workingDirectory,
|
||||
userHasResolvedConflicts,
|
||||
} = this.props
|
||||
const { conflictState } = step
|
||||
const { manualResolutions } = conflictState
|
||||
const { length: countResolvedConflicts } = getResolvedFiles(
|
||||
workingDirectory,
|
||||
manualResolutions
|
||||
)
|
||||
|
||||
if (userHasResolvedConflicts || countResolvedConflicts > 0) {
|
||||
dispatcher.setCherryPickFlowStep(repository, {
|
||||
kind: CherryPickStepKind.ConfirmAbort,
|
||||
conflictState,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.abortCherryPick()
|
||||
}
|
||||
|
||||
private abortCherryPick = async () => {
|
||||
const { dispatcher, repository, sourceBranch } = this.props
|
||||
|
||||
await dispatcher.abortCherryPick(repository, sourceBranch)
|
||||
dispatcher.closePopup()
|
||||
}
|
||||
|
||||
private showCherryPickConflictsBanner = (step: ShowConflictsStep) => {
|
||||
const { repository, sourceBranch, commits } = this.props
|
||||
this.props.onShowCherryPickConflictsBanner(
|
||||
repository,
|
||||
step.conflictState.targetBranchName,
|
||||
sourceBranch,
|
||||
commits
|
||||
)
|
||||
}
|
||||
|
||||
private moveToShowConflictedFileState = (step: ConfirmAbortStep) => {
|
||||
const { conflictState } = step
|
||||
const { dispatcher, repository } = this.props
|
||||
dispatcher.setCherryPickFlowStep(repository, {
|
||||
kind: CherryPickStepKind.ShowConflicts,
|
||||
conflictState,
|
||||
})
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { step } = this.props
|
||||
|
||||
switch (step.kind) {
|
||||
case CherryPickStepKind.ChooseTargetBranch: {
|
||||
const {
|
||||
allBranches,
|
||||
defaultBranch,
|
||||
currentBranch,
|
||||
recentBranches,
|
||||
} = step
|
||||
return (
|
||||
<ChooseTargetBranchDialog
|
||||
key="choose-target-branch"
|
||||
allBranches={allBranches}
|
||||
defaultBranch={defaultBranch}
|
||||
recentBranches={recentBranches}
|
||||
currentBranch={currentBranch}
|
||||
onCherryPick={this.onCherryPick}
|
||||
onDismissed={this.onFlowEnded}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case CherryPickStepKind.ShowProgress:
|
||||
if (this.props.progress === null) {
|
||||
log.error(
|
||||
`[CherryPickFlow] cherry pick progress should not be null
|
||||
when showing progress. Skipping rendering..`
|
||||
)
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<CherryPickProgressDialog
|
||||
progress={this.props.progress}
|
||||
emoji={this.props.emoji}
|
||||
/>
|
||||
)
|
||||
case CherryPickStepKind.ShowConflicts:
|
||||
const {
|
||||
repository,
|
||||
resolvedExternalEditor,
|
||||
openFileInExternalEditor,
|
||||
openRepositoryInShell,
|
||||
dispatcher,
|
||||
workingDirectory,
|
||||
userHasResolvedConflicts,
|
||||
sourceBranch,
|
||||
} = this.props
|
||||
|
||||
return (
|
||||
<CherryPickConflictsDialog
|
||||
dispatcher={dispatcher}
|
||||
repository={repository}
|
||||
step={step}
|
||||
userHasResolvedConflicts={userHasResolvedConflicts}
|
||||
workingDirectory={workingDirectory}
|
||||
onDismissed={this.onFlowEnded}
|
||||
onContinueCherryPick={this.onContinueCherryPick}
|
||||
onAbortCherryPick={this.onAbortCherryPick}
|
||||
showCherryPickConflictsBanner={this.showCherryPickConflictsBanner}
|
||||
openFileInExternalEditor={openFileInExternalEditor}
|
||||
resolvedExternalEditor={resolvedExternalEditor}
|
||||
openRepositoryInShell={openRepositoryInShell}
|
||||
sourceBranchName={sourceBranch !== null ? sourceBranch.name : null}
|
||||
/>
|
||||
)
|
||||
case CherryPickStepKind.ConfirmAbort:
|
||||
const {
|
||||
commits: { length: commitCount },
|
||||
} = this.props
|
||||
const sourceBranchName =
|
||||
this.props.sourceBranch !== null ? this.props.sourceBranch.name : null
|
||||
return (
|
||||
<ConfirmCherryPickAbortDialog
|
||||
step={step}
|
||||
commitCount={commitCount}
|
||||
sourceBranchName={sourceBranchName}
|
||||
onReturnToConflicts={this.moveToShowConflictedFileState}
|
||||
onConfirmAbort={this.abortCherryPick}
|
||||
/>
|
||||
)
|
||||
case CherryPickStepKind.CommitsChosen:
|
||||
case CherryPickStepKind.HideConflicts:
|
||||
// no ui for this part of flow
|
||||
return null
|
||||
default:
|
||||
return assertNever(step, 'Unknown cherry pick step found')
|
||||
}
|
||||
}
|
||||
}
|
60
app/src/ui/cherry-pick/cherry-pick-progress-dialog.tsx
Normal file
60
app/src/ui/cherry-pick/cherry-pick-progress-dialog.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import * as React from 'react'
|
||||
import { RichText } from '../lib/rich-text'
|
||||
import { Dialog, DialogContent } from '../dialog'
|
||||
import { Octicon, OcticonSymbol } from '../octicons'
|
||||
import { ICherryPickProgress } from '../../models/progress'
|
||||
|
||||
interface ICherryPickProgressDialogProps {
|
||||
/** Progress information about the current cherry pick */
|
||||
readonly progress: ICherryPickProgress
|
||||
|
||||
readonly emoji: Map<string, string>
|
||||
}
|
||||
|
||||
export class CherryPickProgressDialog extends React.Component<
|
||||
ICherryPickProgressDialogProps
|
||||
> {
|
||||
// even tho dialog is not dismissable, it requires an onDismissed method
|
||||
private onDismissed = () => {}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
cherryPickCommitCount,
|
||||
totalCommitCount,
|
||||
value,
|
||||
currentCommitSummary,
|
||||
} = this.props.progress
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
dismissable={false}
|
||||
onDismissed={this.onDismissed}
|
||||
id="cherry-pick-progress"
|
||||
title="Cherry pick in progress"
|
||||
>
|
||||
<DialogContent>
|
||||
<div>
|
||||
<progress value={value} />
|
||||
|
||||
<div className="details">
|
||||
<div className="green-circle">
|
||||
<Octicon symbol={OcticonSymbol.check} />
|
||||
</div>
|
||||
<div className="summary">
|
||||
<div className="message">
|
||||
Commit {cherryPickCommitCount} of {totalCommitCount}
|
||||
</div>
|
||||
<div className="detail">
|
||||
<RichText
|
||||
emoji={this.props.emoji}
|
||||
text={currentCommitSummary || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
173
app/src/ui/cherry-pick/choose-target-branch.tsx
Normal file
173
app/src/ui/cherry-pick/choose-target-branch.tsx
Normal file
|
@ -0,0 +1,173 @@
|
|||
import * as React from 'react'
|
||||
import { Branch } from '../../models/branch'
|
||||
import { IMatches } from '../../lib/fuzzy-find'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
OkCancelButtonGroup,
|
||||
} from '../dialog'
|
||||
import { BranchList, IBranchListItem, renderDefaultBranch } from '../branches'
|
||||
import { ClickSource } from '../lib/list'
|
||||
|
||||
interface IChooseTargetBranchDialogProps {
|
||||
/**
|
||||
* See IBranchesState.defaultBranch
|
||||
*/
|
||||
readonly defaultBranch: Branch | null
|
||||
|
||||
/**
|
||||
* The currently checked out branch
|
||||
*/
|
||||
readonly currentBranch: Branch
|
||||
|
||||
/**
|
||||
* See IBranchesState.allBranches
|
||||
*/
|
||||
readonly allBranches: ReadonlyArray<Branch>
|
||||
|
||||
/**
|
||||
* See IBranchesState.recentBranches
|
||||
*/
|
||||
readonly recentBranches: ReadonlyArray<Branch>
|
||||
|
||||
/**
|
||||
* A function that's called when the user selects a branch and hits start
|
||||
* cherry pick
|
||||
*/
|
||||
readonly onCherryPick: (targetBranch: Branch) => void
|
||||
|
||||
/**
|
||||
* A function that's called when the dialog is dismissed by the user in the
|
||||
* ways described in the Dialog component's dismissable prop.
|
||||
*/
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
interface IChooseTargetBranchDialogState {
|
||||
/** The currently selected branch. */
|
||||
readonly selectedBranch: Branch | null
|
||||
|
||||
/** The filter text to use in the branch selector */
|
||||
readonly filterText: string
|
||||
}
|
||||
|
||||
/** A component for initiating a rebase of the current branch. */
|
||||
export class ChooseTargetBranchDialog extends React.Component<
|
||||
IChooseTargetBranchDialogProps,
|
||||
IChooseTargetBranchDialogState
|
||||
> {
|
||||
public constructor(props: IChooseTargetBranchDialogProps) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
selectedBranch: null,
|
||||
filterText: '',
|
||||
}
|
||||
}
|
||||
|
||||
private onFilterTextChanged = (filterText: string) => {
|
||||
this.setState({ filterText })
|
||||
}
|
||||
|
||||
private onSelectionChanged = (selectedBranch: Branch | null) => {
|
||||
this.setState({ selectedBranch })
|
||||
}
|
||||
|
||||
private renderBranch = (item: IBranchListItem, matches: IMatches) => {
|
||||
return renderDefaultBranch(item, matches, this.props.currentBranch)
|
||||
}
|
||||
|
||||
private onEnterPressed = (branch: Branch, source: ClickSource) => {
|
||||
if (source.kind !== 'keyboard' || source.event.key !== 'Enter') {
|
||||
return
|
||||
}
|
||||
|
||||
source.event.preventDefault()
|
||||
|
||||
const { selectedBranch } = this.state
|
||||
if (selectedBranch !== null && selectedBranch.name === branch.name) {
|
||||
this.startCherryPick()
|
||||
}
|
||||
}
|
||||
|
||||
private canCherryPickOntoSelectedBranch() {
|
||||
const { selectedBranch } = this.state
|
||||
return selectedBranch !== null && !this.selectedBranchIsCurrentBranch()
|
||||
}
|
||||
|
||||
private selectedBranchIsCurrentBranch() {
|
||||
const { selectedBranch } = this.state
|
||||
const currentBranch = this.props.currentBranch
|
||||
return (
|
||||
selectedBranch !== null &&
|
||||
currentBranch !== null &&
|
||||
selectedBranch.name === currentBranch.name
|
||||
)
|
||||
}
|
||||
|
||||
private renderOkButtonText() {
|
||||
const okButtonText = 'Cherry pick commit'
|
||||
|
||||
const { selectedBranch } = this.state
|
||||
if (selectedBranch !== null) {
|
||||
return (
|
||||
<>
|
||||
{okButtonText} to <strong>{selectedBranch.name}</strong>…
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return okButtonText
|
||||
}
|
||||
|
||||
public render() {
|
||||
const tooltip = this.selectedBranchIsCurrentBranch()
|
||||
? 'You are not able to cherry pick from and to the same branch'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id="cherry-pick"
|
||||
onDismissed={this.props.onDismissed}
|
||||
onSubmit={this.startCherryPick}
|
||||
dismissable={true}
|
||||
title={<strong>Cherry pick commit to a branch</strong>}
|
||||
>
|
||||
<DialogContent>
|
||||
<BranchList
|
||||
allBranches={this.props.allBranches}
|
||||
currentBranch={this.props.currentBranch}
|
||||
defaultBranch={this.props.defaultBranch}
|
||||
recentBranches={this.props.recentBranches}
|
||||
filterText={this.state.filterText}
|
||||
onFilterTextChanged={this.onFilterTextChanged}
|
||||
selectedBranch={this.state.selectedBranch}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
canCreateNewBranch={false}
|
||||
renderBranch={this.renderBranch}
|
||||
onItemClick={this.onEnterPressed}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<OkCancelButtonGroup
|
||||
okButtonText={this.renderOkButtonText()}
|
||||
okButtonDisabled={!this.canCherryPickOntoSelectedBranch()}
|
||||
okButtonTitle={tooltip}
|
||||
cancelButtonVisible={false}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private startCherryPick = async () => {
|
||||
const { selectedBranch } = this.state
|
||||
|
||||
if (selectedBranch === null || !this.canCherryPickOntoSelectedBranch()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onCherryPick(selectedBranch)
|
||||
}
|
||||
}
|
107
app/src/ui/cherry-pick/confirm-cherry-pick-abort-dialog.tsx
Normal file
107
app/src/ui/cherry-pick/confirm-cherry-pick-abort-dialog.tsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
import { Ref } from '../lib/ref'
|
||||
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
|
||||
import { ConfirmAbortStep } from '../../models/cherry-pick'
|
||||
|
||||
interface IConfirmCherryPickAbortDialogProps {
|
||||
readonly step: ConfirmAbortStep
|
||||
readonly commitCount: number
|
||||
readonly sourceBranchName: string | null
|
||||
|
||||
readonly onReturnToConflicts: (step: ConfirmAbortStep) => void
|
||||
readonly onConfirmAbort: () => Promise<void>
|
||||
}
|
||||
|
||||
interface IConfirmCherryPickAbortDialogState {
|
||||
readonly isAborting: boolean
|
||||
}
|
||||
|
||||
export class ConfirmCherryPickAbortDialog extends React.Component<
|
||||
IConfirmCherryPickAbortDialogProps,
|
||||
IConfirmCherryPickAbortDialogState
|
||||
> {
|
||||
public constructor(props: IConfirmCherryPickAbortDialogProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
isAborting: false,
|
||||
}
|
||||
}
|
||||
|
||||
private onSubmit = async () => {
|
||||
this.setState({
|
||||
isAborting: true,
|
||||
})
|
||||
|
||||
await this.props.onConfirmAbort()
|
||||
|
||||
this.setState({
|
||||
isAborting: false,
|
||||
})
|
||||
}
|
||||
|
||||
private onCancel = async () => {
|
||||
await this.props.onReturnToConflicts(this.props.step)
|
||||
}
|
||||
|
||||
private renderTextContent() {
|
||||
const { commitCount, step, sourceBranchName } = this.props
|
||||
const { targetBranchName } = step.conflictState
|
||||
|
||||
const pluralize = commitCount > 1 ? 'commits' : 'commit'
|
||||
const confirm = (
|
||||
<p>
|
||||
{`Are you sure you want to abort cherry picking ${commitCount} ${pluralize}`}
|
||||
{' onto '}
|
||||
<Ref>{targetBranchName}</Ref>?
|
||||
</p>
|
||||
)
|
||||
|
||||
let returnTo = null
|
||||
if (sourceBranchName !== null) {
|
||||
returnTo = (
|
||||
<>
|
||||
{' and you will be taken back to '}
|
||||
<Ref>{sourceBranchName}</Ref>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="column-left">
|
||||
{confirm}
|
||||
<p>
|
||||
{'The conflicts you have already resolved will be discarded'}
|
||||
{returnTo}
|
||||
{'.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<Dialog
|
||||
id="abort-merge-warning"
|
||||
title={
|
||||
__DARWIN__ ? 'Confirm Abort Cherry Pick' : 'Confirm abort cherry pick'
|
||||
}
|
||||
onDismissed={this.onCancel}
|
||||
onSubmit={this.onSubmit}
|
||||
disabled={this.state.isAborting}
|
||||
type="warning"
|
||||
>
|
||||
<DialogContent>{this.renderTextContent()}</DialogContent>
|
||||
<DialogFooter>
|
||||
<OkCancelButtonGroup
|
||||
destructive={true}
|
||||
okButtonText={
|
||||
__DARWIN__ ? 'Abort Cherry Pick' : 'Abort cherry pick'
|
||||
}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -13,8 +13,10 @@ import {
|
|||
FoldoutType,
|
||||
ICompareFormUpdate,
|
||||
RepositorySectionTab,
|
||||
isMergeConflictState,
|
||||
RebaseConflictState,
|
||||
isRebaseConflictState,
|
||||
isCherryPickConflictState,
|
||||
CherryPickConflictState,
|
||||
} from '../../lib/app-state'
|
||||
import { assertNever, fatalError } from '../../lib/fatal-error'
|
||||
import {
|
||||
|
@ -97,6 +99,12 @@ import { IStashEntry } from '../../models/stash-entry'
|
|||
import { WorkflowPreferences } from '../../models/workflow-preferences'
|
||||
import { enableForkSettings } from '../../lib/feature-flag'
|
||||
import { resolveWithin } from '../../lib/path'
|
||||
import {
|
||||
CherryPickFlowStep,
|
||||
CherryPickStepKind,
|
||||
} from '../../models/cherry-pick'
|
||||
import { CherryPickResult } from '../../lib/git/cherry-pick'
|
||||
import { sleep } from '../../lib/promise'
|
||||
|
||||
/**
|
||||
* An error handler function.
|
||||
|
@ -212,9 +220,9 @@ export class Dispatcher {
|
|||
*/
|
||||
public changeCommitSelection(
|
||||
repository: Repository,
|
||||
sha: string
|
||||
shas: ReadonlyArray<string>
|
||||
): Promise<void> {
|
||||
return this.appStore._changeCommitSelection(repository, sha)
|
||||
return this.appStore._changeCommitSelection(repository, shas)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -450,7 +458,7 @@ export class Dispatcher {
|
|||
const repositoryState = this.repositoryStateManager.get(repository)
|
||||
const { conflictState } = repositoryState.changesState
|
||||
|
||||
if (conflictState === null || conflictState.kind === 'merge') {
|
||||
if (conflictState === null || !isRebaseConflictState(conflictState)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1020,9 +1028,9 @@ export class Dispatcher {
|
|||
return
|
||||
}
|
||||
|
||||
if (isMergeConflictState(conflictState)) {
|
||||
if (!isRebaseConflictState(conflictState)) {
|
||||
log.warn(
|
||||
`[rebase] conflict state after rebase is merge conflicts - unable to continue`
|
||||
`[rebase] conflict state after rebase is not rebase conflicts - unable to continue`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
@ -1113,9 +1121,9 @@ export class Dispatcher {
|
|||
return
|
||||
}
|
||||
|
||||
if (isMergeConflictState(conflictState)) {
|
||||
if (!isRebaseConflictState(conflictState)) {
|
||||
log.warn(
|
||||
`[continueRebase] conflict state after rebase is merge conflicts - unable to continue`
|
||||
`[continueRebase] conflict state after rebase is not rebase conflicts - unable to continue`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
@ -1903,6 +1911,13 @@ export class Dispatcher {
|
|||
retryAction.baseBranch,
|
||||
retryAction.targetBranch
|
||||
)
|
||||
case RetryActionType.CherryPick:
|
||||
return this.cherryPick(
|
||||
retryAction.repository,
|
||||
retryAction.targetBranch,
|
||||
retryAction.commits,
|
||||
retryAction.sourceBranch
|
||||
)
|
||||
|
||||
default:
|
||||
return assertNever(retryAction, `Unknown retry action: ${retryAction}`)
|
||||
|
@ -2505,16 +2520,299 @@ export class Dispatcher {
|
|||
}
|
||||
|
||||
/**
|
||||
* Show the cherry pick branch selection dialog
|
||||
* Move the cherry pick flow to a new state.
|
||||
*/
|
||||
public showCherryPickBranchDialog(
|
||||
public setCherryPickFlowStep(
|
||||
repository: Repository,
|
||||
commitSha: string
|
||||
step: CherryPickFlowStep
|
||||
): Promise<void> {
|
||||
return this.showPopup({
|
||||
type: PopupType.CherryPick,
|
||||
return this.appStore._setCherryPickFlowStep(repository, step)
|
||||
}
|
||||
|
||||
/** Initialize and start the cherry pick operation */
|
||||
public async initializeCherryPickFlow(
|
||||
repository: Repository,
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
): Promise<void> {
|
||||
this.appStore._initializeCherryPickProgress(repository, commits)
|
||||
this.switchCherryPickingFlowToShowProgress(repository)
|
||||
}
|
||||
|
||||
private logHowToRevertCherryPick(
|
||||
repository: Repository,
|
||||
targetBranch: Branch
|
||||
) {
|
||||
const beforeSha = targetBranch.tip.sha
|
||||
this.appStore._setCherryPickTargetBranchUndoSha(repository, beforeSha)
|
||||
log.info(
|
||||
`[cherryPick] starting cherry pick for ${targetBranch.name} at ${beforeSha}`
|
||||
)
|
||||
log.info(
|
||||
`[cherryPick] to restore the previous state if this completed cherry pick is unsatisfactory:`
|
||||
)
|
||||
log.info(`[cherryPick] - git checkout ${targetBranch.name}`)
|
||||
log.info(`[cherryPick] - git reset ${beforeSha} --hard`)
|
||||
}
|
||||
|
||||
/** Starts a cherry pick of the given commits onto the target branch */
|
||||
public async cherryPick(
|
||||
repository: Repository,
|
||||
targetBranch: Branch,
|
||||
commits: ReadonlyArray<CommitOneLine>,
|
||||
sourceBranch: Branch | null
|
||||
): Promise<void> {
|
||||
this.initializeCherryPickFlow(repository, commits)
|
||||
this.dismissCherryPickIntro()
|
||||
this.logHowToRevertCherryPick(repository, targetBranch)
|
||||
|
||||
const result = await this.appStore._cherryPick(
|
||||
repository,
|
||||
commitSha,
|
||||
targetBranch,
|
||||
commits,
|
||||
sourceBranch
|
||||
)
|
||||
|
||||
this.processCherryPickResult(
|
||||
repository,
|
||||
result,
|
||||
targetBranch.name,
|
||||
commits.length,
|
||||
sourceBranch
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains the current app conflict state and switches cherry pick flow to
|
||||
* show conflicts step
|
||||
*/
|
||||
private startConflictCherryPickFlow(repository: Repository): void {
|
||||
const stateAfter = this.repositoryStateManager.get(repository)
|
||||
const { conflictState } = stateAfter.changesState
|
||||
if (conflictState === null || !isCherryPickConflictState(conflictState)) {
|
||||
log.warn(
|
||||
'[cherryPick] - conflict state was null or not in a cherry pick conflict state - unable to continue'
|
||||
)
|
||||
return
|
||||
}
|
||||
this.setCherryPickFlowStep(repository, {
|
||||
kind: CherryPickStepKind.ShowConflicts,
|
||||
conflictState,
|
||||
})
|
||||
}
|
||||
|
||||
/** Tidy up the cherry pick flow after reaching the end */
|
||||
/** Wrap cherry pick up actions:
|
||||
* - closes flow popup
|
||||
* - displays success banner
|
||||
* - clears out cherry pick flow state
|
||||
*/
|
||||
private async completeCherryPick(
|
||||
repository: Repository,
|
||||
targetBranchName: string,
|
||||
countCherryPicked: number,
|
||||
sourceBranch: Branch | null,
|
||||
commitsCount: number
|
||||
): Promise<void> {
|
||||
this.closePopup()
|
||||
|
||||
const banner: Banner = {
|
||||
type: BannerType.SuccessfulCherryPick,
|
||||
targetBranchName,
|
||||
countCherryPicked,
|
||||
onUndoCherryPick: () => {
|
||||
this.undoCherryPick(
|
||||
repository,
|
||||
targetBranchName,
|
||||
sourceBranch,
|
||||
commitsCount
|
||||
)
|
||||
},
|
||||
}
|
||||
this.setBanner(banner)
|
||||
|
||||
this.appStore._endCherryPickFlow(repository)
|
||||
|
||||
await this.refreshRepository(repository)
|
||||
}
|
||||
|
||||
/** Aborts an ongoing cherry pick and switches back to the source branch. */
|
||||
public async abortCherryPick(
|
||||
repository: Repository,
|
||||
sourceBranch: Branch | null
|
||||
) {
|
||||
await this.appStore._abortCherryPick(repository, sourceBranch)
|
||||
await this.appStore._loadStatus(repository)
|
||||
this.appStore._endCherryPickFlow(repository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cherry pick state to indicate the user has resolved conflicts in
|
||||
* the current repository.
|
||||
*/
|
||||
public setCherryPickConflictsResolved(repository: Repository) {
|
||||
return this.appStore._setCherryPickConflictsResolved(repository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves cherry pick flow step to progress and defers to allow user to
|
||||
* see the cherry picking progress dialog instead of suddenly appearing
|
||||
* and disappearing again.
|
||||
*/
|
||||
private async switchCherryPickingFlowToShowProgress(repository: Repository) {
|
||||
this.setCherryPickFlowStep(repository, {
|
||||
kind: CherryPickStepKind.ShowProgress,
|
||||
})
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue with the cherryPick after the user has resolved all conflicts with
|
||||
* tracked files in the working directory.
|
||||
*/
|
||||
public async continueCherryPick(
|
||||
repository: Repository,
|
||||
files: ReadonlyArray<WorkingDirectoryFileChange>,
|
||||
conflictsState: CherryPickConflictState,
|
||||
commitsCount: number,
|
||||
sourceBranch: Branch | null
|
||||
): Promise<void> {
|
||||
await this.switchCherryPickingFlowToShowProgress(repository)
|
||||
|
||||
const result = await this.appStore._continueCherryPick(
|
||||
repository,
|
||||
files,
|
||||
conflictsState.manualResolutions
|
||||
)
|
||||
|
||||
this.processCherryPickResult(
|
||||
repository,
|
||||
result,
|
||||
conflictsState.targetBranchName,
|
||||
commitsCount,
|
||||
sourceBranch
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the cherry pick result.
|
||||
* 1. Completes the cherry pick with banner if successful.
|
||||
* 2. Moves cherry pick flow if conflicts.
|
||||
* 3. Handles errors.
|
||||
*/
|
||||
private async processCherryPickResult(
|
||||
repository: Repository,
|
||||
cherryPickResult: CherryPickResult,
|
||||
targetBranchName: string,
|
||||
commitsCount: number,
|
||||
sourceBranch: Branch | null
|
||||
): Promise<void> {
|
||||
// This will update the conflict state of the app. This is needed to start
|
||||
// conflict flow if cherry pick results in conflict.
|
||||
await this.appStore._loadStatus(repository)
|
||||
|
||||
switch (cherryPickResult) {
|
||||
case CherryPickResult.CompletedWithoutError:
|
||||
await this.completeCherryPick(
|
||||
repository,
|
||||
targetBranchName,
|
||||
commitsCount,
|
||||
sourceBranch,
|
||||
commitsCount
|
||||
)
|
||||
break
|
||||
case CherryPickResult.ConflictsEncountered:
|
||||
this.startConflictCherryPickFlow(repository)
|
||||
break
|
||||
case CherryPickResult.UnableToStart:
|
||||
// This is an expected error such as not being able to checkout the
|
||||
// target branch which means the cherry pick operation never started or
|
||||
// was cleanly aborted.
|
||||
this.appStore._endCherryPickFlow(repository)
|
||||
break
|
||||
default:
|
||||
// If the user closes error dialog and tries to cherry pick again, it
|
||||
// will fail again due to ongoing cherry pick. Thus, if we get to an
|
||||
// unhandled error state, we want to abort any ongoing cherry pick.
|
||||
// A known error is if a user attempts to cherry pick a merge commit.
|
||||
this.appStore._clearCherryPickingHead(repository, sourceBranch)
|
||||
this.appStore._endCherryPickFlow(repository)
|
||||
this.appStore._closePopup()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cherry pick progress in application state by querying the Git
|
||||
* repository state.
|
||||
*/
|
||||
public setCherryPickProgressFromState(repository: Repository) {
|
||||
return this.appStore._setCherryPickProgressFromState(repository)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method starts a cherry pick after drag and dropping on a branch.
|
||||
* It needs to:
|
||||
* - get the current branch,
|
||||
* - get the commits dragged from cherry picking state
|
||||
* - invoke popup
|
||||
* - invoke cherry pick
|
||||
*/
|
||||
public async startCherryPickWithBranch(
|
||||
repository: Repository,
|
||||
targetBranch: Branch
|
||||
): Promise<void> {
|
||||
const { branchesState, cherryPickState } = this.repositoryStateManager.get(
|
||||
repository
|
||||
)
|
||||
|
||||
if (
|
||||
cherryPickState.step == null ||
|
||||
cherryPickState.step.kind !== CherryPickStepKind.CommitsChosen
|
||||
) {
|
||||
log.warn(
|
||||
'[cherryPick] Invalid Cherry Picking State: Could not determine selected commits.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const { tip } = branchesState
|
||||
if (tip.kind !== TipState.Valid) {
|
||||
throw new Error(
|
||||
'Tip is not in a valid state, which is required to start the cherry pick flow.'
|
||||
)
|
||||
}
|
||||
const sourceBranch = tip.branch
|
||||
const commits = cherryPickState.step.commits
|
||||
|
||||
this.showPopup({
|
||||
type: PopupType.CherryPick,
|
||||
repository,
|
||||
commits,
|
||||
sourceBranch,
|
||||
})
|
||||
|
||||
this.cherryPick(repository, targetBranch, commits, sourceBranch)
|
||||
}
|
||||
|
||||
/** Method to dismiss cherry pick intro */
|
||||
public dismissCherryPickIntro(): void {
|
||||
this.appStore._dismissCherryPickIntro()
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will perform a hard reset back to the tip of the target branch
|
||||
* before the cherry pick happened.
|
||||
*/
|
||||
private async undoCherryPick(
|
||||
repository: Repository,
|
||||
targetBranchName: string,
|
||||
sourceBranch: Branch | null,
|
||||
commitsCount: number
|
||||
): Promise<void> {
|
||||
await this.appStore._undoCherryPick(
|
||||
repository,
|
||||
targetBranchName,
|
||||
sourceBranch,
|
||||
commitsCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from 'react'
|
||||
import { Commit } from '../../models/commit'
|
||||
import { Commit, CommitOneLine } from '../../models/commit'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { IAvatarUser, getAvatarUsersForCommit } from '../../models/avatar'
|
||||
import { RichText } from '../lib/rich-text'
|
||||
|
@ -20,13 +20,16 @@ import {
|
|||
interface ICommitProps {
|
||||
readonly gitHubRepository: GitHubRepository | null
|
||||
readonly commit: Commit
|
||||
readonly selectedCommits: ReadonlyArray<Commit>
|
||||
readonly emoji: Map<string, string>
|
||||
readonly isLocal: boolean
|
||||
readonly onRevertCommit?: (commit: Commit) => void
|
||||
readonly onViewCommitOnGitHub?: (sha: string) => void
|
||||
readonly onCreateTag?: (targetCommitSha: string) => void
|
||||
readonly onDeleteTag?: (tagName: string) => void
|
||||
readonly onCherryPick?: (commitSha: string) => void
|
||||
readonly onCherryPick?: (commits: ReadonlyArray<CommitOneLine>) => void
|
||||
readonly onDragStart?: (commits: ReadonlyArray<CommitOneLine>) => void
|
||||
readonly onDragEnd?: () => void
|
||||
readonly showUnpushedIndicator: boolean
|
||||
readonly unpushedIndicatorTitle?: string
|
||||
readonly unpushedTags?: ReadonlyArray<string>
|
||||
|
@ -69,8 +72,17 @@ export class CommitListItem extends React.PureComponent<
|
|||
author: { date },
|
||||
} = commit
|
||||
|
||||
const isDraggable =
|
||||
this.props.onDragStart !== undefined && enableCherryPicking()
|
||||
|
||||
return (
|
||||
<div className="commit" onContextMenu={this.onContextMenu}>
|
||||
<div
|
||||
className="commit"
|
||||
onContextMenu={this.onContextMenu}
|
||||
draggable={isDraggable}
|
||||
onDragStart={this.onDragStart}
|
||||
onDragEnd={this.onDragEnd}
|
||||
>
|
||||
<div className="info">
|
||||
<RichText
|
||||
className="summary"
|
||||
|
@ -146,13 +158,24 @@ export class CommitListItem extends React.PureComponent<
|
|||
|
||||
private onCherryPick = () => {
|
||||
if (this.props.onCherryPick !== undefined) {
|
||||
this.props.onCherryPick(this.props.commit.sha)
|
||||
this.props.onCherryPick(this.props.selectedCommits)
|
||||
}
|
||||
}
|
||||
|
||||
private onContextMenu = (event: React.MouseEvent<any>) => {
|
||||
event.preventDefault()
|
||||
|
||||
let items: IMenuItem[] = []
|
||||
if (this.props.selectedCommits.length > 1) {
|
||||
items = this.getContextMenuMultipleCommits()
|
||||
} else {
|
||||
items = this.getContextMenuForSingleCommit()
|
||||
}
|
||||
|
||||
showContextualMenu(items)
|
||||
}
|
||||
|
||||
private getContextMenuForSingleCommit(): IMenuItem[] {
|
||||
let viewOnGitHubLabel = 'View on GitHub'
|
||||
const gitHubRepository = this.props.gitHubRepository
|
||||
|
||||
|
@ -216,7 +239,23 @@ export class CommitListItem extends React.PureComponent<
|
|||
}
|
||||
)
|
||||
|
||||
showContextualMenu(items)
|
||||
return items
|
||||
}
|
||||
|
||||
private getContextMenuMultipleCommits(): IMenuItem[] {
|
||||
const items: IMenuItem[] = []
|
||||
|
||||
const count = this.props.selectedCommits.length
|
||||
if (enableCherryPicking()) {
|
||||
items.push({
|
||||
label: __DARWIN__
|
||||
? `Cherry Pick ${count} Commits…`
|
||||
: `Cherry pick ${count} commits…`,
|
||||
action: this.onCherryPick,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private getDeleteTagsMenuItem(): IMenuItem | null {
|
||||
|
@ -254,6 +293,24 @@ export class CommitListItem extends React.PureComponent<
|
|||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: For typing, event is required parameter.
|
||||
**/
|
||||
private onDragStart = (event: React.DragEvent<HTMLDivElement>): void => {
|
||||
if (this.props.onDragStart !== undefined) {
|
||||
this.props.onDragStart(this.props.selectedCommits)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: For typing, event is required parameter.
|
||||
**/
|
||||
private onDragEnd = (event: React.DragEvent<HTMLDivElement>): void => {
|
||||
if (this.props.onDragEnd !== undefined) {
|
||||
this.props.onDragEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderRelativeTime(date: Date) {
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import * as React from 'react'
|
||||
import memoize from 'memoize-one'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { Commit } from '../../models/commit'
|
||||
import { Commit, CommitOneLine } from '../../models/commit'
|
||||
import { CommitListItem } from './commit-list-item'
|
||||
import { List } from '../lib/list'
|
||||
import { List, SelectionSource } from '../lib/list'
|
||||
import { arrayEquals } from '../../lib/equality'
|
||||
import { Popover, PopoverCaretPosition } from '../lib/popover'
|
||||
import { Button } from '../lib/button'
|
||||
import { enableCherryPicking } from '../../lib/feature-flag'
|
||||
import { encodePathAsUrl } from '../../lib/path'
|
||||
|
||||
const RowHeight = 50
|
||||
|
||||
|
@ -18,8 +22,8 @@ interface ICommitListProps {
|
|||
/** The commits loaded, keyed by their full SHA. */
|
||||
readonly commitLookup: Map<string, Commit>
|
||||
|
||||
/** The SHA of the selected commit */
|
||||
readonly selectedSHA: string | null
|
||||
/** The SHAs of the selected commits */
|
||||
readonly selectedSHAs: ReadonlyArray<string>
|
||||
|
||||
/** The emoji lookup to render images inline */
|
||||
readonly emoji: Map<string, string>
|
||||
|
@ -31,7 +35,7 @@ interface ICommitListProps {
|
|||
readonly emptyListMessage: JSX.Element | string
|
||||
|
||||
/** Callback which fires when a commit has been selected in the list */
|
||||
readonly onCommitSelected: (commit: Commit) => void
|
||||
readonly onCommitsSelected: (commits: ReadonlyArray<Commit>) => void
|
||||
|
||||
/** Callback that fires when a scroll event has occurred */
|
||||
readonly onScroll: (start: number, end: number) => void
|
||||
|
@ -49,8 +53,13 @@ interface ICommitListProps {
|
|||
readonly onDeleteTag: (tagName: string) => void
|
||||
|
||||
/** Callback to fire to cherry picking the commit */
|
||||
readonly onCherryPick: (commitSha: string) => void
|
||||
readonly onCherryPick: (commits: ReadonlyArray<CommitOneLine>) => void
|
||||
|
||||
/** Callback to fire to when has started being dragged */
|
||||
readonly onDragCommitStart: (commits: ReadonlyArray<CommitOneLine>) => void
|
||||
|
||||
/** Callback to fire to when has started being dragged */
|
||||
readonly onDragCommitEnd: () => void
|
||||
/**
|
||||
* Optional callback that fires on page scroll in order to allow passing
|
||||
* a new scrollTop value up to the parent component for storing.
|
||||
|
@ -65,12 +74,17 @@ interface ICommitListProps {
|
|||
|
||||
/* Tags that haven't been pushed yet. This is used to show the unpushed indicator */
|
||||
readonly tagsToPush: ReadonlyArray<string> | null
|
||||
|
||||
/* Whether or not the user has been introduced to cherry picking feature */
|
||||
readonly hasShownCherryPickIntro: boolean
|
||||
|
||||
/** Callback to fire when cherry pick intro popover has been dismissed */
|
||||
readonly onDismissCherryPickIntro: () => void
|
||||
}
|
||||
|
||||
/** A component which displays the list of commits. */
|
||||
export class CommitList extends React.Component<ICommitListProps, {}> {
|
||||
private commitsHash = memoize(makeCommitsHash, arrayEquals)
|
||||
|
||||
private getVisibleCommits(): ReadonlyArray<Commit> {
|
||||
const commits = new Array<Commit>()
|
||||
for (const sha of this.props.commitSHAs) {
|
||||
|
@ -125,6 +139,9 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
onCherryPick={this.props.onCherryPick}
|
||||
onRevertCommit={this.props.onRevertCommit}
|
||||
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}
|
||||
selectedCommits={this.lookupCommits(this.props.selectedSHAs)}
|
||||
onDragStart={this.props.onDragCommitStart}
|
||||
onDragEnd={this.props.onDragCommitEnd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -146,14 +163,48 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
return undefined
|
||||
}
|
||||
|
||||
private onSelectedRangeChanged = (
|
||||
start: number,
|
||||
end: number,
|
||||
source: SelectionSource
|
||||
) => {
|
||||
// if user selects a range top down, start < end.
|
||||
// if user selects a range down to up, start > end and need to be inverted.
|
||||
// .slice is exclusive of last range end, thus + 1
|
||||
const rangeStart = start < end ? start : end
|
||||
const rangeEnd = start < end ? end + 1 : start + 1
|
||||
const commitSHARange = this.props.commitSHAs.slice(rangeStart, rangeEnd)
|
||||
const selectedCommits = this.lookupCommits(commitSHARange)
|
||||
this.props.onCommitsSelected(selectedCommits)
|
||||
}
|
||||
|
||||
// This is required along with onSelectedRangeChanged in the case of a user
|
||||
// paging up/down or using arrow keys up/down.
|
||||
private onSelectedRowChanged = (row: number) => {
|
||||
const sha = this.props.commitSHAs[row]
|
||||
const commit = this.props.commitLookup.get(sha)
|
||||
if (commit) {
|
||||
this.props.onCommitSelected(commit)
|
||||
this.props.onCommitsSelected([commit])
|
||||
}
|
||||
}
|
||||
|
||||
private lookupCommits(
|
||||
commitSHAs: ReadonlyArray<string>
|
||||
): ReadonlyArray<Commit> {
|
||||
const commits: Commit[] = []
|
||||
commitSHAs.forEach(sha => {
|
||||
const commit = this.props.commitLookup.get(sha)
|
||||
if (commit === undefined) {
|
||||
log.warn(
|
||||
'[Commit List] - Unable to lookup commit from sha - This should not happen.'
|
||||
)
|
||||
return
|
||||
}
|
||||
commits.push(commit)
|
||||
})
|
||||
return commits
|
||||
}
|
||||
|
||||
private onScroll = (scrollTop: number, clientHeight: number) => {
|
||||
const numberOfRows = Math.ceil(clientHeight / RowHeight)
|
||||
const top = Math.floor(scrollTop / RowHeight)
|
||||
|
@ -175,6 +226,36 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
return this.props.commitSHAs.findIndex(s => s === sha)
|
||||
}
|
||||
|
||||
private renderCherryPickIntroPopover() {
|
||||
if (this.props.hasShownCherryPickIntro || !enableCherryPicking()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cherryPickIntro = encodePathAsUrl(
|
||||
__dirname,
|
||||
'static/cherry-pick-intro.png'
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover caretPosition={PopoverCaretPosition.LeftTop}>
|
||||
<img src={cherryPickIntro} className="cherry-pick-intro" />
|
||||
<h3>
|
||||
Drag and drop to cherry pick!
|
||||
<span className="call-to-action-bubble">New</span>
|
||||
</h3>
|
||||
<p>
|
||||
Copy commits to another branch by dragging and dropping them onto a
|
||||
branch in the branch menu, or by right clicking on a commit.
|
||||
</p>
|
||||
<div>
|
||||
<Button onClick={this.props.onDismissCherryPickIntro} type="submit">
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.props.commitSHAs.length === 0) {
|
||||
return (
|
||||
|
@ -187,9 +268,11 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
<List
|
||||
rowCount={this.props.commitSHAs.length}
|
||||
rowHeight={RowHeight}
|
||||
selectedRows={[this.rowForSHA(this.props.selectedSHA)]}
|
||||
selectedRows={this.props.selectedSHAs.map(sha => this.rowForSHA(sha))}
|
||||
rowRenderer={this.renderCommit}
|
||||
onSelectedRangeChanged={this.onSelectedRangeChanged}
|
||||
onSelectedRowChanged={this.onSelectedRowChanged}
|
||||
selectionMode={enableCherryPicking() ? 'range' : 'single'}
|
||||
onScroll={this.onScroll}
|
||||
invalidationProps={{
|
||||
commits: this.props.commitSHAs,
|
||||
|
@ -199,6 +282,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
}}
|
||||
setScrollTop={this.props.compareListScrollTop}
|
||||
/>
|
||||
{this.renderCherryPickIntroPopover()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { Commit } from '../../models/commit'
|
||||
import { Commit, CommitOneLine } from '../../models/commit'
|
||||
import {
|
||||
HistoryTabMode,
|
||||
ICompareState,
|
||||
ICompareBranch,
|
||||
ComparisonMode,
|
||||
IDisplayHistory,
|
||||
FoldoutType,
|
||||
} from '../../lib/app-state'
|
||||
import { CommitList } from './commit-list'
|
||||
import { Repository } from '../../models/repository'
|
||||
|
@ -25,6 +26,7 @@ import { IMatches } from '../../lib/fuzzy-find'
|
|||
import { Ref } from '../lib/ref'
|
||||
import { MergeCallToActionWithConflicts } from './merge-call-to-action-with-conflicts'
|
||||
import { AheadBehindStore } from '../../lib/stores/ahead-behind-store'
|
||||
import { CherryPickStepKind } from '../../models/cherry-pick'
|
||||
|
||||
interface ICompareSidebarProps {
|
||||
readonly repository: Repository
|
||||
|
@ -35,14 +37,19 @@ interface ICompareSidebarProps {
|
|||
readonly localCommitSHAs: ReadonlyArray<string>
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly currentBranch: Branch | null
|
||||
readonly selectedCommitSha: string | null
|
||||
readonly selectedCommitShas: ReadonlyArray<string>
|
||||
readonly onRevertCommit: (commit: Commit) => void
|
||||
readonly onViewCommitOnGitHub: (sha: string) => void
|
||||
readonly onCompareListScrolled: (scrollTop: number) => void
|
||||
readonly onCherryPick: (
|
||||
repository: Repository,
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
) => void
|
||||
readonly compareListScrollTop?: number
|
||||
readonly localTags: Map<string, string> | null
|
||||
readonly tagsToPush: ReadonlyArray<string> | null
|
||||
readonly aheadBehindStore: AheadBehindStore
|
||||
readonly hasShownCherryPickIntro: boolean
|
||||
}
|
||||
|
||||
interface ICompareSidebarState {
|
||||
|
@ -217,7 +224,7 @@ export class CompareSidebar extends React.Component<
|
|||
isLocalRepository={this.props.isLocalRepository}
|
||||
commitLookup={this.props.commitLookup}
|
||||
commitSHAs={commitSHAs}
|
||||
selectedSHA={this.props.selectedCommitSha}
|
||||
selectedSHAs={this.props.selectedCommitShas}
|
||||
localCommitSHAs={this.props.localCommitSHAs}
|
||||
emoji={this.props.emoji}
|
||||
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}
|
||||
|
@ -226,7 +233,7 @@ export class CompareSidebar extends React.Component<
|
|||
? this.props.onRevertCommit
|
||||
: undefined
|
||||
}
|
||||
onCommitSelected={this.onCommitSelected}
|
||||
onCommitsSelected={this.onCommitsSelected}
|
||||
onScroll={this.onScroll}
|
||||
onCreateTag={this.onCreateTag}
|
||||
onDeleteTag={this.onDeleteTag}
|
||||
|
@ -235,10 +242,18 @@ export class CompareSidebar extends React.Component<
|
|||
onCompareListScrolled={this.props.onCompareListScrolled}
|
||||
compareListScrollTop={this.props.compareListScrollTop}
|
||||
tagsToPush={this.props.tagsToPush}
|
||||
onDragCommitStart={this.onDragCommitStart}
|
||||
onDragCommitEnd={this.onDragCommitEnd}
|
||||
hasShownCherryPickIntro={this.props.hasShownCherryPickIntro}
|
||||
onDismissCherryPickIntro={this.onDismissCherryPickIntro}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private onDismissCherryPickIntro = () => {
|
||||
this.props.dispatcher.dismissCherryPickIntro()
|
||||
}
|
||||
|
||||
private renderActiveTab(view: ICompareBranch) {
|
||||
return (
|
||||
<div className="compare-commit-list">
|
||||
|
@ -392,10 +407,10 @@ export class CompareSidebar extends React.Component<
|
|||
}
|
||||
}
|
||||
|
||||
private onCommitSelected = (commit: Commit) => {
|
||||
private onCommitsSelected = (commits: ReadonlyArray<Commit>) => {
|
||||
this.props.dispatcher.changeCommitSelection(
|
||||
this.props.repository,
|
||||
commit.sha
|
||||
commits.map(c => c.sha)
|
||||
)
|
||||
|
||||
this.loadChangedFilesScheduler.queue(() => {
|
||||
|
@ -506,11 +521,31 @@ export class CompareSidebar extends React.Component<
|
|||
this.props.dispatcher.showDeleteTagDialog(this.props.repository, tagName)
|
||||
}
|
||||
|
||||
private onCherryPick = (commitSha: string) => {
|
||||
this.props.dispatcher.showCherryPickBranchDialog(
|
||||
this.props.repository,
|
||||
commitSha
|
||||
)
|
||||
private onCherryPick = (commits: ReadonlyArray<CommitOneLine>) => {
|
||||
this.props.onCherryPick(this.props.repository, commits)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is a generic event handler for when a commit has started being
|
||||
* dragged.
|
||||
*
|
||||
* Currently only used for cherry picking, but this could be more generic.
|
||||
*/
|
||||
private onDragCommitStart = (commits: ReadonlyArray<CommitOneLine>) => {
|
||||
this.props.dispatcher.setCherryPickFlowStep(this.props.repository, {
|
||||
kind: CherryPickStepKind.CommitsChosen,
|
||||
commits,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is a generic event handler for when a commit has ended being
|
||||
* dragged.
|
||||
*
|
||||
* Currently only used for cherry picking, but this could be more generic.
|
||||
*/
|
||||
private onDragCommitEnd = () => {
|
||||
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -67,6 +67,9 @@ interface ISelectedCommitProps {
|
|||
|
||||
/** Called when the user opens the diff options popover */
|
||||
readonly onDiffOptionsOpened: () => void
|
||||
|
||||
/** Whether multiple commits are selected. */
|
||||
readonly areMultipleCommitsSelected: boolean
|
||||
}
|
||||
|
||||
interface ISelectedCommitState {
|
||||
|
@ -236,6 +239,10 @@ export class SelectedCommit extends React.Component<
|
|||
public render() {
|
||||
const commit = this.props.selectedCommit
|
||||
|
||||
if (this.props.areMultipleCommitsSelected) {
|
||||
return <MultipleCommitsSelected />
|
||||
}
|
||||
|
||||
if (commit == null) {
|
||||
return <NoCommitSelected />
|
||||
}
|
||||
|
@ -324,3 +331,25 @@ function NoCommitSelected() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MultipleCommitsSelected() {
|
||||
const BlankSlateImage = encodePathAsUrl(
|
||||
__dirname,
|
||||
'static/empty-no-commit.svg'
|
||||
)
|
||||
|
||||
return (
|
||||
<div id="multiple-commits-selected" className="panel blankslate">
|
||||
<img src={BlankSlateImage} className="blankslate-image" />
|
||||
<div>
|
||||
<p>Unable to display diff when multiple commits are selected.</p>
|
||||
<div>You can:</div>
|
||||
<ul>
|
||||
<li>Select a single commit to view a diff.</li>
|
||||
<li>Drag the commits to the branch menu to cherry pick them.</li>
|
||||
<li>Right click on multiple commits to see options.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -467,6 +467,10 @@ export class AuthorInput extends React.Component<IAuthorInputProps, {}> {
|
|||
this.state = {}
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.editor?.focus()
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
// Sometimes the completion box seems to fail to register
|
||||
// the blur event and close. It's hard to reproduce so
|
||||
|
|
|
@ -250,6 +250,7 @@ export class ConfigureGitUser extends React.Component<
|
|||
gitHubRepository={null}
|
||||
isLocal={false}
|
||||
showUnpushedIndicator={false}
|
||||
selectedCommits={[dummyCommit]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -48,9 +48,11 @@ export const renderUnmergedFile: React.FunctionComponent<{
|
|||
*
|
||||
* - for a merge, this is the tip of the repository
|
||||
* - for a rebase, this is the base branch that commits are being applied on top
|
||||
* - for a cherry pick, this is the source branch that the commits come from
|
||||
*
|
||||
* If the rebase is started outside Desktop, the details about this branch may
|
||||
* not be known - the rendered component will handle this fine.
|
||||
* If the rebase or cherry pick is started outside Desktop, the details about
|
||||
* this branch may not be known - the rendered component will handle this
|
||||
* fine.
|
||||
*/
|
||||
readonly ourBranch?: string
|
||||
/**
|
||||
|
@ -58,6 +60,8 @@ export const renderUnmergedFile: React.FunctionComponent<{
|
|||
*
|
||||
* - for a merge, this is be the branch being merged into the tip of the repository
|
||||
* - for a rebase, this is the target branch that is having it's history rewritten
|
||||
* - for a cherrypick, this is the target branch that the commits are being
|
||||
* applied to.
|
||||
*
|
||||
* If the merge is started outside Desktop, the details about this branch may
|
||||
* not be known - the rendered component will handle this fine.
|
||||
|
|
|
@ -32,6 +32,9 @@ interface IListRowProps {
|
|||
/** callback to fire when the row receives a mousedown event */
|
||||
readonly onRowMouseDown: (index: number, e: React.MouseEvent<any>) => void
|
||||
|
||||
/** callback to fire when the row receives a mouseup event */
|
||||
readonly onRowMouseUp: (index: number, e: React.MouseEvent<any>) => void
|
||||
|
||||
/** callback to fire when the row is clicked */
|
||||
readonly onRowClick: (index: number, e: React.MouseEvent<any>) => void
|
||||
|
||||
|
@ -58,6 +61,10 @@ export class ListRow extends React.Component<IListRowProps, {}> {
|
|||
this.props.onRowMouseDown(this.props.rowIndex, e)
|
||||
}
|
||||
|
||||
private onRowMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
this.props.onRowMouseUp(this.props.rowIndex, e)
|
||||
}
|
||||
|
||||
private onRowClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
this.props.onRowClick(this.props.rowIndex, e)
|
||||
}
|
||||
|
@ -97,6 +104,7 @@ export class ListRow extends React.Component<IListRowProps, {}> {
|
|||
ref={this.props.onRef}
|
||||
onMouseOver={this.onRowMouseOver}
|
||||
onMouseDown={this.onRowMouseDown}
|
||||
onMouseUp={this.onRowMouseUp}
|
||||
onClick={this.onRowClick}
|
||||
onKeyDown={this.onRowKeyDown}
|
||||
style={style}
|
||||
|
|
|
@ -814,6 +814,7 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
onRowClick={this.onRowClick}
|
||||
onRowKeyDown={this.onRowKeyDown}
|
||||
onRowMouseDown={this.onRowMouseDown}
|
||||
onRowMouseUp={this.onRowMouseUp}
|
||||
onRowMouseOver={this.onRowMouseOver}
|
||||
style={params.style}
|
||||
tabIndex={tabIndex}
|
||||
|
@ -1065,6 +1066,15 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
event,
|
||||
})
|
||||
}
|
||||
} else if (
|
||||
this.props.selectionMode === 'range' &&
|
||||
this.props.selectedRows.length > 1 &&
|
||||
this.props.selectedRows.includes(row)
|
||||
) {
|
||||
// Do nothing. Multiple rows are already selected for a range. We assume
|
||||
// the user is pressing down on multiple and may desire to start
|
||||
// dragging. We will invoke the single selection `onRowMouseUp` if they
|
||||
// let go here and no special keys are being pressed.
|
||||
} else if (
|
||||
this.props.selectedRows.length !== 1 ||
|
||||
(this.props.selectedRows.length === 1 &&
|
||||
|
@ -1074,33 +1084,72 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
* if no special key is pressed, and that the selection is different,
|
||||
* single selection occurs
|
||||
*/
|
||||
if (this.props.onSelectionChanged) {
|
||||
this.props.onSelectionChanged([row], { kind: 'mouseclick', event })
|
||||
}
|
||||
|
||||
if (this.props.onSelectedRangeChanged) {
|
||||
this.props.onSelectedRangeChanged(row, row, {
|
||||
kind: 'mouseclick',
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.props.onSelectedRowChanged) {
|
||||
const { rowCount } = this.props
|
||||
|
||||
if (row < 0 || row >= rowCount) {
|
||||
log.debug(
|
||||
`[List.onRowMouseDown] unable to onSelectedRowChanged for row '${row}' as it is outside the bounds of the array [0, ${rowCount}]`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onSelectedRowChanged(row, { kind: 'mouseclick', event })
|
||||
}
|
||||
this.selectSingleRowAfterMouseEvent(row, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onRowMouseUp = (row: number, event: React.MouseEvent<any>) => {
|
||||
if (!this.canSelectRow(row)) {
|
||||
return
|
||||
}
|
||||
|
||||
// macOS allow emulating a right click by holding down the ctrl key while
|
||||
// performing a "normal" click.
|
||||
const isRightClick =
|
||||
event.button === 2 || (__DARWIN__ && event.button === 0 && event.ctrlKey)
|
||||
|
||||
// prevent the right-click event from changing the selection if not necessary
|
||||
if (isRightClick && this.props.selectedRows.includes(row)) {
|
||||
return
|
||||
}
|
||||
|
||||
const multiSelectKey = __DARWIN__ ? event.metaKey : event.ctrlKey
|
||||
|
||||
if (
|
||||
!event.shiftKey &&
|
||||
!multiSelectKey &&
|
||||
this.props.selectedRows.length > 1 &&
|
||||
this.props.selectedRows.includes(row) &&
|
||||
this.props.selectionMode === 'range'
|
||||
) {
|
||||
// No special keys are depressed and multiple rows were selected in a
|
||||
// range. The onRowMouseDown event was ignored for this scenario because
|
||||
// the user may desire to started dragging multiple. However, if they let
|
||||
// go, we want a new single selection to occur.
|
||||
this.selectSingleRowAfterMouseEvent(row, event)
|
||||
}
|
||||
}
|
||||
|
||||
private selectSingleRowAfterMouseEvent(
|
||||
row: number,
|
||||
event: React.MouseEvent<any>
|
||||
): void {
|
||||
if (this.props.onSelectionChanged) {
|
||||
this.props.onSelectionChanged([row], { kind: 'mouseclick', event })
|
||||
}
|
||||
|
||||
if (this.props.onSelectedRangeChanged) {
|
||||
this.props.onSelectedRangeChanged(row, row, {
|
||||
kind: 'mouseclick',
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.props.onSelectedRowChanged) {
|
||||
const { rowCount } = this.props
|
||||
|
||||
if (row < 0 || row >= rowCount) {
|
||||
log.debug(
|
||||
`[List.selectSingleRowAfterMouseEvent] unable to onSelectedRowChanged for row '${row}' as it is outside the bounds of the array [0, ${rowCount}]`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onSelectedRowChanged(row, { kind: 'mouseclick', event })
|
||||
}
|
||||
}
|
||||
|
||||
private onRowClick = (row: number, event: React.MouseEvent<any>) => {
|
||||
if (this.canSelectRow(row) && this.props.onRowClick) {
|
||||
const rowCount = this.props.rowCount
|
||||
|
|
|
@ -20,7 +20,7 @@ export enum PopoverCaretPosition {
|
|||
LeftBottom = 'left-bottom',
|
||||
}
|
||||
interface IPopoverProps {
|
||||
readonly onClickOutside: () => void
|
||||
readonly onClickOutside?: () => void
|
||||
readonly caretPosition: PopoverCaretPosition
|
||||
}
|
||||
|
||||
|
@ -54,7 +54,8 @@ export class Popover extends React.Component<IPopoverProps> {
|
|||
ref !== null &&
|
||||
ref.parentElement !== null &&
|
||||
target instanceof Node &&
|
||||
!ref.parentElement.contains(target)
|
||||
!ref.parentElement.contains(target) &&
|
||||
this.props.onClickOutside !== undefined
|
||||
) {
|
||||
this.props.onClickOutside()
|
||||
}
|
||||
|
|
|
@ -170,6 +170,8 @@ export class LocalChangesOverwrittenDialog extends React.Component<
|
|||
return 'fetch'
|
||||
case RetryActionType.Push:
|
||||
return 'push'
|
||||
case RetryActionType.CherryPick:
|
||||
return 'cherry pick'
|
||||
default:
|
||||
assertNever(
|
||||
this.props.retryAction,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import { Repository } from '../models/repository'
|
||||
import { Commit } from '../models/commit'
|
||||
import { Commit, CommitOneLine } from '../models/commit'
|
||||
import { TipState } from '../models/tip'
|
||||
import { UiView } from './ui-view'
|
||||
import { Changes, ChangesSidebar } from './changes'
|
||||
|
@ -85,6 +85,12 @@ interface IRepositoryViewProps {
|
|||
|
||||
readonly onExitTutorial: () => void
|
||||
readonly aheadBehindStore: AheadBehindStore
|
||||
readonly onCherryPick: (
|
||||
repository: Repository,
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
) => void
|
||||
/* Whether or not the user has been introduced to cherry picking feature */
|
||||
readonly hasShownCherryPickIntro: boolean
|
||||
}
|
||||
|
||||
interface IRepositoryViewState {
|
||||
|
@ -226,7 +232,7 @@ export class RepositoryView extends React.Component<
|
|||
repository={this.props.repository}
|
||||
isLocalRepository={this.props.state.remote === null}
|
||||
compareState={this.props.state.compareState}
|
||||
selectedCommitSha={this.props.state.commitSelection.sha}
|
||||
selectedCommitShas={this.props.state.commitSelection.shas}
|
||||
currentBranch={currentBranch}
|
||||
emoji={this.props.emoji}
|
||||
commitLookup={this.props.state.commitLookup}
|
||||
|
@ -236,9 +242,11 @@ export class RepositoryView extends React.Component<
|
|||
onRevertCommit={this.onRevertCommit}
|
||||
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}
|
||||
onCompareListScrolled={this.onCompareListScrolled}
|
||||
onCherryPick={this.props.onCherryPick}
|
||||
compareListScrollTop={scrollTop}
|
||||
tagsToPush={this.props.state.tagsToPush}
|
||||
aheadBehindStore={this.props.aheadBehindStore}
|
||||
hasShownCherryPickIntro={this.props.hasShownCherryPickIntro}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -324,7 +332,8 @@ export class RepositoryView extends React.Component<
|
|||
private renderContentForHistory(): JSX.Element {
|
||||
const { commitSelection } = this.props.state
|
||||
|
||||
const sha = commitSelection.sha
|
||||
const sha =
|
||||
commitSelection.shas.length === 1 ? commitSelection.shas[0] : null
|
||||
|
||||
const selectedCommit =
|
||||
sha != null ? this.props.state.commitLookup.get(sha) || null : null
|
||||
|
@ -349,6 +358,7 @@ export class RepositoryView extends React.Component<
|
|||
onOpenBinaryFile={this.onOpenBinaryFile}
|
||||
onChangeImageDiffType={this.onChangeImageDiffType}
|
||||
onDiffOptionsOpened={this.onDiffOptionsOpened}
|
||||
areMultipleCommitsSelected={commitSelection.shas.length > 1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,12 +4,17 @@ import { OcticonSymbol, syncClockwise } from '../octicons'
|
|||
import { Repository } from '../../models/repository'
|
||||
import { TipState } from '../../models/tip'
|
||||
import { ToolbarDropdown, DropdownState } from './dropdown'
|
||||
import { IRepositoryState, isRebaseConflictState } from '../../lib/app-state'
|
||||
import {
|
||||
FoldoutType,
|
||||
IRepositoryState,
|
||||
isRebaseConflictState,
|
||||
} from '../../lib/app-state'
|
||||
import { BranchesContainer, PullRequestBadge } from '../branches'
|
||||
import { assertNever } from '../../lib/fatal-error'
|
||||
import { BranchesTab } from '../../models/branches-tab'
|
||||
import { PullRequest } from '../../models/pull-request'
|
||||
import classNames from 'classnames'
|
||||
import { CherryPickStepKind } from '../../models/cherry-pick'
|
||||
|
||||
interface IBranchDropdownProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
|
@ -168,12 +173,30 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
|
|||
showDisclosureArrow={canOpen}
|
||||
progressValue={progressValue}
|
||||
buttonClassName={buttonClassName}
|
||||
onDragOver={this.onDragOver}
|
||||
>
|
||||
{this.renderPullRequestInfo()}
|
||||
</ToolbarDropdown>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to capture when something is dragged over the branch dropdown.
|
||||
*/
|
||||
private onDragOver = (event: React.DragEvent<HTMLDivElement>): void => {
|
||||
event.preventDefault()
|
||||
|
||||
// If the cherry picking state is initiated, we assume the user is
|
||||
// dragging commits. Therefore, we should open the branch menu.
|
||||
const { cherryPickState } = this.props.repositoryState
|
||||
if (
|
||||
cherryPickState.step !== null &&
|
||||
cherryPickState.step.kind === CherryPickStepKind.CommitsChosen
|
||||
) {
|
||||
this.props.dispatcher.showFoldout({ type: FoldoutType.Branch })
|
||||
}
|
||||
}
|
||||
|
||||
private renderPullRequestInfo() {
|
||||
const pr = this.props.currentPullRequest
|
||||
|
||||
|
|
|
@ -63,6 +63,12 @@ export interface IToolbarDropdownProps {
|
|||
*/
|
||||
readonly dropdownContentRenderer: () => JSX.Element | null
|
||||
|
||||
/**
|
||||
* A function that's called whenever something is dragged over the
|
||||
* dropdown.
|
||||
*/
|
||||
readonly onDragOver?: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
|
||||
/**
|
||||
* An optional classname that will be appended to the default
|
||||
* class name 'toolbar-button dropdown open|closed'
|
||||
|
@ -339,6 +345,7 @@ export class ToolbarDropdown extends React.Component<
|
|||
onKeyDown={this.props.onKeyDown}
|
||||
role={this.props.role}
|
||||
aria-expanded={ariaExpanded}
|
||||
onDragOver={this.props.onDragOver}
|
||||
>
|
||||
{this.renderDropdownContents()}
|
||||
<ToolbarButton
|
||||
|
|
BIN
app/static/common/cherry-pick-intro.png
Normal file
BIN
app/static/common/cherry-pick-intro.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
|
@ -433,7 +433,7 @@ $overlay-background-color: rgba(0, 0, 0, 0.4);
|
|||
--easing-ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
/** rebase progress bar */
|
||||
--dialog-rebase-progress-background: #{$green};
|
||||
--dialog-progress-background: #{$green};
|
||||
|
||||
/** merge/rebase status indicators */
|
||||
--status-pending-color: #{$yellow-700};
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
@import 'banners/successful-merge';
|
||||
@import 'banners/merge-conflicts';
|
||||
@import 'banners/successful-rebase';
|
||||
@import 'banners/rebase-conflicts';
|
||||
@import 'banners/successful';
|
||||
@import 'banners/conflicts';
|
||||
@import 'banners/update-available';
|
||||
|
||||
.banner {
|
||||
|
|
|
@ -14,6 +14,20 @@
|
|||
width: 365px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.branches-list-item {
|
||||
height: 100%;
|
||||
&.dragged-over {
|
||||
--text-color: var(--box-selected-active-text-color);
|
||||
--text-secondary-color: var(--box-selected-active-text-color);
|
||||
|
||||
color: var(--text-color);
|
||||
background-color: var(--box-selected-active-background-color);
|
||||
}
|
||||
div {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pull-request-tab {
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
@import 'dialogs/merge';
|
||||
@import 'dialogs/merge-conflicts';
|
||||
@import 'dialogs/rebase';
|
||||
@import 'dialogs/rebase-progress';
|
||||
@import 'dialogs/progress-dialog';
|
||||
@import 'dialogs/abort-merge';
|
||||
@import 'dialogs/push-needs-pull';
|
||||
@import 'dialogs/publish-repository';
|
||||
|
@ -14,6 +14,7 @@
|
|||
@import 'dialogs/create-tutorial-repository';
|
||||
@import 'dialogs/create-fork';
|
||||
@import 'dialogs/fork-settings';
|
||||
@import 'dialogs/cherry-pick';
|
||||
|
||||
// The styles herein attempt to follow a flow where margins are only applied
|
||||
// to the bottom of elements (with the exception of the last child). This to
|
||||
|
|
|
@ -2,3 +2,4 @@
|
|||
@import 'history/commit-list';
|
||||
@import 'history/commit-summary';
|
||||
@import 'history/file-list';
|
||||
@import 'history/multiple_commits_selected';
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
#merge-conflicts-banner {
|
||||
#merge-conflicts-banner,
|
||||
#rebase-conflicts-banner,
|
||||
#cherry-pick-conflicts-banner {
|
||||
.banner-message {
|
||||
span {
|
||||
max-width: 100%;
|
|
@ -1,17 +0,0 @@
|
|||
#rebase-conflicts-banner {
|
||||
.banner-message {
|
||||
span {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0 1ex;
|
||||
}
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.alert-icon {
|
||||
fill: var(--color-conflicted);
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
#successful-rebase {
|
||||
.banner-message {
|
||||
span {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.green-circle {
|
||||
background-color: var(--color-new);
|
||||
color: var(--background-color);
|
||||
border-radius: 50%;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: var(--spacing);
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
}
|
|
@ -1,3 +1,6 @@
|
|||
#cherry-pick-undone,
|
||||
#successful-cherry-pick,
|
||||
#successful-rebase,
|
||||
#successful-merge {
|
||||
.banner-message {
|
||||
span {
|
51
app/styles/ui/dialogs/_cherry-pick.scss
Normal file
51
app/styles/ui/dialogs/_cherry-pick.scss
Normal file
|
@ -0,0 +1,51 @@
|
|||
@import '../../mixins';
|
||||
|
||||
dialog#cherry-pick {
|
||||
width: 450px;
|
||||
|
||||
.branches-list {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
// We're faking it so that the filter text box appears to reside
|
||||
// withing the header even though our current component structure
|
||||
// make it extremely hard to do so.
|
||||
.dialog-header {
|
||||
border-bottom: none;
|
||||
|
||||
h1 {
|
||||
font-weight: var(--font-weight-light);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 0;
|
||||
|
||||
.filter-field-row {
|
||||
margin: 0;
|
||||
border-bottom: var(--base-border);
|
||||
|
||||
.filter-list-filter-field {
|
||||
padding: 0 var(--spacing-double);
|
||||
padding-bottom: var(--spacing);
|
||||
}
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 0 var(--spacing-double);
|
||||
|
||||
.filter-list-group-header,
|
||||
.branches-list-item {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
button[type='submit'] {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
padding: var(--spacing-half);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
@import '../../mixins';
|
||||
|
||||
dialog#merge-conflicts-list,
|
||||
dialog#cherry-pick-conflicts-list,
|
||||
dialog#rebase-conflicts-list {
|
||||
width: 500px;
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
dialog#cherry-pick-progress,
|
||||
dialog#rebase-progress {
|
||||
progress {
|
||||
-webkit-appearance: none;
|
||||
|
@ -8,7 +9,7 @@ dialog#rebase-progress {
|
|||
}
|
||||
|
||||
&::-webkit-progress-value {
|
||||
background-color: var(--dialog-rebase-progress-background);
|
||||
background-color: var(--dialog-progress-background);
|
||||
// ensure a smooth transition if the rebase goes immediately from 0 to 100%
|
||||
transition: width 1s ease;
|
||||
}
|
|
@ -7,6 +7,27 @@
|
|||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.popover-component {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 73px;
|
||||
width: 282px;
|
||||
|
||||
.call-to-action-bubble {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-xs);
|
||||
border: 1px solid var(--call-to-action-bubble-border-color);
|
||||
color: var(--call-to-action-bubble-color);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--border-radius);
|
||||
margin-left: var(--spacing-third);
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.commit {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
6
app/styles/ui/history/_multiple_commits_selected.scss
Normal file
6
app/styles/ui/history/_multiple_commits_selected.scss
Normal file
|
@ -0,0 +1,6 @@
|
|||
#multiple-commits-selected {
|
||||
text-align: left;
|
||||
ul {
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
|
@ -32,6 +32,7 @@ export function createStatus<K extends keyof IStatusResult>(
|
|||
exists: true,
|
||||
mergeHeadFound: false,
|
||||
rebaseInternalState: null,
|
||||
isCherryPickingHeadFound: false,
|
||||
workingDirectory: WorkingDirectoryStatus.fromFiles([]),
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,9 @@ import {
|
|||
continueCherryPick,
|
||||
getCherryPickSnapshot,
|
||||
} from '../../../src/lib/git/cherry-pick'
|
||||
import { isConflictedFile } from '../../../src/lib/status'
|
||||
import { Branch } from '../../../src/models/branch'
|
||||
import { ManualConflictResolution } from '../../../src/models/manual-conflict-resolution'
|
||||
import { ICherryPickProgress } from '../../../src/models/progress'
|
||||
import { Repository } from '../../../src/models/repository'
|
||||
import { AppFileStatusKind } from '../../../src/models/status'
|
||||
|
@ -94,6 +96,76 @@ describe('git/cherry-pick', () => {
|
|||
expect(result).toBe(CherryPickResult.CompletedWithoutError)
|
||||
})
|
||||
|
||||
it('successfully cherry picks a redundant commit', async () => {
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
|
||||
const commits = await getCommits(repository, targetBranch.ref, 5)
|
||||
expect(commits.length).toBe(2)
|
||||
expect(result).toBe(CherryPickResult.CompletedWithoutError)
|
||||
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
|
||||
const commitsAfterRedundant = await getCommits(
|
||||
repository,
|
||||
targetBranch.ref,
|
||||
5
|
||||
)
|
||||
expect(commitsAfterRedundant.length).toBe(3)
|
||||
expect(result).toBe(CherryPickResult.CompletedWithoutError)
|
||||
})
|
||||
|
||||
it('successfully cherry picks an empty commit', async () => {
|
||||
// add empty commit to feature branch
|
||||
await switchTo(repository, featureBranchName)
|
||||
await GitProcess.exec(
|
||||
['commit', '--allow-empty', '-m', 'Empty Commit'],
|
||||
repository.path
|
||||
)
|
||||
|
||||
featureBranch = await getBranchOrError(repository, featureBranchName)
|
||||
await switchTo(repository, targetBranchName)
|
||||
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
|
||||
const commits = await getCommits(repository, targetBranch.ref, 5)
|
||||
expect(commits.length).toBe(2)
|
||||
expect(result).toBe(CherryPickResult.CompletedWithoutError)
|
||||
})
|
||||
|
||||
it('successfully cherry picks an empty commit inside a range', async () => {
|
||||
const firstCommitSha = featureBranch.tip.sha
|
||||
|
||||
// add empty commit to feature branch
|
||||
await switchTo(repository, featureBranchName)
|
||||
await GitProcess.exec(
|
||||
['commit', '--allow-empty', '-m', 'Empty Commit'],
|
||||
repository.path
|
||||
)
|
||||
|
||||
// add another commit so empty commit will be inside a range
|
||||
const featureBranchCommitTwo = {
|
||||
commitMessage: 'Cherry Picked Feature! Number Two',
|
||||
entries: [
|
||||
{
|
||||
path: 'THING_TWO.md',
|
||||
contents: '# HELLO WORLD! \nTHINGS GO HERE\n',
|
||||
},
|
||||
],
|
||||
}
|
||||
await makeCommit(repository, featureBranchCommitTwo)
|
||||
|
||||
featureBranch = await getBranchOrError(repository, featureBranchName)
|
||||
await switchTo(repository, targetBranchName)
|
||||
|
||||
// cherry picking 3 (on added in setup, empty, featureBranchCommitTwo)
|
||||
const commitRange = revRangeInclusive(firstCommitSha, featureBranch.tip.sha)
|
||||
result = await cherryPick(repository, commitRange)
|
||||
|
||||
const commits = await getCommits(repository, targetBranch.ref, 5)
|
||||
expect(commits.length).toBe(4) // original commit + 4 cherry picked
|
||||
expect(result).toBe(CherryPickResult.CompletedWithoutError)
|
||||
})
|
||||
|
||||
it('successfully cherry picked multiple commits without conflicts', async () => {
|
||||
// keep reference to the first commit in cherry pick range
|
||||
const firstCommitSha = featureBranch.tip.sha
|
||||
|
@ -127,10 +199,12 @@ describe('git/cherry-pick', () => {
|
|||
Path.join(repository.path, 'THING.md'),
|
||||
'# HELLO WORLD! \nTHINGS GO HERE\nFEATURE BRANCH UNDERWAY\n'
|
||||
)
|
||||
// This error is not one of the parsed dugite errors
|
||||
// https://github.com/desktop/dugite/blob/master/lib/errors.ts
|
||||
// TODO: add to dugite error so we can make use of
|
||||
// `localChangesOverwrittenHandler` in `error-handler.ts`
|
||||
|
||||
// This error should not occur in the wild due to the nature of Desktop's UI
|
||||
// starting on source branch and having to checkout the target branch.
|
||||
// During target branch checkout, it will fail before we even get to cherry
|
||||
// picking. Thus, this scenario from a UI's perspective is already handled.
|
||||
// No need to add dugite errors to handle it.
|
||||
result = null
|
||||
try {
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
|
@ -180,76 +254,6 @@ describe('git/cherry-pick', () => {
|
|||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it('fails to cherry pick an empty commit', async () => {
|
||||
// add empty commit to feature branch
|
||||
await switchTo(repository, featureBranchName)
|
||||
await GitProcess.exec(
|
||||
['commit', '--allow-empty', '-m', 'Empty Commit'],
|
||||
repository.path
|
||||
)
|
||||
|
||||
featureBranch = await getBranchOrError(repository, featureBranchName)
|
||||
await switchTo(repository, targetBranchName)
|
||||
|
||||
result = null
|
||||
try {
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
} catch (error) {
|
||||
expect(error.toString()).toContain('There are no changes to commit')
|
||||
}
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it('fails to cherry pick an empty commit inside a range', async () => {
|
||||
const firstCommitSha = featureBranch.tip.sha
|
||||
|
||||
// add empty commit to feature branch
|
||||
await switchTo(repository, featureBranchName)
|
||||
await GitProcess.exec(
|
||||
['commit', '--allow-empty', '-m', 'Empty Commit'],
|
||||
repository.path
|
||||
)
|
||||
|
||||
// add another commit so empty commit will be inside a range
|
||||
const featureBranchCommitTwo = {
|
||||
commitMessage: 'Cherry Picked Feature! Number Two',
|
||||
entries: [
|
||||
{
|
||||
path: 'THING_TWO.md',
|
||||
contents: '# HELLO WORLD! \nTHINGS GO HERE\n',
|
||||
},
|
||||
],
|
||||
}
|
||||
await makeCommit(repository, featureBranchCommitTwo)
|
||||
|
||||
featureBranch = await getBranchOrError(repository, featureBranchName)
|
||||
await switchTo(repository, targetBranchName)
|
||||
|
||||
try {
|
||||
const commitRange = revRangeInclusive(
|
||||
firstCommitSha,
|
||||
featureBranch.tip.sha
|
||||
)
|
||||
result = await cherryPick(repository, commitRange)
|
||||
} catch (error) {
|
||||
expect(error.toString()).toContain('There are no changes to commit')
|
||||
}
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it('fails to cherry pick a redundant commit', async () => {
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
expect(result).toBe(CherryPickResult.CompletedWithoutError)
|
||||
|
||||
result = null
|
||||
try {
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
} catch (error) {
|
||||
expect(error.toString()).toContain('There are no changes to commit')
|
||||
}
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
describe('cherry picking with conflicts', () => {
|
||||
beforeEach(async () => {
|
||||
// In the 'git/cherry-pick' `beforeEach`, we call `createRepository` which
|
||||
|
@ -279,7 +283,7 @@ describe('git/cherry-pick', () => {
|
|||
expect(conflictedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('successfully continues cherry picking with conflicts after resolving them', async () => {
|
||||
it('successfully continues cherry picking with conflicts after resolving them by overwriting', async () => {
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
expect(result).toBe(CherryPickResult.ConflictsEncountered)
|
||||
|
||||
|
@ -312,6 +316,62 @@ describe('git/cherry-pick', () => {
|
|||
expect(result).toBe(CherryPickResult.CompletedWithoutError)
|
||||
})
|
||||
|
||||
it('successfully continues cherry picking with conflicts after resolving them manually', async () => {
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
expect(result).toBe(CherryPickResult.ConflictsEncountered)
|
||||
|
||||
const statusAfterCherryPick = await getStatusOrThrow(repository)
|
||||
const { files } = statusAfterCherryPick.workingDirectory
|
||||
|
||||
// git diff --check warns if conflict markers exist and will exit with
|
||||
// non-zero status if conflicts found
|
||||
const diffCheckBefore = await GitProcess.exec(
|
||||
['diff', '--check'],
|
||||
repository.path
|
||||
)
|
||||
expect(diffCheckBefore.exitCode).toBeGreaterThan(0)
|
||||
|
||||
const manualResolutions = new Map<string, ManualConflictResolution>()
|
||||
|
||||
for (const file of files) {
|
||||
if (isConflictedFile(file.status)) {
|
||||
manualResolutions.set(file.path, ManualConflictResolution.theirs)
|
||||
}
|
||||
}
|
||||
|
||||
result = await continueCherryPick(repository, files, manualResolutions)
|
||||
|
||||
expect(result).toBe(CherryPickResult.CompletedWithoutError)
|
||||
})
|
||||
|
||||
it('successfully continues cherry picking with conflicts after resolving them manually and no changes to commit', async () => {
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
expect(result).toBe(CherryPickResult.ConflictsEncountered)
|
||||
|
||||
const statusAfterCherryPick = await getStatusOrThrow(repository)
|
||||
const { files } = statusAfterCherryPick.workingDirectory
|
||||
|
||||
// git diff --check warns if conflict markers exist and will exit with
|
||||
// non-zero status if conflicts found
|
||||
const diffCheckBefore = await GitProcess.exec(
|
||||
['diff', '--check'],
|
||||
repository.path
|
||||
)
|
||||
expect(diffCheckBefore.exitCode).toBeGreaterThan(0)
|
||||
|
||||
const manualResolutions = new Map<string, ManualConflictResolution>()
|
||||
|
||||
for (const file of files) {
|
||||
if (isConflictedFile(file.status)) {
|
||||
manualResolutions.set(file.path, ManualConflictResolution.ours)
|
||||
}
|
||||
}
|
||||
|
||||
result = await continueCherryPick(repository, files, manualResolutions)
|
||||
|
||||
expect(result).toBe(CherryPickResult.CompletedWithoutError)
|
||||
})
|
||||
|
||||
it('successfully detects cherry picking with outstanding files not staged', async () => {
|
||||
result = await cherryPick(repository, featureBranch.tip.sha)
|
||||
expect(result).toBe(CherryPickResult.ConflictsEncountered)
|
||||
|
@ -384,7 +444,7 @@ describe('git/cherry-pick', () => {
|
|||
result = await cherryPick(repository, 'INVALID REF', p =>
|
||||
progress.push(p)
|
||||
)
|
||||
expect(result).toBe(CherryPickResult.Error)
|
||||
expect(result).toBe(CherryPickResult.UnableToStart)
|
||||
})
|
||||
|
||||
it('successfully parses progress for a single commit', async () => {
|
||||
|
@ -462,7 +522,7 @@ describe('git/cherry-pick', () => {
|
|||
Path.join(repository.path, 'THING_THREE.md'),
|
||||
'# Resolve conflicts!'
|
||||
)
|
||||
result = await continueCherryPick(repository, files, p =>
|
||||
result = await continueCherryPick(repository, files, new Map(), p =>
|
||||
progress.push(p)
|
||||
)
|
||||
expect(result).toBe(CherryPickResult.CompletedWithoutError)
|
||||
|
|
|
@ -4,6 +4,11 @@
|
|||
"[Fixed] Commit attribution warning is not shown for emails with different capitalization - #11711",
|
||||
"[Improved] Upgrade embedded Git to v2.29.3 on macOS, and to v2.29.2.windows.4 on Windows - #11755"
|
||||
],
|
||||
"2.6.6-beta1": [
|
||||
"[Added] Add .avif image support - #11625. Thanks @brendonbarreto!",
|
||||
"[Fixed] Performing remote Git operations in rare cases displays an error message instead of crashing the app - #11694",
|
||||
"[Improved] Clicking on \"Add Co-Author\" moves the focus to the co-authors text field - #11621"
|
||||
],
|
||||
"2.6.5": ["[Fixed] Performing remote Git operations could crash the app"],
|
||||
"2.6.4": [
|
||||
"[Added] Allow users to rename and delete branches via a new context menu on branches in the branches list - #5803 #10432",
|
||||
|
|
|
@ -207,6 +207,8 @@ fatal: could not read Username for 'https://github.com': terminal prompts disabl
|
|||
|
||||
Known causes and workarounds:
|
||||
|
||||
**If you're experiencing this error, please download the [beta version](https://github.com/desktop/desktop#beta-channel) where it should hopefully be solved.**
|
||||
|
||||
- Modifying the `AutoRun` registry entry. To check if this entry has been modified open `Regedit.exe` and navigate to `HKEY_CURRENT_USER\Software\Microsoft\Command Processor\autorun` and `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Command Processor\autorun` to see if there is anything set (sometimes applications will also modify this). See [#6789](https://github.com/desktop/desktop/issues/6879#issuecomment-471042891) and [#2623](https://github.com/desktop/desktop/issues/2623#issuecomment-334305916) for examples of this.
|
||||
|
||||
- Special characters in your Windows username like a `&` or `-` can cause this error to be thrown. See [#7064](https://github.com/desktop/desktop/issues/7064) for an example of this. Try installing GitHub Desktop in a new user account to verify if this is the case.
|
||||
|
|
32
yarn.lock
32
yarn.lock
|
@ -2566,10 +2566,10 @@ bluebird@^3.5.5:
|
|||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
|
||||
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
|
||||
|
||||
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
|
||||
version "4.11.9"
|
||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828"
|
||||
integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==
|
||||
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.11.9:
|
||||
version "4.12.0"
|
||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
|
||||
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
|
||||
|
||||
body-parser@1.19.0:
|
||||
version "1.19.0"
|
||||
|
@ -2656,7 +2656,7 @@ braces@^3.0.1, braces@~3.0.2:
|
|||
dependencies:
|
||||
fill-range "^7.0.1"
|
||||
|
||||
brorand@^1.0.1:
|
||||
brorand@^1.0.1, brorand@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
|
||||
integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=
|
||||
|
@ -4154,17 +4154,17 @@ element-closest@^2.0.2:
|
|||
integrity sha1-cqdAoQdFM4LijfnOXbtajfD5Zuw=
|
||||
|
||||
elliptic@^6.0.0:
|
||||
version "6.5.3"
|
||||
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6"
|
||||
integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==
|
||||
version "6.5.4"
|
||||
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
|
||||
integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
|
||||
dependencies:
|
||||
bn.js "^4.4.0"
|
||||
brorand "^1.0.1"
|
||||
bn.js "^4.11.9"
|
||||
brorand "^1.1.0"
|
||||
hash.js "^1.0.0"
|
||||
hmac-drbg "^1.0.0"
|
||||
inherits "^2.0.1"
|
||||
minimalistic-assert "^1.0.0"
|
||||
minimalistic-crypto-utils "^1.0.0"
|
||||
hmac-drbg "^1.0.1"
|
||||
inherits "^2.0.4"
|
||||
minimalistic-assert "^1.0.1"
|
||||
minimalistic-crypto-utils "^1.0.1"
|
||||
|
||||
emittery@^0.7.1:
|
||||
version "0.7.2"
|
||||
|
@ -5563,7 +5563,7 @@ he@1.1.x:
|
|||
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
|
||||
integrity sha1-k0EP0hsAlzUVH4howvJx80J+I/0=
|
||||
|
||||
hmac-drbg@^1.0.0:
|
||||
hmac-drbg@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
|
||||
integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=
|
||||
|
@ -7607,7 +7607,7 @@ minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
|
||||
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
|
||||
|
||||
minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
|
||||
minimalistic-crypto-utils@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
|
||||
integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=
|
||||
|
|
Loading…
Reference in a new issue