mirror of
https://github.com/desktop/desktop
synced 2024-09-13 21:31:32 +00:00
Merge branch 'development' into windows-arm-support
This commit is contained in:
commit
cdbbd8038f
|
@ -3,7 +3,7 @@
|
|||
"productName": "GitHub Desktop",
|
||||
"bundleID": "com.github.GitHubClient",
|
||||
"companyName": "GitHub, Inc.",
|
||||
"version": "2.6.4-beta2",
|
||||
"version": "2.7.2-beta2",
|
||||
"main": "./main.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -25,10 +25,10 @@
|
|||
"codemirror-mode-elixir": "^1.1.2",
|
||||
"compare-versions": "^3.6.0",
|
||||
"deep-equal": "^1.0.1",
|
||||
"desktop-trampoline": "desktop/desktop-trampoline#v0.9.3",
|
||||
"desktop-trampoline": "desktop/desktop-trampoline#v0.9.4",
|
||||
"dexie": "^2.0.0",
|
||||
"double-ended-queue": "^2.1.0-0",
|
||||
"dugite": "^1.98.0",
|
||||
"dugite": "^1.102.0",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"event-kit": "^2.0.0",
|
||||
"file-metadata": "^1.0.0",
|
||||
|
|
|
@ -15,6 +15,13 @@ import username from 'username'
|
|||
import { GitProtocol } from './remote-parsing'
|
||||
|
||||
const envEndpoint = process.env['DESKTOP_GITHUB_DOTCOM_API_ENDPOINT']
|
||||
const envHTMLURL = process.env['DESKTOP_GITHUB_DOTCOM_HTML_URL']
|
||||
const envAdditionalCookies =
|
||||
process.env['DESKTOP_GITHUB_DOTCOM_ADDITIONAL_COOKIES']
|
||||
|
||||
if (envAdditionalCookies !== undefined) {
|
||||
document.cookie += '; ' + envAdditionalCookies
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional set of configurable settings for the fetchAll method
|
||||
|
@ -1325,6 +1332,10 @@ export function getEndpointForRepository(url: string): string {
|
|||
* http://github.mycompany.com/api -> http://github.mycompany.com/
|
||||
*/
|
||||
export function getHTMLURL(endpoint: string): string {
|
||||
if (envHTMLURL !== undefined) {
|
||||
return envHTMLURL
|
||||
}
|
||||
|
||||
// In the case of GitHub.com, the HTML site lives on the parent domain.
|
||||
// E.g., https://api.github.com -> https://github.com
|
||||
//
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
Progress,
|
||||
ICheckoutProgress,
|
||||
ICloneProgress,
|
||||
ICherryPickProgress,
|
||||
} from '../models/progress'
|
||||
import { Popup } from '../models/popup'
|
||||
|
||||
|
@ -28,7 +29,7 @@ import { SignInState } from './stores/sign-in-store'
|
|||
import { WindowState } from './window-state'
|
||||
import { Shell } from './shells'
|
||||
|
||||
import { ApplicationTheme } from '../ui/lib/application-theme'
|
||||
import { ApplicableTheme, ApplicationTheme } from '../ui/lib/application-theme'
|
||||
import { IAccountRepositories } from './stores/api-repositories-store'
|
||||
import { ManualConflictResolution } from '../models/manual-conflict-resolution'
|
||||
import { Banner } from '../models/banner'
|
||||
|
@ -37,6 +38,8 @@ 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'
|
||||
import { DragElement } from '../models/drag-element'
|
||||
|
||||
export enum SelectionType {
|
||||
Repository,
|
||||
|
@ -109,6 +112,12 @@ export interface IAppState {
|
|||
readonly currentFoldout: Foldout | null
|
||||
readonly currentBanner: Banner | null
|
||||
|
||||
/**
|
||||
* The shape of the drag element rendered in the `app.renderDragElement`. It
|
||||
* is used in conjunction with the `Draggable` component.
|
||||
*/
|
||||
readonly currentDragElement: DragElement | null
|
||||
|
||||
/**
|
||||
* A list of currently open menus with their selected items
|
||||
* in the application menu.
|
||||
|
@ -211,11 +220,11 @@ export interface IAppState {
|
|||
/** The currently selected tab for the Branches foldout. */
|
||||
readonly selectedBranchesTab: BranchesTab
|
||||
|
||||
/** The currently selected appearance (aka theme) */
|
||||
/** The selected appearance (aka theme) preference */
|
||||
readonly selectedTheme: ApplicationTheme
|
||||
|
||||
/** Whether we should automatically change the currently selected appearance (aka theme) */
|
||||
readonly automaticallySwitchTheme: boolean
|
||||
/** The currently applied appearance (aka theme) */
|
||||
readonly currentTheme: ApplicableTheme
|
||||
|
||||
/**
|
||||
* A map keyed on a user account (GitHub.com or GitHub Enterprise)
|
||||
|
@ -249,6 +258,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 +365,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 +447,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 +542,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 +744,64 @@ 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
|
||||
|
||||
/**
|
||||
* Whether the target branch was created during cherry-pick operation
|
||||
*/
|
||||
readonly branchCreated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
|
|
|
@ -288,15 +288,17 @@ export class DiffParser {
|
|||
|
||||
const header = this.parseHunkHeader(headerLine)
|
||||
const lines = new Array<DiffLine>()
|
||||
lines.push(new DiffLine(headerLine, DiffLineType.Hunk, null, null))
|
||||
lines.push(new DiffLine(headerLine, DiffLineType.Hunk, 1, null, null))
|
||||
|
||||
let c: DiffLinePrefix | null
|
||||
|
||||
let rollingDiffBeforeCounter = header.oldStartLine
|
||||
let rollingDiffAfterCounter = header.newStartLine
|
||||
|
||||
let diffLineNumber = linesConsumed
|
||||
while ((c = this.parseLinePrefix(this.peek()))) {
|
||||
const line = this.readLine()
|
||||
diffLineNumber++
|
||||
|
||||
if (!line) {
|
||||
throw new Error('Expected unified diff line but reached end of diff')
|
||||
|
@ -329,6 +331,7 @@ export class DiffParser {
|
|||
diffLine = new DiffLine(
|
||||
line,
|
||||
DiffLineType.Add,
|
||||
diffLineNumber,
|
||||
null,
|
||||
rollingDiffAfterCounter++
|
||||
)
|
||||
|
@ -336,6 +339,7 @@ export class DiffParser {
|
|||
diffLine = new DiffLine(
|
||||
line,
|
||||
DiffLineType.Delete,
|
||||
diffLineNumber,
|
||||
rollingDiffBeforeCounter++,
|
||||
null
|
||||
)
|
||||
|
@ -343,6 +347,7 @@ export class DiffParser {
|
|||
diffLine = new DiffLine(
|
||||
line,
|
||||
DiffLineType.Context,
|
||||
diffLineNumber,
|
||||
rollingDiffBeforeCounter++,
|
||||
rollingDiffAfterCounter++
|
||||
)
|
||||
|
|
57
app/src/lib/drag-and-drop-manager.ts
Normal file
57
app/src/lib/drag-and-drop-manager.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Disposable, Emitter } from 'event-kit'
|
||||
|
||||
/**
|
||||
* The drag and drop manager is implemented to manage drag and drop events
|
||||
* that we want to track app wide without updating the enter app state.
|
||||
*
|
||||
* This was specifically implemented due to reduced performance during drag and
|
||||
* drop when updating app state variables to track drag element changes during a
|
||||
* drag event.
|
||||
*/
|
||||
export class DragAndDropManager {
|
||||
private _isDragInProgress: boolean = false
|
||||
|
||||
protected readonly emitter = new Emitter()
|
||||
|
||||
public get isDragInProgress(): boolean {
|
||||
return this._isDragInProgress
|
||||
}
|
||||
|
||||
public emitEnterDropTarget(targetDescription: string) {
|
||||
this.emitter.emit('enter-drop-target', targetDescription)
|
||||
}
|
||||
|
||||
public emitLeaveDropTarget() {
|
||||
this.emitter.emit('leave-drop-target', {})
|
||||
}
|
||||
|
||||
public onEnterDropTarget(
|
||||
fn: (targetDescription: string) => void
|
||||
): Disposable {
|
||||
return this.emitter.on('enter-drop-target', fn)
|
||||
}
|
||||
|
||||
public onLeaveDropTarget(fn: () => void): Disposable {
|
||||
return this.emitter.on('leave-drop-target', fn)
|
||||
}
|
||||
|
||||
public dragStarted(): void {
|
||||
this._isDragInProgress = true
|
||||
}
|
||||
|
||||
public dragEnded() {
|
||||
this._isDragInProgress = false
|
||||
}
|
||||
|
||||
public emitEnterDragZone(dropZoneDescription: string) {
|
||||
this.emitter.emit('enter-drop-zone', dropZoneDescription)
|
||||
}
|
||||
|
||||
public onEnterDragZone(
|
||||
fn: (dropZoneDescription: string) => void
|
||||
): Disposable {
|
||||
return this.emitter.on('enter-drop-zone', fn)
|
||||
}
|
||||
}
|
||||
|
||||
export const dragAndDropManager = new DragAndDropManager()
|
|
@ -63,6 +63,10 @@ const editors: IDarwinExternalEditor[] = [
|
|||
name: 'RubyMine',
|
||||
bundleIdentifiers: ['com.jetbrains.RubyMine'],
|
||||
},
|
||||
{
|
||||
name: 'RStudio',
|
||||
bundleIdentifiers: ['org.rstudio.RStudio'],
|
||||
},
|
||||
{
|
||||
name: 'TextMate',
|
||||
bundleIdentifiers: ['com.macromates.TextMate'],
|
||||
|
|
|
@ -270,6 +270,14 @@ const editors: IWindowsExternalEditor[] = [
|
|||
displayName.startsWith('JetBrains Rider') &&
|
||||
publisher === 'JetBrains s.r.o.',
|
||||
},
|
||||
{
|
||||
name: 'RStudio',
|
||||
registryKeys: [Wow64LocalMachineUninstallKey('RStudio')],
|
||||
executableShimPath: [],
|
||||
installLocationRegistryKey: 'DisplayIcon',
|
||||
expectedInstallationChecker: (displayName, publisher) =>
|
||||
displayName === 'RStudio' && publisher === 'RStudio',
|
||||
},
|
||||
]
|
||||
|
||||
function getKeyOrEmpty(
|
||||
|
|
|
@ -47,9 +47,14 @@ export function enableWSLDetection(): boolean {
|
|||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
/** Should the app show hide whitespace in changes tab */
|
||||
export function enableHideWhitespaceInDiffOption(): boolean {
|
||||
return enableDevelopmentFeatures()
|
||||
}
|
||||
|
||||
/** Should the app use the shiny new TCP-based trampoline? */
|
||||
export function enableDesktopTrampoline(): boolean {
|
||||
return enableBetaFeatures()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -153,5 +158,10 @@ export function enableUnhandledRejectionReporting(): boolean {
|
|||
* Should we allow cherry picking
|
||||
*/
|
||||
export function enableCherryPicking(): boolean {
|
||||
return false // enableBetaFeatures()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Should we allow expanding text diffs? */
|
||||
export function enableTextDiffExpansion(): boolean {
|
||||
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.
|
||||
*
|
||||
|
@ -75,7 +79,7 @@ class GitCherryPickParser {
|
|||
|
||||
return {
|
||||
kind: 'cherryPick',
|
||||
title: `Cherry picking commit ${this.count} of ${this.commits.length} commits`,
|
||||
title: `Cherry-picking commit ${this.count} of ${this.commits.length} commits`,
|
||||
value: round(this.count / this.commits.length, 2),
|
||||
cherryPickCommitCount: this.count,
|
||||
totalCommitCount: this.commits.length,
|
||||
|
@ -129,7 +133,10 @@ export async function cherryPick(
|
|||
progressCallback?: (progress: ICherryPickProgress) => void
|
||||
): Promise<CherryPickResult> {
|
||||
let baseOptions: IGitExecutionOptions = {
|
||||
expectedErrors: new Set([GitError.MergeConflicts]),
|
||||
expectedErrors: new Set([
|
||||
GitError.MergeConflicts,
|
||||
GitError.ConflictModifyDeletedInBranch,
|
||||
]),
|
||||
}
|
||||
|
||||
if (progressCallback !== undefined) {
|
||||
|
@ -146,10 +153,10 @@ export async function cherryPick(
|
|||
// revision range, so we need to signal to the caller that this cherry
|
||||
// pick is not possible to perform
|
||||
log.warn(
|
||||
`Unable to cherry pick these branches
|
||||
`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,10 +166,19 @@ 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.)
|
||||
//
|
||||
// -m 1 makes it so a merge commit always takes the first parent's history
|
||||
// (the branch you are cherry-picking from) for the commit. It also means
|
||||
// there could be multiple empty commits. I.E. If user does a range that
|
||||
// includes commits from that merge.
|
||||
const result = await git(
|
||||
['cherry-pick', revisionRange],
|
||||
['cherry-pick', revisionRange, '--keep-redundant-commits', '-m 1'],
|
||||
repository.path,
|
||||
'cherry pick',
|
||||
'cherry-pick',
|
||||
baseOptions
|
||||
)
|
||||
|
||||
|
@ -175,6 +191,7 @@ function parseCherryPickResult(result: IGitResult): CherryPickResult {
|
|||
}
|
||||
|
||||
switch (result.gitError) {
|
||||
case GitError.ConflictModifyDeletedInBranch:
|
||||
case GitError.MergeConflicts:
|
||||
return CherryPickResult.ConflictsEncountered
|
||||
case GitError.UnresolvedConflicts:
|
||||
|
@ -203,8 +220,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,16 +292,19 @@ export async function getCherryPickSnapshot(
|
|||
}
|
||||
|
||||
const count = commits.length - remainingShas.length
|
||||
const commitSummaryIndex = count > 0 ? count - 1 : 0
|
||||
return {
|
||||
progress: {
|
||||
kind: 'cherryPick',
|
||||
title: `Cherry picking commit ${count} of ${commits.length} commits`,
|
||||
title: `Cherry-picking commit ${count} of ${commits.length} commits`,
|
||||
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 +322,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,18 +351,18 @@ 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 = {
|
||||
expectedErrors: new Set([
|
||||
GitError.MergeConflicts,
|
||||
GitError.ConflictModifyDeletedInBranch,
|
||||
GitError.UnresolvedConflicts,
|
||||
]),
|
||||
env: {
|
||||
|
@ -341,9 +375,9 @@ export async function continueCherryPick(
|
|||
const snapshot = await getCherryPickSnapshot(repository)
|
||||
if (snapshot === null) {
|
||||
log.warn(
|
||||
`[continueCherryPick] unable to get cherry pick status, skipping other steps`
|
||||
`[continueCherryPick] unable to get cherry-pick status, skipping other steps`
|
||||
)
|
||||
return CherryPickResult.Aborted
|
||||
return CherryPickResult.UnableToStart
|
||||
}
|
||||
options = configureOptionsWithCallBack(
|
||||
options,
|
||||
|
@ -352,8 +386,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 +427,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`,
|
||||
so it is unsafe to continue cherry-picking`,
|
||||
err
|
||||
)
|
||||
return null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -135,7 +135,7 @@ export async function setConfigValue(
|
|||
HOME: string
|
||||
}
|
||||
): Promise<void> {
|
||||
setConfigValueInPath(name, value, repository.path, env)
|
||||
return setConfigValueInPath(name, value, repository.path, env)
|
||||
}
|
||||
|
||||
/** Set the global config value by name. */
|
||||
|
@ -146,7 +146,7 @@ export async function setGlobalConfigValue(
|
|||
HOME: string
|
||||
}
|
||||
): Promise<void> {
|
||||
await setConfigValueInPath(name, value, null, env)
|
||||
return setConfigValueInPath(name, value, null, env)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -186,7 +186,7 @@ export async function removeConfigValue(
|
|||
HOME: string
|
||||
}
|
||||
): Promise<void> {
|
||||
removeConfigValueInPath(name, repository.path, env)
|
||||
return removeConfigValueInPath(name, repository.path, env)
|
||||
}
|
||||
|
||||
/** Remove the global config value by name. */
|
||||
|
@ -196,7 +196,7 @@ export async function removeGlobalConfigValue(
|
|||
HOME: string
|
||||
}
|
||||
): Promise<void> {
|
||||
removeConfigValueInPath(name, null, env)
|
||||
return removeConfigValueInPath(name, null, env)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -158,7 +158,7 @@ export async function git(
|
|||
// from a terminal or if the system environment variables
|
||||
// have TERM set Git won't consider us as a smart terminal.
|
||||
// See https://github.com/git/git/blob/a7312d1a2/editor.c#L11-L15
|
||||
opts.env = { TERM: 'dumb', ...opts.env }
|
||||
opts.env = { TERM: 'dumb', ...opts.env } as Object
|
||||
|
||||
const commandName = `${name}: git ${args.join(' ')}`
|
||||
|
||||
|
@ -408,6 +408,9 @@ function getDescriptionForError(error: DugiteError): string | null {
|
|||
return 'A tag with that name already exists'
|
||||
case DugiteError.MergeWithLocalChanges:
|
||||
case DugiteError.RebaseWithLocalChanges:
|
||||
case DugiteError.GPGFailedToSignData:
|
||||
case DugiteError.ConflictModifyDeletedInBranch:
|
||||
case DugiteError.MergeCommitNoMainlineOption:
|
||||
return null
|
||||
default:
|
||||
return assertNever(error, `Unknown error: ${error}`)
|
||||
|
|
|
@ -87,6 +87,7 @@ const imageFileExtensions = new Set([
|
|||
'.ico',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.avif',
|
||||
])
|
||||
|
||||
/**
|
||||
|
@ -138,11 +139,19 @@ export async function getCommitDiff(
|
|||
*/
|
||||
export async function getWorkingDirectoryDiff(
|
||||
repository: Repository,
|
||||
file: WorkingDirectoryFileChange
|
||||
file: WorkingDirectoryFileChange,
|
||||
hideWhitespaceInDiff: boolean = false
|
||||
): Promise<IDiff> {
|
||||
// `--no-ext-diff` should be provided wherever we invoke `git diff` so that any
|
||||
// diff.external program configured by the user is ignored
|
||||
const args = ['diff', '--no-ext-diff', '--patch-with-raw', '-z', '--no-color']
|
||||
const args = [
|
||||
'diff',
|
||||
...(hideWhitespaceInDiff ? ['-w'] : []),
|
||||
'--no-ext-diff',
|
||||
'--patch-with-raw',
|
||||
'-z',
|
||||
'--no-color',
|
||||
]
|
||||
const successExitCodes = new Set([0])
|
||||
|
||||
if (
|
||||
|
@ -152,7 +161,7 @@ export async function getWorkingDirectoryDiff(
|
|||
// `git diff --no-index` seems to emulate the exit codes from `diff` irrespective of
|
||||
// whether you set --exit-code
|
||||
//
|
||||
// this is the behaviour:
|
||||
// this is the behavior:
|
||||
// - 0 if no changes found
|
||||
// - 1 if changes found
|
||||
// - and error otherwise
|
||||
|
@ -299,6 +308,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'
|
||||
|
|
|
@ -234,7 +234,7 @@ export async function getRebaseSnapshot(
|
|||
const hasValidCommit =
|
||||
commits.length > 0 &&
|
||||
nextCommitIndex >= 0 &&
|
||||
nextCommitIndex <= commits.length
|
||||
nextCommitIndex < commits.length
|
||||
|
||||
const currentCommitSummary = hasValidCommit
|
||||
? commits[nextCommitIndex].summary
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
21
app/src/lib/is-account-email.ts
Normal file
21
app/src/lib/is-account-email.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Checks if a given email address is included (case-insensitively) among the
|
||||
* email addresses belonging to one or more accounts.
|
||||
*
|
||||
* Note: this check must be used only to decide whether or not when to warn the
|
||||
* user about the chance of getting misattributed commits, but not to
|
||||
* override a different but equivalent email address that the user entered
|
||||
* on purpose. For example, the user's account might have an address like
|
||||
* My.Email@domain.com, but they'd rather use my.email@domain in their git
|
||||
* commits.
|
||||
*
|
||||
* @param accountEmails Email addresses belonging to user accounts.
|
||||
* @param email Email address to validate.
|
||||
*/
|
||||
export function isAccountEmail(
|
||||
accountEmails: ReadonlyArray<string>,
|
||||
email: string
|
||||
) {
|
||||
const lowercaseAccountEmails = accountEmails.map(email => email.toLowerCase())
|
||||
return lowercaseAccountEmails.includes(email.toLowerCase())
|
||||
}
|
178
app/src/lib/mouse-scroller.ts
Normal file
178
app/src/lib/mouse-scroller.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* The mouse scroller was built in conjunction with the drag functionality.
|
||||
* Its purpose is to provide the ability to scroll a scrollable element when
|
||||
* the mouse gets close to the scrollable elements edge.
|
||||
*
|
||||
* Thus, it is built on the premise that we are providing it a scrollable
|
||||
* element and will continually provide it the mouse's position.
|
||||
* (which is tracked as part of drag event)
|
||||
*
|
||||
* Note: This implementation only accounts for vertical scrolling, but
|
||||
* horizontal scrolling would just be a matter of the same logic for left and
|
||||
* right bounds.
|
||||
*/
|
||||
class MouseScroller {
|
||||
private scrollTimer: number | undefined
|
||||
private defaultScrollEdge = 30
|
||||
private scrollSpeed = 5
|
||||
|
||||
/**
|
||||
* If provided element or a parent of that element is scrollable, it starts
|
||||
* scrolling based on the mouse's position.
|
||||
*/
|
||||
public setupMouseScroll(element: Element, mouseY: number) {
|
||||
const scrollable = this.getClosestScrollElement(element)
|
||||
if (scrollable === null) {
|
||||
this.clearScrollTimer()
|
||||
return
|
||||
}
|
||||
|
||||
this.updateMouseScroll(scrollable, mouseY)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scrolling action is wrapped in a continual time out, it will
|
||||
* continue to scroll until it reaches the end of the scroll area.
|
||||
*/
|
||||
private updateMouseScroll(scrollable: Element, mouseY: number) {
|
||||
window.clearTimeout(this.scrollTimer)
|
||||
|
||||
if (this.scrollVerticallyOnMouseNearEdge(scrollable, mouseY)) {
|
||||
this.scrollTimer = window.setTimeout(() => {
|
||||
this.updateMouseScroll(scrollable, mouseY)
|
||||
}, 30)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleat the scroller's timeout.
|
||||
*/
|
||||
public clearScrollTimer() {
|
||||
window.clearTimeout(this.scrollTimer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to scroll elements based on the mouse position. If the user moves
|
||||
* their mouse near to the edge of the scrollable container, then, we want to
|
||||
* invoke the scroll.
|
||||
*
|
||||
* Returns false if mouse is not positioned near the edge of the scroll area
|
||||
* or the the scroll position is already at end of scroll area.
|
||||
*/
|
||||
private scrollVerticallyOnMouseNearEdge(
|
||||
scrollable: Element,
|
||||
mouseY: number
|
||||
): boolean {
|
||||
// how far away from the edge of container to invoke scroll
|
||||
const { top, bottom } = scrollable.getBoundingClientRect()
|
||||
const distanceFromBottom = bottom - mouseY
|
||||
const distanceFromTop = mouseY - top
|
||||
|
||||
if (distanceFromBottom > 0 && distanceFromBottom < this.defaultScrollEdge) {
|
||||
const scrollDistance = this.getScrollDistance(distanceFromBottom)
|
||||
return this.scrollDown(scrollable, scrollDistance)
|
||||
}
|
||||
|
||||
if (distanceFromTop > 0 && distanceFromTop < this.defaultScrollEdge) {
|
||||
const scrollDistance = this.getScrollDistance(distanceFromTop)
|
||||
return this.scrollUp(scrollable, scrollDistance)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the scroll amount (which in turn is scroll speed). It uses the
|
||||
* distance from the scroll edge to get faster as the user moves their mouse
|
||||
* closer to the edge.
|
||||
*/
|
||||
private getScrollDistance(distanceFromScrollEdge: number) {
|
||||
const intensity = this.defaultScrollEdge / distanceFromScrollEdge
|
||||
return this.scrollSpeed * intensity
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls an element up by given scroll distance.
|
||||
* Returns false if already at top limit else true.
|
||||
*/
|
||||
private scrollUp(scrollable: Element, scrollDistance: number): boolean {
|
||||
const limit = 0
|
||||
if (scrollable.scrollTop <= limit) {
|
||||
return false
|
||||
}
|
||||
|
||||
const inBounds = scrollable.scrollTop > scrollDistance
|
||||
const scrollTo = inBounds ? scrollable.scrollTop - scrollDistance : limit
|
||||
scrollable.scrollTo({ top: scrollTo })
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls an element up by given scroll distance.
|
||||
* Returns false if already at bottom limit else true.
|
||||
*/
|
||||
private scrollDown(scrollable: Element, scrollDistance: number): boolean {
|
||||
const limit = scrollable.scrollHeight - scrollable.clientHeight
|
||||
if (scrollable.scrollTop >= limit) {
|
||||
return false
|
||||
}
|
||||
|
||||
const inBounds = scrollable.scrollTop + scrollDistance < limit
|
||||
const scrollTo = inBounds ? scrollable.scrollTop + scrollDistance : limit
|
||||
scrollable.scrollTo({ top: scrollTo })
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to determine if an element is scrollable if not finds the closest
|
||||
* parent that is scrollable or returns null.
|
||||
*/
|
||||
private getClosestScrollElement(element: Element): Element | null {
|
||||
const { position: elemPosition } = getComputedStyle(element)
|
||||
|
||||
if (elemPosition === 'fixed') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.isScrollable(element)) {
|
||||
return element
|
||||
}
|
||||
|
||||
let parent: Element | null
|
||||
for (parent = element; (parent = parent.parentElement); ) {
|
||||
const { position: parentPosition } = getComputedStyle(parent)
|
||||
|
||||
// exclude static parents
|
||||
if (elemPosition === 'absolute' && parentPosition === 'static') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.isScrollable(parent) && this.hasScrollableContent(parent)) {
|
||||
return parent
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if element is scrollable based on elements styles.
|
||||
*/
|
||||
private isScrollable(element: Element): boolean {
|
||||
const style = getComputedStyle(element)
|
||||
const overflowRegex = /(auto|scroll)/
|
||||
return overflowRegex.test(
|
||||
style.overflow + style.overflowY + style.overflowX
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if there is content overflow that could be handled by a
|
||||
* scrollbar
|
||||
*/
|
||||
private hasScrollableContent(scrollable: Element): boolean {
|
||||
return scrollable.clientHeight < scrollable.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
export const mouseScroller = new MouseScroller()
|
|
@ -1,7 +1,3 @@
|
|||
export { StatsDatabase, ILaunchStats } from './stats-database'
|
||||
export { StatsStore, IStatsStore, SamplesURL } from './stats-store'
|
||||
export { getGUID } from './get-guid'
|
||||
export {
|
||||
hasSeenUsageStatsNote,
|
||||
markUsageStatsNoteSeen,
|
||||
} from './usage-stats-change'
|
||||
|
|
|
@ -355,6 +355,33 @@ export interface IDailyMeasures {
|
|||
|
||||
/** Number of times the user has encountered an unhandled rejection */
|
||||
readonly unhandledRejectionCount: number
|
||||
|
||||
/** The number of times a successful cherry pick occurs */
|
||||
readonly cherryPickSuccessfulCount: number
|
||||
|
||||
/** The number of times a cherry pick is initiated through drag and drop */
|
||||
readonly cherryPickViaDragAndDropCount: number
|
||||
|
||||
/** The number of times a cherry pick is initiated through the context menu */
|
||||
readonly cherryPickViaContextMenuCount: number
|
||||
|
||||
/** The number of times a cherry pick drag was started and canceled */
|
||||
readonly cherryPickDragStartedAndCanceledCount: number
|
||||
|
||||
/** The number of times conflicts encountered during a cherry pick */
|
||||
readonly cherryPickConflictsEncounteredCount: number
|
||||
|
||||
/** The number of times cherry pick ended successfully after conflicts */
|
||||
readonly cherryPickSuccessfulWithConflictsCount: number
|
||||
|
||||
/** The number of times cherry pick of multiple commits initiated */
|
||||
readonly cherryPickMultipleCommitsCount: number
|
||||
|
||||
/** The number of times a cherry pick was undone */
|
||||
readonly cherryPickUndoneCount: number
|
||||
|
||||
/** The number of times a branch was created during a cherry-pick */
|
||||
readonly cherryPickBranchCreatedCount: number
|
||||
}
|
||||
|
||||
export class StatsDatabase extends Dexie {
|
||||
|
|
|
@ -140,6 +140,15 @@ const DefaultDailyMeasures: IDailyMeasures = {
|
|||
diffOptionsViewedCount: 0,
|
||||
repositoryViewChangeCount: 0,
|
||||
unhandledRejectionCount: 0,
|
||||
cherryPickSuccessfulCount: 0,
|
||||
cherryPickViaDragAndDropCount: 0,
|
||||
cherryPickViaContextMenuCount: 0,
|
||||
cherryPickDragStartedAndCanceledCount: 0,
|
||||
cherryPickConflictsEncounteredCount: 0,
|
||||
cherryPickSuccessfulWithConflictsCount: 0,
|
||||
cherryPickMultipleCommitsCount: 0,
|
||||
cherryPickUndoneCount: 0,
|
||||
cherryPickBranchCreatedCount: 0,
|
||||
}
|
||||
|
||||
interface IOnboardingStats {
|
||||
|
@ -245,20 +254,6 @@ interface IOnboardingStats {
|
|||
readonly welcomeWizardSignInMethod?: 'basic' | 'web'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account id of the current user's GitHub.com account or null if the user
|
||||
* is not currently signed in to GitHub.com.
|
||||
*
|
||||
* @param accounts The active accounts stored in Desktop
|
||||
*/
|
||||
function findDotComAccountId(accounts: ReadonlyArray<Account>): number | null {
|
||||
const gitHubAccount = accounts.find(
|
||||
a => a.endpoint === getDotComAPIEndpoint()
|
||||
)
|
||||
|
||||
return gitHubAccount !== undefined ? gitHubAccount.id : null
|
||||
}
|
||||
|
||||
interface ICalculatedStats {
|
||||
/** The app version. */
|
||||
readonly version: string
|
||||
|
@ -406,10 +401,7 @@ export class StatsStore implements IStatsStore {
|
|||
}
|
||||
|
||||
const now = Date.now()
|
||||
const stats = await this.getDailyStats(accounts, repositories)
|
||||
|
||||
const user_id = findDotComAccountId(accounts)
|
||||
const payload = user_id === null ? stats : { ...stats, user_id }
|
||||
const payload = await this.getDailyStats(accounts, repositories)
|
||||
|
||||
try {
|
||||
const response = await this.post(payload)
|
||||
|
@ -1387,6 +1379,63 @@ export class StatsStore implements IStatsStore {
|
|||
}))
|
||||
}
|
||||
|
||||
public recordCherryPickSuccessful(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
cherryPickSuccessfulCount: m.cherryPickSuccessfulCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
public recordCherryPickViaDragAndDrop(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
cherryPickViaDragAndDropCount: m.cherryPickViaDragAndDropCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
public recordCherryPickViaContextMenu(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
cherryPickViaContextMenuCount: m.cherryPickViaContextMenuCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
public recordCherryPickDragStartedAndCanceled(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
cherryPickDragStartedAndCanceledCount:
|
||||
m.cherryPickDragStartedAndCanceledCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
public recordCherryPickConflictsEncountered(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
cherryPickConflictsEncounteredCount:
|
||||
m.cherryPickConflictsEncounteredCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
public recordCherryPickSuccessfulWithConflicts(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
cherryPickSuccessfulWithConflictsCount:
|
||||
m.cherryPickSuccessfulWithConflictsCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
public recordCherryPickMultipleCommits(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
cherryPickMultipleCommitsCount: m.cherryPickMultipleCommitsCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
public recordCherryPickUndone(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
cherryPickUndoneCount: m.cherryPickUndoneCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
public recordCherryPickBranchCreatedCount(): Promise<void> {
|
||||
return this.updateDailyMeasures(m => ({
|
||||
cherryPickBranchCreatedCount: m.cherryPickBranchCreatedCount + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Post some data to our stats endpoint. */
|
||||
private post(body: object): Promise<Response> {
|
||||
const options: RequestInit = {
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import { getBoolean, setBoolean } from '../local-storage'
|
||||
|
||||
/** The `localStorage` for whether we've shown the usage stats change notice. */
|
||||
const HasSeenUsageStatsNoteKey = 'has-seen-usage-stats-note'
|
||||
|
||||
/**
|
||||
* Check if the current user has acknowledged the usage stats change
|
||||
*/
|
||||
export function hasSeenUsageStatsNote(): boolean {
|
||||
return getBoolean(HasSeenUsageStatsNoteKey, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update local storage to indicate the usage stats dialog has been seen
|
||||
*/
|
||||
export function markUsageStatsNoteSeen() {
|
||||
setBoolean(HasSeenUsageStatsNoteKey, true)
|
||||
}
|
|
@ -127,7 +127,8 @@ export function getLabelForManualResolutionOption(
|
|||
case GitStatusEntry.UpdatedButUnmerged:
|
||||
return `Use the modified file${suffix}`
|
||||
case GitStatusEntry.Deleted:
|
||||
return `Use the deleted file${suffix}`
|
||||
const deleteSuffix = branch ? ` on ${branch}` : ''
|
||||
return `Do not include this file${deleteSuffix}`
|
||||
default:
|
||||
return assertNever(entry, 'Unknown status entry to format')
|
||||
}
|
||||
|
|
|
@ -60,17 +60,18 @@ import {
|
|||
IFetchProgress,
|
||||
IRevertProgress,
|
||||
IRebaseProgress,
|
||||
ICherryPickProgress,
|
||||
} from '../../models/progress'
|
||||
import { Popup, PopupType } from '../../models/popup'
|
||||
import { IGitAccount } from '../../models/git-account'
|
||||
import { themeChangeMonitor } from '../../ui/lib/theme-change-monitor'
|
||||
import { getAppPath } from '../../ui/lib/app-proxy'
|
||||
import {
|
||||
ApplicableTheme,
|
||||
ApplicationTheme,
|
||||
getCurrentlyAppliedTheme,
|
||||
getPersistedTheme,
|
||||
setPersistedTheme,
|
||||
getAutoSwitchPersistedTheme,
|
||||
setAutoSwitchPersistedTheme,
|
||||
} from '../../ui/lib/application-theme'
|
||||
import {
|
||||
getAppMenu,
|
||||
|
@ -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,
|
||||
|
@ -178,12 +185,7 @@ import {
|
|||
parse as parseShell,
|
||||
Shell,
|
||||
} from '../shells'
|
||||
import {
|
||||
ILaunchStats,
|
||||
StatsStore,
|
||||
markUsageStatsNoteSeen,
|
||||
hasSeenUsageStatsNote,
|
||||
} from '../stats'
|
||||
import { ILaunchStats, StatsStore } from '../stats'
|
||||
import { hasShownWelcomeFlow, markWelcomeFlowComplete } from '../welcome'
|
||||
import {
|
||||
getWindowState,
|
||||
|
@ -217,10 +219,12 @@ import {
|
|||
} from './updates/changes-state'
|
||||
import { ManualConflictResolution } from '../../models/manual-conflict-resolution'
|
||||
import { BranchPruner } from './helpers/branch-pruner'
|
||||
import { enableUpdateRemoteUrl } from '../feature-flag'
|
||||
import {
|
||||
enableHideWhitespaceInDiffOption,
|
||||
enableUpdateRemoteUrl,
|
||||
} from '../feature-flag'
|
||||
import { Banner, BannerType } from '../../models/banner'
|
||||
import moment from 'moment'
|
||||
import { isDarkModeEnabled } from '../../ui/lib/dark-theme'
|
||||
import { ComputedAction } from '../../models/computed-action'
|
||||
import {
|
||||
createDesktopStashEntry,
|
||||
|
@ -266,6 +270,19 @@ 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'
|
||||
import { DragElement } from '../../models/drag-element'
|
||||
|
||||
const LastSelectedRepositoryIDKey = 'last-selected-repository-id'
|
||||
|
||||
|
@ -321,6 +338,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
|
||||
|
||||
|
@ -410,8 +429,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
private selectedCloneRepositoryTab = CloneRepositoryTab.DotCom
|
||||
|
||||
private selectedBranchesTab = BranchesTab.Branches
|
||||
private selectedTheme = ApplicationTheme.Light
|
||||
private automaticallySwitchTheme = false
|
||||
private selectedTheme = ApplicationTheme.System
|
||||
private currentTheme: ApplicableTheme = ApplicationTheme.Light
|
||||
|
||||
private hasUserViewedStash = false
|
||||
|
||||
|
@ -421,6 +440,13 @@ 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
|
||||
|
||||
private currentDragElement: DragElement | null = null
|
||||
|
||||
public constructor(
|
||||
private readonly gitHubUserStore: GitHubUserStore,
|
||||
private readonly cloningRepositoriesStore: CloningRepositoriesStore,
|
||||
|
@ -761,12 +787,14 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
selectedCloneRepositoryTab: this.selectedCloneRepositoryTab,
|
||||
selectedBranchesTab: this.selectedBranchesTab,
|
||||
selectedTheme: this.selectedTheme,
|
||||
automaticallySwitchTheme: this.automaticallySwitchTheme,
|
||||
currentTheme: this.currentTheme,
|
||||
apiRepositories: this.apiRepositoriesStore.getState(),
|
||||
optOutOfUsageTracking: this.statsStore.getOptOut(),
|
||||
currentOnboardingTutorialStep: this.currentOnboardingTutorialStep,
|
||||
repositoryIndicatorsEnabled: this.repositoryIndicatorsEnabled,
|
||||
commitSpellcheckEnabled: this.commitSpellcheckEnabled,
|
||||
hasShownCherryPickIntro: this.hasShownCherryPickIntro,
|
||||
currentDragElement: this.currentDragElement,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -935,7 +963,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 +973,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 +999,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 +1013,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 +1270,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 +1287,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 +1335,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 +1347,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) {
|
||||
|
@ -1694,24 +1735,19 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
)
|
||||
this.showSideBySideDiff = getShowSideBySideDiff()
|
||||
|
||||
this.automaticallySwitchTheme = getAutoSwitchPersistedTheme()
|
||||
this.selectedTheme = getPersistedTheme()
|
||||
// Make sure the persisted theme is applied
|
||||
setPersistedTheme(this.selectedTheme)
|
||||
|
||||
if (this.automaticallySwitchTheme) {
|
||||
this.selectedTheme = isDarkModeEnabled()
|
||||
? ApplicationTheme.Dark
|
||||
: ApplicationTheme.Light
|
||||
setPersistedTheme(this.selectedTheme)
|
||||
} else {
|
||||
this.selectedTheme = getPersistedTheme()
|
||||
}
|
||||
this.currentTheme = getCurrentlyAppliedTheme()
|
||||
|
||||
themeChangeMonitor.onThemeChanged(theme => {
|
||||
if (this.automaticallySwitchTheme) {
|
||||
this.selectedTheme = theme
|
||||
this.emitUpdate()
|
||||
}
|
||||
this.currentTheme = theme
|
||||
this.emitUpdate()
|
||||
})
|
||||
|
||||
this.hasShownCherryPickIntro = getBoolean(hasShownCherryPickIntroKey, false)
|
||||
|
||||
this.emitUpdateNow()
|
||||
|
||||
this.accountsStore.refresh()
|
||||
|
@ -1881,6 +1917,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}))
|
||||
|
||||
this.updateRebaseFlowConflictsIfFound(repository)
|
||||
this.updateCherryPickFlowConflictsIfFound(repository)
|
||||
|
||||
if (this.selectedRepository === repository) {
|
||||
this._triggerConflictsFlow(repository)
|
||||
|
@ -1902,7 +1939,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
)
|
||||
const { conflictState } = changesState
|
||||
|
||||
if (conflictState === null || isMergeConflictState(conflictState)) {
|
||||
if (conflictState === null || !isRebaseConflictState(conflictState)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1929,6 +1966,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 +2001,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`)
|
||||
}
|
||||
|
@ -2132,7 +2197,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
|
||||
const diff = await getWorkingDirectoryDiff(
|
||||
repository,
|
||||
selectedFileBeforeLoad
|
||||
selectedFileBeforeLoad,
|
||||
enableHideWhitespaceInDiffOption() && this.hideWhitespaceInDiff
|
||||
)
|
||||
|
||||
const stateAfterLoad = this.repositoryStateCache.get(repository)
|
||||
|
@ -2943,14 +3009,17 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
repository: Repository,
|
||||
name: string,
|
||||
startPoint: string | null,
|
||||
noTrackOption: boolean = false
|
||||
): Promise<void> {
|
||||
noTrackOption: boolean = false,
|
||||
checkoutBranch: boolean = true
|
||||
): Promise<Branch | undefined> {
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
const branch = await gitStore.createBranch(name, startPoint, noTrackOption)
|
||||
|
||||
if (branch !== undefined) {
|
||||
if (branch !== undefined && checkoutBranch) {
|
||||
await this._checkoutBranch(repository, branch)
|
||||
}
|
||||
|
||||
return branch
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
|
@ -3346,7 +3415,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
public async _deleteBranch(
|
||||
repository: Repository,
|
||||
branch: Branch,
|
||||
includeUpstream?: boolean
|
||||
includeUpstream?: boolean,
|
||||
toCheckout?: Branch | null
|
||||
): Promise<void> {
|
||||
return this.withAuthenticatingUser(repository, async (r, account) => {
|
||||
const gitStore = this.gitStoreCache.get(r)
|
||||
|
@ -3376,7 +3446,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
|
||||
// If a local branch, user may have the branch to delete checked out and
|
||||
// we need to switch to a different branch (default or recent).
|
||||
const branchToCheckout = this.getBranchToCheckoutAfterDelete(branch, r)
|
||||
const branchToCheckout =
|
||||
toCheckout ?? this.getBranchToCheckoutAfterDelete(branch, r)
|
||||
|
||||
if (branchToCheckout !== null) {
|
||||
await gitStore.performFailableOperation(() =>
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -4507,10 +4581,6 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
this.emitUpdate()
|
||||
}
|
||||
|
||||
public markUsageStatsNoteSeen() {
|
||||
markUsageStatsNoteSeen()
|
||||
}
|
||||
|
||||
public _setConfirmRepositoryRemovalSetting(
|
||||
confirmRepoRemoval: boolean
|
||||
): Promise<void> {
|
||||
|
@ -4582,7 +4652,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public _setHideWhitespaceInDiff(
|
||||
public async _setHideWhitespaceInDiff(
|
||||
hideWhitespaceInDiff: boolean,
|
||||
repository: Repository,
|
||||
file: CommittedFileChange | null
|
||||
|
@ -4590,6 +4660,13 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
setBoolean(hideWhitespaceInDiffKey, hideWhitespaceInDiff)
|
||||
this.hideWhitespaceInDiff = hideWhitespaceInDiff
|
||||
|
||||
if (enableHideWhitespaceInDiffOption()) {
|
||||
await this.refreshChangesSection(repository, {
|
||||
includingStatus: true,
|
||||
clearPartialState: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (file === null) {
|
||||
return this.updateChangesWorkingDirectoryDiff(repository)
|
||||
} else {
|
||||
|
@ -4633,12 +4710,6 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
}
|
||||
|
||||
public _reportStats() {
|
||||
// ensure the user has seen and acknowledged the current usage stats setting
|
||||
if (!this.showWelcomeFlow && !hasSeenUsageStatsNote()) {
|
||||
this._showPopup({ type: PopupType.UsageReportingChanges })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return this.statsStore.reportStats(this.accounts, this.repositories)
|
||||
}
|
||||
|
||||
|
@ -5300,6 +5371,26 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
headCloneUrl: string,
|
||||
headRefName: string
|
||||
): Promise<void> {
|
||||
const prBranch = await this._findPullRequestBranch(
|
||||
repository,
|
||||
prNumber,
|
||||
headRepoOwner,
|
||||
headCloneUrl,
|
||||
headRefName
|
||||
)
|
||||
if (prBranch !== undefined) {
|
||||
await this._checkoutBranch(repository, prBranch)
|
||||
this.statsStore.recordPRBranchCheckout()
|
||||
}
|
||||
}
|
||||
|
||||
public async _findPullRequestBranch(
|
||||
repository: RepositoryWithGitHubRepository,
|
||||
prNumber: number,
|
||||
headRepoOwner: string,
|
||||
headCloneUrl: string,
|
||||
headRefName: string
|
||||
): Promise<Branch | undefined> {
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
const remotes = await getRemotes(repository)
|
||||
|
||||
|
@ -5314,7 +5405,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
remote = await addRemote(repository, forkRemoteName, headCloneUrl)
|
||||
} catch (e) {
|
||||
this.emitError(
|
||||
new Error(`Couldn't checkout PR, adding remote failed: ${e.message}`)
|
||||
new Error(
|
||||
`Couldn't find PR branch, adding remote failed: ${e.message}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
@ -5329,9 +5422,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
|
||||
// If we found one, let's check it out and get out of here, quick
|
||||
if (existingBranch !== undefined) {
|
||||
await this._checkoutBranch(repository, existingBranch)
|
||||
this.statsStore.recordPRBranchCheckout()
|
||||
return
|
||||
return existingBranch
|
||||
}
|
||||
|
||||
const findRemoteBranch = (name: string) =>
|
||||
|
@ -5342,7 +5433,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
// No such luck, let's see if we can at least find the remote branch then
|
||||
existingBranch = findRemoteBranch(remoteRef)
|
||||
|
||||
// If quite possible that the PR was created after our last fetch of the
|
||||
// It's quite possible that the PR was created after our last fetch of the
|
||||
// remote so let's fetch it and then try again.
|
||||
if (existingBranch === undefined) {
|
||||
try {
|
||||
|
@ -5372,12 +5463,15 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
remote.name !== gitStore.upstreamRemote?.name
|
||||
|
||||
if (isForkRemote) {
|
||||
await this._createBranch(repository, `pr/${prNumber}`, remoteRef)
|
||||
} else {
|
||||
await this._checkoutBranch(repository, existingBranch)
|
||||
return await this._createBranch(
|
||||
repository,
|
||||
`pr/${prNumber}`,
|
||||
remoteRef,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
this.statsStore.recordPRBranchCheckout()
|
||||
return existingBranch
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -5417,17 +5511,6 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the application-wide theme
|
||||
*/
|
||||
public _setAutomaticallySwitchTheme(automaticallySwitchTheme: boolean) {
|
||||
setAutoSwitchPersistedTheme(automaticallySwitchTheme)
|
||||
this.automaticallySwitchTheme = automaticallySwitchTheme
|
||||
this.emitUpdate()
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public async _resolveCurrentEditor() {
|
||||
const match = await findEditorOrDefault(this.selectedExternalEditor)
|
||||
const resolvedExternalEditor = match != null ? match.editor : null
|
||||
|
@ -5480,8 +5563,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 +5590,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 +5798,382 @@ 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,
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
): Promise<CherryPickResult> {
|
||||
if (commits.length === 0) {
|
||||
log.error('[_cherryPick] - Unable to cherry-pick. No commits provided.')
|
||||
return CherryPickResult.UnableToStart
|
||||
}
|
||||
|
||||
await this._refreshRepository(repository)
|
||||
|
||||
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)
|
||||
const result = await gitStore.performFailableOperation(() =>
|
||||
cherryPick(repository, revisionRange, progressCallback)
|
||||
)
|
||||
|
||||
return result || CherryPickResult.Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for uncommitted changes
|
||||
*
|
||||
* If uncommitted changes exist, ask user to stash, retry provided retry
|
||||
* action and return true.
|
||||
*
|
||||
* If no uncommitted changes, return false.
|
||||
*
|
||||
* This shouldn't be called directly. See `Dispatcher`.
|
||||
*/
|
||||
public _checkForUncommittedChanges(
|
||||
repository: Repository,
|
||||
retryAction: RetryAction
|
||||
): boolean {
|
||||
const { changesState } = this.repositoryStateCache.get(repository)
|
||||
const hasChanges = changesState.workingDirectory.files.length > 0
|
||||
if (!hasChanges) {
|
||||
return false
|
||||
}
|
||||
|
||||
this._showPopup({
|
||||
type: PopupType.LocalChangesOverwritten,
|
||||
repository,
|
||||
retryAction,
|
||||
files: changesState.workingDirectory.files.map(f => f.path),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to checkout target branch and return it's name after checkout.
|
||||
* This is useful if you want the local name when checking out a potentially
|
||||
* remote branch during an operation.
|
||||
*
|
||||
* Note: This does not do any existing changes checking like _checkout does.
|
||||
*
|
||||
* This shouldn't be called directly. See `Dispatcher`.
|
||||
*/
|
||||
public async _checkoutBranchReturnName(
|
||||
repository: Repository,
|
||||
targetBranch: Branch
|
||||
): Promise<string | undefined> {
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
|
||||
const checkoutSuccessful = await this.withAuthenticatingUser(
|
||||
repository,
|
||||
(r, account) => {
|
||||
return gitStore.performFailableOperation(() =>
|
||||
checkoutBranch(repository, account, targetBranch)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (checkoutSuccessful !== true) {
|
||||
return
|
||||
}
|
||||
|
||||
const status = await gitStore.loadStatus()
|
||||
return status?.currentBranch
|
||||
}
|
||||
|
||||
/** 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))
|
||||
|
||||
await 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 _setCherryPickBranchCreated(
|
||||
repository: Repository,
|
||||
branchCreated: boolean
|
||||
): 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, () => ({
|
||||
branchCreated,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 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.error(
|
||||
`[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))
|
||||
|
||||
await 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<boolean> {
|
||||
const { branchesState } = this.repositoryStateCache.get(repository)
|
||||
const { tip } = branchesState
|
||||
if (tip.kind !== TipState.Valid || tip.branch.name !== targetBranchName) {
|
||||
log.error(
|
||||
'[undoCherryPick] - Could not undo cherry-pick. User no longer on target branch.'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const {
|
||||
cherryPickState: { targetBranchUndoSha, branchCreated },
|
||||
} = this.repositoryStateCache.get(repository)
|
||||
|
||||
// If a new branch is created as part of the cherry-pick,
|
||||
// We just want to delete it, no need to reset it.
|
||||
if (branchCreated) {
|
||||
this._deleteBranch(repository, tip.branch, false, sourceBranch)
|
||||
return true
|
||||
}
|
||||
|
||||
if (targetBranchUndoSha === null) {
|
||||
log.error('[undoCherryPick] - Could not determine target branch undo sha')
|
||||
return false
|
||||
}
|
||||
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
const result = await gitStore.performFailableOperation(() =>
|
||||
reset(repository, GitResetMode.Hard, targetBranchUndoSha)
|
||||
)
|
||||
|
||||
if (result !== true) {
|
||||
return false
|
||||
}
|
||||
|
||||
await this.checkoutBranchIfNotNull(repository, sourceBranch)
|
||||
|
||||
const banner: Banner = {
|
||||
type: BannerType.CherryPickUndone,
|
||||
targetBranchName,
|
||||
countCherryPicked,
|
||||
}
|
||||
this._setBanner(banner)
|
||||
|
||||
await this._refreshRepository(repository)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _setDragElement(dragElement: DragElement | null): Promise<void> {
|
||||
this.currentDragElement = dragElement
|
||||
this.emitUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,12 @@ function getInitialRepositoryState(): IRepositoryState {
|
|||
checkoutProgress: null,
|
||||
pushPullFetchProgress: null,
|
||||
revertProgress: null,
|
||||
cherryPickState: {
|
||||
step: null,
|
||||
progress: null,
|
||||
userHasResolvedConflicts: false,
|
||||
targetBranchUndoSha: null,
|
||||
branchCreated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.onServerError)
|
||||
|
||||
resolve()
|
||||
})
|
||||
|
@ -125,6 +125,8 @@ export class TrampolineServer {
|
|||
socket.pipe(split2(/\0/)).on('data', data => {
|
||||
this.onDataReceived(socket, parser, data)
|
||||
})
|
||||
|
||||
socket.on('error', this.onClientError)
|
||||
}
|
||||
|
||||
private onDataReceived(
|
||||
|
@ -177,10 +179,14 @@ export class TrampolineServer {
|
|||
}
|
||||
}
|
||||
|
||||
private onError(error: Error) {
|
||||
private onServerError = (error: Error) => {
|
||||
sendNonFatalException('trampolineServer', error)
|
||||
this.close()
|
||||
}
|
||||
|
||||
private onClientError = (error: Error) => {
|
||||
sendNonFatalException('trampolineClient', error)
|
||||
}
|
||||
}
|
||||
|
||||
export const trampolineServer = new TrampolineServer()
|
||||
|
|
|
@ -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,10 +1,138 @@
|
|||
import { CherryPickConflictState } from '../lib/app-state'
|
||||
import { Branch } from './branch'
|
||||
import { CommitOneLine } from './commit'
|
||||
import { GitHubRepository } from './github-repository'
|
||||
import { ICherryPickProgress } from './progress'
|
||||
import { IDetachedHead, IUnbornRepository, IValidBranch } from './tip'
|
||||
|
||||
/** Represents a snapshot of the cherry pick state from the Git repository */
|
||||
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
|
||||
| CreateBranchStep
|
||||
|
||||
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',
|
||||
|
||||
/**
|
||||
* User can create a new branch through dialog or dropping on new branch drop
|
||||
* zone.
|
||||
*/
|
||||
CreateBranch = 'CreateBranch',
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Shape of data to track when user is creating branch during cherry-pick */
|
||||
export type CreateBranchStep = {
|
||||
readonly kind: CherryPickStepKind.CreateBranch
|
||||
allBranches: ReadonlyArray<Branch>
|
||||
defaultBranch: Branch | null
|
||||
upstreamDefaultBranch: Branch | null
|
||||
upstreamGhRepo: GitHubRepository | null
|
||||
tip: IUnbornRepository | IDetachedHead | IValidBranch
|
||||
targetBranchName: string
|
||||
}
|
||||
|
|
|
@ -11,6 +11,9 @@ export class DiffLine {
|
|||
public constructor(
|
||||
public readonly text: string,
|
||||
public readonly type: DiffLineType,
|
||||
// Line number in the original diff patch (before expanding it), or null if
|
||||
// it was added as part of a diff expansion action.
|
||||
public readonly originalLineNumber: number | null,
|
||||
public readonly oldLineNumber: number | null,
|
||||
public readonly newLineNumber: number | null,
|
||||
public readonly noTrailingNewLine: boolean = false
|
||||
|
@ -20,6 +23,7 @@ export class DiffLine {
|
|||
return new DiffLine(
|
||||
this.text,
|
||||
this.type,
|
||||
this.originalLineNumber,
|
||||
this.oldLineNumber,
|
||||
this.newLineNumber,
|
||||
noTrailingNewLine
|
||||
|
|
|
@ -30,6 +30,10 @@ export class DiffHunkHeader {
|
|||
public readonly newStartLine: number,
|
||||
public readonly newLineCount: number
|
||||
) {}
|
||||
|
||||
public toDiffLineRepresentation() {
|
||||
return `@@ -${this.oldStartLine},${this.oldLineCount} +${this.newStartLine},${this.newLineCount} @@`
|
||||
}
|
||||
}
|
||||
|
||||
/** the contents of a diff generated by Git */
|
||||
|
|
13
app/src/models/drag-element.ts
Normal file
13
app/src/models/drag-element.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { Commit } from './commit'
|
||||
import { GitHubRepository } from './github-repository'
|
||||
|
||||
export enum DragElementType {
|
||||
CherryPickCommit,
|
||||
}
|
||||
|
||||
export type DragElement = {
|
||||
type: DragElementType.CherryPickCommit
|
||||
commit: Commit
|
||||
selectedCommits: ReadonlyArray<Commit>
|
||||
gitHubRepository: GitHubRepository | null
|
||||
}
|
|
@ -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'
|
||||
|
@ -50,7 +50,6 @@ export enum PopupType {
|
|||
MergeConflicts,
|
||||
AbortMerge,
|
||||
OversizedFiles,
|
||||
UsageReportingChanges,
|
||||
CommitConflictsWarning,
|
||||
PushNeedsPull,
|
||||
RebaseFlow,
|
||||
|
@ -69,6 +68,7 @@ export enum PopupType {
|
|||
ChooseForkSettings,
|
||||
ConfirmDiscardSelection,
|
||||
CherryPick,
|
||||
MoveToApplicationsFolder,
|
||||
}
|
||||
|
||||
export type Popup =
|
||||
|
@ -186,7 +186,6 @@ export type Popup =
|
|||
context: ICommitContext
|
||||
repository: Repository
|
||||
}
|
||||
| { type: PopupType.UsageReportingChanges }
|
||||
| {
|
||||
type: PopupType.CommitConflictsWarning
|
||||
/** files that were selected for committing that are also conflicted */
|
||||
|
@ -273,5 +272,7 @@ export type Popup =
|
|||
| {
|
||||
type: PopupType.CherryPick
|
||||
repository: Repository
|
||||
commitSha: string
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
sourceBranch: Branch | null
|
||||
}
|
||||
| { type: PopupType.MoveToApplicationsFolder }
|
||||
|
|
|
@ -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,8 @@ export enum RetryActionType {
|
|||
Checkout,
|
||||
Merge,
|
||||
Rebase,
|
||||
CherryPick,
|
||||
CreateBranchForCherryPick,
|
||||
}
|
||||
|
||||
/** The retriable actions and their associated data. */
|
||||
|
@ -42,3 +45,19 @@ export type RetryAction =
|
|||
baseBranch: Branch
|
||||
targetBranch: Branch
|
||||
}
|
||||
| {
|
||||
type: RetryActionType.CherryPick
|
||||
repository: Repository
|
||||
targetBranch: Branch
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
sourceBranch: Branch | null
|
||||
}
|
||||
| {
|
||||
type: RetryActionType.CreateBranchForCherryPick
|
||||
repository: Repository
|
||||
targetBranchName: string
|
||||
startPoint: string | null
|
||||
noTrackOption: boolean
|
||||
commits: ReadonlyArray<CommitOneLine>
|
||||
sourceBranch: Branch | null
|
||||
}
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import * as React from 'react'
|
||||
import { ApplicationTheme, getThemeName } from './lib/application-theme'
|
||||
import {
|
||||
ApplicationTheme,
|
||||
getThemeName,
|
||||
getCurrentlyAppliedTheme,
|
||||
} from './lib/application-theme'
|
||||
|
||||
interface IAppThemeProps {
|
||||
readonly theme: ApplicationTheme
|
||||
|
@ -31,7 +35,13 @@ export class AppTheme extends React.PureComponent<IAppThemeProps> {
|
|||
}
|
||||
|
||||
private ensureTheme() {
|
||||
const newThemeClassName = `theme-${getThemeName(this.props.theme)}`
|
||||
let themeToDisplay = this.props.theme
|
||||
|
||||
if (this.props.theme === ApplicationTheme.System) {
|
||||
themeToDisplay = getCurrentlyAppliedTheme()
|
||||
}
|
||||
|
||||
const newThemeClassName = `theme-${getThemeName(themeToDisplay)}`
|
||||
const body = document.body
|
||||
|
||||
if (body.classList.contains(newThemeClassName)) {
|
||||
|
|
|
@ -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'
|
||||
|
@ -18,7 +21,7 @@ import { RetryAction } from '../models/retry-actions'
|
|||
import { shouldRenderApplicationMenu } from './lib/features'
|
||||
import { matchExistingRepository } from '../lib/repository-matching'
|
||||
import { getDotComAPIEndpoint } from '../lib/api'
|
||||
import { ILaunchStats, SamplesURL } from '../lib/stats'
|
||||
import { ILaunchStats } from '../lib/stats'
|
||||
import { getVersion, getName } from './lib/app-proxy'
|
||||
import { getOS } from '../lib/get-os'
|
||||
import { validatedRepositoryPath } from '../lib/stores/helpers/validated-repository-path'
|
||||
|
@ -92,7 +95,6 @@ import { AbortMergeWarning } from './abort-merge'
|
|||
import { isConflictedFile } from '../lib/status'
|
||||
import { PopupType, Popup } from '../models/popup'
|
||||
import { OversizedFiles } from './changes/oversized-files-warning'
|
||||
import { UsageStatsChange } from './usage-stats-change'
|
||||
import { PushNeedsPullWarning } from './push-needs-pull'
|
||||
import { RebaseFlow, ConfirmForcePush } from './rebase'
|
||||
import {
|
||||
|
@ -120,7 +122,19 @@ 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'
|
||||
import { DragElementType } from '../models/drag-element'
|
||||
import { CherryPickCommit } from './drag-elements/cherry-pick-commit'
|
||||
import classNames from 'classnames'
|
||||
import { dragAndDropManager } from '../lib/drag-and-drop-manager'
|
||||
import { MoveToApplicationsFolder } from './move-to-applications-folder'
|
||||
|
||||
const MinuteInMilliseconds = 1000 * 60
|
||||
const HourInMilliseconds = MinuteInMilliseconds * 60
|
||||
|
@ -158,7 +172,6 @@ export const bannerTransitionTimeout = { enter: 500, exit: 400 }
|
|||
* changes. See https://github.com/desktop/desktop/issues/1398.
|
||||
*/
|
||||
const ReadyDelay = 100
|
||||
|
||||
export class App extends React.Component<IAppProps, IAppState> {
|
||||
private loading = true
|
||||
|
||||
|
@ -296,6 +309,11 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
|
||||
log.info(`launching: ${getVersion()} (${getOS()})`)
|
||||
log.info(`execPath: '${process.execPath}'`)
|
||||
|
||||
// Only show the popup in beta/production releases and mac machines
|
||||
if (__DEV__ === false && remote.app.isInApplicationsFolder?.() === false) {
|
||||
this.showPopup({ type: PopupType.MoveToApplicationsFolder })
|
||||
}
|
||||
}
|
||||
|
||||
private onMenuEvent(name: MenuEvent): any {
|
||||
|
@ -1376,7 +1394,6 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
onDismissed={onPopupDismissedFn}
|
||||
selectedShell={this.state.selectedShell}
|
||||
selectedTheme={this.state.selectedTheme}
|
||||
automaticallySwitchTheme={this.state.automaticallySwitchTheme}
|
||||
repositoryIndicatorsEnabled={this.state.repositoryIndicatorsEnabled}
|
||||
/>
|
||||
)
|
||||
|
@ -1746,15 +1763,6 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.UsageReportingChanges:
|
||||
return (
|
||||
<UsageStatsChange
|
||||
key="usage-stats-change"
|
||||
onOpenUsageDataUrl={this.openUsageDataUrl}
|
||||
onSetStatsOptOut={this.onSetStatsOptOut}
|
||||
onDismissed={onPopupDismissedFn}
|
||||
/>
|
||||
)
|
||||
case PopupType.CommitConflictsWarning:
|
||||
return (
|
||||
<CommitConflictsWarning
|
||||
|
@ -1991,9 +1999,55 @@ 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
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case PopupType.MoveToApplicationsFolder: {
|
||||
return (
|
||||
<MoveToApplicationsFolder
|
||||
dispatcher={this.props.dispatcher}
|
||||
onDismissed={onPopupDismissedFn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return assertNever(popup, `Unknown popup type: ${popup}`)
|
||||
}
|
||||
|
@ -2026,9 +2080,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
|
||||
}
|
||||
|
@ -2053,16 +2107,6 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
this.props.dispatcher.endRebaseFlow(repository)
|
||||
}
|
||||
|
||||
private onSetStatsOptOut = (optOut: boolean) => {
|
||||
this.props.appStore.setStatsOptOut(optOut, true)
|
||||
this.props.appStore.markUsageStatsNoteSeen()
|
||||
this.props.appStore._reportStats()
|
||||
}
|
||||
|
||||
private openUsageDataUrl = () => {
|
||||
this.props.dispatcher.openInBrowser(SamplesURL)
|
||||
}
|
||||
|
||||
private onUpdateExistingUpstreamRemote = (repository: Repository) => {
|
||||
this.props.dispatcher.updateExistingUpstreamRemote(repository)
|
||||
}
|
||||
|
@ -2137,6 +2181,39 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
)
|
||||
}
|
||||
|
||||
private renderDragElement() {
|
||||
return <div id="dragElement">{this.renderCurrentDragElement()}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current drag element based on it's type. Used in conjunction
|
||||
* with the `Draggable` component.
|
||||
*/
|
||||
private renderCurrentDragElement(): JSX.Element | null {
|
||||
const { currentDragElement, emoji } = this.state
|
||||
if (currentDragElement === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { gitHubRepository, commit, selectedCommits } = currentDragElement
|
||||
switch (currentDragElement.type) {
|
||||
case DragElementType.CherryPickCommit:
|
||||
return (
|
||||
<CherryPickCommit
|
||||
gitHubRepository={gitHubRepository}
|
||||
commit={commit}
|
||||
selectedCommits={selectedCommits}
|
||||
emoji={emoji}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return assertNever(
|
||||
currentDragElement.type,
|
||||
`Unknown drag element type: ${currentDragElement}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private renderZoomInfo() {
|
||||
return <ZoomInfo windowZoomFactor={this.state.windowZoomFactor} />
|
||||
}
|
||||
|
@ -2170,14 +2247,28 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
this.props.dispatcher.showPopup(popup)
|
||||
}
|
||||
|
||||
private getDesktopAppContentsClassNames = (): string => {
|
||||
const { currentDragElement } = this.state
|
||||
const isCherryPickCommitBeingDragged =
|
||||
currentDragElement !== null &&
|
||||
currentDragElement.type === DragElementType.CherryPickCommit
|
||||
return classNames({
|
||||
'cherry-pick-mouse-over': isCherryPickCommitBeingDragged,
|
||||
})
|
||||
}
|
||||
|
||||
private renderApp() {
|
||||
return (
|
||||
<div id="desktop-app-contents">
|
||||
<div
|
||||
id="desktop-app-contents"
|
||||
className={this.getDesktopAppContentsClassNames()}
|
||||
>
|
||||
{this.renderToolbar()}
|
||||
{this.renderBanner()}
|
||||
{this.renderRepository()}
|
||||
{this.renderPopup()}
|
||||
{this.renderAppError()}
|
||||
{this.renderDragElement()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -2453,6 +2544,8 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
shouldNudge={
|
||||
this.state.currentOnboardingTutorialStep === TutorialStep.CreateBranch
|
||||
}
|
||||
onDragEnterBranch={this.onDragEnterBranch}
|
||||
onDragLeaveBranch={this.onDragLeaveBranch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -2587,6 +2680,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) {
|
||||
|
@ -2628,7 +2723,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
|
||||
const currentTheme = this.state.showWelcomeFlow
|
||||
? ApplicationTheme.Light
|
||||
: this.state.selectedTheme
|
||||
: this.state.currentTheme
|
||||
|
||||
return (
|
||||
<div id="desktop-app-chrome" className={className}>
|
||||
|
@ -2687,6 +2782,149 @@ 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.props.dispatcher.recordCherryPickViaContextMenu()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to handle when something is dragged onto a branch item
|
||||
*
|
||||
* Note: We currently use this in conjunction with cherry picking and a cherry
|
||||
* picking commit is the only type of drag element. Thus, below uses those
|
||||
* assumptions to just update the currentDragElement.
|
||||
*/
|
||||
private onDragEnterBranch = (branchName: string): void => {
|
||||
dragAndDropManager.emitEnterDropTarget(branchName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to handle when something is dragged out of a branch item
|
||||
*
|
||||
* Note: We currently use this in conjunction with cherry picking and a cherry
|
||||
* picking commit is the only type of drag element. Thus, below uses those
|
||||
* assumptions to just update the currentDragElement.
|
||||
*/
|
||||
private onDragLeaveBranch = (): void => {
|
||||
dragAndDropManager.emitLeaveDropTarget()
|
||||
}
|
||||
}
|
||||
|
||||
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={15000}
|
||||
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,7 @@ 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'
|
||||
|
||||
interface IBranchListItemProps {
|
||||
/** The name of the branch */
|
||||
|
@ -27,6 +28,21 @@ interface IBranchListItemProps {
|
|||
readonly onRenameBranch?: (branchName: string) => void
|
||||
|
||||
readonly onDeleteBranch?: (branchName: string) => void
|
||||
|
||||
/** When a drag element has landed on a branch that is not current */
|
||||
readonly onDropOntoBranch?: (branchName: String) => void
|
||||
|
||||
/** When a drag element has landed on the current branch */
|
||||
readonly onDropOntoCurrentBranch?: () => void
|
||||
|
||||
/** Whether something is being dragged */
|
||||
readonly isSomethingBeingDragged?: boolean
|
||||
|
||||
/** When a drag element enters a branch */
|
||||
readonly onDragEnterBranch?: (branchName: String) => void
|
||||
|
||||
/** When a drag element leaves a branch */
|
||||
readonly onDragLeaveBranch?: () => void
|
||||
}
|
||||
|
||||
/** The branch component. */
|
||||
|
@ -65,6 +81,39 @@ export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
|
|||
showContextualMenu(items)
|
||||
}
|
||||
|
||||
private onMouseEnter = () => {
|
||||
if (this.props.isSomethingBeingDragged) {
|
||||
if (this.props.onDragEnterBranch !== undefined) {
|
||||
this.props.onDragEnterBranch(this.props.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onMouseLeave = () => {
|
||||
if (this.props.isSomethingBeingDragged) {
|
||||
if (this.props.onDragLeaveBranch !== undefined) {
|
||||
this.props.onDragLeaveBranch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onMouseUp = () => {
|
||||
const {
|
||||
onDropOntoBranch,
|
||||
onDropOntoCurrentBranch,
|
||||
name,
|
||||
isCurrentBranch,
|
||||
} = this.props
|
||||
|
||||
if (onDropOntoBranch !== undefined && !isCurrentBranch) {
|
||||
onDropOntoBranch(name)
|
||||
}
|
||||
|
||||
if (onDropOntoCurrentBranch !== undefined && isCurrentBranch) {
|
||||
onDropOntoCurrentBranch()
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const lastCommitDate = this.props.lastCommitDate
|
||||
const isCurrentBranch = this.props.isCurrentBranch
|
||||
|
@ -77,8 +126,15 @@ export class BranchListItem extends React.Component<IBranchListItemProps, {}> {
|
|||
: lastCommitDate
|
||||
? lastCommitDate.toString()
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div onContextMenu={this.onContextMenu} className="branches-list-item">
|
||||
<div
|
||||
onContextMenu={this.onContextMenu}
|
||||
className="branches-list-item"
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onMouseUp={this.onMouseUp}
|
||||
>
|
||||
<Octicon className="icon" symbol={icon} />
|
||||
<div className="name" title={name}>
|
||||
<HighlightText text={name} highlight={this.props.matches.title} />
|
||||
|
|
|
@ -11,7 +11,12 @@ export function renderDefaultBranch(
|
|||
matches: IMatches,
|
||||
currentBranch: Branch | null,
|
||||
onRenameBranch?: (branchName: string) => void,
|
||||
onDeleteBranch?: (branchName: string) => void
|
||||
onDeleteBranch?: (branchName: string) => void,
|
||||
onDropOntoBranch?: (branchName: string) => void,
|
||||
onDropOntoCurrentBranch?: () => void,
|
||||
onDragEnterBranch?: (branchName: string) => void,
|
||||
onDragLeaveBranch?: () => void,
|
||||
isSomethingBeingDragged?: boolean
|
||||
): JSX.Element {
|
||||
const branch = item.branch
|
||||
const commit = branch.tip
|
||||
|
@ -25,6 +30,11 @@ export function renderDefaultBranch(
|
|||
matches={matches}
|
||||
onRenameBranch={onRenameBranch}
|
||||
onDeleteBranch={onDeleteBranch}
|
||||
onDropOntoBranch={onDropOntoBranch}
|
||||
onDropOntoCurrentBranch={onDropOntoCurrentBranch}
|
||||
isSomethingBeingDragged={isSomethingBeingDragged}
|
||||
onDragEnterBranch={onDragEnterBranch}
|
||||
onDragLeaveBranch={onDragLeaveBranch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -41,6 +41,18 @@ interface IBranchesContainerProps {
|
|||
|
||||
/** Are we currently loading pull requests? */
|
||||
readonly isLoadingPullRequests: boolean
|
||||
|
||||
/** When a drag element has landed on the current branch */
|
||||
readonly onDropOntoCurrentBranch?: () => void
|
||||
|
||||
/** Whether a cherry pick is in progress */
|
||||
readonly isCherryPickInProgress?: boolean
|
||||
|
||||
/** When a drag element enters a branch */
|
||||
readonly onDragEnterBranch: (branchName: string) => void
|
||||
|
||||
//** When a drag element leave a branch */
|
||||
readonly onDragLeaveBranch: () => void
|
||||
}
|
||||
|
||||
interface IBranchesContainerState {
|
||||
|
@ -135,6 +147,7 @@ export class BranchesContainer extends React.Component<
|
|||
<TabBar
|
||||
onTabClicked={this.onTabClicked}
|
||||
selectedIndex={this.props.selectedTab}
|
||||
allowDragOverSwitching={true}
|
||||
>
|
||||
<span>Branches</span>
|
||||
<span className="pull-request-tab">
|
||||
|
@ -151,7 +164,12 @@ export class BranchesContainer extends React.Component<
|
|||
matches,
|
||||
this.props.currentBranch,
|
||||
this.onRenameBranch,
|
||||
this.onDeleteBranch
|
||||
this.onDeleteBranch,
|
||||
this.onDropOntoBranch,
|
||||
this.props.onDropOntoCurrentBranch,
|
||||
this.props.onDragEnterBranch,
|
||||
this.props.onDragLeaveBranch,
|
||||
this.props.isCherryPickInProgress
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -211,6 +229,7 @@ export class BranchesContainer extends React.Component<
|
|||
dispatcher={this.props.dispatcher}
|
||||
repository={repository}
|
||||
isLoadingPullRequests={this.props.isLoadingPullRequests}
|
||||
isCherryPickInProgress={this.props.isCherryPickInProgress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -309,4 +328,32 @@ 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
|
||||
}
|
||||
|
||||
if (this.props.isCherryPickInProgress) {
|
||||
this.props.dispatcher.startCherryPickWithBranch(
|
||||
this.props.repository,
|
||||
branch
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { HighlightText } from '../lib/highlight-text'
|
|||
import { IMatches } from '../../lib/fuzzy-find'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import { dragAndDropManager } from '../../lib/drag-and-drop-manager'
|
||||
|
||||
export interface IPullRequestListItemProps {
|
||||
/** The title. */
|
||||
|
@ -40,6 +41,9 @@ export interface IPullRequestListItemProps {
|
|||
|
||||
/** The GitHub repository to use when looking up commit status. */
|
||||
readonly repository: GitHubRepository
|
||||
|
||||
/** When a drag element has landed on a pull request */
|
||||
readonly onDropOntoPullRequest: (prNumber: number) => void
|
||||
}
|
||||
|
||||
/** Pull requests as rendered in the Pull Requests list. */
|
||||
|
@ -57,6 +61,24 @@ export class PullRequestListItem extends React.Component<
|
|||
return this.props.draft ? `${subtitle} • Draft` : subtitle
|
||||
}
|
||||
|
||||
private onMouseEnter = () => {
|
||||
if (dragAndDropManager.isDragInProgress) {
|
||||
dragAndDropManager.emitEnterDragZone(this.props.title)
|
||||
}
|
||||
}
|
||||
|
||||
private onMouseLeave = () => {
|
||||
if (dragAndDropManager.isDragInProgress) {
|
||||
dragAndDropManager.emitLeaveDropTarget()
|
||||
}
|
||||
}
|
||||
|
||||
private onMouseUp = () => {
|
||||
if (dragAndDropManager.isDragInProgress) {
|
||||
this.props.onDropOntoPullRequest(this.props.number)
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const title = this.props.loading === true ? undefined : this.props.title
|
||||
const subtitle = this.getSubtitle()
|
||||
|
@ -68,8 +90,15 @@ export class PullRequestListItem extends React.Component<
|
|||
})
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Octicon className="icon" symbol={OcticonSymbol.gitPullRequest} />
|
||||
<div
|
||||
className={className}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onMouseUp={this.onMouseUp}
|
||||
>
|
||||
<div>
|
||||
<Octicon className="icon" symbol={OcticonSymbol.gitPullRequest} />
|
||||
</div>
|
||||
<div className="info">
|
||||
<div className="title" title={title}>
|
||||
<HighlightText text={title || ''} highlight={matches.title} />
|
||||
|
|
|
@ -63,6 +63,9 @@ interface IPullRequestListProps {
|
|||
|
||||
/** Are we currently loading pull requests? */
|
||||
readonly isLoadingPullRequests: boolean
|
||||
|
||||
/** Whether a cherry pick is in progress */
|
||||
readonly isCherryPickInProgress?: boolean
|
||||
}
|
||||
|
||||
interface IPullRequestListState {
|
||||
|
@ -170,10 +173,47 @@ export class PullRequestList extends React.Component<
|
|||
matches={matches}
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={pr.base.gitHubRepository}
|
||||
onDropOntoPullRequest={this.onDropOntoPullRequest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private onDropOntoPullRequest = (prNumber: number) => {
|
||||
const {
|
||||
isCherryPickInProgress,
|
||||
repository,
|
||||
selectedPullRequest,
|
||||
dispatcher,
|
||||
pullRequests,
|
||||
} = this.props
|
||||
|
||||
if (!isCherryPickInProgress) {
|
||||
return
|
||||
}
|
||||
|
||||
// If dropped on currently checked out pull request, it is treated the same
|
||||
// as dropping on non-pull-request.
|
||||
if (
|
||||
selectedPullRequest !== null &&
|
||||
prNumber === selectedPullRequest.pullRequestNumber
|
||||
) {
|
||||
dispatcher.endCherryPickFlow(repository)
|
||||
dispatcher.recordCherryPickDragStartedAndCanceled()
|
||||
return
|
||||
}
|
||||
|
||||
// If not the currently checked out pull request, find the full pull request
|
||||
// object to start the cherry-pick
|
||||
const pr = pullRequests.find(pr => pr.pullRequestNumber === prNumber)
|
||||
if (pr === undefined) {
|
||||
log.error('[onDropOntoPullRequest] - Could not find pull request.')
|
||||
dispatcher.endCherryPickFlow(repository)
|
||||
return
|
||||
}
|
||||
|
||||
dispatcher.startCherryPickWithPullRequest(repository, pr)
|
||||
}
|
||||
|
||||
private onItemClick = (
|
||||
item: IPullRequestListItem,
|
||||
source: SelectionSource
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Octicon, OcticonSymbol, iconForStatus } from '../octicons'
|
|||
import { mapStatus } from '../../lib/status'
|
||||
import { enableSideBySideDiffs } from '../../lib/feature-flag'
|
||||
import { DiffOptions } from '../diff/diff-options'
|
||||
import { RepositorySectionTab } from '../../lib/app-state'
|
||||
|
||||
interface IChangedFileDetailsProps {
|
||||
readonly path: string
|
||||
|
@ -18,6 +19,12 @@ interface IChangedFileDetailsProps {
|
|||
/** Called when the user changes the side by side diffs setting. */
|
||||
readonly onShowSideBySideDiffChanged: (checked: boolean) => void
|
||||
|
||||
/** Whether we should hide whitespace in diffs. */
|
||||
readonly hideWhitespaceInDiff: boolean
|
||||
|
||||
/** Called when the user changes the hide whitespace in diffs setting. */
|
||||
readonly onHideWhitespaceInDiffChanged: (checked: boolean) => Promise<void>
|
||||
|
||||
/** Called when the user opens the diff options popover */
|
||||
readonly onDiffOptionsOpened: () => void
|
||||
}
|
||||
|
@ -38,6 +45,11 @@ export class ChangedFileDetails extends React.Component<
|
|||
|
||||
{enableSideBySideDiffs() && (
|
||||
<DiffOptions
|
||||
sourceTab={RepositorySectionTab.Changes}
|
||||
onHideWhitespaceChangesChanged={
|
||||
this.props.onHideWhitespaceInDiffChanged
|
||||
}
|
||||
hideWhitespaceChanges={this.props.hideWhitespaceInDiff}
|
||||
onShowSideBySideDiffChanged={this.props.onShowSideBySideDiffChanged}
|
||||
showSideBySideDiff={this.props.showSideBySideDiff}
|
||||
onDiffOptionsOpened={this.props.onDiffOptionsOpened}
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { WorkingDirectoryFileChange } from '../../models/status'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import { enableHideWhitespaceInDiffOption } from '../../lib/feature-flag'
|
||||
import { SeamlessDiffSwitcher } from '../diff/seamless-diff-switcher'
|
||||
import { PopupType } from '../../models/popup'
|
||||
|
||||
|
@ -85,7 +86,10 @@ export class Changes extends React.Component<IChangesProps, {}> {
|
|||
public render() {
|
||||
const diff = this.props.diff
|
||||
const file = this.props.file
|
||||
const isCommitting = this.props.isCommitting
|
||||
const isReadonly =
|
||||
this.props.isCommitting ||
|
||||
(enableHideWhitespaceInDiffOption() && this.props.hideWhitespaceInDiff)
|
||||
|
||||
return (
|
||||
<div className="changed-file">
|
||||
<ChangedFileDetails
|
||||
|
@ -94,13 +98,16 @@ export class Changes extends React.Component<IChangesProps, {}> {
|
|||
diff={diff}
|
||||
showSideBySideDiff={this.props.showSideBySideDiff}
|
||||
onShowSideBySideDiffChanged={this.onShowSideBySideDiffChanged}
|
||||
hideWhitespaceInDiff={this.props.hideWhitespaceInDiff}
|
||||
onHideWhitespaceInDiffChanged={this.onHideWhitespaceInDiffChanged}
|
||||
onDiffOptionsOpened={this.props.onDiffOptionsOpened}
|
||||
/>
|
||||
|
||||
<SeamlessDiffSwitcher
|
||||
repository={this.props.repository}
|
||||
imageDiffType={this.props.imageDiffType}
|
||||
file={file}
|
||||
readOnly={isCommitting}
|
||||
readOnly={isReadonly}
|
||||
onIncludeChanged={this.onDiffLineIncludeChanged}
|
||||
onDiscardChanges={this.onDiscardChanges}
|
||||
diff={diff}
|
||||
|
@ -119,4 +126,13 @@ export class Changes extends React.Component<IChangesProps, {}> {
|
|||
private onShowSideBySideDiffChanged = (showSideBySideDiff: boolean) => {
|
||||
this.props.dispatcher.onShowSideBySideDiffChanged(showSideBySideDiff)
|
||||
}
|
||||
|
||||
private onHideWhitespaceInDiffChanged = async (
|
||||
hideWhitespaceInDiff: boolean
|
||||
) => {
|
||||
await this.props.dispatcher.onHideWhitespaceInDiffChanged(
|
||||
hideWhitespaceInDiff,
|
||||
this.props.repository
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,6 +34,7 @@ import { lookupPreferredEmail } from '../../lib/email'
|
|||
import { setGlobalConfigValue } from '../../lib/git/config'
|
||||
import { PopupType } from '../../models/popup'
|
||||
import { RepositorySettingsTab } from '../repository-settings/repository-settings'
|
||||
import { isAccountEmail } from '../../lib/is-account-email'
|
||||
|
||||
const addAuthorIcon = new OcticonSymbol(
|
||||
18,
|
||||
|
@ -121,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
|
||||
|
@ -180,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -283,7 +294,7 @@ export class CommitMessage extends React.Component<
|
|||
const warningBadgeVisible =
|
||||
email !== undefined &&
|
||||
repositoryAccount !== null &&
|
||||
accountEmails.includes(email) === false
|
||||
isAccountEmail(accountEmails, email) === false
|
||||
|
||||
return (
|
||||
<CommitMessageAvatar
|
||||
|
@ -344,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: ourBranch,
|
||||
} = 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:
|
||||
sourceBranchName !== null ? sourceBranchName : undefined,
|
||||
ourBranch,
|
||||
})
|
||||
: 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>
|
||||
)
|
||||
}
|
||||
}
|
304
app/src/ui/cherry-pick/cherry-pick-flow.tsx
Normal file
304
app/src/ui/cherry-pick/cherry-pick-flow.tsx
Normal file
|
@ -0,0 +1,304 @@
|
|||
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'
|
||||
import { CreateBranch } from '../create-branch'
|
||||
import { String } from 'aws-sdk/clients/acm'
|
||||
|
||||
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 = () => {
|
||||
const { onDismissed, dispatcher, repository } = this.props
|
||||
onDismissed()
|
||||
dispatcher.endCherryPickFlow(repository)
|
||||
}
|
||||
|
||||
private onChooseBranch = (targetBranch: Branch) => {
|
||||
const { dispatcher, repository, commits, sourceBranch } = this.props
|
||||
dispatcher.setCherryPickBranchCreated(repository, false)
|
||||
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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
private onCreateNewBranch = (targetBranchName: String) => {
|
||||
const { dispatcher, repository } = this.props
|
||||
dispatcher.setCherryPickCreateBranchFlowStep(repository, targetBranchName)
|
||||
}
|
||||
|
||||
private onCreateBranchAndCherryPick = (
|
||||
branchName: string,
|
||||
startPoint: string | null,
|
||||
noTrackOption: boolean
|
||||
) => {
|
||||
const { dispatcher, repository, commits, sourceBranch } = this.props
|
||||
if (this.props.step.kind !== CherryPickStepKind.CreateBranch) {
|
||||
log.warn(
|
||||
'[cherryPickFlow] - Invalid cherry-picking state for creating a branch.'
|
||||
)
|
||||
this.onFlowEnded()
|
||||
return
|
||||
}
|
||||
|
||||
dispatcher.startCherryPickWithBranchName(
|
||||
repository,
|
||||
branchName,
|
||||
startPoint,
|
||||
noTrackOption,
|
||||
commits,
|
||||
sourceBranch
|
||||
)
|
||||
}
|
||||
|
||||
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.onChooseBranch}
|
||||
onDismissed={this.onFlowEnded}
|
||||
commitCount={this.props.commits.length}
|
||||
onCreateNewBranch={this.onCreateNewBranch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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.props.onDismissed}
|
||||
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.CreateBranch:
|
||||
const {
|
||||
allBranches,
|
||||
defaultBranch,
|
||||
upstreamDefaultBranch,
|
||||
upstreamGhRepo,
|
||||
tip,
|
||||
targetBranchName,
|
||||
} = step
|
||||
|
||||
const okButtonText = __DARWIN__
|
||||
? 'Create Branch and Cherry-pick'
|
||||
: 'Create branch and cherry-pick'
|
||||
|
||||
const headerText = __DARWIN__
|
||||
? 'Cherry-pick to New Branch'
|
||||
: 'Cherry-pick to new branch'
|
||||
|
||||
return (
|
||||
<CreateBranch
|
||||
key="create-branch"
|
||||
tip={tip}
|
||||
defaultBranch={defaultBranch}
|
||||
upstreamDefaultBranch={upstreamDefaultBranch}
|
||||
upstreamGitHubRepository={upstreamGhRepo}
|
||||
allBranches={allBranches}
|
||||
repository={this.props.repository}
|
||||
onDismissed={this.onFlowEnded}
|
||||
dispatcher={this.props.dispatcher}
|
||||
initialName={targetBranchName}
|
||||
createBranch={this.onCreateBranchAndCherryPick}
|
||||
okButtonText={okButtonText}
|
||||
headerText={headerText}
|
||||
/>
|
||||
)
|
||||
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>
|
||||
)
|
||||
}
|
||||
}
|
224
app/src/ui/cherry-pick/choose-target-branch.tsx
Normal file
224
app/src/ui/cherry-pick/choose-target-branch.tsx
Normal file
|
@ -0,0 +1,224 @@
|
|||
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>
|
||||
|
||||
/**
|
||||
* Number of commits to cherry pick
|
||||
*/
|
||||
readonly commitCount: number
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Call back to invoke create new branch dialog
|
||||
*/
|
||||
readonly onCreateNewBranch: (targetBranchName: string) => void
|
||||
}
|
||||
|
||||
interface IChooseTargetBranchDialogState {
|
||||
/** The currently selected branch. */
|
||||
readonly selectedBranch: Branch | null
|
||||
|
||||
/** The filter text to use in the branch selector */
|
||||
readonly filterText: string
|
||||
|
||||
/** When there are no branches to show, prompt for create branch */
|
||||
readonly isCreateBranchState: boolean
|
||||
}
|
||||
|
||||
/** 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: '',
|
||||
isCreateBranchState: props.allBranches.length === 0,
|
||||
}
|
||||
}
|
||||
|
||||
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, isCreateBranchState } = this.state
|
||||
return (
|
||||
(selectedBranch !== null && !this.selectedBranchIsCurrentBranch()) ||
|
||||
isCreateBranchState
|
||||
)
|
||||
}
|
||||
|
||||
private selectedBranchIsCurrentBranch() {
|
||||
const { selectedBranch } = this.state
|
||||
const currentBranch = this.props.currentBranch
|
||||
return (
|
||||
selectedBranch !== null &&
|
||||
currentBranch !== null &&
|
||||
selectedBranch.name === currentBranch.name
|
||||
)
|
||||
}
|
||||
|
||||
private renderOkButtonText() {
|
||||
const { selectedBranch, isCreateBranchState } = this.state
|
||||
|
||||
if (isCreateBranchState) {
|
||||
return __DARWIN__
|
||||
? 'Cherry-pick to New Branch'
|
||||
: 'Cherry-pick to new branch'
|
||||
}
|
||||
|
||||
const pluralize = this.props.commitCount > 1 ? 'commits' : 'commit'
|
||||
const okButtonText = `Cherry-pick ${this.props.commitCount} ${pluralize}`
|
||||
|
||||
if (selectedBranch !== null) {
|
||||
return (
|
||||
<>
|
||||
{okButtonText} to <strong>{selectedBranch.name}</strong>…
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return okButtonText
|
||||
}
|
||||
|
||||
private onFilterListResultsChanged = (resultCount: number) => {
|
||||
const { isCreateBranchState } = this.state
|
||||
if (resultCount === 0 && !isCreateBranchState) {
|
||||
this.setState({ isCreateBranchState: true })
|
||||
} else if (resultCount > 0 && isCreateBranchState) {
|
||||
this.setState({ isCreateBranchState: false })
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const tooltip = this.selectedBranchIsCurrentBranch()
|
||||
? 'You are not able to cherry-pick from and to the same branch'
|
||||
: undefined
|
||||
|
||||
const pluralize = this.props.commitCount > 1 ? 'commits' : 'commit'
|
||||
return (
|
||||
<Dialog
|
||||
id="cherry-pick"
|
||||
onDismissed={this.props.onDismissed}
|
||||
onSubmit={this.onSubmit}
|
||||
dismissable={true}
|
||||
title={
|
||||
<strong>
|
||||
Cherry-pick {this.props.commitCount} {pluralize} 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}
|
||||
onFilterListResultsChanged={this.onFilterListResultsChanged}
|
||||
selectedBranch={this.state.selectedBranch}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
canCreateNewBranch={true}
|
||||
onCreateNewBranch={this.props.onCreateNewBranch}
|
||||
renderBranch={this.renderBranch}
|
||||
onItemClick={this.onEnterPressed}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<OkCancelButtonGroup
|
||||
okButtonText={this.renderOkButtonText()}
|
||||
okButtonDisabled={!this.canCherryPickOntoSelectedBranch()}
|
||||
okButtonTitle={tooltip}
|
||||
cancelButtonVisible={false}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private onSubmit = async () => {
|
||||
const { isCreateBranchState, filterText } = this.state
|
||||
if (isCreateBranchState) {
|
||||
this.props.onCreateNewBranch(filterText)
|
||||
return
|
||||
}
|
||||
|
||||
this.startCherryPick()
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -30,11 +30,30 @@ interface ICreateBranchProps {
|
|||
readonly upstreamGitHubRepository: GitHubRepository | null
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly onDismissed: () => void
|
||||
/**
|
||||
* If provided, the branch creation is handled by the given method.
|
||||
*
|
||||
* It is also responsible for dismissing the popup.
|
||||
*/
|
||||
readonly createBranch?: (
|
||||
name: string,
|
||||
startPoint: string | null,
|
||||
noTrack: boolean
|
||||
) => void
|
||||
readonly tip: IUnbornRepository | IDetachedHead | IValidBranch
|
||||
readonly defaultBranch: Branch | null
|
||||
readonly upstreamDefaultBranch: Branch | null
|
||||
readonly allBranches: ReadonlyArray<Branch>
|
||||
readonly initialName: string
|
||||
/**
|
||||
* If provided, use as the okButtonText
|
||||
*/
|
||||
readonly okButtonText?: string
|
||||
|
||||
/**
|
||||
* If provided, use as the header
|
||||
*/
|
||||
readonly headerText?: string
|
||||
}
|
||||
|
||||
interface ICreateBranchState {
|
||||
|
@ -173,7 +192,7 @@ export class CreateBranch extends React.Component<
|
|||
return (
|
||||
<Dialog
|
||||
id="create-branch"
|
||||
title={__DARWIN__ ? 'Create a Branch' : 'Create a branch'}
|
||||
title={this.getHeaderText()}
|
||||
onSubmit={this.createBranch}
|
||||
onDismissed={this.props.onDismissed}
|
||||
loading={this.state.isCreatingBranch}
|
||||
|
@ -198,7 +217,7 @@ export class CreateBranch extends React.Component<
|
|||
|
||||
<DialogFooter>
|
||||
<OkCancelButtonGroup
|
||||
okButtonText={__DARWIN__ ? 'Create Branch' : 'Create branch'}
|
||||
okButtonText={this.getOkButtonText()}
|
||||
okButtonDisabled={disabled}
|
||||
/>
|
||||
</DialogFooter>
|
||||
|
@ -206,6 +225,22 @@ export class CreateBranch extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private getHeaderText = (): string => {
|
||||
if (this.props.headerText !== undefined) {
|
||||
return this.props.headerText
|
||||
}
|
||||
|
||||
return __DARWIN__ ? 'Create a Branch' : 'Create a branch'
|
||||
}
|
||||
|
||||
private getOkButtonText = (): string => {
|
||||
if (this.props.okButtonText !== undefined) {
|
||||
return this.props.okButtonText
|
||||
}
|
||||
|
||||
return __DARWIN__ ? 'Create Branch' : 'Create branch'
|
||||
}
|
||||
|
||||
private onBranchNameChange = (name: string) => {
|
||||
this.updateBranchName(name)
|
||||
}
|
||||
|
@ -260,6 +295,13 @@ export class CreateBranch extends React.Component<
|
|||
|
||||
if (name.length > 0) {
|
||||
this.setState({ isCreatingBranch: true })
|
||||
|
||||
// If createBranch is provided, use it instead of dispatcher
|
||||
if (this.props.createBranch !== undefined) {
|
||||
this.props.createBranch(name, startPoint, noTrack)
|
||||
return
|
||||
}
|
||||
|
||||
const timer = startTimer('create branch', repository)
|
||||
await this.props.dispatcher.createBranch(
|
||||
repository,
|
||||
|
|
|
@ -23,6 +23,11 @@ interface IDiffRange {
|
|||
readonly type: DiffRangeType | null
|
||||
}
|
||||
|
||||
interface IDiffLineInfo {
|
||||
readonly line: DiffLine
|
||||
readonly hunk: DiffHunk
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the diff hunk for the given (absolute) line number in the diff.
|
||||
*/
|
||||
|
@ -36,6 +41,26 @@ export function diffHunkForIndex(
|
|||
return hunk || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the diff line and hunk for the given (absolute) line number in the diff.
|
||||
*/
|
||||
export function diffLineInfoForIndex(
|
||||
hunks: ReadonlyArray<DiffHunk>,
|
||||
index: number
|
||||
): IDiffLineInfo | null {
|
||||
const hunk = diffHunkForIndex(hunks, index)
|
||||
if (!hunk) {
|
||||
return null
|
||||
}
|
||||
|
||||
const line = hunk.lines[index - hunk.unifiedDiffStart]
|
||||
if (!line) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { hunk, line }
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the diff line for the given (absolute) line number in the diff.
|
||||
*/
|
||||
|
@ -43,12 +68,12 @@ export function diffLineForIndex(
|
|||
hunks: ReadonlyArray<DiffHunk>,
|
||||
index: number
|
||||
): DiffLine | null {
|
||||
const hunk = diffHunkForIndex(hunks, index)
|
||||
if (!hunk) {
|
||||
const diffLineInfo = diffLineInfoForIndex(hunks, index)
|
||||
if (diffLineInfo === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return hunk.lines[index - hunk.unifiedDiffStart] || null
|
||||
return diffLineInfo.line
|
||||
}
|
||||
|
||||
/** Get the line number as represented in the diff text itself. */
|
||||
|
@ -69,6 +94,52 @@ export function lineNumberForDiffLine(
|
|||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* For the given row in the diff, determine the range of elements that
|
||||
* should be displayed as interactive, as a hunk is not granular enough.
|
||||
* The values in the returned range are mapped to lines in the original diff,
|
||||
* in case the current diff has been partially expanded.
|
||||
*/
|
||||
export function findInteractiveOriginalDiffRange(
|
||||
hunks: ReadonlyArray<DiffHunk>,
|
||||
index: number
|
||||
): IDiffRange | null {
|
||||
const range = findInteractiveDiffRange(hunks, index)
|
||||
|
||||
if (range === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const from = getLineInOriginalDiff(hunks, range.from)
|
||||
const to = getLineInOriginalDiff(hunks, range.to)
|
||||
|
||||
if (from === null || to === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...range,
|
||||
from,
|
||||
to,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get the line number in the original line from a given
|
||||
* line number in the current text diff (which might be expanded).
|
||||
*/
|
||||
export function getLineInOriginalDiff(
|
||||
hunks: ReadonlyArray<DiffHunk>,
|
||||
index: number
|
||||
) {
|
||||
const diffLine = diffLineForIndex(hunks, index)
|
||||
if (diffLine === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return diffLine.originalLineNumber
|
||||
}
|
||||
|
||||
/**
|
||||
* For the given row in the diff, determine the range of elements that
|
||||
* should be displayed as interactive, as a hunk is not granular enough
|
||||
|
|
|
@ -4,12 +4,15 @@ import { Octicon, OcticonSymbol } from '../octicons'
|
|||
import { RadioButton } from '../lib/radio-button'
|
||||
import { getBoolean, setBoolean } from '../../lib/local-storage'
|
||||
import { Popover, PopoverCaretPosition } from '../lib/popover'
|
||||
import { enableHideWhitespaceInDiffOption } from '../../lib/feature-flag'
|
||||
import { RepositorySectionTab } from '../../lib/app-state'
|
||||
|
||||
interface IDiffOptionsProps {
|
||||
readonly hideWhitespaceChanges?: boolean
|
||||
readonly onHideWhitespaceChangesChanged?: (
|
||||
readonly sourceTab: RepositorySectionTab
|
||||
readonly hideWhitespaceChanges: boolean
|
||||
readonly onHideWhitespaceChangesChanged: (
|
||||
hideWhitespaceChanges: boolean
|
||||
) => void
|
||||
) => Promise<void>
|
||||
|
||||
readonly showSideBySideDiff: boolean
|
||||
readonly onShowSideBySideDiffChanged: (showSideBySideDiff: boolean) => void
|
||||
|
@ -71,12 +74,10 @@ export class DiffOptions extends React.Component<
|
|||
})
|
||||
}
|
||||
|
||||
private onHideWhitespaceChangesChanged = (
|
||||
private onHideWhitespaceChangesChanged = async (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (this.props.onHideWhitespaceChangesChanged !== undefined) {
|
||||
this.props.onHideWhitespaceChangesChanged(event.currentTarget.checked)
|
||||
}
|
||||
await this.props.onHideWhitespaceChangesChanged(event.currentTarget.checked)
|
||||
}
|
||||
|
||||
public render() {
|
||||
|
@ -139,9 +140,13 @@ export class DiffOptions extends React.Component<
|
|||
}
|
||||
|
||||
private renderHideWhitespaceChanges() {
|
||||
if (this.props.hideWhitespaceChanges === undefined) {
|
||||
return null
|
||||
if (
|
||||
this.props.sourceTab === RepositorySectionTab.Changes &&
|
||||
!enableHideWhitespaceInDiffOption()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h3>Whitespace</h3>
|
||||
|
|
|
@ -4,6 +4,8 @@ import { diffLineForIndex } from './diff-explorer'
|
|||
import { ITokens } from '../../lib/highlighter/types'
|
||||
|
||||
import 'codemirror/mode/javascript/javascript'
|
||||
import { enableTextDiffExpansion } from '../../lib/feature-flag'
|
||||
import { DiffExpansionStep } from './text-diff-expansion'
|
||||
|
||||
export interface IDiffSyntaxModeOptions {
|
||||
/**
|
||||
|
@ -37,6 +39,7 @@ const TokenNames: { [key: string]: string | null } = {
|
|||
|
||||
interface IState {
|
||||
diffLineIndex: number
|
||||
previousHunkOldEndLine: number | null
|
||||
}
|
||||
|
||||
function skipLine(stream: CodeMirror.StringStream, state: IState) {
|
||||
|
@ -106,7 +109,7 @@ export class DiffSyntaxMode {
|
|||
}
|
||||
|
||||
public startState(): IState {
|
||||
return { diffLineIndex: 0 }
|
||||
return { diffLineIndex: 0, previousHunkOldEndLine: null }
|
||||
}
|
||||
|
||||
// Should never happen except for blank diffs but
|
||||
|
@ -130,7 +133,43 @@ export class DiffSyntaxMode {
|
|||
|
||||
const token = index ? TokenNames[index] : null
|
||||
|
||||
return token ? `line-${token} line-background-${token}` : null
|
||||
if (token === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
let result = `line-${token} line-background-${token}`
|
||||
|
||||
// If it's a hunk header line, we want to make a few extra checks
|
||||
// depending on the distance to the previous hunk.
|
||||
if (index === '@' && enableTextDiffExpansion()) {
|
||||
// First we grab the numbers in the hunk header
|
||||
const matches = stream.match(/\@ -(\d+),(\d+) \+\d+,\d+ \@\@/)
|
||||
if (matches !== null) {
|
||||
const oldStartLine = parseInt(matches[1])
|
||||
const oldLineCount = parseInt(matches[2])
|
||||
|
||||
// If there is a hunk above and the distance with this one is bigger
|
||||
// than the expansion "step", return an additional class name that
|
||||
// will be used to make that line taller to fit the expansion buttons.
|
||||
if (
|
||||
state.previousHunkOldEndLine !== null &&
|
||||
oldStartLine - state.previousHunkOldEndLine > DiffExpansionStep
|
||||
) {
|
||||
result += ` line-${token}-expandable-both`
|
||||
}
|
||||
|
||||
// Finally we update the state with the index of the last line of the
|
||||
// current hunk.
|
||||
state.previousHunkOldEndLine = oldStartLine + oldLineCount
|
||||
}
|
||||
|
||||
// Check again if we reached the EOL after matching the regex
|
||||
if (stream.eol()) {
|
||||
state.diffLineIndex++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// This happens when the mode is running without tokens, in this
|
||||
|
|
|
@ -30,6 +30,7 @@ import { BinaryFile } from './binary-file'
|
|||
import { TextDiff } from './text-diff'
|
||||
import { SideBySideDiff } from './side-by-side-diff'
|
||||
import {
|
||||
enableHideWhitespaceInDiffOption,
|
||||
enableExperimentalDiffViewer,
|
||||
enableSideBySideDiffs,
|
||||
} from '../../lib/feature-flag'
|
||||
|
@ -265,11 +266,15 @@ export class Diff extends React.Component<IDiffProps, IDiffState> {
|
|||
)
|
||||
}
|
||||
|
||||
const hideWhitespaceInDiff =
|
||||
enableHideWhitespaceInDiffOption() && this.props.hideWhitespaceInDiff
|
||||
|
||||
return (
|
||||
<TextDiff
|
||||
repository={this.props.repository}
|
||||
file={this.props.file}
|
||||
readOnly={this.props.readOnly}
|
||||
hideWhitespaceInDiff={hideWhitespaceInDiff}
|
||||
onIncludeChanged={this.props.onIncludeChanged}
|
||||
onDiscardChanges={this.props.onDiscardChanges}
|
||||
diff={diff}
|
||||
|
|
|
@ -28,7 +28,10 @@ import {
|
|||
} from 'react-virtualized'
|
||||
import { SideBySideDiffRow } from './side-by-side-diff-row'
|
||||
import memoize from 'memoize-one'
|
||||
import { findInteractiveDiffRange, DiffRangeType } from './diff-explorer'
|
||||
import {
|
||||
findInteractiveOriginalDiffRange,
|
||||
DiffRangeType,
|
||||
} from './diff-explorer'
|
||||
import {
|
||||
ChangedFile,
|
||||
DiffRow,
|
||||
|
@ -589,7 +592,7 @@ export class SideBySideDiff extends React.Component<
|
|||
const selection = this.getSelection()
|
||||
|
||||
if (selection !== undefined) {
|
||||
const range = findInteractiveDiffRange(diff.hunks, hunkStartLine)
|
||||
const range = findInteractiveOriginalDiffRange(diff.hunks, hunkStartLine)
|
||||
if (range !== null) {
|
||||
const { from, to } = range
|
||||
const sel = selection.withRangeSelection(from, to - from + 1, select)
|
||||
|
@ -629,7 +632,7 @@ export class SideBySideDiff extends React.Component<
|
|||
return
|
||||
}
|
||||
|
||||
const range = findInteractiveDiffRange(diff.hunks, diffLineNumber)
|
||||
const range = findInteractiveOriginalDiffRange(diff.hunks, diffLineNumber)
|
||||
if (range === null || range.type === null) {
|
||||
return
|
||||
}
|
||||
|
@ -656,7 +659,10 @@ export class SideBySideDiff extends React.Component<
|
|||
return
|
||||
}
|
||||
|
||||
const range = findInteractiveDiffRange(this.props.diff.hunks, hunkStartLine)
|
||||
const range = findInteractiveOriginalDiffRange(
|
||||
this.props.diff.hunks,
|
||||
hunkStartLine
|
||||
)
|
||||
if (range === null || range.type === null) {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -28,8 +28,8 @@ interface ILineFilters {
|
|||
|
||||
interface IFileContents {
|
||||
readonly file: ChangedFile
|
||||
readonly oldContents: Buffer
|
||||
readonly newContents: Buffer
|
||||
readonly oldContents: string
|
||||
readonly newContents: string
|
||||
}
|
||||
|
||||
interface IFileTokens {
|
||||
|
@ -120,7 +120,11 @@ export async function getFileContents(
|
|||
}),
|
||||
])
|
||||
|
||||
return { file, oldContents, newContents }
|
||||
return {
|
||||
file,
|
||||
oldContents: oldContents.toString('utf8'),
|
||||
newContents: newContents.toString('utf8'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -184,7 +188,7 @@ export async function highlightContents(
|
|||
|
||||
const [oldTokens, newTokens] = await Promise.all([
|
||||
highlight(
|
||||
oldContents.toString('utf8'),
|
||||
oldContents,
|
||||
Path.basename(oldPath),
|
||||
Path.extname(oldPath),
|
||||
tabSize,
|
||||
|
@ -194,7 +198,7 @@ export async function highlightContents(
|
|||
return {}
|
||||
}),
|
||||
highlight(
|
||||
newContents.toString('utf8'),
|
||||
newContents,
|
||||
Path.basename(file.path),
|
||||
Path.extname(file.path),
|
||||
tabSize,
|
||||
|
|
399
app/src/ui/diff/text-diff-expansion.ts
Normal file
399
app/src/ui/diff/text-diff-expansion.ts
Normal file
|
@ -0,0 +1,399 @@
|
|||
import {
|
||||
DiffHunk,
|
||||
DiffHunkHeader,
|
||||
DiffLine,
|
||||
DiffLineType,
|
||||
ITextDiff,
|
||||
} from '../../models/diff'
|
||||
|
||||
/** How many new lines will be added to a diff hunk. */
|
||||
export const DiffExpansionStep = 20
|
||||
|
||||
/** Type of expansion: could be up or down. */
|
||||
export type ExpansionKind = 'up' | 'down'
|
||||
|
||||
/** Interface to represent the expansion capabilities of a hunk header. */
|
||||
interface IHunkHeaderExpansionInfo {
|
||||
/** True if the hunk header can be expanded down. */
|
||||
readonly isExpandableDown: boolean
|
||||
|
||||
/** True if the hunk header can be expanded up. */
|
||||
readonly isExpandableUp: boolean
|
||||
|
||||
/** True if the hunk header can be expanded both up and down. */
|
||||
readonly isExpandableBoth: boolean
|
||||
|
||||
/**
|
||||
* True if the hunk header represents a short gap that, when expanded, will
|
||||
* result in merging this hunk and the hunk above.
|
||||
*/
|
||||
readonly isExpandableShort: boolean
|
||||
}
|
||||
|
||||
/** Builds the diff text string given a list of hunks. */
|
||||
function getDiffTextFromHunks(hunks: ReadonlyArray<DiffHunk>) {
|
||||
// Grab all hunk lines and rebuild the diff text from it
|
||||
const newDiffLines = hunks.reduce<ReadonlyArray<DiffLine>>(
|
||||
(result, hunk) => result.concat(hunk.lines),
|
||||
[]
|
||||
)
|
||||
|
||||
return newDiffLines.map(diffLine => diffLine.text).join('\n')
|
||||
}
|
||||
|
||||
/** Merges two consecutive hunks into one. */
|
||||
function mergeDiffHunks(hunk1: DiffHunk, hunk2: DiffHunk): DiffHunk {
|
||||
// Remove the first line in both hunks, because those are hunk header lines
|
||||
// that will be replaced by a new one for the resulting hunk.
|
||||
const allHunk1LinesButFirst = hunk1.lines.slice(1)
|
||||
const allHunk2LinesButFirst = hunk2.lines.slice(1)
|
||||
|
||||
const newHunkHeader = new DiffHunkHeader(
|
||||
hunk1.header.oldStartLine,
|
||||
hunk1.header.oldLineCount + hunk2.header.oldLineCount,
|
||||
hunk1.header.newStartLine,
|
||||
hunk1.header.newLineCount + hunk2.header.newLineCount
|
||||
)
|
||||
|
||||
// Create a new hunk header line for the resulting hunk
|
||||
const newFirstHunkLine = new DiffLine(
|
||||
newHunkHeader.toDiffLineRepresentation(),
|
||||
DiffLineType.Hunk,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false
|
||||
)
|
||||
|
||||
return new DiffHunk(
|
||||
newHunkHeader,
|
||||
[newFirstHunkLine, ...allHunk1LinesButFirst, ...allHunk2LinesButFirst],
|
||||
hunk1.unifiedDiffStart,
|
||||
// This -1 represents the header line of the second hunk that we removed
|
||||
hunk2.unifiedDiffEnd - 1
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates whether or not a hunk header can be expanded up, down, both, or if
|
||||
* the space represented by the hunk header is short and expansion there would
|
||||
* mean merging with the hunk above.
|
||||
*
|
||||
* @param hunks All hunks in the diff.
|
||||
* @param hunk The specific hunk for which we want to know the expansion info.
|
||||
*/
|
||||
export function getHunkHeaderExpansionInfo(
|
||||
hunks: ReadonlyArray<DiffHunk>,
|
||||
hunk: DiffHunk
|
||||
): IHunkHeaderExpansionInfo {
|
||||
let isExpandableDown = false
|
||||
let isExpandableUp = false
|
||||
let isExpandableBoth = false
|
||||
let isExpandableShort = false
|
||||
|
||||
const hunkIndex = hunks.indexOf(hunk)
|
||||
const previousHunk = hunks[hunkIndex - 1]
|
||||
const distanceToPrevious =
|
||||
previousHunk === undefined
|
||||
? Infinity
|
||||
: hunk.header.oldStartLine -
|
||||
previousHunk.header.oldStartLine -
|
||||
previousHunk.header.oldLineCount
|
||||
|
||||
// In order to simplify the whole logic around expansion, only the hunk at the
|
||||
// top can be expanded up exclusively, and only the hunk at the bottom (the
|
||||
// dummy one, see getTextDiffWithBottomDummyHunk) can be expanded down
|
||||
// exclusively.
|
||||
// The rest of the hunks can be expanded both ways, except those which are too
|
||||
// short and therefore the direction of expansion doesn't matter.
|
||||
if (hunkIndex === 0) {
|
||||
// The top hunk can only be expanded if there is content above it
|
||||
isExpandableUp =
|
||||
hunk.header.oldStartLine > 1 && hunk.header.newStartLine > 1
|
||||
} else if (hunkIndex === hunks.length - 1 && hunk.lines.length === 1) {
|
||||
isExpandableDown = true
|
||||
} else if (distanceToPrevious <= DiffExpansionStep) {
|
||||
isExpandableShort = true
|
||||
} else {
|
||||
isExpandableBoth = true
|
||||
}
|
||||
|
||||
return {
|
||||
isExpandableDown,
|
||||
isExpandableUp,
|
||||
isExpandableBoth,
|
||||
isExpandableShort,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a hunk in a text diff. Returns the new diff with the expanded hunk,
|
||||
* or undefined if anything went wrong.
|
||||
*
|
||||
* @param diff Original text diff to expand.
|
||||
* @param hunk Specific hunk in the original diff to expand.
|
||||
* @param kind Kind of expansion (up or down).
|
||||
* @param newContentLines Array with all the lines of the new content.
|
||||
*/
|
||||
export function expandTextDiffHunk(
|
||||
diff: ITextDiff,
|
||||
hunk: DiffHunk,
|
||||
kind: ExpansionKind,
|
||||
newContentLines: ReadonlyArray<string>
|
||||
): ITextDiff | undefined {
|
||||
const hunkIndex = diff.hunks.indexOf(hunk)
|
||||
if (hunkIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const isExpandingUp = kind === 'up'
|
||||
const adjacentHunkIndex =
|
||||
isExpandingUp && hunkIndex > 0
|
||||
? hunkIndex - 1
|
||||
: !isExpandingUp && hunkIndex < diff.hunks.length - 1
|
||||
? hunkIndex + 1
|
||||
: null
|
||||
const adjacentHunk =
|
||||
adjacentHunkIndex !== null ? diff.hunks[adjacentHunkIndex] : null
|
||||
|
||||
// The adjacent hunk can only be the dummy hunk at the bottom if:
|
||||
// - We're expanding down.
|
||||
// - It only has one line.
|
||||
// - That line is of type "Hunk".
|
||||
// - The adjacent hunk is the last one.
|
||||
const isAdjacentDummyHunk =
|
||||
adjacentHunk !== null &&
|
||||
isExpandingUp === false &&
|
||||
adjacentHunk.lines.length === 1 &&
|
||||
adjacentHunk.lines[0].type === DiffLineType.Hunk &&
|
||||
adjacentHunkIndex === diff.hunks.length - 1
|
||||
|
||||
const newLineNumber = hunk.header.newStartLine
|
||||
const oldLineNumber = hunk.header.oldStartLine
|
||||
|
||||
// Calculate the range of new lines to add to the diff. We could use new or
|
||||
// old line number indistinctly, so I chose the new lines.
|
||||
let [from, to] = isExpandingUp
|
||||
? [newLineNumber - DiffExpansionStep, newLineNumber]
|
||||
: [
|
||||
newLineNumber + hunk.header.newLineCount,
|
||||
newLineNumber + hunk.header.newLineCount + DiffExpansionStep,
|
||||
]
|
||||
|
||||
// We will merge the current hunk with the adjacent only if the expansion
|
||||
// ends where the adjacent hunk begins (depending on the expansion direction).
|
||||
// In any case, never let the expanded hunk to overlap the adjacent hunk.
|
||||
let shouldMergeWithAdjacent = false
|
||||
|
||||
if (adjacentHunk !== null) {
|
||||
if (isExpandingUp) {
|
||||
const upLimit =
|
||||
adjacentHunk.header.newStartLine + adjacentHunk.header.newLineCount
|
||||
from = Math.max(from, upLimit)
|
||||
shouldMergeWithAdjacent = from === upLimit
|
||||
} else {
|
||||
// Make sure we're not comparing against the dummy hunk at the bottom,
|
||||
// which is effectively taking all the undiscovered file contents and
|
||||
// would prevent us from expanding down the diff.
|
||||
if (isAdjacentDummyHunk === false) {
|
||||
const downLimit = adjacentHunk.header.newStartLine - 1
|
||||
to = Math.min(to, downLimit)
|
||||
shouldMergeWithAdjacent = to === downLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newLines = newContentLines.slice(
|
||||
Math.max(from - 1, 0),
|
||||
Math.min(to - 1, newContentLines.length)
|
||||
)
|
||||
const numberOfLinesToAdd = newLines.length
|
||||
|
||||
// Nothing to do here
|
||||
if (numberOfLinesToAdd === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create the DiffLine instances using the right line numbers.
|
||||
const newLineDiffs = newLines.map((line, index) => {
|
||||
const newNewLineNumber = isExpandingUp
|
||||
? newLineNumber - (numberOfLinesToAdd - index)
|
||||
: newLineNumber + hunk.header.newLineCount + index
|
||||
const newOldLineNumber = isExpandingUp
|
||||
? oldLineNumber - (numberOfLinesToAdd - index)
|
||||
: oldLineNumber + hunk.header.oldLineCount + index
|
||||
|
||||
// We need to prepend a space before the line text to match the diff
|
||||
// output.
|
||||
return new DiffLine(
|
||||
' ' + line,
|
||||
DiffLineType.Context,
|
||||
// This null means this line doesn't exist in the original line
|
||||
null,
|
||||
newOldLineNumber,
|
||||
newNewLineNumber,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
// Update the resulting hunk header with the new line count
|
||||
const newHunkHeader = new DiffHunkHeader(
|
||||
isExpandingUp
|
||||
? hunk.header.oldStartLine - numberOfLinesToAdd
|
||||
: hunk.header.oldStartLine,
|
||||
hunk.header.oldLineCount + numberOfLinesToAdd,
|
||||
isExpandingUp
|
||||
? hunk.header.newStartLine - numberOfLinesToAdd
|
||||
: hunk.header.newStartLine,
|
||||
hunk.header.newLineCount + numberOfLinesToAdd
|
||||
)
|
||||
|
||||
// Grab the header line of the hunk to expand
|
||||
const firstHunkLine = hunk.lines[0]
|
||||
|
||||
// Create a new Hunk header line
|
||||
const newDiffHunkLine = new DiffLine(
|
||||
newHunkHeader.toDiffLineRepresentation(),
|
||||
DiffLineType.Hunk,
|
||||
null,
|
||||
firstHunkLine.oldLineNumber,
|
||||
firstHunkLine.newLineNumber,
|
||||
firstHunkLine.noTrailingNewLine
|
||||
)
|
||||
|
||||
const allHunkLinesButFirst = hunk.lines.slice(1)
|
||||
|
||||
// Update the diff lines of the hunk with the new lines
|
||||
const updatedHunkLines = isExpandingUp
|
||||
? [newDiffHunkLine, ...newLineDiffs, ...allHunkLinesButFirst]
|
||||
: [newDiffHunkLine, ...allHunkLinesButFirst, ...newLineDiffs]
|
||||
|
||||
let numberOfNewDiffLines = updatedHunkLines.length - hunk.lines.length
|
||||
|
||||
// Update the hunk with all the new info (header, lines, start/end...)
|
||||
let updatedHunk = new DiffHunk(
|
||||
newHunkHeader,
|
||||
updatedHunkLines,
|
||||
hunk.unifiedDiffStart,
|
||||
hunk.unifiedDiffEnd + numberOfNewDiffLines
|
||||
)
|
||||
|
||||
let previousHunksEndIndex = 0 // Exclusive
|
||||
let followingHunksStartIndex = 0 // Inclusive
|
||||
|
||||
// Merge hunks if needed. Depending on whether we need to merge the current
|
||||
// hunk and the adjacent, we will strip (or not) the adjacent from the list
|
||||
// of hunks, and replace the current one with the merged version.
|
||||
if (shouldMergeWithAdjacent && adjacentHunk !== null) {
|
||||
if (isExpandingUp) {
|
||||
updatedHunk = mergeDiffHunks(adjacentHunk, updatedHunk)
|
||||
previousHunksEndIndex = hunkIndex - 1
|
||||
followingHunksStartIndex = hunkIndex + 1
|
||||
} else {
|
||||
previousHunksEndIndex = hunkIndex
|
||||
followingHunksStartIndex = hunkIndex + 2
|
||||
updatedHunk = mergeDiffHunks(updatedHunk, adjacentHunk)
|
||||
}
|
||||
|
||||
// After merging, there is one line less (the Hunk header line from one
|
||||
// of the merged hunks).
|
||||
numberOfNewDiffLines = numberOfNewDiffLines - 1
|
||||
} else {
|
||||
previousHunksEndIndex = hunkIndex
|
||||
followingHunksStartIndex = hunkIndex + 1
|
||||
}
|
||||
|
||||
const previousHunks = diff.hunks.slice(0, previousHunksEndIndex)
|
||||
|
||||
// Grab the hunks after the current one, and update their start/end, but only
|
||||
// if the currently expanded hunk didn't reach the bottom of the file.
|
||||
const newHunkLastLine =
|
||||
newHunkHeader.newStartLine + newHunkHeader.newLineCount - 1
|
||||
const followingHunks =
|
||||
newHunkLastLine >= newContentLines.length
|
||||
? []
|
||||
: diff.hunks
|
||||
.slice(followingHunksStartIndex)
|
||||
.map(
|
||||
hunk =>
|
||||
new DiffHunk(
|
||||
hunk.header,
|
||||
hunk.lines,
|
||||
hunk.unifiedDiffStart + numberOfNewDiffLines,
|
||||
hunk.unifiedDiffEnd + numberOfNewDiffLines
|
||||
)
|
||||
)
|
||||
|
||||
// Create the new list of hunks of the diff, and the new diff text
|
||||
const newHunks = [...previousHunks, updatedHunk, ...followingHunks]
|
||||
const newDiffText = getDiffTextFromHunks(newHunks)
|
||||
|
||||
return {
|
||||
...diff,
|
||||
text: newDiffText,
|
||||
hunks: newHunks,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a new text diff, if needed, with a dummy hunk at the end to allow
|
||||
* expansion of the diff at the bottom.
|
||||
* If such dummy hunk at the bottom is not needed, returns null.
|
||||
*
|
||||
* @param diff Original diff
|
||||
* @param hunks Hunks from the original diff
|
||||
* @param numberOfOldLines Number of lines in the old content
|
||||
* @param numberOfNewLines Number of lines in the new content
|
||||
*/
|
||||
export function getTextDiffWithBottomDummyHunk(
|
||||
diff: ITextDiff,
|
||||
hunks: ReadonlyArray<DiffHunk>,
|
||||
numberOfOldLines: number,
|
||||
numberOfNewLines: number
|
||||
): ITextDiff | null {
|
||||
if (hunks.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// If the last hunk doesn't reach the end of the file, create a dummy hunk
|
||||
// at the end to allow expanding the diff down.
|
||||
const lastHunk = hunks[hunks.length - 1]
|
||||
const lastHunkNewLine =
|
||||
lastHunk.header.newStartLine + lastHunk.header.newLineCount
|
||||
|
||||
if (lastHunkNewLine >= numberOfNewLines) {
|
||||
return null
|
||||
}
|
||||
const dummyOldStartLine =
|
||||
lastHunk.header.oldStartLine + lastHunk.header.oldLineCount
|
||||
const dummyNewStartLine =
|
||||
lastHunk.header.newStartLine + lastHunk.header.newLineCount
|
||||
const dummyHeader = new DiffHunkHeader(
|
||||
dummyOldStartLine,
|
||||
numberOfOldLines - dummyOldStartLine + 1,
|
||||
dummyNewStartLine,
|
||||
numberOfNewLines - dummyNewStartLine + 1
|
||||
)
|
||||
const dummyLine = new DiffLine(
|
||||
'@@ @@',
|
||||
DiffLineType.Hunk,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false
|
||||
)
|
||||
const dummyHunk = new DiffHunk(
|
||||
dummyHeader,
|
||||
[dummyLine],
|
||||
lastHunk.unifiedDiffEnd + 1,
|
||||
lastHunk.unifiedDiffEnd + 1
|
||||
)
|
||||
|
||||
const newHunks = [...hunks, dummyHunk]
|
||||
|
||||
return {
|
||||
...diff,
|
||||
text: getDiffTextFromHunks(newHunks),
|
||||
hunks: newHunks,
|
||||
}
|
||||
}
|
|
@ -21,9 +21,11 @@ import { DiffSyntaxMode, IDiffSyntaxModeSpec } from './diff-syntax-mode'
|
|||
import { CodeMirrorHost } from './code-mirror-host'
|
||||
import {
|
||||
diffLineForIndex,
|
||||
findInteractiveDiffRange,
|
||||
findInteractiveOriginalDiffRange,
|
||||
lineNumberForDiffLine,
|
||||
DiffRangeType,
|
||||
diffLineInfoForIndex,
|
||||
getLineInOriginalDiff,
|
||||
} from './diff-explorer'
|
||||
|
||||
import {
|
||||
|
@ -40,8 +42,18 @@ import { clamp } from '../../lib/clamp'
|
|||
import { uuid } from '../../lib/uuid'
|
||||
import { showContextualMenu } from '../main-process-proxy'
|
||||
import { IMenuItem } from '../../lib/menu-item'
|
||||
import { enableDiscardLines } from '../../lib/feature-flag'
|
||||
import {
|
||||
enableDiscardLines,
|
||||
enableTextDiffExpansion,
|
||||
} from '../../lib/feature-flag'
|
||||
import { canSelect } from './diff-helpers'
|
||||
import {
|
||||
expandTextDiffHunk,
|
||||
ExpansionKind,
|
||||
getHunkHeaderExpansionInfo,
|
||||
getTextDiffWithBottomDummyHunk,
|
||||
} from './text-diff-expansion'
|
||||
import { createOcticonElement } from '../octicons/octicon'
|
||||
|
||||
/** The longest line for which we'd try to calculate a line diff. */
|
||||
const MaxIntraLineDiffStringLength = 4096
|
||||
|
@ -65,12 +77,13 @@ type ChangedFile = WorkingDirectoryFileChange | CommittedFileChange
|
|||
*/
|
||||
function highlightParametersEqual(
|
||||
newProps: ITextDiffProps,
|
||||
prevProps: ITextDiffProps
|
||||
prevProps: ITextDiffProps,
|
||||
newState: ITextDiffState,
|
||||
prevState: ITextDiffState
|
||||
) {
|
||||
return (
|
||||
newProps === prevProps ||
|
||||
(newProps.file.id === prevProps.file.id &&
|
||||
newProps.diff.text === prevProps.diff.text)
|
||||
(newProps === prevProps || newProps.file.id === prevProps.file.id) &&
|
||||
newState.diff.text === prevState.diff.text
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -133,7 +146,7 @@ interface ITextDiffProps {
|
|||
readonly repository: Repository
|
||||
/** The file whose diff should be displayed. */
|
||||
readonly file: ChangedFile
|
||||
/** The diff that should be rendered */
|
||||
/** The initial diff that should be rendered */
|
||||
readonly diff: ITextDiff
|
||||
/** If true, no selections or discards can be done against this diff. */
|
||||
readonly readOnly: boolean
|
||||
|
@ -142,6 +155,9 @@ interface ITextDiffProps {
|
|||
* Only applicable when readOnly is false.
|
||||
*/
|
||||
readonly onIncludeChanged?: (diffSelection: DiffSelection) => void
|
||||
|
||||
readonly hideWhitespaceInDiff: boolean
|
||||
|
||||
/**
|
||||
* Called when the user wants to discard a selection of the diff.
|
||||
* Only applicable when readOnly is false.
|
||||
|
@ -157,6 +173,11 @@ interface ITextDiffProps {
|
|||
readonly askForConfirmationOnDiscardChanges?: boolean
|
||||
}
|
||||
|
||||
interface ITextDiffState {
|
||||
/** The diff that should be rendered */
|
||||
readonly diff: ITextDiff
|
||||
}
|
||||
|
||||
const diffGutterName = 'diff-gutter'
|
||||
|
||||
function showSearch(cm: Editor) {
|
||||
|
@ -255,7 +276,7 @@ const defaultEditorOptions: IEditorConfigurationExtra = {
|
|||
gutters: [diffGutterName],
|
||||
}
|
||||
|
||||
export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
||||
export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
|
||||
private codeMirror: Editor | null = null
|
||||
|
||||
private getCodeMirrorDocument = memoizeOne(
|
||||
|
@ -317,6 +338,8 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
/** The current, active, diff gutter selection if any */
|
||||
private selection: ISelection | null = null
|
||||
|
||||
private newContentLines: ReadonlyArray<string> | null = null
|
||||
|
||||
/** Whether a particular range should be highlighted due to hover */
|
||||
private hunkHighlightRange: ISelection | null = null
|
||||
|
||||
|
@ -337,17 +360,25 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
*/
|
||||
private swappedDocumentHasUpdatedViewport = true
|
||||
|
||||
public constructor(props: ITextDiffProps) {
|
||||
super(props)
|
||||
|
||||
this.state = { diff: this.props.diff }
|
||||
}
|
||||
|
||||
private async initDiffSyntaxMode() {
|
||||
if (!this.codeMirror) {
|
||||
return
|
||||
}
|
||||
|
||||
const { file, diff, repository } = this.props
|
||||
const { file, repository } = this.props
|
||||
const diff = this.state.diff
|
||||
|
||||
// Store the current props to that we can see if anything
|
||||
// Store the current props and state to that we can see if anything
|
||||
// changes from underneath us as we're making asynchronous
|
||||
// operations that makes our data stale or useless.
|
||||
const propsSnapshot = this.props
|
||||
const stateSnapshot = this.state
|
||||
|
||||
const lineFilters = getLineFilters(diff.hunks)
|
||||
const tsOpt = this.codeMirror.getOption('tabSize')
|
||||
|
@ -355,19 +386,47 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
|
||||
const contents = await getFileContents(repository, file, lineFilters)
|
||||
|
||||
if (!highlightParametersEqual(this.props, propsSnapshot)) {
|
||||
if (
|
||||
!highlightParametersEqual(
|
||||
this.props,
|
||||
propsSnapshot,
|
||||
this.state,
|
||||
stateSnapshot
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const tokens = await highlightContents(contents, tabSize, lineFilters)
|
||||
|
||||
if (!highlightParametersEqual(this.props, propsSnapshot)) {
|
||||
if (
|
||||
!highlightParametersEqual(
|
||||
this.props,
|
||||
propsSnapshot,
|
||||
this.state,
|
||||
stateSnapshot
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const newContentLines = contents.newContents.split('\n')
|
||||
const oldContentLines = contents.oldContents.split('\n')
|
||||
|
||||
const currentDiff = this.state.diff
|
||||
const newDiff = enableTextDiffExpansion()
|
||||
? getTextDiffWithBottomDummyHunk(
|
||||
currentDiff,
|
||||
currentDiff.hunks,
|
||||
oldContentLines.length,
|
||||
newContentLines.length
|
||||
)
|
||||
: null
|
||||
this.newContentLines = newContentLines
|
||||
|
||||
const spec: IDiffSyntaxModeSpec = {
|
||||
name: DiffSyntaxMode.ModeName,
|
||||
hunks: this.props.diff.hunks,
|
||||
hunks: newDiff !== null ? newDiff.hunks : currentDiff.hunks,
|
||||
oldTokens: tokens.oldTokens,
|
||||
newTokens: tokens.newTokens,
|
||||
}
|
||||
|
@ -375,6 +434,11 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
if (this.codeMirror) {
|
||||
this.codeMirror.setOption('mode', spec)
|
||||
}
|
||||
|
||||
// If there is a new diff with the fake hunk at the end, update the state
|
||||
if (newDiff !== null) {
|
||||
this.setState({ diff: newDiff })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -390,10 +454,15 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
this.cancelSelection()
|
||||
}
|
||||
|
||||
const isSelected = !file.selection.isSelected(index)
|
||||
const indexInOriginalDiff = getLineInOriginalDiff(hunks, index)
|
||||
if (indexInOriginalDiff === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const isSelected = !file.selection.isSelected(indexInOriginalDiff)
|
||||
|
||||
if (kind === 'hunk') {
|
||||
const range = findInteractiveDiffRange(hunks, index)
|
||||
const range = findInteractiveOriginalDiffRange(hunks, index)
|
||||
if (!range) {
|
||||
console.error('unable to find range for given line in diff')
|
||||
return
|
||||
|
@ -402,7 +471,12 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
const { from, to } = range
|
||||
this.selection = { isSelected, from, to, kind }
|
||||
} else if (kind === 'range') {
|
||||
this.selection = { isSelected, from: index, to: index, kind }
|
||||
this.selection = {
|
||||
isSelected,
|
||||
from: indexInOriginalDiff,
|
||||
to: indexInOriginalDiff,
|
||||
kind,
|
||||
}
|
||||
document.addEventListener('mousemove', this.onDocumentMouseMove)
|
||||
} else {
|
||||
assertNever(kind, `Unknown selection kind ${kind}`)
|
||||
|
@ -433,9 +507,15 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
// pointer is placed underneath the last line so we clamp it
|
||||
// to the range of valid values.
|
||||
const max = Math.max(0, this.codeMirror.getDoc().lineCount() - 1)
|
||||
const to = clamp(this.codeMirror.lineAtHeight(ev.y), 0, max)
|
||||
const index = clamp(this.codeMirror.lineAtHeight(ev.y), 0, max)
|
||||
|
||||
this.codeMirror.scrollIntoView({ line: to, ch: 0 })
|
||||
this.codeMirror.scrollIntoView({ line: index, ch: 0 })
|
||||
|
||||
const to = getLineInOriginalDiff(this.state.diff.hunks, index)
|
||||
|
||||
if (to === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (to !== this.selection.to) {
|
||||
this.selection = { ...this.selection, to }
|
||||
|
@ -463,11 +543,21 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
// we need to make sure the user is still within that hunk handle
|
||||
// section and in the correct range.
|
||||
if (this.selection.kind === 'hunk') {
|
||||
const index = this.codeMirror.lineAtHeight(ev.y)
|
||||
const indexInOriginalDiff = getLineInOriginalDiff(
|
||||
this.state.diff.hunks,
|
||||
index
|
||||
)
|
||||
if (indexInOriginalDiff === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// Is the pointer over the same range (i.e hunk) that the
|
||||
// selection was originally started from?
|
||||
if (
|
||||
indexInOriginalDiff === null ||
|
||||
!targetHasClass(ev.target, 'hunk-handle') ||
|
||||
!inSelection(this.selection, this.codeMirror.lineAtHeight(ev.y))
|
||||
!inSelection(this.selection, indexInOriginalDiff)
|
||||
) {
|
||||
return this.cancelSelection()
|
||||
}
|
||||
|
@ -517,6 +607,31 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
return this.selection === null
|
||||
}
|
||||
|
||||
/** Expand a selected hunk. */
|
||||
private expandHunk(hunk: DiffHunk, kind: ExpansionKind) {
|
||||
const diff = this.state.diff
|
||||
|
||||
if (this.newContentLines === null || this.newContentLines.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const updatedDiff = expandTextDiffHunk(
|
||||
diff,
|
||||
hunk,
|
||||
kind,
|
||||
this.newContentLines
|
||||
)
|
||||
|
||||
if (updatedDiff === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
diff: updatedDiff,
|
||||
})
|
||||
this.updateViewport()
|
||||
}
|
||||
|
||||
private getAndStoreCodeMirrorInstance = (cmh: CodeMirrorHost | null) => {
|
||||
this.codeMirror = cmh === null ? null : cmh.getEditor()
|
||||
}
|
||||
|
@ -526,16 +641,14 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
const isTextSelected = selectionRanges != null
|
||||
|
||||
const action = () => {
|
||||
if (this.onCopy !== null) {
|
||||
this.onCopy(instance, event)
|
||||
}
|
||||
this.onCopy(instance, event)
|
||||
}
|
||||
|
||||
const items: IMenuItem[] = [
|
||||
{
|
||||
label: 'Copy',
|
||||
action,
|
||||
enabled: this.onCopy && isTextSelected,
|
||||
enabled: isTextSelected,
|
||||
},
|
||||
]
|
||||
|
||||
|
@ -573,13 +686,16 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
}
|
||||
|
||||
const lineNumber = editor.lineAtHeight(event.y)
|
||||
const diffLine = diffLineForIndex(this.props.diff.hunks, lineNumber)
|
||||
const diffLine = diffLineForIndex(this.state.diff.hunks, lineNumber)
|
||||
if (diffLine === null || !diffLine.isIncludeableLine()) {
|
||||
// Do not show the discard options for lines that are not additions/deletions.
|
||||
return null
|
||||
}
|
||||
|
||||
const range = findInteractiveDiffRange(this.props.diff.hunks, lineNumber)
|
||||
const range = findInteractiveOriginalDiffRange(
|
||||
this.state.diff.hunks,
|
||||
lineNumber
|
||||
)
|
||||
if (range === null) {
|
||||
return null
|
||||
}
|
||||
|
@ -631,6 +747,8 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
.withSelectNone()
|
||||
.withRangeSelection(startLine, endLine - startLine + 1, true)
|
||||
|
||||
// Pass the original diff (from props) instead of the (potentially)
|
||||
// expanded one.
|
||||
this.props.onDiscardChanges(this.props.diff, selection)
|
||||
}
|
||||
|
||||
|
@ -745,7 +863,7 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
private onSwapDoc = (cm: Editor, oldDoc: Doc) => {
|
||||
this.swappedDocumentHasUpdatedViewport = false
|
||||
this.initDiffSyntaxMode()
|
||||
this.markIntraLineChanges(cm.getDoc(), this.props.diff.hunks)
|
||||
this.markIntraLineChanges(cm.getDoc(), this.state.diff.hunks)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -773,13 +891,16 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
|
||||
this.swappedDocumentHasUpdatedViewport = true
|
||||
|
||||
const hunks = this.state.diff.hunks
|
||||
|
||||
doc.eachLine(from, to, line => {
|
||||
const lineNumber = doc.getLineNumber(line)
|
||||
|
||||
if (lineNumber !== null) {
|
||||
const diffLine = diffLineForIndex(this.props.diff.hunks, lineNumber)
|
||||
const diffLineInfo = diffLineInfoForIndex(hunks, lineNumber)
|
||||
|
||||
if (diffLine !== null) {
|
||||
if (diffLineInfo !== null) {
|
||||
const { hunk, line: diffLine } = diffLineInfo
|
||||
const lineInfo = cm.lineInfo(line)
|
||||
|
||||
if (
|
||||
|
@ -788,11 +909,16 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
) {
|
||||
const marker = lineInfo.gutterMarkers[diffGutterName]
|
||||
if (marker instanceof HTMLElement) {
|
||||
this.updateGutterMarker(marker, lineNumber, diffLine)
|
||||
this.updateGutterMarker(marker, hunks, hunk, diffLine)
|
||||
}
|
||||
} else {
|
||||
batchedOps.push(() => {
|
||||
const marker = this.createGutterMarker(lineNumber, diffLine)
|
||||
const marker = this.createGutterMarker(
|
||||
lineNumber,
|
||||
hunks,
|
||||
hunk,
|
||||
diffLine
|
||||
)
|
||||
cm.setGutterMarker(line, diffGutterName, marker)
|
||||
})
|
||||
}
|
||||
|
@ -823,12 +949,23 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
}
|
||||
|
||||
private getGutterLineClassNameInfo(
|
||||
index: number,
|
||||
hunks: ReadonlyArray<DiffHunk>,
|
||||
hunk: DiffHunk,
|
||||
diffLine: DiffLine
|
||||
): { [className: string]: boolean } {
|
||||
const isIncludeable = diffLine.isIncludeableLine()
|
||||
const isIncluded = isIncludeable && this.isIncluded(index)
|
||||
const hover = isIncludeable && inSelection(this.hunkHighlightRange, index)
|
||||
const isIncluded =
|
||||
isIncludeable &&
|
||||
diffLine.originalLineNumber !== null &&
|
||||
this.isIncluded(diffLine.originalLineNumber)
|
||||
const hover =
|
||||
isIncludeable &&
|
||||
diffLine.originalLineNumber !== null &&
|
||||
inSelection(this.hunkHighlightRange, diffLine.originalLineNumber)
|
||||
const hunkExpansionInfo =
|
||||
enableTextDiffExpansion() && diffLine.type === DiffLineType.Hunk
|
||||
? getHunkHeaderExpansionInfo(hunks, hunk)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
'diff-line-gutter': true,
|
||||
|
@ -839,15 +976,27 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
'read-only': this.props.readOnly,
|
||||
'diff-line-selected': isIncluded,
|
||||
'diff-line-hover': hover,
|
||||
'expandable-down': hunkExpansionInfo?.isExpandableDown === true,
|
||||
'expandable-up': hunkExpansionInfo?.isExpandableUp === true,
|
||||
'expandable-both': hunkExpansionInfo?.isExpandableBoth === true,
|
||||
'expandable-short': hunkExpansionInfo?.isExpandableShort === true,
|
||||
includeable: isIncludeable && !this.props.readOnly,
|
||||
}
|
||||
}
|
||||
|
||||
private createGutterMarker(index: number, diffLine: DiffLine) {
|
||||
private createGutterMarker(
|
||||
index: number,
|
||||
hunks: ReadonlyArray<DiffHunk>,
|
||||
hunk: DiffHunk,
|
||||
diffLine: DiffLine
|
||||
) {
|
||||
const marker = document.createElement('div')
|
||||
marker.className = 'diff-line-gutter'
|
||||
|
||||
marker.addEventListener('mousedown', this.onDiffLineGutterMouseDown)
|
||||
marker.addEventListener(
|
||||
'mousedown',
|
||||
this.onDiffLineGutterMouseDown.bind(this, index)
|
||||
)
|
||||
|
||||
const oldLineNumber = document.createElement('div')
|
||||
oldLineNumber.classList.add('diff-line-number', 'before')
|
||||
|
@ -864,17 +1013,155 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
hunkHandle.classList.add('hunk-handle')
|
||||
marker.appendChild(hunkHandle)
|
||||
|
||||
this.updateGutterMarker(marker, index, diffLine)
|
||||
if (enableTextDiffExpansion()) {
|
||||
const hunkExpandUpHandle = document.createElement('div')
|
||||
hunkExpandUpHandle.classList.add('hunk-expand-up-handle')
|
||||
hunkExpandUpHandle.title = 'Expand Up'
|
||||
hunkExpandUpHandle.addEventListener(
|
||||
'click',
|
||||
this.onHunkExpandHalfHandleMouseDown.bind(this, hunks, hunk, 'up')
|
||||
)
|
||||
marker.appendChild(hunkExpandUpHandle)
|
||||
|
||||
hunkExpandUpHandle.appendChild(
|
||||
createOcticonElement(OcticonSymbol.foldUp, 'hunk-expand-icon')
|
||||
)
|
||||
|
||||
const hunkExpandDownHandle = document.createElement('div')
|
||||
hunkExpandDownHandle.classList.add('hunk-expand-down-handle')
|
||||
hunkExpandDownHandle.title = 'Expand Down'
|
||||
hunkExpandDownHandle.addEventListener(
|
||||
'click',
|
||||
this.onHunkExpandHalfHandleMouseDown.bind(this, hunks, hunk, 'down')
|
||||
)
|
||||
marker.appendChild(hunkExpandDownHandle)
|
||||
|
||||
hunkExpandDownHandle.appendChild(
|
||||
createOcticonElement(OcticonSymbol.foldDown, 'hunk-expand-icon')
|
||||
)
|
||||
|
||||
const hunkExpandWholeHandle = document.createElement('div')
|
||||
hunkExpandWholeHandle.classList.add('hunk-expand-whole-handle')
|
||||
hunkExpandWholeHandle.title = 'Expand whole'
|
||||
hunkExpandWholeHandle.addEventListener(
|
||||
'click',
|
||||
this.onHunkExpandWholeHandleMouseDown.bind(this, hunks, hunk)
|
||||
)
|
||||
marker.appendChild(hunkExpandWholeHandle)
|
||||
|
||||
hunkExpandWholeHandle.appendChild(
|
||||
createOcticonElement(
|
||||
OcticonSymbol.foldDown,
|
||||
'hunk-expand-icon',
|
||||
'hunk-expand-down-icon'
|
||||
)
|
||||
)
|
||||
|
||||
hunkExpandWholeHandle.appendChild(
|
||||
createOcticonElement(
|
||||
OcticonSymbol.foldUp,
|
||||
'hunk-expand-icon',
|
||||
'hunk-expand-up-icon'
|
||||
)
|
||||
)
|
||||
|
||||
hunkExpandWholeHandle.appendChild(
|
||||
createOcticonElement(
|
||||
OcticonSymbol.fold,
|
||||
'hunk-expand-icon',
|
||||
'hunk-expand-short-icon'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
this.updateGutterMarker(marker, hunks, hunk, diffLine)
|
||||
|
||||
return marker
|
||||
}
|
||||
|
||||
private onHunkExpandWholeHandleMouseDown = (
|
||||
hunks: ReadonlyArray<DiffHunk>,
|
||||
hunk: DiffHunk,
|
||||
ev: MouseEvent
|
||||
) => {
|
||||
// If the event is prevented that means the hunk handle was
|
||||
// clicked first and prevented the default action so we'll bail.
|
||||
if (ev.defaultPrevented || this.codeMirror === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// We only care about the primary button here, secondary
|
||||
// button clicks are handled by `onContextMenu`
|
||||
if (ev.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
ev.preventDefault()
|
||||
|
||||
// This code is invoked when the user clicks a hunk line gutter that is
|
||||
// not splitted in half, meaning it can only be expanded either up or down
|
||||
// (or the distance between hunks is too short it doesn't matter). It
|
||||
// won't be invoked when the user can choose to expand it up or down.
|
||||
//
|
||||
// With that in mind, in those situations, we'll ALWAYS expand the hunk
|
||||
// up except when it's the last "dummy" hunk we placed to allow expanding
|
||||
// the diff from the bottom. In that case, we'll expand the second-to-last
|
||||
// hunk down.
|
||||
if (
|
||||
hunk.lines.length === 1 &&
|
||||
hunks.length > 1 &&
|
||||
hunk === hunks[hunks.length - 1]
|
||||
) {
|
||||
const previousHunk = hunks[hunks.length - 2]
|
||||
this.expandHunk(previousHunk, 'down')
|
||||
} else {
|
||||
this.expandHunk(hunk, 'up')
|
||||
}
|
||||
}
|
||||
|
||||
private onHunkExpandHalfHandleMouseDown = (
|
||||
hunks: ReadonlyArray<DiffHunk>,
|
||||
hunk: DiffHunk,
|
||||
kind: ExpansionKind,
|
||||
ev: MouseEvent
|
||||
) => {
|
||||
if (!this.codeMirror) {
|
||||
return
|
||||
}
|
||||
|
||||
// We only care about the primary button here, secondary
|
||||
// button clicks are handled by `onContextMenu`
|
||||
if (ev.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
ev.preventDefault()
|
||||
|
||||
// This code is run when the user clicks on a hunk header line gutter that
|
||||
// is split in two, meaning you can expand up or down the gap the line is
|
||||
// located.
|
||||
// Expanding it up will basically expand *up* the hunk to which that line
|
||||
// belongs, as expected.
|
||||
// Expanding that gap down, however, will expand *down* the hunk that is
|
||||
// located right above this one.
|
||||
if (kind === 'down') {
|
||||
const hunkIndex = hunks.indexOf(hunk)
|
||||
if (hunkIndex > 0) {
|
||||
const previousHunk = hunks[hunkIndex - 1]
|
||||
this.expandHunk(previousHunk, 'down')
|
||||
}
|
||||
} else {
|
||||
this.expandHunk(hunk, 'up')
|
||||
}
|
||||
}
|
||||
|
||||
private updateGutterMarker(
|
||||
marker: HTMLElement,
|
||||
index: number,
|
||||
hunks: ReadonlyArray<DiffHunk>,
|
||||
hunk: DiffHunk,
|
||||
diffLine: DiffLine
|
||||
) {
|
||||
const classNameInfo = this.getGutterLineClassNameInfo(index, diffLine)
|
||||
const classNameInfo = this.getGutterLineClassNameInfo(hunks, hunk, diffLine)
|
||||
for (const [className, include] of Object.entries(classNameInfo)) {
|
||||
if (include) {
|
||||
marker.classList.add(className)
|
||||
|
@ -883,7 +1170,30 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
}
|
||||
}
|
||||
|
||||
if (!this.props.readOnly && diffLine.isIncludeableLine()) {
|
||||
if (this.props.hideWhitespaceInDiff) {
|
||||
marker.title =
|
||||
'Partial committing is not available while hiding whitespace changes'
|
||||
}
|
||||
|
||||
const hunkExpandWholeHandle = marker.getElementsByClassName(
|
||||
'hunk-expand-whole-handle'
|
||||
)[0]
|
||||
if (hunkExpandWholeHandle !== undefined) {
|
||||
if (classNameInfo['expandable-short'] === true) {
|
||||
hunkExpandWholeHandle.setAttribute('title', 'Expand All')
|
||||
} else if (classNameInfo['expandable-both'] !== true) {
|
||||
if (classNameInfo['expandable-down']) {
|
||||
hunkExpandWholeHandle.setAttribute('title', 'Expand Down')
|
||||
} else {
|
||||
hunkExpandWholeHandle.setAttribute('title', 'Expand Up')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isIncludeableLine =
|
||||
!this.props.readOnly && diffLine.isIncludeableLine()
|
||||
|
||||
if (diffLine.type === DiffLineType.Hunk || isIncludeableLine) {
|
||||
marker.setAttribute('role', 'button')
|
||||
} else {
|
||||
marker.removeAttribute('role')
|
||||
|
@ -909,20 +1219,21 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
return
|
||||
}
|
||||
const lineNumber = this.codeMirror.lineAtHeight(ev.y)
|
||||
|
||||
const diffLine = diffLineForIndex(this.props.diff.hunks, lineNumber)
|
||||
const hunks = this.state.diff.hunks
|
||||
const diffLine = diffLineForIndex(hunks, lineNumber)
|
||||
|
||||
if (!diffLine || !diffLine.isIncludeableLine()) {
|
||||
return
|
||||
}
|
||||
|
||||
const range = findInteractiveDiffRange(this.props.diff.hunks, lineNumber)
|
||||
const range = findInteractiveOriginalDiffRange(hunks, lineNumber)
|
||||
|
||||
if (range === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const { from, to } = range
|
||||
|
||||
this.hunkHighlightRange = { from, to, kind: 'hunk', isSelected: false }
|
||||
this.updateViewport()
|
||||
}
|
||||
|
@ -934,7 +1245,7 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
}
|
||||
}
|
||||
|
||||
private onDiffLineGutterMouseDown = (ev: MouseEvent) => {
|
||||
private onDiffLineGutterMouseDown = (index: number, ev: MouseEvent) => {
|
||||
// If the event is prevented that means the hunk handle was
|
||||
// clicked first and prevented the default action so we'll bail.
|
||||
if (ev.defaultPrevented || this.codeMirror === null) {
|
||||
|
@ -947,7 +1258,8 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
return
|
||||
}
|
||||
|
||||
const { file, diff, readOnly } = this.props
|
||||
const { file, readOnly } = this.props
|
||||
const diff = this.state.diff
|
||||
|
||||
if (!canSelect(file) || readOnly) {
|
||||
return
|
||||
|
@ -955,8 +1267,7 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
|
||||
ev.preventDefault()
|
||||
|
||||
const lineNumber = this.codeMirror.lineAtHeight(ev.y)
|
||||
this.startSelection(file, diff.hunks, lineNumber, 'range')
|
||||
this.startSelection(file, diff.hunks, index, 'range')
|
||||
}
|
||||
|
||||
private onHunkHandleMouseLeave = (ev: MouseEvent) => {
|
||||
|
@ -977,7 +1288,8 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
return
|
||||
}
|
||||
|
||||
const { file, diff, readOnly } = this.props
|
||||
const { file, readOnly } = this.props
|
||||
const diff = this.state.diff
|
||||
|
||||
if (!canSelect(file) || readOnly) {
|
||||
return
|
||||
|
@ -997,7 +1309,7 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
|
||||
public componentDidUpdate(
|
||||
prevProps: ITextDiffProps,
|
||||
prevState: {},
|
||||
prevState: ITextDiffState,
|
||||
// tslint:disable-next-line:react-proper-lifecycle-methods
|
||||
snapshot: CodeMirror.ScrollInfo | null
|
||||
) {
|
||||
|
@ -1019,19 +1331,27 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.props.diff.text !== prevProps.diff.text) {
|
||||
this.setState({ diff: this.props.diff })
|
||||
}
|
||||
|
||||
if (snapshot !== null) {
|
||||
this.codeMirror.scrollTo(undefined, snapshot.top)
|
||||
}
|
||||
}
|
||||
|
||||
public getSnapshotBeforeUpdate(prevProps: ITextDiffProps) {
|
||||
public getSnapshotBeforeUpdate(
|
||||
prevProps: ITextDiffProps,
|
||||
prevState: ITextDiffState
|
||||
) {
|
||||
// Store the scroll position when the file stays the same
|
||||
// but we probably swapped out the document
|
||||
if (
|
||||
this.codeMirror !== null &&
|
||||
this.props.file !== prevProps.file &&
|
||||
this.props.file.id === prevProps.file.id &&
|
||||
this.props.diff.text !== prevProps.diff.text
|
||||
((this.props.file !== prevProps.file &&
|
||||
this.props.file.id === prevProps.file.id &&
|
||||
this.props.diff.text !== prevProps.diff.text) ||
|
||||
this.state.diff.text !== prevState.diff.text)
|
||||
) {
|
||||
return this.codeMirror.getScrollInfo()
|
||||
}
|
||||
|
@ -1055,8 +1375,8 @@ export class TextDiff extends React.Component<ITextDiffProps, {}> {
|
|||
|
||||
public render() {
|
||||
const doc = this.getCodeMirrorDocument(
|
||||
this.props.diff.text,
|
||||
this.getNoNewlineIndicatorLines(this.props.diff.hunks)
|
||||
this.state.diff.text,
|
||||
this.getNoNewlineIndicatorLines(this.state.diff.hunks)
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
@ -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 {
|
||||
|
@ -72,6 +74,7 @@ import {
|
|||
isRepositoryWithGitHubRepository,
|
||||
getGitHubHtmlUrl,
|
||||
isRepositoryWithForkedGitHubRepository,
|
||||
getNonForkGitHubRepository,
|
||||
} from '../../models/repository'
|
||||
import { RetryAction, RetryActionType } from '../../models/retry-actions'
|
||||
import {
|
||||
|
@ -97,6 +100,15 @@ 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,
|
||||
CreateBranchStep,
|
||||
} from '../../models/cherry-pick'
|
||||
import { CherryPickResult } from '../../lib/git/cherry-pick'
|
||||
import { sleep } from '../../lib/promise'
|
||||
import { DragElement } from '../../models/drag-element'
|
||||
import { findDefaultUpstreamBranch } from '../../lib/branch'
|
||||
|
||||
/**
|
||||
* An error handler function.
|
||||
|
@ -212,9 +224,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 +462,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
|
||||
}
|
||||
|
||||
|
@ -488,7 +500,7 @@ export class Dispatcher {
|
|||
name: string,
|
||||
startPoint: string | null,
|
||||
noTrackOption: boolean = false
|
||||
): Promise<void> {
|
||||
): Promise<Branch | undefined> {
|
||||
return this.appStore._createBranch(
|
||||
repository,
|
||||
name,
|
||||
|
@ -1020,9 +1032,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 +1125,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
|
||||
}
|
||||
|
@ -1293,8 +1305,8 @@ export class Dispatcher {
|
|||
return this.appStore.setStatsOptOut(optOut, userViewedPrompt)
|
||||
}
|
||||
|
||||
public markUsageStatsNoteSeen() {
|
||||
this.appStore.markUsageStatsNoteSeen()
|
||||
public moveToApplicationsFolder() {
|
||||
remote.app.moveToApplicationsFolder?.()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1903,7 +1915,22 @@ export class Dispatcher {
|
|||
retryAction.baseBranch,
|
||||
retryAction.targetBranch
|
||||
)
|
||||
|
||||
case RetryActionType.CherryPick:
|
||||
return this.cherryPick(
|
||||
retryAction.repository,
|
||||
retryAction.targetBranch,
|
||||
retryAction.commits,
|
||||
retryAction.sourceBranch
|
||||
)
|
||||
case RetryActionType.CreateBranchForCherryPick:
|
||||
return this.startCherryPickWithBranchName(
|
||||
retryAction.repository,
|
||||
retryAction.targetBranchName,
|
||||
retryAction.startPoint,
|
||||
retryAction.noTrackOption,
|
||||
retryAction.commits,
|
||||
retryAction.sourceBranch
|
||||
)
|
||||
default:
|
||||
return assertNever(retryAction, `Unknown retry action: ${retryAction}`)
|
||||
}
|
||||
|
@ -2202,13 +2229,6 @@ export class Dispatcher {
|
|||
return this.appStore._setSelectedTheme(theme)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the automatically switch application-wide theme
|
||||
*/
|
||||
public onAutomaticallySwitchThemeChanged(theme: boolean) {
|
||||
return this.appStore._setAutomaticallySwitchTheme(theme)
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments either the `repoWithIndicatorClicked` or
|
||||
* the `repoWithoutIndicatorClicked` metric
|
||||
|
@ -2505,16 +2525,492 @@ 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({
|
||||
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(
|
||||
targetBranchName: string,
|
||||
beforeSha: string | null
|
||||
) {
|
||||
log.info(
|
||||
`[cherryPick] starting cherry-pick for ${targetBranchName} at ${beforeSha}`
|
||||
)
|
||||
log.info(
|
||||
`[cherryPick] to restore the previous state if this completed cherry-pick is unsatisfactory:`
|
||||
)
|
||||
log.info(`[cherryPick] - git checkout ${targetBranchName}`)
|
||||
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()
|
||||
|
||||
const retry: RetryAction = {
|
||||
type: RetryActionType.CherryPick,
|
||||
repository,
|
||||
targetBranch,
|
||||
commits,
|
||||
sourceBranch,
|
||||
}
|
||||
|
||||
if (this.appStore._checkForUncommittedChanges(repository, retry)) {
|
||||
this.appStore._endCherryPickFlow(repository)
|
||||
return
|
||||
}
|
||||
|
||||
const { tip } = targetBranch
|
||||
this.appStore._setCherryPickTargetBranchUndoSha(repository, tip.sha)
|
||||
|
||||
if (commits.length > 1) {
|
||||
this.statsStore.recordCherryPickMultipleCommits()
|
||||
}
|
||||
|
||||
const nameAfterCheckout = await this.appStore._checkoutBranchReturnName(
|
||||
repository,
|
||||
targetBranch
|
||||
)
|
||||
|
||||
if (nameAfterCheckout === undefined) {
|
||||
log.error('[cherryPick] - Failed to check out the target branch.')
|
||||
this.endCherryPickFlow(repository)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.appStore._cherryPick(repository, commits)
|
||||
|
||||
if (result !== CherryPickResult.UnableToStart) {
|
||||
this.logHowToRevertCherryPick(nameAfterCheckout, tip.sha)
|
||||
}
|
||||
|
||||
this.processCherryPickResult(
|
||||
repository,
|
||||
result,
|
||||
nameAfterCheckout,
|
||||
commits,
|
||||
sourceBranch
|
||||
)
|
||||
}
|
||||
|
||||
public async startCherryPickWithBranchName(
|
||||
repository: Repository,
|
||||
targetBranchName: string,
|
||||
startPoint: string | null,
|
||||
noTrackOption: boolean = false,
|
||||
commits: ReadonlyArray<CommitOneLine>,
|
||||
sourceBranch: Branch | null
|
||||
): Promise<void> {
|
||||
const retry: RetryAction = {
|
||||
type: RetryActionType.CreateBranchForCherryPick,
|
||||
repository,
|
||||
targetBranchName,
|
||||
startPoint,
|
||||
noTrackOption,
|
||||
commits,
|
||||
sourceBranch,
|
||||
}
|
||||
|
||||
if (this.appStore._checkForUncommittedChanges(repository, retry)) {
|
||||
this.appStore._endCherryPickFlow(repository)
|
||||
return
|
||||
}
|
||||
|
||||
const targetBranch = await this.appStore._createBranch(
|
||||
repository,
|
||||
targetBranchName,
|
||||
startPoint,
|
||||
noTrackOption,
|
||||
false
|
||||
)
|
||||
|
||||
if (targetBranch === undefined) {
|
||||
log.error(
|
||||
'[startCherryPickWithBranchName] - Unable to create branch for cherry-pick operation'
|
||||
)
|
||||
this.endCherryPickFlow(repository)
|
||||
return
|
||||
}
|
||||
|
||||
this.appStore._setCherryPickBranchCreated(repository, true)
|
||||
this.statsStore.recordCherryPickBranchCreatedCount()
|
||||
return this.cherryPick(repository, targetBranch, commits, sourceBranch)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.error(
|
||||
'[cherryPick] Invalid Cherry-picking State: Could not determine selected commits.'
|
||||
)
|
||||
this.endCherryPickFlow(repository)
|
||||
return
|
||||
}
|
||||
|
||||
const { tip } = branchesState
|
||||
if (tip.kind !== TipState.Valid) {
|
||||
this.endCherryPickFlow(repository)
|
||||
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,
|
||||
commitSha,
|
||||
commits,
|
||||
sourceBranch,
|
||||
})
|
||||
|
||||
this.statsStore.recordCherryPickViaDragAndDrop()
|
||||
this.setCherryPickBranchCreated(repository, false)
|
||||
this.cherryPick(repository, targetBranch, commits, sourceBranch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to start a cherry-pick after drag and dropping onto a pull request.
|
||||
*/
|
||||
public async startCherryPickWithPullRequest(
|
||||
repository: RepositoryWithGitHubRepository,
|
||||
pullRequest: PullRequest
|
||||
) {
|
||||
const { pullRequestNumber, head } = pullRequest
|
||||
const { ref, gitHubRepository } = head
|
||||
const {
|
||||
cloneURL,
|
||||
owner: { login },
|
||||
} = gitHubRepository
|
||||
|
||||
let targetBranch
|
||||
if (cloneURL !== null) {
|
||||
targetBranch = await this.appStore._findPullRequestBranch(
|
||||
repository,
|
||||
pullRequestNumber,
|
||||
login,
|
||||
cloneURL,
|
||||
ref
|
||||
)
|
||||
}
|
||||
|
||||
if (targetBranch === undefined) {
|
||||
log.error(
|
||||
'[cherryPick] Could not determine target branch for cherry-pick operation - aborting cherry-pick.'
|
||||
)
|
||||
this.endCherryPickFlow(repository)
|
||||
return
|
||||
}
|
||||
|
||||
return this.startCherryPickWithBranch(repository, targetBranch)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
commits: ReadonlyArray<CommitOneLine>,
|
||||
sourceBranch: Branch | null
|
||||
): Promise<void> {
|
||||
await this.switchCherryPickingFlowToShowProgress(repository)
|
||||
|
||||
const result = await this.appStore._continueCherryPick(
|
||||
repository,
|
||||
files,
|
||||
conflictsState.manualResolutions
|
||||
)
|
||||
|
||||
if (result === CherryPickResult.CompletedWithoutError) {
|
||||
this.statsStore.recordCherryPickSuccessfulWithConflicts()
|
||||
}
|
||||
|
||||
this.processCherryPickResult(
|
||||
repository,
|
||||
result,
|
||||
conflictsState.targetBranchName,
|
||||
commits,
|
||||
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.error(
|
||||
'[cherryPick] - conflict state was null or not in a cherry-pick conflict state - unable to continue'
|
||||
)
|
||||
this.endCherryPickFlow(repository)
|
||||
return
|
||||
}
|
||||
this.setCherryPickFlowStep(repository, {
|
||||
kind: CherryPickStepKind.ShowConflicts,
|
||||
conflictState,
|
||||
})
|
||||
this.statsStore.recordCherryPickConflictsEncountered()
|
||||
}
|
||||
|
||||
/** 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
|
||||
): Promise<void> {
|
||||
this.closePopup()
|
||||
|
||||
const banner: Banner = {
|
||||
type: BannerType.SuccessfulCherryPick,
|
||||
targetBranchName,
|
||||
countCherryPicked,
|
||||
onUndoCherryPick: () => {
|
||||
this.undoCherryPick(
|
||||
repository,
|
||||
targetBranchName,
|
||||
sourceBranch,
|
||||
countCherryPicked
|
||||
)
|
||||
},
|
||||
}
|
||||
this.setBanner(banner)
|
||||
|
||||
this.appStore._endCherryPickFlow(repository)
|
||||
|
||||
this.statsStore.recordCherryPickSuccessful()
|
||||
|
||||
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)
|
||||
await this.refreshRepository(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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
commits: ReadonlyArray<CommitOneLine>,
|
||||
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.changeCommitSelection(repository, [commits[0].sha])
|
||||
await this.completeCherryPick(
|
||||
repository,
|
||||
targetBranchName,
|
||||
commits.length,
|
||||
sourceBranch
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
const result = await this.appStore._undoCherryPick(
|
||||
repository,
|
||||
targetBranchName,
|
||||
sourceBranch,
|
||||
commitsCount
|
||||
)
|
||||
if (result) {
|
||||
this.statsStore.recordCherryPickUndone()
|
||||
}
|
||||
}
|
||||
|
||||
/** Method to record cherry pick initiated via the context menu. */
|
||||
public recordCherryPickViaContextMenu() {
|
||||
this.statsStore.recordCherryPickViaContextMenu()
|
||||
}
|
||||
|
||||
/** Method to record cherry pick started via drag and drop and canceled. */
|
||||
public recordCherryPickDragStartedAndCanceled() {
|
||||
this.statsStore.recordCherryPickDragStartedAndCanceled()
|
||||
}
|
||||
|
||||
/** Method to reset cherry picking state. */
|
||||
public endCherryPickFlow(repository: Repository) {
|
||||
this.appStore._endCherryPickFlow(repository)
|
||||
}
|
||||
|
||||
/** Method to set the drag element */
|
||||
public setDragElement(dragElement: DragElement): void {
|
||||
this.appStore._setDragElement(dragElement)
|
||||
}
|
||||
|
||||
/** Method to clear the drag element */
|
||||
public clearDragElement(): void {
|
||||
this.appStore._setDragElement(null)
|
||||
}
|
||||
|
||||
/** Set Cherry Pick Flow Step For Create Branch */
|
||||
public async setCherryPickCreateBranchFlowStep(
|
||||
repository: Repository,
|
||||
targetBranchName: string
|
||||
): Promise<void> {
|
||||
const { branchesState } = this.repositoryStateManager.get(repository)
|
||||
const { defaultBranch, allBranches, tip } = branchesState
|
||||
|
||||
if (tip.kind === TipState.Unknown) {
|
||||
this.appStore._clearCherryPickingHead(repository, null)
|
||||
this.appStore._endCherryPickFlow(repository)
|
||||
log.error('Tip is in unknown state. Cherry-pick aborted.')
|
||||
return
|
||||
}
|
||||
|
||||
const isGHRepo = isRepositoryWithGitHubRepository(repository)
|
||||
const upstreamGhRepo = isGHRepo
|
||||
? getNonForkGitHubRepository(repository as RepositoryWithGitHubRepository)
|
||||
: null
|
||||
const upstreamDefaultBranch = isGHRepo
|
||||
? findDefaultUpstreamBranch(
|
||||
repository as RepositoryWithGitHubRepository,
|
||||
allBranches
|
||||
)
|
||||
: null
|
||||
|
||||
const step: CreateBranchStep = {
|
||||
kind: CherryPickStepKind.CreateBranch,
|
||||
allBranches,
|
||||
defaultBranch,
|
||||
upstreamDefaultBranch,
|
||||
upstreamGhRepo,
|
||||
tip,
|
||||
targetBranchName,
|
||||
}
|
||||
return this.appStore._setCherryPickFlowStep(repository, step)
|
||||
}
|
||||
|
||||
/** Set cherry-pick branch created state */
|
||||
public setCherryPickBranchCreated(
|
||||
repository: Repository,
|
||||
branchCreated: boolean
|
||||
): void {
|
||||
this.appStore._setCherryPickBranchCreated(repository, branchCreated)
|
||||
}
|
||||
}
|
||||
|
|
81
app/src/ui/drag-elements/cherry-pick-commit.tsx
Normal file
81
app/src/ui/drag-elements/cherry-pick-commit.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import classNames from 'classnames'
|
||||
import * as React from 'react'
|
||||
import { dragAndDropManager } from '../../lib/drag-and-drop-manager'
|
||||
import { Commit } from '../../models/commit'
|
||||
import { GitHubRepository } from '../../models/github-repository'
|
||||
import { CommitListItem } from '../history/commit-list-item'
|
||||
import { Octicon, OcticonSymbol } from '../octicons'
|
||||
|
||||
interface ICherryPickCommitProps {
|
||||
readonly commit: Commit
|
||||
readonly selectedCommits: ReadonlyArray<Commit>
|
||||
readonly gitHubRepository: GitHubRepository | null
|
||||
readonly emoji: Map<string, string>
|
||||
}
|
||||
|
||||
interface ICherryPickCommitState {
|
||||
readonly branchName: string | null
|
||||
}
|
||||
|
||||
export class CherryPickCommit extends React.Component<
|
||||
ICherryPickCommitProps,
|
||||
ICherryPickCommitState
|
||||
> {
|
||||
public constructor(props: ICherryPickCommitProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
branchName: null,
|
||||
}
|
||||
|
||||
dragAndDropManager.onEnterDropTarget(targetDescription => {
|
||||
this.setState({ branchName: targetDescription })
|
||||
})
|
||||
|
||||
dragAndDropManager.onLeaveDropTarget(() => {
|
||||
this.setState({ branchName: null })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The "copy to" label is a windows convention we are implementing to provide
|
||||
* a more intuitive ux for windows users.
|
||||
*/
|
||||
private renderDragCopyLabel(count: number) {
|
||||
const { branchName } = this.state
|
||||
if (branchName === null || __DARWIN__) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="copy-message-label">
|
||||
<div>
|
||||
<Octicon symbol={OcticonSymbol.plus} />
|
||||
Copy to <span className="branch-name">{branchName}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { commit, gitHubRepository, selectedCommits, emoji } = this.props
|
||||
const count = selectedCommits.length
|
||||
|
||||
const className = classNames({ 'multiple-selected': count > 1 })
|
||||
return (
|
||||
<div id="cherry-pick-commit-drag-element" className={className}>
|
||||
<div className="commit-box">
|
||||
<div className="count">{count}</div>
|
||||
<CommitListItem
|
||||
gitHubRepository={gitHubRepository}
|
||||
commit={commit}
|
||||
selectedCommits={selectedCommits}
|
||||
emoji={emoji}
|
||||
isLocal={false}
|
||||
showUnpushedIndicator={false}
|
||||
/>
|
||||
</div>
|
||||
{this.renderDragCopyLabel(count)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
73
app/src/ui/drag-overlay.tsx
Normal file
73
app/src/ui/drag-overlay.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import * as React from 'react'
|
||||
import { dragAndDropManager } from '../lib/drag-and-drop-manager'
|
||||
import { PopoverCaretPosition } from './lib/popover'
|
||||
|
||||
// time till we prompt the user about where to drag in seconds
|
||||
const dragPromptWaitTime = 2500
|
||||
interface IDragOverlayProps {
|
||||
readonly dragZoneDescription: string
|
||||
}
|
||||
|
||||
interface IDragOverlayState {
|
||||
readonly showDragPrompt: boolean
|
||||
}
|
||||
|
||||
export class DragOverlay extends React.Component<
|
||||
IDragOverlayProps,
|
||||
IDragOverlayState
|
||||
> {
|
||||
private timeoutId: number | null = null
|
||||
|
||||
public constructor(props: IDragOverlayProps) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
showDragPrompt: false,
|
||||
}
|
||||
}
|
||||
|
||||
private clearDragPromptTimeOut = () => {
|
||||
if (this.timeoutId !== null) {
|
||||
window.clearTimeout(this.timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
/** If drop zone is entered, hide drag prompts */
|
||||
private dragZoneEntered = (dropZoneDescription: string) => {
|
||||
if (this.props.dragZoneDescription === dropZoneDescription) {
|
||||
this.clearDragPromptTimeOut()
|
||||
this.setState({ showDragPrompt: false })
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillMount = () => {
|
||||
// sets timer to wait before prompting the user on where it drag
|
||||
this.timeoutId = window.setTimeout(() => {
|
||||
this.setState({ showDragPrompt: true })
|
||||
}, dragPromptWaitTime)
|
||||
dragAndDropManager.onEnterDragZone(this.dragZoneEntered)
|
||||
}
|
||||
|
||||
public componentWillUnmount = () => {
|
||||
this.clearDragPromptTimeOut()
|
||||
}
|
||||
|
||||
private renderDragPrompt(): JSX.Element | null {
|
||||
if (!this.state.showDragPrompt) {
|
||||
return null
|
||||
}
|
||||
|
||||
// This acts more as a tool tip as we don't want to use the focus trap as in
|
||||
// the Popover component. However, we wanted to use its styles.
|
||||
const className = `popover-component popover-caret-${PopoverCaretPosition.TopLeft}`
|
||||
return (
|
||||
<div className={className}>
|
||||
Drag to a branch in the branch menu to copy your commits
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <div id="drag-overlay">{this.renderDragPrompt()}</div>
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
|
@ -16,20 +16,27 @@ import {
|
|||
enableGitTagsCreation,
|
||||
enableCherryPicking,
|
||||
} from '../../lib/feature-flag'
|
||||
import { Draggable } from '../lib/draggable'
|
||||
|
||||
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?: (clearCherryPickingState: boolean) => void
|
||||
readonly onRenderCherryPickCommitDragElement?: (commit: Commit) => void
|
||||
readonly onRemoveCherryPickDragElement?: () => void
|
||||
readonly showUnpushedIndicator: boolean
|
||||
readonly unpushedIndicatorTitle?: string
|
||||
readonly unpushedTags?: ReadonlyArray<string>
|
||||
readonly isCherryPickInProgress?: boolean
|
||||
}
|
||||
|
||||
interface ICommitListItemState {
|
||||
|
@ -64,33 +71,45 @@ export class CommitListItem extends React.PureComponent<
|
|||
}
|
||||
|
||||
public render() {
|
||||
const commit = this.props.commit
|
||||
const { commit } = this.props
|
||||
const {
|
||||
author: { date },
|
||||
} = commit
|
||||
|
||||
const dragHandlerExists = this.props.onDragStart !== undefined
|
||||
const isDraggable = dragHandlerExists && this.canCherryPick()
|
||||
|
||||
return (
|
||||
<div className="commit" onContextMenu={this.onContextMenu}>
|
||||
<div className="info">
|
||||
<RichText
|
||||
className="summary"
|
||||
emoji={this.props.emoji}
|
||||
text={commit.summary}
|
||||
renderUrlsAsLinks={false}
|
||||
/>
|
||||
<div className="description">
|
||||
<AvatarStack users={this.state.avatarUsers} />
|
||||
<div className="byline">
|
||||
<CommitAttribution
|
||||
gitHubRepository={this.props.gitHubRepository}
|
||||
commit={commit}
|
||||
/>
|
||||
{renderRelativeTime(date)}
|
||||
<Draggable
|
||||
isEnabled={isDraggable}
|
||||
onDragStart={this.onDragStart}
|
||||
onDragEnd={this.onDragEnd}
|
||||
onRenderDragElement={this.onRenderCherryPickCommitDragElement}
|
||||
onRemoveDragElement={this.onRemoveDragElement}
|
||||
dropTargetSelectors={['.branches-list-item', '.pull-request-item']}
|
||||
>
|
||||
<div className="commit" onContextMenu={this.onContextMenu}>
|
||||
<div className="info">
|
||||
<RichText
|
||||
className="summary"
|
||||
emoji={this.props.emoji}
|
||||
text={commit.summary}
|
||||
renderUrlsAsLinks={false}
|
||||
/>
|
||||
<div className="description">
|
||||
<AvatarStack users={this.state.avatarUsers} />
|
||||
<div className="byline">
|
||||
<CommitAttribution
|
||||
gitHubRepository={this.props.gitHubRepository}
|
||||
commit={commit}
|
||||
/>
|
||||
{renderRelativeTime(date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{this.renderCommitIndicators()}
|
||||
</div>
|
||||
{this.renderCommitIndicators()}
|
||||
</div>
|
||||
</Draggable>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -146,13 +165,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
|
||||
|
||||
|
@ -198,8 +228,9 @@ export class CommitListItem extends React.PureComponent<
|
|||
|
||||
if (enableCherryPicking()) {
|
||||
items.push({
|
||||
label: __DARWIN__ ? 'Cherry Pick Commit…' : 'Cherry pick commit…',
|
||||
label: __DARWIN__ ? 'Cherry-pick Commit…' : 'Cherry-pick commit…',
|
||||
action: this.onCherryPick,
|
||||
enabled: this.canCherryPick(),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -216,7 +247,33 @@ 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,
|
||||
enabled: this.canCherryPick(),
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private canCherryPick(): boolean {
|
||||
const { onCherryPick, isCherryPickInProgress } = this.props
|
||||
return (
|
||||
onCherryPick !== undefined &&
|
||||
isCherryPickInProgress === false &&
|
||||
enableCherryPicking()
|
||||
)
|
||||
}
|
||||
|
||||
private getDeleteTagsMenuItem(): IMenuItem | null {
|
||||
|
@ -254,6 +311,36 @@ export class CommitListItem extends React.PureComponent<
|
|||
}),
|
||||
}
|
||||
}
|
||||
|
||||
private onDragStart = () => {
|
||||
// Removes active status from commit selection so they do not appear
|
||||
// highlighted in commit list.
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
if (this.props.onDragStart !== undefined) {
|
||||
this.props.onDragStart(this.props.selectedCommits)
|
||||
}
|
||||
}
|
||||
|
||||
private onDragEnd = (isOverDragTarget: boolean): void => {
|
||||
if (this.props.onDragEnd !== undefined) {
|
||||
this.props.onDragEnd(!isOverDragTarget)
|
||||
}
|
||||
}
|
||||
|
||||
private onRenderCherryPickCommitDragElement = () => {
|
||||
if (this.props.onRenderCherryPickCommitDragElement !== undefined) {
|
||||
this.props.onRenderCherryPickCommitDragElement(this.props.commit)
|
||||
}
|
||||
}
|
||||
|
||||
private onRemoveDragElement = () => {
|
||||
if (this.props.onRemoveCherryPickDragElement !== undefined) {
|
||||
this.props.onRemoveCherryPickDragElement()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: (clearCherryPickingState: boolean) => 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,29 @@ 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
|
||||
|
||||
/** Whether a cherry pick is progress */
|
||||
readonly isCherryPickInProgress: boolean
|
||||
|
||||
/** Callback to render cherry pick commit drag element */
|
||||
readonly onRenderCherryPickCommitDragElement: (
|
||||
commit: Commit,
|
||||
selectedCommits: ReadonlyArray<Commit>
|
||||
) => void
|
||||
|
||||
/** Callback to remove cherry pick commit drag element */
|
||||
readonly onRemoveCherryPickCommitDragElement: () => 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,10 +151,27 @@ 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}
|
||||
isCherryPickInProgress={this.props.isCherryPickInProgress}
|
||||
onRenderCherryPickCommitDragElement={
|
||||
this.onRenderCherryPickCommitDragElement
|
||||
}
|
||||
onRemoveCherryPickDragElement={
|
||||
this.props.onRemoveCherryPickCommitDragElement
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private onRenderCherryPickCommitDragElement = (commit: Commit) => {
|
||||
this.props.onRenderCherryPickCommitDragElement(
|
||||
commit,
|
||||
this.lookupCommits(this.props.selectedSHAs)
|
||||
)
|
||||
}
|
||||
|
||||
private getUnpushedIndicatorTitle(
|
||||
isLocalCommit: boolean,
|
||||
numUnpushedTags: number
|
||||
|
@ -146,14 +189,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 +252,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 +294,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 +308,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
|
|||
}}
|
||||
setScrollTop={this.props.compareListScrollTop}
|
||||
/>
|
||||
{this.renderCherryPickIntroPopover()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { Tokenizer, TokenResult } from '../../lib/text-token-parser'
|
||||
import { wrapRichTextCommitMessage } from '../../lib/wrap-rich-text-commit-message'
|
||||
import { DiffOptions } from '../diff/diff-options'
|
||||
import { RepositorySectionTab } from '../../lib/app-state'
|
||||
|
||||
interface ICommitSummaryProps {
|
||||
readonly repository: Repository
|
||||
|
@ -43,7 +44,7 @@ interface ICommitSummaryProps {
|
|||
|
||||
/** Whether we should display side by side diffs. */
|
||||
readonly showSideBySideDiff: boolean
|
||||
readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void
|
||||
readonly onHideWhitespaceInDiffChanged: (checked: boolean) => Promise<void>
|
||||
|
||||
/** Called when the user changes the side by side diffs setting. */
|
||||
readonly onShowSideBySideDiffChanged: (checked: boolean) => void
|
||||
|
@ -156,11 +157,11 @@ export class CommitSummary extends React.Component<
|
|||
}
|
||||
}
|
||||
|
||||
private onHideWhitespaceInDiffChanged = (
|
||||
private onHideWhitespaceInDiffChanged = async (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => {
|
||||
const value = event.currentTarget.checked
|
||||
this.props.onHideWhitespaceInDiffChanged(value)
|
||||
await this.props.onHideWhitespaceInDiffChanged(value)
|
||||
}
|
||||
|
||||
private onResized = () => {
|
||||
|
@ -374,24 +375,23 @@ export class CommitSummary extends React.Component<
|
|||
)}
|
||||
|
||||
{enableSideBySideDiffs() && (
|
||||
<>
|
||||
<li
|
||||
className="commit-summary-meta-item without-truncation"
|
||||
title="Split View"
|
||||
>
|
||||
<DiffOptions
|
||||
onHideWhitespaceChangesChanged={
|
||||
this.props.onHideWhitespaceInDiffChanged
|
||||
}
|
||||
hideWhitespaceChanges={this.props.hideWhitespaceInDiff}
|
||||
showSideBySideDiff={this.props.showSideBySideDiff}
|
||||
onShowSideBySideDiffChanged={
|
||||
this.props.onShowSideBySideDiffChanged
|
||||
}
|
||||
onDiffOptionsOpened={this.props.onDiffOptionsOpened}
|
||||
/>
|
||||
</li>
|
||||
</>
|
||||
<li
|
||||
className="commit-summary-meta-item without-truncation"
|
||||
title="Diff Options"
|
||||
>
|
||||
<DiffOptions
|
||||
sourceTab={RepositorySectionTab.History}
|
||||
hideWhitespaceChanges={this.props.hideWhitespaceInDiff}
|
||||
onHideWhitespaceChangesChanged={
|
||||
this.props.onHideWhitespaceInDiffChanged
|
||||
}
|
||||
showSideBySideDiff={this.props.showSideBySideDiff}
|
||||
onShowSideBySideDiffChanged={
|
||||
this.props.onShowSideBySideDiffChanged
|
||||
}
|
||||
onDiffOptionsOpened={this.props.onDiffOptionsOpened}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { Commit } from '../../models/commit'
|
||||
import { Commit, CommitOneLine } from '../../models/commit'
|
||||
import {
|
||||
HistoryTabMode,
|
||||
ICompareState,
|
||||
|
@ -25,6 +25,8 @@ 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'
|
||||
import { DragElementType } from '../../models/drag-element'
|
||||
|
||||
interface ICompareSidebarProps {
|
||||
readonly repository: Repository
|
||||
|
@ -35,14 +37,21 @@ 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 onDragCommitEnd: (clearCherryPickingState: boolean) => void
|
||||
readonly compareListScrollTop?: number
|
||||
readonly localTags: Map<string, string> | null
|
||||
readonly tagsToPush: ReadonlyArray<string> | null
|
||||
readonly aheadBehindStore: AheadBehindStore
|
||||
readonly hasShownCherryPickIntro: boolean
|
||||
readonly isCherryPickInProgress: boolean
|
||||
}
|
||||
|
||||
interface ICompareSidebarState {
|
||||
|
@ -217,7 +226,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 +235,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 +244,41 @@ export class CompareSidebar extends React.Component<
|
|||
onCompareListScrolled={this.props.onCompareListScrolled}
|
||||
compareListScrollTop={this.props.compareListScrollTop}
|
||||
tagsToPush={this.props.tagsToPush}
|
||||
onDragCommitStart={this.onDragCommitStart}
|
||||
onDragCommitEnd={this.props.onDragCommitEnd}
|
||||
hasShownCherryPickIntro={this.props.hasShownCherryPickIntro}
|
||||
onDismissCherryPickIntro={this.onDismissCherryPickIntro}
|
||||
isCherryPickInProgress={this.props.isCherryPickInProgress}
|
||||
onRenderCherryPickCommitDragElement={
|
||||
this.onRenderCherryPickCommitDragElement
|
||||
}
|
||||
onRemoveCherryPickCommitDragElement={
|
||||
this.onRemoveCherryPickCommitDragElement
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private onRenderCherryPickCommitDragElement = (
|
||||
commit: Commit,
|
||||
selectedCommits: ReadonlyArray<Commit>
|
||||
) => {
|
||||
this.props.dispatcher.setDragElement({
|
||||
type: DragElementType.CherryPickCommit,
|
||||
commit,
|
||||
selectedCommits,
|
||||
gitHubRepository: this.props.repository.gitHubRepository,
|
||||
})
|
||||
}
|
||||
|
||||
private onRemoveCherryPickCommitDragElement = () => {
|
||||
this.props.dispatcher.clearDragElement()
|
||||
}
|
||||
|
||||
private onDismissCherryPickIntro = () => {
|
||||
this.props.dispatcher.dismissCherryPickIntro()
|
||||
}
|
||||
|
||||
private renderActiveTab(view: ICompareBranch) {
|
||||
return (
|
||||
<div className="compare-commit-list">
|
||||
|
@ -392,10 +432,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 +546,21 @@ 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import { showContextualMenu } from '../main-process-proxy'
|
|||
import { CommitSummary } from './commit-summary'
|
||||
import { FileList } from './file-list'
|
||||
import { SeamlessDiffSwitcher } from '../diff/seamless-diff-switcher'
|
||||
import { DragOverlay } from '../drag-overlay'
|
||||
|
||||
interface ISelectedCommitProps {
|
||||
readonly repository: Repository
|
||||
|
@ -67,6 +68,12 @@ interface ISelectedCommitProps {
|
|||
|
||||
/** Called when the user opens the diff options popover */
|
||||
readonly onDiffOptionsOpened: () => void
|
||||
|
||||
/** Whether multiple commits are selected. */
|
||||
readonly areMultipleCommitsSelected: boolean
|
||||
|
||||
/** Whether or not to show the drag overlay */
|
||||
readonly showDragOverlay: boolean
|
||||
}
|
||||
|
||||
interface ISelectedCommitState {
|
||||
|
@ -183,8 +190,10 @@ export class SelectedCommit extends React.Component<
|
|||
}
|
||||
}
|
||||
|
||||
private onHideWhitespaceInDiffChanged = (hideWhitespaceInDiff: boolean) => {
|
||||
this.props.dispatcher.onHideWhitespaceInDiffChanged(
|
||||
private onHideWhitespaceInDiffChanged = async (
|
||||
hideWhitespaceInDiff: boolean
|
||||
) => {
|
||||
await this.props.dispatcher.onHideWhitespaceInDiffChanged(
|
||||
hideWhitespaceInDiff,
|
||||
this.props.repository,
|
||||
this.props.selectedFile as CommittedFileChange
|
||||
|
@ -236,6 +245,10 @@ export class SelectedCommit extends React.Component<
|
|||
public render() {
|
||||
const commit = this.props.selectedCommit
|
||||
|
||||
if (this.props.areMultipleCommitsSelected) {
|
||||
return this.renderMultipleCommitsSelected()
|
||||
}
|
||||
|
||||
if (commit == null) {
|
||||
return <NoCommitSelected />
|
||||
}
|
||||
|
@ -255,6 +268,40 @@ export class SelectedCommit extends React.Component<
|
|||
</Resizable>
|
||||
{this.renderDiff()}
|
||||
</div>
|
||||
{this.renderDragOverlay()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private renderDragOverlay(): JSX.Element | null {
|
||||
if (!this.props.showDragOverlay) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <DragOverlay dragZoneDescription="branch-button" />
|
||||
}
|
||||
|
||||
private renderMultipleCommitsSelected(): JSX.Element {
|
||||
const BlankSlateImage = encodePathAsUrl(
|
||||
__dirname,
|
||||
'static/empty-no-commit.svg'
|
||||
)
|
||||
|
||||
return (
|
||||
<div id="multiple-commits-selected" className="blankslate">
|
||||
<div 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>
|
||||
{this.renderDragOverlay()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -78,6 +78,10 @@ import momentDurationFormatSetup from 'moment-duration-format'
|
|||
import { sendNonFatalException } from '../lib/helpers/non-fatal-exception'
|
||||
import { enableUnhandledRejectionReporting } from '../lib/feature-flag'
|
||||
import { AheadBehindStore } from '../lib/stores/ahead-behind-store'
|
||||
import {
|
||||
ApplicationTheme,
|
||||
supportsSystemThemeChanges,
|
||||
} from './lib/application-theme'
|
||||
|
||||
if (__DEV__) {
|
||||
installDevGlobals()
|
||||
|
@ -176,9 +180,10 @@ const sendErrorWithContext = (
|
|||
extra.windowState = currentState.windowState
|
||||
extra.accounts = `${currentState.accounts.length}`
|
||||
|
||||
if (__DARWIN__) {
|
||||
extra.automaticallySwitchTheme = `${currentState.automaticallySwitchTheme}`
|
||||
}
|
||||
extra.automaticallySwitchTheme = `${
|
||||
currentState.selectedTheme === ApplicationTheme.System &&
|
||||
supportsSystemThemeChanges()
|
||||
}`
|
||||
}
|
||||
} catch (err) {
|
||||
/* ignore */
|
||||
|
|
|
@ -1,58 +1,53 @@
|
|||
import { assertNever } from '../../lib/fatal-error'
|
||||
import { getBoolean, setBoolean } from '../../lib/local-storage'
|
||||
import { remote } from 'electron'
|
||||
import {
|
||||
isMacOSMojaveOrLater,
|
||||
isWindows10And1809Preview17666OrLater,
|
||||
} from '../../lib/get-os'
|
||||
import { getBoolean } from '../../lib/local-storage'
|
||||
|
||||
/**
|
||||
* A set of the user-selectable appearances (aka themes)
|
||||
*/
|
||||
export enum ApplicationTheme {
|
||||
Light,
|
||||
Dark,
|
||||
System,
|
||||
}
|
||||
|
||||
export type ApplicableTheme = ApplicationTheme.Light | ApplicationTheme.Dark
|
||||
|
||||
/**
|
||||
* Gets the friendly name of an application theme for use
|
||||
* in persisting to storage and/or calculating the required
|
||||
* body class name to set in order to apply the theme.
|
||||
*/
|
||||
export function getThemeName(theme: ApplicationTheme): string {
|
||||
export function getThemeName(
|
||||
theme: ApplicationTheme
|
||||
): 'light' | 'dark' | 'system' {
|
||||
switch (theme) {
|
||||
case ApplicationTheme.Light:
|
||||
return 'light'
|
||||
case ApplicationTheme.Dark:
|
||||
return 'dark'
|
||||
default:
|
||||
return assertNever(theme, `Unknown theme ${theme}`)
|
||||
return 'system'
|
||||
}
|
||||
}
|
||||
|
||||
// The key under which the currently selected theme is persisted
|
||||
// in localStorage.
|
||||
const applicationThemeKey = 'theme'
|
||||
|
||||
/**
|
||||
* Load the currently selected theme from the persistent
|
||||
* store (localStorage). If no theme is selected the default
|
||||
* theme will be returned.
|
||||
* Load the currently selected theme
|
||||
*/
|
||||
export function getPersistedTheme(): ApplicationTheme {
|
||||
return localStorage.getItem(applicationThemeKey) === 'dark'
|
||||
? ApplicationTheme.Dark
|
||||
: ApplicationTheme.Light
|
||||
}
|
||||
const currentTheme = getPersistedThemeName()
|
||||
|
||||
/**
|
||||
* Load the name of the currently selected theme from the persistent
|
||||
* store (localStorage). If no theme is selected the default
|
||||
* theme name will be returned.
|
||||
*/
|
||||
export function getPersistedThemeName(): string {
|
||||
return getThemeName(getPersistedTheme())
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the given theme in the persistent store (localStorage).
|
||||
*/
|
||||
export function setPersistedTheme(theme: ApplicationTheme) {
|
||||
localStorage.setItem(applicationThemeKey, getThemeName(theme))
|
||||
switch (currentTheme) {
|
||||
case 'light':
|
||||
return ApplicationTheme.Light
|
||||
case 'dark':
|
||||
return ApplicationTheme.Dark
|
||||
default:
|
||||
return ApplicationTheme.System
|
||||
}
|
||||
}
|
||||
|
||||
// The key under which the decision to automatically switch the theme is persisted
|
||||
|
@ -60,17 +55,95 @@ export function setPersistedTheme(theme: ApplicationTheme) {
|
|||
const automaticallySwitchApplicationThemeKey = 'autoSwitchTheme'
|
||||
|
||||
/**
|
||||
* Load the whether or not the user wishes to automatically switch the selected theme from the persistent
|
||||
* store (localStorage). If no theme is selected the default
|
||||
* theme will be returned.
|
||||
* Function to preserve and convert legacy theme settings
|
||||
* should be removable after most users have upgraded to 2.7.0+
|
||||
*/
|
||||
export function getAutoSwitchPersistedTheme(): boolean {
|
||||
return getBoolean(automaticallySwitchApplicationThemeKey, false)
|
||||
function migrateAutomaticallySwitchSetting(): string | null {
|
||||
const automaticallySwitchApplicationTheme = getBoolean(
|
||||
automaticallySwitchApplicationThemeKey,
|
||||
false
|
||||
)
|
||||
|
||||
localStorage.removeItem(automaticallySwitchApplicationThemeKey)
|
||||
|
||||
if (automaticallySwitchApplicationTheme) {
|
||||
setPersistedTheme(ApplicationTheme.System)
|
||||
return 'system'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// The key under which the currently selected theme is persisted
|
||||
// in localStorage.
|
||||
const applicationThemeKey = 'theme'
|
||||
|
||||
/**
|
||||
* Returns User's theme preference or 'system' if not set or parsable
|
||||
*/
|
||||
function getApplicationThemeSetting(): 'light' | 'dark' | 'system' {
|
||||
const themeSetting = localStorage.getItem(applicationThemeKey)
|
||||
|
||||
if (themeSetting === null) {
|
||||
return 'system'
|
||||
}
|
||||
|
||||
if (
|
||||
themeSetting === 'light' ||
|
||||
themeSetting === 'dark' ||
|
||||
themeSetting === 'system'
|
||||
) {
|
||||
return themeSetting
|
||||
}
|
||||
|
||||
return 'system'
|
||||
}
|
||||
|
||||
/**
|
||||
* Store whether or not the user wishes to automatically switch the selected theme in the persistent store (localStorage).
|
||||
* Load the name of the currently selected theme
|
||||
*/
|
||||
export function setAutoSwitchPersistedTheme(autoSwitchTheme: boolean) {
|
||||
setBoolean(automaticallySwitchApplicationThemeKey, autoSwitchTheme)
|
||||
export function getCurrentlyAppliedTheme(): ApplicableTheme {
|
||||
return isDarkModeEnabled() ? ApplicationTheme.Dark : ApplicationTheme.Light
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the name of the currently selected theme
|
||||
*/
|
||||
export function getPersistedThemeName(): string {
|
||||
const setting = migrateAutomaticallySwitchSetting()
|
||||
|
||||
if (setting === 'system') {
|
||||
return setting
|
||||
}
|
||||
|
||||
return getApplicationThemeSetting()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the given theme in the persistent store.
|
||||
*/
|
||||
export function setPersistedTheme(theme: ApplicationTheme): void {
|
||||
const themeName = getThemeName(theme)
|
||||
localStorage.setItem(applicationThemeKey, themeName)
|
||||
remote.nativeTheme.themeSource = themeName
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not the current OS supports System Theme Changes
|
||||
*/
|
||||
export function supportsSystemThemeChanges(): boolean {
|
||||
if (__DARWIN__) {
|
||||
return isMacOSMojaveOrLater()
|
||||
} else if (__WIN32__) {
|
||||
// Its technically possible this would still work on prior versions of Windows 10 but 1809
|
||||
// was released October 2nd, 2018 and the feature can just be "attained" by upgrading
|
||||
// See https://github.com/desktop/desktop/issues/9015 for more
|
||||
return isWindows10And1809Preview17666OrLater()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isDarkModeEnabled(): boolean {
|
||||
return remote.nativeTheme.shouldUseDarkColors
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
ConflictedFileStatus,
|
||||
ConflictsWithMarkers,
|
||||
ManualConflict,
|
||||
GitStatusEntry,
|
||||
} from '../../../models/status'
|
||||
import { join } from 'path'
|
||||
import { Repository } from '../../../models/repository'
|
||||
|
@ -48,9 +49,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 +61,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.
|
||||
|
@ -158,12 +163,28 @@ const renderManualConflictedFile: React.FunctionComponent<{
|
|||
props.ourBranch,
|
||||
props.theirBranch
|
||||
)
|
||||
const { ourBranch, theirBranch } = props
|
||||
const { entry } = props.status
|
||||
|
||||
let conflictTypeString = manualConflictString
|
||||
|
||||
if ([entry.us, entry.them].includes(GitStatusEntry.Deleted)) {
|
||||
let targetBranch = 'target branch'
|
||||
if (entry.us === GitStatusEntry.Deleted && ourBranch !== undefined) {
|
||||
targetBranch = ourBranch
|
||||
}
|
||||
|
||||
if (entry.them === GitStatusEntry.Deleted && theirBranch !== undefined) {
|
||||
targetBranch = theirBranch
|
||||
}
|
||||
conflictTypeString = `File does not exist on ${targetBranch}.`
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="column-left">
|
||||
<PathText path={props.path} />
|
||||
<div className="file-conflicts-status">{manualConflictString}</div>
|
||||
<div className="file-conflicts-status">{conflictTypeString}</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<Button
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
import { remote } from 'electron'
|
||||
import {
|
||||
isMacOSMojaveOrLater,
|
||||
isWindows10And1809Preview17666OrLater,
|
||||
} from '../../lib/get-os'
|
||||
|
||||
export function supportsDarkMode() {
|
||||
if (__DARWIN__) {
|
||||
return isMacOSMojaveOrLater()
|
||||
} else if (__WIN32__) {
|
||||
// Its technically possible this would still work on prior versions of Windows 10 but 1809
|
||||
// was released October 2nd, 2018 that the feature can just be "attained" by upgrading
|
||||
// See https://github.com/desktop/desktop/issues/9015 for more
|
||||
return isWindows10And1809Preview17666OrLater()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isDarkModeEnabled() {
|
||||
if (!supportsDarkMode()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// remote is an IPC call, so if we know there's no point making this call
|
||||
// we should avoid paying the IPC tax
|
||||
return remote.nativeTheme.shouldUseDarkColors
|
||||
}
|
178
app/src/ui/lib/draggable.tsx
Normal file
178
app/src/ui/lib/draggable.tsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
import * as React from 'react'
|
||||
import { dragAndDropManager } from '../../lib/drag-and-drop-manager'
|
||||
import { mouseScroller } from '../../lib/mouse-scroller'
|
||||
import { sleep } from '../../lib/promise'
|
||||
|
||||
interface IDraggableProps {
|
||||
/**
|
||||
* Callback for when a drag starts - user must hold down (mouse down event)
|
||||
* and move the mouse (mouse move event)
|
||||
*/
|
||||
readonly onDragStart: () => void
|
||||
|
||||
/**
|
||||
* Callback for when the drag ends - user releases mouse (mouse up event) or
|
||||
* mouse goes out of screen
|
||||
*
|
||||
* @param isOverDropTarget - whether the last element the mouse was over
|
||||
* before the mouse up event matches one of the dropTargetSelectors provided
|
||||
*/
|
||||
readonly onDragEnd: (isOverDropTarget: boolean) => void
|
||||
|
||||
/** Callback to render a drag element inside the #dragElement */
|
||||
readonly onRenderDragElement: () => void
|
||||
|
||||
/** Callback to remove a drag element inside the #dragElement */
|
||||
readonly onRemoveDragElement: () => void
|
||||
|
||||
/** Whether dragging is enabled */
|
||||
readonly isEnabled: boolean
|
||||
|
||||
/** An array of css selectors for elements that are valid drop targets. */
|
||||
readonly dropTargetSelectors: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export class Draggable extends React.Component<IDraggableProps> {
|
||||
private hasDragStarted: boolean = false
|
||||
private hasDragEnded: boolean = false
|
||||
private dragElement: HTMLElement | null = null
|
||||
private elemBelow: Element | null = null
|
||||
// Default offset to place the cursor slightly above the top left corner of
|
||||
// the drag element. Note: if placed at (0,0) or cursor is inside the
|
||||
// dragElement then elemBelow will always return the dragElement and cannot
|
||||
// detect drop targets or scroll elements.
|
||||
private verticalOffset: number = __DARWIN__ ? 32 : 15
|
||||
|
||||
public componentDidMount() {
|
||||
this.dragElement = document.getElementById('dragElement')
|
||||
}
|
||||
|
||||
private canDragCommit(event: React.MouseEvent<HTMLDivElement>): boolean {
|
||||
// right clicks or shift clicks
|
||||
const isSpecialClick =
|
||||
event.button === 2 ||
|
||||
(__DARWIN__ && event.button === 0 && event.ctrlKey) ||
|
||||
event.shiftKey
|
||||
|
||||
return !isSpecialClick && this.props.isEnabled
|
||||
}
|
||||
|
||||
private initializeDrag(): void {
|
||||
this.hasDragStarted = false
|
||||
this.elemBelow = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the drag event.
|
||||
*
|
||||
* - clears variables from last drag
|
||||
* - sets up mouse move and mouse up listeners
|
||||
*/
|
||||
private onMouseDown = async (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!this.canDragCommit(event)) {
|
||||
return
|
||||
}
|
||||
this.hasDragEnded = false
|
||||
document.onmouseup = this.handleDragEndEvent
|
||||
await sleep(100)
|
||||
if (this.hasDragEnded) {
|
||||
return
|
||||
}
|
||||
|
||||
this.initializeDrag()
|
||||
document.addEventListener('mousemove', this.onMouseMove)
|
||||
}
|
||||
|
||||
/**
|
||||
* During drag event
|
||||
*
|
||||
* Note: A drag is not started until a user moves their mouse. This is
|
||||
* important or the drag will start and drag element will render for a user
|
||||
* just clicking a draggable element.
|
||||
*/
|
||||
private onMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (this.hasDragEnded) {
|
||||
this.onDragEnd()
|
||||
return
|
||||
}
|
||||
// start drag
|
||||
if (!this.hasDragStarted) {
|
||||
this.props.onRenderDragElement()
|
||||
this.props.onDragStart()
|
||||
dragAndDropManager.dragStarted()
|
||||
this.hasDragStarted = true
|
||||
window.addEventListener('keyup', this.onKeyUp)
|
||||
}
|
||||
|
||||
// move drag element where mouse is
|
||||
if (this.dragElement !== null) {
|
||||
this.dragElement.style.left = moveEvent.pageX + 0 + 'px'
|
||||
this.dragElement.style.top = moveEvent.pageY + this.verticalOffset + 'px'
|
||||
}
|
||||
|
||||
// inspect element mouse is is hovering over
|
||||
this.elemBelow = document.elementFromPoint(
|
||||
moveEvent.clientX,
|
||||
moveEvent.clientY
|
||||
)
|
||||
|
||||
if (this.elemBelow === null) {
|
||||
mouseScroller.clearScrollTimer()
|
||||
return
|
||||
}
|
||||
|
||||
mouseScroller.setupMouseScroll(this.elemBelow, moveEvent.clientY)
|
||||
}
|
||||
|
||||
/**
|
||||
* End a drag event
|
||||
*/
|
||||
private handleDragEndEvent = () => {
|
||||
this.hasDragEnded = true
|
||||
if (this.hasDragStarted) {
|
||||
this.onDragEnd()
|
||||
}
|
||||
document.onmouseup = null
|
||||
window.removeEventListener('keyup', this.onKeyUp)
|
||||
}
|
||||
|
||||
private onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
this.handleDragEndEvent()
|
||||
}
|
||||
|
||||
private onDragEnd(): void {
|
||||
document.removeEventListener('mousemove', this.onMouseMove)
|
||||
mouseScroller.clearScrollTimer()
|
||||
this.props.onRemoveDragElement()
|
||||
this.props.onDragEnd(this.isLastElemBelowDropTarget())
|
||||
dragAndDropManager.dragEnded()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the last element that the mouse was over during a drag with the
|
||||
* css selectors provided in dropTargetSelectors to determine if the drag
|
||||
* ended on target or not.
|
||||
*/
|
||||
private isLastElemBelowDropTarget = (): boolean => {
|
||||
if (this.elemBelow === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const foundDropTarget = this.props.dropTargetSelectors.find(dts => {
|
||||
return this.elemBelow !== null && this.elemBelow.closest(dts) !== null
|
||||
})
|
||||
|
||||
return foundDropTarget !== undefined
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div className="draggable" onMouseDown={this.onMouseDown}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -55,10 +55,17 @@ export class GitConfigUserForm extends React.Component<
|
|||
prevProps: IGitConfigUserFormProps,
|
||||
prevState: IGitConfigUserFormState
|
||||
) {
|
||||
const isEmailInputFocused =
|
||||
this.emailInputRef.current !== null &&
|
||||
this.emailInputRef.current.isFocused
|
||||
|
||||
// If the email coming from the props has changed, it means a new config
|
||||
// was loaded into the form. In that case, make sure to only select the
|
||||
// option "Other" if strictly needed.
|
||||
if (prevProps.email !== this.props.email) {
|
||||
// option "Other" if strictly needed, and select one of the account emails
|
||||
// otherwise.
|
||||
// If the "Other email" input field is currently focused, we won't hide it
|
||||
// from the user, to prevent annoying UI glitches.
|
||||
if (prevProps.email !== this.props.email && !isEmailInputFocused) {
|
||||
this.setState({
|
||||
emailIsOther: !this.accountEmails.includes(this.props.email),
|
||||
})
|
||||
|
|
|
@ -2,6 +2,7 @@ import * as React from 'react'
|
|||
import { Account } from '../../models/account'
|
||||
import { LinkButton } from './link-button'
|
||||
import { getDotComAPIEndpoint } from '../../lib/api'
|
||||
import { isAccountEmail } from '../../lib/is-account-email'
|
||||
|
||||
interface IGitEmailNotFoundWarningProps {
|
||||
/** The account the commit should be attributed to. */
|
||||
|
@ -31,7 +32,7 @@ export class GitEmailNotFoundWarning extends React.Component<
|
|||
public render() {
|
||||
if (
|
||||
this.props.accounts.length === 0 ||
|
||||
this.accountEmails.includes(this.props.email)
|
||||
isAccountEmail(this.accountEmails, this.props.email)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -16,11 +16,12 @@ import { Options as FocusTrapOptions } from 'focus-trap'
|
|||
**/
|
||||
export enum PopoverCaretPosition {
|
||||
TopRight = 'top-right',
|
||||
TopLeft = 'top-left',
|
||||
LeftTop = 'left-top',
|
||||
LeftBottom = 'left-bottom',
|
||||
}
|
||||
interface IPopoverProps {
|
||||
readonly onClickOutside: () => void
|
||||
readonly onClickOutside?: () => void
|
||||
readonly caretPosition: PopoverCaretPosition
|
||||
}
|
||||
|
||||
|
@ -54,7 +55,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()
|
||||
}
|
||||
|
|
|
@ -143,6 +143,15 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Determines if the contained text input element is currently focused. */
|
||||
public get isFocused() {
|
||||
return (
|
||||
this.inputElement !== null &&
|
||||
document.activeElement !== null &&
|
||||
this.inputElement === document.activeElement
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatically moves keyboard focus to the inner text input element if it can be focused
|
||||
* (i.e. if it's not disabled explicitly or implicitly through for example a fieldset).
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import { remote } from 'electron'
|
||||
import { ApplicationTheme } from './application-theme'
|
||||
import {
|
||||
ApplicableTheme,
|
||||
getCurrentlyAppliedTheme,
|
||||
supportsSystemThemeChanges,
|
||||
} from './application-theme'
|
||||
import { IDisposable, Disposable, Emitter } from 'event-kit'
|
||||
import { supportsDarkMode, isDarkModeEnabled } from './dark-theme'
|
||||
|
||||
class ThemeChangeMonitor implements IDisposable {
|
||||
private readonly emitter = new Emitter()
|
||||
|
@ -15,27 +18,23 @@ class ThemeChangeMonitor implements IDisposable {
|
|||
}
|
||||
|
||||
private subscribe = () => {
|
||||
if (!supportsDarkMode()) {
|
||||
if (!supportsSystemThemeChanges()) {
|
||||
return
|
||||
}
|
||||
|
||||
remote.nativeTheme.addListener('updated', this.onThemeNotificationFromOS)
|
||||
remote.nativeTheme.addListener('updated', this.onThemeNotificationUpdated)
|
||||
}
|
||||
|
||||
private onThemeNotificationFromOS = (event: string, userInfo: any) => {
|
||||
const darkModeEnabled = isDarkModeEnabled()
|
||||
|
||||
const theme = darkModeEnabled
|
||||
? ApplicationTheme.Dark
|
||||
: ApplicationTheme.Light
|
||||
private onThemeNotificationUpdated = (event: string, userInfo: any) => {
|
||||
const theme = getCurrentlyAppliedTheme()
|
||||
this.emitThemeChanged(theme)
|
||||
}
|
||||
|
||||
public onThemeChanged(fn: (theme: ApplicationTheme) => void): Disposable {
|
||||
public onThemeChanged(fn: (theme: ApplicableTheme) => void): Disposable {
|
||||
return this.emitter.on('theme-changed', fn)
|
||||
}
|
||||
|
||||
private emitThemeChanged(theme: ApplicationTheme) {
|
||||
private emitThemeChanged(theme: ApplicableTheme) {
|
||||
this.emitter.emit('theme-changed', theme)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -170,6 +170,9 @@ export class LocalChangesOverwrittenDialog extends React.Component<
|
|||
return 'fetch'
|
||||
case RetryActionType.Push:
|
||||
return 'push'
|
||||
case RetryActionType.CherryPick:
|
||||
case RetryActionType.CreateBranchForCherryPick:
|
||||
return 'cherry-pick'
|
||||
default:
|
||||
assertNever(
|
||||
this.props.retryAction,
|
||||
|
|
|
@ -118,9 +118,60 @@ function mergeDeferredContextMenuItems(
|
|||
})
|
||||
}
|
||||
|
||||
if (!__DARWIN__) {
|
||||
// NOTE: "On macOS as we use the native APIs there is no way to set the
|
||||
// language that the spellchecker uses" -- electron docs Therefore, we are
|
||||
// only allowing setting to English for non-mac machines.
|
||||
const spellCheckLanguageItem = getSpellCheckLanguageMenuItem(
|
||||
webContents.session
|
||||
)
|
||||
if (spellCheckLanguageItem !== null) {
|
||||
items.push(spellCheckLanguageItem)
|
||||
}
|
||||
}
|
||||
|
||||
showContextualMenu(items, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to get a menu item to give user the option to use English or their
|
||||
* system language.
|
||||
*
|
||||
* If system language is english, it returns null. If spellchecker is not set to
|
||||
* english, it returns item that can set it to English. If spellchecker is set
|
||||
* to english, it returns the item that can set it to their system language.
|
||||
*/
|
||||
function getSpellCheckLanguageMenuItem(
|
||||
session: Electron.session
|
||||
): IMenuItem | null {
|
||||
const userLanguageCode = remote.app.getLocale()
|
||||
const englishLanguageCode = 'en-US'
|
||||
const spellcheckLanguageCodes = session.getSpellCheckerLanguages()
|
||||
|
||||
if (
|
||||
userLanguageCode === englishLanguageCode &&
|
||||
spellcheckLanguageCodes.includes(englishLanguageCode)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const languageCode =
|
||||
spellcheckLanguageCodes.includes(englishLanguageCode) &&
|
||||
!spellcheckLanguageCodes.includes(userLanguageCode)
|
||||
? userLanguageCode
|
||||
: englishLanguageCode
|
||||
|
||||
const label =
|
||||
languageCode === englishLanguageCode
|
||||
? 'Set spellcheck to English'
|
||||
: 'Set spellcheck to system language'
|
||||
|
||||
return {
|
||||
label,
|
||||
action: () => session.setSpellCheckerLanguages([languageCode]),
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the given menu items in a contextual menu. */
|
||||
export async function showContextualMenu(
|
||||
items: ReadonlyArray<IMenuItem>,
|
||||
|
|
69
app/src/ui/move-to-applications-folder.tsx
Normal file
69
app/src/ui/move-to-applications-folder.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import * as React from 'react'
|
||||
import { sendNonFatalException } from '../lib/helpers/non-fatal-exception'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
OkCancelButtonGroup,
|
||||
} from './dialog'
|
||||
import { Dispatcher } from './dispatcher'
|
||||
|
||||
interface IMoveToApplicationsFolderProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
|
||||
/**
|
||||
* Callback to use when the dialog gets closed.
|
||||
*/
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
export class MoveToApplicationsFolder extends React.Component<
|
||||
IMoveToApplicationsFolderProps
|
||||
> {
|
||||
public render() {
|
||||
return (
|
||||
<Dialog
|
||||
title="Move GitHub Desktop to the Applications folder?"
|
||||
id="move-to-applications-folder"
|
||||
onDismissed={this.props.onDismissed}
|
||||
onSubmit={this.onSubmit}
|
||||
type="warning"
|
||||
>
|
||||
<DialogContent>
|
||||
<p>
|
||||
We've detected that you're not running GitHub Desktop from the
|
||||
Applications folder of your machine. This could cause problems with
|
||||
the app, including impacting your ability to sign in.
|
||||
<br />
|
||||
<br />
|
||||
Do you want to move GitHub Desktop to the Applications folder now?
|
||||
This will also restart the app.
|
||||
</p>
|
||||
</DialogContent>
|
||||
{this.renderFooter()}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private renderFooter() {
|
||||
return (
|
||||
<DialogFooter>
|
||||
<OkCancelButtonGroup
|
||||
okButtonText={'Move and Restart'}
|
||||
okButtonTitle="This will move GitHub Desktop to the Applications folder in your machine and restart the app."
|
||||
cancelButtonText="Cancel"
|
||||
/>
|
||||
</DialogFooter>
|
||||
)
|
||||
}
|
||||
|
||||
private onSubmit = async () => {
|
||||
try {
|
||||
this.props.dispatcher.moveToApplicationsFolder()
|
||||
} catch (error) {
|
||||
sendNonFatalException('moveApplication', error)
|
||||
}
|
||||
|
||||
this.props.onDismissed()
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ import * as React from 'react'
|
|||
import { OcticonSymbol } from './octicons.generated'
|
||||
import classNames from 'classnames'
|
||||
import { createUniqueId, releaseUniqueId } from '../lib/id-pool'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
interface IOcticonProps {
|
||||
/**
|
||||
|
@ -76,3 +77,25 @@ export class Octicon extends React.Component<IOcticonProps, {}> {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Octicon element for the DOM, wrapped in a div element.
|
||||
*
|
||||
* @param symbol OcticonSymbol to render in the element.
|
||||
* @param className Optional class to add to the wrapper element.
|
||||
* @param id Optional identifier to set to the wrapper element.
|
||||
*/
|
||||
export function createOcticonElement(
|
||||
symbol: OcticonSymbol,
|
||||
className?: string,
|
||||
id?: string
|
||||
) {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.id = id ?? ''
|
||||
if (className !== undefined) {
|
||||
wrapper.classList.add(className)
|
||||
}
|
||||
const octicon = <Octicon symbol={symbol} />
|
||||
ReactDOM.render(octicon, wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
|
|
@ -1,19 +1,25 @@
|
|||
import * as React from 'react'
|
||||
import { supportsDarkMode, isDarkModeEnabled } from '../lib/dark-theme'
|
||||
import { Checkbox, CheckboxValue } from '../lib/checkbox'
|
||||
import {
|
||||
ApplicationTheme,
|
||||
supportsSystemThemeChanges,
|
||||
getCurrentlyAppliedTheme,
|
||||
} from '../lib/application-theme'
|
||||
import { Row } from '../lib/row'
|
||||
import { DialogContent } from '../dialog'
|
||||
import {
|
||||
VerticalSegmentedControl,
|
||||
ISegmentedItem,
|
||||
} from '../lib/vertical-segmented-control'
|
||||
import { ApplicationTheme } from '../lib/application-theme'
|
||||
|
||||
interface IAppearanceProps {
|
||||
readonly selectedTheme: ApplicationTheme
|
||||
readonly onSelectedThemeChanged: (theme: ApplicationTheme) => void
|
||||
readonly automaticallySwitchTheme: boolean
|
||||
readonly onAutomaticallySwitchThemeChanged: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const systemTheme: ISegmentedItem<ApplicationTheme> = {
|
||||
title: 'System',
|
||||
description: 'Automatically switch theme to match system theme.',
|
||||
key: ApplicationTheme.System,
|
||||
}
|
||||
|
||||
const themes: ReadonlyArray<ISegmentedItem<ApplicationTheme>> = [
|
||||
|
@ -27,66 +33,34 @@ const themes: ReadonlyArray<ISegmentedItem<ApplicationTheme>> = [
|
|||
description: 'GitHub Desktop is for you too, creatures of the night',
|
||||
key: ApplicationTheme.Dark,
|
||||
},
|
||||
...(supportsSystemThemeChanges() ? [systemTheme] : []),
|
||||
]
|
||||
|
||||
export class Appearance extends React.Component<IAppearanceProps, {}> {
|
||||
private onSelectedThemeChanged = (value: ApplicationTheme) => {
|
||||
this.props.onSelectedThemeChanged(value)
|
||||
this.props.onAutomaticallySwitchThemeChanged(false)
|
||||
}
|
||||
|
||||
private onAutomaticallySwitchThemeChanged = (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => {
|
||||
const value = event.currentTarget.checked
|
||||
|
||||
if (value) {
|
||||
this.onSelectedThemeChanged(
|
||||
isDarkModeEnabled() ? ApplicationTheme.Dark : ApplicationTheme.Light
|
||||
)
|
||||
}
|
||||
|
||||
this.props.onAutomaticallySwitchThemeChanged(value)
|
||||
private onSelectedThemeChanged = (theme: ApplicationTheme) => {
|
||||
this.props.onSelectedThemeChanged(theme)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<DialogContent>
|
||||
{this.renderThemeOptions()}
|
||||
{this.renderAutoSwitcherOption()}
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
let selectedTheme = this.props.selectedTheme
|
||||
|
||||
public renderThemeOptions() {
|
||||
return (
|
||||
<Row>
|
||||
<VerticalSegmentedControl
|
||||
items={themes}
|
||||
selectedKey={this.props.selectedTheme}
|
||||
onSelectionChanged={this.onSelectedThemeChanged}
|
||||
/>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
public renderAutoSwitcherOption() {
|
||||
if (!supportsDarkMode()) {
|
||||
return null
|
||||
if (
|
||||
this.props.selectedTheme === ApplicationTheme.System &&
|
||||
!supportsSystemThemeChanges()
|
||||
) {
|
||||
selectedTheme = getCurrentlyAppliedTheme()
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Checkbox
|
||||
label="Automatically switch theme to match system theme."
|
||||
value={
|
||||
this.props.automaticallySwitchTheme
|
||||
? CheckboxValue.On
|
||||
: CheckboxValue.Off
|
||||
}
|
||||
onChange={this.onAutomaticallySwitchThemeChanged}
|
||||
/>
|
||||
</Row>
|
||||
<DialogContent>
|
||||
<Row>
|
||||
<VerticalSegmentedControl
|
||||
items={themes}
|
||||
selectedKey={selectedTheme}
|
||||
onSelectionChanged={this.onSelectedThemeChanged}
|
||||
/>
|
||||
</Row>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,7 +55,6 @@ interface IPreferencesProps {
|
|||
readonly selectedExternalEditor: string | null
|
||||
readonly selectedShell: Shell
|
||||
readonly selectedTheme: ApplicationTheme
|
||||
readonly automaticallySwitchTheme: boolean
|
||||
readonly repositoryIndicatorsEnabled: boolean
|
||||
}
|
||||
|
||||
|
@ -302,10 +301,6 @@ export class Preferences extends React.Component<
|
|||
<Appearance
|
||||
selectedTheme={this.props.selectedTheme}
|
||||
onSelectedThemeChanged={this.onSelectedThemeChanged}
|
||||
automaticallySwitchTheme={this.props.automaticallySwitchTheme}
|
||||
onAutomaticallySwitchThemeChanged={
|
||||
this.onAutomaticallySwitchThemeChanged
|
||||
}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
@ -413,14 +408,6 @@ export class Preferences extends React.Component<
|
|||
this.props.dispatcher.setSelectedTheme(theme)
|
||||
}
|
||||
|
||||
private onAutomaticallySwitchThemeChanged = (
|
||||
automaticallySwitchTheme: boolean
|
||||
) => {
|
||||
this.props.dispatcher.onAutomaticallySwitchThemeChanged(
|
||||
automaticallySwitchTheme
|
||||
)
|
||||
}
|
||||
|
||||
private renderFooter() {
|
||||
const hasDisabledError = this.state.disallowedCharactersMessage != null
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
@ -14,6 +14,7 @@ import {
|
|||
IRepositoryState,
|
||||
RepositorySectionTab,
|
||||
ChangesSelectionKind,
|
||||
FoldoutType,
|
||||
} from '../lib/app-state'
|
||||
import { Dispatcher } from './dispatcher'
|
||||
import { IssuesStore, GitHubUserStore } from '../lib/stores'
|
||||
|
@ -28,6 +29,7 @@ import { TutorialPanel, TutorialWelcome, TutorialDone } from './tutorial'
|
|||
import { TutorialStep, isValidTutorialStep } from '../models/tutorial-step'
|
||||
import { openFile } from './lib/open-file'
|
||||
import { AheadBehindStore } from '../lib/stores/ahead-behind-store'
|
||||
import { CherryPickStepKind } from '../models/cherry-pick'
|
||||
|
||||
/** The widest the sidebar can be with the minimum window size. */
|
||||
const MaxSidebarWidth = 495
|
||||
|
@ -85,6 +87,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 {
|
||||
|
@ -146,12 +154,21 @@ export class RepositoryView extends React.Component<
|
|||
</span>
|
||||
|
||||
<div className="with-indicator">
|
||||
<span>History</span>
|
||||
<span>History {this.renderNewCallToActionBubble()}</span>
|
||||
</div>
|
||||
</TabBar>
|
||||
)
|
||||
}
|
||||
|
||||
private renderNewCallToActionBubble(): JSX.Element | null {
|
||||
const { hasShownCherryPickIntro, state } = this.props
|
||||
const { compareState } = state
|
||||
if (hasShownCherryPickIntro || compareState.commitSHAs.length === 0) {
|
||||
return null
|
||||
}
|
||||
return <span className="call-to-action-bubble">New</span>
|
||||
}
|
||||
|
||||
private renderChangesSidebar(): JSX.Element {
|
||||
const tip = this.props.state.branchesState.tip
|
||||
|
||||
|
@ -226,7 +243,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 +253,13 @@ 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}
|
||||
onDragCommitEnd={this.onDragCommitEnd}
|
||||
isCherryPickInProgress={this.props.state.cherryPickState.step !== null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -322,15 +343,21 @@ export class RepositoryView extends React.Component<
|
|||
}
|
||||
|
||||
private renderContentForHistory(): JSX.Element {
|
||||
const { commitSelection } = this.props.state
|
||||
const { commitSelection, cherryPickState } = 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
|
||||
|
||||
const { changedFiles, file, diff } = commitSelection
|
||||
|
||||
const { step } = cherryPickState
|
||||
|
||||
const showDragOverlay =
|
||||
step !== null && step.kind === CherryPickStepKind.CommitsChosen
|
||||
|
||||
return (
|
||||
<SelectedCommit
|
||||
repository={this.props.repository}
|
||||
|
@ -349,6 +376,8 @@ export class RepositoryView extends React.Component<
|
|||
onOpenBinaryFile={this.onOpenBinaryFile}
|
||||
onChangeImageDiffType={this.onChangeImageDiffType}
|
||||
onDiffOptionsOpened={this.onDiffOptionsOpened}
|
||||
areMultipleCommitsSelected={commitSelection.shas.length > 1}
|
||||
showDragOverlay={showDragOverlay}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -535,4 +564,25 @@ export class RepositoryView extends React.Component<
|
|||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = async (clearCherryPickingState: boolean) => {
|
||||
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
|
||||
|
||||
if (!clearCherryPickingState) {
|
||||
return
|
||||
}
|
||||
|
||||
const { state, repository } = this.props
|
||||
const { cherryPickState } = state
|
||||
if (cherryPickState !== null && cherryPickState.step !== null) {
|
||||
this.props.dispatcher.endCherryPickFlow(repository)
|
||||
this.props.dispatcher.recordCherryPickDragStartedAndCanceled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import * as React from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { dragAndDropManager } from '../lib/drag-and-drop-manager'
|
||||
|
||||
/** Time to wait for drag element hover before switching tabs */
|
||||
const dragTabSwitchWaitTime = 500
|
||||
|
||||
/** The tab bar type. */
|
||||
export enum TabBarType {
|
||||
|
@ -22,6 +26,9 @@ interface ITabBarProps {
|
|||
|
||||
/** The type of TabBar controlling its style */
|
||||
readonly type?: TabBarType
|
||||
|
||||
/** Navigate via drag over */
|
||||
readonly allowDragOverSwitching?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -31,6 +38,7 @@ interface ITabBarProps {
|
|||
*/
|
||||
export class TabBar extends React.Component<ITabBarProps, {}> {
|
||||
private readonly tabRefsByIndex = new Map<number, HTMLButtonElement>()
|
||||
private mouseOverTimeoutId: number | null = null
|
||||
|
||||
public render() {
|
||||
const { type } = this.props
|
||||
|
@ -88,6 +96,31 @@ export class TabBar extends React.Component<ITabBarProps, {}> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If something is being dragged, this allows for tab selection by hovering
|
||||
* over a tab for a few seconds (dragTabSwitchWaitTime)
|
||||
*/
|
||||
private onMouseEnter = (index: number) => {
|
||||
if (
|
||||
index === this.props.selectedIndex ||
|
||||
!dragAndDropManager.isDragInProgress ||
|
||||
this.props.allowDragOverSwitching === undefined ||
|
||||
!this.props.allowDragOverSwitching
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.mouseOverTimeoutId = window.setTimeout(() => {
|
||||
this.onTabClicked(index)
|
||||
}, dragTabSwitchWaitTime)
|
||||
}
|
||||
|
||||
private onMouseLeave = () => {
|
||||
if (this.mouseOverTimeoutId !== null) {
|
||||
window.clearTimeout(this.mouseOverTimeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
private renderItems() {
|
||||
const children = React.Children.toArray(this.props.children)
|
||||
|
||||
|
@ -99,6 +132,8 @@ export class TabBar extends React.Component<ITabBarProps, {}> {
|
|||
selected={selected}
|
||||
index={index}
|
||||
onClick={this.onTabClicked}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onSelectAdjacent={this.onSelectAdjacentTab}
|
||||
onButtonRef={this.onTabRef}
|
||||
type={this.props.type}
|
||||
|
@ -114,6 +149,8 @@ interface ITabBarItemProps {
|
|||
readonly index: number
|
||||
readonly selected: boolean
|
||||
readonly onClick: (index: number) => void
|
||||
readonly onMouseEnter: (index: number) => void
|
||||
readonly onMouseLeave: () => void
|
||||
readonly onSelectAdjacent: (
|
||||
direction: 'next' | 'previous',
|
||||
index: number
|
||||
|
@ -147,6 +184,10 @@ class TabBarItem extends React.Component<ITabBarItemProps, {}> {
|
|||
this.props.onButtonRef(this.props.index, buttonRef)
|
||||
}
|
||||
|
||||
private onMouseEnter = () => {
|
||||
this.props.onMouseEnter(this.props.index)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const selected = this.props.selected
|
||||
const className = classNames('tab-bar-item', { selected })
|
||||
|
@ -159,6 +200,8 @@ class TabBarItem extends React.Component<ITabBarItemProps, {}> {
|
|||
aria-selected={selected}
|
||||
tabIndex={selected ? undefined : -1}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.props.onMouseLeave}
|
||||
type="button"
|
||||
>
|
||||
{this.props.children}
|
||||
|
|
|
@ -4,12 +4,18 @@ 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'
|
||||
import { dragAndDropManager } from '../../lib/drag-and-drop-manager'
|
||||
|
||||
interface IBranchDropdownProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
|
@ -45,6 +51,12 @@ interface IBranchDropdownProps {
|
|||
|
||||
/** Whether this component should show its onboarding tutorial nudge arrow */
|
||||
readonly shouldNudge: boolean
|
||||
|
||||
/** When a drag element enters a branch */
|
||||
readonly onDragEnterBranch: (branchName: string) => void
|
||||
|
||||
//** When a drag element leave a branch */
|
||||
readonly onDragLeaveBranch: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -70,10 +82,23 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
|
|||
pullRequests={this.props.pullRequests}
|
||||
currentPullRequest={this.props.currentPullRequest}
|
||||
isLoadingPullRequests={this.props.isLoadingPullRequests}
|
||||
onDropOntoCurrentBranch={this.onDropOntoCurrentBranch}
|
||||
onDragEnterBranch={this.props.onDragEnterBranch}
|
||||
onDragLeaveBranch={this.props.onDragLeaveBranch}
|
||||
isCherryPickInProgress={repositoryState.cherryPickState.step !== null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private onDropOntoCurrentBranch = () => {
|
||||
const { repositoryState, repository } = this.props
|
||||
const { cherryPickState } = repositoryState
|
||||
if (cherryPickState !== null && cherryPickState.step !== null) {
|
||||
this.props.dispatcher.endCherryPickFlow(repository)
|
||||
this.props.dispatcher.recordCherryPickDragStartedAndCanceled()
|
||||
}
|
||||
}
|
||||
|
||||
private onDropDownStateChanged = (state: DropdownState) => {
|
||||
// Don't allow opening the drop down when checkout is in progress
|
||||
if (state === 'open' && this.props.repositoryState.checkoutProgress) {
|
||||
|
@ -168,12 +193,33 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
|
|||
showDisclosureArrow={canOpen}
|
||||
progressValue={progressValue}
|
||||
buttonClassName={buttonClassName}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
>
|
||||
{this.renderPullRequestInfo()}
|
||||
</ToolbarDropdown>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to capture when the mouse is over the branch dropdown button.
|
||||
*
|
||||
* We currently only use this in conjunction with dragging cherry picks so
|
||||
* that we can open the branch menu when dragging a commit over it.
|
||||
*/
|
||||
private onMouseEnter = (): void => {
|
||||
// If the cherry picking state is initiated with commits chosen, 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
|
||||
) {
|
||||
dragAndDropManager.emitEnterDragZone('branch-button')
|
||||
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
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
export { UsageStatsChange } from './usage-stats-change'
|
|
@ -1,110 +0,0 @@
|
|||
import * as React from 'react'
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
import { Row } from '../lib/row'
|
||||
import { Checkbox, CheckboxValue } from '../lib/checkbox'
|
||||
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
|
||||
|
||||
interface IUsageStatsChangeProps {
|
||||
readonly onSetStatsOptOut: (optOut: boolean) => void
|
||||
readonly onDismissed: () => void
|
||||
readonly onOpenUsageDataUrl: () => void
|
||||
}
|
||||
|
||||
interface IUsageStatsChangeState {
|
||||
readonly optOutOfUsageTracking: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog shown if the user has not seen the details about how our usage
|
||||
* tracking has changed
|
||||
*/
|
||||
export class UsageStatsChange extends React.Component<
|
||||
IUsageStatsChangeProps,
|
||||
IUsageStatsChangeState
|
||||
> {
|
||||
public constructor(props: IUsageStatsChangeProps) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
optOutOfUsageTracking: false,
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<Dialog
|
||||
id="usage-reporting"
|
||||
title={
|
||||
__DARWIN__ ? 'Usage Reporting Changes' : 'Usage reporting changes'
|
||||
}
|
||||
dismissable={false}
|
||||
onDismissed={this.onDismissed}
|
||||
onSubmit={this.onDismissed}
|
||||
type="normal"
|
||||
>
|
||||
<DialogContent>
|
||||
<Row>
|
||||
GitHub Desktop has introduced a change around how it reports usage
|
||||
stats, to help us better understand how our GitHub users get value
|
||||
from Desktop:
|
||||
</Row>
|
||||
<Row>
|
||||
<ul>
|
||||
<li>
|
||||
<span>
|
||||
<strong>If you are signed into a GitHub account</strong>, your
|
||||
GitHub.com account ID will be included in the periodic usage
|
||||
stats.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>
|
||||
<strong>
|
||||
If you are only signed into a GitHub Enterprise account, or
|
||||
only using Desktop with non-GitHub remotes
|
||||
</strong>
|
||||
, nothing is going to change.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Row>
|
||||
<Row className="selection">
|
||||
<Checkbox
|
||||
label="Help GitHub Desktop improve by submitting usage stats"
|
||||
value={
|
||||
this.state.optOutOfUsageTracking
|
||||
? CheckboxValue.Off
|
||||
: CheckboxValue.On
|
||||
}
|
||||
onChange={this.onReportingOptOutChanged}
|
||||
/>
|
||||
</Row>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<OkCancelButtonGroup
|
||||
okButtonText="Continue"
|
||||
cancelButtonText={__DARWIN__ ? 'More Info' : 'More info'}
|
||||
onCancelButtonClick={this.viewMoreInfo}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private onReportingOptOutChanged = (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => {
|
||||
const value = !event.currentTarget.checked
|
||||
this.setState({ optOutOfUsageTracking: value })
|
||||
}
|
||||
|
||||
private onDismissed = () => {
|
||||
this.props.onSetStatsOptOut(this.state.optOutOfUsageTracking)
|
||||
this.props.onDismissed()
|
||||
}
|
||||
|
||||
private viewMoreInfo = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
this.props.onOpenUsageDataUrl()
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ import { Button } from '../lib/button'
|
|||
interface IConfigureGitProps {
|
||||
readonly accounts: ReadonlyArray<Account>
|
||||
readonly advance: (step: WelcomeStep) => void
|
||||
readonly done: () => void
|
||||
}
|
||||
|
||||
/** The Welcome flow step to configure git. */
|
||||
|
@ -22,8 +23,8 @@ export class ConfigureGit extends React.Component<IConfigureGitProps, {}> {
|
|||
|
||||
<ConfigureGitUser
|
||||
accounts={this.props.accounts}
|
||||
onSave={this.continue}
|
||||
saveLabel="Continue"
|
||||
onSave={this.props.done}
|
||||
saveLabel="Finish"
|
||||
>
|
||||
<Button onClick={this.cancel}>Cancel</Button>
|
||||
</ConfigureGitUser>
|
||||
|
@ -34,8 +35,4 @@ export class ConfigureGit extends React.Component<IConfigureGitProps, {}> {
|
|||
private cancel = () => {
|
||||
this.props.advance(WelcomeStep.Start)
|
||||
}
|
||||
|
||||
private continue = () => {
|
||||
this.props.advance(WelcomeStep.UsageOptOut)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Octicon, OcticonSymbol } from '../octicons'
|
|||
import { Button } from '../lib/button'
|
||||
import { Loading } from '../lib/loading'
|
||||
import { BrowserRedirectMessage } from '../lib/authentication-form'
|
||||
import { SamplesURL } from '../../lib/stats'
|
||||
|
||||
/**
|
||||
* The URL to the sign-up page on GitHub.com. Used in conjunction
|
||||
|
@ -71,6 +72,21 @@ export class Start extends React.Component<IStartProps, {}> {
|
|||
Skip this step
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="welcome-start-disclaimer-container">
|
||||
By creating an account, you agree to the{' '}
|
||||
<LinkButton uri={'https://github.com/site/terms'}>
|
||||
Terms of Service
|
||||
</LinkButton>
|
||||
. For more information about GitHub's privacy practices, see the{' '}
|
||||
<LinkButton uri={'https://github.com/site/privacy'}>
|
||||
GitHub Privacy Statement
|
||||
</LinkButton>
|
||||
.<br />
|
||||
<br />
|
||||
GitHub Desktop sends usage metrics to improve the product and inform
|
||||
feature decisions. Read more about what metrics are sent and how we
|
||||
use them <LinkButton uri={SamplesURL}>here</LinkButton>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,78 +0,0 @@
|
|||
import * as React from 'react'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import { WelcomeStep } from './welcome'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
import { Checkbox, CheckboxValue } from '../lib/checkbox'
|
||||
import { Form } from '../lib/form'
|
||||
import { Button } from '../lib/button'
|
||||
import { Row } from '../lib/row'
|
||||
import { SamplesURL } from '../../lib/stats'
|
||||
|
||||
interface IUsageOptOutProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly advance: (step: WelcomeStep) => void
|
||||
readonly done: () => void
|
||||
readonly optOut: boolean
|
||||
}
|
||||
|
||||
interface IUsageOptOutState {
|
||||
readonly newOptOutValue: boolean
|
||||
}
|
||||
|
||||
/** The Welcome flow step for opting out of stats reporting. */
|
||||
export class UsageOptOut extends React.Component<
|
||||
IUsageOptOutProps,
|
||||
IUsageOptOutState
|
||||
> {
|
||||
public constructor(props: IUsageOptOutProps) {
|
||||
super(props)
|
||||
|
||||
this.state = { newOptOutValue: props.optOut }
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div className="usage-opt-out">
|
||||
<h1 className="welcome-title">Make GitHub Desktop better!</h1>
|
||||
|
||||
<p>
|
||||
Would you like to help us improve GitHub Desktop by periodically
|
||||
submitting <LinkButton uri={SamplesURL}>usage stats</LinkButton>?
|
||||
</p>
|
||||
|
||||
<Form onSubmit={this.finish}>
|
||||
<Row>
|
||||
<Checkbox
|
||||
label="Yes, submit periodic usage stats"
|
||||
value={
|
||||
this.state.newOptOutValue ? CheckboxValue.Off : CheckboxValue.On
|
||||
}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row className="actions">
|
||||
<Button type="submit">Finish</Button>
|
||||
<Button onClick={this.cancel}>Cancel</Button>
|
||||
</Row>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onChange = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked
|
||||
this.setState({ newOptOutValue: !value })
|
||||
}
|
||||
|
||||
private cancel = () => {
|
||||
this.props.advance(WelcomeStep.ConfigureGit)
|
||||
}
|
||||
|
||||
private finish = () => {
|
||||
this.props.dispatcher.setStatsOptOut(this.state.newOptOutValue, true)
|
||||
// new users do not need to see the usage notes warning
|
||||
this.props.dispatcher.markUsageStatsNoteSeen()
|
||||
this.props.done()
|
||||
}
|
||||
}
|
|
@ -10,7 +10,6 @@ import { Start } from './start'
|
|||
import { SignInEnterprise } from './sign-in-enterprise'
|
||||
import { ConfigureGit } from './configure-git'
|
||||
import { UiView } from '../ui-view'
|
||||
import { UsageOptOut } from './usage-opt-out'
|
||||
|
||||
/** The steps along the Welcome flow. */
|
||||
export enum WelcomeStep {
|
||||
|
@ -18,7 +17,6 @@ export enum WelcomeStep {
|
|||
SignInToDotComWithBrowser = 'SignInToDotComWithBrowser',
|
||||
SignInToEnterprise = 'SignInToEnterprise',
|
||||
ConfigureGit = 'ConfigureGit',
|
||||
UsageOptOut = 'UsageOptOut',
|
||||
}
|
||||
|
||||
interface IWelcomeProps {
|
||||
|
@ -168,15 +166,6 @@ export class Welcome extends React.Component<IWelcomeProps, IWelcomeState> {
|
|||
<ConfigureGit
|
||||
advance={this.advanceToStep}
|
||||
accounts={this.props.accounts}
|
||||
/>
|
||||
)
|
||||
|
||||
case WelcomeStep.UsageOptOut:
|
||||
return (
|
||||
<UsageOptOut
|
||||
dispatcher={this.props.dispatcher}
|
||||
advance={this.advanceToStep}
|
||||
optOut={this.props.optOut}
|
||||
done={this.done}
|
||||
/>
|
||||
)
|
||||
|
|
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 |
|
@ -84,3 +84,5 @@
|
|||
@import 'ui/diff-options';
|
||||
@import 'ui/commit-message-avatar';
|
||||
@import 'ui/popover';
|
||||
@import 'ui/drag-elements';
|
||||
@import 'ui/drag-overlay';
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue