mirror of
https://github.com/desktop/desktop
synced 2024-10-05 23:59:33 +00:00
Merge branch 'development' into releases/2.6.2
This commit is contained in:
commit
c184eb25c4
|
@ -36,7 +36,7 @@ install GitHub Desktop:
|
|||
- Windows users can install using [Chocolatey](https://chocolatey.org/) package manager:
|
||||
`c:\> choco install github-desktop`
|
||||
- macOS users can install using [Homebrew](https://brew.sh/) package manager:
|
||||
`$ brew cask install github`
|
||||
`$ brew install --cask github`
|
||||
|
||||
Installers for various Linux distributions can be found on the
|
||||
[`shiftkey/desktop`](https://github.com/shiftkey/desktop) fork.
|
||||
|
|
|
@ -47,7 +47,7 @@ function getBundleIdentifiers(editor: ExternalEditor): ReadonlyArray<string> {
|
|||
case ExternalEditor.VSCodium:
|
||||
return ['com.visualstudio.code.oss']
|
||||
case ExternalEditor.SublimeText:
|
||||
return ['com.sublimetext.3']
|
||||
return ['com.sublimetext.4', 'com.sublimetext.3', 'com.sublimetext.2']
|
||||
case ExternalEditor.BBEdit:
|
||||
return ['com.barebones.bbedit']
|
||||
case ExternalEditor.PhpStorm:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { git, GitError, parseCommitSHA } from './core'
|
||||
import { git, parseCommitSHA } from './core'
|
||||
import { stageFiles } from './update-index'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { WorkingDirectoryFileChange } from '../../models/status'
|
||||
|
@ -15,7 +15,7 @@ export async function createCommit(
|
|||
repository: Repository,
|
||||
message: string,
|
||||
files: ReadonlyArray<WorkingDirectoryFileChange>
|
||||
): Promise<string | undefined> {
|
||||
): Promise<string> {
|
||||
// Clear the staging area, our diffs reflect the difference between the
|
||||
// working directory and the last commit (if any) so our commits should
|
||||
// do the same thing.
|
||||
|
@ -23,20 +23,15 @@ export async function createCommit(
|
|||
|
||||
await stageFiles(repository, files)
|
||||
|
||||
try {
|
||||
const result = await git(
|
||||
['commit', '-F', '-'],
|
||||
repository.path,
|
||||
'createCommit',
|
||||
{
|
||||
stdin: message,
|
||||
}
|
||||
)
|
||||
return parseCommitSHA(result)
|
||||
} catch (e) {
|
||||
logCommitError(e)
|
||||
return undefined
|
||||
}
|
||||
const result = await git(
|
||||
['commit', '-F', '-'],
|
||||
repository.path,
|
||||
'createCommit',
|
||||
{
|
||||
stdin: message,
|
||||
}
|
||||
)
|
||||
return parseCommitSHA(result)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -51,80 +46,54 @@ export async function createMergeCommit(
|
|||
repository: Repository,
|
||||
files: ReadonlyArray<WorkingDirectoryFileChange>,
|
||||
manualResolutions: ReadonlyMap<string, ManualConflictResolution> = new Map()
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
// apply manual conflict resolutions
|
||||
for (const [path, resolution] of manualResolutions) {
|
||||
const file = files.find(f => f.path === path)
|
||||
if (file !== undefined) {
|
||||
await stageManualConflictResolution(repository, file, resolution)
|
||||
} else {
|
||||
log.error(
|
||||
`couldn't find file ${path} even though there's a manual resolution for it`
|
||||
)
|
||||
}
|
||||
): Promise<string> {
|
||||
// apply manual conflict resolutions
|
||||
for (const [path, resolution] of manualResolutions) {
|
||||
const file = files.find(f => f.path === path)
|
||||
if (file !== undefined) {
|
||||
await stageManualConflictResolution(repository, file, resolution)
|
||||
} else {
|
||||
log.error(
|
||||
`couldn't find file ${path} even though there's a manual resolution for it`
|
||||
)
|
||||
}
|
||||
|
||||
const otherFiles = files.filter(f => !manualResolutions.has(f.path))
|
||||
|
||||
await stageFiles(repository, otherFiles)
|
||||
const result = await git(
|
||||
[
|
||||
'commit',
|
||||
// no-edit here ensures the app does not accidentally invoke the user's editor
|
||||
'--no-edit',
|
||||
// By default Git merge commits do not contain any commentary (which
|
||||
// are lines prefixed with `#`). This works because the Git CLI will
|
||||
// prompt the user to edit the file in `.git/COMMIT_MSG` before
|
||||
// committing, and then it will run `--cleanup=strip`.
|
||||
//
|
||||
// This clashes with our use of `--no-edit` above as Git will now change
|
||||
// it's behavior to invoke `--cleanup=whitespace` as it did not ask
|
||||
// the user to edit the COMMIT_MSG as part of creating a commit.
|
||||
//
|
||||
// From the docs on git-commit (https://git-scm.com/docs/git-commit) I'll
|
||||
// quote the relevant section:
|
||||
// --cleanup=<mode>
|
||||
// strip
|
||||
// Strip leading and trailing empty lines, trailing whitespace,
|
||||
// commentary and collapse consecutive empty lines.
|
||||
// whitespace
|
||||
// Same as `strip` except #commentary is not removed.
|
||||
// default
|
||||
// Same as `strip` if the message is to be edited. Otherwise `whitespace`.
|
||||
//
|
||||
// We should emulate the behavior in this situation because we don't
|
||||
// let the user view or change the commit message before making the
|
||||
// commit.
|
||||
'--cleanup=strip',
|
||||
],
|
||||
repository.path,
|
||||
'createMergeCommit'
|
||||
)
|
||||
return parseCommitSHA(result)
|
||||
} catch (e) {
|
||||
logCommitError(e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit failures could come from a pre-commit hook rejection.
|
||||
* So display a bit more context than we otherwise would,
|
||||
* then re-raise the error.
|
||||
*/
|
||||
function logCommitError(e: Error): void {
|
||||
if (e instanceof GitError) {
|
||||
const output = e.result.stderr.trim()
|
||||
const otherFiles = files.filter(f => !manualResolutions.has(f.path))
|
||||
|
||||
const standardError = output.length > 0 ? `, with output: '${output}'` : ''
|
||||
const { exitCode } = e.result
|
||||
const error = new Error(
|
||||
`Commit failed - exit code ${exitCode} received${standardError}`
|
||||
)
|
||||
error.name = 'commit-failed'
|
||||
throw error
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
await stageFiles(repository, otherFiles)
|
||||
const result = await git(
|
||||
[
|
||||
'commit',
|
||||
// no-edit here ensures the app does not accidentally invoke the user's editor
|
||||
'--no-edit',
|
||||
// By default Git merge commits do not contain any commentary (which
|
||||
// are lines prefixed with `#`). This works because the Git CLI will
|
||||
// prompt the user to edit the file in `.git/COMMIT_MSG` before
|
||||
// committing, and then it will run `--cleanup=strip`.
|
||||
//
|
||||
// This clashes with our use of `--no-edit` above as Git will now change
|
||||
// it's behavior to invoke `--cleanup=whitespace` as it did not ask
|
||||
// the user to edit the COMMIT_MSG as part of creating a commit.
|
||||
//
|
||||
// From the docs on git-commit (https://git-scm.com/docs/git-commit) I'll
|
||||
// quote the relevant section:
|
||||
// --cleanup=<mode>
|
||||
// strip
|
||||
// Strip leading and trailing empty lines, trailing whitespace,
|
||||
// commentary and collapse consecutive empty lines.
|
||||
// whitespace
|
||||
// Same as `strip` except #commentary is not removed.
|
||||
// default
|
||||
// Same as `strip` if the message is to be edited. Otherwise `whitespace`.
|
||||
//
|
||||
// We should emulate the behavior in this situation because we don't
|
||||
// let the user view or change the commit message before making the
|
||||
// commit.
|
||||
'--cleanup=strip',
|
||||
],
|
||||
repository.path,
|
||||
'createMergeCommit'
|
||||
)
|
||||
return parseCommitSHA(result)
|
||||
}
|
||||
|
|
|
@ -15,6 +15,9 @@ import * as Path from 'path'
|
|||
import { Repository } from '../../models/repository'
|
||||
import { getConfigValue, getGlobalConfigValue } from './config'
|
||||
import { isErrnoException } from '../errno-exception'
|
||||
import { ChildProcess } from 'child_process'
|
||||
import { Readable } from 'stream'
|
||||
import split2 from 'split2'
|
||||
|
||||
/**
|
||||
* An extension of the execution options in dugite that
|
||||
|
@ -54,6 +57,9 @@ export interface IGitResult extends DugiteResult {
|
|||
/** The human-readable error description, based on `gitError`. */
|
||||
readonly gitErrorDescription: string | null
|
||||
|
||||
/** Both stdout and stderr combined. */
|
||||
readonly combinedOutput: string
|
||||
|
||||
/**
|
||||
* The path that the Git command was executed from, i.e. the
|
||||
* process working directory (not to be confused with the Git
|
||||
|
@ -61,22 +67,6 @@ export interface IGitResult extends DugiteResult {
|
|||
*/
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
function getResultMessage(result: IGitResult) {
|
||||
const description = result.gitErrorDescription
|
||||
if (description) {
|
||||
return description
|
||||
}
|
||||
|
||||
if (result.stderr.length) {
|
||||
return result.stderr
|
||||
} else if (result.stdout.length) {
|
||||
return result.stdout
|
||||
} else {
|
||||
return 'Unknown error'
|
||||
}
|
||||
}
|
||||
|
||||
export class GitError extends Error {
|
||||
/** The result from the failed command. */
|
||||
public readonly result: IGitResult
|
||||
|
@ -84,12 +74,35 @@ export class GitError extends Error {
|
|||
/** The args for the failed command. */
|
||||
public readonly args: ReadonlyArray<string>
|
||||
|
||||
/**
|
||||
* Whether or not the error message is just the raw output of the git command.
|
||||
*/
|
||||
public readonly isRawMessage: boolean
|
||||
|
||||
public constructor(result: IGitResult, args: ReadonlyArray<string>) {
|
||||
super(getResultMessage(result))
|
||||
let rawMessage = true
|
||||
let message
|
||||
|
||||
if (result.gitErrorDescription) {
|
||||
message = result.gitErrorDescription
|
||||
rawMessage = false
|
||||
} else if (result.combinedOutput.length > 0) {
|
||||
message = result.combinedOutput
|
||||
} else if (result.stderr.length) {
|
||||
message = result.stderr
|
||||
} else if (result.stdout.length) {
|
||||
message = result.stdout
|
||||
} else {
|
||||
message = 'Unknown error'
|
||||
rawMessage = false
|
||||
}
|
||||
|
||||
super(message)
|
||||
|
||||
this.name = 'GitError'
|
||||
this.result = result
|
||||
this.args = args
|
||||
this.isRawMessage = rawMessage
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -123,8 +136,24 @@ export async function git(
|
|||
expectedErrors: new Set(),
|
||||
}
|
||||
|
||||
let combinedOutput = ''
|
||||
const opts = { ...defaultOptions, ...options }
|
||||
|
||||
opts.processCallback = (process: ChildProcess) => {
|
||||
options?.processCallback?.(process)
|
||||
|
||||
const combineOutput = (readable: Readable | null) => {
|
||||
if (readable) {
|
||||
readable.pipe(split2()).on('data', (line: string) => {
|
||||
combinedOutput += line + '\n'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
combineOutput(process.stderr)
|
||||
combineOutput(process.stdout)
|
||||
}
|
||||
|
||||
// Explicitly set TERM to 'dumb' so that if Desktop was launched
|
||||
// from a terminal or if the system environment variables
|
||||
// have TERM set Git won't consider us as a smart terminal.
|
||||
|
@ -160,7 +189,13 @@ export async function git(
|
|||
}
|
||||
|
||||
const gitErrorDescription = gitError ? getDescriptionForError(gitError) : null
|
||||
const gitResult = { ...result, gitError, gitErrorDescription, path }
|
||||
const gitResult = {
|
||||
...result,
|
||||
gitError,
|
||||
gitErrorDescription,
|
||||
combinedOutput,
|
||||
path,
|
||||
}
|
||||
|
||||
let acceptableError = true
|
||||
if (gitError && opts.expectedErrors) {
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
} from '../local-storage'
|
||||
import { PushOptions } from '../git'
|
||||
import { getShowSideBySideDiff } from '../../ui/lib/diff-mode'
|
||||
import { remote } from 'electron'
|
||||
|
||||
const StatsEndpoint = 'https://central.github.com/api/usage/desktop'
|
||||
|
||||
|
@ -312,6 +313,12 @@ interface ICalculatedStats {
|
|||
* default) diff view mode
|
||||
*/
|
||||
readonly diffMode: 'split' | 'unified'
|
||||
|
||||
/**
|
||||
* Whether the app was launched from the Applications folder or not. This is
|
||||
* only relevant on macOS, null will be sent otherwise.
|
||||
*/
|
||||
readonly launchedFromApplicationsFolder: boolean | null
|
||||
}
|
||||
|
||||
type DailyStats = ICalculatedStats &
|
||||
|
@ -481,6 +488,10 @@ export class StatsStore implements IStatsStore {
|
|||
).length
|
||||
const diffMode = getShowSideBySideDiff() ? 'split' : 'unified'
|
||||
|
||||
// isInApplicationsFolder is undefined when not running on Darwin
|
||||
const launchedFromApplicationsFolder =
|
||||
remote.app.isInApplicationsFolder?.() ?? null
|
||||
|
||||
return {
|
||||
eventType: 'usage',
|
||||
version: getVersion(),
|
||||
|
@ -497,6 +508,7 @@ export class StatsStore implements IStatsStore {
|
|||
...repositoryCounts,
|
||||
repositoriesCommittedInWithoutWriteAccess,
|
||||
diffMode,
|
||||
launchedFromApplicationsFolder,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2376,90 +2376,106 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
|
||||
const gitStore = this.gitStoreCache.get(repository)
|
||||
|
||||
const result = await this.isCommitting(repository, () => {
|
||||
return gitStore.performFailableOperation(async () => {
|
||||
return this.withIsCommitting(repository, async () => {
|
||||
const result = await gitStore.performFailableOperation(async () => {
|
||||
const message = await formatCommitMessage(repository, context)
|
||||
return createCommit(repository, message, selectedFiles)
|
||||
})
|
||||
|
||||
if (result !== undefined) {
|
||||
await this._recordCommitStats(
|
||||
gitStore,
|
||||
repository,
|
||||
state,
|
||||
context,
|
||||
files
|
||||
)
|
||||
|
||||
await this._refreshRepository(repository)
|
||||
await this.refreshChangesSection(repository, {
|
||||
includingStatus: true,
|
||||
clearPartialState: true,
|
||||
})
|
||||
}
|
||||
|
||||
return result !== undefined
|
||||
})
|
||||
}
|
||||
|
||||
if (result) {
|
||||
this.statsStore.recordCommit()
|
||||
private async _recordCommitStats(
|
||||
gitStore: GitStore,
|
||||
repository: Repository,
|
||||
repositoryState: IRepositoryState,
|
||||
context: ICommitContext,
|
||||
files: readonly WorkingDirectoryFileChange[]
|
||||
) {
|
||||
this.statsStore.recordCommit()
|
||||
|
||||
const includedPartialSelections = files.some(
|
||||
file => file.selection.getSelectionType() === DiffSelectionType.Partial
|
||||
)
|
||||
if (includedPartialSelections) {
|
||||
this.statsStore.recordPartialCommit()
|
||||
}
|
||||
|
||||
const { trailers } = context
|
||||
if (trailers !== undefined && trailers.some(isCoAuthoredByTrailer)) {
|
||||
this.statsStore.recordCoAuthoredCommit()
|
||||
}
|
||||
|
||||
const account = getAccountForRepository(this.accounts, repository)
|
||||
if (repository.gitHubRepository !== null) {
|
||||
if (account !== null) {
|
||||
if (account.endpoint === getDotComAPIEndpoint()) {
|
||||
this.statsStore.recordCommitToDotcom()
|
||||
} else {
|
||||
this.statsStore.recordCommitToEnterprise()
|
||||
}
|
||||
|
||||
const { commitAuthor } = state
|
||||
if (commitAuthor !== null) {
|
||||
const commitEmail = commitAuthor.email.toLowerCase()
|
||||
const attributableEmails = getAttributableEmailsFor(account)
|
||||
const commitEmailMatchesAccount = attributableEmails.some(
|
||||
email => email.toLowerCase() === commitEmail
|
||||
)
|
||||
if (!commitEmailMatchesAccount) {
|
||||
this.statsStore.recordUnattributedCommit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const branchProtectionsFound = await this.repositoriesStore.hasBranchProtectionsConfigured(
|
||||
repository.gitHubRepository
|
||||
)
|
||||
|
||||
if (branchProtectionsFound) {
|
||||
this.statsStore.recordCommitToRepositoryWithBranchProtections()
|
||||
}
|
||||
|
||||
const branchName = findRemoteBranchName(
|
||||
gitStore.tip,
|
||||
gitStore.currentRemote,
|
||||
repository.gitHubRepository
|
||||
)
|
||||
|
||||
if (branchName !== null) {
|
||||
const { changesState } = this.repositoryStateCache.get(repository)
|
||||
if (changesState.currentBranchProtected) {
|
||||
this.statsStore.recordCommitToProtectedBranch()
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
repository.gitHubRepository !== null &&
|
||||
!hasWritePermission(repository.gitHubRepository)
|
||||
) {
|
||||
this.statsStore.recordCommitToRepositoryWithoutWriteAccess()
|
||||
this.statsStore.recordRepositoryCommitedInWithoutWriteAccess(
|
||||
repository.gitHubRepository.dbID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await this._refreshRepository(repository)
|
||||
await this.refreshChangesSection(repository, {
|
||||
includingStatus: true,
|
||||
clearPartialState: true,
|
||||
})
|
||||
const includedPartialSelections = files.some(
|
||||
file => file.selection.getSelectionType() === DiffSelectionType.Partial
|
||||
)
|
||||
if (includedPartialSelections) {
|
||||
this.statsStore.recordPartialCommit()
|
||||
}
|
||||
|
||||
return result || false
|
||||
const { trailers } = context
|
||||
if (trailers !== undefined && trailers.some(isCoAuthoredByTrailer)) {
|
||||
this.statsStore.recordCoAuthoredCommit()
|
||||
}
|
||||
|
||||
const account = getAccountForRepository(this.accounts, repository)
|
||||
if (repository.gitHubRepository !== null) {
|
||||
if (account !== null) {
|
||||
if (account.endpoint === getDotComAPIEndpoint()) {
|
||||
this.statsStore.recordCommitToDotcom()
|
||||
} else {
|
||||
this.statsStore.recordCommitToEnterprise()
|
||||
}
|
||||
|
||||
const { commitAuthor } = repositoryState
|
||||
if (commitAuthor !== null) {
|
||||
const commitEmail = commitAuthor.email.toLowerCase()
|
||||
const attributableEmails = getAttributableEmailsFor(account)
|
||||
const commitEmailMatchesAccount = attributableEmails.some(
|
||||
email => email.toLowerCase() === commitEmail
|
||||
)
|
||||
if (!commitEmailMatchesAccount) {
|
||||
this.statsStore.recordUnattributedCommit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const branchProtectionsFound = await this.repositoriesStore.hasBranchProtectionsConfigured(
|
||||
repository.gitHubRepository
|
||||
)
|
||||
|
||||
if (branchProtectionsFound) {
|
||||
this.statsStore.recordCommitToRepositoryWithBranchProtections()
|
||||
}
|
||||
|
||||
const branchName = findRemoteBranchName(
|
||||
gitStore.tip,
|
||||
gitStore.currentRemote,
|
||||
repository.gitHubRepository
|
||||
)
|
||||
|
||||
if (branchName !== null) {
|
||||
const { changesState } = this.repositoryStateCache.get(repository)
|
||||
if (changesState.currentBranchProtected) {
|
||||
this.statsStore.recordCommitToProtectedBranch()
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
repository.gitHubRepository !== null &&
|
||||
!hasWritePermission(repository.gitHubRepository)
|
||||
) {
|
||||
this.statsStore.recordCommitToRepositoryWithoutWriteAccess()
|
||||
this.statsStore.recordRepositoryCommitedInWithoutWriteAccess(
|
||||
repository.gitHubRepository.dbID
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
|
@ -3562,14 +3578,14 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
})
|
||||
}
|
||||
|
||||
private async isCommitting(
|
||||
private async withIsCommitting(
|
||||
repository: Repository,
|
||||
fn: () => Promise<string | undefined>
|
||||
): Promise<boolean | undefined> {
|
||||
fn: () => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
const state = this.repositoryStateCache.get(repository)
|
||||
// ensure the user doesn't try and commit again
|
||||
if (state.isCommitting) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
this.repositoryStateCache.update(repository, () => ({
|
||||
|
@ -3578,8 +3594,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
|
|||
this.emitUpdate()
|
||||
|
||||
try {
|
||||
const sha = await fn()
|
||||
return sha !== undefined
|
||||
return await fn()
|
||||
} finally {
|
||||
this.repositoryStateCache.update(repository, () => ({
|
||||
isCommitting: false,
|
||||
|
|
|
@ -108,16 +108,13 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
|
|||
}
|
||||
|
||||
private renderErrorMessage(error: Error) {
|
||||
const e = error instanceof ErrorWithMetadata ? error.underlyingError : error
|
||||
const e = getUnderlyingError(error)
|
||||
|
||||
if (e instanceof GitError) {
|
||||
// See getResultMessage in core.ts
|
||||
// If the error message is the same as stderr or stdout then we know
|
||||
// it's output from git and we'll display it in fixed-width font
|
||||
if (e.message === e.result.stderr || e.message === e.result.stdout) {
|
||||
const formattedMessage = this.formatGitErrorMessage(e.message)
|
||||
return <p className="monospace">{formattedMessage}</p>
|
||||
}
|
||||
// If the error message is just the raw git output, display it in
|
||||
// fixed-width font
|
||||
if (isRawGitError(e)) {
|
||||
const formattedMessage = this.formatGitErrorMessage(e.message)
|
||||
return <p className="monospace">{formattedMessage}</p>
|
||||
}
|
||||
|
||||
return <p>{e.message}</p>
|
||||
|
@ -148,6 +145,9 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
|
|||
onSubmit={this.onDismissed}
|
||||
onDismissed={this.onDismissed}
|
||||
disabled={this.state.disabled}
|
||||
className={
|
||||
isRawGitError(this.state.error) ? 'raw-git-error' : undefined
|
||||
}
|
||||
>
|
||||
<DialogContent onRef={this.onDialogContentRef}>
|
||||
{this.renderErrorMessage(error)}
|
||||
|
@ -187,10 +187,8 @@ export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
|
|||
|
||||
const e = getUnderlyingError(this.state.error)
|
||||
|
||||
if (isGitError(e)) {
|
||||
if (e.message === e.result.stderr || e.message === e.result.stdout) {
|
||||
this.dialogContent.scrollTop = this.dialogContent.scrollHeight
|
||||
}
|
||||
if (isRawGitError(e)) {
|
||||
this.dialogContent.scrollTop = this.dialogContent.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -285,6 +283,14 @@ function isGitError(error: Error): error is GitError {
|
|||
return error instanceof GitError
|
||||
}
|
||||
|
||||
function isRawGitError(error: Error | null) {
|
||||
if (!error) {
|
||||
return false
|
||||
}
|
||||
const e = getUnderlyingError(error)
|
||||
return e instanceof GitError && e.isRawMessage
|
||||
}
|
||||
|
||||
function isCloneError(error: Error) {
|
||||
if (!isErrorWithMetaData(error)) {
|
||||
return false
|
||||
|
|
|
@ -193,6 +193,11 @@ export class AppMenuBarButton extends React.Component<
|
|||
dropdownState={dropDownState}
|
||||
onDropdownStateChanged={this.onDropdownStateChanged}
|
||||
dropdownContentRenderer={this.dropDownContentRenderer}
|
||||
// Disable the dropdown focus trap for menus. Items in the menus are not
|
||||
// "tabbable", so the app crashes when this prop is set to true and the
|
||||
// user opens a menu (on Windows).
|
||||
// Besides, we use a custom "focus trap" for menus anyway.
|
||||
enableFocusTrap={false}
|
||||
showDisclosureArrow={false}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onKeyDown={this.onKeyDown}
|
||||
|
|
|
@ -776,7 +776,10 @@ export class ChangesList extends React.Component<
|
|||
selectedRows={this.state.selectedRows}
|
||||
selectionMode="multi"
|
||||
onSelectionChanged={this.props.onFileSelectionChanged}
|
||||
invalidationProps={this.props.workingDirectory}
|
||||
invalidationProps={{
|
||||
workingDirectory: this.props.workingDirectory,
|
||||
isCommitting: this.props.isCommitting,
|
||||
}}
|
||||
onRowClick={this.props.onRowClick}
|
||||
onScroll={this.onScroll}
|
||||
setScrollTop={this.props.changesListScrollTop}
|
||||
|
|
|
@ -4,6 +4,8 @@ import { assertNever } from '../../lib/fatal-error'
|
|||
import { ToolbarButton, ToolbarButtonStyle } from './button'
|
||||
import { rectEquals } from '../lib/rect'
|
||||
import classNames from 'classnames'
|
||||
import FocusTrap from 'focus-trap-react'
|
||||
import { Options as FocusTrapOptions } from 'focus-trap'
|
||||
|
||||
export type DropdownState = 'open' | 'closed'
|
||||
|
||||
|
@ -73,6 +75,9 @@ export interface IToolbarDropdownProps {
|
|||
/** The button's style. Defaults to `ToolbarButtonStyle.Standard`. */
|
||||
readonly style?: ToolbarButtonStyle
|
||||
|
||||
/** Wether the dropdown will trap focus or not. Defaults to true. */
|
||||
readonly enableFocusTrap?: boolean
|
||||
|
||||
/**
|
||||
* Sets the styles for the dropdown's foldout. Useful for custom positioning
|
||||
* and sizes.
|
||||
|
@ -142,10 +147,20 @@ export class ToolbarDropdown extends React.Component<
|
|||
IToolbarDropdownState
|
||||
> {
|
||||
private innerButton: ToolbarButton | null = null
|
||||
private focusTrapOptions: FocusTrapOptions
|
||||
|
||||
public constructor(props: IToolbarDropdownProps) {
|
||||
super(props)
|
||||
this.state = { clientRect: null }
|
||||
|
||||
this.focusTrapOptions = {
|
||||
allowOutsideClick: true,
|
||||
|
||||
// Explicitly disable deactivation from the FocusTrap, since in that case
|
||||
// we would lose the "source" of the event (keyboard vs pointer).
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}
|
||||
}
|
||||
|
||||
private get isOpen() {
|
||||
|
@ -273,21 +288,26 @@ export class ToolbarDropdown extends React.Component<
|
|||
// bar to instantly close before even receiving the onDropdownStateChanged
|
||||
// event from us.
|
||||
return (
|
||||
<div id="foldout-container" style={this.getFoldoutContainerStyle()}>
|
||||
<div
|
||||
className="overlay"
|
||||
tabIndex={-1}
|
||||
onClick={this.handleOverlayClick}
|
||||
/>
|
||||
<div
|
||||
className="foldout"
|
||||
style={this.getFoldoutStyle()}
|
||||
tabIndex={-1}
|
||||
onKeyDown={this.onFoldoutKeyDown}
|
||||
>
|
||||
{this.props.dropdownContentRenderer()}
|
||||
<FocusTrap
|
||||
active={this.props.enableFocusTrap ?? true}
|
||||
focusTrapOptions={this.focusTrapOptions}
|
||||
>
|
||||
<div id="foldout-container" style={this.getFoldoutContainerStyle()}>
|
||||
<div
|
||||
className="overlay"
|
||||
tabIndex={-1}
|
||||
onClick={this.handleOverlayClick}
|
||||
/>
|
||||
<div
|
||||
className="foldout"
|
||||
style={this.getFoldoutStyle()}
|
||||
tabIndex={-1}
|
||||
onKeyDown={this.onFoldoutKeyDown}
|
||||
>
|
||||
{this.props.dropdownContentRenderer()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -321,6 +321,12 @@ dialog {
|
|||
}
|
||||
|
||||
&#app-error {
|
||||
// Use wider dialogs for raw git errors, to make sure we can display lines
|
||||
// up to 80 characters long without wrapping them.
|
||||
&.raw-git-error {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
p {
|
||||
-webkit-user-select: text;
|
||||
|
|
|
@ -727,7 +727,7 @@ describe('git/commit', () => {
|
|||
const status = await getStatusOrThrow(repository)
|
||||
expect(
|
||||
createMergeCommit(repository, status.workingDirectory.files)
|
||||
).rejects.toThrow(/Commit failed/i)
|
||||
).rejects.toThrow('There are no changes to commit.')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -112,6 +112,7 @@ describe('git/core', () => {
|
|||
gitErrorDescription: null,
|
||||
stderr,
|
||||
stdout: '',
|
||||
combinedOutput: stderr,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -166,7 +166,7 @@ describe('git/tag', () => {
|
|||
|
||||
await checkoutBranch(repository, account, branch!)
|
||||
const commitSha = await createCommit(repository, 'a commit', files)
|
||||
await createTag(repository, 'my-new-tag', commitSha!)
|
||||
await createTag(repository, 'my-new-tag', commitSha)
|
||||
|
||||
expect(
|
||||
await fetchTagsToPush(repository, account, originRemote, 'master')
|
||||
|
|
|
@ -10,6 +10,15 @@
|
|||
"[Fixed] Avoid bright flash for users of the dark theme when launching the app maximized - #5631. Thanks @AndreiMaga!",
|
||||
"[Fixed] VSCodium is now detected as an editor on Windows - #11252. Thanks @KallePM!"
|
||||
],
|
||||
"2.6.2-beta4": [
|
||||
"[Improved] Show full output of Git hooks on errors - #6403",
|
||||
"[Improved] Keep focus in dropdown when pressing tab multiple times - #11278",
|
||||
"[Improved] Upgrade embedded Git LFS to 2.13.2 - #11394",
|
||||
"[Improved] Add detection of Sublime Text 4 and 2 as editor on macOS. - #11263. Thanks @yurikoles!",
|
||||
"[Fixed] Fork behavior changes are now reflected in the app immediately - #11327",
|
||||
"[Fixed] Commit message remains in text box until user chooses to commit - #7251"
|
||||
],
|
||||
"2.6.2-beta3": ["[Removed] Release removed in favor of 2.6.2-beta4"],
|
||||
"2.6.2-beta2": [
|
||||
"[Fixed] Forked repository remotes are no longer removed when there's local branches tracking them - #11266",
|
||||
"[Fixed] Avoid bright flash for users of the dark theme when launching the app maximized - #5631. Thanks @AndreiMaga!",
|
||||
|
|
|
@ -101,6 +101,7 @@
|
|||
"sass-loader": "^10.0.3",
|
||||
"semver": "^5.5.0",
|
||||
"spectron": "^11.1.0",
|
||||
"split2": "^3.2.2",
|
||||
"stop-build": "^1.1.0",
|
||||
"style-loader": "^0.21.0",
|
||||
"to-camel-case": "^1.0.0",
|
||||
|
@ -154,6 +155,7 @@
|
|||
"@types/request": "^2.0.9",
|
||||
"@types/semver": "^5.5.0",
|
||||
"@types/source-map-support": "^0.5.2",
|
||||
"@types/split2": "^2.1.6",
|
||||
"@types/strip-ansi": "^3.0.0",
|
||||
"@types/temp": "^0.8.29",
|
||||
"@types/textarea-caret": "^3.0.0",
|
||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -681,6 +681,13 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/source-map/-/source-map-0.5.1.tgz#7e74db5d06ab373a712356eebfaea2fad0ea2367"
|
||||
integrity sha512-/GVAjL1Y8puvZab63n8tsuBiYwZt1bApMdx58/msQ9ID5T05ov+wm/ZV1DvYC/DKKEygpTJViqQvkh5Rhrl4CA==
|
||||
|
||||
"@types/split2@^2.1.6":
|
||||
version "2.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/split2/-/split2-2.1.6.tgz#b095c9e064853824b22c67993d99b066777402b1"
|
||||
integrity sha512-ddaFSOMuy2Rp97l6q/LEteQygvTQJuEZ+SRhxFKR0uXGsdbFDqX/QF2xoGcOqLQ8XV91v01SnAv2vpgihNgW/Q==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/stack-utils@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
|
||||
|
@ -8746,7 +8753,7 @@ readable-stream@1.0:
|
|||
isarray "0.0.1"
|
||||
string_decoder "~0.10.x"
|
||||
|
||||
readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0:
|
||||
readable-stream@^3.0.0, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
|
||||
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
|
||||
|
@ -9670,6 +9677,13 @@ split-string@^3.0.1, split-string@^3.0.2:
|
|||
dependencies:
|
||||
extend-shallow "^2.0.1"
|
||||
|
||||
split2@^3.2.2:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/split2/-/split2-3.2.2.tgz#bf2cf2a37d838312c249c89206fd7a17dd12365f"
|
||||
integrity sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==
|
||||
dependencies:
|
||||
readable-stream "^3.0.0"
|
||||
|
||||
split@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9"
|
||||
|
|
Loading…
Reference in a new issue