Merge branch 'development' into windows-arm-support

This commit is contained in:
Sergio Padrino 2021-04-06 11:03:22 +02:00 committed by GitHub
commit cdbbd8038f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
133 changed files with 6829 additions and 1056 deletions

View file

@ -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",

View file

@ -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
//

View file

@ -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'
}

View file

@ -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++
)

View 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()

View file

@ -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'],

View file

@ -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(

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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)
}
/**

View file

@ -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}`)

View file

@ -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'

View file

@ -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

View file

@ -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,
}
}

View 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())
}

View 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()

View file

@ -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'

View file

@ -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 {

View file

@ -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 = {

View file

@ -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)
}

View file

@ -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')
}

View file

@ -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()
}
}
/**

View file

@ -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,
},
}
}

View file

@ -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
}

View file

@ -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()

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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 */

View 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
}

View file

@ -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 }

View file

@ -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
}

View file

@ -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)) {

View file

@ -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() {

View 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>
)
}
}

View 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>
)
}
}

View file

@ -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}`)

View 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>
)
}
}

View file

@ -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} />

View file

@ -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}
/>
)
}

View file

@ -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
)
}
}
}

View file

@ -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} />

View file

@ -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

View file

@ -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}

View file

@ -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
)
}
}

View file

@ -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}

View 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>
)
}
}

View 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')
}
}
}

View 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>
)
}
}

View 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)
}
}

View 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>
)
}
}

View file

@ -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,

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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}

View file

@ -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
}

View file

@ -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,

View 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,
}
}

View file

@ -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 (

View file

@ -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)
}
}

View 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>
)
}
}

View 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>
}
}

View file

@ -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) {

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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,
})
}
}

View file

@ -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>
)
}

View file

@ -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 */

View file

@ -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
}

View file

@ -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

View file

@ -250,6 +250,7 @@ export class ConfigureGitUser extends React.Component<
gitHubRepository={null}
isLocal={false}
showUnpushedIndicator={false}
selectedCommits={[dummyCommit]}
/>
</div>
)

View file

@ -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

View file

@ -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
}

View 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>
)
}
}

View file

@ -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),
})

View file

@ -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
}

View file

@ -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}

View file

@ -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

View file

@ -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()
}

View file

@ -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).

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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>,

View 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()
}
}

View file

@ -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
}

View file

@ -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>
)
}
}

View file

@ -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

View file

@ -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()
}
}
}

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -1 +0,0 @@
export { UsageStatsChange } from './usage-stats-change'

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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>
)
}

View file

@ -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&nbsp;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()
}
}

View file

@ -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}
/>
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -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