Merge branch 'development' into upgrade-octicons

This commit is contained in:
Markus Olsson 2020-08-21 13:00:23 +02:00
commit edac8e0f5e
50 changed files with 916 additions and 411 deletions

View file

@ -21,7 +21,7 @@ jobs:
friendlyName: macOS
- os: windows-2019
friendlyName: Windows
timeout-minutes: 30
timeout-minutes: 40
steps:
- uses: actions/checkout@v2
with:
@ -79,4 +79,4 @@ jobs:
S3_KEY: ${{ secrets.S3_KEY }}
S3_SECRET: ${{ secrets.S3_SECRET }}
S3_BUCKET: github-desktop
timeout-minutes: 5
timeout-minutes: 10

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "2.5.4-beta2",
"version": "2.5.4-beta4",
"main": "./main.js",
"repository": {
"type": "git",
@ -49,7 +49,7 @@
"react": "^16.8.4",
"react-css-transition-replace": "^3.0.3",
"react-dom": "^16.8.4",
"react-transition-group": "^1.2.0",
"react-transition-group": "^4.4.1",
"react-virtualized": "^9.20.0",
"registry-js": "^1.4.0",
"source-map-support": "^0.4.15",

View file

@ -72,6 +72,8 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
{
install: () => import('codemirror/mode/htmlembedded/htmlembedded'),
mappings: {
'.aspx': 'application/x-aspx',
'.cshtml': 'application/x-aspx',
'.jsp': 'application/x-jsp',
},
},
@ -114,6 +116,15 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
'.vbproj': 'text/xml',
'.svg': 'text/xml',
'.resx': 'text/xml',
'.props': 'text/xml',
'.targets': 'text/xml',
},
},
{
install: () => import('codemirror/mode/diff/diff'),
mappings: {
'.diff': 'text/x-diff',
'.patch': 'text/x-diff',
},
},
{
@ -527,7 +538,7 @@ function getInnerModeName(
* @param stream The StringStream for the current line
* @param state The current mode state (if any)
* @param addModeClass Whether or not to append the current (inner) mode name
* as an extra CSS clas to the token, indicating the mode
* as an extra CSS class to the token, indicating the mode
* that produced it, prefixed with "cm-m-". For example,
* tokens from the XML mode will get the cm-m-xml class.
*/

View file

@ -252,7 +252,21 @@ export interface IAPIIssue {
}
/** The combined state of a ref. */
export type APIRefState = 'failure' | 'pending' | 'success'
export type APIRefState = 'failure' | 'pending' | 'success' | 'error'
/** The overall status of a check run */
export type APICheckStatus = 'queued' | 'in_progress' | 'completed'
/** The conclusion of a completed check run */
export type APICheckConclusion =
| 'action_required'
| 'cancelled'
| 'timed_out'
| 'failure'
| 'neutral'
| 'success'
| 'skipped'
| 'stale'
/**
* The API response for a combined view of a commit
@ -273,6 +287,30 @@ export interface IAPIRefStatus {
readonly statuses: ReadonlyArray<IAPIRefStatusItem>
}
export interface IAPIRefCheckRun {
readonly id: number
readonly url: string
readonly status: APICheckStatus
readonly conclusion: APICheckConclusion | null
readonly name: string
readonly output: IAPIRefCheckRunOutput
readonly check_suite: IAPIRefCheckRunCheckSuite
}
// NB. Only partially mapped
export interface IAPIRefCheckRunOutput {
readonly title: string | null
}
export interface IAPIRefCheckRunCheckSuite {
readonly id: number
}
export interface IAPIRefCheckRuns {
readonly total_count: number
readonly check_runs: IAPIRefCheckRun[]
}
/** Protected branch information returned by the GitHub API */
export interface IAPIPushControl {
/**
@ -767,18 +805,52 @@ export class API {
/**
* Get the combined status for the given ref.
*
* Note: Contrary to many other methods in this class this will not
* suppress or log errors, callers must ensure that they handle errors.
*/
public async fetchCombinedRefStatus(
owner: string,
name: string,
ref: string
): Promise<IAPIRefStatus> {
const path = `repos/${owner}/${name}/commits/${ref}/status`
): Promise<IAPIRefStatus | null> {
const safeRef = encodeURIComponent(ref)
const path = `repos/${owner}/${name}/commits/${safeRef}/status?per_page=100`
const response = await this.request('GET', path)
return await parsedResponse<IAPIRefStatus>(response)
try {
return await parsedResponse<IAPIRefStatus>(response)
} catch (err) {
log.debug(
`Failed fetching check runs for ref ${ref} (${owner}/${name})`,
err
)
return null
}
}
/**
* Get any check run results for the given ref.
*/
public async fetchRefCheckRuns(
owner: string,
name: string,
ref: string
): Promise<IAPIRefCheckRuns | null> {
const safeRef = encodeURIComponent(ref)
const path = `repos/${owner}/${name}/commits/${safeRef}/check-runs?per_page=100`
const headers = {
Accept: 'application/vnd.github.antiope-preview+json',
}
const response = await this.request('GET', path, undefined, headers)
try {
return await parsedResponse<IAPIRefCheckRuns>(response)
} catch (err) {
log.debug(
`Failed fetching check runs for ref ${ref} (${owner}/${name})`,
err
)
return null
}
}
/**

View file

@ -133,3 +133,10 @@ export function enableForkSettings(): boolean {
export function enableDiscardLines(): boolean {
return true
}
/**
* Should we allow to change the default branch when creating new repositories?
*/
export function enableDefaultBranchSetting(): boolean {
return enableBetaFeatures()
}

View file

@ -120,3 +120,20 @@ export async function checkoutPaths(
'checkoutPaths'
)
}
/**
* Create and checkout the given branch.
*
* @param repository The repository.
* @param branchName The branch to create and checkout.
*/
export async function createAndCheckoutBranch(
repository: Repository,
branchName: string
): Promise<void> {
await git(
['checkout', '-b', branchName],
repository.path,
'createAndCheckoutBranch'
)
}

View file

@ -80,11 +80,9 @@ export async function resetSubmodulePaths(
repository: Repository,
paths: ReadonlyArray<string>
): Promise<void> {
for (const path of paths) {
await git(
['submodule', 'update', '--recursive', '--', path],
repository.path,
'updateSubmodule'
)
}
await git(
['submodule', 'update', '--recursive', '--force', '--', ...paths],
repository.path,
'updateSubmodule'
)
}

View file

@ -0,0 +1,39 @@
import { getGlobalConfigValue, setGlobalConfigValue } from '../git'
import { enableDefaultBranchSetting } from '../feature-flag'
export const DefaultBranchInGit = 'master'
const DefaultBranchSettingName = 'init.defaultBranch'
/**
* The branch names that Desktop shows by default as radio buttons on the
* form that allows users to change default branch name.
*/
export const SuggestedBranchNames: ReadonlyArray<string> = ['master', 'main']
/**
* Returns the configured default branch when creating new repositories
*/
async function getConfiguredDefaultBranch(): Promise<string | null> {
if (!enableDefaultBranchSetting()) {
return null
}
return getGlobalConfigValue(DefaultBranchSettingName)
}
/**
* Returns the configured default branch when creating new repositories
*/
export async function getDefaultBranch(): Promise<string> {
return (await getConfiguredDefaultBranch()) ?? DefaultBranchInGit
}
/**
* Sets the configured default branch when creating new repositories.
*
* @param branchName The default branch name to use.
*/
export async function setDefaultBranch(branchName: string) {
return setGlobalConfigValue('init.defaultBranch', branchName)
}

View file

@ -4,15 +4,47 @@ import QuickLRU from 'quick-lru'
import { Account } from '../../models/account'
import { AccountsStore } from './accounts-store'
import { GitHubRepository } from '../../models/github-repository'
import { API, IAPIRefStatus } from '../api'
import {
API,
IAPIRefStatusItem,
IAPIRefCheckRun,
APICheckStatus,
APICheckConclusion,
} from '../api'
import { IDisposable, Disposable } from 'event-kit'
/**
* A Desktop-specific model closely related to a GitHub API Check Run.
*
* The RefCheck object abstracts the difference between the legacy
* Commit Status objects and the modern Check Runs and unifies them
* under one common interface. Since all commit statuses can be
* represented as Check Runs but not all Check Runs can be represented
* as statuses the model closely aligns with Check Runs.
*/
export interface IRefCheck {
readonly name: string
readonly description: string
readonly status: APICheckStatus
readonly conclusion: APICheckConclusion | null
}
/**
* A combined view of all legacy commit statuses as well as
* check runs for a particular Git reference.
*/
export interface ICombinedRefCheck {
readonly status: APICheckStatus
readonly conclusion: APICheckConclusion | null
readonly checks: ReadonlyArray<IRefCheck>
}
interface ICommitStatusCacheEntry {
/**
* The combined ref status from the API or null if
* the status could not be retrieved.
*/
readonly status: IAPIRefStatus | null
readonly check: ICombinedRefCheck | null
/**
* The timestamp for when this cache entry was last
@ -21,7 +53,7 @@ interface ICommitStatusCacheEntry {
readonly fetchedAt: Date
}
export type StatusCallBack = (status: IAPIRefStatus | null) => void
export type StatusCallBack = (status: ICombinedRefCheck | null) => void
/**
* An interface describing one or more subscriptions for
@ -67,10 +99,6 @@ function getCacheKeyForRepository(repository: GitHubRepository, ref: string) {
/**
* Creates a cache key for a particular ref in a specific repository.
*
* Remarks: The cache key is currently the same as the canonical API status
* URI but that has no bearing on the functionality, it does, however
* help with debugging.
*
* @param endpoint The repository endpoint (for example https://api.github.com for
* GitHub.com and https://github.corporation.local/api for GHE)
* @param owner The repository owner's login (i.e niik for niik/desktop)
@ -84,7 +112,7 @@ function getCacheKey(
name: string,
ref: string
) {
return `${endpoint}/repos/${owner}/${name}/commits/${ref}/status`
return `${endpoint}/repos/${owner}/${name}/commits/${ref}`
}
/**
@ -110,7 +138,7 @@ function entryIsEligibleForRefresh(entry: ICommitStatusCacheEntry) {
* application is focused.
*/
const BackgroundRefreshInterval = 3 * 60 * 1000
const MaxConcurrentFetches = 5
const MaxConcurrentFetches = 6
export class CommitStatusStore {
/** The list of signed-in accounts, kept in sync with the accounts store */
@ -246,13 +274,16 @@ export class CommitStatusStore {
return
}
try {
const api = API.fromAccount(account)
const status = await api.fetchCombinedRefStatus(owner, name, ref)
const api = API.fromAccount(account)
this.cache.set(key, { status, fetchedAt: new Date() })
subscription.callbacks.forEach(cb => cb(status))
} catch (err) {
const [statuses, checkRuns] = await Promise.all([
api.fetchCombinedRefStatus(owner, name, ref),
api.fetchRefCheckRuns(owner, name, ref),
])
const checks = new Array<IRefCheck>()
if (statuses === null && checkRuns === null) {
// Okay, so we failed retrieving the status for one reason or another.
// That's a bummer, but we still need to put something in the cache
// or else we'll consider this subscription eligible for refresh
@ -261,13 +292,27 @@ export class CommitStatusStore {
// notifying subscribers we ensure they keep their current status
// if they have one and that we attempt to fetch it again on the same
// schedule as the others.
log.debug(`Failed fetching status for ref ${ref} (${owner}/${name})`, err)
const existingEntry = this.cache.get(key)
const status = existingEntry === undefined ? null : existingEntry.status
const check = existingEntry?.check ?? null
this.cache.set(key, { status, fetchedAt: new Date() })
this.cache.set(key, { check, fetchedAt: new Date() })
return
}
if (statuses !== null) {
checks.push(...statuses.statuses.map(apiStatusToRefCheck))
}
if (checkRuns !== null) {
const latestCheckRunsByName = getLatestCheckRunsByName(
checkRuns.check_runs
)
checks.push(...latestCheckRunsByName.map(apiCheckRunToRefCheck))
}
const check = createCombinedCheckFromChecks(checks)
this.cache.set(key, { check, fetchedAt: new Date() })
subscription.callbacks.forEach(cb => cb(check))
}
/**
@ -280,9 +325,9 @@ export class CommitStatusStore {
public tryGetStatus(
repository: GitHubRepository,
ref: string
): IAPIRefStatus | null {
const entry = this.cache.get(getCacheKeyForRepository(repository, ref))
return entry !== undefined ? entry.status : null
): ICombinedRefCheck | null {
const key = getCacheKeyForRepository(repository, ref)
return this.cache.get(key)?.check ?? null
}
private getOrCreateSubscription(repository: GitHubRepository, ref: string) {
@ -334,3 +379,150 @@ export class CommitStatusStore {
})
}
}
/**
* Convert a legacy API commit status to a fake check run
*/
function apiStatusToRefCheck(apiStatus: IAPIRefStatusItem): IRefCheck {
let state: APICheckStatus
let conclusion: APICheckConclusion | null = null
if (apiStatus.state === 'success') {
state = 'completed'
conclusion = 'success'
} else if (apiStatus.state === 'pending') {
state = 'in_progress'
} else {
state = 'completed'
conclusion = 'failure'
}
return {
name: apiStatus.context,
description: apiStatus.description,
status: state,
conclusion,
}
}
/**
* Convert an API check run object to a RefCheck model
*/
function apiCheckRunToRefCheck(checkRun: IAPIRefCheckRun): IRefCheck {
return {
name: checkRun.name,
description:
checkRun?.output.title ?? checkRun.conclusion ?? checkRun.status,
status: checkRun.status,
conclusion: checkRun.conclusion,
}
}
function createCombinedCheckFromChecks(
checks: ReadonlyArray<IRefCheck>
): ICombinedRefCheck | null {
if (checks.length === 0) {
// This case is distinct from when we fail to call the API in
// that this means there are no checks or statuses so we should
// clear whatever info we've got for this ref.
return null
}
if (checks.length === 1) {
// If we've got exactly one check then we can mirror its status
// and conclusion 1-1 without having to create an aggregate status
const { status, conclusion } = checks[0]
return { status, conclusion, checks }
}
if (checks.some(isIncompleteOrFailure)) {
return { status: 'completed', conclusion: 'failure', checks }
} else if (checks.every(isSuccess)) {
return { status: 'completed', conclusion: 'success', checks }
} else {
return { status: 'in_progress', conclusion: null, checks }
}
}
/**
* Whether the check is either incomplete or has failed
*/
export function isIncompleteOrFailure(check: IRefCheck) {
return isIncomplete(check) || isFailure(check)
}
/**
* Whether the check is incomplete (timed out, stale or cancelled).
*
* The terminology here is confusing and deserves explanation. An
* incomplete check is a check run that has been started and who's
* state is 'completed' but it never got to produce a conclusion
* because it was either cancelled, it timed out, or GitHub marked
* it as stale.
*/
export function isIncomplete(check: IRefCheck) {
if (check.status === 'completed') {
switch (check.conclusion) {
case 'timed_out':
case 'stale':
case 'cancelled':
return true
}
}
return false
}
/** Whether the check has failed (failure or requires action) */
export function isFailure(check: IRefCheck) {
if (check.status === 'completed') {
switch (check.conclusion) {
case 'failure':
case 'action_required':
return true
}
}
return false
}
/** Whether the check can be considered successful (success, neutral or skipped) */
export function isSuccess(check: IRefCheck) {
if (check.status === 'completed') {
switch (check.conclusion) {
case 'success':
case 'neutral':
case 'skipped':
return true
}
}
return false
}
/**
* In some cases there may be multiple check runs reported for a
* reference. In that case GitHub.com will pick only the latest
* run for each check name to present in the PR merge footer and
* only the latest run counts towards the mergeability of a PR.
*
* We use the check suite id as a proxy for determining what's
* the "latest" of two check runs with the same name.
*/
function getLatestCheckRunsByName(
checkRuns: ReadonlyArray<IAPIRefCheckRun>
): ReadonlyArray<IAPIRefCheckRun> {
const latestCheckRunsByName = new Map<string, IAPIRefCheckRun>()
for (const checkRun of checkRuns) {
const current = latestCheckRunsByName.get(checkRun.name)
if (
current === undefined ||
current.check_suite.id < checkRun.check_suite.id
) {
latestCheckRunsByName.set(checkRun.name, checkRun)
}
}
return [...latestCheckRunsByName.values()]
}

View file

@ -88,6 +88,7 @@ import { PullRequest } from '../../models/pull-request'
import { StatsStore } from '../stats'
import { getTagsToPush, storeTagsToPush } from './helpers/tags-to-push-storage'
import { DiffSelection, ITextDiff } from '../../models/diff'
import { getDefaultBranch } from '../helpers/default-branch'
/** The number of commits to load from history per batch. */
const CommitBatchSize = 100
@ -524,7 +525,7 @@ export class GitStore extends BaseStore {
}
}
return 'master'
return getDefaultBranch()
}
private refreshRecentBranches(

View file

@ -43,6 +43,7 @@ export class AppWindow {
disableBlinkFeatures: 'Auxclick',
nodeIntegration: true,
enableRemoteModule: true,
spellcheck: false,
},
acceptFirstMouse: true,
}

View file

@ -37,6 +37,7 @@ export class CrashWindow {
// See https://developers.google.com/web/updates/2016/10/auxclick
disableBlinkFeatures: 'Auxclick',
nodeIntegration: true,
spellcheck: false,
},
}

View file

@ -1,4 +1,3 @@
import { APIRefState } from '../lib/api'
import { GitHubRepository } from './github-repository'
export class PullRequestRef {
@ -15,13 +14,6 @@ export class PullRequestRef {
) {}
}
/** The commit status and metadata for a given ref */
export interface ICommitStatus {
readonly id: number
readonly state: APIRefState
readonly description: string
}
export class PullRequest {
/**
* @param created The date on which the PR was created.

View file

@ -10,6 +10,7 @@ import {
getStatus,
getAuthorIdentity,
isGitRepository,
createAndCheckoutBranch,
} from '../../lib/git'
import { sanitizedRepositoryName } from './sanitized-repository-name'
import { TextBox } from '../lib/text-box'
@ -30,6 +31,10 @@ import { PopupType } from '../../models/popup'
import { Ref } from '../lib/ref'
import { enableReadmeOverwriteWarning } from '../../lib/feature-flag'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import {
getDefaultBranch,
DefaultBranchInGit,
} from '../../lib/helpers/default-branch'
/** The sentinel value used to indicate no gitignore should be used. */
const NoGitIgnoreValue = 'None'
@ -240,6 +245,25 @@ export class CreateRepository extends React.Component<
const repository = repositories[0]
const defaultBranch = await getDefaultBranch()
if (defaultBranch !== DefaultBranchInGit) {
try {
// Manually checkout to the configured default branch.
// TODO (git@2.28): Remove this code when upgrading to git v2.28
// since this will be natively implemented.
await createAndCheckoutBranch(repository, defaultBranch)
} catch (e) {
// When we cannot checkout the default branch just log the error,
// since we don't want to stop the repository creation (since we're
// in the middle of the creation process).
log.error(
`createRepository: unable to create default branch "${defaultBranch}"`,
e
)
}
}
if (this.state.createWithReadme) {
try {
await writeDefaultReadme(

View file

@ -6,13 +6,10 @@ import {
DialogFooter,
DefaultDialogFooter,
} from './dialog'
import {
dialogTransitionEnterTimeout,
dialogTransitionLeaveTimeout,
} from './app'
import { dialogTransitionTimeout } from './app'
import { GitError, isAuthFailureError } from '../lib/git/core'
import { Popup, PopupType } from '../models/popup'
import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { OkCancelButtonGroup } from './dialog/ok-cancel-button-group'
import { ErrorWithMetadata } from '../lib/error-with-metadata'
import { RetryActionType, RetryAction } from '../models/retry-actions'
@ -82,7 +79,7 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
// with the next error in the queue.
window.setTimeout(() => {
this.props.onClearError(currentError)
}, dialogTransitionLeaveTimeout)
}, dialogTransitionTimeout.exit)
}
}
@ -93,7 +90,7 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
//being open at the same time.
window.setTimeout(() => {
this.props.onShowPopup({ type: PopupType.Preferences })
}, dialogTransitionLeaveTimeout)
}, dialogTransitionTimeout.exit)
}
private onRetryAction = (event: React.MouseEvent<HTMLButtonElement>) => {
@ -262,15 +259,16 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
}
public render() {
const dialogContent = this.renderDialog()
return (
<CSSTransitionGroup
transitionName="modal"
component="div"
transitionEnterTimeout={dialogTransitionEnterTimeout}
transitionLeaveTimeout={dialogTransitionLeaveTimeout}
>
{this.renderDialog()}
</CSSTransitionGroup>
<TransitionGroup>
{dialogContent && (
<CSSTransition classNames="modal" timeout={dialogTransitionTimeout}>
{dialogContent}
</CSSTransition>
)}
</TransitionGroup>
)
}
}

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import { ipcRenderer, remote } from 'electron'
import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import {
IAppState,
@ -154,8 +154,12 @@ interface IAppProps {
readonly startTime: number
}
export const dialogTransitionEnterTimeout = 250
export const dialogTransitionLeaveTimeout = 100
export const dialogTransitionTimeout = {
enter: 250,
exit: 100,
}
export const bannerTransitionTimeout = { enter: 500, exit: 400 }
/**
* The time to delay (in ms) from when we've loaded the initial state to showing
@ -2142,15 +2146,16 @@ export class App extends React.Component<IAppProps, IAppState> {
}
private renderPopup() {
const popupContent = this.currentPopupContent()
return (
<CSSTransitionGroup
transitionName="modal"
component="div"
transitionEnterTimeout={dialogTransitionEnterTimeout}
transitionLeaveTimeout={dialogTransitionLeaveTimeout}
>
{this.currentPopupContent()}
</CSSTransitionGroup>
<TransitionGroup>
{popupContent && (
<CSSTransition classNames="modal" timeout={dialogTransitionTimeout}>
{popupContent}
</CSSTransition>
)}
</TransitionGroup>
)
}
@ -2505,14 +2510,13 @@ export class App extends React.Component<IAppProps, IAppState> {
banner = this.renderUpdateBanner()
}
return (
<CSSTransitionGroup
transitionName="banner"
component="div"
transitionEnterTimeout={500}
transitionLeaveTimeout={400}
>
{banner}
</CSSTransitionGroup>
<TransitionGroup>
{banner && (
<CSSTransition classNames="banner" timeout={bannerTransitionTimeout}>
{banner}
</CSSTransition>
)}
</TransitionGroup>
)
}

View file

@ -1,12 +1,13 @@
import * as React from 'react'
import { Octicon, OcticonSymbol } from '../octicons'
import { APIRefState, IAPIRefStatus } from '../../lib/api'
import { assertNever } from '../../lib/fatal-error'
import classNames from 'classnames'
import { getRefStatusSummary } from './pull-request-status'
import { GitHubRepository } from '../../models/github-repository'
import { IDisposable } from 'event-kit'
import { Dispatcher } from '../dispatcher'
import {
ICombinedRefCheck,
isSuccess,
} from '../../lib/stores/commit-status-store'
interface ICIStatusProps {
/** The classname for the underlying element. */
@ -22,7 +23,7 @@ interface ICIStatusProps {
}
interface ICIStatusState {
readonly status: IAPIRefStatus | null
readonly check: ICombinedRefCheck | null
}
/** The little CI status indicator. */
@ -35,7 +36,7 @@ export class CIStatus extends React.PureComponent<
public constructor(props: ICIStatusProps) {
super(props)
this.state = {
status: props.dispatcher.tryGetCommitStatus(
check: props.dispatcher.tryGetCommitStatus(
this.props.repository,
this.props.commitRef
),
@ -66,7 +67,7 @@ export class CIStatus extends React.PureComponent<
this.props.commitRef !== prevProps.commitRef
) {
this.setState({
status: this.props.dispatcher.tryGetCommitStatus(
check: this.props.dispatcher.tryGetCommitStatus(
this.props.repository,
this.props.commitRef
),
@ -83,43 +84,87 @@ export class CIStatus extends React.PureComponent<
this.unsubscribe()
}
private onStatus = (status: IAPIRefStatus | null) => {
this.setState({ status })
private onStatus = (check: ICombinedRefCheck | null) => {
this.setState({ check })
}
public render() {
const { status } = this.state
const { check } = this.state
if (status === null || status.total_count === 0) {
if (check === null || check.checks.length === 0) {
return null
}
const title = getRefStatusSummary(status)
const state = status.state
return (
<Octicon
className={classNames(
'ci-status',
`ci-status-${state}`,
`ci-status-${getClassNameForCheck(check)}`,
this.props.className
)}
symbol={getSymbolForState(state)}
title={title}
symbol={getSymbolForCheck(check)}
title={getRefCheckSummary(check)}
/>
)
}
}
function getSymbolForState(state: APIRefState): OcticonSymbol {
switch (state) {
case 'pending':
return OcticonSymbol.dotFill
function getSymbolForCheck(check: ICombinedRefCheck): OcticonSymbol {
switch (check.conclusion) {
case 'timed_out':
return OcticonSymbol.x
case 'failure':
return OcticonSymbol.x
case 'neutral':
return OcticonSymbol.squareFill
case 'success':
return OcticonSymbol.check
default:
return assertNever(state, `Unknown state: ${state}`)
case 'cancelled':
return OcticonSymbol.stop
case 'action_required':
return OcticonSymbol.alert
case 'skipped':
return OcticonSymbol.skip
case 'stale':
return OcticonSymbol.issueReopened
}
// Pending
return OcticonSymbol.dotFill
}
function getClassNameForCheck(check: ICombinedRefCheck): string {
switch (check.conclusion) {
case 'timed_out':
return 'timed-out'
case 'action_required':
return 'action-required'
case 'failure':
case 'neutral':
case 'success':
case 'cancelled':
case 'skipped':
case 'stale':
return check.conclusion
}
// Pending
return 'pending'
}
/**
* Convert the combined check to an app-friendly string.
*/
export function getRefCheckSummary(check: ICombinedRefCheck): string {
if (check.checks.length === 1) {
const { name, description } = check.checks[0]
return `${name}: ${description}`
}
const successCount = check.checks.reduce(
(acc, cur) => acc + (isSuccess(cur) ? 1 : 0),
0
)
return `${successCount}/${check.checks.length} checks OK`
}

View file

@ -79,11 +79,13 @@ export class PullRequestListItem extends React.Component<
private renderPullRequestStatus() {
const ref = `refs/pull/${this.props.number}/head`
return (
<CIStatus
dispatcher={this.props.dispatcher}
repository={this.props.repository}
commitRef={ref}
/>
<div className="ci-status-container">
<CIStatus
dispatcher={this.props.dispatcher}
repository={this.props.repository}
commitRef={ref}
/>
</div>
)
}
}

View file

@ -1,48 +0,0 @@
import { ICommitStatus } from '../../models/pull-request'
import { APIRefState, IAPIRefStatus } from '../../lib/api'
import { assertNever } from '../../lib/fatal-error'
function formatState(state: APIRefState): string {
switch (state) {
case 'failure':
return 'Commit status: failed'
case 'pending':
case 'success':
return `Commit status: ${state}`
default:
return assertNever(state, `Unknown APIRefState value: ${state}`)
}
}
function formatSingleStatus(status: ICommitStatus) {
const word = status.state
const sentenceCaseWord =
word.charAt(0).toUpperCase() + word.substring(1, word.length)
return `${sentenceCaseWord}: ${status.description}`
}
/**
* Convert the Pull Request status to an app-friendly string.
*
* If the pull request contains commit statuses, this method will compute
* the number of successful statuses. Oteherwise, it will fall back
* to the `state` value reported by the GitHub API.
*/
export function getRefStatusSummary(prStatus: IAPIRefStatus): string {
const statusCount = prStatus.statuses.length || 0
if (statusCount === 0) {
return formatState(prStatus.state)
}
if (statusCount === 1) {
return formatSingleStatus(prStatus.statuses[0])
}
const successCount = prStatus.statuses.filter(x => x.state === 'success')
.length
return `${successCount}/${statusCount} checks OK`
}

View file

@ -27,7 +27,7 @@ import {
} from '../autocompletion'
import { ClickSource } from '../lib/list'
import { WorkingDirectoryFileChange } from '../../models/status'
import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { openFile } from '../lib/open-file'
import { Account } from '../../models/account'
import { PopupType } from '../../models/popup'
@ -329,27 +329,23 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
// the commit won't be completely deleted because the tag will still point to it.
if (commit && commit.tags.length === 0) {
child = (
<UndoCommit
isPushPullFetchInProgress={this.props.isPushPullFetchInProgress}
commit={commit}
onUndo={this.onUndo}
emoji={this.props.emoji}
isCommitting={this.props.isCommitting}
/>
<CSSTransition
classNames="undo"
appear={true}
timeout={UndoCommitAnimationTimeout}
>
<UndoCommit
isPushPullFetchInProgress={this.props.isPushPullFetchInProgress}
commit={commit}
onUndo={this.onUndo}
emoji={this.props.emoji}
isCommitting={this.props.isCommitting}
/>
</CSSTransition>
)
}
return (
<CSSTransitionGroup
transitionName="undo"
transitionAppear={true}
transitionAppearTimeout={UndoCommitAnimationTimeout}
transitionEnterTimeout={UndoCommitAnimationTimeout}
transitionLeaveTimeout={UndoCommitAnimationTimeout}
>
{child}
</CSSTransitionGroup>
)
return <TransitionGroup>{child}</TransitionGroup>
}
private renderUndoCommit = (

View file

@ -1,7 +1,7 @@
import { remote } from 'electron'
import { Disposable, IDisposable } from 'event-kit'
import { IAPIOrganization, IAPIRefStatus, IAPIRepository } from '../../lib/api'
import { IAPIOrganization, IAPIRepository } from '../../lib/api'
import { shell } from '../../lib/app-shell'
import {
CompareAction,
@ -86,6 +86,7 @@ import { executeMenuItem } from '../main-process-proxy'
import {
CommitStatusStore,
StatusCallBack,
ICombinedRefCheck,
} from '../../lib/stores/commit-status-store'
import { MergeResult } from '../../models/merge'
import {
@ -2340,7 +2341,7 @@ export class Dispatcher {
public tryGetCommitStatus(
repository: GitHubRepository,
ref: string
): IAPIRefStatus | null {
): ICombinedRefCheck | null {
return this.commitStatusStore.tryGetStatus(repository, ref)
}

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { Commit } from '../../models/commit'
import {
@ -32,6 +32,8 @@ import { MergeCallToActionWithConflicts } from './merge-call-to-action-with-conf
import { assertNever } from '../../lib/fatal-error'
import { enableNDDBBanner } from '../../lib/feature-flag'
const DivergingBannerAnimationTimeout = 300
interface ICompareSidebarProps {
readonly repository: Repository
readonly isLocalRepository: boolean
@ -153,20 +155,11 @@ export class CompareSidebar extends React.Component<
public render() {
const { allBranches, filterText, showBranchList } = this.props.compareState
const placeholderText = getPlaceholderText(this.props.compareState)
const DivergingBannerAnimationTimeout = 300
return (
<div id="compare-view">
{enableNDDBBanner() && (
<CSSTransitionGroup
transitionName="diverge-banner"
transitionAppear={true}
transitionAppearTimeout={DivergingBannerAnimationTimeout}
transitionEnterTimeout={DivergingBannerAnimationTimeout}
transitionLeaveTimeout={DivergingBannerAnimationTimeout}
>
{this.renderNotificationBanner()}
</CSSTransitionGroup>
<TransitionGroup>{this.renderNotificationBanner()}</TransitionGroup>
)}
<div className="compare-form">
@ -205,15 +198,23 @@ export class CompareSidebar extends React.Component<
return inferredComparisonBranch.branch !== null &&
inferredComparisonBranch.aheadBehind !== null &&
inferredComparisonBranch.aheadBehind.behind > 0 ? (
<div className="diverge-banner-wrapper">
<NewCommitsBanner
dispatcher={this.props.dispatcher}
repository={this.props.repository}
commitsBehindBaseBranch={inferredComparisonBranch.aheadBehind.behind}
baseBranch={inferredComparisonBranch.branch}
onDismiss={this.onNotificationBannerDismissed}
/>
</div>
<CSSTransition
classNames="diverge-banner"
appear={true}
timeout={DivergingBannerAnimationTimeout}
>
<div className="diverge-banner-wrapper">
<NewCommitsBanner
dispatcher={this.props.dispatcher}
repository={this.props.repository}
commitsBehindBaseBranch={
inferredComparisonBranch.aheadBehind.behind
}
baseBranch={inferredComparisonBranch.branch}
onDismiss={this.onNotificationBannerDismissed}
/>
</div>
</CSSTransition>
) : null
}

View file

@ -92,6 +92,12 @@ enableSourceMaps()
// see https://github.com/desktop/dugite/pull/85
process.env['LOCAL_GIT_DIRECTORY'] = Path.resolve(__dirname, 'git')
// Ensure that dugite infers the GIT_EXEC_PATH
// based on the LOCAL_GIT_DIRECTORY env variable
// instead of just blindly trusting what's set in
// the current environment. See https://git.io/JJ7KF
delete process.env.GIT_EXEC_PATH
momentDurationFormatSetup(moment)
const startTime = performance.now()

View file

@ -54,6 +54,8 @@ export class RefNameTextBox extends React.Component<
IRefNameProps,
IRefNameState
> {
private textBoxRef = React.createRef<TextBox>()
public constructor(props: IRefNameProps) {
super(props)
@ -80,6 +82,7 @@ export class RefNameTextBox extends React.Component<
<TextBox
label={this.props.label}
value={this.state.proposedValue}
ref={this.textBoxRef}
onValueChanged={this.onValueChange}
onBlur={this.onBlur}
/>
@ -89,6 +92,16 @@ export class RefNameTextBox extends React.Component<
)
}
/**
* 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).
*/
public focus() {
if (this.textBoxRef.current !== null) {
this.textBoxRef.current.focus()
}
}
private onValueChange = (proposedValue: string) => {
const sanitizedValue = sanitizedRefName(proposedValue)
const previousSanitizedValue = this.state.sanitizedValue

View file

@ -2,16 +2,60 @@ import * as React from 'react'
import { TextBox } from '../lib/text-box'
import { Row } from '../lib/row'
import { DialogContent } from '../dialog'
import { SuggestedBranchNames } from '../../lib/helpers/default-branch'
import { RefNameTextBox } from '../lib/ref-name-text-box'
import { Ref } from '../lib/ref'
import { RadioButton } from '../lib/radio-button'
import { enableDefaultBranchSetting } from '../../lib/feature-flag'
interface IGitProps {
readonly name: string
readonly email: string
readonly defaultBranch: string
readonly onNameChanged: (name: string) => void
readonly onEmailChanged: (email: string) => void
readonly onDefaultBranchChanged: (defaultBranch: string) => void
}
export class Git extends React.Component<IGitProps, {}> {
interface IGitState {
/**
* True if the default branch setting is not one of the suggestions.
* It's used to display the "Other" text box that allows the user to
* enter a custom branch name.
*/
readonly defaultBranchIsOther: boolean
}
// This will be the prepopulated branch name on the "other" input
// field when the user selects it.
const OtherNameForDefaultBranch = ''
export class Git extends React.Component<IGitProps, IGitState> {
private defaultBranchInputRef = React.createRef<RefNameTextBox>()
public constructor(props: IGitProps) {
super(props)
this.state = {
defaultBranchIsOther: !SuggestedBranchNames.includes(
this.props.defaultBranch
),
}
}
public componentDidUpdate(prevProps: IGitProps) {
// Focus the text input that allows the user to enter a custom
// branch name when the user has selected "Other...".
if (
this.props.defaultBranch !== prevProps.defaultBranch &&
this.props.defaultBranch === OtherNameForDefaultBranch &&
this.defaultBranchInputRef.current !== null
) {
this.defaultBranchInputRef.current.focus()
}
}
public render() {
return (
<DialogContent>
@ -29,7 +73,92 @@ export class Git extends React.Component<IGitProps, {}> {
onValueChanged={this.props.onEmailChanged}
/>
</Row>
{this.renderDefaultBranchSetting()}
</DialogContent>
)
}
private renderWarningMessage = (
sanitizedBranchName: string,
proposedBranchName: string
) => {
if (sanitizedBranchName === '') {
return (
<>
<Ref>{proposedBranchName}</Ref> is an invalid branch name.
</>
)
}
return (
<>
Will be saved as <Ref>{sanitizedBranchName}</Ref>.
</>
)
}
private renderDefaultBranchSetting() {
if (!enableDefaultBranchSetting()) {
return null
}
const { defaultBranchIsOther } = this.state
return (
<div className="default-branch-component">
<h2>Default branch for new repositories</h2>
{SuggestedBranchNames.map((branchName: string) => (
<RadioButton
key={branchName}
checked={
!defaultBranchIsOther && this.props.defaultBranch === branchName
}
value={branchName}
label={branchName}
onSelected={this.onDefaultBranchChanged}
/>
))}
<RadioButton
key={OtherNameForDefaultBranch}
checked={defaultBranchIsOther}
value={OtherNameForDefaultBranch}
label="Other…"
onSelected={this.onDefaultBranchChanged}
/>
{defaultBranchIsOther && (
<RefNameTextBox
initialValue={this.props.defaultBranch}
renderWarningMessage={this.renderWarningMessage}
onValueChange={this.props.onDefaultBranchChanged}
ref={this.defaultBranchInputRef}
/>
)}
<p className="git-settings-description">
These preferences will edit your global Git config.
</p>
</div>
)
}
/**
* Handler to make sure that we show/hide the text box to enter a custom
* branch name when the user clicks on one of the radio buttons.
*
* We don't want to call this handler on changes to the text box since that
* will cause the text box to be hidden if the user types a branch name
* that starts with one of the suggested branch names (e.g `mastera`).
*
* @param defaultBranch string the selected default branch
*/
private onDefaultBranchChanged = (defaultBranch: string) => {
this.setState({
defaultBranchIsOther: !SuggestedBranchNames.includes(defaultBranch),
})
this.props.onDefaultBranchChanged(defaultBranch)
}
}

View file

@ -32,6 +32,10 @@ import {
parseConfigLockFilePathFromError,
} from '../../lib/git'
import { ConfigLockFileExists } from '../lib/config-lock-file-exists'
import {
setDefaultBranch,
getDefaultBranch,
} from '../../lib/helpers/default-branch'
interface IPreferencesProps {
readonly dispatcher: Dispatcher
@ -54,8 +58,10 @@ interface IPreferencesState {
readonly selectedIndex: PreferencesTab
readonly committerName: string
readonly committerEmail: string
readonly defaultBranch: string
readonly initialCommitterName: string | null
readonly initialCommitterEmail: string | null
readonly initialDefaultBranch: string | null
readonly disallowedCharactersMessage: string | null
readonly optOutOfUsageTracking: boolean
readonly confirmRepositoryRemoval: boolean
@ -90,8 +96,10 @@ export class Preferences extends React.Component<
selectedIndex: this.props.initialSelectedTab || PreferencesTab.Accounts,
committerName: '',
committerEmail: '',
defaultBranch: '',
initialCommitterName: null,
initialCommitterEmail: null,
initialDefaultBranch: null,
disallowedCharactersMessage: null,
availableEditors: [],
optOutOfUsageTracking: false,
@ -110,6 +118,7 @@ export class Preferences extends React.Component<
public async componentWillMount() {
const initialCommitterName = await getGlobalConfigValue('user.name')
const initialCommitterEmail = await getGlobalConfigValue('user.email')
const initialDefaultBranch = await getDefaultBranch()
// There's no point in us reading http.schannelCheckRevoke on macOS, it's
// just a wasted Git process since the option only affects Windows. Besides,
@ -150,8 +159,10 @@ export class Preferences extends React.Component<
this.setState({
committerName,
committerEmail,
defaultBranch: initialDefaultBranch,
initialCommitterName,
initialCommitterEmail,
initialDefaultBranch,
optOutOfUsageTracking: this.props.optOutOfUsageTracking,
confirmRepositoryRemoval: this.props.confirmRepositoryRemoval,
confirmDiscardChanges: this.props.confirmDiscardChanges,
@ -278,8 +289,10 @@ export class Preferences extends React.Component<
<Git
name={this.state.committerName}
email={this.state.committerEmail}
defaultBranch={this.state.defaultBranch}
onNameChanged={this.onCommitterNameChanged}
onEmailChanged={this.onCommitterEmailChanged}
onDefaultBranchChanged={this.onDefaultBranchChanged}
/>
</>
)
@ -372,6 +385,10 @@ export class Preferences extends React.Component<
this.setState({ committerEmail })
}
private onDefaultBranchChanged = (defaultBranch: string) => {
this.setState({ defaultBranch })
}
private onSelectedEditorChanged = (editor: ExternalEditor) => {
this.setState({ selectedExternalEditor: editor })
}
@ -431,6 +448,20 @@ export class Preferences extends React.Component<
await setGlobalConfigValue('user.email', this.state.committerEmail)
}
// If the entered default branch is empty, we don't store it and keep
// the previous value.
// We do this because the preferences dialog doesn't have error states,
// and since the preferences dialog have a global "Save" button (that will
// save all the changes performed in every single tab), we cannot
// block the user from clicking "Save" because the entered branch is not valid
// (they will not be able to know the issue if they are in a different tab).
if (
this.state.defaultBranch.length > 0 &&
this.state.defaultBranch !== this.state.initialDefaultBranch
) {
await setDefaultBranch(this.state.defaultBranch)
}
if (
this.state.schannelCheckRevoke !==
this.state.initialSchannelCheckRevoke &&

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { WindowState } from '../../lib/window-state'
interface IFullScreenInfoProps {
@ -11,8 +11,7 @@ interface IFullScreenInfoState {
readonly renderTransitionGroup: boolean
}
const transitionAppearDuration = 100
const transitionLeaveDuration = 250
const toastTransitionTimeout = { appear: 100, exit: 250 }
const holdDuration = 3000
/**
@ -60,7 +59,9 @@ export class FullScreenInfo extends React.Component<
this.transitionGroupDisappearTimeoutId = window.setTimeout(
this.onTransitionGroupDisappearTimeout,
transitionAppearDuration + holdDuration + transitionLeaveDuration
toastTransitionTimeout.appear +
holdDuration +
toastTransitionTimeout.exit
)
this.setState({
@ -93,9 +94,17 @@ export class FullScreenInfo extends React.Component<
const kbdShortcut = __DARWIN__ ? '⌃⌘F' : 'F11'
return (
<div key="notification" className="toast-notification">
Press <kbd>{kbdShortcut}</kbd> to exit fullscreen
</div>
<CSSTransition
classNames="toast-animation"
appear={true}
enter={false}
exit={true}
timeout={toastTransitionTimeout}
>
<div key="notification" className="toast-notification">
Press <kbd>{kbdShortcut}</kbd> to exit fullscreen
</div>
</CSSTransition>
)
}
@ -105,18 +114,9 @@ export class FullScreenInfo extends React.Component<
}
return (
<CSSTransitionGroup
className="toast-notification-container"
transitionName="toast-animation"
component="div"
transitionAppear={true}
transitionEnter={false}
transitionLeave={true}
transitionAppearTimeout={transitionAppearDuration}
transitionLeaveTimeout={transitionLeaveDuration}
>
<TransitionGroup className="toast-notification-container">
{this.renderFullScreenNotification()}
</CSSTransitionGroup>
</TransitionGroup>
)
}
}

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
interface IZoomInfoProps {
readonly windowZoomFactor: number
@ -91,9 +91,17 @@ export class ZoomInfo extends React.Component<IZoomInfoProps, IZoomInfoState> {
const zoomPercent = `${(this.state.windowZoomFactor * 100).toFixed(0)}%`
return (
<div>
<span>{zoomPercent}</span>
</div>
<CSSTransition
classNames={this.state.transitionName}
appear={true}
enter={false}
exit={true}
timeout={transitionDuration}
>
<div>
<span>{zoomPercent}</span>
</div>
</CSSTransition>
)
}
@ -103,18 +111,9 @@ export class ZoomInfo extends React.Component<IZoomInfoProps, IZoomInfoState> {
}
return (
<CSSTransitionGroup
id="window-zoom-info"
transitionName={this.state.transitionName}
component="div"
transitionAppear={true}
transitionEnter={false}
transitionLeave={true}
transitionAppearTimeout={transitionDuration}
transitionLeaveTimeout={transitionDuration}
>
<TransitionGroup id="window-zoom-info">
{this.renderZoomInfo()}
</CSSTransitionGroup>
</TransitionGroup>
)
}
}

View file

@ -61,11 +61,11 @@
}
}
&.banner-leave {
&.banner-exit {
height: $banner-height;
opacity: 1;
&.banner-leave-active {
&.banner-exit-active {
height: 0px;
opacity: 0;
transition: height 225ms ease-in-out 175ms, opacity 175ms ease-in;

View file

@ -106,8 +106,11 @@
}
}
.ci-status {
.ci-status-container {
margin-right: var(--spacing-half);
min-width: 16px;
text-align: center;
flex-shrink: 0;
}
}

View file

@ -7,10 +7,19 @@
color: $yellow-700;
}
.ci-status-timed-out,
.ci-status-action-required,
.ci-status-failure {
color: $red-500;
}
.ci-status-cancelled,
.ci-status-stale,
.ci-status-skipped,
.ci-status-neutral {
color: $gray-400;
}
.ci-status-success {
color: $green-500;
}

View file

@ -70,7 +70,7 @@ dialog {
}
}
&-leave {
&-exit {
opacity: 1;
transform: scale(1);
pointer-events: none;
@ -80,7 +80,7 @@ dialog {
}
}
&-leave-active {
&-exit-active {
opacity: 0.01;
transform: scale(0.25);
transition: opacity 100ms ease-in, transform 100ms var(--easing-ease-in-back);

View file

@ -68,7 +68,7 @@
}
}
&-leave {
&-exit {
max-height: 200px;
&-active {

View file

@ -59,6 +59,20 @@
color: var(--text-secondary-color);
}
}
.default-branch-component {
margin-top: var(--spacing-double);
.ref-name-text-box {
margin-top: var(--spacing);
}
}
.git-settings-description {
margin-top: var(--spacing-double);
font-size: var(--font-size-sm);
color: var(--text-secondary-color);
}
}
#external-editor-error {

View file

@ -1,9 +1,16 @@
.radio-button-component {
display: flex;
align-items: center;
& + .radio-button-component {
margin-top: var(--spacing-half);
}
label {
& > input {
margin: 0;
}
& > label {
margin: 0;
margin-left: var(--spacing-half);
}

View file

@ -27,5 +27,24 @@
input {
@include textboxish;
@include textboxish-disabled;
// Customize the "clear" icon on search text inputs to match our
// theme icons and colors.
//
// To do so, we're creating an opaque 16x16 element with the background color
// that we want the icon to appear in and then apply the icon path
// as a mask, that way we can control the color dynamically based on
// our variables instead of hardcoding it in the SVG.
&[type='search']::-webkit-search-cancel-button {
-webkit-appearance: none;
width: 16px;
height: 16px;
margin-right: 0;
background-color: var(--text-color);
// The following SVG corresponds to the `x` octicon in 16px size:
// https://github.com/primer/octicons/blob/4661c1e0aa30c7d252318d2b003af782a6891089/icons/x-16.svg#L1
-webkit-mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/></svg>');
-webkit-mask-repeat: no-repeat;
}
}
}

View file

@ -117,11 +117,11 @@
transition: max-height var(--undo-animation-duration) ease-in;
}
.undo-leave {
.undo-exit {
max-height: 200px;
}
.undo-leave.undo-leave-active {
.undo-exit.undo-exit-active {
max-height: 0;
transition: max-height var(--undo-animation-duration) ease-out;

View file

@ -43,7 +43,7 @@
transition: all 100ms ease-out;
}
&-leave-active {
&-exit-active {
opacity: 0;
transition: all 250ms ease-out;
}

View file

@ -42,7 +42,7 @@
.zoom-in,
.zoom-out {
&-leave-active {
&-exit-active {
opacity: 0;
transition: opacity 100ms ease-out;
}

View file

@ -1,4 +1,5 @@
import * as path from 'path'
import { readFile, writeFile } from 'fs-extra'
import { Repository } from '../../../src/models/repository'
import {
@ -73,5 +74,20 @@ describe('git/submodule', () => {
result = await listSubmodules(repository)
expect(result[0].describe).toBe('first-tag~2')
})
it('eliminate submodule dirty state', async () => {
const testRepoPath = await setupFixtureRepository('submodule-basic-setup')
const repository = new Repository(testRepoPath, -1, null, false)
const submodulePath = path.join(testRepoPath, 'foo', 'submodule')
const filePath = path.join(submodulePath, 'README.md')
await writeFile(filePath, 'changed', { encoding: 'utf8' })
await resetSubmodulePaths(repository, ['foo/submodule'])
const result = await readFile(filePath, { encoding: 'utf8' })
expect(result).toBe('# submodule-test-case')
})
})
})

View file

@ -1,119 +0,0 @@
import { APIRefState } from '../../src/lib/api'
import { getRefStatusSummary } from '../../src/ui/branches/pull-request-status'
const failure: APIRefState = 'failure'
const pending: APIRefState = 'pending'
const success: APIRefState = 'success'
describe('pull request status', () => {
it('uses the state when no statuses found', () => {
const status = {
state: success,
total_count: 0,
statuses: [],
}
expect(getRefStatusSummary(status)).toBe('Commit status: success')
})
it('changes the failure message to something more friendly', () => {
const status = {
state: failure,
total_count: 0,
statuses: [],
}
expect(getRefStatusSummary(status)).toBe('Commit status: failed')
})
it('reads the statuses when they are populated', () => {
const status = {
state: success,
total_count: 2,
statuses: [
{
id: 1,
state: success,
description: 'first',
target_url: '',
context: '2',
},
{
id: 2,
state: success,
description: 'second',
target_url: '',
context: '2',
},
],
}
expect(getRefStatusSummary(status)).toBe('2/2 checks OK')
})
it('a successful status shows the description', () => {
const status = {
state: success,
total_count: 2,
statuses: [
{
id: 1,
state: success,
description: 'The Travis CI build passed',
target_url: '',
context: '1',
},
],
}
expect(getRefStatusSummary(status)).toBe(
'Success: The Travis CI build passed'
)
})
it('an error status shows the description', () => {
const status = {
state: success,
total_count: 2,
statuses: [
{
id: 1,
state: failure,
description: 'The Travis CI build failed',
target_url: '',
context: '1',
},
],
}
expect(getRefStatusSummary(status)).toBe(
'Failure: The Travis CI build failed'
)
})
it('only counts the successful statuses', () => {
const status = {
state: success,
total_count: 3,
statuses: [
{
id: 1,
state: success,
description: 'first',
target_url: '',
context: '1',
},
{
id: 2,
state: pending,
description: 'second',
target_url: '',
context: '2',
},
{
id: 2,
state: pending,
description: 'third',
target_url: '',
context: '3',
},
],
}
expect(getRefStatusSummary(status)).toBe('1/3 checks OK')
})
})

View file

@ -59,7 +59,6 @@ const commonConfig: webpack.Configuration = {
],
resolve: {
extensions: ['.js', '.ts', '.tsx'],
modules: [path.resolve(__dirname, 'node_modules/')],
},
node: {
__dirname: false,

View file

@ -9,6 +9,13 @@
dependencies:
regenerator-runtime "^0.12.0"
"@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
version "7.11.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.0.tgz#f10245877042a815e07f7e693faff0ae9d3a2aac"
integrity sha512-qArkXsjJq7H+T86WrIFV0Fnu/tNOkZ4cgXmjkzAu3b/58D5mFIO8JH/y77t7C9q0OdDRdh9s7Ue5GasYssxtXw==
dependencies:
regenerator-runtime "^0.13.4"
"@sindresorhus/is@^0.14.0":
version "0.14.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
@ -274,6 +281,11 @@ cross-spawn@^6.0.0:
shebang-command "^1.2.0"
which "^1.2.9"
csstype@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.2.tgz#ee5ff8f208c8cd613b389f7b222c9801ca62b3f7"
integrity sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw==
cycle@1.0.x:
version "1.0.3"
resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2"
@ -344,18 +356,21 @@ dom-classlist@^1.0.1:
resolved "https://registry.yarnpkg.com/dom-classlist/-/dom-classlist-1.0.1.tgz#722814dc2f509d3d7d06f2533ba9ff48eeea59b9"
integrity sha1-cigU3C9QnT19BvJTO6n/SO7qWbk=
"dom-helpers@^2.4.0 || ^3.0.0", dom-helpers@^3.2.0:
version "3.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a"
integrity sha1-MgPgf+0he9H0JLAZc1WC/Deyglo=
dom-helpers@^3.3.1:
"dom-helpers@^2.4.0 || ^3.0.0", dom-helpers@^3.3.1:
version "3.4.0"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"
integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==
dependencies:
"@babel/runtime" "^7.1.2"
dom-helpers@^5.0.1:
version "5.2.0"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b"
integrity sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==
dependencies:
"@babel/runtime" "^7.8.7"
csstype "^3.0.2"
dom-matches@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dom-matches/-/dom-matches-2.0.0.tgz#d2728b416a87533980eb089b848d253cf23a758c"
@ -1213,7 +1228,7 @@ promise@^7.1.1:
dependencies:
asap "~2.0.3"
prop-types@^15.5.6, prop-types@^15.6.0:
prop-types@^15.6.0:
version "15.6.0"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
integrity sha1-zq8IMCL8RrSjX2nhPvda7Q1jmFY=
@ -1325,16 +1340,15 @@ react-lifecycles-compat@^3.0.4:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-transition-group@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-1.2.1.tgz#e11f72b257f921b213229a774df46612346c7ca6"
integrity sha512-CWaL3laCmgAFdxdKbhhps+c0HRGF4c+hdM4H23+FI1QBNUyx/AMeIJGWorehPNSaKnQNOAxL7PQmqMu78CDj3Q==
react-transition-group@^4.4.1:
version "4.4.1"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==
dependencies:
chain-function "^1.0.0"
dom-helpers "^3.2.0"
loose-envify "^1.3.1"
prop-types "^15.5.6"
warning "^3.0.0"
"@babel/runtime" "^7.5.5"
dom-helpers "^5.0.1"
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-virtualized@^9.20.0:
version "9.20.0"
@ -1403,6 +1417,11 @@ regenerator-runtime@^0.12.0:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
regenerator-runtime@^0.13.4:
version "0.13.7"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
registry-js@^1.4.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/registry-js/-/registry-js-1.6.0.tgz#b7414b29690fb9287a2ea2d65980be0810303e49"

View file

@ -1,5 +1,15 @@
{
"releases": {
"2.5.4-beta4": [
"[Added] Support for specifying the default branch for new repositories - #10262",
"[Improved] Enable discarding submodule working directory changes - #8218",
"[Improved] Fix label alignment in radio-button component styles - #10397",
"[Improved] Custom icon in list filter text boxes - #10394",
"[Removed] Disable spellchecking for the time being - #10398"
],
"2.5.4-beta3": [
"[Added] Add Alacritty shell support - #10243. Thanks @halitogunc!"
],
"2.5.4-beta2": [
"[Added] Suggest to stash changes when trying to do an operation that requires a clean working directory - #10053",
"[Fixed] Pull request list is now keyboard accessible - #10334",

View file

@ -4,30 +4,28 @@
To authenticate against Azure DevOps repositories you will need to create a personal access token.
1. Go to your Azure DevOps account and select **Security** in the user profile dropdown:
1. Go to your Azure DevOps account and select **Personal Access Tokens** in the user settings dropdown:
![](https://user-images.githubusercontent.com/4404199/29400833-79755fe0-8337-11e7-8cfb-1d346a6801b4.png)
![](https://user-images.githubusercontent.com/792378/90431645-f9d9cd80-e08e-11ea-9fb4-ca8ba2a5d769.png)
2. Select **Personal access tokens**
2. Click **New token** to create a new personal access token. Give it a name, select the organizations you would like the token to apply to, and choose when you would like the token to expire.
3. Click **New token** to create a new personal access token. Give it a name, select the organizations you would like the token to apply to, and choose when you would like the token to expire.
- **Note:** For the **Expiration** dropdown you can select **Custom defined** to select an expiration date up to a year in advance of the current date. This is useful if you do not want to have to periodically go back and generate a new token after your current token expires.
- **Note:** For the **Expiration** dropdown you can select **Custom defined** to select an expiration date up to a year in advance of the current date. This is useful if you do not want to have to periodically go back and generate a new token after your current token expires.
3 . Under the **Scopes** section choose **Custom defined** and then select **Read & Write** under the **Code** section. This will grant GitHub Desktop read and write access to your Azure DevOps repositories.
4. Under the **Scopes** section choose **Custom defined** and then select **Read & Write** under the **Code** section. This will grant GitHub Desktop read and write access to your Azure DevOps repositories.
4 . Click **Create** to create a new token, and then copy it to your clipboard.
5. Click **Create** to create a new token, and then copy it to your clipboard.
![](https://user-images.githubusercontent.com/721500/51131191-fd470c00-17fc-11e9-8895-94f3784ebd4b.png)
## Cloning your Azure DevOps repository in GitHub Desktop
1. Open GitHub Desktop and go to **File** > **Clone Repository** > **URL**. Enter the Git URL of your Azure DevOps repository. Make sure you enter the correct URL, which should have the following structure:
`https://<username>@dev.azure.com/<username>/<project_name>/_git/<repository_name>`
2. You will receive an `Authentication Failed` error. Enter your Azure DevOps username and paste in the token you just copied to your clipboard. Click **Save and Retry** to successfully clone the repository to your local machine in GitHub Desktop.
![](https://user-images.githubusercontent.com/4404199/29401109-8bf03536-8338-11e7-8abb-b467378b6115.png)
- **Note:** Your Azure DevOps credentials will be securely stored on your local machine so you will not need to repeat this process when cloning another repository from Azure DevOps.

View file

@ -6,9 +6,9 @@ We introduced syntax highlighted diffs in [#3101](https://github.com/desktop/des
## Supported languages
We currently support syntax highlighting for the following languages.
We currently support syntax highlighting for the following languages and file types.
JavaScript, JSON, TypeScript, Coffeescript, HTML, CSS, SCSS, LESS, VUE, Markdown, Yaml, XML, Objective-C, Scala, C#, Java, C, C++, Kotlin, Ocaml, F#, sh/bash, Swift, SQL, CYPHER, Go, Perl, PHP, Python, Ruby, Clojure, Rust, Elixir, Haxe, R, JavaServer Pages, PowerShell, Docker, Visual Basic, Fortran, Lua, Crystal, Julia, sTex, SPARQL, Stylus, Soy, Smalltalk, Slim, Sieve, Scheme, ReStructuredText, RPM, Q, Puppet, Pug, Protobuf, Properties, Apache Pig, ASCII Armor (PGP), Oz and Pascal.
JavaScript, JSON, TypeScript, Coffeescript, HTML, Asp, JavaServer Pages, CSS, SCSS, LESS, VUE, Markdown, Yaml, XML, Diff, Objective-C, Scala, C#, Java, C, C++, Kotlin, Ocaml, F#, Swift, sh/bash, SQL, CYPHER, Go, Perl, PHP, Python, Ruby, Clojure, Rust, Elixir, Haxe, R, PowerShell, Visual Basic, Fortran, Lua, Crystal, Julia, sTex, SPARQL, Stylus, Soy, Smalltalk, Slim, Sieve, Scheme, ReStructuredText, RPM, Q, Puppet, Pug, Protobuf, Properties, Apache Pig, ASCII Armor (PGP), Oz, Pascal and Docker.
This list was never meant to be exhaustive, we expect to add more languages going forward but this seemed like a good first step.

View file

@ -149,7 +149,7 @@
"@types/react": "^16.8.7",
"@types/react-css-transition-replace": "^2.1.3",
"@types/react-dom": "^16.8.2",
"@types/react-transition-group": "1.1.1",
"@types/react-transition-group": "^4.4.0",
"@types/react-virtualized": "^9.7.12",
"@types/request": "^2.0.9",
"@types/semver": "^5.5.0",

View file

@ -36,10 +36,10 @@ function packageOSX() {
const dest = getOSXZipPath()
fs.removeSync(dest)
console.log('Packaging for macOS…')
cp.execSync(
`ditto -ck --keepParent "${distPath}/${productName}.app" "${dest}"`
)
console.log(`Zipped to ${dest}`)
}
function packageWindows() {
@ -53,7 +53,8 @@ function packageWindows() {
)
if (isAppveyor() || isGitHubActions()) {
cp.execSync(`powershell ${setupCertificatePath}`)
console.log('Installing signing certificate…')
cp.execSync(`powershell ${setupCertificatePath}`, { stdio: 'inherit' })
}
const iconSource = path.join(
@ -108,6 +109,7 @@ function packageWindows() {
options.signWithParams = `/f ${certificatePath} /p ${process.env.WINDOWS_CERT_PASSWORD} /tr http://timestamp.digicert.com /td sha256 /fd sha256`
}
console.log('Packaging for Windows…')
electronInstaller
.createWindowsInstaller(options)
.then(() => {
@ -141,5 +143,6 @@ function packageLinux() {
configPath,
]
console.log('Packaging for Linux…')
cp.spawnSync(electronBuilder, args, { stdio: 'inherit' })
}

View file

@ -29,7 +29,7 @@ import * as Crypto from 'crypto'
import request from 'request'
console.log('Packaging…')
execSync('yarn package')
execSync('yarn package', { stdio: 'inherit' })
const sha = platforms.getSha().substr(0, 8)

View file

@ -607,10 +607,10 @@
dependencies:
"@types/react" "*"
"@types/react-transition-group@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-1.1.1.tgz#372fd2b4777b96aa983ac15fb5cc0ce150550aeb"
integrity sha512-LOXbB5NTmyaZeiysrSRhgQDGFic7gyavZ6HcBkWwSL7PW+aYRh/cOPO+wLp2YnHJLWTQz9Avr3qOXWCrWhGOfA==
"@types/react-transition-group@^4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.0.tgz#882839db465df1320e4753e6e9f70ca7e9b4d46d"
integrity sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==
dependencies:
"@types/react" "*"
@ -6729,15 +6729,10 @@ kind-of@^5.0.0:
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
kind-of@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.0.tgz#3606e9e2fa960e7ddaa8898c03804e47e5d66644"
integrity sha512-sUd5AnFyOPh+RW+ZIHd1FHuwM4OFvhKCPVxxhamLxWLpmv1xQ394lzRMmhLQOiMpXvnB64YRLezWaJi5xGk7Dg==
kind-of@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==
kind-of@^6.0.0, kind-of@^6.0.2:
version "6.0.3"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
klaw-sync@^3.0.0:
version "3.0.2"