Merge branch 'development' into diff-line-checkboxes

This commit is contained in:
tidy-dev 2024-01-30 13:21:03 -05:00
commit 51cd66f504
59 changed files with 1265 additions and 682 deletions

View file

@ -0,0 +1,41 @@
name: Close Single-Word Issues
on:
issues:
types:
- opened
jobs:
close-issue:
runs-on: ubuntu-latest
steps:
- name: Close Single-Word Issue
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const issueTitle = context.payload.issue.title.trim();
const isSingleWord = /^\S+$/.test(issueTitle);
if (isSingleWord) {
const issueNumber = context.payload.issue.number;
const repo = context.repo.repo;
// Close the issue and add the invalid label
github.rest.issues.update({
owner: context.repo.owner,
repo: repo,
issue_number: issueNumber,
labels: ['invalid'],
state: 'closed'
});
// Comment on the issue
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: repo,
issue_number: issueNumber,
body: `This issue may have been opened accidentally. I'm going to close it now, but feel free to open a new issue with a more descriptive title.`
});
}

View file

@ -1,6 +1,6 @@
# [GitHub Desktop](https://desktop.github.com)
[GitHub Desktop](https://desktop.github.com/) is an open source [Electron](https://www.electronjs.org/)-based
[GitHub Desktop](https://desktop.github.com/) is an open-source [Electron](https://www.electronjs.org/)-based
GitHub app. It is written in [TypeScript](https://www.typescriptlang.org) and
uses [React](https://reactjs.org/).
@ -90,7 +90,7 @@ To setup your development environment for building Desktop, check out: [`setup.m
See [desktop.github.com](https://desktop.github.com) for more product-oriented
information about GitHub Desktop.
See our [getting started documentation](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/overview/getting-started-with-github-desktop) for more information on how to set up, authenticate, and configure GitHub Desktop.
See our [getting started documentation](https://docs.github.com/en/desktop/overview/getting-started-with-github-desktop) for more information on how to set up, authenticate, and configure GitHub Desktop.
## License

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "3.3.7-beta2",
"version": "3.3.9-beta1",
"main": "./main.js",
"repository": {
"type": "git",

View file

@ -104,6 +104,7 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
mappings: {
'.markdown': 'text/x-markdown',
'.md': 'text/x-markdown',
'.mdx': 'text/x-markdown',
},
},
{
@ -118,6 +119,7 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
mappings: {
'.xml': 'text/xml',
'.xaml': 'text/xml',
'.xsd': 'text/xml',
'.csproj': 'text/xml',
'.fsproj': 'text/xml',
'.vcxproj': 'text/xml',
@ -149,6 +151,9 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
'.cpp': 'text/x-c++src',
'.hpp': 'text/x-c++src',
'.cc': 'text/x-c++src',
'.hh': 'text/x-c++src',
'.hxx': 'text/x-c++src',
'.cxx': 'text/x-c++src',
'.ino': 'text/x-c++src',
'.kt': 'text/x-kotlin',
},
@ -425,6 +430,12 @@ const extensionModes: ReadonlyArray<IModeDefinition> = [
'.dart': 'application/dart',
},
},
{
install: () => import('codemirror/mode/cmake/cmake'),
mappings: {
'.cmake': 'text/x-cmake',
},
},
]
/**

View file

@ -1546,6 +1546,23 @@ export class API {
})
}
public async getAvatarToken() {
return this.request('GET', `/desktop/avatar-token`)
.then(x => x.json())
.then((x: unknown) =>
x &&
typeof x === 'object' &&
'avatar_token' in x &&
typeof x.avatar_token === 'string'
? x.avatar_token
: null
)
.catch(err => {
log.debug(`Failed to load avatar token`, err)
return null
})
}
/**
* Gets a single check suite using its id
*/

View file

@ -311,7 +311,6 @@ export class DiffParser {
let diffLineNumber = linesConsumed
while ((c = this.parseLinePrefix(this.peek()))) {
const line = this.readLine()
diffLineNumber++
if (!line) {
throw new Error('Expected unified diff line but reached end of diff')
@ -338,6 +337,12 @@ export class DiffParser {
continue
}
// We must increase `diffLineNumber` only when we're certain that the line
// is not a "no newline" marker. Otherwise, we'll end up with a wrong
// `diffLineNumber` for the next line. This could happen if the last line
// in the file doesn't have a newline before the change.
diffLineNumber++
let diffLine: DiffLine
if (c === DiffPrefixAdd) {

View file

@ -1,7 +1,6 @@
import * as URL from 'url'
import { IAPIEmail, getDotComAPIEndpoint } from './api'
import { IAPIEmail } from './api'
import { Account } from '../models/account'
import { isGHES } from './endpoint-capabilities'
/**
* Lookup a suitable email address to display in the application, based on the
@ -53,11 +52,10 @@ function isEmailPublic(email: IAPIEmail): boolean {
* email host is hardcoded to the subdomain users.noreply under the
* endpoint host.
*/
function getStealthEmailHostForEndpoint(endpoint: string) {
return getDotComAPIEndpoint() !== endpoint
? `users.noreply.${URL.parse(endpoint).hostname}`
const getStealthEmailHostForEndpoint = (endpoint: string) =>
isGHES(endpoint)
? `users.noreply.${new URL(endpoint).hostname}`
: 'users.noreply.github.com'
}
/**
* Generate a legacy stealth email address for the user

View file

@ -3,19 +3,22 @@ import { getDotComAPIEndpoint } from './api'
import { assertNonNullable } from './fatal-error'
export type VersionConstraint = {
/** Whether this constrain will be satisfied when using GitHub.com */
dotcom: boolean
/**
* Whether this constrain will be satisfied when using GitHub AE
* Supports specifying a version constraint as a SemVer Range (ex: >= 3.1.0)
* Whether this constrain will be satisfied when using GitHub.com, defaults
* to false
**/
dotcom?: boolean
/**
* Whether this constrain will be satisfied when using ghe.com, defaults to
* the value of `dotcom` if not specified
*/
ae: boolean | string
ghe?: boolean
/**
* Whether this constrain will be satisfied when using GitHub Enterprise
* Server. Supports specifying a version constraint as a SemVer Range (ex: >=
* 3.1.0)
* 3.1.0), defaults to false
*/
es: boolean | string
es?: boolean | string
}
/**
@ -29,16 +32,6 @@ export type VersionConstraint = {
*/
const assumedGHESVersion = new semver.SemVer('3.1.0')
/**
* If we're connected to a GHAE instance we won't know its version number
* since it doesn't report that so we'll use this substitute GHES equivalent
* version number.
*
* This should correspond loosely with the most recent GHES series and
* needs to be updated manually.
*/
const assumedGHAEVersion = new semver.SemVer('3.2.0')
/** Stores raw x-github-enterprise-version headers keyed on endpoint */
const rawVersionCache = new Map<string, string>()
@ -58,18 +51,14 @@ const endpointVersionKey = (ep: string) => `endpoint-version:${ep}`
*/
export const isDotCom = (ep: string) => ep === getDotComAPIEndpoint()
/**
* Whether or not the given endpoint URI appears to point to a GitHub AE
* instance
*/
export const isGHAE = (ep: string) =>
/^https:\/\/[a-z0-9-]+\.ghe\.com$/i.test(ep)
/** Whether or not the given endpoint URI is under the ghe.com domain */
export const isGHE = (ep: string) => new URL(ep).hostname.endsWith('.ghe.com')
/**
* Whether or not the given endpoint URI appears to point to a GitHub Enterprise
* Server instance
*/
export const isGHES = (ep: string) => !isDotCom(ep) && !isGHAE(ep)
export const isGHES = (ep: string) => !isDotCom(ep) && !isGHE(ep)
function getEndpointVersion(endpoint: string) {
const key = endpointVersionKey(endpoint)
@ -104,12 +93,12 @@ export function updateEndpointVersion(endpoint: string, version: string) {
}
function checkConstraint(
epConstraint: string | boolean,
epConstraint: string | boolean | undefined,
epMatchesType: boolean,
epVersion?: semver.SemVer
) {
// Denial of endpoint type regardless of version
if (epConstraint === false) {
if (epConstraint === undefined || epConstraint === false) {
return false
}
@ -131,32 +120,25 @@ function checkConstraint(
* Consumers should use the various `supports*` methods instead.
*/
export const endpointSatisfies =
({ dotcom, ae, es }: VersionConstraint, getVersion = getEndpointVersion) =>
({ dotcom, ghe, es }: VersionConstraint, getVersion = getEndpointVersion) =>
(ep: string) =>
checkConstraint(dotcom, isDotCom(ep)) ||
checkConstraint(ae, isGHAE(ep), assumedGHAEVersion) ||
checkConstraint(ghe ?? dotcom, isGHE(ep)) ||
checkConstraint(es, isGHES(ep), getVersion(ep) ?? assumedGHESVersion)
/**
* Whether or not the endpoint supports the internal GitHub Enterprise Server
* avatars API
*/
export const supportsAvatarsAPI = endpointSatisfies({
dotcom: false,
ae: '>= 3.0.0',
es: '>= 3.0.0',
})
export const supportsAvatarsAPI = endpointSatisfies({ es: '>= 3.0.0' })
export const supportsRerunningChecks = endpointSatisfies({
dotcom: true,
ae: '>= 3.4.0',
es: '>= 3.4.0',
})
export const supportsRerunningIndividualOrFailedChecks = endpointSatisfies({
dotcom: true,
ae: false,
es: false,
})
/**
@ -165,18 +147,8 @@ export const supportsRerunningIndividualOrFailedChecks = endpointSatisfies({
*/
export const supportsRetrieveActionWorkflowByCheckSuiteId = endpointSatisfies({
dotcom: true,
ae: false,
es: false,
})
export const supportsAliveSessions = endpointSatisfies({
dotcom: true,
ae: false,
es: false,
})
export const supportsAliveSessions = endpointSatisfies({ dotcom: true })
export const supportsRepoRules = endpointSatisfies({
dotcom: true,
ae: false,
es: false,
})
export const supportsRepoRules = endpointSatisfies({ dotcom: true })

View file

@ -12,7 +12,6 @@ import { Repository } from '../../models/repository'
import { Commit } from '../../models/commit'
import { CommitIdentity } from '../../models/commit-identity'
import { parseRawUnfoldedTrailers } from './interpret-trailers'
import { getCaptures } from '../helpers/regex'
import { createLogParser } from './git-delimiter-parser'
import { revRange } from '.'
import { forceUnwrap } from '../fatal-error'
@ -155,9 +154,12 @@ export async function getCommits(
const parsed = parse(result.stdout)
return parsed.map(commit => {
const tags = getCaptures(commit.refs, /tag: ([^\s,]+)/g)
.filter(i => i[0] !== undefined)
.map(i => i[0])
// Ref is of the format: (HEAD -> master, tag: some-tag-name, tag: some-other-tag,with-a-comma, origin/master, origin/HEAD)
// Refs are comma separated, but some like tags can also contain commas in the name, so we split on the pattern ", " and then
// check each ref for the tag prefix. We used to use the regex /tag: ([^\s,]+)/g)`, but will clip a tag with a comma short.
const tags = commit.refs
.split(', ')
.flatMap(ref => (ref.startsWith('tag: ') ? ref.substring(5) : []))
return new Commit(
commit.sha,

View file

@ -1,14 +0,0 @@
import * as crypto from 'crypto'
/**
* Convert an email address to a Gravatar URL format
*
* @param email The email address associated with a user
* @param size The size (in pixels) of the avatar to render
*/
export function generateGravatarUrl(email: string, size: number = 60): string {
const input = email.trim().toLowerCase()
const hash = crypto.createHash('md5').update(input).digest('hex')
return `https://www.gravatar.com/avatar/${hash}?s=${size}`
}

View file

@ -4829,7 +4829,49 @@ export class AppStore extends TypedBaseStore<IAppState> {
return this._refreshRepository(repository)
}
public _setRepositoryCommitToAmend(
public async _startAmendingRepository(
repository: Repository,
commit: Commit,
isLocalCommit: boolean,
continueWithForcePush: boolean = false
) {
const repositoryState = this.repositoryStateCache.get(repository)
const { tip } = repositoryState.branchesState
const { askForConfirmationOnForcePush } = this.getState()
if (
askForConfirmationOnForcePush &&
!continueWithForcePush &&
!isLocalCommit &&
tip.kind === TipState.Valid
) {
return this._showPopup({
type: PopupType.WarnForcePush,
operation: 'Amend',
onBegin: () => {
this._startAmendingRepository(repository, commit, isLocalCommit, true)
},
})
}
await this._changeRepositorySection(
repository,
RepositorySectionTab.Changes
)
const gitStore = this.gitStoreCache.get(repository)
await gitStore.prepareToAmendCommit(commit)
this.setRepositoryCommitToAmend(repository, commit)
this.statsStore.increment('amendCommitStartedCount')
}
public async _stopAmendingRepository(repository: Repository) {
this.setRepositoryCommitToAmend(repository, null)
}
private setRepositoryCommitToAmend(
repository: Repository,
commit: Commit | null
) {

View file

@ -704,6 +704,32 @@ export class GitStore extends BaseStore {
return
}
const coAuthorsRestored = await this.restoreCoAuthorsFromCommit(commit)
if (coAuthorsRestored) {
return
}
this._commitMessage = {
summary: commit.summary,
description: commit.body,
}
this.emitUpdate()
}
public async prepareToAmendCommit(commit: Commit) {
const coAuthorsRestored = await this.restoreCoAuthorsFromCommit(commit)
if (coAuthorsRestored) {
return
}
this._commitMessage = {
summary: commit.summary,
description: commit.body,
}
this.emitUpdate()
}
private async restoreCoAuthorsFromCommit(commit: Commit) {
// Let's be safe about this since it's untried waters.
// If we can restore co-authors then that's fantastic
// but if we can't we shouldn't be throwing an error,
@ -713,17 +739,14 @@ export class GitStore extends BaseStore {
try {
await this.loadCommitAndCoAuthors(commit)
this.emitUpdate()
return
return true
} catch (e) {
log.error('Failed to restore commit and co-authors, falling back', e)
}
}
this._commitMessage = {
summary: commit.summary,
description: commit.body,
}
this.emitUpdate()
return false
}
/**

View file

@ -8,19 +8,26 @@ export function installAliveOriginFilter(orderedWebRequest: OrderedWebRequest) {
orderedWebRequest.onBeforeSendHeaders.addEventListener(async details => {
const { protocol, host } = new URL(details.url)
// If it's a WebSocket Secure request directed to a github.com subdomain,
// probably related to the Alive server, we need to override the `Origin`
// header with a valid value.
if (protocol === 'wss:' && /(^|\.)github\.com$/.test(host)) {
return {
requestHeaders: {
...details.requestHeaders,
// TODO: discuss with Alive team a good Origin value to use here
Origin: 'https://desktop.github.com',
},
}
// Here we're only interested in WebSockets
if (protocol !== 'wss:') {
return {}
}
return {}
// Alive URLs are supposed to be prefixed by "alive" and then the hostname
if (
!/^alive\.github\.com$/.test(host) &&
!/^alive\.(.*)\.ghe\.com$/.test(host)
) {
return {}
}
// We will just replace the `alive` prefix (which indicates the service)
// with `desktop`.
return {
requestHeaders: {
...details.requestHeaders,
Origin: `https://${host.replace('alive.', 'desktop.')}`,
},
}
})
}

View file

@ -1868,7 +1868,7 @@ export class App extends React.Component<IAppProps, IAppState> {
key="editor-error"
message={popup.message}
onDismissed={onPopupDismissedFn}
showPreferencesDialog={this.onShowAdvancedPreferences}
showPreferencesDialog={this.onShowIntegrationsPreferences}
viewPreferences={openPreferences}
suggestDefaultEditor={suggestDefaultEditor}
/>
@ -1879,7 +1879,7 @@ export class App extends React.Component<IAppProps, IAppState> {
key="shell-error"
message={popup.message}
onDismissed={onPopupDismissedFn}
showPreferencesDialog={this.onShowAdvancedPreferences}
showPreferencesDialog={this.onShowIntegrationsPreferences}
/>
)
case PopupType.InitializeLFS:
@ -2224,6 +2224,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onDismissed={onPopupDismissedFn}
onSubmitCommitMessage={popup.onSubmitCommitMessage}
repositoryAccount={repositoryAccount}
accounts={this.state.accounts}
/>
)
case PopupType.MultiCommitOperation: {
@ -2404,6 +2405,7 @@ export class App extends React.Component<IAppProps, IAppState> {
emoji={this.state.emoji}
onSubmit={onPopupDismissedFn}
onDismissed={onPopupDismissedFn}
accounts={this.state.accounts}
/>
)
}
@ -2429,6 +2431,7 @@ export class App extends React.Component<IAppProps, IAppState> {
selectedTab={popup.selectedTab}
emoji={emoji}
onDismissed={onPopupDismissedFn}
accounts={this.state.accounts}
/>
)
}
@ -2529,6 +2532,7 @@ export class App extends React.Component<IAppProps, IAppState> {
emoji={this.state.emoji}
onSubmit={onPopupDismissedFn}
onDismissed={onPopupDismissedFn}
accounts={this.state.accounts}
/>
)
}
@ -2618,10 +2622,10 @@ export class App extends React.Component<IAppProps, IAppState> {
this.props.dispatcher.refreshApiRepositories(account)
}
private onShowAdvancedPreferences = () => {
private onShowIntegrationsPreferences = () => {
this.props.dispatcher.showPopup({
type: PopupType.Preferences,
initialSelectedTab: PreferencesTab.Advanced,
initialSelectedTab: PreferencesTab.Integrations,
})
}
@ -2700,6 +2704,7 @@ export class App extends React.Component<IAppProps, IAppState> {
commit={commit}
selectedCommits={selectedCommits}
emoji={emoji}
accounts={this.state.accounts}
/>
)
default:

View file

@ -221,6 +221,8 @@ interface IChangesListProps {
readonly commitSpellcheckEnabled: boolean
readonly showCommitLengthWarning: boolean
readonly accounts: ReadonlyArray<Account>
}
interface IChangesState {
@ -560,13 +562,35 @@ export class ChangesList extends React.Component<
{ type: 'separator' },
]
if (paths.length === 1) {
const enabled = Path.basename(path) !== GitIgnoreFileName
items.push({
label: __DARWIN__
? 'Ignore File (Add to .gitignore)'
: 'Ignore file (add to .gitignore)',
action: () => this.props.onIgnoreFile(path),
enabled: Path.basename(path) !== GitIgnoreFileName,
enabled,
})
const pathComponents = path.split(Path.sep).slice(0, -1)
if (pathComponents.length > 0) {
const submenu = pathComponents.map((_, index) => {
const label = `/${pathComponents
.slice(0, pathComponents.length - index)
.join('/')}`
return {
label,
action: () => this.props.onIgnoreFile(label),
}
})
items.push({
label: __DARWIN__
? 'Ignore Folder (Add to .gitignore)'
: 'Ignore folder (add to .gitignore)',
submenu,
enabled,
})
}
} else if (paths.length > 1) {
items.push({
label: __DARWIN__
@ -834,6 +858,7 @@ export class ChangesList extends React.Component<
onCommitSpellcheckEnabledChanged={this.onCommitSpellcheckEnabledChanged}
onStopAmending={this.onStopAmending}
onShowCreateForkDialog={this.onShowCreateForkDialog}
accounts={this.props.accounts}
/>
)
}

View file

@ -18,6 +18,7 @@ import { Repository } from '../../models/repository'
import classNames from 'classnames'
import { RepoRulesMetadataFailures } from '../../models/repo-rules'
import { RepoRulesMetadataFailureList } from '../repository-rules/repo-rules-failure-list'
import { Account } from '../../models/account'
export type CommitMessageAvatarWarningType =
| 'none'
@ -89,6 +90,8 @@ interface ICommitMessageAvatarProps {
* dialog
*/
readonly onOpenGitSettings: () => void
readonly accounts: ReadonlyArray<Account>
}
/**
@ -169,7 +172,7 @@ export class CommitMessageAvatar extends React.Component<
onClick={this.onAvatarClick}
>
{warningType !== 'none' && this.renderWarningBadge()}
<Avatar user={user} title={null} />
<Avatar accounts={this.props.accounts} user={user} title={null} />
</Button>
{this.state.isPopoverOpen && this.renderPopover()}
</div>

View file

@ -157,6 +157,8 @@ interface ICommitMessageProps {
readonly onCommitSpellcheckEnabledChanged: (enabled: boolean) => void
readonly onStopAmending: () => void
readonly onShowCreateForkDialog: () => void
readonly accounts: ReadonlyArray<Account>
}
interface ICommitMessageState {
@ -267,37 +269,21 @@ export class CommitMessage extends React.Component<
public componentWillReceiveProps(nextProps: ICommitMessageProps) {
const { commitMessage } = nextProps
// If we switch from not amending to amending, we want to populate the
// textfields with the commit message from the commit.
if (this.props.commitToAmend === null && nextProps.commitToAmend !== null) {
this.fillWithCommitMessage({
summary: nextProps.commitToAmend.summary,
description: nextProps.commitToAmend.body,
})
} else if (
this.props.commitToAmend !== null &&
nextProps.commitToAmend === null &&
commitMessage !== null
) {
this.fillWithCommitMessage(commitMessage)
}
if (!commitMessage || commitMessage === this.props.commitMessage) {
return
}
if (this.state.summary === '' && !this.state.description) {
this.fillWithCommitMessage(commitMessage)
if (
(this.state.summary === '' && !this.state.description) ||
(this.props.commitToAmend === null && nextProps.commitToAmend)
) {
this.setState({
summary: commitMessage.summary,
description: commitMessage.description,
})
}
}
private fillWithCommitMessage(commitMessage: ICommitMessage) {
this.setState({
summary: commitMessage.summary,
description: commitMessage.description,
})
}
public async componentDidUpdate(
prevProps: ICommitMessageProps,
prevState: ICommitMessageState
@ -326,7 +312,8 @@ export class CommitMessage extends React.Component<
this.isCoAuthorInputVisible &&
// The co-author input could be also shown when switching between repos,
// but in that case we don't want to give the focus to the input.
prevProps.repository.id === this.props.repository.id
prevProps.repository.id === this.props.repository.id &&
!!prevProps.commitToAmend === !!this.props.commitToAmend
) {
this.coAuthorInputRef.current?.focus()
}
@ -708,6 +695,7 @@ export class CommitMessage extends React.Component<
onOpenRepositorySettings={this.onOpenRepositorySettings}
onOpenGitSettings={this.onOpenGitSettings}
repository={repository}
accounts={this.props.accounts}
/>
)
}

View file

@ -443,6 +443,7 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
showCommitLengthWarning={this.props.showCommitLengthWarning}
currentRepoRulesInfo={currentRepoRulesInfo}
aheadBehind={this.props.aheadBehind}
accounts={this.props.accounts}
/>
{this.renderUndoCommit(rebaseConflictState)}
</div>

View file

@ -94,6 +94,7 @@ interface ICommitMessageDialogProps {
readonly onSubmitCommitMessage: (context: ICommitContext) => Promise<boolean>
readonly repositoryAccount: Account | null
readonly accounts: ReadonlyArray<Account>
}
interface ICommitMessageDialogState {
@ -161,6 +162,7 @@ export class CommitMessageDialog extends React.Component<
repositoryAccount={this.props.repositoryAccount}
onStopAmending={this.onStopAmending}
onShowCreateForkDialog={this.onShowCreateForkDialog}
accounts={this.props.accounts}
/>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,90 @@
import React from 'react'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { LinkButton } from '../lib/link-button'
import { ITextDiff, LineEndingsChange } from '../../models/diff'
enum DiffContentsWarningType {
UnicodeBidiCharacters,
LineEndingsChange,
}
type DiffContentsWarningItem =
| {
readonly type: DiffContentsWarningType.UnicodeBidiCharacters
}
| {
readonly type: DiffContentsWarningType.LineEndingsChange
readonly lineEndingsChange: LineEndingsChange
}
interface IDiffContentsWarningProps {
readonly diff: ITextDiff
}
export class DiffContentsWarning extends React.Component<IDiffContentsWarningProps> {
public render() {
const items = this.getTextDiffWarningItems()
if (items.length === 0) {
return null
}
return (
<div className="diff-contents-warning-container">
{items.map((item, i) => (
<div className="diff-contents-warning" key={i}>
<Octicon symbol={OcticonSymbol.alert} />
{this.getWarningMessageForItem(item)}
</div>
))}
</div>
)
}
private getTextDiffWarningItems(): ReadonlyArray<DiffContentsWarningItem> {
const items = new Array<DiffContentsWarningItem>()
const { diff } = this.props
if (diff.hasHiddenBidiChars) {
items.push({
type: DiffContentsWarningType.UnicodeBidiCharacters,
})
}
if (diff.lineEndingsChange) {
items.push({
type: DiffContentsWarningType.LineEndingsChange,
lineEndingsChange: diff.lineEndingsChange,
})
}
return items
}
private getWarningMessageForItem(item: DiffContentsWarningItem) {
switch (item.type) {
case DiffContentsWarningType.UnicodeBidiCharacters:
return (
<>
This diff contains bidirectional Unicode text that may be
interpreted or compiled differently than what appears below. To
review, open the file in an editor that reveals hidden Unicode
characters.{' '}
<LinkButton uri="https://github.co/hiddenchars">
Learn more about bidirectional Unicode characters
</LinkButton>
</>
)
case DiffContentsWarningType.LineEndingsChange:
const { lineEndingsChange } = item
return (
<>
This diff contains a change in line endings from '
{lineEndingsChange.from}' to '{lineEndingsChange.to}'.
</>
)
}
}
}

View file

@ -3,7 +3,6 @@ import { PathLabel } from '../lib/path-label'
import { AppFileStatus } from '../../models/status'
import { IDiff, DiffType } from '../../models/diff'
import { Octicon, iconForStatus } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { mapStatus } from '../../lib/status'
import { DiffOptions } from './diff-options'
@ -37,7 +36,6 @@ export class DiffHeader extends React.Component<IDiffHeaderProps, {}> {
return (
<div className="header">
<PathLabel path={this.props.path} status={this.props.status} />
{this.renderDecorator()}
{this.renderDiffOptions()}
@ -68,25 +66,4 @@ export class DiffHeader extends React.Component<IDiffHeaderProps, {}> {
/>
)
}
private renderDecorator() {
const diff = this.props.diff
if (diff === null) {
return null
}
if (diff.kind === DiffType.Text && diff.lineEndingsChange) {
const message = `Warning: line endings will be changed from '${diff.lineEndingsChange.from}' to '${diff.lineEndingsChange.to}'.`
return (
<Octicon
symbol={OcticonSymbol.alert}
className={'line-endings'}
title={message}
/>
)
} else {
return null
}
}
}

View file

@ -1,20 +0,0 @@
import React from 'react'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { LinkButton } from '../lib/link-button'
export class HiddenBidiCharsWarning extends React.Component {
public render() {
return (
<div className="hidden-bidi-chars-warning">
<Octicon symbol={OcticonSymbol.alert} />
This diff contains bidirectional Unicode text that may be interpreted or
compiled differently than what appears below. To review, open the file
in an editor that reveals hidden Unicode characters.{' '}
<LinkButton uri="https://github.co/hiddenchars">
Learn more about bidirectional Unicode characters
</LinkButton>
</div>
)
}
}

View file

@ -71,13 +71,6 @@ interface ISideBySideDiffRowProps {
select: boolean
) => void
/**
* Called when a line selection is updated. Called with the
* row and column of the hovered line.
* (only relevant when isDiffSelectable is true)
*/
readonly onUpdateSelection: (row: number, column: DiffColumn) => void
/**
* Called when the user hovers the hunk handle. Called with the start
* line of the hunk.
@ -224,10 +217,7 @@ export class SideBySideDiffRow extends React.Component<
const { lineNumber, isSelected } = row.data
if (!showSideBySideDiff) {
return (
<div
className="row added"
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className="row added">
<div className={afterClasses}>
{this.renderLineNumbers(
[undefined, lineNumber],
@ -243,7 +233,7 @@ export class SideBySideDiffRow extends React.Component<
}
return (
<div className="row added" onMouseEnter={this.onMouseEnterLineNumber}>
<div className="row added">
<div className={beforeClasses}>
{this.renderLineNumber(undefined, DiffColumn.Before)}
{this.renderContentFromString('')}
@ -262,10 +252,7 @@ export class SideBySideDiffRow extends React.Component<
const { lineNumber, isSelected } = row.data
if (!showSideBySideDiff) {
return (
<div
className="row deleted"
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className="row deleted">
<div className={beforeClasses}>
{this.renderLineNumbers(
[lineNumber, undefined],
@ -281,10 +268,7 @@ export class SideBySideDiffRow extends React.Component<
}
return (
<div
className="row deleted"
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className="row deleted">
<div className={beforeClasses}>
{this.renderLineNumber(lineNumber, DiffColumn.Before, isSelected)}
{this.renderContent(row.data, DiffRowPrefix.Deleted)}
@ -303,10 +287,7 @@ export class SideBySideDiffRow extends React.Component<
const { beforeData: before, afterData: after } = row
return (
<div className="row modified">
<div
className={beforeClasses}
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className={beforeClasses}>
{this.renderLineNumber(
before.lineNumber,
DiffColumn.Before,
@ -315,10 +296,7 @@ export class SideBySideDiffRow extends React.Component<
{this.renderContent(before, DiffRowPrefix.Deleted)}
{this.renderWhitespaceHintPopover(DiffColumn.Before)}
</div>
<div
className={afterClasses}
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className={afterClasses}>
{this.renderLineNumber(
after.lineNumber,
DiffColumn.After,
@ -712,19 +690,6 @@ export class SideBySideDiffRow extends React.Component<
this.props.onStartSelection(this.props.numRow, column, !data.isSelected)
}
private onMouseEnterLineNumber = (evt: React.MouseEvent) => {
if (this.props.hideWhitespaceInDiff) {
return
}
const data = this.getDiffData(evt.currentTarget)
const column = this.getDiffColumn(evt.currentTarget)
if (data !== null && column !== null) {
this.props.onUpdateSelection(this.props.numRow, column)
}
}
private onMouseEnterHunk = () => {
if ('hunkStartLine' in this.props.row) {
this.props.onMouseEnterHunk(this.props.row.hunkStartLine)

View file

@ -60,20 +60,20 @@ import {
expandWholeTextDiff,
} from './text-diff-expansion'
import { IMenuItem } from '../../lib/menu-item'
import { HiddenBidiCharsWarning } from './hidden-bidi-chars-warning'
import { DiffContentsWarning } from './diff-contents-warning'
import { findDOMNode } from 'react-dom'
import escapeRegExp from 'lodash/escapeRegExp'
import ReactDOM from 'react-dom'
const DefaultRowHeight = 20
export interface ISelectionPoint {
readonly column: DiffColumn
readonly row: number
}
export interface ISelection {
readonly from: ISelectionPoint
readonly to: ISelectionPoint
/// Initial diff line number in the selection
readonly from: number
/// Last diff line number in the selection
readonly to: number
readonly isSelected: boolean
}
@ -372,6 +372,7 @@ export class SideBySideDiff extends React.Component<
'selectionchange',
this.onDocumentSelectionChange
)
document.removeEventListener('mousemove', this.onUpdateSelection)
}
public componentDidUpdate(
@ -514,14 +515,20 @@ export class SideBySideDiff extends React.Component<
this.diffContainer = ref
}
public render() {
private getCurrentDiffRows() {
const { diff } = this.state
const rows = getDiffRows(
return getDiffRows(
diff,
this.props.showSideBySideDiff,
this.canExpandDiff()
)
}
public render() {
const { diff } = this.state
const rows = this.getCurrentDiffRows()
const containerClassName = classNames('side-by-side-diff-container', {
'unified-diff': !this.props.showSideBySideDiff,
[`selecting-${this.state.selectingTextInRow}`]:
@ -537,7 +544,7 @@ export class SideBySideDiff extends React.Component<
onMouseDown={this.onMouseDown}
onKeyDown={this.onKeyDown}
>
{diff.hasHiddenBidiChars && <HiddenBidiCharsWarning />}
<DiffContentsWarning diff={diff} />
{this.state.isSearching && (
<DiffSearchInput
onSearch={this.onSearch}
@ -655,7 +662,6 @@ export class SideBySideDiff extends React.Component<
showSideBySideDiff={this.props.showSideBySideDiff}
hideWhitespaceInDiff={this.props.hideWhitespaceInDiff}
onStartSelection={this.onStartSelection}
onUpdateSelection={this.onUpdateSelection}
onMouseEnterHunk={this.onMouseEnterHunk}
onMouseLeaveHunk={this.onMouseLeaveHunk}
onExpandHunk={this.onExpandHunk}
@ -774,7 +780,7 @@ export class SideBySideDiff extends React.Component<
data: this.getRowDataPopulated(
row.data,
numRow,
this.props.showSideBySideDiff ? DiffColumn.After : DiffColumn.Before,
DiffColumn.After,
this.state.afterTokens
),
}
@ -863,8 +869,6 @@ export class SideBySideDiff extends React.Component<
data.diffLineNumber !== null &&
isInSelection(
data.diffLineNumber,
row,
column,
this.getSelection(),
this.state.temporarySelection
),
@ -917,6 +921,10 @@ export class SideBySideDiff extends React.Component<
return null
}
return this.getDiffRowLineNumber(row, column)
}
private getDiffRowLineNumber(row: SimplifiedDiffRow, column: DiffColumn) {
if (row.type === DiffRowType.Added || row.type === DiffRowType.Deleted) {
return row.data.diffLineNumber
}
@ -986,21 +994,88 @@ export class SideBySideDiff extends React.Component<
column: DiffColumn,
isSelected: boolean
) => {
const point: ISelectionPoint = { row, column }
const temporarySelection = { from: point, to: point, isSelected }
const line = this.getDiffLineNumber(row, column)
if (line === null) {
return
}
const temporarySelection = { from: line, to: line, isSelected }
this.setState({ temporarySelection })
document.addEventListener('mouseup', this.onEndSelection, { once: true })
document.addEventListener('mousemove', this.onUpdateSelection)
}
private onUpdateSelection = (row: number, column: DiffColumn) => {
private onUpdateSelection = (ev: MouseEvent) => {
const { temporarySelection } = this.state
if (temporarySelection === undefined) {
const list = this.virtualListRef.current
if (!temporarySelection || !list) {
return
}
const to = { row, column }
this.setState({ temporarySelection: { ...temporarySelection, to } })
const listNode = ReactDOM.findDOMNode(list)
if (!(listNode instanceof Element)) {
return
}
const rect = listNode.getBoundingClientRect()
const offsetInList = ev.clientY - rect.top
const offsetInListScroll = offsetInList + listNode.scrollTop
const rows = this.getCurrentDiffRows()
const totalRows = rows.length
let rowOffset = 0
// I haven't found an easy way to calculate which row the mouse is over,
// especially since react-virtualized's `getOffsetForRow` is buggy (see
// https://github.com/bvaughn/react-virtualized/issues/1422).
// Instead, the approach here is to iterate over all rows and sum their
// heights to calculate the offset of each row. Once we find the row that
// contains the mouse, we scroll to it and update the temporary selection.
for (let index = 0; index < totalRows; index++) {
// Use row height cache in order to do the math faster
let height = listRowsHeightCache.getHeight(index, 0)
if (height === undefined) {
list.recomputeRowHeights(index)
height = listRowsHeightCache.getHeight(index, 0) ?? DefaultRowHeight
}
if (
offsetInListScroll >= rowOffset &&
offsetInListScroll < rowOffset + height
) {
const row = rows[index]
let column = DiffColumn.Before
if (this.props.showSideBySideDiff) {
column =
ev.clientX <= rect.left + rect.width / 2
? DiffColumn.Before
: DiffColumn.After
} else {
// `column` is irrelevant in unified diff because there aren't rows of
// type Modified (see `getModifiedRows`)
}
const diffLineNumber = this.getDiffRowLineNumber(row, column)
// Always scroll to the row that contains the mouse, to ease range-based
// selection with it
list.scrollToRow(index)
if (diffLineNumber !== null) {
this.setState({
temporarySelection: {
...temporarySelection,
to: diffLineNumber,
},
})
}
return
}
rowOffset += height
}
}
private onEndSelection = () => {
@ -1013,20 +1088,11 @@ export class SideBySideDiff extends React.Component<
const { from: tmpFrom, to: tmpTo, isSelected } = temporarySelection
const fromRow = Math.min(tmpFrom.row, tmpTo.row)
const toRow = Math.max(tmpFrom.row, tmpTo.row)
const fromLine = Math.min(tmpFrom, tmpTo)
const toLine = Math.max(tmpFrom, tmpTo)
for (let row = fromRow; row <= toRow; row++) {
const lineBefore = this.getDiffLineNumber(row, tmpFrom.column)
const lineAfter = this.getDiffLineNumber(row, tmpTo.column)
if (lineBefore !== null) {
selection = selection.withLineSelection(lineBefore, isSelected)
}
if (lineAfter !== null) {
selection = selection.withLineSelection(lineAfter, isSelected)
}
for (let line = fromLine; line <= toLine; line++) {
selection = selection.withLineSelection(line, isSelected)
}
this.props.onIncludeChanged?.(selection)
@ -1737,8 +1803,6 @@ function* enumerateColumnContents(
function isInSelection(
diffLineNumber: number,
row: number,
column: DiffColumn,
selection: DiffSelection | undefined,
temporarySelection: ISelection | undefined
) {
@ -1748,7 +1812,10 @@ function isInSelection(
return isInStoredSelection
}
const isInTemporary = isInTemporarySelection(row, column, temporarySelection)
const isInTemporary = isInTemporarySelection(
diffLineNumber,
temporarySelection
)
if (temporarySelection.isSelected) {
return isInStoredSelection || isInTemporary
@ -1757,9 +1824,8 @@ function isInSelection(
}
}
export function isInTemporarySelection(
row: number,
column: DiffColumn,
function isInTemporarySelection(
diffLineNumber: number,
selection: ISelection | undefined
): selection is ISelection {
if (selection === undefined) {
@ -1767,9 +1833,8 @@ export function isInTemporarySelection(
}
if (
row >= Math.min(selection.from.row, selection.to.row) &&
row <= Math.max(selection.to.row, selection.from.row) &&
(column === selection.from.column || column === selection.to.column)
diffLineNumber >= Math.min(selection.from, selection.to) &&
diffLineNumber <= Math.max(selection.to, selection.from)
) {
return true
}

View file

@ -56,7 +56,7 @@ import { createOcticonElement } from '../octicons/octicon'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { WhitespaceHintPopover } from './whitespace-hint-popover'
import { PopoverAnchorPosition } from '../lib/popover'
import { HiddenBidiCharsWarning } from './hidden-bidi-chars-warning'
import { DiffContentsWarning } from './diff-contents-warning'
// This is a custom version of the no-newline octicon that's exactly as
// tall as it needs to be (8px) which helps with aligning it on the line.
@ -1556,7 +1556,7 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
return (
<>
{diff.hasHiddenBidiChars && <HiddenBidiCharsWarning />}
<DiffContentsWarning diff={diff} />
<CodeMirrorHost
className="diff-code-mirror"
value={doc}

View file

@ -901,35 +901,17 @@ export class Dispatcher {
isLocalCommit: boolean,
continueWithForcePush: boolean = false
) {
const repositoryState = this.repositoryStateManager.get(repository)
const { tip } = repositoryState.branchesState
const { askForConfirmationOnForcePush } = this.appStore.getState()
if (
askForConfirmationOnForcePush &&
!continueWithForcePush &&
!isLocalCommit &&
tip.kind === TipState.Valid
) {
return this.showPopup({
type: PopupType.WarnForcePush,
operation: 'Amend',
onBegin: () => {
this.startAmendingRepository(repository, commit, isLocalCommit, true)
},
})
}
await this.changeRepositorySection(repository, RepositorySectionTab.Changes)
this.appStore._setRepositoryCommitToAmend(repository, commit)
this.statsStore.increment('amendCommitStartedCount')
this.appStore._startAmendingRepository(
repository,
commit,
isLocalCommit,
continueWithForcePush
)
}
/** Stop amending the most recent commit. */
public async stopAmendingRepository(repository: Repository) {
this.appStore._setRepositoryCommitToAmend(repository, null)
this.appStore._stopAmendingRepository(repository)
}
/** Undo the given commit. */

View file

@ -9,6 +9,7 @@ import { GitHubRepository } from '../../models/github-repository'
import { CommitListItem } from '../history/commit-list-item'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { Account } from '../../models/account'
interface ICommitDragElementProps {
readonly commit: Commit
@ -20,6 +21,7 @@ interface ICommitDragElementProps {
*/
readonly isKeyboardInsertion?: boolean
readonly emoji: Map<string, string>
readonly accounts: ReadonlyArray<Account>
}
interface ICommitDragElementState {
@ -187,6 +189,7 @@ export class CommitDragElement extends React.Component<
selectedCommits={selectedCommits}
emoji={emoji}
showUnpushedIndicator={false}
accounts={this.props.accounts}
/>
</div>
{this.renderDragToolTip()}

View file

@ -18,6 +18,7 @@ import {
} from '../../models/drag-drop'
import classNames from 'classnames'
import { TooltippedContent } from '../lib/tooltipped-content'
import { Account } from '../../models/account'
interface ICommitProps {
readonly gitHubRepository: GitHubRepository | null
@ -39,6 +40,7 @@ interface ICommitProps {
readonly showUnpushedIndicator: boolean
readonly unpushedIndicatorTitle?: string
readonly disableSquashing?: boolean
readonly accounts: ReadonlyArray<Account>
}
interface ICommitListItemState {
@ -149,7 +151,10 @@ export class CommitListItem extends React.PureComponent<
renderUrlsAsLinks={false}
/>
<div className="description">
<AvatarStack users={this.state.avatarUsers} />
<AvatarStack
users={this.state.avatarUsers}
accounts={this.props.accounts}
/>
<div className="byline">
<CommitAttribution
gitHubRepository={this.props.gitHubRepository}

View file

@ -26,6 +26,7 @@ import {
PopoverScreenBorderPadding,
} from '../lib/popover'
import { KeyboardShortcut } from '../keyboard-shortcut/keyboard-shortcut'
import { Account } from '../../models/account'
const RowHeight = 50
@ -174,6 +175,8 @@ interface ICommitListProps {
/** Shas that should be highlighted */
readonly shasToHighlight?: ReadonlyArray<string>
readonly accounts: ReadonlyArray<Account>
}
interface ICommitListState {
@ -299,6 +302,7 @@ export class CommitList extends React.Component<
onRenderCommitDragElement={this.onRenderCommitDragElement}
onRemoveDragElement={this.props.onRemoveCommitDragElement}
disableSquashing={this.props.disableSquashing}
accounts={this.props.accounts}
/>
)
}
@ -583,6 +587,7 @@ export class CommitList extends React.Component<
selectedCommits={commits}
isKeyboardInsertion={true}
emoji={emoji}
accounts={this.props.accounts}
/>
)
default:

View file

@ -20,6 +20,7 @@ import { LinkButton } from '../lib/link-button'
import { UnreachableCommitsTab } from './unreachable-commits-dialog'
import { TooltippedCommitSHA } from '../lib/tooltipped-commit-sha'
import memoizeOne from 'memoize-one'
import { Account } from '../../models/account'
interface ICommitSummaryProps {
readonly repository: Repository
@ -60,6 +61,8 @@ interface ICommitSummaryProps {
/** Called to show unreachable commits dialog */
readonly showUnreachableCommits: (tab: UnreachableCommitsTab) => void
readonly accounts: ReadonlyArray<Account>
}
interface ICommitSummaryState {
@ -400,7 +403,7 @@ export class CommitSummary extends React.Component<
}
private renderAuthors = () => {
const { selectedCommits, repository } = this.props
const { selectedCommits, repository, accounts } = this.props
const { avatarUsers } = this.state
if (selectedCommits.length > 1) {
return
@ -408,7 +411,7 @@ export class CommitSummary extends React.Component<
return (
<li className="commit-summary-meta-item without-truncation">
<AvatarStack users={avatarUsers} />
<AvatarStack users={avatarUsers} accounts={accounts} />
<CommitAttribution
gitHubRepository={repository.gitHubRepository}
commits={selectedCommits}

View file

@ -31,6 +31,7 @@ import { getUniqueCoauthorsAsAuthors } from '../../lib/unique-coauthors-as-autho
import { getSquashedCommitDescription } from '../../lib/squash/squashed-commit-description'
import { doMergeCommitsExistAfterCommit } from '../../lib/git'
import { KeyboardInsertionData } from '../lib/list'
import { Account } from '../../models/account'
interface ICompareSidebarProps {
readonly repository: Repository
@ -57,6 +58,7 @@ interface ICompareSidebarProps {
readonly aheadBehindStore: AheadBehindStore
readonly isMultiCommitOperationInProgress?: boolean
readonly shasToHighlight: ReadonlyArray<string>
readonly accounts: ReadonlyArray<Account>
}
interface ICompareSidebarState {
@ -281,6 +283,7 @@ export class CompareSidebar extends React.Component<
this.props.isMultiCommitOperationInProgress
}
keyboardReorderData={this.state.keyboardReorderData}
accounts={this.props.accounts}
/>
)
}

View file

@ -19,6 +19,7 @@ import memoizeOne from 'memoize-one'
import { Button } from '../lib/button'
import { Avatar } from '../lib/avatar'
import { CopyButton } from '../copy-button'
import { Account } from '../../models/account'
interface IExpandableCommitSummaryProps {
readonly repository: Repository
@ -45,6 +46,8 @@ interface IExpandableCommitSummaryProps {
/** Called to show unreachable commits dialog */
readonly showUnreachableCommits: (tab: UnreachableCommitsTab) => void
readonly accounts: ReadonlyArray<Account>
}
interface IExpandableCommitSummaryState {
@ -411,7 +414,7 @@ export class ExpandableCommitSummary extends React.Component<
return this.state.avatarUsers.map((user, i) => {
return (
<div className="author selectable" key={i}>
<Avatar user={user} title={null} />
<Avatar accounts={this.props.accounts} user={user} title={null} />
<div>{this.renderExpandedAuthor(user)}</div>
</div>
)
@ -419,12 +422,12 @@ export class ExpandableCommitSummary extends React.Component<
}
private renderAuthorStack = () => {
const { selectedCommits, repository } = this.props
const { selectedCommits, repository, accounts } = this.props
const { avatarUsers } = this.state
return (
<>
<AvatarStack users={avatarUsers} />
<AvatarStack users={avatarUsers} accounts={accounts} />
<CommitAttribution
gitHubRepository={repository.gitHubRepository}
commits={selectedCommits}

View file

@ -48,8 +48,10 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
)
}
private rowForFile(file: CommittedFileChange | null): number {
return file ? this.props.files.findIndex(f => f.path === file.path) : -1
private selectedRowsForFile(): ReadonlyArray<number> {
const { selectedFile: file, files } = this.props
const fileIndex = file ? files.findIndex(f => f.path === file.path) : -1
return fileIndex >= 0 ? [fileIndex] : []
}
private onRowContextMenu = (
@ -73,7 +75,7 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
rowRenderer={this.renderFile}
rowCount={this.props.files.length}
rowHeight={29}
selectedRows={[this.rowForFile(this.props.selectedFile)]}
selectedRows={this.selectedRowsForFile()}
onSelectedRowChanged={this.onSelectedRowChanged}
onRowDoubleClick={this.props.onRowDoubleClick}
onRowContextMenu={this.onRowContextMenu}

View file

@ -38,6 +38,7 @@ import { UnreachableCommitsTab } from './unreachable-commits-dialog'
import { enableCommitDetailsHeaderExpansion } from '../../lib/feature-flag'
import { ExpandableCommitSummary } from './expandable-commit-summary'
import { DiffHeader } from '../diff/diff-header'
import { Account } from '../../models/account'
interface ISelectedCommitsProps {
readonly repository: Repository
@ -89,6 +90,8 @@ interface ISelectedCommitsProps {
/** Whether or not the selection of commits is contiguous */
readonly isContiguous: boolean
readonly accounts: ReadonlyArray<Account>
}
interface ISelectedCommitsState {
@ -216,6 +219,7 @@ export class SelectedCommits extends React.Component<
onDescriptionBottomChanged={this.onDescriptionBottomChanged}
onHighlightShas={this.onHighlightShas}
showUnreachableCommits={this.showUnreachableCommits}
accounts={this.props.accounts}
/>
)
}
@ -237,6 +241,7 @@ export class SelectedCommits extends React.Component<
onDiffOptionsOpened={this.props.onDiffOptionsOpened}
onHighlightShas={this.onHighlightShas}
showUnreachableCommits={this.showUnreachableCommits}
accounts={this.props.accounts}
/>
)
}

View file

@ -5,6 +5,7 @@ import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { Commit } from '../../models/commit'
import { CommitList } from './commit-list'
import { LinkButton } from '../lib/link-button'
import { Account } from '../../models/account'
export enum UnreachableCommitsTab {
Unreachable,
@ -29,6 +30,8 @@ interface IUnreachableCommitsDialogProps {
/** Called to dismiss the */
readonly onDismissed: () => void
readonly accounts: ReadonlyArray<Account>
}
interface IUnreachableCommitsDialogState {
@ -111,6 +114,7 @@ export class UnreachableCommitsDialog extends React.Component<
localCommitSHAs={[]}
emoji={emoji}
onCommitsSelected={this.onCommitsSelected}
accounts={this.props.accounts}
/>
</div>
</>

View file

@ -2,6 +2,7 @@ import * as React from 'react'
import classNames from 'classnames'
import { Avatar } from './avatar'
import { IAvatarUser } from '../../models/avatar'
import { Account } from '../../models/account'
/**
* The maximum number of avatars to stack before hiding
@ -12,6 +13,7 @@ const MaxDisplayedAvatars = 3
interface IAvatarStackProps {
readonly users: ReadonlyArray<IAvatarUser>
readonly accounts: ReadonlyArray<Account>
}
/**
@ -22,7 +24,7 @@ interface IAvatarStackProps {
export class AvatarStack extends React.Component<IAvatarStackProps, {}> {
public render() {
const elems = []
const users = this.props.users
const { users, accounts } = this.props
for (let i = 0; i < this.props.users.length; i++) {
if (
@ -32,7 +34,7 @@ export class AvatarStack extends React.Component<IAvatarStackProps, {}> {
elems.push(<div key="more" className="avatar-more avatar" />)
}
elems.push(<Avatar key={`${i}`} user={users[i]} />)
elems.push(<Avatar key={`${i}`} user={users[i]} accounts={accounts} />)
}
const className = classNames('AvatarStack', {

View file

@ -1,12 +1,42 @@
import * as React from 'react'
import { IAvatarUser } from '../../models/avatar'
import { shallowEquals } from '../../lib/equality'
import { generateGravatarUrl } from '../../lib/gravatar'
import { Octicon } from '../octicons'
import { getDotComAPIEndpoint } from '../../lib/api'
import { API, getDotComAPIEndpoint, getHTMLURL } from '../../lib/api'
import { TooltippedContent } from './tooltipped-content'
import { TooltipDirection } from './tooltip'
import { supportsAvatarsAPI } from '../../lib/endpoint-capabilities'
import {
isGHE,
isGHES,
supportsAvatarsAPI,
} from '../../lib/endpoint-capabilities'
import { Account } from '../../models/account'
import { offsetFrom } from '../../lib/offset-from'
import { ExpiringOperationCache } from './expiring-operation-cache'
import { forceUnwrap } from '../../lib/fatal-error'
const avatarTokenCache = new ExpiringOperationCache<
{ endpoint: string; accounts: ReadonlyArray<Account> },
string
>(
({ endpoint }) => endpoint,
async ({ endpoint, accounts }) => {
if (!isGHE(endpoint)) {
throw new Error('Avatar tokens are only available for ghe.com')
}
const account = accounts.find(a => a.endpoint === endpoint)
if (!account) {
throw new Error('No account found for endpoint')
}
const api = new API(endpoint, account.token)
const token = await api.getAvatarToken()
return forceUnwrap('Avatar token missing', token)
},
() => offsetFrom(0, 50, 'minutes')
)
/**
* This maps contains avatar URLs that have failed to load and
@ -60,12 +90,15 @@ interface IAvatarProps {
* attempt to request, defaults to 64px.
*/
readonly size?: number
readonly accounts: ReadonlyArray<Account>
}
interface IAvatarState {
readonly user?: IAvatarUser
readonly candidates: ReadonlyArray<string>
readonly imageLoaded: boolean
readonly imageError: boolean
readonly avatarToken?: string | Promise<void>
}
/**
@ -79,14 +112,26 @@ const DefaultAvatarSymbol = {
d: 'M13 13.145a.844.844 0 0 1-.832.855H3.834A.846.846 0 0 1 3 13.142v-.856c0-2.257 3.333-3.429 3.333-3.429s.191-.35 0-.857c-.7-.531-.786-1.363-.833-3.429C5.644 2.503 7.056 2 8 2s2.356.502 2.5 2.571C10.453 6.637 10.367 7.47 9.667 8c-.191.506 0 .857 0 .857S13 10.03 13 12.286v.859z',
}
/**
* A regular expression meant to match both the legacy format GitHub.com
* stealth email address and the modern format (login@ vs id+login@).
*
* Yields two capture groups, the first being an optional capture of the
* user id and the second being the mandatory login.
*/
const StealthEmailRegexp = /^(?:(\d+)\+)?(.+?)@users\.noreply\.(.*)$/i
function getEmailAvatarUrl(ep: string) {
if (isGHES(ep)) {
// GHES Endpoint urls look something like https://github.example.com/api/v3
// (note the lack of a trailing slash). We really should change our endpoint
// URLs to always have a trailing slash but that's one heck of an
// undertaking since we'd have to migrate all the existing endpoints in
// our IndexedDB databases so for now we'll just assume we'll add a trailing
// slash in the future and be future proof.
return new URL(`enterprise/avatars/u/e`, ep.endsWith('/') ? ep : `${ep}/`)
} else if (isGHE(ep)) {
// getHTMLURL(ep) is a noop here since it currently only deals with
// github.com and assumes all others confrom to the GHES url structure but
// we're likely going to need to update that in the future to support
// ghe.com specifically so we're calling it here for future proofing.
return new URL('/avatars/u/e', getHTMLURL(ep))
} else {
// It's safe to fall back to GitHub.com, at worst we'll get identicons
return new URL('https://avatars.githubusercontent.com/u/e')
}
}
/**
* Produces an ordered iterable of avatar urls to attempt to load for the
@ -94,6 +139,7 @@ const StealthEmailRegexp = /^(?:(\d+)\+)?(.+?)@users\.noreply\.(.*)$/i
*/
function getAvatarUrlCandidates(
user: IAvatarUser | undefined,
avatarToken: string | undefined,
size = 64
): ReadonlyArray<string> {
const candidates = new Array<string>()
@ -102,14 +148,14 @@ function getAvatarUrlCandidates(
return candidates
}
const { email, endpoint, avatarURL } = user
const isDotCom = endpoint === getDotComAPIEndpoint()
const { email, avatarURL } = user
const ep = user.endpoint ?? getDotComAPIEndpoint()
// By leveraging the avatar url from the API (if we've got it) we can
// load the avatar from one of the load balanced domains (avatars). We can't
// do the same for GHES/GHAE however since the URLs returned by the API are
// behind private mode.
if (isDotCom && avatarURL !== undefined) {
if (!isGHES(ep) && avatarURL !== undefined) {
// The avatar urls returned by the API doesn't come with a size parameter,
// they default to the biggest size we need on GitHub.com which is usually
// much bigger than what desktop needs so we'll set a size explicitly.
@ -123,133 +169,122 @@ function getAvatarUrlCandidates(
// URLs which we can expect the API to not give us
candidates.push(avatarURL)
}
} else if (endpoint !== null && !isDotCom && !supportsAvatarsAPI(endpoint)) {
}
if (isGHES(ep) && !supportsAvatarsAPI(ep)) {
// We're dealing with an old GitHub Enterprise instance so we're unable to
// get to the avatar by requesting the avatarURL due to the private mode
// (see https://github.com/desktop/desktop/issues/821). So we have no choice
// but to fall back to gravatar for now.
candidates.push(generateGravatarUrl(email, size))
return candidates
// (see https://github.com/desktop/desktop/issues/821).
return []
}
// Are we dealing with a GitHub.com stealth/anonymous email address in
// either legacy format:
// niik@users.noreply.github.com
//
// or the current format
// 634063+niik@users.noreply.github.com
//
// If so we unfortunately can't rely on the GitHub avatar endpoint to
// deliver a match based solely on that email address but luckily for us
// the avatar service supports looking up a user based either on user id
// of login, user id being the better option as it's not affected by
// account renames.
const stealthEmailMatch = StealthEmailRegexp.exec(email)
const avatarEndpoint =
endpoint === null || isDotCom
? 'https://avatars.githubusercontent.com'
: `${endpoint}/enterprise/avatars`
if (stealthEmailMatch) {
const [, userId, login, hostname] = stealthEmailMatch
if (
hostname === 'github.com' ||
(endpoint !== null && hostname === new URL(endpoint).hostname)
) {
if (userId !== undefined) {
const userIdParam = encodeURIComponent(userId)
candidates.push(`${avatarEndpoint}/u/${userIdParam}?s=${size}`)
} else {
const loginParam = encodeURIComponent(login)
candidates.push(`${avatarEndpoint}/${loginParam}?s=${size}`)
}
}
if (isGHE(ep) && !avatarToken) {
// ghe.com requires a token, nothing we can do here, we'll be called again
// once the token has been loaded
return []
}
// The /u/e endpoint above falls back to gravatar (proxied)
// so we don't have to add gravatar to the fallback.
const emailParam = encodeURIComponent(email)
candidates.push(`${avatarEndpoint}/u/e?email=${emailParam}&s=${size}`)
const emailAvatarUrl = getEmailAvatarUrl(ep)
emailAvatarUrl.searchParams.set('email', email)
emailAvatarUrl.searchParams.set('s', `${size}`)
if (isGHE(ep) && avatarToken) {
emailAvatarUrl.searchParams.set('token', avatarToken)
}
candidates.push(`${emailAvatarUrl}`)
return candidates
}
const getInitialStateForUser = (
user: IAvatarUser | undefined,
accounts: ReadonlyArray<Account>,
size: number | undefined
): Pick<IAvatarState, 'user' | 'candidates' | 'avatarToken'> => {
const endpoint = user?.endpoint
const avatarToken =
endpoint && isGHE(endpoint)
? avatarTokenCache.tryGet({ endpoint, accounts })
: undefined
const candidates = getAvatarUrlCandidates(user, avatarToken, size)
return { user, candidates, avatarToken }
}
/** A component for displaying a user avatar. */
export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
public static getDerivedStateFromProps(
props: IAvatarProps,
state: IAvatarState
): Partial<IAvatarState> | null {
const { user, size } = props
if (!shallowEquals(user, state.user)) {
const candidates = getAvatarUrlCandidates(user, size)
return { user, candidates, imageLoaded: false }
}
return null
) {
const { user, size, accounts } = props
// If the endpoint has changed we need to reset the avatar token so that
// it'll be re-fetched for the new endpoint
return shallowEquals(user, state.user)
? null
: getInitialStateForUser(user, accounts, size)
}
/** Set to true when unmounting to avoid unnecessary state updates */
private cancelAvatarTokenRequest = false
public constructor(props: IAvatarProps) {
super(props)
const { user, size } = props
const { user, size, accounts } = props
this.state = {
user,
candidates: getAvatarUrlCandidates(user, size),
imageLoaded: false,
...getInitialStateForUser(user, accounts, size),
imageError: false,
}
}
private getTitle(): string | JSX.Element | undefined {
if (this.props.title === null) {
private getTitle() {
const { title, user, accounts } = this.props
if (title === null) {
return undefined
}
if (this.props.title !== undefined) {
return this.props.title
if (title !== undefined) {
return title
}
const user = this.props.user
if (user) {
if (user.name) {
return (
<>
<Avatar title={null} user={user} />
if (user?.name) {
return (
<>
<Avatar title={null} user={user} accounts={accounts} />
<div>
<div>
<div>
<strong>{user.name}</strong>
</div>
<div>{user.email}</div>
<strong>{user.name}</strong>
</div>
</>
)
} else {
return user.email
}
<div>{user.email}</div>
</div>
</>
)
}
return 'Unknown user'
return user?.email ?? 'Unknown user'
}
private onImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
const { candidates } = this.state
if (candidates.length > 0) {
this.setState({
candidates: candidates.filter(x => x !== e.currentTarget.src),
imageLoaded: false,
})
}
const { src } = e.currentTarget
const candidates = this.state.candidates.filter(x => x !== src)
this.setState({ candidates, imageError: candidates.length === 0 })
}
private onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
this.setState({ imageLoaded: true })
if (this.state.imageError) {
this.setState({ imageError: false })
}
}
public render() {
const title = this.getTitle()
const { user } = this.props
const { imageLoaded } = this.state
const { imageError } = this.state
const alt = user
? `Avatar for ${user.name || user.email}`
: `Avatar for unknown user`
@ -268,7 +303,7 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
direction={TooltipDirection.NORTH}
tagName="div"
>
{!imageLoaded && (
{(!src || imageError) && (
<Octicon symbol={DefaultAvatarSymbol} className="avatar" />
)}
{src && (
@ -282,7 +317,7 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
alt={alt}
onLoad={this.onImageLoad}
onError={this.onImageError}
style={{ display: imageLoaded ? undefined : 'none' }}
style={{ display: imageError ? 'none' : undefined }}
/>
)}
</TooltippedContent>
@ -302,13 +337,68 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
})
}
private ensureAvatarToken() {
// fetch the avatar token for the endpoint if we don't have it
// when the async fetch completes, check if we're still mounted and if
// the endpoint still matches
// also need to keep track of whether we have an async fetch in flight or
// not so we don't trigger multiple fetches for the same endpoint
const { user, accounts } = this.props
const endpoint = user?.endpoint
// We've already got a token or we don't have a user, nothing to do here
if (this.state.avatarToken || !user || !accounts) {
return
}
if (!endpoint || !isGHE(endpoint)) {
return
}
// Can we get a token synchronously?
const token = avatarTokenCache.tryGet({ endpoint, accounts })
if (token) {
this.resetAvatarCandidates(token)
return
}
this.setState({
avatarToken: avatarTokenCache.get({ endpoint, accounts }).then(token => {
if (!this.cancelAvatarTokenRequest) {
if (token && this.props.user?.endpoint === endpoint) {
this.resetAvatarCandidates(token)
}
}
}),
})
}
public componentDidUpdate(prevProps: IAvatarProps, prevState: IAvatarState) {
this.ensureAvatarToken()
}
private resetAvatarCandidates(avatarToken?: string) {
const { user, size, accounts } = this.props
if (!avatarToken && user?.endpoint && isGHE(user.endpoint)) {
avatarToken =
avatarTokenCache.tryGet({ endpoint: user.endpoint, accounts }) ??
avatarToken
}
const candidates = getAvatarUrlCandidates(user, avatarToken, size)
this.setState({ candidates, avatarToken })
}
public componentDidMount() {
window.addEventListener('online', this.onInternetConnected)
pruneExpiredFailingAvatars()
this.ensureAvatarToken()
}
public componentWillUnmount() {
window.removeEventListener('online', this.onInternetConnected)
this.cancelAvatarTokenRequest = true
}
private onInternetConnected = () => {
@ -319,12 +409,7 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
// If we've been offline and therefore failed to load an avatar
// we'll automatically retry when the user becomes connected again.
if (this.state.candidates.length === 0) {
const { user, size } = this.props
const candidates = getAvatarUrlCandidates(user, size)
if (candidates.length > 0) {
this.setState({ candidates })
}
this.resetAvatarCandidates()
}
}
}

View file

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

View file

@ -20,7 +20,6 @@ import {
RevealInFileManagerLabel,
} from '../context-menu'
import { openFile } from '../open-file'
import { shell } from 'electron'
import { Button } from '../button'
import { IMenuItem } from '../../../lib/menu-item'
import {
@ -28,6 +27,7 @@ import {
getUnmergedStatusEntryDescription,
getLabelForManualResolutionOption,
} from '../../../lib/status'
import { revealInFileManager } from '../../../lib/app-shell'
const defaultConflictsResolvedMessage = 'No conflicts remaining'
@ -355,7 +355,7 @@ const makeMarkerConflictDropdownClickHandler = (
},
{
label: RevealInFileManagerLabel,
action: () => shell.showItemInFolder(absoluteFilePath),
action: () => revealInFileManager(repository, relativeFilePath),
},
{
type: 'separator',

View file

@ -0,0 +1,112 @@
const isPending = <T>(item: unknown | Promise<T>): item is Promise<T> =>
typeof item === 'object' && item !== null && 'then' in item
/**
* An asynchronous operation cache with a configurable expiration time.
*
* Supports synchronously checking whether the operation result is available.
*/
export class ExpiringOperationCache<TKey, T> {
private readonly data: Map<
string,
{ item: T; timeoutId?: number } | Promise<T>
> = new Map()
public constructor(
/** Function returning a unique string used as the underlying cache key */
private readonly keyFunc: (key: TKey) => string,
/**
* Function returning a promise resolving to the operation result for the
* given key
**/
private readonly valueFunc: (key: TKey) => Promise<T>,
/**
* Function returning number of milliseconds (or Infinity) to store the
* operation result before it expires. Defaults to Infinity.
*/
private readonly expirationFunc: (key: TKey, value: T) => number = () =>
Infinity
) {}
/**
* Store the given value in the cache.
*
* Useful for preloading the cache. Note that this overrides any existing or
* pending operation result for the given key.
*/
public set(key: TKey, value: T, expiresIn?: number) {
const timeout = expiresIn ?? this.expirationFunc(key, value)
if (timeout <= 0) {
return this.delete(key)
}
const cacheKey = this.keyFunc(key)
const item = {
item: value,
timeoutId: isFinite(timeout)
? window.setTimeout(() => {
if (this.data.get(cacheKey) === item) {
this.data.delete(cacheKey)
}
}, timeout)
: undefined,
}
this.delete(key)
this.data.set(cacheKey, item)
}
/**
* Manually expire the operation result for the given key.
*/
public delete(key: TKey) {
const cacheKey = this.keyFunc(key)
const cached = this.data.get(cacheKey)
if (cached && !isPending(cached)) {
if (cached.timeoutId !== undefined) {
window.clearTimeout(cached.timeoutId)
}
}
this.data.delete(cacheKey)
}
/**
* Attempt to synchronously return the cached operation result
*/
public tryGet(key: TKey): T | undefined {
const cached = this.data.get(this.keyFunc(key))
return cached && !isPending(cached) ? cached.item : undefined
}
/**
* Asynchronously return the cached operation result, or start the operation
*/
public async get(key: TKey): Promise<T> {
const cacheKey = this.keyFunc(key)
const cached = this.data.get(cacheKey)
if (cached) {
return isPending(cached) ? cached : cached.item
}
const promise = this.valueFunc(key)
.then(value => {
if (this.data.get(cacheKey) === promise) {
this.set(key, value)
}
return value
})
.catch(e => {
if (this.data.get(cacheKey) === promise) {
this.data.delete(cacheKey)
}
return Promise.reject(e)
})
this.data.set(this.keyFunc(key), promise)
return promise
}
}

View file

@ -1496,14 +1496,17 @@ export class SectionList extends React.Component<
this.lastScroll = 'fake'
// TODO: calculate scrollTop of the right grid(s)?
if (this.rootGrid) {
const element = ReactDOM.findDOMNode(this.rootGrid)
if (element instanceof Element) {
element.scrollTop = e.currentTarget.scrollTop
}
}
this.setState({ scrollTop: e.currentTarget.scrollTop })
// Make sure the root grid re-renders its children
this.rootGrid?.recomputeGridSize()
}
private onRowMouseDown = (

View file

@ -103,6 +103,7 @@ export class ToggledtippedContent extends React.Component<
className={classes}
aria-label={ariaLabel}
aria-haspopup="dialog"
type="button"
onClick={this.onToggle}
>
<>

View file

@ -2,7 +2,6 @@ import * as React from 'react'
import { Branch } from '../../../models/branch'
import { Repository } from '../../../models/repository'
import { IMatches } from '../../../lib/fuzzy-find'
import { truncateWithEllipsis } from '../../../lib/truncate-with-ellipsis'
import { Dialog, DialogContent, DialogFooter } from '../../dialog'
import {
BranchList,
@ -22,8 +21,33 @@ import {
import { assertNever } from '../../../lib/fatal-error'
import { getMergeOptions } from '../../lib/update-branch'
import { getDefaultAriaLabelForBranch } from '../../branches/branch-renderer'
import { ComputedAction } from '../../../models/computed-action'
interface IBaseChooseBranchDialogProps {
export function canStartOperation(
selectedBranch: Branch | null,
currentBranch: Branch,
commitCount: number | undefined,
statusKind: ComputedAction | undefined
): boolean {
// Is there even a branch selected?
if (selectedBranch === null) {
return false
}
// Is the selected branch the current branch?
if (selectedBranch.name === currentBranch?.name) {
return false
}
// Are there even commits to operate on?
if (commitCount === undefined || commitCount === 0) {
return false
}
return statusKind !== ComputedAction.Invalid
}
export interface IBaseChooseBranchDialogProps {
readonly dispatcher: Dispatcher
readonly repository: Repository
@ -38,6 +62,11 @@ interface IBaseChooseBranchDialogProps {
*/
readonly currentBranch: Branch
/**
* The branch to select when dialog it is opened
*/
readonly initialBranch?: Branch
/**
* See IBranchesState.allBranches
*/
@ -48,11 +77,6 @@ interface IBaseChooseBranchDialogProps {
*/
readonly recentBranches: ReadonlyArray<Branch>
/**
* The branch to select when the rebase dialog is opened
*/
readonly initialBranch?: Branch
/**
* Type of operation (Merge, Squash, Rebase)
*/
@ -65,57 +89,59 @@ interface IBaseChooseBranchDialogProps {
readonly onDismissed: () => void
}
export interface IBaseChooseBranchDialogState {
/** The currently selected branch. */
export interface IChooseBranchDialogProps extends IBaseChooseBranchDialogProps {
readonly selectedBranch: Branch | null
/** The filter text to use in the branch selector */
readonly filterText: string
/**
* A preview of the operation using the selected base branch to test whether the
* current branch will be cleanly applied.
*/
readonly statusPreview: JSX.Element | null
readonly dialogTitle: string | JSX.Element | undefined
readonly submitButtonTooltip?: string
readonly canStartOperation: boolean
readonly start: () => void
readonly onSelectionChanged: (selectedBranch: Branch | null) => void
}
export abstract class BaseChooseBranchDialog extends React.Component<
IBaseChooseBranchDialogProps,
IBaseChooseBranchDialogState
export interface IChooseBranchDialogState {
/** The filter text to use in the branch selector */
readonly filterText: string
}
export class ChooseBranchDialog extends React.Component<
IChooseBranchDialogProps,
IChooseBranchDialogState
> {
protected abstract start: () => void
protected abstract canStart: () => boolean
protected abstract updateStatus: (branch: Branch) => Promise<void>
protected abstract getDialogTitle: (
branchName: string
) => string | JSX.Element | undefined
protected abstract renderActionStatusIcon: () => JSX.Element | null
public constructor(props: IBaseChooseBranchDialogProps) {
public constructor(props: IChooseBranchDialogProps) {
super(props)
const selectedBranch = this.resolveSelectedBranch()
this.state = {
selectedBranch,
filterText: '',
statusPreview: null,
}
}
public componentDidMount() {
const { selectedBranch } = this.state
if (selectedBranch !== null) {
this.updateStatus(selectedBranch)
public componentDidMount(): void {
const initialSelectedBranch = this.resolveSelectedBranch()
if (
initialSelectedBranch !== null &&
initialSelectedBranch.ref !== this.props.selectedBranch?.ref
) {
this.props.onSelectionChanged(initialSelectedBranch)
}
}
protected getSubmitButtonToolTip = (): string | undefined => {
return undefined
/**
* Returns the branch to use as the selected branch in the dialog.
*
* The initial branch is used if defined, otherwise the default branch will be
* compared to the current branch.
*
* If the current branch is the default branch, `null` is returned. Otherwise
* the default branch is used.
*/
private resolveSelectedBranch(): Branch | null {
const { currentBranch, defaultBranch, initialBranch } = this.props
if (initialBranch !== undefined) {
return initialBranch
}
return currentBranch === defaultBranch ? null : defaultBranch
}
private onFilterTextChanged = (filterText: string) => {
@ -129,49 +155,19 @@ export abstract class BaseChooseBranchDialog extends React.Component<
source.event.preventDefault()
const { selectedBranch } = this.state
const { selectedBranch } = this.props
if (selectedBranch !== null && selectedBranch.name === branch.name) {
this.start()
this.props.start()
}
}
protected onSelectionChanged = async (selectedBranch: Branch | null) => {
if (selectedBranch != null) {
this.setState({ selectedBranch })
return this.updateStatus(selectedBranch)
}
// return to empty state
this.setState({ selectedBranch })
}
/**
* Returns the branch to use as the selected branch in the dialog.
*
* The initial branch is used if defined, otherwise the default branch will be
* compared to the current branch.
*
* If the current branch is the default branch, `null` is returned. Otherwise
* the default branch is used.
*/
protected resolveSelectedBranch(): Branch | null {
const { currentBranch, defaultBranch, initialBranch } = this.props
if (initialBranch !== undefined) {
return initialBranch
}
return currentBranch === defaultBranch ? null : defaultBranch
}
private onOperationChange = (option: IDropdownSelectButtonOption) => {
if (!isIdMultiCommitOperation(option.id)) {
return
}
const { dispatcher, repository } = this.props
const { selectedBranch } = this.state
const { selectedBranch } = this.props
switch (option.id) {
case MultiCommitOperationKind.Merge:
dispatcher.startMergeBranchOperation(repository, false, selectedBranch)
@ -191,26 +187,13 @@ export abstract class BaseChooseBranchDialog extends React.Component<
}
private renderStatusPreview() {
const { currentBranch } = this.props
const { selectedBranch, statusPreview: preview } = this.state
const { currentBranch, selectedBranch, children } = this.props
if (
preview == null ||
currentBranch == null ||
selectedBranch == null ||
currentBranch.name === selectedBranch.name
) {
if (selectedBranch == null || currentBranch.name === selectedBranch.name) {
return null
}
return (
<div className="merge-status-component">
{this.renderActionStatusIcon()}
<p className="merge-info" id="merge-status-preview">
{preview}
</p>
</div>
)
return <div className="merge-status-component">{children}</div>
}
private renderBranch = (item: IBranchListItem, matches: IMatches) => {
@ -222,17 +205,24 @@ export abstract class BaseChooseBranchDialog extends React.Component<
}
public render() {
const { selectedBranch } = this.state
const { currentBranch, operation } = this.props
const truncatedName = truncateWithEllipsis(currentBranch.name, 40)
const {
selectedBranch,
currentBranch,
operation,
dialogTitle,
canStartOperation,
submitButtonTooltip,
start,
onSelectionChanged,
} = this.props
return (
<Dialog
id="choose-branch"
onDismissed={this.props.onDismissed}
onSubmit={this.start}
onSubmit={start}
dismissable={true}
title={this.getDialogTitle(truncatedName)}
title={dialogTitle}
>
<DialogContent>
<BranchList
@ -243,7 +233,7 @@ export abstract class BaseChooseBranchDialog extends React.Component<
filterText={this.state.filterText}
onFilterTextChanged={this.onFilterTextChanged}
selectedBranch={selectedBranch}
onSelectionChanged={this.onSelectionChanged}
onSelectionChanged={onSelectionChanged}
canCreateNewBranch={false}
renderBranch={this.renderBranch}
getBranchAriaLabel={this.getBranchAriaLabel}
@ -255,10 +245,10 @@ export abstract class BaseChooseBranchDialog extends React.Component<
<DropdownSelectButton
checkedOption={operation}
options={getMergeOptions()}
disabled={!this.canStart()}
disabled={!canStartOperation}
ariaDescribedBy="merge-status-preview"
dropdownAriaLabel="Merge options"
tooltip={this.getSubmitButtonToolTip()}
tooltip={submitButtonTooltip}
onCheckedOptionChange={this.onOperationChange}
/>
</DialogFooter>

View file

@ -8,18 +8,39 @@ import { MergeTreeResult } from '../../../models/merge'
import { MultiCommitOperationKind } from '../../../models/multi-commit-operation'
import { PopupType } from '../../../models/popup'
import { ActionStatusIcon } from '../../lib/action-status-icon'
import { BaseChooseBranchDialog } from './base-choose-branch-dialog'
import {
ChooseBranchDialog,
IBaseChooseBranchDialogProps,
canStartOperation,
} from './base-choose-branch-dialog'
import { truncateWithEllipsis } from '../../../lib/truncate-with-ellipsis'
export class MergeChooseBranchDialog extends BaseChooseBranchDialog {
private commitCount: number = 0
private mergeStatus: MergeTreeResult | null = null
interface IMergeChooseBranchDialogState {
readonly commitCount: number
readonly mergeStatus: MergeTreeResult | null
readonly selectedBranch: Branch | null
}
protected start = () => {
export class MergeChooseBranchDialog extends React.Component<
IBaseChooseBranchDialogProps,
IMergeChooseBranchDialogState
> {
public constructor(props: IBaseChooseBranchDialogProps) {
super(props)
this.state = {
selectedBranch: null,
commitCount: 0,
mergeStatus: null,
}
}
private start = () => {
if (!this.canStart()) {
return
}
const { selectedBranch } = this.state
const { selectedBranch, mergeStatus } = this.state
const { operation, dispatcher, repository } = this.props
if (!selectedBranch) {
return
@ -28,144 +49,125 @@ export class MergeChooseBranchDialog extends BaseChooseBranchDialog {
dispatcher.mergeBranch(
repository,
selectedBranch,
this.mergeStatus,
mergeStatus,
operation === MultiCommitOperationKind.Squash
)
this.props.dispatcher.closePopup(PopupType.MultiCommitOperation)
dispatcher.closePopup(PopupType.MultiCommitOperation)
}
protected canStart = (): boolean => {
const selectedBranch = this.state.selectedBranch
const currentBranch = this.props.currentBranch
private canStart = (): boolean => {
const { currentBranch } = this.props
const { selectedBranch, commitCount, mergeStatus } = this.state
const selectedBranchIsCurrentBranch =
selectedBranch !== null &&
currentBranch !== null &&
selectedBranch.name === currentBranch.name
const isBehind = this.commitCount !== undefined && this.commitCount > 0
const canMergeBranch =
this.mergeStatus === null ||
this.mergeStatus.kind !== ComputedAction.Invalid
return (
selectedBranch !== null &&
!selectedBranchIsCurrentBranch &&
isBehind &&
canMergeBranch
return canStartOperation(
selectedBranch,
currentBranch,
commitCount,
mergeStatus?.kind
)
}
protected onSelectionChanged = async (selectedBranch: Branch | null) => {
if (selectedBranch != null) {
this.setState({ selectedBranch })
return this.updateStatus(selectedBranch)
private onSelectionChanged = (selectedBranch: Branch | null) => {
this.setState({ selectedBranch })
if (selectedBranch === null) {
this.setState({ commitCount: 0, mergeStatus: null })
return
}
// return to empty state
this.setState({ selectedBranch })
this.commitCount = 0
this.mergeStatus = null
this.updateStatus(selectedBranch)
}
protected renderActionStatusIcon = () => {
return (
<ActionStatusIcon
status={this.mergeStatus}
classNamePrefix="merge-status"
/>
private getDialogTitle = () => {
const truncatedName = truncateWithEllipsis(
this.props.currentBranch.name,
40
)
}
protected getDialogTitle = (branchName: string) => {
const squashPrefix =
this.props.operation === MultiCommitOperationKind.Squash
? 'Squash and '
: null
return (
<>
{squashPrefix}Merge into <strong>{branchName}</strong>
{squashPrefix}Merge into <strong>{truncatedName}</strong>
</>
)
}
protected updateStatus = async (branch: Branch) => {
private updateStatus = async (branch: Branch) => {
const { currentBranch, repository } = this.props
this.mergeStatus = { kind: ComputedAction.Loading }
this.updateMergeStatusPreview(branch)
this.setState({
commitCount: 0,
mergeStatus: { kind: ComputedAction.Loading },
})
if (currentBranch != null) {
this.mergeStatus = await promiseWithMinimumTimeout(
() => determineMergeability(repository, currentBranch, branch),
500
).catch<MergeTreeResult>(e => {
log.error('Failed determining mergeability', e)
return { kind: ComputedAction.Clean }
})
const mergeStatus = await promiseWithMinimumTimeout(
() => determineMergeability(repository, currentBranch, branch),
500
).catch<MergeTreeResult>(e => {
log.error('Failed determining mergeability', e)
return { kind: ComputedAction.Clean }
})
if (
this.mergeStatus.kind === ComputedAction.Conflicts ||
this.mergeStatus.kind === ComputedAction.Invalid
) {
this.updateMergeStatusPreview(branch)
// Because the clean status is the only one that needs the ahead/Behind count
// So if mergeState is conflicts or invalid, update the UI here and end the function
return
}
// The user has selected a different branch since we started, so don't
// update the preview with stale data.
if (this.state.selectedBranch !== branch) {
return
}
// Can't go forward if the merge status is invalid, no need to check commit count
if (mergeStatus.kind === ComputedAction.Invalid) {
this.setState({ mergeStatus })
return
}
// Commit count is used in the UI output as well as determining whether the
// submit button is enabled
const range = revSymmetricDifference('', branch.name)
const aheadBehind = await getAheadBehind(this.props.repository, range)
this.commitCount = aheadBehind ? aheadBehind.behind : 0
const commitCount = aheadBehind ? aheadBehind.behind : 0
if (this.state.selectedBranch !== branch) {
this.commitCount = 0
return
}
this.updateMergeStatusPreview(branch)
this.setState({ commitCount, mergeStatus })
}
private updateMergeStatusPreview(branch: Branch) {
this.setState({ statusPreview: this.getMergeStatusPreview(branch) })
}
private getMergeStatusPreview(branch: Branch): JSX.Element | null {
private renderStatusPreviewMessage(): JSX.Element | null {
const { mergeStatus, selectedBranch: branch } = this.state
const { currentBranch } = this.props
if (this.mergeStatus === null) {
if (mergeStatus === null || branch === null) {
return null
}
if (this.mergeStatus.kind === ComputedAction.Loading) {
if (mergeStatus.kind === ComputedAction.Loading) {
return this.renderLoadingMergeMessage()
}
if (this.mergeStatus.kind === ComputedAction.Clean) {
if (mergeStatus.kind === ComputedAction.Clean) {
return this.renderCleanMergeMessage(
branch,
currentBranch,
this.commitCount
this.state.commitCount
)
}
if (this.mergeStatus.kind === ComputedAction.Invalid) {
if (mergeStatus.kind === ComputedAction.Invalid) {
return this.renderInvalidMergeMessage()
}
return this.renderConflictedMergeMessage(
branch,
currentBranch,
this.mergeStatus.conflictedFiles
mergeStatus.conflictedFiles
)
}
private renderLoadingMergeMessage() {
return (
<React.Fragment>
Checking for ability to merge automatically...
</React.Fragment>
)
return <>Checking for ability to merge automatically...</>
}
private renderCleanMergeMessage(
@ -220,4 +222,33 @@ export class MergeChooseBranchDialog extends BaseChooseBranchDialog {
</React.Fragment>
)
}
private renderStatusPreview() {
return (
<>
<ActionStatusIcon
status={this.state.mergeStatus}
classNamePrefix="merge-status"
/>
<p className="merge-info" id="merge-status-preview">
{this.renderStatusPreviewMessage()}
</p>
</>
)
}
public render() {
return (
<ChooseBranchDialog
{...this.props}
start={this.start}
selectedBranch={this.state.selectedBranch}
canStartOperation={this.canStart()}
dialogTitle={this.getDialogTitle()}
onSelectionChanged={this.onSelectionChanged}
>
{this.renderStatusPreview()}
</ChooseBranchDialog>
)
}
}

View file

@ -4,118 +4,142 @@ import { ComputedAction } from '../../../models/computed-action'
import { RebasePreview } from '../../../models/rebase'
import { ActionStatusIcon } from '../../lib/action-status-icon'
import { updateRebasePreview } from '../../lib/update-branch'
import { BaseChooseBranchDialog } from './base-choose-branch-dialog'
import {
ChooseBranchDialog,
IBaseChooseBranchDialogProps,
canStartOperation,
} from './base-choose-branch-dialog'
import { truncateWithEllipsis } from '../../../lib/truncate-with-ellipsis'
export abstract class RebaseChooseBranchDialog extends BaseChooseBranchDialog {
private rebasePreview: RebasePreview | null = null
interface IRebaseChooseBranchDialogState {
readonly rebasePreview: RebasePreview | null
readonly selectedBranch: Branch | null
}
protected start = () => {
const { selectedBranch } = this.state
const { repository, currentBranch } = this.props
if (!selectedBranch) {
return
}
if (
this.rebasePreview === null ||
this.rebasePreview.kind !== ComputedAction.Clean
) {
return
export class RebaseChooseBranchDialog extends React.Component<
IBaseChooseBranchDialogProps,
IRebaseChooseBranchDialogState
> {
public constructor(props: IBaseChooseBranchDialogProps) {
super(props)
this.state = {
selectedBranch: null,
rebasePreview: null,
}
}
private start = () => {
if (!this.canStart()) {
return
}
this.props.dispatcher.startRebase(
const { selectedBranch, rebasePreview } = this.state
const { repository, currentBranch, dispatcher } = this.props
// Just type checking here, this shouldn't be possible
if (
selectedBranch === null ||
rebasePreview === null ||
rebasePreview.kind !== ComputedAction.Clean
) {
return
}
dispatcher.startRebase(
repository,
selectedBranch,
currentBranch,
this.rebasePreview.commits
rebasePreview.commits
)
}
protected canStart = (): boolean => {
return (
this.state.selectedBranch !== null &&
!this.selectedBranchIsCurrentBranch() &&
this.selectedBranchIsAheadOfCurrentBranch()
private canStart = (): boolean => {
const { currentBranch } = this.props
const { selectedBranch, rebasePreview } = this.state
const commitCount =
rebasePreview?.kind === ComputedAction.Clean
? rebasePreview.commits.length
: undefined
return canStartOperation(
selectedBranch,
currentBranch,
commitCount,
rebasePreview?.kind
)
}
private selectedBranchIsCurrentBranch() {
const currentBranch = this.props.currentBranch
const { selectedBranch } = this.state
return (
private onSelectionChanged = (selectedBranch: Branch | null) => {
this.setState({ selectedBranch })
if (selectedBranch === null) {
this.setState({ rebasePreview: null })
return
}
this.updateStatus(selectedBranch)
}
private getSubmitButtonToolTip = () => {
const { currentBranch } = this.props
const { selectedBranch, rebasePreview } = this.state
const selectedBranchIsCurrentBranch =
selectedBranch !== null &&
currentBranch !== null &&
selectedBranch.name === currentBranch.name
)
}
private selectedBranchIsAheadOfCurrentBranch() {
return this.rebasePreview !== null &&
this.rebasePreview.kind === ComputedAction.Clean
? this.rebasePreview.commits.length > 0
: false
}
const areCommitsToRebase =
rebasePreview?.kind === ComputedAction.Clean
? rebasePreview.commits.length > 0
: false
protected getSubmitButtonToolTip = () => {
return this.selectedBranchIsCurrentBranch()
? 'You are not able to rebase this branch onto itself'
: !this.selectedBranchIsAheadOfCurrentBranch()
? 'There are no commits on the current branch to rebase'
return selectedBranchIsCurrentBranch
? 'You are not able to rebase this branch onto itself.'
: !areCommitsToRebase
? 'There are no commits on the current branch to rebase.'
: undefined
}
protected getDialogTitle = (branchName: string) => {
private getDialogTitle = () => {
const truncatedName = truncateWithEllipsis(
this.props.currentBranch.name,
40
)
return (
<>
Rebase <strong>{branchName}</strong>
Rebase <strong>{truncatedName}</strong>
</>
)
}
protected renderActionStatusIcon = () => {
return (
<ActionStatusIcon
status={this.rebasePreview}
classNamePrefix="merge-status"
/>
)
}
protected updateStatus = async (baseBranch: Branch) => {
private updateStatus = async (baseBranch: Branch) => {
const { currentBranch: targetBranch, repository } = this.props
updateRebasePreview(baseBranch, targetBranch, repository, rebasePreview => {
this.rebasePreview = rebasePreview
this.updateRebaseStatusPreview(baseBranch)
this.setState({ rebasePreview })
})
}
private updateRebaseStatusPreview(baseBranch: Branch) {
this.setState({ statusPreview: this.getRebaseStatusPreview(baseBranch) })
}
private getRebaseStatusPreview(baseBranch: Branch): JSX.Element | null {
if (this.rebasePreview == null) {
private renderStatusPreviewMessage(): JSX.Element | null {
const { rebasePreview, selectedBranch: baseBranch } = this.state
if (rebasePreview == null || baseBranch == null) {
return null
}
const { currentBranch } = this.props
if (this.rebasePreview.kind === ComputedAction.Loading) {
if (rebasePreview.kind === ComputedAction.Loading) {
return this.renderLoadingRebaseMessage()
}
if (this.rebasePreview.kind === ComputedAction.Clean) {
if (rebasePreview.kind === ComputedAction.Clean) {
return this.renderCleanRebaseMessage(
currentBranch,
baseBranch,
this.rebasePreview.commits.length
rebasePreview.commits.length
)
}
if (this.rebasePreview.kind === ComputedAction.Invalid) {
if (rebasePreview.kind === ComputedAction.Invalid) {
return this.renderInvalidRebaseMessage()
}
@ -155,4 +179,34 @@ export abstract class RebaseChooseBranchDialog extends BaseChooseBranchDialog {
</>
)
}
private renderStatusPreview() {
return (
<>
<ActionStatusIcon
status={this.state.rebasePreview}
classNamePrefix="merge-status"
/>
<p className="merge-info" id="merge-status-preview">
{this.renderStatusPreviewMessage()}
</p>
</>
)
}
public render() {
return (
<ChooseBranchDialog
{...this.props}
start={this.start}
selectedBranch={this.state.selectedBranch}
canStartOperation={this.canStart()}
dialogTitle={this.getDialogTitle()}
submitButtonTooltip={this.getSubmitButtonToolTip()}
onSelectionChanged={this.onSelectionChanged}
>
{this.renderStatusPreview()}
</ChooseBranchDialog>
)
}
}

View file

@ -13,6 +13,7 @@ import { Avatar } from '../lib/avatar'
import { formatRelative } from '../../lib/format-relative'
import { getStealthEmailForUser } from '../../lib/email'
import { IAPIIdentity } from '../../lib/api'
import { Account } from '../../models/account'
interface IPullRequestCommentLikeProps {
readonly id?: string
@ -36,6 +37,8 @@ interface IPullRequestCommentLikeProps {
readonly onSubmit: () => void
readonly onDismissed: () => void
readonly accounts: ReadonlyArray<Account>
}
/**
@ -77,7 +80,8 @@ export abstract class PullRequestCommentLike extends React.Component<IPullReques
}
private renderTimelineItem() {
const { user, repository, eventDate, eventVerb, externalURL } = this.props
const { user, repository, eventDate, eventVerb, externalURL, accounts } =
this.props
const { endpoint } = repository.gitHubRepository
const userAvatar = {
name: user.login,
@ -101,7 +105,12 @@ export abstract class PullRequestCommentLike extends React.Component<IPullReques
<div className="timeline-item-container">
{this.renderDashedTimelineLine('top')}
<div className={timelineItemClass}>
<Avatar user={userAvatar} title={null} size={40} />
<Avatar
accounts={accounts}
user={userAvatar}
title={null}
size={40}
/>
{this.renderReviewIcon()}
<div className="summary">
<LinkButton uri={user.html_url} className="author">

View file

@ -8,6 +8,7 @@ import { LinkButton } from '../lib/link-button'
import { IAPIComment } from '../../lib/api'
import { getPullRequestReviewStateIcon } from './pull-request-review-helpers'
import { PullRequestCommentLike } from './pull-request-comment-like'
import { Account } from '../../models/account'
interface IPullRequestCommentProps {
readonly dispatcher: Dispatcher
@ -27,6 +28,8 @@ interface IPullRequestCommentProps {
readonly onSubmit: () => void
readonly onDismissed: () => void
readonly accounts: ReadonlyArray<Account>
}
interface IPullRequestCommentState {
@ -57,6 +60,7 @@ export class PullRequestComment extends React.Component<
comment,
onSubmit,
onDismissed,
accounts,
} = this.props
const icon = getPullRequestReviewStateIcon('COMMENTED')
@ -79,6 +83,7 @@ export class PullRequestComment extends React.Component<
renderFooterContent={this.renderFooterContent}
onSubmit={onSubmit}
onDismissed={onDismissed}
accounts={accounts}
/>
)
}

View file

@ -11,6 +11,7 @@ import {
import { LinkButton } from '../lib/link-button'
import { ValidNotificationPullRequestReview } from '../../lib/valid-notification-pull-request-review'
import { PullRequestCommentLike } from './pull-request-comment-like'
import { Account } from '../../models/account'
interface IPullRequestReviewProps {
readonly dispatcher: Dispatcher
@ -30,6 +31,8 @@ interface IPullRequestReviewProps {
readonly onSubmit: () => void
readonly onDismissed: () => void
readonly accounts: ReadonlyArray<Account>
}
interface IPullRequestReviewState {
@ -82,6 +85,7 @@ export class PullRequestReview extends React.Component<
renderFooterContent={this.renderFooterContent}
onSubmit={onSubmit}
onDismissed={onDismissed}
accounts={this.props.accounts}
/>
)
}

View file

@ -20,9 +20,9 @@ interface IOcticonProps {
readonly className?: string
/**
* An optional string to use as a tooltip for the icon
* An optional string to use as a tooltip and aria-label for the icon
*/
readonly title?: JSX.Element | string
readonly title?: string
readonly tooltipDirection?: TooltipDirection
}
@ -56,6 +56,7 @@ export class Octicon extends React.Component<IOcticonProps, {}> {
return (
<svg
aria-hidden={ariaHidden}
aria-label={title}
className={className}
version="1.1"
viewBox={viewBox}

View file

@ -54,9 +54,14 @@ export class Accounts extends React.Component<IAccountsProps, {}> {
const accountTypeLabel =
type === SignInType.DotCom ? 'GitHub.com' : 'GitHub Enterprise'
const accounts = [
...(this.props.dotComAccount ? [this.props.dotComAccount] : []),
...(this.props.enterpriseAccount ? [this.props.enterpriseAccount] : []),
]
return (
<Row className="account-info">
<Avatar user={avatarUser} />
<Avatar accounts={accounts} user={avatarUser} />
<div className="user-info">
<div className="name">{account.name}</div>
<div className="login">@{account.login}</div>

View file

@ -306,6 +306,7 @@ export class RepositoryView extends React.Component<
askForConfirmationOnCheckoutCommit={
this.props.askForConfirmationOnCheckoutCommit
}
accounts={this.props.accounts}
/>
)
}
@ -441,6 +442,7 @@ export class RepositoryView extends React.Component<
onChangeImageDiffType={this.onChangeImageDiffType}
onDiffOptionsOpened={this.onDiffOptionsOpened}
showDragOverlay={showDragOverlay}
accounts={this.props.accounts}
/>
)
}

View file

@ -19,6 +19,7 @@
border-radius: 50%;
.avatar-container,
.avatar {
flex-shrink: 0;
width: 100%;

View file

@ -728,6 +728,12 @@
color: var(--syntax-header-color);
}
.cm-m-cmake {
&.cm-def {
color: var(--syntax-atom-color);
}
}
.cm-m-css {
&.cm-property {
color: var(--syntax-atom-color);
@ -796,7 +802,7 @@
pointer-events: none;
}
.hidden-bidi-chars-warning {
.diff-contents-warning-container {
background-color: var(--file-warning-background-color);
padding: var(--spacing) var(--spacing-double);
border-bottom: var(--file-warning-border-color) solid 1px;
@ -810,6 +816,13 @@
a.link-button-component {
display: unset;
}
// Separate warning items
.diff-contents-warning:not(:last-child) {
margin-bottom: var(--spacing);
border-bottom: var(--file-warning-border-color) solid 1px;
padding-bottom: var(--spacing);
}
}
// When loading a diff

View file

@ -256,6 +256,7 @@ index 1910281..257cc56 100644
expect(lines[i].type).toBe(DiffLineType.Delete)
expect(lines[i].oldLineNumber).toBe(1)
expect(lines[i].newLineNumber).toBeNull()
expect(lines[i].originalLineNumber).toBe(1)
expect(lines[i].noTrailingNewLine).toBe(true)
i++
@ -263,6 +264,7 @@ index 1910281..257cc56 100644
expect(lines[i].type).toBe(DiffLineType.Add)
expect(lines[i].oldLineNumber).toBeNull()
expect(lines[i].newLineNumber).toBe(1)
expect(lines[i].originalLineNumber).toBe(2)
expect(lines[i].noTrailingNewLine).toBe(false)
i++
})
@ -305,6 +307,7 @@ index 1910281..ba0e162 100644
expect(lines[i].type).toBe(DiffLineType.Delete)
expect(lines[i].oldLineNumber).toBe(1)
expect(lines[i].newLineNumber).toBeNull()
expect(lines[i].originalLineNumber).toBe(1)
expect(lines[i].noTrailingNewLine).toBe(true)
i++
@ -312,6 +315,7 @@ index 1910281..ba0e162 100644
expect(lines[i].type).toBe(DiffLineType.Add)
expect(lines[i].oldLineNumber).toBeNull()
expect(lines[i].newLineNumber).toBe(1)
expect(lines[i].originalLineNumber).toBe(2)
expect(lines[i].noTrailingNewLine).toBe(true)
i++
})

View file

@ -19,17 +19,8 @@ describe('endpoint-capabilities', () => {
})
it('recognizes GHAE', () => {
expect(testGHAE(false)).toBeFalse()
expect(testGHAE(true)).toBeTrue()
})
// GHAE doesn't advertise the installed version so we'll assume its
// capabilities match that of a recent supported version of GHES. This is
// defined in the `assumedGHAEVersion` constant in endpoint-capabilities.ts
// and needs to be updated periodically.
it('assumes GHAE versions', () => {
expect(testGHAE('>= 3.2.1')).toBeFalse()
expect(testGHAE('>= 3.2.0')).toBeTrue()
expect(testGHEDotCom(false)).toBeFalse()
expect(testGHEDotCom(true)).toBeTrue()
})
// If we can't determine the actual version of a GitHub Enterprise Server
@ -51,7 +42,7 @@ describe('endpoint-capabilities', () => {
expect(
testEndpoint('https://api.github.com', {
dotcom: true,
ae: '>= 3.0.0',
ghe: false,
es: '>= 3.0.0',
})
).toBeTrue()
@ -61,7 +52,7 @@ describe('endpoint-capabilities', () => {
'https://ghe.io',
{
dotcom: false,
ae: '>= 4.0.0',
ghe: false,
es: '>= 3.1.0',
},
'3.1.0'
@ -77,7 +68,7 @@ function testDotCom(
) {
return testEndpoint(
getDotComAPIEndpoint(),
{ dotcom: constraint, ae: false, es: false },
{ dotcom: constraint, ghe: false, es: false },
endpointVersion
)
}
@ -88,20 +79,17 @@ function testGHES(
) {
return testEndpoint(
'https://ghe.io',
{ dotcom: false, ae: false, es: constraint },
{ dotcom: false, ghe: false, es: constraint },
endpointVersion
)
}
function testGHAE(
constraint: boolean | string,
endpointVersion: string | SemVer | null = null
) {
return testEndpoint(
'https://corp.ghe.com',
{ dotcom: false, ae: constraint, es: false },
endpointVersion
)
function testGHEDotCom(constraint: boolean) {
return testEndpoint('https://corp.ghe.com', {
dotcom: false,
ghe: constraint,
es: false,
})
}
function testEndpoint(

View file

@ -55,6 +55,14 @@ describe('git/tag', () => {
expect(commit!.tags).toEqual(['my-new-tag'])
})
it('creates a tag with the a comma in it', async () => {
await createTag(repository, 'my-new-tag,has-a-comma', 'HEAD')
const commit = await getCommit(repository, 'HEAD')
expect(commit).not.toBeNull()
expect(commit!.tags).toEqual(['my-new-tag,has-a-comma'])
})
it('creates multiple tags', async () => {
await createTag(repository, 'my-new-tag', 'HEAD')
await createTag(repository, 'another-tag', 'HEAD')

View file

@ -1,5 +1,49 @@
{
"releases": {
"3.3.9-beta1": [
"[Fixed] The merge dialog submit button is available when conflicts are detected - #18037",
"[Fixed] Avatars are once again loading for GitHub Enterprise Server users - #18034",
"[Fixed] Lists scroll and render as expected when scrolling by dragging the scrollbar on Windows - #18012",
"[Fixed] External editor or shell failure error will open to integration settings - #18021. Thanks @yasuking0304!",
"[Improved] Implemented folder ignore with all parent directories - #1203. Thanks @masecla22!"
],
"3.3.8": [
"[Fixed] The merge dialog submit button is available when conflicts are detected. - #18037",
"[Fixed] Avatars are once again loading for GitHub Enterprise Server users. - #18036"
],
"3.3.7": [
"[Fixed] Merge branch dialog's merge preview no longer shows stale merge check data - #17929",
"[Fixed] Co-authors are restored as such when a commit is amended - #17879",
"[Fixed] Tags with commas are no longer truncated to the first comma - #17952",
"[Fixed] The \"Reveal in Finder\" context menu option in the conflict resolution dialog no longer causes Finder to be unresponsive - #17933",
"[Fixed] Clicking on the commit message length warning does not close the squash commit dialog - #17966",
"[Fixed] Fix Alacritty bundle ID on macOS - #17940. Thanks @JannesMeyer!",
"[Fixed] Merge branch dialog no longer shows flickering merge preview when switching branches - #17948. Thanks @GengShengJia!",
"[Fixed] Diff no longer jumps when scrolling after pressing expansion buttons - #17776",
"[Fixed] Use list semantics in job step lists for improved accessibility - #17855",
"[Fixed] Fix heading levels used in dialogs for improved accessibility - #17848",
"[Improved] Replace the \"Default branch name for new repositories\" radio button setting with a more accessible and inclusive textbox input and description - #17844",
"[Improved] The \"You're Done\" header is focused after tutorial completion so it is announced and screen reader users are made aware of the completion screen - #17835",
"[Improved] Checkboxes always have unique id's for label association - #17839",
"[Improved] Better visibility of checkbox focus indicator - #17842",
"[Improved] Improve inclusivity and clarification of branch name change warning. - #17866",
"[Improved] Focus moves to closest expansion button or diff container after expansion - #17499",
"[Improved] Tooltips can be dismissed with the escape key - #17823, #17836",
"[Improved] Semantically grouping our settings radio and checkbox groups so their group headers will be announced to screen reader users. - #17787",
"[Improved] The \"Other\" email description is announced on input focus in the git config form - #17785",
"[Improved] Move the repository list on the \"Let's get started!\" screen to the left hand side so it can be the first logical tab placement. - #17821",
"[Improved] Increased the specificity of the \"Sign In\" and \"Sign Out\" buttons in the Account settings - #17794"
],
"3.3.7-beta3": [
"[Fixed] Merge branch dialog's merge preview no longer shows stale merge check data - #17929",
"[Fixed] Co-authors are restored as such when a commit is amended - #17879",
"[Fixed] Tags with commas are no longer truncated to the first comma - #17952",
"[Fixed] The \"Reveal in Finder\" context menu option in the conflict resolution dialog no longer causers Finder to be unresponsive - #17933",
"[Fixed] Clicking on the commit message length warning does not close the squash commit dialog - #17966",
"[Fixed] Fix Alacritty bundle ID on macOS - #17940. Thanks @JannesMeyer!",
"[Fixed] Merge branch dialog no longer shows flickering merge preview when switching branches - #17948. Thanks @GengShengJia!",
"[Improved] Improve inclusivity and clarification of branch name change warning. - #17866"
],
"3.3.7-beta2": [
"[Fixed] Use list semantics in job step lists for improved accessibility - #17855",
"[Fixed] Fix heading levels used in dialogs for improved accessibility - #17848",

View file

@ -8,7 +8,7 @@ We introduced syntax highlighted diffs in [#3101](https://github.com/desktop/des
We currently support syntax highlighting for the following languages and file types.
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, HAML, Sieve, Scheme, ReStructuredText, RPM, Q, Puppet, Pug, Protobuf, Properties, Apache Pig, ASCII Armor (PGP), Oz, Pascal, Toml, Dart and Docker.
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, HAML, Sieve, Scheme, ReStructuredText, RPM, Q, Puppet, Pug, Protobuf, Properties, Apache Pig, ASCII Armor (PGP), Oz, Pascal, Toml, Dart, CMake 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

@ -178,7 +178,7 @@ function packageApp() {
new RegExp('/\\.git($|/)'),
new RegExp('/node_modules/\\.bin($|/)'),
],
appCopyright: 'Copyright © 2023 GitHub, Inc.',
appCopyright: `Copyright © ${new Date().getFullYear()} GitHub, Inc.`,
// macOS
appBundleId: getBundleID(),