Merge branch 'master' into my-hands-are-typing

This commit is contained in:
Jed Fox 2018-01-31 13:55:00 -05:00 committed by GitHub
commit 3c2042f7a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 4132 additions and 776 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
/app/test/fixtures/** -text

View file

@ -1,4 +1,4 @@
runtime = electron
disturl = https://atom.io/download/electron
target = 1.7.10
target = 1.7.11
arch = x64

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "1.0.12",
"version": "1.0.14-beta1",
"main": "./main.js",
"repository": {
"type": "git",
@ -24,7 +24,7 @@
"codemirror": "^5.31.0",
"deep-equal": "^1.0.1",
"dexie": "^2.0.0",
"dugite": "1.53.0",
"dugite": "1.57.0",
"electron-window-state": "^4.0.2",
"event-kit": "^2.0.0",
"file-uri-to-path": "0.0.2",

View file

@ -65,10 +65,27 @@ export interface IAPIUser {
readonly url: string
readonly login: string
readonly avatar_url: string
readonly name: string
/**
* The user's real name or null if the user hasn't provided
* a real name for their public profile.
*/
readonly name: string | null
/**
* The email address for this user or null if the user has not
* specified a public email address in their profile.
*/
readonly email: string | null
readonly type: 'User' | 'Organization'
}
/**
* An expression that validates a GitHub.com or GitHub Enterprise
* username
*/
export const validLoginExpression = /^[a-z0-9]+(-[a-z0-9]+)*$/i
/** The users we get from the mentionables endpoint. */
export interface IAPIMentionableUser {
readonly avatar_url: string
@ -543,6 +560,25 @@ export class API {
return null
}
}
/**
* Retrieve the public profile information of a user with
* a given username.
*/
public async fetchUser(login: string): Promise<IAPIUser | null> {
try {
const response = await this.request('GET', `users/${login}`)
if (response.status === 404) {
return null
}
return await parsedResponse<IAPIUser>(response)
} catch (e) {
log.warn(`fetchUser: failed with endpoint ${this.endpoint}`, e)
throw e
}
}
}
export enum AuthorizationResponseKind {
@ -692,7 +728,7 @@ export async function fetchUser(
emails,
avatarURL,
user.id,
user.name
user.name || user.login
)
} catch (e) {
log.warn(`fetchUser: failed with endpoint ${endpoint}`, e)

View file

@ -25,6 +25,7 @@ import { Shell } from './shells'
import { CloneRepositoryTab } from '../models/clone-repository-tab'
import { BranchesTab } from '../models/branches-tab'
import { PullRequest } from '../models/pull-request'
import { IAuthor } from '../models/author'
export { ICommitMessage }
export { IAheadBehind }
@ -566,11 +567,26 @@ export interface IChangesState {
readonly diff: IDiff | null
/**
* The commit message to use based on the contex of the repository, e.g., the
* The commit message to use based on the context of the repository, e.g., the
* message from a recently undone commit.
*/
readonly contextualCommitMessage: ICommitMessage | null
/** The commit message for a work-in-progress commit in the changes view. */
readonly commitMessage: ICommitMessage | null
/**
* Whether or not to show a field for adding co-authors to
* a commit (currently only supported for GH/GHE repositories)
*/
readonly showCoAuthoredBy: boolean
/**
* A list of authors (name, email pairs) which have been
* entered into the co-authors input box in the commit form
* and which _may_ be used in the subsequent commit to add
* Co-Authored-By commit message trailers depending on whether
* the user has chosen to do so.
*/
readonly coAuthors: ReadonlyArray<IAuthor>
}

View file

@ -4,15 +4,28 @@ import Dexie from 'dexie'
const DatabaseVersion = 2
export interface IGitHubUser {
/**
* The internal (to desktop) database id for this user or undefined
* if not yet inserted into the database.
*/
readonly id?: number
readonly endpoint: string
readonly email: string
readonly login: string
readonly avatarURL: string
readonly name: string
/**
* The user's real name or null if the user hasn't provided a real
* name yet.
*/
readonly name: string | null
}
export interface IMentionableAssociation {
/**
* The internal (to desktop) database id for this association
* or undefined if not yet inserted into the database.
*/
readonly id?: number
readonly userID: number
readonly repositoryID: number

View file

@ -60,8 +60,12 @@ export interface IPullRequestStatus {
/** The SHA for which this status applies. */
readonly sha: string
/** The list of statuses for this specific ref */
readonly statuses: ReadonlyArray<IAPIRefStatusItem>
/**
* The list of statuses for this specific ref or undefined
* if the database object was created prior to status support
* being added in #3588
*/
readonly statuses: ReadonlyArray<IAPIRefStatusItem> | undefined
}
export class PullRequestDatabase extends Dexie {

View file

@ -51,6 +51,9 @@ import { CloneRepositoryTab } from '../../models/clone-repository-tab'
import { validatedRepositoryPath } from '../../lib/stores/helpers/validated-repository-path'
import { BranchesTab } from '../../models/branches-tab'
import { FetchType } from '../../lib/stores'
import { PullRequest } from '../../models/pull-request'
import { IAuthor } from '../../models/author'
import { ITrailer } from '../git/interpret-trailers'
/**
* An error handler function.
@ -189,13 +192,21 @@ export class Dispatcher {
/**
* Commit the changes which were marked for inclusion, using the given commit
* summary and description.
* summary and description and optionally any number of commit message trailers
* which will be merged into the final commit message.
*/
public async commitIncludedChanges(
repository: Repository,
message: ICommitMessage
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
): Promise<boolean> {
return this.appStore._commitIncludedChanges(repository, message)
return this.appStore._commitIncludedChanges(
repository,
summary,
description,
trailers
)
}
/** Change the file's includedness. */
@ -1096,4 +1107,38 @@ export class Dispatcher {
public ignoreExistingUpstreamRemote(repository: Repository): Promise<void> {
return this.appStore._ignoreExistingUpstreamRemote(repository)
}
/** Checks out a PR whose ref exists locally or in a forked repo. */
public async checkoutPullRequest(
repository: Repository,
pullRequest: PullRequest
): Promise<void> {
return this.appStore._checkoutPullRequest(repository, pullRequest)
}
/**
* Set whether the user has chosen to hide or show the
* co-authors field in the commit message component
*
* @param repository Co-author settings are per-repository
*/
public setShowCoAuthoredBy(
repository: Repository,
showCoAuthoredBy: boolean
) {
return this.appStore._setShowCoAuthoredBy(repository, showCoAuthoredBy)
}
/**
* Update the per-repository co-authors list
*
* @param repository Co-author settings are per-repository
* @param coAuthors Zero or more authors
*/
public setCoAuthors(
repository: Repository,
coAuthors: ReadonlyArray<IAuthor>
) {
return this.appStore._setCoAuthors(repository, coAuthors)
}
}

View file

@ -56,3 +56,28 @@ export function shallowEquals(x: any, y: any) {
return true
}
/**
* Compares two arrays for element reference equality.
*
* Two arrays are considered equal if they either contain the
* exact same elements in the same order (reference equality)
* if they're both empty, or if they are the exact same object
*/
export function arrayEquals<T>(x: ReadonlyArray<T>, y: ReadonlyArray<T>) {
if (x === y) {
return true
}
if (x.length !== y.length) {
return false
}
for (let i = 0; i < x.length; i++) {
if (x[i] !== y[i]) {
return false
}
}
return true
}

View file

@ -1,17 +1,35 @@
import { ICommitMessage } from './stores/git-store'
import { ITrailer, mergeTrailers } from './git/interpret-trailers'
import { Repository } from '../models/repository'
/**
* Formats a summary and a description into a git-friendly
* commit message where the summary and (optional) description
* is separated by a blank line.
*
* Also accepts an optional array of commit message trailers,
* see git-interpret-trailers which, if present, will be merged
* into the commit message.
*
* Always returns commit message with a trailing newline
*
* See https://git-scm.com/docs/git-commit#_discussion
*/
export function formatCommitMessage(message: ICommitMessage) {
let msg = message.summary
if (message.description) {
msg += `\n\n${message.description}`
}
export async function formatCommitMessage(
repository: Repository,
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
) {
// Git always trim whitespace at the end of commit messages
// so we concatenate the summary with the description, ensuring
// that they're separated by two newlines. If we don't have a
// description or if it consists solely of whitespace that'll
// all get trimmed away and replaced with a single newline (since
// all commit messages needs to end with a newline for git
// interpret-trailers to work)
const message = `${summary}\n\n${description || ''}\n`.replace(/\s+$/, '\n')
return msg
return trailers !== undefined && trailers.length > 0
? mergeTrailers(repository, message, trailers)
: message
}

View file

@ -3,6 +3,10 @@ import { Repository } from '../../models/repository'
import { Commit } from '../../models/commit'
import { Branch, BranchType } from '../../models/branch'
import { CommitIdentity } from '../../models/commit-identity'
import {
getTrailerSeparatorCharacters,
parseRawUnfoldedTrailers,
} from './interpret-trailers'
/** Get all the branches. */
export async function getBranches(
@ -18,10 +22,12 @@ export async function getBranches(
'%(upstream:short)',
'%(objectname)', // SHA
'%(author)',
'%(committer)',
'%(parent)', // parent SHAs
'%(symref)',
'%(subject)',
'%(body)',
'%(trailers:unfold,only)',
`%${delimiter}`, // indicate end-of-line as %(body) may contain newlines
].join('%00')
@ -40,6 +46,12 @@ export async function getBranches(
// Remove the trailing newline
lines.splice(-1, 1)
if (lines.length === 0) {
return []
}
const trailerSeparators = await getTrailerSeparatorCharacters(repository)
const branches = []
for (const [ix, line] of lines.entries()) {
@ -58,12 +70,28 @@ export async function getBranches(
throw new Error(`Couldn't parse author identity ${authorIdentity}`)
}
const parentSHAs = pieces[5].split(' ')
const symref = pieces[6]
const summary = pieces[7]
const body = pieces[8]
const committerIdentity = pieces[5]
const committer = CommitIdentity.parseIdentity(committerIdentity)
const tip = new Commit(sha, summary, body, author, parentSHAs)
if (!committer) {
throw new Error(`Couldn't parse committer identity ${committerIdentity}`)
}
const parentSHAs = pieces[6].split(' ')
const symref = pieces[7]
const summary = pieces[8]
const body = pieces[9]
const trailers = parseRawUnfoldedTrailers(pieces[10], trailerSeparators)
const tip = new Commit(
sha,
summary,
body,
author,
committer,
parentSHAs,
trailers
)
const type = ref.startsWith('refs/head')
? BranchType.Local

View file

@ -29,3 +29,4 @@ export * from './revert'
export * from './rm'
export * from './mergetool'
export * from './submodule'
export * from './interpret-trailers'

View file

@ -0,0 +1,165 @@
import { git } from './core'
import { Repository } from '../../models/repository'
import { getConfigValue } from './config'
/**
* A representation of a Git commit message trailer.
*
* See git-interpret-trailers for more information.
*/
export interface ITrailer {
readonly token: string
readonly value: string
}
/**
* Parse a string containing only unfolded trailers produced by
* git-interpret-trailers --only-input --only-trailers --unfold or
* a derivative such as git log --format="%(trailers:only,unfold)"
*
* @param trailers A string containing one well formed trailer per
* line
*
* @param separators A string containing all characters to use when
* attempting to find the separator between token
* and value in a trailer. See the configuration
* option trailer.separators for more information
*
* Also see getTrailerSeparatorCharacters.
*/
export function parseRawUnfoldedTrailers(trailers: string, separators: string) {
const lines = trailers.split('\n')
const parsedTrailers = new Array<ITrailer>()
for (const line of lines) {
const trailer = parseSingleUnfoldedTrailer(line, separators)
if (trailer) {
parsedTrailers.push(trailer)
}
}
return parsedTrailers
}
export function parseSingleUnfoldedTrailer(
line: string,
separators: string
): ITrailer | null {
for (const separator of separators) {
const ix = line.indexOf(separator)
if (ix > 0) {
return {
token: line.substring(0, ix).trim(),
value: line.substring(ix + 1).trim(),
}
}
}
return null
}
/**
* Get a string containing the characters that may be used in this repository
* separate tokens from values in commit message trailers. If no specific
* trailer separator is configured the default separator (:) will be returned.
*/
export async function getTrailerSeparatorCharacters(
repository: Repository
): Promise<string> {
return (await getConfigValue(repository, 'trailer.separators')) || ':'
}
/**
* Extract commit message trailers from a commit message.
*
* The trailers returned here are unfolded, i.e. they've had their
* whitespace continuation removed and are all on one line. See the
* documentation for --unfold in the help for `git interpret-trailers`
*
* @param repository The repository in which to run the interpret-
* trailers command. Although not intuitive this
* does matter as there are configuration options
* available for the format, position, etc of commit
* message trailers. See the manpage for
* git-interpret-trailers for more information.
*
* @param commitMessage A commit message from where to attempt to extract
* commit message trailers.
*
* @returns An array of zero or more parsed trailers
*/
export async function parseTrailers(
repository: Repository,
commitMessage: string
): Promise<ReadonlyArray<ITrailer>> {
const result = await git(
['interpret-trailers', '--parse'],
repository.path,
'parseTrailers',
{
stdin: commitMessage,
}
)
const trailers = result.stdout
if (trailers.length === 0) {
return []
}
const separators = await getTrailerSeparatorCharacters(repository)
return parseRawUnfoldedTrailers(result.stdout, separators)
}
/**
* Merge one or more commit message trailers into a commit message.
*
* If no trailers are given this method will simply try to ensure that
* any trailers that happen to be part of the raw message are formatted
* in accordance with the configuration options set for trailers in
* the given repository.
*
* Note that configuration may be set so that duplicate trailers are
* kept or discarded.
*
* @param repository The repository in which to run the interpret-
* trailers command. Although not intuitive this
* does matter as there are configuration options
* available for the format, position, etc of commit
* message trailers. See the manpage for
* git-interpret-trailers for more information.
*
* @param commitMessage A commit message with or withot existing commit
* message trailers into which to merge the trailers
* given in the trailers parameter
*
* @param trailers Zero or more trailers to merge into the commit message
*
* @returns A commit message string where the provided trailers (if)
* any have been merged into the commit message using the
* configuration settings for trailers in the provided
* repository.
*/
export async function mergeTrailers(
repository: Repository,
commitMessage: string,
trailers: ReadonlyArray<ITrailer>,
unfold: boolean = false
) {
const args = ['interpret-trailers']
if (unfold) {
args.push('--unfold')
}
for (const trailer of trailers) {
args.push('--trailer', `${trailer.token}=${trailer.value}`)
}
const result = await git(args, repository.path, 'mergeTrailers', {
stdin: commitMessage,
})
return result.stdout
}

View file

@ -3,6 +3,10 @@ import { AppFileStatus, CommittedFileChange } from '../../models/status'
import { Repository } from '../../models/repository'
import { Commit } from '../../models/commit'
import { CommitIdentity } from '../../models/commit-identity'
import {
getTrailerSeparatorCharacters,
parseRawUnfoldedTrailers,
} from './interpret-trailers'
/**
* Map the raw status text from Git to an app-friendly value
@ -59,7 +63,9 @@ export async function getCommits(
// author name <author email> <author date>
// author date format dependent on --date arg, should be raw
'%an <%ae> %ad',
'%P', // parent SHAs
'%cn <%ce> %cd',
'%P', // parent SHAs,
'%(trailers:unfold,only)',
].join(`%x${delimiter}`)
const result = await git(
@ -89,14 +95,23 @@ export async function getCommits(
// Remove the trailing empty line
lines.splice(-1, 1)
if (lines.length === 0) {
return []
}
const trailerSeparators = await getTrailerSeparatorCharacters(repository)
const commits = lines.map(line => {
const pieces = line.split(delimiterString)
const sha = pieces[0]
const summary = pieces[1]
const body = pieces[2]
const authorIdentity = pieces[3]
const shaList = pieces[4]
const committerIdentity = pieces[4]
const shaList = pieces[5]
const parentSHAs = shaList.length ? shaList.split(' ') : []
const trailers = parseRawUnfoldedTrailers(pieces[6], trailerSeparators)
const author = CommitIdentity.parseIdentity(authorIdentity)
@ -104,7 +119,21 @@ export async function getCommits(
throw new Error(`Couldn't parse author identity ${authorIdentity}`)
}
return new Commit(sha, summary, body, author, parentSHAs)
const committer = CommitIdentity.parseIdentity(committerIdentity)
if (!committer) {
throw new Error(`Couldn't parse committer identity ${committerIdentity}`)
}
return new Commit(
sha,
summary,
body,
author,
committer,
parentSHAs,
trailers
)
})
return commits

View file

@ -44,8 +44,12 @@ export async function listSubmodules(
const [path, describeOutput] = entry.substr(42).split(/\s+/)
const describe = describeOutput.substr(1, describeOutput.length - 2)
submodules.push(new SubmoduleEntry(sha, path, describe))
// if the submodule has not been initialized, no describe output is set
// this means we don't have a submodule to work with
if (describeOutput != null) {
const describe = describeOutput.substr(1, describeOutput.length - 2)
submodules.push(new SubmoduleEntry(sha, path, describe))
}
}
return submodules

View file

@ -32,10 +32,10 @@ export function generateGravatarUrl(email: string, size: number = 200): string {
*/
export function getAvatarWithEnterpriseFallback(
avatar_url: string,
email: string,
email: string | null,
endpoint: string
): string {
if (endpoint === getDotComAPIEndpoint()) {
if (endpoint === getDotComAPIEndpoint() || email === null) {
return avatar_url
}

View file

@ -2,14 +2,11 @@
// ASCII Control chars and space, DEL, ~ ^ : ? * [ \
// | " < and > is technically a valid refname but not on Windows
// the magic sequence @{, consecutive dots, leading and trailing dot, ref ending in .lock
const invalidCharacterRegex = /[\x00-\x20\x7F~^:?*\[\\|""<>]|@{|\.\.+|^\.|\.$|\.lock$|\/$/g
const invalidCharacterRegex = /[\x00-\x20\x7F~^:?*\[\\|""<>]+|@{|\.\.+|^\.|\.$|\.lock$|\/$/g
/** Sanitize a proposed branch name by replacing illegal characters. */
export function sanitizedBranchName(name: string): string {
return name
.replace(invalidCharacterRegex, '-')
.replace(/--+/g, '-')
.replace(/^-/g, '')
return name.replace(invalidCharacterRegex, '-').replace(/^-/g, '')
}
/** Validate a branch does not contain any invalid characters */

View file

@ -1,9 +1,9 @@
import { Emitter, Disposable } from 'event-kit'
import { IDataStore, ISecureStore } from './stores'
import { getKeyForAccount } from '../auth'
import { Account } from '../../models/account'
import { fetchUser, EmailVisibility } from '../api'
import { fatalError } from '../fatal-error'
import { BaseStore } from './base-store'
/** The data-only interface for storage. */
interface IEmail {
@ -36,7 +36,7 @@ interface IAccount {
}
/** The store for logged in accounts. */
export class AccountsStore {
export class AccountsStore extends BaseStore {
private dataStore: IDataStore
private secureStore: ISecureStore
@ -45,32 +45,14 @@ export class AccountsStore {
/** A promise that will resolve when the accounts have been loaded. */
private loadingPromise: Promise<void>
private readonly emitter = new Emitter()
public constructor(dataStore: IDataStore, secureStore: ISecureStore) {
super()
this.dataStore = dataStore
this.secureStore = secureStore
this.loadingPromise = this.loadFromStore()
}
private emitUpdate() {
this.emitter.emit('did-update', {})
}
private emitError(error: Error) {
this.emitter.emit('did-error', error)
}
/** Register a function to be called when the store updates. */
public onDidUpdate(fn: () => void): Disposable {
return this.emitter.on('did-update', fn)
}
/** Register a function to be called when an error occurs. */
public onDidError(fn: (error: Error) => void): Disposable {
return this.emitter.on('did-error', fn)
}
/**
* Get the list of accounts in the cache.
*/

View file

@ -1,4 +1,3 @@
import { Emitter, Disposable } from 'event-kit'
import { ipcRenderer, remote } from 'electron'
import {
IRepositoryState,
@ -38,14 +37,9 @@ import { TipState } from '../../models/tip'
import { CloningRepository } from '../../models/cloning-repository'
import { Commit } from '../../models/commit'
import { ExternalEditor, getAvailableEditors, parse } from '../editors'
import { CloningRepositoriesStore } from './cloning-repositories-store'
import { IGitHubUser } from '../databases/github-user-database'
import { GitHubUserStore } from './github-user-store'
import { shell } from '../app-shell'
import { EmojiStore } from './emoji-store'
import { GitStore, ICommitMessage } from './git-store'
import { assertNever } from '../fatal-error'
import { IssuesStore } from './issues-store'
import { assertNever, forceUnwrap } from '../fatal-error'
import { BackgroundFetcher } from './helpers/background-fetcher'
import { formatCommitMessage } from '../format-commit-message'
import { AppMenu, IMenu } from '../../models/app-menu'
@ -56,7 +50,6 @@ import {
import { merge } from '../merge'
import { getAppPath } from '../../ui/lib/app-proxy'
import { StatsStore, ILaunchStats } from '../stats'
import { SignInStore } from './sign-in-store'
import { hasShownWelcomeFlow, markWelcomeFlowComplete } from '../welcome'
import { WindowState, getWindowState } from '../window-state'
import { fatalError } from '../fatal-error'
@ -80,11 +73,26 @@ import {
getDefaultRemote,
formatAsLocalRef,
getMergeBase,
getRemotes,
ITrailer,
} from '../git'
import { launchExternalEditor } from '../editors'
import { AccountsStore } from './accounts-store'
import { RepositoriesStore } from './repositories-store'
import { TypedBaseStore } from './base-store'
import {
AccountsStore,
RepositoriesStore,
RepositorySettingsStore,
PullRequestStore,
SignInStore,
IssuesStore,
GitStore,
ICommitMessage,
EmojiStore,
GitHubUserStore,
CloningRepositoriesStore,
ForkedRemotePrefix,
} from '../stores'
import { validatedRepositoryPath } from './helpers/validated-repository-path'
import { IGitAccount } from '../git/authentication'
import { getGenericHostname, getGenericUsername } from '../generic-git-auth'
@ -105,11 +113,12 @@ import {
import { CloneRepositoryTab } from '../../models/clone-repository-tab'
import { getAccountForRepository } from '../get-account-for-repository'
import { BranchesTab } from '../../models/branches-tab'
import { PullRequestStore } from './pull-request-store'
import { Owner } from '../../models/owner'
import { PullRequest } from '../../models/pull-request'
import { PullRequestUpdater } from './helpers/pull-request-updater'
import * as QueryString from 'querystring'
import { IRemote } from '../../models/remote'
import { IAuthor } from '../../models/author'
/**
* Enum used by fetch to determine if
@ -143,9 +152,7 @@ const shellKey = 'shell'
// background fetching should not occur more than once every two minutes
const BackgroundFetchMinimumInterval = 2 * 60 * 1000
export class AppStore {
private emitter = new Emitter()
export class AppStore extends TypedBaseStore<IAppState> {
private accounts: ReadonlyArray<Account> = new Array<Account>()
private repositories: ReadonlyArray<Repository> = new Array<Repository>()
@ -159,35 +166,32 @@ export class AppStore {
private repositoryState = new Map<string, IRepositoryState>()
private showWelcomeFlow = false
private currentPopup: Popup | null = null
private currentFoldout: Foldout | null = null
private errors: ReadonlyArray<Error> = new Array<Error>()
private emitQueued = false
/** GitStores keyed by their hash. */
private readonly gitStores = new Map<string, GitStore>()
private readonly repositorySettingsStores = new Map<
string,
RepositorySettingsStore
>()
public readonly gitHubUserStore: GitHubUserStore
private readonly cloningRepositoriesStore: CloningRepositoriesStore
private readonly emojiStore: EmojiStore
private readonly _issuesStore: IssuesStore
private readonly signInStore: SignInStore
private readonly accountsStore: AccountsStore
private readonly repositoriesStore: RepositoriesStore
private readonly statsStore: StatsStore
private readonly pullRequestStore: PullRequestStore
/** The issues store for all repositories. */
public get issuesStore(): IssuesStore {
return this._issuesStore
}
/** GitStores keyed by their hash. */
private readonly gitStores = new Map<string, GitStore>()
private readonly signInStore: SignInStore
private readonly accountsStore: AccountsStore
private readonly repositoriesStore: RepositoriesStore
/**
* The Application menu as an AppMenu instance or null if
* the main process has not yet provided the renderer with
@ -224,8 +228,6 @@ export class AppStore {
/** The current repository filter text */
private repositoryFilterText: string = ''
private readonly statsStore: StatsStore
/** The function to resolve the current Open in Desktop flow. */
private resolveOpenInDesktop:
| ((repository: Repository | null) => void)
@ -235,8 +237,6 @@ export class AppStore {
private selectedBranchesTab = BranchesTab.Branches
private pullRequestStore: PullRequestStore
public constructor(
gitHubUserStore: GitHubUserStore,
cloningRepositoriesStore: CloningRepositoriesStore,
@ -248,6 +248,8 @@ export class AppStore {
repositoriesStore: RepositoriesStore,
pullRequestStore: PullRequestStore
) {
super()
this.gitHubUserStore = gitHubUserStore
this.cloningRepositoriesStore = cloningRepositoriesStore
this.emojiStore = emojiStore
@ -262,6 +264,16 @@ export class AppStore {
const window = remote.getCurrentWindow()
this.windowState = getWindowState(window)
window.webContents.getZoomFactor(factor => {
this.onWindowZoomFactorChanged(factor)
})
this.wireupIpcEventHandlers(window)
this.wireupStoreEventHandlers()
getAppMenu()
}
private wireupIpcEventHandlers(window: Electron.BrowserWindow) {
ipcRenderer.on(
'window-state-changed',
(event: Electron.IpcMessageEvent, args: any[]) => {
@ -270,10 +282,6 @@ export class AppStore {
}
)
window.webContents.getZoomFactor(factor => {
this.onWindowZoomFactorChanged(factor)
})
ipcRenderer.on('zoom-factor-changed', (event: any, zoomFactor: number) => {
this.onWindowZoomFactorChanged(zoomFactor)
})
@ -284,9 +292,9 @@ export class AppStore {
this.setAppMenu(menu)
}
)
}
getAppMenu()
private wireupStoreEventHandlers() {
this.gitHubUserStore.onDidUpdate(() => {
this.emitUpdate()
})
@ -301,22 +309,22 @@ export class AppStore {
this.signInStore.onDidUpdate(() => this.emitUpdate())
this.signInStore.onDidError(error => this.emitError(error))
accountsStore.onDidUpdate(async () => {
this.accountsStore.onDidUpdate(async () => {
const accounts = await this.accountsStore.getAll()
this.accounts = accounts
this.emitUpdate()
})
accountsStore.onDidError(error => this.emitError(error))
this.accountsStore.onDidError(error => this.emitError(error))
repositoriesStore.onDidUpdate(async () => {
this.repositoriesStore.onDidUpdate(async () => {
const repositories = await this.repositoriesStore.getAll()
this.repositories = repositories
this.updateRepositorySelectionAfterRepositoriesChanged()
this.emitUpdate()
})
pullRequestStore.onDidError(error => this.emitError(error))
pullRequestStore.onDidUpdate(gitHubRepository =>
this.pullRequestStore.onDidError(error => this.emitError(error))
this.pullRequestStore.onDidUpdate(gitHubRepository =>
this.onPullRequestStoreUpdated(gitHubRepository)
)
}
@ -327,7 +335,7 @@ export class AppStore {
this.emojiStore.read(rootDir).then(() => this.emitUpdate())
}
private emitUpdate() {
protected emitUpdate() {
// If the window is hidden then we won't get an animation frame, but there
// may still be work we wanna do in response to the state change. So
// immediately emit the update.
@ -351,23 +359,10 @@ export class AppStore {
this.emitQueued = false
const state = this.getState()
this.emitter.emit('did-update', state)
super.emitUpdate(state)
updateMenuState(state, this.appMenu)
}
public onDidUpdate(fn: (state: IAppState) => void): Disposable {
return this.emitter.on('did-update', fn)
}
private emitError(error: Error) {
this.emitter.emit('did-error', error)
}
/** Register a listener for when an error occurs. */
public onDidError(fn: (error: Error) => void): Disposable {
return this.emitter.on('did-error', fn)
}
/**
* Called when we have reason to suspect that the zoom factor
* has changed. Note that this doesn't necessarily mean that it
@ -402,6 +397,8 @@ export class AppStore {
diff: null,
contextualCommitMessage: null,
commitMessage: null,
coAuthors: [],
showCoAuthoredBy: false,
},
selectedSection: RepositorySection.Changes,
branchesState: {
@ -569,6 +566,8 @@ export class AppStore {
this.updateChangesState(repository, state => ({
commitMessage: gitStore.commitMessage,
contextualCommitMessage: gitStore.contextualCommitMessage,
showCoAuthoredBy: gitStore.showCoAuthoredBy,
coAuthors: gitStore.coAuthors,
}))
this.updateRepositoryState(repository, state => ({
@ -604,6 +603,30 @@ export class AppStore {
return gitStore
}
private removeRepositorySettingsStore(repository: Repository) {
const key = repository.hash
if (this.repositorySettingsStores.has(key)) {
this.repositorySettingsStores.delete(key)
}
}
private getRepositorySettingsStore(
repository: Repository
): RepositorySettingsStore {
let store = this.repositorySettingsStores.get(repository.hash)
if (store == null) {
store = new RepositorySettingsStore(repository)
store.onDidError(error => this.emitError(error))
this.repositorySettingsStores.set(repository.hash, store)
}
return store
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _loadHistory(repository: Repository): Promise<void> {
const gitStore = this.getGitStore(repository)
@ -788,6 +811,7 @@ export class AppStore {
// ensures we don't accidentally run any Git operations against the
// wrong location if the user then relocates the `.git` folder elsewhere
this.removeGitStore(repository)
this.removeRepositorySettingsStore(repository)
return Promise.resolve(null)
}
@ -876,7 +900,7 @@ export class AppStore {
}
const updater = new PullRequestUpdater(
repository.gitHubRepository,
repository,
account,
this.pullRequestStore
)
@ -1315,7 +1339,9 @@ export class AppStore {
/** This shouldn't be called directly. See `Dispatcher`. */
public async _commitIncludedChanges(
repository: Repository,
message: ICommitMessage
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
): Promise<boolean> {
const state = this.getRepositoryState(repository)
const files = state.changesState.workingDirectory.files
@ -1326,9 +1352,14 @@ export class AppStore {
const gitStore = this.getGitStore(repository)
const result = await this.isCommitting(repository, () => {
return gitStore.performFailableOperation(() => {
const commitMessage = formatCommitMessage(message)
return createCommit(repository, commitMessage, selectedFiles)
return gitStore.performFailableOperation(async () => {
const message = await formatCommitMessage(
repository,
summary,
description,
trailers
)
return createCommit(repository, message, selectedFiles)
})
})
@ -1927,8 +1958,9 @@ export class AppStore {
const state = this.getRepositoryState(repository)
const currentPR = state.branchesState.currentPullRequest
const gitHubRepository = repository.gitHubRepository
if (currentPR && gitHubRepository) {
prUpdater.didPushPullRequest(gitHubRepository, currentPR)
prUpdater.didPushPullRequest(currentPR)
}
}
}
@ -2485,14 +2517,14 @@ export class AppStore {
repository: Repository,
text: string
): Promise<void> {
const gitStore = this.getGitStore(repository)
return gitStore.saveGitIgnore(text)
const repositorySettingsStore = this.getRepositorySettingsStore(repository)
return repositorySettingsStore.saveGitIgnore(text)
}
/** This shouldn't be called directly. See `Dispatcher`. */
public async _readGitIgnore(repository: Repository): Promise<string | null> {
const gitStore = this.getGitStore(repository)
return gitStore.readGitIgnore()
const repositorySettingsStore = this.getRepositorySettingsStore(repository)
return repositorySettingsStore.readGitIgnore()
}
/** Has the user opted out of stats reporting? */
@ -2569,8 +2601,9 @@ export class AppStore {
}
public async _ignore(repository: Repository, pattern: string): Promise<void> {
const gitStore = this.getGitStore(repository)
await gitStore.ignore(pattern)
const repoSettingsStore = this.getRepositorySettingsStore(repository)
await repoSettingsStore.ignore(pattern)
return this._refreshRepository(repository)
}
@ -2996,7 +3029,8 @@ export class AppStore {
public async _refreshPullRequests(repository: Repository): Promise<void> {
const gitHubRepository = repository.gitHubRepository
if (!gitHubRepository) {
if (gitHubRepository == null) {
return
}
@ -3004,12 +3038,13 @@ export class AppStore {
this.accounts,
gitHubRepository.endpoint
)
if (!account) {
if (account == null) {
return
}
await this.pullRequestStore.refreshPullRequests(gitHubRepository, account)
return this.updateMenuItemLabels(repository)
await this.pullRequestStore.refreshPullRequests(repository, account)
this.updateMenuItemLabels(repository)
}
private async onPullRequestStoreUpdated(gitHubRepository: GitHubRepository) {
@ -3030,29 +3065,22 @@ export class AppStore {
}
this.updateBranchesState(repository, state => {
let currentPullRequest = null
if (state.tip.kind === TipState.Valid) {
currentPullRequest = this.findAssociatedPullRequest(
state.tip.branch,
pullRequests,
gitHubRepository
)
}
return {
openPullRequests: pullRequests,
currentPullRequest,
isLoadingPullRequests: isLoading,
}
})
this._updateCurrentPullRequest(repository)
this.emitUpdate()
}
private findAssociatedPullRequest(
branch: Branch,
pullRequests: ReadonlyArray<PullRequest>,
gitHubRepository: GitHubRepository
gitHubRepository: GitHubRepository,
remote: IRemote
): PullRequest | null {
const upstream = branch.upstreamWithoutRemote
if (!upstream) {
@ -3063,8 +3091,7 @@ export class AppStore {
if (
pr.head.ref === upstream &&
pr.head.gitHubRepository &&
// TODO: This doesn't work for when I've checked out a PR from a fork.
pr.head.gitHubRepository.cloneURL === gitHubRepository.cloneURL
pr.head.gitHubRepository.cloneURL === remote.url
) {
return pr
}
@ -3083,11 +3110,14 @@ export class AppStore {
this.updateBranchesState(repository, state => {
let currentPullRequest: PullRequest | null = null
if (state.tip.kind === TipState.Valid) {
const remote = this.getRepositoryState(repository).remote
if (state.tip.kind === TipState.Valid && remote) {
currentPullRequest = this.findAssociatedPullRequest(
state.tip.branch,
state.openPullRequests,
gitHubRepository
gitHubRepository,
remote
)
}
@ -3153,4 +3183,98 @@ export class AppStore {
return gitStore.addUpstreamRemoteIfNeeded()
}
public async _checkoutPullRequest(
repository: Repository,
pullRequest: PullRequest
): Promise<void> {
const gitHubRepository = forceUnwrap(
`Cannot checkout a PR if the repository doesn't have a GitHub repository`,
repository.gitHubRepository
)
const head = pullRequest.head
const isRefInThisRepo =
head.gitHubRepository &&
head.gitHubRepository.cloneURL === gitHubRepository.cloneURL
if (isRefInThisRepo) {
// We need to fetch FIRST because someone may have created a PR since the last fetch
await this._fetch(repository, FetchType.UserInitiatedTask)
await this._checkoutBranch(repository, head.ref)
} else if (head.gitHubRepository != null) {
const cloneURL = forceUnwrap(
"This pull request's clone URL is not populated but should be",
head.gitHubRepository.cloneURL
)
const remoteName = forkPullRequestRemoteName(
head.gitHubRepository.owner.login
)
const remotes = await getRemotes(repository)
const remote = remotes.find(r => r.name === remoteName)
if (remote == null) {
await addRemote(repository, remoteName, cloneURL)
} else if (remote.url !== cloneURL) {
const error = new Error(
`Expected PR remote ${remoteName} url to be ${cloneURL} got ${
remote.url
}.`
)
log.error(error.message)
this.emitError(error)
}
const gitStore = this.getGitStore(repository)
await this.withAuthenticatingUser(repository, async (repo, account) => {
await gitStore.fetchRemote(account, remoteName, false)
})
const localBranchName = `pr/${pullRequest.number}`
const doesBranchExist =
gitStore.allBranches.find(branch => branch.name === localBranchName) !=
null
if (!doesBranchExist) {
await this._createBranch(
repository,
localBranchName,
`${remoteName}/${head.ref}`
)
}
await this._checkoutBranch(repository, localBranchName)
}
}
/**
* Set whether the user has chosen to hide or show the
* co-authors field in the commit message component
*/
public _setShowCoAuthoredBy(
repository: Repository,
showCoAuthoredBy: boolean
) {
this.getGitStore(repository).setShowCoAuthoredBy(showCoAuthoredBy)
return Promise.resolve()
}
/**
* Update the per-repository co-authors list
*
* @param repository Co-author settings are per-repository
* @param coAuthors Zero or more authors
*/
public _setCoAuthors(
repository: Repository,
coAuthors: ReadonlyArray<IAuthor>
) {
this.getGitStore(repository).setCoAuthors(coAuthors)
return Promise.resolve()
}
}
function forkPullRequestRemoteName(remoteName: string) {
return `${ForkedRemotePrefix}${remoteName}`
}

View file

@ -0,0 +1,55 @@
import { Emitter, Disposable } from 'event-kit'
export abstract class BaseStore {
protected readonly emitter = new Emitter()
protected emitUpdate() {
this.emitter.emit('did-update', {})
}
protected emitError(error: Error) {
this.emitter.emit('did-error', error)
}
/** Register a function to be called when the store updates. */
public onDidUpdate(fn: () => void): Disposable {
return this.emitter.on('did-update', fn)
}
/**
* Register an event handler which will be invoked whenever
* an unexpected error occurs during the sign-in process. Note
* that some error are handled in the flow and passed along in
* the sign in state for inline presentation to the user.
*/
public onDidError(fn: (e: Error) => void): Disposable {
return this.emitter.on('did-error', fn)
}
}
export class TypedBaseStore<T> {
protected readonly emitter = new Emitter()
protected emitUpdate(data: T) {
this.emitter.emit('did-update', data)
}
protected emitError(error: Error) {
this.emitter.emit('did-error', error)
}
/** Register a function to be called when the store updates. */
public onDidUpdate(fn: (data: T) => void): Disposable {
return this.emitter.on('did-update', fn)
}
/**
* Register an event handler which will be invoked whenever
* an unexpected error occurs during the sign-in process. Note
* that some error are handled in the flow and passed along in
* the sign in state for inline presentation to the user.
*/
public onDidError(fn: (e: Error) => void): Disposable {
return this.emitter.on('did-error', fn)
}
}

View file

@ -1,36 +1,15 @@
import { Emitter, Disposable } from 'event-kit'
import { CloningRepository } from '../../models/cloning-repository'
import { clone as cloneRepo, CloneOptions } from '../git'
import { ICloneProgress } from '../app-state'
import { RetryAction, RetryActionType } from '../retry-actions'
import { ErrorWithMetadata } from '../error-with-metadata'
import { BaseStore } from './base-store'
/** The store in charge of repository currently being cloned. */
export class CloningRepositoriesStore {
private readonly emitter = new Emitter()
export class CloningRepositoriesStore extends BaseStore {
private readonly _repositories = new Array<CloningRepository>()
private readonly stateByID = new Map<number, ICloneProgress>()
private emitUpdate() {
this.emitter.emit('did-update', {})
}
/** Register a function to be called when the store updates. */
public onDidUpdate(fn: () => void): Disposable {
return this.emitter.on('did-update', fn)
}
private emitError(error: Error) {
this.emitter.emit('did-error', error)
}
/** Register a function to be called when an error occurs. */
public onDidError(fn: (error: Error) => void): Disposable {
return this.emitter.on('did-error', fn)
}
/**
* Clone the repository at the URL to the path.
*

View file

@ -1,6 +1,6 @@
import * as Fs from 'fs'
import * as Path from 'path'
import { Emitter, Disposable } from 'event-kit'
import { Disposable } from 'event-kit'
import { Repository } from '../../models/repository'
import { WorkingDirectoryFileChange, AppFileStatus } from '../../models/status'
import { Branch, BranchType } from '../../models/branch'
@ -38,13 +38,16 @@ import {
checkoutIndex,
checkoutPaths,
resetPaths,
getConfigValue,
revertCommit,
unstageAllFiles,
openMergeTool,
addRemote,
listSubmodules,
resetSubmodulePaths,
parseTrailers,
mergeTrailers,
getTrailerSeparatorCharacters,
parseSingleUnfoldedTrailer,
} from '../git'
import { IGitAccount } from '../git/authentication'
import { RetryAction, RetryActionType } from '../retry-actions'
@ -54,6 +57,10 @@ import {
findUpstreamRemote,
UpstreamRemoteName,
} from './helpers/find-upstream-remote'
import { IAuthor } from '../../models/author'
import { formatCommitMessage } from '../format-commit-message'
import { GitAuthor } from '../../models/git-author'
import { BaseStore } from './base-store'
/** The number of commits to load from history per batch. */
const CommitBatchSize = 100
@ -70,9 +77,7 @@ export interface ICommitMessage {
}
/** The store for a repository's git data. */
export class GitStore {
private readonly emitter = new Emitter()
export class GitStore extends BaseStore {
private readonly shell: IAppShell
/** The commits keyed by their SHA. */
@ -94,9 +99,13 @@ export class GitStore {
private _localCommitSHAs: ReadonlyArray<string> = []
private _commitMessage: ICommitMessage | null
private _commitMessage: ICommitMessage | null = null
private _contextualCommitMessage: ICommitMessage | null
private _contextualCommitMessage: ICommitMessage | null = null
private _showCoAuthoredBy: boolean = false
private _coAuthors: ReadonlyArray<IAuthor> = []
private _aheadBehind: IAheadBehind | null = null
@ -107,27 +116,16 @@ export class GitStore {
private _lastFetched: Date | null = null
public constructor(repository: Repository, shell: IAppShell) {
super()
this.repository = repository
this.shell = shell
}
private emitUpdate() {
this.emitter.emit('did-update', {})
}
private emitNewCommitsLoaded(commits: ReadonlyArray<Commit>) {
this.emitter.emit('did-load-new-commits', commits)
}
private emitError(error: Error) {
this.emitter.emit('did-error', error)
}
/** Register a function to be called when the store updates. */
public onDidUpdate(fn: () => void): Disposable {
return this.emitter.on('did-update', fn)
}
/** Register a function to be called when the store loads new commits. */
public onDidLoadNewCommits(
fn: (commits: ReadonlyArray<Commit>) => void
@ -135,11 +133,6 @@ export class GitStore {
return this.emitter.on('did-load-new-commits', fn)
}
/** Register a function to be called when an error occurs. */
public onDidError(fn: (error: Error) => void): Disposable {
return this.emitter.on('did-error', fn)
}
/**
* Reconcile the local history view with the repository state
* after a pull has completed, to include merged remote commits.
@ -494,25 +487,175 @@ export class GitStore {
public async undoCommit(commit: Commit): Promise<void> {
// For an initial commit, just delete the reference but leave HEAD. This
// will make the branch unborn again.
let success: true | undefined = undefined
if (commit.parentSHAs.length === 0) {
success = await this.performFailableOperation(() =>
this.undoFirstCommit(this.repository)
)
} else {
success = await this.performFailableOperation(() =>
reset(this.repository, GitResetMode.Mixed, commit.parentSHAs[0])
)
const success = await this.performFailableOperation(
() =>
commit.parentSHAs.length === 0
? this.undoFirstCommit(this.repository)
: reset(this.repository, GitResetMode.Mixed, commit.parentSHAs[0])
)
if (success === undefined) {
return
}
if (success) {
// 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,
// let's just fall back to the old way of restoring the
// entire message
if (this.repository.gitHubRepository) {
try {
await this.loadCommitAndCoAuthors(commit)
this.emitUpdate()
return
} catch (e) {
log.error('Failed to restore commit and co-authors, falling back', e)
}
}
this._contextualCommitMessage = {
summary: commit.summary,
description: commit.body,
}
this.emitUpdate()
}
/**
* Attempt to restore both the commit message and any co-authors
* in it after an undo operation.
*
* This is a deceivingly simple task which complicated by the
* us wanting to follow the heuristics of Git when finding, and
* parsing trailers.
*/
private async loadCommitAndCoAuthors(commit: Commit) {
const repository = this.repository
// git-interpret-trailers is really only made for working
// with full commit messages so let's start with that
const message = await formatCommitMessage(
repository,
commit.summary,
commit.body,
[]
)
// Next we extract any co-authored-by trailers we
// can find. We use interpret-trailers for this
const foundTrailers = await parseTrailers(repository, message)
const coAuthorTrailers = foundTrailers.filter(
t => t.token.toLowerCase() === 'co-authored-by'
)
// This is the happy path, nothing more for us to do
if (coAuthorTrailers.length === 0) {
this._contextualCommitMessage = {
summary: commit.summary,
description: commit.body,
}
return
}
this.emitUpdate()
// call interpret-trailers --unfold so that we can be sure each
// trailer sits on a single line
const unfolded = await mergeTrailers(repository, message, [], true)
const lines = unfolded.split('\n')
// We don't know (I mean, we're fairly sure) what the separator character
// used for the trailer is so we call out to git to get all possible
// characters. We'll need them in a bit
const separators = await getTrailerSeparatorCharacters(this.repository)
// We know that what we've got now is well formed so we can capture the leading
// token, followed by the separator char and a single space, followed by the
// value
const coAuthorRe = /^co-authored-by(.)\s(.*)/i
const extractedTrailers = []
// Iterate backwards from the unfolded message and look for trailers that we've
// already seen when calling parseTrailers earlier.
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]
const match = coAuthorRe.exec(line)
// Not a trailer line, we're sure of that
if (!match || separators.indexOf(match[1]) === -1) {
continue
}
const trailer = parseSingleUnfoldedTrailer(line, match[1])
if (!trailer) {
continue
}
// We already know that the key is Co-Authored-By so we only
// need to compare by value. Let's see if we can find the thing
// that we believe to be a trailer among what interpret-trailers
// --parse told us was a trailer. This step is a bit redundant
// but it ensure we match exactly with what Git thinks is a trailer
const foundTrailerIx = coAuthorTrailers.findIndex(
t => t.value === trailer.value
)
if (foundTrailerIx === -1) {
continue
}
// We're running backwards
extractedTrailers.unshift(coAuthorTrailers[foundTrailerIx])
// Remove the trailer that matched so that we can be sure
// we're not picking it up again
coAuthorTrailers.splice(foundTrailerIx, 1)
// This line was a co-author trailer so we'll remove it to
// make sure it doesn't end up in the restored commit body
lines.splice(i, 1)
}
// Get rid of the summary/title
lines.splice(0, 2)
const newBody = lines.join('\n').trim()
this._contextualCommitMessage = {
summary: commit.summary,
description: newBody,
}
const extractedAuthors = extractedTrailers.map(t =>
GitAuthor.parse(t.value)
)
const newAuthors = new Array<IAuthor>()
// Last step, phew! The most likely scenario where we
// get called is when someone has just made a commit and
// either forgot to add a co-author or forgot to remove
// someone so chances are high that we already have a
// co-author which includes a username. If we don't we'll
// add it without a username which is fine as well
for (let i = 0; i < extractedAuthors.length; i++) {
const extractedAuthor = extractedAuthors[i]
// If GitAuthor failed to parse
if (extractedAuthor === null) {
continue
}
const { name, email } = extractedAuthor
const existing = this.coAuthors.find(
a => a.name === name && a.email === email && a.username !== null
)
newAuthors.push(existing || { name, email, username: null })
}
this._coAuthors = newAuthors
if (this._coAuthors.length > 0 && this._showCoAuthoredBy === false) {
this._showCoAuthoredBy = true
}
}
/**
@ -553,6 +696,23 @@ export class GitStore {
return this._contextualCommitMessage
}
/**
* Gets a value indicating whether the user has chosen to
* hide or show the co-authors field in the commit message
* component
*/
public get showCoAuthoredBy(): boolean {
return this._showCoAuthoredBy
}
/**
* Gets a list of co-authors to use when crafting the next
* commit.
*/
public get coAuthors(): ReadonlyArray<IAuthor> {
return this._coAuthors
}
/**
* Fetch the default and upstream remote, using the given account for
* authentication.
@ -734,18 +894,21 @@ export class GitStore {
*/
public async loadCurrentRemote(): Promise<void> {
const tip = this.tip
if (tip.kind === TipState.Valid) {
const branch = tip.branch
if (branch.remote) {
if (branch.remote != null) {
const allRemotes = await getRemotes(this.repository)
const foundRemote = allRemotes.find(r => r.name === branch.remote)
if (foundRemote) {
this._remote = foundRemote
}
}
}
if (!this._remote) {
if (this._remote == null) {
this._remote = await getDefaultRemote(this.repository)
}
@ -832,6 +995,29 @@ export class GitStore {
return this._upstream
}
/**
* Set whether the user has chosen to hide or show the
* co-authors field in the commit message component
*/
public setShowCoAuthoredBy(showCoAuthoredBy: boolean) {
this._showCoAuthoredBy = showCoAuthoredBy
// Clear co-authors when hiding
if (!showCoAuthoredBy) {
this._coAuthors = []
}
this.emitUpdate()
}
/**
* Update co-authors list
*
* @param coAuthors Zero or more authors
*/
public setCoAuthors(coAuthors: ReadonlyArray<IAuthor>) {
this._coAuthors = coAuthors
this.emitUpdate()
}
public setCommitMessage(message: ICommitMessage | null): Promise<void> {
this._commitMessage = message
this.emitUpdate()
@ -879,66 +1065,6 @@ export class GitStore {
this.emitUpdate()
}
/**
* Read the contents of the repository .gitignore.
*
* Returns a promise which will either be rejected or resolved
* with the contents of the file. If there's no .gitignore file
* in the repository root the promise will resolve with null.
*/
public async readGitIgnore(): Promise<string | null> {
const repository = this.repository
const ignorePath = Path.join(repository.path, '.gitignore')
return new Promise<string | null>((resolve, reject) => {
Fs.readFile(ignorePath, 'utf8', (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
resolve(null)
} else {
reject(err)
}
} else {
resolve(data)
}
})
})
}
/**
* Persist the given content to the repository root .gitignore.
*
* If the repository root doesn't contain a .gitignore file one
* will be created, otherwise the current file will be overwritten.
*/
public async saveGitIgnore(text: string): Promise<void> {
const repository = this.repository
const ignorePath = Path.join(repository.path, '.gitignore')
const fileContents = await formatGitIgnoreContents(text, repository)
return new Promise<void>((resolve, reject) => {
Fs.writeFile(ignorePath, fileContents, err => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
/** Ignore the given path or pattern. */
public async ignore(pattern: string): Promise<void> {
const text = (await this.readGitIgnore()) || ''
const repository = this.repository
const currentContents = await formatGitIgnoreContents(text, repository)
const newText = await formatGitIgnoreContents(
`${currentContents}${pattern}`,
repository
)
await this.saveGitIgnore(newText)
}
public async discardChanges(
files: ReadonlyArray<WorkingDirectoryFileChange>
): Promise<void> {
@ -1122,46 +1248,3 @@ export class GitStore {
)
}
}
/**
* Format the gitignore text based on the current config settings.
*
* This setting looks at core.autocrlf to decide which line endings to use
* when updating the .gitignore file.
*
* If core.safecrlf is also set, adding this file to the index may cause
* Git to return a non-zero exit code, leaving the working directory in a
* confusing state for the user. So we should reformat the file in that
* case.
*
* @param text The text to format.
* @param repository The repository associated with the gitignore file.
*/
async function formatGitIgnoreContents(
text: string,
repository: Repository
): Promise<string> {
const autocrlf = await getConfigValue(repository, 'core.autocrlf')
const safecrlf = await getConfigValue(repository, 'core.safecrlf')
return new Promise<string>((resolve, reject) => {
if (autocrlf === 'true' && safecrlf === 'true') {
// based off https://stackoverflow.com/a/141069/1363815
const normalizedText = text.replace(/\r\n|\n\r|\n|\r/g, '\r\n')
resolve(normalizedText)
return
}
if (text.endsWith('\n')) {
resolve(text)
return
}
const linesEndInCRLF = autocrlf === 'true'
if (linesEndInCRLF) {
resolve(`${text}\n`)
} else {
resolve(`${text}\r\n`)
}
})
}

View file

@ -1,4 +1,3 @@
import { Emitter, Disposable } from 'event-kit'
import { Repository } from '../../models/repository'
import { Account } from '../../models/account'
import { GitHubRepository } from '../../models/github-repository'
@ -10,14 +9,14 @@ import {
import { getAvatarWithEnterpriseFallback } from '../gravatar'
import { fatalError } from '../fatal-error'
import { compare } from '../compare'
import { BaseStore } from './base-store'
/**
* The store for GitHub users. This is used to match commit authors to GitHub
* users and avatars.
*/
export class GitHubUserStore {
private readonly emitter = new Emitter()
export class GitHubUserStore extends BaseStore {
private readonly requestsInFlight = new Set<string>()
/** The outer map is keyed by the endpoint, the inner map is keyed by email. */
@ -35,18 +34,11 @@ export class GitHubUserStore {
private readonly mentionablesEtags = new Map<number, string>()
public constructor(database: GitHubUserDatabase) {
super()
this.database = database
}
private emitUpdate() {
this.emitter.emit('did-update', {})
}
/** Register a function to be called when the store updates. */
public onDidUpdate(fn: () => void): Disposable {
return this.emitter.on('did-update', fn)
}
private getUsersForEndpoint(
endpoint: string
): Map<string, IGitHubUser> | null {
@ -63,6 +55,58 @@ export class GitHubUserStore {
return this.getUsersForEndpoint(endpoint)
}
/**
* Retrieve a public user profile based on the user login.
*
* If the user is already cached no additional API requests
* will be made. If the user isn't in the cache but found in
* the API it will be persisted to the database and the
* intermediate cache.
*
* @param account The account to use when querying the API
* for information about the user
* @param login The login (i.e. handle) of the user
*/
public async getByLogin(
account: Account,
login: string
): Promise<IGitHubUser | null> {
const existing = await this.database.users
.where('[endpoint+login]')
.equals([account.endpoint, login])
.first()
if (existing) {
return existing
}
const api = API.fromAccount(account)
const apiUser = await api.fetchUser(login).catch(e => null)
if (!apiUser || apiUser.type !== 'User') {
return null
}
const avatarURL = getAvatarWithEnterpriseFallback(
apiUser.avatar_url,
apiUser.email,
account.endpoint
)
const user: IGitHubUser = {
avatarURL,
email: apiUser.email || '',
endpoint: account.endpoint,
name: apiUser.name || apiUser.login,
login: apiUser.login,
}
// We don't overwrite email addresses since we might not get one from this
// endpoint, but we could already have one from looking up a commit
// specifically.
return await this.cacheUser(user, false)
}
/** Update the mentionable users for the repository. */
public async updateMentionables(
repository: GitHubRepository,
@ -219,7 +263,7 @@ export class GitHubUserStore {
avatarURL,
login: apiCommit.author.login,
endpoint: account.endpoint,
name: apiCommit.author.name,
name: apiCommit.author.name || apiCommit.author.login,
}
}
}
@ -236,7 +280,7 @@ export class GitHubUserStore {
login: matchingUser.login,
avatarURL,
endpoint: account.endpoint,
name: matchingUser.name,
name: matchingUser.name || matchingUser.login,
}
}
@ -256,7 +300,12 @@ export class GitHubUserStore {
this.usersByEndpoint.set(user.endpoint, userMap)
}
userMap.set(user.email, user)
// We still store unknown emails as empty strings,
// inserting that into cache would just create a
// race condition of whoever gets added last
if (user.email.length > 0) {
userMap.set(user.email, user)
}
const addedUser = await this.database.transaction(
'rw',
@ -328,7 +377,7 @@ export class GitHubUserStore {
}
/**
* Pune the mentionable associations by removing any association that isn't in
* Prune the mentionable associations by removing any association that isn't in
* the given array of users.
*/
private async pruneRemovedMentionables(
@ -408,33 +457,52 @@ export class GitHubUserStore {
return users
}
/** Get the mentionable users which match the text in some way. */
/**
* Get the mentionable users which match the text in some way.
*
* Hit results are ordered by how close in the search string
* they matched. Search strings start with username and are followed
* by real name. Only the first substring hit is considered
*
* @param text A string to use when looking for a matching
* user. A user is considered a hit if this text
* matches any subtext of the username or real name
*
* @param maxHits The maximum number of hits to return.
*/
public async getMentionableUsersMatching(
repository: GitHubRepository,
text: string
text: string,
maxHits: number = 100
): Promise<ReadonlyArray<IGitHubUser>> {
const users = await this.getMentionableUsers(repository)
const MaxScore = 1
const score = (u: IGitHubUser) => {
const login = u.login
if (login && login.toLowerCase().startsWith(text.toLowerCase())) {
return MaxScore
}
const hits = []
const needle = text.toLowerCase()
// `name` shouldn't even be `undefined` going forward, but older versions
// of the user cache didn't persist `name`. The `GitHubUserStore` will fix
// that, but autocompletions could be requested before that happens. So we
// need to check here even though the type says its superfluous.
const name = u.name
if (name && name.toLowerCase().includes(text.toLowerCase())) {
return MaxScore - 0.1
}
// Simple substring comparison on login and real name
for (let i = 0; i < users.length && hits.length < maxHits; i++) {
const user = users[i]
const ix = `${user.login} ${user.name}`
.trim()
.toLowerCase()
.indexOf(needle)
return 0
if (ix >= 0) {
hits.push({ user, ix })
}
}
return users.filter(u => score(u) > 0).sort((a, b) => score(b) - score(a))
// Sort hits primarily based on how early in the text the match
// was found and then secondarily using alphabetic order. Ideally
// we'd use the GitHub user id in order to match dotcom behavior
// but sadly we don't have it handy here. The id property on IGitHubUser
// refers to our internal database id.
return hits
.sort(
(x, y) => compare(x.ix, y.ix) || compare(x.user.login, y.user.login)
)
.map(h => h.user)
}
}

View file

@ -1,8 +1,8 @@
import { PullRequestStore } from '../pull-request-store'
import { Account } from '../../../models/account'
import { fatalError } from '../../fatal-error'
import { fatalError, forceUnwrap } from '../../fatal-error'
import { PullRequest } from '../../../models/pull-request'
import { GitHubRepository } from '../../../models/github-repository'
import { Repository } from '../../../models/repository'
//** Interval to check for pull requests */
const PullRequestInterval = 1000 * 60 * 10
@ -24,7 +24,7 @@ enum TimeoutHandles {
* and status info from GitHub.
*/
export class PullRequestUpdater {
private readonly repository: GitHubRepository
private readonly repository: Repository
private readonly account: Account
private readonly store: PullRequestStore
@ -34,7 +34,7 @@ export class PullRequestUpdater {
private currentPullRequests: ReadonlyArray<PullRequest> = []
public constructor(
repository: GitHubRepository,
repository: Repository,
account: Account,
pullRequestStore: PullRequestStore
) {
@ -45,6 +45,11 @@ export class PullRequestUpdater {
/** Starts the updater */
public start() {
const githubRepo = forceUnwrap(
'Can only refresh pull requests for GitHub repositories',
this.repository.gitHubRepository
)
if (!this.isStopped) {
fatalError(
'Cannot start the Pull Request Updater that is already running.'
@ -64,7 +69,7 @@ export class PullRequestUpdater {
this.timeoutHandles.set(
TimeoutHandles.Status,
window.setTimeout(() => {
this.store.refreshPullRequestStatuses(this.repository, this.account)
this.store.refreshPullRequestStatuses(githubRepo, this.account)
}, StatusInterval)
)
}
@ -80,10 +85,7 @@ export class PullRequestUpdater {
}
/** Starts fetching the statuses of PRs at an accelerated rate */
public didPushPullRequest(
repository: GitHubRepository,
pullRequest: PullRequest
) {
public didPushPullRequest(pullRequest: PullRequest) {
if (this.currentPullRequests.find(p => p.id === pullRequest.id)) {
return
}
@ -98,15 +100,20 @@ export class PullRequestUpdater {
}
const handle = window.setTimeout(
() => this.refreshPullRequestStatus(repository),
() => this.refreshPullRequestStatus(),
PostPushInterval
)
this.timeoutHandles.set(TimeoutHandles.PushedPullRequest, handle)
}
private async refreshPullRequestStatus(repository: GitHubRepository) {
await this.store.refreshPullRequestStatuses(this.repository, this.account)
const prs = await this.store.getPullRequests(repository)
private async refreshPullRequestStatus() {
const githubRepo = forceUnwrap(
'Can only refresh pull requests for GitHub repositories',
this.repository.gitHubRepository
)
await this.store.refreshPullRequestStatuses(githubRepo, this.account)
const prs = await this.store.getPullRequests(githubRepo)
for (const pr of prs) {
const status = pr.status

View file

@ -9,4 +9,5 @@ export * from './repositories-store'
export * from './sign-in-store'
export * from './token-store'
export * from './pull-request-store'
export * from './repository-settings-store'
export { UpstreamRemoteName } from './helpers/find-upstream-remote'

View file

@ -13,11 +13,20 @@ import {
PullRequestRef,
PullRequestStatus,
} from '../../models/pull-request'
import { Emitter, Disposable } from 'event-kit'
import { TypedBaseStore } from './base-store'
import { Repository } from '../../models/repository'
import { getRemotes, removeRemote } from '../git'
import { IRemote } from '../../models/remote'
/**
* This is the magic remote name prefix
* for when we add a remote on behalf of
* the user.
*/
export const ForkedRemotePrefix = 'github-desktop-'
/** The store for GitHub Pull Requests. */
export class PullRequestStore {
private readonly emitter = new Emitter()
export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
private readonly pullRequestDatabase: PullRequestDatabase
private readonly repositoriesStore: RepositoriesStore
@ -27,35 +36,86 @@ export class PullRequestStore {
db: PullRequestDatabase,
repositoriesStore: RepositoriesStore
) {
super()
this.pullRequestDatabase = db
this.repositoriesStore = repositoriesStore
}
/** Loads all pull requests against the given repository. */
public async refreshPullRequests(
repository: GitHubRepository,
repository: Repository,
account: Account
): Promise<void> {
const githubRepo = forceUnwrap(
'Can only refresh pull requests for GitHub repositories',
repository.gitHubRepository
)
const api = API.fromAccount(account)
this.changeActiveFetchCount(repository, c => c + 1)
this.changeActiveFetchCount(githubRepo, c => c + 1)
try {
const raw = await api.fetchPullRequests(
repository.owner.login,
repository.name,
githubRepo.owner.login,
githubRepo.name,
'open'
)
await this.writePRs(raw, repository)
await this.writePRs(raw, githubRepo)
const prs = await this.getPullRequests(repository)
await this.refreshStatusForPRs(prs, repository, account)
const prs = await this.getPullRequests(githubRepo)
await this.refreshStatusForPRs(prs, githubRepo, account)
await this.pruneForkedRemotes(repository, prs)
} catch (error) {
log.warn(`Error refreshing pull requests for '${repository.name}'`, error)
this.emitError(error)
} finally {
this.changeActiveFetchCount(repository, c => c - 1)
this.changeActiveFetchCount(githubRepo, c => c - 1)
}
}
private async pruneForkedRemotes(
repository: Repository,
pullRequests: ReadonlyArray<PullRequest>
) {
const remotes = await getRemotes(repository)
const forkedRemotesToDelete = this.forkedRemotesToDelete(
remotes,
pullRequests
)
await this.deleteForkedRemotes(repository, forkedRemotesToDelete)
}
private forkedRemotesToDelete(
remotes: ReadonlyArray<IRemote>,
openPullRequests: ReadonlyArray<PullRequest>
): ReadonlyArray<IRemote> {
const forkedRemotes = remotes.filter(remote =>
remote.name.startsWith(ForkedRemotePrefix)
)
const remotesOfPullRequests = new Set<string>()
openPullRequests.forEach(openPullRequest => {
const { gitHubRepository } = openPullRequest.head
if (gitHubRepository != null && gitHubRepository.cloneURL != null) {
remotesOfPullRequests.add(gitHubRepository.cloneURL)
}
})
const forkedRemotesToDelete = forkedRemotes.filter(
forkedRemote => !remotesOfPullRequests.has(forkedRemote.url)
)
return forkedRemotesToDelete
}
private async deleteForkedRemotes(
repository: Repository,
remotes: ReadonlyArray<IRemote>
) {
for (const remote of remotes) {
await removeRemote(repository, remote.name)
}
}
@ -175,7 +235,7 @@ export class PullRequestStore {
return null
}
const combinedRefStatuses = result.statuses.map(x => {
const combinedRefStatuses = (result.statuses || []).map(x => {
return {
id: x.id,
state: x.state,
@ -333,22 +393,4 @@ export class PullRequestStore {
return pullRequests
}
private emitUpdate(repository: GitHubRepository) {
this.emitter.emit('did-update', repository)
}
private emitError(error: Error) {
this.emitter.emit('did-error', error)
}
/** Register a function to be called when the store updates. */
public onDidUpdate(fn: (repository: GitHubRepository) => void): Disposable {
return this.emitter.on('did-update', fn)
}
/** Register a function to be called when an error occurs. */
public onDidError(fn: (error: Error) => void): Disposable {
return this.emitter.on('did-error', fn)
}
}

View file

@ -1,4 +1,3 @@
import { Emitter, Disposable } from 'event-kit'
import {
RepositoriesDatabase,
IDatabaseGitHubRepository,
@ -9,26 +8,18 @@ import { GitHubRepository } from '../../models/github-repository'
import { Repository } from '../../models/repository'
import { fatalError } from '../fatal-error'
import { IAPIRepository } from '../api'
import { BaseStore } from './base-store'
/** The store for local repositories. */
export class RepositoriesStore {
export class RepositoriesStore extends BaseStore {
private db: RepositoriesDatabase
private readonly emitter = new Emitter()
public constructor(db: RepositoriesDatabase) {
super()
this.db = db
}
private emitUpdate() {
this.emitter.emit('did-update', {})
}
/** Register a function to be called when the store updates. */
public onDidUpdate(fn: () => void): Disposable {
return this.emitter.on('did-update', fn)
}
/** Find the matching GitHub repository or add it if it doesn't exist. */
public async findOrPutGitHubRepository(
endpoint: string,

View file

@ -0,0 +1,120 @@
import * as Path from 'path'
import * as FS from 'fs'
import { BaseStore } from './base-store'
import { Repository } from '../../models/repository'
import { getConfigValue } from '../git'
export class RepositorySettingsStore extends BaseStore {
private readonly _repository: Repository
public constructor(repository: Repository) {
super()
this._repository = repository
}
/**
* Read the contents of the repository .gitignore.
*
* Returns a promise which will either be rejected or resolved
* with the contents of the file. If there's no .gitignore file
* in the repository root the promise will resolve with null.
*/
public async readGitIgnore(): Promise<string | null> {
const repository = this._repository
const ignorePath = Path.join(repository.path, '.gitignore')
return new Promise<string | null>((resolve, reject) => {
FS.readFile(ignorePath, 'utf8', (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
resolve(null)
} else {
reject(err)
}
} else {
resolve(data)
}
})
})
}
/**
* Persist the given content to the repository root .gitignore.
*
* If the repository root doesn't contain a .gitignore file one
* will be created, otherwise the current file will be overwritten.
*/
public async saveGitIgnore(text: string): Promise<void> {
const repository = this._repository
const ignorePath = Path.join(repository.path, '.gitignore')
const fileContents = await formatGitIgnoreContents(text, repository)
return new Promise<void>((resolve, reject) => {
FS.writeFile(ignorePath, fileContents, err => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
/** Ignore the given path or pattern. */
public async ignore(pattern: string): Promise<void> {
const text = (await this.readGitIgnore()) || ''
const repository = this._repository
const currentContents = await formatGitIgnoreContents(text, repository)
const newText = await formatGitIgnoreContents(
`${currentContents}${pattern}`,
repository
)
await this.saveGitIgnore(newText)
}
}
/**
* Format the gitignore text based on the current config settings.
*
* This setting looks at core.autocrlf to decide which line endings to use
* when updating the .gitignore file.
*
* If core.safecrlf is also set, adding this file to the index may cause
* Git to return a non-zero exit code, leaving the working directory in a
* confusing state for the user. So we should reformat the file in that
* case.
*
* @param text The text to format.
* @param repository The repository associated with the gitignore file.
*/
async function formatGitIgnoreContents(
text: string,
repository: Repository
): Promise<string> {
const autocrlf = await getConfigValue(repository, 'core.autocrlf')
const safecrlf = await getConfigValue(repository, 'core.safecrlf')
return new Promise<string>((resolve, reject) => {
if (autocrlf === 'true' && safecrlf === 'true') {
// based off https://stackoverflow.com/a/141069/1363815
const normalizedText = text.replace(/\r\n|\n\r|\n|\r/g, '\r\n')
resolve(normalizedText)
return
}
if (text.endsWith('\n')) {
resolve(text)
return
}
const linesEndInCRLF = autocrlf === 'true'
if (linesEndInCRLF) {
resolve(`${text}\n`)
} else {
resolve(`${text}\r\n`)
}
})
}

View file

@ -1,4 +1,4 @@
import { Emitter, Disposable } from 'event-kit'
import { Disposable } from 'event-kit'
import { Account } from '../../models/account'
import { assertNever, fatalError } from '../fatal-error'
import { askUserToOAuth } from '../../lib/oauth'
@ -22,6 +22,7 @@ import {
import { AuthenticationMode } from '../../lib/2fa'
import { minimumSupportedEnterpriseVersion } from '../../lib/enterprise'
import { TypedBaseStore } from './base-store'
function getUnverifiedUserErrorMessage(login: string): string {
return `Unable to authenticate. The account ${login} is lacking a verified email address. Please sign in to GitHub.com, confirm your email address in the Emails section under Personal settings, and try again.`
@ -167,27 +168,13 @@ export interface ISuccessState {
* A store encapsulating all logic related to signing in a user
* to GitHub.com, or a GitHub Enterprise instance.
*/
export class SignInStore {
private readonly emitter = new Emitter()
export class SignInStore extends TypedBaseStore<SignInState | null> {
private state: SignInState | null = null
private emitUpdate() {
this.emitter.emit('did-update', this.getState())
}
private emitAuthenticate(account: Account) {
this.emitter.emit('did-authenticate', account)
}
private emitError(error: Error) {
this.emitter.emit('did-error', error)
}
/** Register a function to be called when the store updates. */
public onDidUpdate(fn: (state: ISignInState) => void): Disposable {
return this.emitter.on('did-update', fn)
}
/**
* Registers an event handler which will be invoked whenever
* a user has successfully completed a sign-in process.
@ -196,16 +183,6 @@ export class SignInStore {
return this.emitter.on('did-authenticate', fn)
}
/**
* Register an even handler which will be invoked whenever
* an unexpected error occurs during the sign-in process. Note
* that some error are handled in the flow and passed along in
* the sign in state for inline presentation to the user.
*/
public onDidError(fn: (error: Error) => void): Disposable {
return this.emitter.on('did-error', fn)
}
/**
* Returns the current state of the sign in store or null if
* no sign in process is in flight.
@ -220,7 +197,7 @@ export class SignInStore {
*/
private setState(state: SignInState | null) {
this.state = state
this.emitUpdate()
this.emitUpdate(this.getState())
}
private async endpointSupportsBasicAuth(endpoint: string): Promise<boolean> {

27
app/src/models/author.ts Normal file
View file

@ -0,0 +1,27 @@
/**
* A representation of an 'author'. In reality we're
* talking about co-authors here but the representation
* is general purpose.
*
* For visualization purposes this object represents a
* string such as
*
* Foo Bar <foo@bar.com>
*
* Additionally it includes an optional username which is
* solely for presentation purposes inside AuthorInput
*/
export interface IAuthor {
/** The real name of the author */
readonly name: string
/** The email address of the author */
readonly email: string
/**
* The GitHub.com or GitHub Enterprise login for
* this author or null if that information is not
* available.
*/
readonly username: string | null
}

View file

@ -1,3 +1,11 @@
import { IGitHubUser } from '../lib/databases/github-user-database'
import { Commit } from './commit'
import { CommitIdentity } from './commit-identity'
import { GitAuthor } from './git-author'
import { generateGravatarUrl } from '../lib/gravatar'
import { getDotComAPIEndpoint } from '../lib/api'
import { GitHubRepository } from './github-repository'
/** The minimum properties we need in order to display a user's avatar. */
export interface IAvatarUser {
/** The user's email. */
@ -9,3 +17,80 @@ export interface IAvatarUser {
/** The user's name. */
readonly name: string
}
function getFallbackAvatarUrlForAuthor(
gitHubRepository: GitHubRepository | null,
author: CommitIdentity | GitAuthor
) {
if (
gitHubRepository &&
gitHubRepository.endpoint === getDotComAPIEndpoint()
) {
return `https://avatars.githubusercontent.com/u/e?email=${encodeURIComponent(
author.email
)}&s=40`
}
return generateGravatarUrl(author.email)
}
function getAvatarUserFromAuthor(
gitHubRepository: GitHubRepository | null,
gitHubUsers: Map<string, IGitHubUser> | null,
author: CommitIdentity | GitAuthor
) {
const gitHubUser =
gitHubUsers === null
? null
: gitHubUsers.get(author.email.toLowerCase()) || null
const avatarURL = gitHubUser
? gitHubUser.avatarURL
: getFallbackAvatarUrlForAuthor(gitHubRepository, author)
return {
email: author.email,
name: author.name,
avatarURL,
}
}
/**
* Attempt to look up avatars for all authors (and committer)
* of a particular commit.
*
* Avatars are returned ordered, starting with the author, followed
* by all co-authors and finally the committer (if different from
* author).
*
* @param gitHubRepository
* @param gitHubUsers
* @param commit
*/
export function getAvatarUsersForCommit(
gitHubRepository: GitHubRepository | null,
gitHubUsers: Map<string, IGitHubUser> | null,
commit: Commit
) {
const avatarUsers = []
avatarUsers.push(
getAvatarUserFromAuthor(gitHubRepository, gitHubUsers, commit.author)
)
avatarUsers.push(
...commit.coAuthors.map(x =>
getAvatarUserFromAuthor(gitHubRepository, gitHubUsers, x)
)
)
const isWebFlowCommitter =
gitHubRepository !== null && commit.isWebFlowCommitter(gitHubRepository)
if (!commit.authoredByCommitter && !isWebFlowCommitter) {
avatarUsers.push(
getAvatarUserFromAuthor(gitHubRepository, gitHubUsers, commit.committer)
)
}
return avatarUsers
}

View file

@ -1,4 +1,23 @@
import { CommitIdentity } from './commit-identity'
import { ITrailer } from '../lib/git/interpret-trailers'
import { GitAuthor } from './git-author'
import { GitHubRepository } from './github-repository'
import { getDotComAPIEndpoint } from '../lib/api'
function extractCoAuthors(trailers: ReadonlyArray<ITrailer>) {
const coAuthors = []
for (const trailer of trailers) {
if (trailer.token.toLowerCase() === 'co-authored-by') {
const author = GitAuthor.parse(trailer.value)
if (author) {
coAuthors.push(author)
}
}
}
return coAuthors
}
/** A git commit. */
export class Commit {
@ -13,24 +32,91 @@ export class Commit {
/**
* Information about the author of this commit.
* includes name, email and date.
* Includes name, email and date.
*/
public readonly author: CommitIdentity
/**
* Information about the committer of this commit.
* Includes name, email and date.
*/
public readonly committer: CommitIdentity
/** The SHAs for the parents of the commit. */
public readonly parentSHAs: ReadonlyArray<string>
/**
* Parsed, unfolded trailers from the commit message body,
* if any, as interpreted by `git interpret-trailers`
*/
public readonly trailers: ReadonlyArray<ITrailer>
/**
* A list of co-authors parsed from the commit message
* trailers.
*/
public readonly coAuthors: ReadonlyArray<GitAuthor>
/**
* A value indicating whether the author and the committer
* are the same person.
*/
public readonly authoredByCommitter: boolean
public constructor(
sha: string,
summary: string,
body: string,
author: CommitIdentity,
parentSHAs: ReadonlyArray<string>
committer: CommitIdentity,
parentSHAs: ReadonlyArray<string>,
trailers: ReadonlyArray<ITrailer>
) {
this.sha = sha
this.summary = summary
this.body = body
this.author = author
this.committer = committer
this.parentSHAs = parentSHAs
this.trailers = trailers
this.coAuthors = extractCoAuthors(trailers)
this.authoredByCommitter =
this.author.name === this.committer.name &&
this.author.email === this.committer.email
}
/**
* Best-effort attempt to figure out if this commit was committed using
* the web flow on GitHub.com or GitHub Enterprise. Web flow
* commits (such as PR merges) will have a special GitHub committer
* with a noreply email address.
*
* For GitHub.com we can be spot on but for GitHub Enterprise it's
* possible we could fail if they've set up a custom smtp host
* that doesn't correspond to the hostname.
*/
public isWebFlowCommitter(gitHubRepository: GitHubRepository) {
if (!gitHubRepository) {
return false
}
const endpoint = gitHubRepository.owner.endpoint
const { name, email } = this.committer
if (
endpoint === getDotComAPIEndpoint() &&
name === 'GitHub' &&
email === 'noreply@github.com'
) {
return true
}
if (this.committer.name === 'GitHub Enterprise') {
const host = new URL(endpoint).host.toLowerCase()
return email === `noreply@${host}`
}
return false
}
}

View file

@ -0,0 +1,18 @@
export class GitAuthor {
public readonly name: string
public readonly email: string
public static parse(nameAddr: string): GitAuthor | null {
const m = nameAddr.match(/^(.*?)\s+<(.*?)>/)
return m === null ? null : new GitAuthor(m[1], m[2])
}
public constructor(name: string, email: string) {
this.name = name
this.email = email
}
public toString() {
return `${this.name} <${this.email}>`
}
}

View file

@ -1601,6 +1601,7 @@ export class App extends React.Component<IAppProps, IAppState> {
askForConfirmationOnDiscardChanges={
this.state.askForConfirmationOnDiscardChanges
}
accounts={this.state.accounts}
/>
)
} else if (selectedState.type === SelectionType.CloningRepository) {

View file

@ -37,6 +37,12 @@ interface IAutocompletingTextInputProps<ElementType> {
* input.
*/
readonly autocompletionProviders: ReadonlyArray<IAutocompletionProvider<any>>
/**
* A method that's called when the internal input or textarea element
* is mounted or unmounted.
*/
readonly onElementRef?: (elem: ElementType | null) => void
}
interface IAutocompletionState<T> {
@ -266,6 +272,15 @@ export abstract class AutocompletingTextInput<
private onRef = (ref: ElementType | null) => {
this.element = ref
if (this.props.onElementRef) {
this.props.onElementRef(ref)
}
}
public focus() {
if (this.element) {
this.element.focus()
}
}
public render() {

View file

@ -3,14 +3,38 @@ import * as React from 'react'
import { IAutocompletionProvider } from './index'
import { GitHubUserStore } from '../../lib/stores'
import { GitHubRepository } from '../../models/github-repository'
import { Account } from '../../models/account'
import { IGitHubUser } from '../../lib/databases/index'
import { validLoginExpression } from '../../lib/api'
/** An autocompletion hit for a user. */
export interface IUserHit {
/** The username. */
readonly username: string
/** The user's name. */
readonly name: string
/**
* The user's name or null if the user
* hasn't entered a name in their profile
*/
readonly name: string | null
/**
* The user's public email address. If the user
* hasn't selected a public email address this
* field will be an empty string.
*/
readonly email: string
readonly endpoint: string
}
function userToHit(user: IGitHubUser): IUserHit {
return {
username: user.login,
name: user.name,
email: user.email,
endpoint: user.endpoint,
}
}
/** The autocompletion provider for user mentions in a GitHub repository. */
@ -20,13 +44,16 @@ export class UserAutocompletionProvider
private readonly gitHubUserStore: GitHubUserStore
private readonly repository: GitHubRepository
private readonly account: Account | null
public constructor(
gitHubUserStore: GitHubUserStore,
repository: GitHubRepository
repository: GitHubRepository,
account?: Account
) {
this.gitHubUserStore = gitHubUserStore
this.repository = repository
this.account = account || null
}
public getRegExp(): RegExp {
@ -40,7 +67,14 @@ export class UserAutocompletionProvider
this.repository,
text
)
return users.map(u => ({ username: u.login, name: u.name }))
// dotcom doesn't let you autocomplete on your own handle
const account = this.account
const filtered = account
? users.filter(x => x.login !== account.login)
: users
return filtered.map(userToHit)
}
public renderItem(item: IUserHit): JSX.Element {
@ -55,4 +89,37 @@ export class UserAutocompletionProvider
public getCompletionText(item: IUserHit): string {
return `@${item.username}`
}
/**
* Retrieve a user based on the user login name, i.e their handle.
*
* If the user is already cached no additional API requests
* will be made. If the user isn't in the cache but found in
* the API it will be persisted to the database and the
* intermediate cache.
*
* @param login The login (i.e. handle) of the user
*/
public async exactMatch(login: string): Promise<IUserHit | null> {
if (this.account === null) {
return null
}
// Since we might be looking up stuff in the API it's
// important we sanitize this input or someone could lead with
// ../ and then start GETing random resources in the API.
// Not that they should be able to do any harm with just GET
// but still, it ain't cool
if (!validLoginExpression.test(login)) {
return null
}
const user = await this.gitHubUserStore.getByLogin(this.account, login)
if (!user) {
return null
}
return userToHit(user)
}
}

View file

@ -60,16 +60,11 @@ export class BranchesContainer extends React.Component<
const currentBranch = this.props.currentBranch
if (currentBranch != null && currentBranch.name !== branch.name) {
if (currentBranch == null || currentBranch.name !== branch.name) {
this.props.dispatcher.checkoutBranch(this.props.repository, branch)
}
}
private checkoutRef(ref: string) {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
this.props.dispatcher.checkoutBranch(this.props.repository, ref)
}
private onFilterKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
if (this.state.filterText.length === 0) {
@ -235,27 +230,11 @@ export class BranchesContainer extends React.Component<
}
private onPullRequestClicked = (pullRequest: PullRequest) => {
const gitHubRepository = this.props.repository.gitHubRepository
if (!gitHubRepository) {
return log.error(
`We shouldn't be checking out a PR on a repository that doesn't have a GitHub repository.`
)
}
const head = pullRequest.head
const isRefInThisRepo =
head.gitHubRepository &&
head.gitHubRepository.cloneURL === gitHubRepository.cloneURL
if (isRefInThisRepo) {
this.checkoutRef(head.ref)
} else {
log.debug(
`onPullRequestClicked, but we can't checkout the branch: '${
head.ref
}' belongs to fork '${pullRequest.author}'`
)
// TODO: It's in a fork so we'll need to do ... something.
}
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
this.props.dispatcher.checkoutPullRequest(
this.props.repository,
pullRequest
)
this.onPullRequestSelectionChanged(pullRequest)
}

View file

@ -18,15 +18,12 @@ export class PullRequestBadge extends React.Component<
public render() {
const status = this.props.status
if (!status || status.totalCount === 0) {
return null
}
return (
<div className="pr-badge">
<span className="number">#{this.props.number}</span>
<CIStatus status={status} />
{status != null && status.totalCount > 0 ? (
<CIStatus status={status} />
) : null}
</div>
)
}

View file

@ -16,6 +16,8 @@ import { Dispatcher } from '../../lib/dispatcher'
import { IAutocompletionProvider } from '../autocompletion'
import { Repository } from '../../models/repository'
import { showContextualMenu, IMenuItem } from '../main-process-proxy'
import { IAuthor } from '../../models/author'
import { ITrailer } from '../../lib/git/interpret-trailers'
const RowHeight = 29
@ -26,7 +28,11 @@ interface IChangesListProps {
readonly onFileSelectionChanged: (row: number) => void
readonly onIncludeChanged: (path: string, include: boolean) => void
readonly onSelectAll: (selectAll: boolean) => void
readonly onCreateCommit: (message: ICommitMessage) => Promise<boolean>
readonly onCreateCommit: (
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
) => Promise<boolean>
readonly onDiscardChanges: (file: WorkingDirectoryFileChange) => void
readonly onDiscardAllChanges: (
files: ReadonlyArray<WorkingDirectoryFileChange>
@ -64,6 +70,21 @@ interface IChangesListProps {
/** Called when the given pattern should be ignored. */
readonly onIgnore: (pattern: string) => void
/**
* Whether or not to show a field for adding co-authors to
* a commit (currently only supported for GH/GHE repositories)
*/
readonly showCoAuthoredBy: boolean
/**
* A list of authors (name, email pairs) which have been
* entered into the co-authors input box in the commit form
* and which _may_ be used in the subsequent commit to add
* Co-Authored-By commit message trailers depending on whether
* the user has chosen to do so.
*/
readonly coAuthors: ReadonlyArray<IAuthor>
}
export class ChangesList extends React.Component<IChangesListProps, {}> {
@ -182,6 +203,8 @@ export class ChangesList extends React.Component<IChangesListProps, {}> {
contextualCommitMessage={this.props.contextualCommitMessage}
autocompletionProviders={this.props.autocompletionProviders}
isCommitting={this.props.isCommitting}
showCoAuthoredBy={this.props.showCoAuthoredBy}
coAuthors={this.props.coAuthors}
/>
</div>
)

View file

@ -1,8 +1,10 @@
import * as React from 'react'
import * as classNames from 'classnames'
import {
AutocompletingTextArea,
AutocompletingInput,
IAutocompletionProvider,
UserAutocompletionProvider,
} from '../autocompletion'
import { CommitIdentity } from '../../models/commit-identity'
import { ICommitMessage } from '../../lib/app-state'
@ -14,9 +16,28 @@ import { Avatar } from '../lib/avatar'
import { Loading } from '../lib/loading'
import { structuralEquals } from '../../lib/equality'
import { generateGravatarUrl } from '../../lib/gravatar'
import { AuthorInput } from '../lib/author-input'
import { FocusContainer } from '../lib/focus-container'
import { showContextualMenu, IMenuItem } from '../main-process-proxy'
import { Octicon, OcticonSymbol } from '../octicons'
import { ITrailer } from '../../lib/git/interpret-trailers'
import { IAuthor } from '../../models/author'
const addAuthorIcon = new OcticonSymbol(
12,
7,
'M9.875 2.125H12v1.75H9.875V6h-1.75V3.875H6v-1.75h2.125V0h1.75v2.125zM6 ' +
'6.5a.5.5 0 0 1-.5.5h-5a.5.5 0 0 1-.5-.5V6c0-1.316 2-2 2-2s.114-.204 ' +
'0-.5c-.42-.31-.472-.795-.5-2C1.587.293 2.434 0 3 0s1.413.293 1.5 1.5c-.028 ' +
'1.205-.08 1.69-.5 2-.114.295 0 .5 0 .5s2 .684 2 2v.5z'
)
interface ICommitMessageProps {
readonly onCreateCommit: (message: ICommitMessage) => Promise<boolean>
readonly onCreateCommit: (
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
) => Promise<boolean>
readonly branch: string | null
readonly commitAuthor: CommitIdentity | null
readonly gitHubUser: IGitHubUser | null
@ -27,6 +48,21 @@ interface ICommitMessageProps {
readonly dispatcher: Dispatcher
readonly autocompletionProviders: ReadonlyArray<IAutocompletionProvider<any>>
readonly isCommitting: boolean
/**
* Whether or not to show a field for adding co-authors to
* a commit (currently only supported for GH/GHE repositories)
*/
readonly showCoAuthoredBy: boolean
/**
* A list of authors (name, email pairs) which have been
* entered into the co-authors input box in the commit form
* and which _may_ be used in the subsequent commit to add
* Co-Authored-By commit message trailers depending on whether
* the user has chosen to do so.
*/
readonly coAuthors: ReadonlyArray<IAuthor>
}
interface ICommitMessageState {
@ -35,12 +71,38 @@ interface ICommitMessageState {
/** The last contextual commit message we've received. */
readonly lastContextualCommitMessage: ICommitMessage | null
readonly userAutocompletionProvider: UserAutocompletionProvider | null
/**
* Whether or not the description text area has more text that's
* obscured by the action bar. Note that this will always be
* false when there's no action bar.
*/
readonly descriptionObscured: boolean
}
function findUserAutoCompleteProvider(
providers: ReadonlyArray<IAutocompletionProvider<any>>
): UserAutocompletionProvider | null {
for (const provider of providers) {
if (provider instanceof UserAutocompletionProvider) {
return provider
}
}
return null
}
export class CommitMessage extends React.Component<
ICommitMessageProps,
ICommitMessageState
> {
private descriptionComponent: AutocompletingTextArea | null = null
private descriptionTextArea: HTMLTextAreaElement | null = null
private descriptionTextAreaScrollDebounceId: number | null = null
public constructor(props: ICommitMessageProps) {
super(props)
@ -48,6 +110,10 @@ export class CommitMessage extends React.Component<
summary: '',
description: '',
lastContextualCommitMessage: null,
userAutocompletionProvider: findUserAutoCompleteProvider(
props.autocompletionProviders
),
descriptionObscured: false,
}
}
@ -72,6 +138,16 @@ export class CommitMessage extends React.Component<
this.props.dispatcher.setCommitMessage(this.props.repository, this.state)
}
if (
nextProps.autocompletionProviders !== this.props.autocompletionProviders
) {
this.setState({
userAutocompletionProvider: findUserAutoCompleteProvider(
nextProps.autocompletionProviders
),
})
}
// This is rather gnarly. We want to persist the commit message (summary,
// and description) in the dispatcher on a per-repository level (git-store).
//
@ -155,31 +231,46 @@ export class CommitMessage extends React.Component<
this.createCommit()
}
private getCoAuthorTrailers() {
if (!this.isCoAuthorInputEnabled) {
return []
}
return this.props.coAuthors.map(a => ({
token: 'Co-Authored-By',
value: `${a.name} <${a.email}>`,
}))
}
private async createCommit() {
if (!this.canCommit) {
const { summary, description } = this.state
if (!this.canCommit()) {
return
}
const success = await this.props.onCreateCommit({
// We know that summary is non-null thanks to canCommit
summary: this.state.summary!,
description: this.state.description,
})
const trailers = this.getCoAuthorTrailers()
if (success) {
const commitCreated = await this.props.onCreateCommit(
summary,
description,
trailers
)
if (commitCreated) {
this.clearCommitMessage()
}
}
private canCommit(): boolean {
return (
this.props.anyFilesSelected &&
this.state.summary !== null &&
this.state.summary.length > 0
)
return this.props.anyFilesSelected && this.state.summary.length > 0
}
private onKeyDown = (event: React.KeyboardEvent<Element>) => {
if (event.defaultPrevented) {
return
}
const isShortcutKey = __DARWIN__ ? event.metaKey : event.ctrlKey
if (isShortcutKey && event.key === 'Enter' && this.canCommit()) {
this.createCommit()
@ -209,14 +300,168 @@ export class CommitMessage extends React.Component<
return <Avatar user={avatarUser} title={avatarTitle} />
}
private get isCoAuthorInputEnabled() {
return this.props.repository.gitHubRepository !== null
}
private get isCoAuthorInputVisible() {
return this.props.showCoAuthoredBy && this.isCoAuthorInputEnabled
}
private onCoAuthorsUpdated = (coAuthors: ReadonlyArray<IAuthor>) => {
this.props.dispatcher.setCoAuthors(this.props.repository, coAuthors)
}
private renderCoAuthorInput() {
if (!this.isCoAuthorInputVisible) {
return null
}
const autocompletionProvider = this.state.userAutocompletionProvider
if (!autocompletionProvider) {
return null
}
return (
<AuthorInput
onAuthorsUpdated={this.onCoAuthorsUpdated}
authors={this.props.coAuthors}
autoCompleteProvider={autocompletionProvider}
/>
)
}
private onToggleCoAuthors = () => {
this.props.dispatcher.setShowCoAuthoredBy(
this.props.repository,
!this.props.showCoAuthoredBy
)
}
private get toggleCoAuthorsText(): string {
return this.props.showCoAuthoredBy
? __DARWIN__ ? 'Remove Co-Authors' : 'Remove co-authors'
: __DARWIN__ ? 'Add Co-Authors' : 'Add co-authors'
}
private onContextMenu = (event: React.MouseEvent<any>) => {
event.preventDefault()
const items: IMenuItem[] = [
{
label: this.toggleCoAuthorsText,
action: this.onToggleCoAuthors,
enabled: this.props.repository.gitHubRepository !== null,
},
]
showContextualMenu(items)
}
private onCoAuthorToggleButtonClick = (
e: React.MouseEvent<HTMLDivElement>
) => {
e.preventDefault()
this.onToggleCoAuthors()
}
private renderCoAuthorToggleButton() {
if (this.props.repository.gitHubRepository === null) {
return null
}
return (
<div
role="button"
className="co-authors-toggle"
onClick={this.onCoAuthorToggleButtonClick}
tabIndex={-1}
aria-label={this.toggleCoAuthorsText}
>
<Octicon symbol={addAuthorIcon} />
</div>
)
}
private onDescriptionFieldRef = (
component: AutocompletingTextArea | null
) => {
this.descriptionComponent = component
}
private onDescriptionTextAreaScroll = () => {
this.descriptionTextAreaScrollDebounceId = null
const elem = this.descriptionTextArea
const descriptionObscured =
elem !== null && elem.scrollTop + elem.offsetHeight < elem.scrollHeight
if (this.state.descriptionObscured !== descriptionObscured) {
this.setState({ descriptionObscured })
}
}
private onDescriptionTextAreaRef = (elem: HTMLTextAreaElement | null) => {
if (elem) {
elem.addEventListener('scroll', () => {
if (this.descriptionTextAreaScrollDebounceId !== null) {
cancelAnimationFrame(this.descriptionTextAreaScrollDebounceId)
this.descriptionTextAreaScrollDebounceId = null
}
this.descriptionTextAreaScrollDebounceId = requestAnimationFrame(
this.onDescriptionTextAreaScroll
)
})
}
this.descriptionTextArea = elem
}
private onFocusContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (this.descriptionComponent) {
this.descriptionComponent.focus()
}
}
/**
* Whether or not there's anything to render in the action bar
*/
private get isActionBarEnabled() {
return this.isCoAuthorInputEnabled
}
private renderActionBar() {
if (!this.isCoAuthorInputEnabled) {
return null
}
return <div className="action-bar">{this.renderCoAuthorToggleButton()}</div>
}
public render() {
const branchName = this.props.branch ? this.props.branch : 'master'
const buttonEnabled = this.canCommit() && !this.props.isCommitting
const loading = this.props.isCommitting ? <Loading /> : undefined
const className = classNames({
'with-action-bar': this.isActionBarEnabled,
'with-co-authors': this.isCoAuthorInputVisible,
})
const descriptionClassName = classNames('description-field', {
'with-overflow': this.state.descriptionObscured,
})
return (
<div id="commit-message" role="group" aria-label="Create commit">
<div
id="commit-message"
role="group"
aria-label="Create commit"
className={className}
onContextMenu={this.onContextMenu}
onKeyDown={this.onKeyDown}
>
<div className="summary">
{this.renderAvatar()}
@ -225,19 +470,27 @@ export class CommitMessage extends React.Component<
placeholder="Summary"
value={this.state.summary}
onValueChanged={this.onSummaryChanged}
onKeyDown={this.onKeyDown}
autocompletionProviders={this.props.autocompletionProviders}
/>
</div>
<AutocompletingTextArea
className="description-field"
placeholder="Description"
value={this.state.description || ''}
onValueChanged={this.onDescriptionChanged}
onKeyDown={this.onKeyDown}
autocompletionProviders={this.props.autocompletionProviders}
/>
<FocusContainer
className="description-focus-container"
onClick={this.onFocusContainerClick}
>
<AutocompletingTextArea
className={descriptionClassName}
placeholder="Description"
value={this.state.description || ''}
onValueChanged={this.onDescriptionChanged}
autocompletionProviders={this.props.autocompletionProviders}
ref={this.onDescriptionFieldRef}
onElementRef={this.onDescriptionTextAreaRef}
/>
{this.renderActionBar()}
</FocusContainer>
{this.renderCoAuthorInput()}
<Button
type="submit"

View file

@ -3,7 +3,7 @@ import * as React from 'react'
import { ChangesList } from './changes-list'
import { DiffSelectionType } from '../../models/diff'
import { ICommitMessage, IChangesState, PopupType } from '../../lib/app-state'
import { IChangesState, PopupType } from '../../lib/app-state'
import { Repository } from '../../models/repository'
import { Dispatcher } from '../../lib/dispatcher'
import { IGitHubUser } from '../../lib/databases'
@ -21,6 +21,8 @@ import { ClickSource } from '../lib/list'
import { WorkingDirectoryFileChange } from '../../models/status'
import { CSSTransitionGroup } from 'react-transition-group'
import { openFile } from '../../lib/open-file'
import { ITrailer } from '../../lib/git/interpret-trailers'
import { Account } from '../../models/account'
/**
* The timeout for the animation of the enter/leave animation for Undo.
@ -45,6 +47,7 @@ interface IChangesSidebarProps {
readonly isPushPullFetchInProgress: boolean
readonly gitHubUserStore: GitHubUserStore
readonly askForConfirmationOnDiscardChanges: boolean
readonly accounts: ReadonlyArray<Account>
}
export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
@ -65,7 +68,8 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
private receiveProps(props: IChangesSidebarProps) {
if (
props.repository.id !== this.props.repository.id ||
!this.autocompletionProviders
!this.autocompletionProviders ||
props.accounts !== this.props.accounts
) {
const autocompletionProviders: IAutocompletionProvider<any>[] = [
new EmojiAutocompletionProvider(this.props.emoji),
@ -81,10 +85,16 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
props.dispatcher
)
)
const account = this.props.accounts.find(
a => a.endpoint === gitHubRepository.endpoint
)
autocompletionProviders.push(
new UserAutocompletionProvider(
props.gitHubUserStore,
gitHubRepository
gitHubRepository,
account
)
)
}
@ -93,10 +103,16 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
}
}
private onCreateCommit = (message: ICommitMessage): Promise<boolean> => {
private onCreateCommit = (
summary: string,
description: string | null,
trailers?: ReadonlyArray<ITrailer>
): Promise<boolean> => {
return this.props.dispatcher.commitIncludedChanges(
this.props.repository,
message
summary,
description,
trailers
)
}
@ -287,6 +303,8 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
availableWidth={this.props.availableWidth}
onIgnore={this.onIgnore}
isCommitting={this.props.isCommitting}
showCoAuthoredBy={this.props.changes.showCoAuthoredBy}
coAuthors={this.props.changes.coAuthors}
/>
{this.renderMostRecentLocalCommit()}
</div>

View file

@ -4,6 +4,9 @@ import * as CodeMirror from 'codemirror'
// Required for us to be able to customize the foreground color of selected text
import 'codemirror/addon/selection/mark-selection'
// Autocompletion plugin
import 'codemirror/addon/hint/show-hint'
if (__DARWIN__) {
// This has to be required to support the `simple` scrollbar style.
require('codemirror/addon/scroll/simplescrollbars')

View file

@ -1,33 +1,65 @@
import * as React from 'react'
import { Commit } from '../../models/commit'
import { GitHubRepository } from '../../models/github-repository'
import { IAvatarUser } from '../../models/avatar'
import { IAvatarUser, getAvatarUsersForCommit } from '../../models/avatar'
import { RichText } from '../lib/rich-text'
import { Avatar } from '../lib/avatar'
import { RelativeTime } from '../relative-time'
import { getDotComAPIEndpoint } from '../../lib/api'
import { clipboard } from 'electron'
import { showContextualMenu, IMenuItem } from '../main-process-proxy'
import { CommitAttribution } from '../lib/commit-attribution'
import { IGitHubUser } from '../../lib/databases/github-user-database'
import { AvatarStack } from '../lib/avatar-stack'
interface ICommitProps {
readonly gitHubRepository: GitHubRepository | null
readonly commit: Commit
readonly user: IAvatarUser | null
readonly emoji: Map<string, string>
readonly isLocal: boolean
readonly onRevertCommit?: (commit: Commit) => void
readonly onViewCommitOnGitHub?: (sha: string) => void
readonly gitHubUsers: Map<string, IGitHubUser> | null
}
interface ICommitListItemState {
readonly avatarUsers: ReadonlyArray<IAvatarUser>
}
/** A component which displays a single commit in a commit list. */
export class CommitListItem extends React.Component<ICommitProps, {}> {
export class CommitListItem extends React.Component<
ICommitProps,
ICommitListItemState
> {
public constructor(props: ICommitProps) {
super(props)
this.state = {
avatarUsers: getAvatarUsersForCommit(
props.gitHubRepository,
props.gitHubUsers,
props.commit
),
}
}
public componentWillReceiveProps(nextProps: ICommitProps) {
if (nextProps.commit !== this.props.commit) {
this.setState({
avatarUsers: getAvatarUsersForCommit(
nextProps.gitHubRepository,
nextProps.gitHubUsers,
nextProps.commit
),
})
}
}
public render() {
const commit = this.props.commit
const author = commit.author
return (
<div className="commit" onContextMenu={this.onContextMenu}>
<Avatar user={this.props.user || undefined} />
<div className="info">
<RichText
className="summary"
@ -35,8 +67,15 @@ export class CommitListItem extends React.Component<ICommitProps, {}> {
text={commit.summary}
renderUrlsAsLinks={false}
/>
<div className="byline">
<RelativeTime date={author.date} /> by {author.name}
<div className="description">
<AvatarStack users={this.state.avatarUsers} />
<div className="byline">
<CommitAttribution
gitHubRepository={this.props.gitHubRepository}
commit={commit}
/>{' '}
<RelativeTime date={author.date} />
</div>
</div>
</div>
</div>
@ -44,10 +83,7 @@ export class CommitListItem extends React.Component<ICommitProps, {}> {
}
public shouldComponentUpdate(nextProps: ICommitProps): boolean {
return (
this.props.commit.sha !== nextProps.commit.sha ||
this.props.user !== nextProps.user
)
return this.props.commit.sha !== nextProps.commit.sha
}
private onCopySHA = () => {

View file

@ -4,7 +4,6 @@ import { Commit } from '../../models/commit'
import { CommitListItem } from './commit-list-item'
import { List } from '../lib/list'
import { IGitHubUser } from '../../lib/databases'
import { generateGravatarUrl } from '../../lib/gravatar'
const RowHeight = 48
@ -27,23 +26,11 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
private renderCommit = (row: number) => {
const sha = this.props.history[row]
const commit = this.props.commits.get(sha)
if (!commit) {
return null
}
const gitHubUser =
this.props.gitHubUsers.get(commit.author.email.toLowerCase()) || null
const avatarURL = gitHubUser
? gitHubUser.avatarURL
: generateGravatarUrl(commit.author.email)
const avatarUser = {
email: commit.author.email,
name: commit.author.name,
avatarURL,
}
const isLocal = this.props.localCommitSHAs.indexOf(commit.sha) > -1
return (
@ -52,7 +39,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
gitHubRepository={this.props.repository.gitHubRepository}
isLocal={isLocal}
commit={commit}
user={avatarUser}
gitHubUsers={this.props.gitHubUsers}
emoji={this.props.emoji}
onRevertCommit={this.props.onRevertCommit}
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}

View file

@ -6,15 +6,17 @@ import { Octicon, OcticonSymbol } from '../octicons'
import { RichText } from '../lib/rich-text'
import { IGitHubUser } from '../../lib/databases'
import { Repository } from '../../models/repository'
import { Avatar } from '../lib/avatar'
import { Commit } from '../../models/commit'
import { getAvatarUsersForCommit, IAvatarUser } from '../../models/avatar'
import { AvatarStack } from '../lib/avatar-stack'
import { CommitAttribution } from '../lib/commit-attribution'
interface ICommitSummaryProps {
readonly repository: Repository
readonly commit: Commit
readonly files: ReadonlyArray<FileChange>
readonly emoji: Map<string, string>
readonly gitHubUser: IGitHubUser | null
readonly gitHubUsers: Map<string, IGitHubUser> | null
/**
* Whether or not the commit body container should
@ -49,6 +51,12 @@ interface ICommitSummaryState {
* conjunction with the isExpanded prop.
*/
readonly isOverflowed: boolean
/**
* The avatars associated with this commit. Used when rendering
* the avatar stack and calculated whenever the commit prop changes.
*/
readonly avatarUsers: ReadonlyArray<IAvatarUser>
}
const maxSummaryLength = 72
@ -88,7 +96,13 @@ function createState(isOverflowed: boolean, props: ICommitSummaryProps) {
summary = `${summary.substr(0, truncateLength)}`
}
return { isOverflowed, summary, body }
const avatarUsers = getAvatarUsersForCommit(
props.repository.gitHubRepository,
props.gitHubUsers,
props.commit
)
return { isOverflowed, summary, body, avatarUsers }
}
/**
@ -261,16 +275,6 @@ export class CommitSummary extends React.Component<
const filesPlural = fileCount === 1 ? 'file' : 'files'
const filesDescription = `${fileCount} changed ${filesPlural}`
const shortSHA = this.props.commit.sha.slice(0, 7)
const author = this.props.commit.author
const authorTitle = `${author.name} <${author.email}>`
let avatarUser = undefined
if (this.props.gitHubUser) {
avatarUser = {
email: author.email,
name: author.name,
avatarURL: this.props.gitHubUser.avatarURL,
}
}
const className = classNames({
expanded: this.props.isExpanded,
@ -289,16 +293,12 @@ export class CommitSummary extends React.Component<
/>
<ul className="commit-summary-meta">
<li
className="commit-summary-meta-item"
title={authorTitle}
aria-label="Author"
>
<span aria-hidden="true">
<Avatar user={avatarUser} />
</span>
{author.name}
<li className="commit-summary-meta-item" aria-label="Author">
<AvatarStack users={this.state.avatarUsers} />
<CommitAttribution
gitHubRepository={this.props.repository.gitHubRepository}
commit={this.props.commit}
/>
</li>
<li className="commit-summary-meta-item" aria-label="SHA">

View file

@ -91,16 +91,13 @@ export class History extends React.Component<IHistoryProps, IHistoryState> {
}
private renderCommitSummary(commit: Commit) {
const gitHubUser =
this.props.gitHubUsers.get(commit.author.email.toLowerCase()) || null
return (
<CommitSummary
commit={commit}
files={this.props.history.changedFiles}
emoji={this.props.emoji}
repository={this.props.repository}
gitHubUser={gitHubUser}
gitHubUsers={this.props.gitHubUsers}
onExpandChanged={this.onExpandChanged}
isExpanded={this.state.isExpanded}
/>

View file

@ -0,0 +1,817 @@
import * as React from 'react'
import * as CodeMirror from 'codemirror'
import * as URL from 'url'
import * as classNames from 'classnames'
import { UserAutocompletionProvider, IUserHit } from '../autocompletion'
import { Editor, Doc, Position } from 'codemirror'
import { validLoginExpression, getDotComAPIEndpoint } from '../../lib/api'
import { compare } from '../../lib/compare'
import { arrayEquals } from '../../lib/equality'
import { OcticonSymbol } from '../octicons'
import { IAuthor } from '../../models/author'
interface IAuthorInputProps {
/**
* An optional class name for the wrapper element around the
* author input component
*/
readonly className?: string
/**
* The user autocomplete provider to use when searching for substring
* matches while autocompleting.
*/
readonly autoCompleteProvider: UserAutocompletionProvider
/**
* The list of authors to fill the input with initially. If this
* prop changes from what's propagated through onAuthorsUpdated
* while the component is mounted it will reset, loosing
* any text that has not yet been resolved to an author.
*/
readonly authors: ReadonlyArray<IAuthor>
/**
* A method called when authors has been added or removed from the
* input field.
*/
readonly onAuthorsUpdated: (authors: ReadonlyArray<IAuthor>) => void
}
/**
* Return the position previous to (i.e before) the given
* position in a codemirror doc
*/
function prevPosition(doc: Doc, pos: Position) {
return doc.posFromIndex(doc.indexFromPos(pos) - 1)
}
/**
* Return the position next to (i.e after) the given
* position in a codemirror doc
*/
function nextPosition(doc: Doc, pos: Position) {
return doc.posFromIndex(doc.indexFromPos(pos) + 1)
}
/**
* Gets a value indicating whether the given position is
* _inside_ of an existing marker. Note that marker ranges
* are inclusive and this method takes that into account.
*/
function posIsInsideMarkedText(doc: Doc, pos: Position) {
const marks = (doc.findMarksAt(pos) as any) as ActualTextMarker[]
const ix = doc.indexFromPos(pos)
return marks.some(mark => {
const markPos = mark.find()
// This shouldn't ever happen since we just pulled them
// from the doc
if (!markPos) {
return false
}
const from = doc.indexFromPos(markPos.from)
const to = doc.indexFromPos(markPos.to)
return ix > from && ix < to
})
}
function isMarkOrWhitespace(doc: Doc, pos: Position) {
const line = doc.getLine(pos.line)
if (/\s/.test(line.charAt(pos.ch))) {
return true
}
return posIsInsideMarkedText(doc, pos)
}
function posEquals(x: Position, y: Position) {
return x.line === y.line && x.ch === y.ch
}
/**
* Scan through the doc, starting at the given start position and
* moving using the iter function for as long as the predicate is
* true or the iterator function fails to update the position (i.e
* at the start or end of the document)
*
* @param doc The codemirror document to scan through
*
* @param start The initial position, note that this position is
* not inclusive, i.e. the predicate will not be
* called for the initial position
*
* @param predicate A function called with each position returned
* from the iter function that determines whether
* or not to keep scanning through the document.
*
* If the predicate returns true this function will
* keep iterating.
*
* @param iter A function that, given either the start position
* or a position returned from the previous iter
* call, returns the next position to scan.
*/
function scanWhile(
doc: Doc,
start: Position,
predicate: (doc: Doc, pos: Position) => boolean,
iter: (doc: Doc, pos: Position) => Position
) {
let pos = start
for (
let next = iter(doc, start);
predicate(doc, next) && !posEquals(pos, next);
next = iter(doc, next)
) {
pos = next
}
return pos
}
/**
* Scan through the doc, starting at the given start position and
* moving using the iter function until the predicate returns
* true or the iterator function fails to update the position (i.e
* at the start or end of the document)
*
* @param doc The codemirror document to scan through
*
* @param start The initial position, note that this position is
* not inclusive, i.e. the predicate will not be
* called for the initial position
*
* @param predicate A function called with each position returned
* from the iter function that determines whether
* or not to keep scanning through the document.
*
* If the predicate returns false this function will
* keep iterating.
*
* @param iter A function that, given either the start position
* or a position returned from the previous iter
* call, returns the next position to scan.
*/
function scanUntil(
doc: Doc,
start: Position,
predicate: (doc: Doc, pos: Position) => boolean,
iter: (doc: Doc, pos: Position) => Position
): Position {
return scanWhile(doc, start, (doc, pos) => !predicate(doc, pos), iter)
}
/**
* Given a cursor position, expand it into a range covering as
* long of an autocompletable string as possible.
*/
function getHintRangeFromCursor(doc: Doc, cursor: Position) {
return {
from: scanUntil(doc, cursor, isMarkOrWhitespace, prevPosition),
to: scanUntil(doc, cursor, isMarkOrWhitespace, nextPosition),
}
}
function appendTextMarker(
cm: Editor,
text: string,
options: CodeMirror.TextMarkerOptions
): ActualTextMarker {
const doc = cm.getDoc()
const from = doc.posFromIndex(Infinity)
doc.replaceRange(text, from)
const to = doc.posFromIndex(Infinity)
return (doc.markText(from, to, options) as any) as ActualTextMarker
}
/**
* Comparison method for use in sorting lists of markers in ascending
* order of start positions.
*/
function orderByPosition(x: ActualTextMarker, y: ActualTextMarker) {
const xPos = x.find()
const yPos = y.find()
if (xPos === undefined || yPos === undefined) {
return compare(xPos, yPos)
}
return compare(xPos.from, yPos.from)
}
// The types for CodeMirror.TextMarker is all wrong, this is what it
// actually looks like
// eslint-disable-next-line typescript/interface-name-prefix
interface ActualTextMarker extends CodeMirror.TextMarkerOptions {
/** Remove the mark. */
clear(): void
/**
* Returns a {from, to} object (both holding document positions), indicating
* the current position of the marked range, or undefined if the marker is
* no longer in the document.
*/
find(): { from: Position; to: Position } | undefined
changed(): void
}
function renderUnknownUserAutocompleteItem(
elem: HTMLElement,
self: any,
data: any
) {
const text = data.username as string
const user = document.createElement('div')
user.classList.add('user', 'unknown')
const username = document.createElement('span')
username.className = 'username'
username.innerText = text
user.appendChild(username)
const description = document.createElement('span')
description.className = 'description'
description.innerText = `Search for user`
user.appendChild(description)
elem.appendChild(user)
}
function renderUserAutocompleteItem(elem: HTMLElement, self: any, data: any) {
const author = data.author as IAuthor
const user = document.createElement('div')
user.className = 'user'
// This will always be non-null when we get it from the
// autocompletion provider but let's be extra cautious
if (author.username) {
const username = document.createElement('span')
username.className = 'username'
username.innerText = author.username
user.appendChild(username)
}
const name = document.createElement('span')
name.className = 'name'
name.innerText = author.name
user.appendChild(name)
elem.appendChild(user)
}
/**
* Returns an email address which can be used on the host side to
* look up the user which is to be given attribution.
*
* If the user has a public email address specified in their profile
* that's used and if they don't then we'll generate a stealth email
* address.
*/
function getEmailAddressForUser(user: IUserHit) {
if (user.email && user.email.length > 0) {
return user.email
}
const url = URL.parse(user.endpoint)
const host =
url.hostname && getDotComAPIEndpoint() !== user.endpoint
? url.hostname
: 'github.com'
return `${user.username}@users.noreply.${host}`
}
function getDisplayTextForAuthor(author: IAuthor) {
return author.username === null ? author.name : `@${author.username}`
}
function renderHandleMarkReplacementElement(author: IAuthor) {
const elem = document.createElement('span')
elem.classList.add('handle')
elem.title = `${author.name} <${author.email}>`
elem.innerText = getDisplayTextForAuthor(author)
return elem
}
function renderUnknownHandleMarkReplacementElement(
username: string,
isError: boolean
) {
const elem = document.createElement('span')
elem.classList.add('handle', isError ? 'error' : 'progress')
elem.title = isError
? `Could not find user with username ${username}`
: `Searching for @${username}`
const symbol = isError ? OcticonSymbol.stop : OcticonSymbol.sync
const spinner = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
spinner.classList.add('icon')
if (!isError) {
spinner.classList.add('spin')
}
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
spinner.viewBox.baseVal.width = symbol.w
spinner.viewBox.baseVal.height = symbol.h
path.setAttribute('d', symbol.d)
spinner.appendChild(path)
elem.appendChild(document.createTextNode(`@${username}`))
elem.appendChild(spinner)
return elem
}
function markRangeAsHandle(
doc: Doc,
from: Position,
to: Position,
author: IAuthor
): ActualTextMarker {
const elem = renderHandleMarkReplacementElement(author)
return (doc.markText(from, to, {
atomic: true,
className: 'handle',
readOnly: false,
replacedWith: elem,
handleMouseEvents: true,
}) as any) as ActualTextMarker
}
function triggerAutoCompleteBasedOnCursorPosition(cm: Editor) {
const doc = cm.getDoc()
if (doc.somethingSelected()) {
return
}
const cursor = doc.getCursor()
const p = scanUntil(doc, cursor, isMarkOrWhitespace, prevPosition)
if (posEquals(cursor, p)) {
return
}
;(cm as any).showHint()
}
/**
* Convert a IUserHit object which is returned from
* user-autocomplete-provider into an IAuthor object.
*
* If the IUserHit object lacks an email address we'll
* attempt to create a stealth email address.
*/
function authorFromUserHit(user: IUserHit): IAuthor {
return {
name: user.name || user.username,
email: getEmailAddressForUser(user),
username: user.username,
}
}
/**
* Autocompletable input field for possible authors of a commit.
*
* Intended primarily for co-authors but written in a general enough
* fashion to deal only with authors in general.
*/
export class AuthorInput extends React.Component<IAuthorInputProps, {}> {
/**
* The codemirror instance if mounted, otherwise null
*/
private editor: Editor | null = null
/**
* Resize observer used for tracking width changes and
* refreshing the internal codemirror instance when
* they occur
*/
private readonly resizeObserver: ResizeObserver
private resizeDebounceId: number | null = null
private lastKnownWidth: number | null = null
/**
* Whether or not the hint (i.e. autocompleter)
* is currently active.
*/
private hintActive: boolean = false
/**
* A reference to the label mark (the persistent
* part of the placeholder text)
*/
private label: ActualTextMarker | null = null
/**
* A reference to the placeholder mark (the second
* part of the placeholder text which is collapsed
* when there's user input)
*/
private placeholder: ActualTextMarker | null = null
/**
* The internal list of authors. Note that codemirror
* ultimately is the source of truth for what authors
* are in here but we synchronize that into this field
* whenever codemirror reports a change. We also use
* this array to detect whether the author props have
* change, in which case we blow away everything and
* start from scratch.
*/
private authors: ReadonlyArray<IAuthor> = []
// For undo association
private readonly markAuthorMap = new Map<ActualTextMarker, IAuthor>()
private readonly authorMarkMap = new Map<IAuthor, ActualTextMarker>()
public constructor(props: IAuthorInputProps) {
super(props)
// Observe size changes and let codemirror know
// when it needs to refresh.
this.resizeObserver = new ResizeObserver(entries => {
if (entries.length === 1 && this.editor) {
const newWidth = entries[0].contentRect.width
// We don't care about the first resize, let's just
// store what we've got
if (!this.lastKnownWidth) {
this.lastKnownWidth = newWidth
return
}
// Codemirror already does a good job of height changes,
// we just need to care about when the width changes and
// do a re-layout
if (this.lastKnownWidth !== newWidth) {
this.lastKnownWidth = newWidth
if (this.resizeDebounceId !== null) {
cancelAnimationFrame(this.resizeDebounceId)
this.resizeDebounceId = null
}
requestAnimationFrame(this.onResized)
}
}
})
this.state = {}
}
public componentWillUnmount() {
// Sometimes the completion box seems to fail to register
// the blur event and close. It's hard to reproduce so
// we'll just make doubly sure it's closed when we're
// about to go away.
if (this.editor) {
const state = this.editor.state
if (state.completionActive && state.completionActive.close) {
state.completionActive.close()
}
}
}
public componentWillReceiveProps(nextProps: IAuthorInputProps) {
// If the authors prop have changed from our internal representation
// we'll throw up our hands and reset the input to whatever we're
// given.
if (
nextProps.authors !== this.props.authors &&
!arrayEquals(this.authors, nextProps.authors)
) {
const cm = this.editor
if (cm) {
cm.operation(() => {
this.reset(cm, nextProps.authors)
})
}
}
}
private onResized = () => {
this.resizeDebounceId = null
if (this.editor) {
this.editor.refresh()
}
}
private onContainerRef = (elem: HTMLDivElement) => {
if (elem) {
this.editor = this.initializeCodeMirror(elem)
this.resizeObserver.observe(elem)
} else {
this.editor = null
this.resizeObserver.disconnect()
}
}
private applyCompletion = (cm: Editor, data: any, completion: any) => {
const from: Position = completion.from || data.from
const to: Position = completion.to || data.to
const author: IAuthor = completion.author
this.insertAuthor(cm, author, from, to)
this.updateAuthors(cm)
}
private applyUnknownUserCompletion = (
cm: Editor,
data: any,
completion: any
) => {
const from: Position = completion.from || data.from
const to: Position = completion.to || data.to
const username: string = completion.username
const text = `@${username}`
const doc = cm.getDoc()
doc.replaceRange(text, from, to, 'complete')
const end = doc.posFromIndex(doc.indexFromPos(from) + text.length)
// Create a temporary, atomic, marker so that the text can't be modified.
// This marker will be styled in such a way as to indicate that it's
// processing.
const tmpMark = (doc.markText(from, end, {
atomic: true,
className: 'handle progress',
readOnly: false,
replacedWith: renderUnknownHandleMarkReplacementElement(username, false),
handleMouseEvents: true,
}) as any) as ActualTextMarker
// Note that it's important that this method isn't async up until
// this point since show-hint expects a synchronous method
return this.props.autoCompleteProvider.exactMatch(username).then(hit => {
cm.operation(() => {
const tmpPos = tmpMark.find()
// Since we're async here it's possible that the user has deleted
// the temporary mark already, in which case we just bail.
if (!tmpPos) {
return
}
// Clear out the temporary mark and get ready to either replace
// it with a proper handle marker or an error marker.
tmpMark.clear()
if (!hit) {
doc.markText(tmpPos.from, tmpPos.to, {
atomic: true,
className: 'handle error',
readOnly: false,
replacedWith: renderUnknownHandleMarkReplacementElement(
username,
true
),
handleMouseEvents: true,
})
return
}
this.insertAuthor(cm, authorFromUserHit(hit), tmpPos.from, tmpPos.to)
})
})
}
private insertAuthor(
cm: Editor,
author: IAuthor,
from: Position,
to?: Position
) {
const text = getDisplayTextForAuthor(author)
const doc = cm.getDoc()
doc.replaceRange(text, from, to, 'complete')
const end = doc.posFromIndex(doc.indexFromPos(from) + text.length)
const marker = markRangeAsHandle(doc, from, end, author)
this.markAuthorMap.set(marker, author)
this.authorMarkMap.set(author, marker)
return marker
}
private appendAuthor(cm: Editor, author: IAuthor) {
const doc = cm.getDoc()
return this.insertAuthor(cm, author, doc.posFromIndex(Infinity))
}
private onAutocompleteUser = async (cm: Editor, x?: any, y?: any) => {
const doc = cm.getDoc()
const cursor = doc.getCursor() as Readonly<Position>
const { from, to } = getHintRangeFromCursor(doc, cursor)
const word = doc.getRange(from, to)
const needle = word.replace(/^@/, '')
const hits = await this.props.autoCompleteProvider.getAutocompletionItems(
needle
)
const exactMatch =
hits.length === 1 &&
hits[0].username.toLowerCase() === needle.toLowerCase()
const existingUsernames = new Set(this.authors.map(x => x.username))
const list: any[] = hits
.map(authorFromUserHit)
.filter(x => x.username === null || !existingUsernames.has(x.username))
.map(author => ({
author,
text: getDisplayTextForAuthor(author),
render: renderUserAutocompleteItem,
className: 'autocompletion-item',
hint: this.applyCompletion,
}))
if (!exactMatch && needle.length > 0 && validLoginExpression.test(needle)) {
list.push({
text: `@${needle}`,
username: needle,
render: renderUnknownUserAutocompleteItem,
className: 'autocompletion-item',
hint: this.applyUnknownUserCompletion,
})
}
return { list, from, to }
}
private updatePlaceholderVisibility(cm: Editor) {
if (this.label && this.placeholder) {
const labelRange = this.label.find()
const placeholderRange = this.placeholder.find()
// If this happen then codemirror has done something
// weird. It shouldn't be possible to remove these
// markers from the document.
if (!labelRange || !placeholderRange) {
return
}
const doc = cm.getDoc()
const collapse =
doc.indexFromPos(labelRange.to) !==
doc.indexFromPos(placeholderRange.from)
if (this.placeholder.collapsed !== collapse) {
this.placeholder.collapsed = collapse
this.placeholder.changed()
}
}
}
private getAllHandleMarks(cm: Editor): Array<ActualTextMarker> {
return (cm.getDoc().getAllMarks() as any) as ActualTextMarker[]
}
private initializeCodeMirror(host: HTMLDivElement) {
const CodeMirrorOptions: CodeMirror.EditorConfiguration & {
hintOptions: any
} = {
mode: null,
lineWrapping: true,
extraKeys: {
Tab: false,
Enter: false,
'Shift-Tab': false,
'Ctrl-Space': 'autocomplete',
'Ctrl-Enter': false,
'Cmd-Enter': false,
},
hintOptions: {
completeOnSingleClick: true,
completeSingle: false,
closeOnUnfocus: true,
closeCharacters: /\s/,
hint: this.onAutocompleteUser,
},
}
const cm = CodeMirror(host, CodeMirrorOptions)
cm.operation(() => {
this.reset(cm, this.props.authors)
})
cm.on('startCompletion', () => {
this.hintActive = true
})
cm.on('endCompletion', () => {
this.hintActive = false
})
cm.on('change', () => {
this.updatePlaceholderVisibility(cm)
if (!this.hintActive) {
triggerAutoCompleteBasedOnCursorPosition(cm)
}
})
cm.on('focus', () => {
if (!this.hintActive) {
triggerAutoCompleteBasedOnCursorPosition(cm)
}
})
cm.on('changes', () => {
this.updateAuthors(cm)
})
// Do the very least we can do to pretend that we're a
// single line textbox. Users can still paste newlines
// though and if the do we don't care.
cm.getWrapperElement().addEventListener('keypress', (e: KeyboardEvent) => {
if (!e.defaultPrevented && e.key === 'Enter') {
e.preventDefault()
}
})
return cm
}
private updateAuthors(cm: Editor) {
const markers = this.getAllHandleMarks(cm).sort(orderByPosition)
const authors = new Array<IAuthor>()
for (const marker of markers) {
const author = this.markAuthorMap.get(marker)
// undefined authors shouldn't happen lol
if (author) {
authors.push(author)
}
}
if (!arrayEquals(this.authors, authors)) {
this.authors = authors
this.props.onAuthorsUpdated(authors)
}
}
private reset(cm: Editor, authors: ReadonlyArray<IAuthor>) {
const doc = cm.getDoc()
cm.setValue('')
doc.clearHistory()
this.authors = []
this.authorMarkMap.clear()
this.markAuthorMap.clear()
this.label = appendTextMarker(cm, 'Co-Authors ', {
atomic: true,
inclusiveLeft: true,
className: 'label',
readOnly: true,
})
for (const author of authors) {
this.appendAuthor(cm, author)
}
this.authors = this.props.authors
this.placeholder = appendTextMarker(cm, '@username', {
atomic: true,
inclusiveRight: true,
className: 'placeholder',
readOnly: true,
collapsed: authors.length > 0,
})
// We know that find won't returned undefined here because we
// _just_ put the placeholder in there
doc.setCursor(this.placeholder.find()!.from)
}
public render() {
const className = classNames('author-input-component', this.props.className)
return <div className={className} ref={this.onContainerRef} />
}
}

View file

@ -0,0 +1,51 @@
import * as React from 'react'
import * as classNames from 'classnames'
import { Avatar } from './avatar'
import { IAvatarUser } from '../../models/avatar'
/**
* The maximum number of avatars to stack before hiding
* the rest behind the hover action. Note that changing this
* means that the css needs to change as well.
*/
const MaxDisplayedAvatars = 3
interface IAvatarStackProps {
readonly users: ReadonlyArray<IAvatarUser>
}
/**
* A component which renders one or more avatars into a stacked
* view which expands on hover, replicated from github.com's
* avatar stacks.
*/
export class AvatarStack extends React.Component<IAvatarStackProps, {}> {
public render() {
const elems = []
const users = this.props.users
for (let i = 0; i < this.props.users.length; i++) {
const user = users[i]
if (users.length > MaxDisplayedAvatars && i === MaxDisplayedAvatars - 1) {
elems.push(<div key="more" className="avatar-more avatar" />)
}
elems.push(
<Avatar key={`${i}${user.avatarURL}`} user={user} title={null} />
)
}
const className = classNames('AvatarStack', {
'AvatarStack--small': true,
'AvatarStack--two': users.length === 2,
'AvatarStack--three-plus': users.length >= MaxDisplayedAvatars,
})
return (
<div className={className}>
<div className="AvatarStack-body">{elems}</div>
</div>
)
}
}

View file

@ -9,8 +9,12 @@ interface IAvatarProps {
/** The user whose avatar should be displayed. */
readonly user?: IAvatarUser
/** The title of the avatar. Defaults to the name and email. */
readonly title?: string
/**
* The title of the avatar.
* Defaults to the name and email if undefined and is
* skipped completely if title is null
*/
readonly title?: string | null
}
interface IAvatarState {
@ -19,6 +23,8 @@ interface IAvatarState {
/** A component for displaying a user avatar. */
export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
private cancelFetchingAvatar = false
public constructor(props: IAvatarProps) {
super(props)
@ -53,16 +59,36 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
public async componentWillMount() {
const dataUrl = await fetchAvatarUrl(DefaultAvatarURL, this.props.user)
this.setState({ dataUrl })
// https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html
// We're basically doing isMounted here. Let's look at better ways
// in the future
if (!this.cancelFetchingAvatar) {
this.setState({ dataUrl })
}
}
public async componentWillReceiveProps(nextProps: IAvatarProps) {
const dataUrl = await fetchAvatarUrl(DefaultAvatarURL, nextProps.user)
this.setState({ dataUrl })
// https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html
// We're basically doing isMounted here. Let's look at better ways
// in the future
if (!this.cancelFetchingAvatar) {
this.setState({ dataUrl })
}
}
private getTitle(): string {
if (this.props.title) {
public componentWillUnmount() {
this.cancelFetchingAvatar = true
}
private getTitle(): string | undefined {
if (this.props.title === null) {
return undefined
}
if (this.props.title === undefined) {
return this.props.title
}
@ -85,15 +111,23 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
? `Avatar for ${this.props.user.name || this.props.user.email}`
: `Avatar for unknown user`
const img = (
<img
className="avatar"
title={title}
src={this.state.dataUrl}
alt={title}
aria-label={ariaLabel}
/>
)
if (title === undefined) {
return img
}
return (
<span title={title} className="avatar-container">
<img
className="avatar"
title={title}
src={this.state.dataUrl}
alt={title}
aria-label={ariaLabel}
/>
{img}
</span>
)
}

View file

@ -0,0 +1,94 @@
import { Commit } from '../../models/commit'
import * as React from 'react'
import { CommitIdentity } from '../../models/commit-identity'
import { GitAuthor } from '../../models/git-author'
import { GitHubRepository } from '../../models/github-repository'
interface ICommitAttributionProps {
/**
* The commit from where to extract the author, commiter
* and co-authors from.
*/
readonly commit: Commit
/**
* The GitHub hosted repository that the given commit is
* associated with or null if repository is local or
* not associated with a GitHub account. Used to determine
* whether a commit is a special GitHub web flow user.
*/
readonly gitHubRepository: GitHubRepository | null
}
/**
* A component used for listing the authors involved in
* a commit, formatting the content as close to what
* GitHub.com does as possible.
*/
export class CommitAttribution extends React.Component<
ICommitAttributionProps,
{}
> {
private renderAuthorInline(author: CommitIdentity | GitAuthor) {
return <span className="author">{author.name}</span>
}
private renderAuthors(
authors: ReadonlyArray<CommitIdentity | GitAuthor>,
committerAttribution: boolean
) {
if (authors.length === 1) {
return (
<span className="authors">{this.renderAuthorInline(authors[0])}</span>
)
} else if (authors.length === 2 && !committerAttribution) {
return (
<span className="authors">
{this.renderAuthorInline(authors[0])}
{' and '}
{this.renderAuthorInline(authors[1])}
</span>
)
} else {
const title = authors.map(a => a.name).join(', ')
return (
<span className="authors" title={title}>
{authors.length} people
</span>
)
}
}
private renderCommitter(committer: CommitIdentity) {
return (
<span className="committer">
{' and '}
{this.renderAuthorInline(committer)}
{' committed'}
</span>
)
}
public render() {
const commit = this.props.commit
const { author, committer, coAuthors } = commit
const authors: Array<CommitIdentity | GitAuthor> = [author, ...coAuthors]
const committerAttribution =
!commit.authoredByCommitter &&
!(
this.props.gitHubRepository !== null &&
commit.isWebFlowCommitter(this.props.gitHubRepository)
)
return (
<span className="commit-attribution-component">
{this.renderAuthors(authors, committerAttribution)}
{committerAttribution ? ' authored' : ' committed'}
{committerAttribution ? this.renderCommitter(committer) : null}
</span>
)
}
}

View file

@ -88,6 +88,8 @@ export class ConfigureGitUser extends React.Component<
'Fix all the things',
'',
author,
author,
[],
[]
)
const emoji = new Map()
@ -120,7 +122,7 @@ export class ConfigureGitUser extends React.Component<
<CommitListItem
commit={dummyCommit}
emoji={emoji}
user={this.getAvatarUser()}
gitHubUsers={null}
gitHubRepository={null}
isLocal={false}
/>
@ -129,17 +131,6 @@ export class ConfigureGitUser extends React.Component<
)
}
private getAvatarUser() {
const email = this.state.email
const avatarURL = this.state.avatarURL
const name = this.state.name
if (email && avatarURL && name) {
return { email, avatarURL, name }
} else {
return null
}
}
private onNameChange = (event: React.FormEvent<HTMLInputElement>) => {
this.setState({
name: event.currentTarget.value,

View file

@ -0,0 +1,80 @@
import * as React from 'react'
import * as classNames from 'classnames'
interface IFocusContainerProps {
readonly className?: string
readonly onClick?: (event: React.MouseEvent<HTMLDivElement>) => void
}
interface IFocusContainerState {
readonly focusWithin: boolean
}
/**
* A helper component which appends a classname to a wrapper
* element if any of its descendant nodes currently has
* keyboard focus.
*
* In other words it's a little workaround that lets use
* use `:focus-within`
* https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-within
* even though it's not supported in our current version
* of chromium (it'll be in 60 or 61 depending on who you trust)
*/
export class FocusContainer extends React.Component<
IFocusContainerProps,
IFocusContainerState
> {
private wrapperRef: HTMLDivElement | null = null
public constructor(props: IFocusContainerProps) {
super(props)
this.state = { focusWithin: false }
}
private onWrapperRef = (elem: HTMLDivElement) => {
if (elem) {
elem.addEventListener('focusin', () => {
this.setState({ focusWithin: true })
})
elem.addEventListener('focusout', () => {
this.setState({ focusWithin: false })
})
}
this.wrapperRef = elem
}
private onClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (this.props.onClick) {
this.props.onClick(e)
}
}
private onMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// If someone is clicking on the focuscontainer itself we'll
// cancel it, that saves us from having a focusout/in cycle
// and a janky focus ring toggle.
if (e.target === this.wrapperRef) {
e.preventDefault()
}
}
public render() {
const className = classNames('focus-container', this.props.className, {
'focus-within': this.state.focusWithin,
})
return (
<div
className={className}
ref={this.onWrapperRef}
onClick={this.onClick}
onMouseDown={this.onMouseDown}
>
{this.props.children}
</div>
)
}
}

View file

@ -76,7 +76,7 @@ export class RelativeTime extends React.Component<
// Future date, let's just show as absolute and reschedule. If it's less
// than a minute into the future we'll treat it as 'just now'.
if (diff > 0 && duration > MINUTE) {
this.updateAndSchedule(absoluteText, then.format('LLL'), duration)
this.updateAndSchedule(absoluteText, then.format('lll'), duration)
} else if (duration < MINUTE) {
this.updateAndSchedule(absoluteText, 'just now', MINUTE - duration)
} else if (duration < HOUR) {
@ -86,7 +86,7 @@ export class RelativeTime extends React.Component<
} else if (duration < 7 * DAY) {
this.updateAndSchedule(absoluteText, then.from(now), 6 * HOUR)
} else {
this.setState({ absoluteText, relativeText: then.format('LL') })
this.setState({ absoluteText, relativeText: then.format('ll') })
}
}

View file

@ -17,6 +17,7 @@ import { Dispatcher } from '../lib/dispatcher'
import { IssuesStore, GitHubUserStore } from '../lib/stores'
import { assertNever } from '../lib/fatal-error'
import { Octicon, OcticonSymbol } from './octicons'
import { Account } from '../models/account'
/** The widest the sidebar can be with the minimum window size. */
const MaxSidebarWidth = 495
@ -33,6 +34,7 @@ interface IRepositoryProps {
readonly onViewCommitOnGitHub: (SHA: string) => void
readonly imageDiffType: ImageDiffType
readonly askForConfirmationOnDiscardChanges: boolean
readonly accounts: ReadonlyArray<Account>
}
const enum Tab {
@ -98,6 +100,7 @@ export class RepositoryView extends React.Component<IRepositoryProps, {}> {
askForConfirmationOnDiscardChanges={
this.props.askForConfirmationOnDiscardChanges
}
accounts={this.props.accounts}
/>
)
}

View file

@ -60,3 +60,7 @@
@import 'ui/ci-status';
@import 'ui/pull-request-badge';
@import 'ui/no-branches';
@import 'ui/codemirror';
@import 'ui/author-input';
@import 'ui/avatar-stack';
@import 'ui/commit-attribution';

View file

@ -0,0 +1,67 @@
.author-input-component {
.CodeMirror {
border: 1px solid var(--box-border-color);
border-radius: var(--border-radius);
font-size: var(--font-size);
font-family: var(--font-family-sans-serif);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
height: auto;
.label {
color: var(--text-secondary-color);
}
.placeholder {
color: var(--text-secondary-color);
cursor: text;
}
.handle {
border-radius: 3px;
border: 1px solid #c3e1ff;
background: #f0f8ff;
padding: 1px 1px;
margin: 0px 2px;
&.progress {
background: transparent;
}
&.error {
background: var(--form-error-background);
color: var(--form-error-text-color);
border-color: var(--form-error-border-color);
svg {
padding-bottom: 2px;
}
}
&.progress,
&.error {
svg {
height: 9px;
margin-left: 3px;
margin-right: 3px;
vertical-align: middle;
fill: currentColor;
}
}
}
}
.CodeMirror-focused {
outline: none;
border-color: var(--focus-color);
box-shadow: 0 0 0 1px var(--text-field-focus-shadow-color);
border-top-width: 1px;
border-top-style: solid;
margin-top: -1px;
}
.CodeMirror-scroll {
max-height: 80px;
}
}

View file

@ -1,4 +1,4 @@
@import "../mixins";
@import '../mixins';
.autocompletion-container {
position: relative;
@ -6,32 +6,70 @@
flex-direction: column;
}
.CodeMirror-hints {
list-style: none;
flex-direction: column;
padding: 0;
max-height: 100px;
// hack to position the dialog a little better than the
// default codemirror position.
transform: translate(0, -15px);
overflow-y: auto;
li {
height: 29px;
padding: 0 var(--spacing);
}
// We can't replicate the nice scrollbars that we have in
// the regular autocomplete popup since that's based on List
// and this CodeMirror stuff is just an unordered list.
// The autocomplete popup is not intended to be used with
// a pointer-device anyway so not being able to mouse-wheel
// through it shouldn't be a big deal
@include win32 {
overflow-y: hidden;
}
}
.autocompletion-popup {
overflow: hidden; // To get those sweet rounded corners
}
.autocompletion-popup,
.CodeMirror-hints {
display: flex;
position: absolute;
z-index: var(--popup-z-index);
width: 250px;
border-radius: var(--border-radius);
overflow: hidden; // To get those sweet rounded corners
&.emoji { width: 200px; }
&.user { width: 220px; }
&.issue { width: 300px; }
&.emoji {
width: 200px;
}
&.user {
width: 220px;
}
&.issue {
width: 300px;
}
background-color: var(--background-color);
box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.3);
.list-item
{
.list-item,
li {
border-bottom: none;
&:not(:first-child) {
border-top: var(--base-border);
}
&.selected {
&.selected,
&.CodeMirror-hint-active {
--text-color: var(--box-selected-active-text-color);
--text-secondary-color: var(--box-selected-active-text-color);
@ -39,15 +77,15 @@
background-color: var(--box-selected-active-background-color);
border-top-color: var(--box-selected-active-background-color);
& + .list-item {
border-top-color: var(--box-selected-active-background-color);
& + .list-item,
& + .CodeMirror-hint-active {
border-top-color: var(--box-selected-active-background-color);
}
}
}
}
.autocompletion-item {
flex-grow: 1;
height: 100%;
min-width: 0;
@ -61,7 +99,6 @@
}
.emoji {
display: flex;
flex-grow: 1;
@ -108,14 +145,27 @@
height: 100%;
width: 100%;
&.unknown {
.username {
font-style: italic;
}
.description {
@include ellipsis;
color: var(--text-secondary-color);
font-style: italic;
font-size: var(--font-size-sm);
}
}
.username {
@include ellipsis;
font-weight: var(--font-weight-semibold);
margin-right: var(--spacing-half);
}
.name {
@include ellipsis;
color: var(--text-secondary-color);
}
}

View file

@ -0,0 +1,133 @@
.AvatarStack {
position: relative;
min-width: 26px;
height: 20px;
.AvatarStack-body {
position: absolute;
}
&.AvatarStack--two {
min-width: 36px;
}
&.AvatarStack--three-plus {
min-width: 46px;
}
&.AvatarStack--small {
height: 16px;
min-width: 20px;
.avatar {
width: 16px;
height: 16px;
}
&.AvatarStack--two {
min-width: 25px;
}
&.AvatarStack--three-plus {
min-width: 30px;
}
.avatar.avatar-more {
&::before,
&::after {
height: 16px;
}
&::before {
width: 13px;
}
}
&:hover {
.avatar {
margin-right: 1px;
}
}
}
}
.AvatarStack-body {
display: flex;
background: inherit;
.avatar {
position: relative;
z-index: 2;
display: flex;
width: 20px;
height: 20px;
box-sizing: content-box;
margin-right: -11px;
background-color: inherit;
box-shadow: 1px 0 0 $white;
border-radius: 2px;
transition: margin 0.1s ease-in-out;
&:first-child {
z-index: 3;
}
&:last-child {
z-index: 1;
box-shadow: none;
}
// stylelint-disable selector-max-type
img {
border-radius: 2px;
}
// stylelint-enable selector-max-type
// Account for 4+ avatars
&:nth-child(n + 4) {
display: none;
opacity: 0;
}
}
&:hover {
.avatar {
margin-right: 3px;
}
.avatar:nth-child(n + 4) {
display: flex;
opacity: 1;
}
.avatar-more {
display: none !important;
}
}
}
.avatar.avatar-more {
z-index: 1;
margin-right: 0;
background: $gray-100;
&::before,
&::after {
position: absolute;
display: block;
height: 20px;
content: '';
border-radius: 2px;
outline: 1px solid $white;
}
&::before {
width: 17px;
background: $gray-200;
}
&::after {
width: 14px;
background: $gray-300;
}
}

View file

@ -0,0 +1,53 @@
// Use the platform default background color for text
// selection when the editor has a selection _and_ is
// focused.
.CodeMirror-focused {
.CodeMirror-selected {
background: Highlight;
}
.CodeMirror-selectedtext {
color: HighlightText !important;
}
}
.CodeMirror-lines * {
cursor: text;
}
// If the editor isn't focused then we should hide
// any trace of the selection to match native input
// control behavior.
.CodeMirror-selectedtext {
color: inherit;
}
.CodeMirror-selected {
background: inherit;
}
// Windows has custom scroll bars, see _scroll.scss
@include win32-context {
.CodeMirror {
// Mirror the behavior of other scroll bars in desktop on Windows
// and only show them while hovering over the scroll container.
// We can't use display here since that's set as an inline style
// by CodeMirror so we'll resort to the old opacity hack.
&-vscrollbar,
&-hscrollbar {
opacity: 0;
}
&:hover {
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar {
opacity: 1;
}
}
// This is a div that sits in the corner between the horizontal
// and vertical scroll bar and has an opaque background by default.
&-scrollbar-filler {
background: transparent;
}
}
}

View file

@ -0,0 +1,5 @@
@import '../mixins';
.commit-attribution-component {
@include ellipsis;
}

View file

@ -6,7 +6,7 @@
min-width: 0;
}
.CodeMirror {
.diff-code-mirror .CodeMirror {
height: 100%;
color: var(--diff-text-color);
font-size: var(--font-size-sm);
@ -54,15 +54,6 @@
// Add a border to the end of the diff
border-bottom: var(--base-border);
}
&-focused &-selected,
&-selected {
background: var(--diff-selected-background-color);
}
&-selectedtext {
color: var(--diff-selected-text-color) !important;
}
}
// The container element which holds the before and after
@ -235,33 +226,6 @@
}
}
// Windows has custom scroll bars, see _scroll.scss
@include win32-context {
.CodeMirror {
// Mirror the behavior of other scroll bars in desktop on Windows
// and only show them while hovering over the scroll container.
// We can't use display here since that's set as an inline style
// by CodeMirror so we'll resort to the old opacity hack.
&-vscrollbar,
&-hscrollbar {
opacity: 0;
}
&:hover {
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar {
opacity: 1;
}
}
// This is a div that sits in the corner between the horizontal
// and vertical scroll bar and has an opaque background by default.
&-scrollbar-filler {
background: transparent;
}
}
}
#diff .panel {
padding-top: var(--spacing);
}

View file

@ -33,10 +33,14 @@
// with the height of the content in the main container.
// Hide the actual scroll bar
.ReactVirtualized__Grid::-webkit-scrollbar { display: none; }
.ReactVirtualized__Grid::-webkit-scrollbar {
display: none;
}
// Hide the scroll bar by default
.fake-scroll { display: none; }
.fake-scroll {
display: none;
}
&:hover {
.fake-scroll {
@ -59,7 +63,6 @@
}
.list-item {
display: flex;
flex-direction: row;
align-items: center;
@ -83,5 +86,7 @@
}
}
&:focus { outline: none; }
&:focus {
outline: none;
}
}

View file

@ -1,8 +1,7 @@
@import "../../mixins";
@import '../../mixins';
/** A React component holding the commit message entry */
#commit-message {
border-top: 1px solid var(--box-border-color);
flex-direction: column;
flex-shrink: 0;
@ -20,18 +19,149 @@
flex: 1;
margin-bottom: var(--spacing);
input { width: 100%; }
input {
width: 100%;
}
}
.avatar {
flex-shrink: 0;
width: var(--text-field-height);
height:var(--text-field-height);
height: var(--text-field-height);
border: var(--base-border);
margin-right: var(--spacing-half);
}
}
&.with-co-authors .description-focus-container {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
// Hack for when co-authors field is merged
// with description text area.
&.focus-within {
z-index: 1;
}
}
.co-authors-toggle {
color: $gray-500;
&:hover {
color: $gray-900;
}
}
&.with-co-authors .co-authors-toggle {
color: $blue-400;
&:hover {
color: $blue-600;
}
}
// When we have an action bar we steal most of that
// space from the description field and a little from the
// changes list
&.with-action-bar {
.description-field textarea {
min-height: 80px;
}
}
.description-focus-container {
border: 1px solid var(--box-border-color);
border-radius: var(--border-radius);
background: var(--background-color);
// Fake that we're a text-box
cursor: text;
&.focus-within {
outline: none;
border-color: var(--focus-color);
box-shadow: 0 0 0 1px var(--text-field-focus-shadow-color);
}
.description-field.with-overflow {
position: relative;
&:after {
content: '';
position: absolute;
width: 100%;
bottom: 0px;
height: 5px;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.1) 100%
);
border-bottom: var(--base-border);
}
}
textarea {
border: none;
border-radius: 0;
color: currentColor;
font-size: var(--font-size);
font-family: var(--font-family-sans-serif);
padding: var(--spacing-half);
resize: none;
min-height: 100px;
&:focus {
outline: 0;
border: none;
box-shadow: none;
}
}
.action-bar {
padding: var(--spacing-half);
display: inline-block;
// We're not faking being a textbox any more
cursor: default;
// This gets rid of the padding for the action bar
// when there's no buttons showing.
&:empty {
display: none;
}
}
.co-authors-toggle {
height: 16px;
width: 16px;
cursor: pointer;
&:after {
content: attr(aria-label);
position: absolute;
margin-left: 3px;
margin-top: 1px;
font-size: var(--font-size-sm);
color: var(--text-secondary-color);
opacity: 0;
transition: 150ms all ease-in-out;
pointer-events: none;
}
&:hover {
&:after {
opacity: 1;
}
}
svg {
height: 7px;
width: 12px;
pointer-events: none;
}
}
}
.commit-button {
max-width: 100%;
margin-top: var(--spacing);
@ -52,6 +182,8 @@
* the contents of the button in a block element and put
* ellipsis on that instead. See commit 67fad24ed
*/
> span { @include ellipsis }
> span {
@include ellipsis;
}
}
}

View file

@ -1,8 +1,7 @@
@import "../../mixins";
@import '../../mixins';
/** A React component holding history's commit list */
#commit-list {
display: flex;
flex-direction: column;
flex: 1;
@ -18,19 +17,26 @@
padding: 0 var(--spacing);
.avatar {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.info {
display: flex;
flex-direction: column;
margin-top: -5px;
overflow: hidden;
margin-left: var(--spacing-half);
width: 100%;
.summary, .byline { @include ellipsis }
.description {
display: flex;
flex-direction: row;
}
.summary {
font-weight: var(--font-weight-semibold);
}
.summary,
.byline {
@include ellipsis;
}
}
}
}

View file

@ -115,6 +115,9 @@
}
&-meta-item {
display: flex;
flex-direction: row;
@include ellipsis;
margin-right: var(--spacing);
font-size: var(--font-size-sm);

View file

@ -1,23 +1,101 @@
import { expect } from 'chai'
import { formatCommitMessage } from '../../src/lib/format-commit-message'
import { setupEmptyRepository } from '../helpers/repositories'
describe('formatCommitMessage', () => {
it('omits description when null', () => {
expect(
formatCommitMessage({ summary: 'test', description: null })
).to.equal('test')
})
it('always adds trailing newline', async () => {
const repo = await setupEmptyRepository()
it('omits description when empty string', () => {
expect(formatCommitMessage({ summary: 'test', description: '' })).to.equal(
'test'
expect(await formatCommitMessage(repo, 'test', null)).to.equal('test\n')
expect(await formatCommitMessage(repo, 'test', 'test')).to.equal(
'test\n\ntest\n'
)
})
it('adds two newlines between summary and description', () => {
it('omits description when null', async () => {
const repo = await setupEmptyRepository()
expect(await formatCommitMessage(repo, 'test', null)).to.equal('test\n')
})
it('omits description when empty string', async () => {
const repo = await setupEmptyRepository()
expect(await formatCommitMessage(repo, 'test', '')).to.equal('test\n')
})
it('adds two newlines between summary and description', async () => {
const repo = await setupEmptyRepository()
expect(await formatCommitMessage(repo, 'foo', 'bar')).to.equal(
'foo\n\nbar\n'
)
})
it('appends trailers to a summary-only message', async () => {
const repo = await setupEmptyRepository()
const trailers = [
{ token: 'Co-Authored-By', value: 'Markus Olsson <niik@github.com>' },
{ token: 'Signed-Off-By', value: 'nerdneha <nerdneha@github.com>' },
]
expect(await formatCommitMessage(repo, 'foo', null, trailers)).to.equal(
'foo\n\n' +
'Co-Authored-By: Markus Olsson <niik@github.com>\n' +
'Signed-Off-By: nerdneha <nerdneha@github.com>\n'
)
})
it('appends trailers to a regular message', async () => {
const repo = await setupEmptyRepository()
const trailers = [
{ token: 'Co-Authored-By', value: 'Markus Olsson <niik@github.com>' },
{ token: 'Signed-Off-By', value: 'nerdneha <nerdneha@github.com>' },
]
expect(await formatCommitMessage(repo, 'foo', 'bar', trailers)).to.equal(
'foo\n\nbar\n\n' +
'Co-Authored-By: Markus Olsson <niik@github.com>\n' +
'Signed-Off-By: nerdneha <nerdneha@github.com>\n'
)
})
// note, this relies on the default git config
it('merges duplicate trailers', async () => {
const repo = await setupEmptyRepository()
const trailers = [
{ token: 'Co-Authored-By', value: 'Markus Olsson <niik@github.com>' },
{ token: 'Signed-Off-By', value: 'nerdneha <nerdneha@github.com>' },
]
expect(
formatCommitMessage({ summary: 'foo', description: 'bar' })
).to.equal('foo\n\nbar')
await formatCommitMessage(
repo,
'foo',
'Co-Authored-By: Markus Olsson <niik@github.com>',
trailers
)
).to.equal(
'foo\n\n' +
'Co-Authored-By: Markus Olsson <niik@github.com>\n' +
'Signed-Off-By: nerdneha <nerdneha@github.com>\n'
)
})
// note, this relies on the default git config
it('fixes up malformed trailers when trailers are given', async () => {
const repo = await setupEmptyRepository()
const trailers = [
{ token: 'Signed-Off-By', value: 'nerdneha <nerdneha@github.com>' },
]
expect(
await formatCommitMessage(
repo,
'foo',
// note the lack of space after :
'Co-Authored-By:Markus Olsson <niik@github.com>',
trailers
)
).to.equal(
'foo\n\n' +
'Co-Authored-By: Markus Olsson <niik@github.com>\n' +
'Signed-Off-By: nerdneha <nerdneha@github.com>\n'
)
})
})

View file

@ -1,25 +1,21 @@
/* eslint-disable no-sync */
import { expect } from 'chai'
import * as Fs from 'fs'
import * as Path from 'path'
import { GitProcess } from 'dugite'
import { shell } from '../helpers/test-app-shell'
import {
setupEmptyRepository,
setupFixtureRepository,
setupConflictedRepo,
} from '../helpers/repositories'
import { GitStore } from '../../src/lib/stores/git-store'
import { GitStore } from '../../src/lib/stores'
import { AppFileStatus } from '../../src/models/status'
import { Repository } from '../../src/models/repository'
import { Commit } from '../../src/models/commit'
import { TipState, IValidBranch } from '../../src/models/tip'
import { getCommit, getStatus } from '../../src/lib/git'
describe('GitStore', () => {
@ -42,7 +38,6 @@ describe('GitStore', () => {
await GitProcess.exec(['commit', '-m', 'added readme file'], repo.path)
Fs.writeFileSync(readmeFilePath, 'WRITING SOME NEW WORDS\n')
// setup requires knowing about the current tip
await gitStore.loadStatus()
@ -52,33 +47,14 @@ describe('GitStore', () => {
expect(files.length).to.equal(2)
expect(files[0].path).to.equal('README.md')
expect(files[0].status).to.equal(AppFileStatus.Modified)
expect(files[1].path).to.equal('LICENSE.md')
expect(files[1].status).to.equal(AppFileStatus.New)
// ignore the file
await gitStore.ignore(licenseFile)
status = await getStatus(repo)
files = status.workingDirectory.files
expect(files.length).to.equal(2)
expect(files[0].path).to.equal('README.md')
expect(files[0].status).to.equal(AppFileStatus.Modified)
expect(files[1].path).to.equal('.gitignore')
expect(files[1].status).to.equal(AppFileStatus.New)
// discard the .gitignore change
// discard the LICENSE.md file
await gitStore.discardChanges([files[1]])
// we should see the original file, modified
status = await getStatus(repo)
files = status.workingDirectory.files
expect(files.length).to.equal(2)
expect(files[0].path).to.equal('README.md')
expect(files[0].status).to.equal(AppFileStatus.Modified)
expect(files[1].path).to.equal('LICENSE.md')
expect(files[1].status).to.equal(AppFileStatus.New)
expect(files.length).to.equal(1)
})
it('can discard a renamed file', async () => {
@ -236,81 +212,4 @@ describe('GitStore', () => {
expect(files.length).to.equal(0)
})
})
describe('ignore files', () => {
it('can commit a change', async () => {
const repo = await setupEmptyRepository()
const gitStore = new GitStore(repo, shell)
await gitStore.saveGitIgnore('node_modules\n')
await GitProcess.exec(['add', '.gitignore'], repo.path)
await GitProcess.exec(
['commit', '-m', 'create the ignore file'],
repo.path
)
await gitStore.saveGitIgnore('node_modules\n*.exe\n')
await GitProcess.exec(['add', '.gitignore'], repo.path)
await GitProcess.exec(['commit', '-m', 'update the file'], repo.path)
const status = await getStatus(repo)
const files = status.workingDirectory.files
expect(files.length).to.equal(0)
})
describe('autocrlf and safecrlf', () => {
let repo: Repository | null
let gitStore: GitStore | null
beforeEach(async () => {
repo = await setupEmptyRepository()
gitStore = new GitStore(repo!, shell)
await GitProcess.exec(
['config', '--local', 'core.autocrlf', 'true'],
repo.path
)
await GitProcess.exec(
['config', '--local', 'core.safecrlf', 'true'],
repo.path
)
})
it('respects config when updating', async () => {
const fixture = gitStore!
const path = repo!.path
// first pass - save a single entry
await fixture.saveGitIgnore('node_modules\n')
await GitProcess.exec(['add', '.gitignore'], path)
await GitProcess.exec(['commit', '-m', 'create the ignore file'], path)
// second pass - update the file with a new entry
await fixture.saveGitIgnore('node_modules\n*.exe\n')
await GitProcess.exec(['add', '.gitignore'], path)
await GitProcess.exec(['commit', '-m', 'update the file'], path)
const status = await getStatus(repo!)
const files = status.workingDirectory.files
expect(files.length).to.equal(0)
})
it('appends newline to file', async () => {
const fixture = gitStore!
const path = repo!.path
await fixture.saveGitIgnore('node_modules')
await GitProcess.exec(['add', '.gitignore'], path)
const commit = await GitProcess.exec(
['commit', '-m', 'create the ignore file'],
path
)
expect(commit.exitCode).to.equal(0)
const contents = await fixture.readGitIgnore()
expect(contents!.endsWith('\r\n'))
})
})
})
})

View file

@ -31,7 +31,17 @@ describe('git/checkout', () => {
date: new Date(),
tzOffset: 0,
},
committer: {
name: '',
email: '',
date: new Date(),
tzOffset: 0,
},
authoredByCommitter: true,
parentSHAs: [],
trailers: [],
coAuthors: [],
isWebFlowCommitter: () => false,
},
remote: null,
}

View file

@ -52,7 +52,15 @@ describe('git/reflog', () => {
new Branch(
'branch-1',
null,
new Commit('', '', '', new CommitIdentity('', '', new Date()), []),
new Commit(
'',
'',
'',
new CommitIdentity('', '', new Date()),
new CommitIdentity('', '', new Date()),
[],
[]
),
BranchType.Local
),
'branch-1-test'

View file

@ -7,7 +7,16 @@ import { CommitIdentity } from '../../src/models/commit-identity'
describe('Branches grouping', () => {
const author = new CommitIdentity('Hubot', 'hubot@github.com', new Date())
const commit = new Commit('300acef', 'summary', 'body', author, [])
const commit = new Commit(
'300acef',
'summary',
'body',
author,
author,
[],
[]
)
const currentBranch = new Branch('master', null, commit, BranchType.Local)
const defaultBranch = new Branch('master', null, commit, BranchType.Local)

View file

@ -44,6 +44,7 @@ describe('RepositoriesStore', () => {
url: 'https://github.com/my-user',
login: 'my-user',
avatar_url: 'https://github.com/my-user.png',
email: 'my-user@users.noreply.github.com',
name: 'My User',
type: 'User',
},

View file

@ -0,0 +1,85 @@
/* eslint-disable no-sync */
import * as FS from 'fs'
import * as Path from 'path'
import { GitProcess } from 'dugite'
import { expect } from 'chai'
import { RepositorySettingsStore } from '../../src/lib/stores'
import { setupEmptyRepository } from '../helpers/repositories'
import { getStatus } from '../../src/lib/git'
import { Repository } from '../../src/models/repository'
import { pathExists } from '../../src/lib/file-system'
describe('RepositorySettingsStore', () => {
it('can create a gitignore file', async () => {
const repo = await setupEmptyRepository()
const path = repo.path
const sut = new RepositorySettingsStore(repo)
// Create git ignore file
await sut.saveGitIgnore('node_modules\n')
// Make sure file exists on FS
const exists = await pathExists(`${path}/.gitignore`)
expect(exists).is.true
})
it('can ignore a file in a repository', async () => {
const repo = await setupEmptyRepository()
const sut = new RepositorySettingsStore(repo)
const path = repo.path
// Ignore txt files
await sut.saveGitIgnore('*.txt\n')
await GitProcess.exec(['add', '.gitignore'], path)
await GitProcess.exec(['commit', '-m', 'create the ignore file'], path)
// Create a txt file
const file = Path.join(repo.path, 'a.txt')
FS.writeFileSync(file, 'thrvbnmerkl;,iuw')
// Check status of repo
const status = await getStatus(repo)
const files = status.workingDirectory.files
expect(files.length).to.equal(0)
})
describe('autocrlf and safecrlf', () => {
let repo: Repository
let sut: RepositorySettingsStore
beforeEach(async () => {
repo = await setupEmptyRepository()
sut = new RepositorySettingsStore(repo)
await GitProcess.exec(
['config', '--local', 'core.autocrlf', 'true'],
repo.path
)
await GitProcess.exec(
['config', '--local', 'core.safecrlf', 'true'],
repo.path
)
})
it('appends newline to file', async () => {
const path = repo.path
await sut.saveGitIgnore('node_modules')
await GitProcess.exec(['add', '.gitignore'], path)
const commit = await GitProcess.exec(
['commit', '-m', 'create the ignore file'],
path
)
const contents = await sut.readGitIgnore()
expect(commit.exitCode).to.equal(0)
expect(contents!.endsWith('\r\n'))
})
})
})

View file

@ -39,9 +39,9 @@ describe('sanitizedBranchName', () => {
expect(result).to.equal('first.dot.is.not.ok')
})
it('collapses double dashes', () => {
const branchName = 'branch ? -|name'
it('allows double dashes after first character', () => {
const branchName = 'branch--name'
const result = sanitizedBranchName(branchName)
expect(result).to.equal('branch-name')
expect(result).to.equal(branchName)
})
})

View file

@ -156,8 +156,8 @@ co@^4.6.0:
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
codemirror@^5.31.0:
version "5.31.0"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.31.0.tgz#ecf3d057eb74174147066bfc7c5f37b4c4e07df2"
version "5.33.0"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.33.0.tgz#462ad9a6fe8d38b541a9536a3997e1ef93b40c6a"
color-convert@^1.9.0:
version "1.9.0"
@ -268,9 +268,9 @@ dom-matches@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dom-matches/-/dom-matches-2.0.0.tgz#d2728b416a87533980eb089b848d253cf23a758c"
dugite@1.53.0:
version "1.53.0"
resolved "https://registry.yarnpkg.com/dugite/-/dugite-1.53.0.tgz#2c402ca92ac34ffeb3a2b6f8b1a6774c2309a41c"
dugite@1.57.0:
version "1.57.0"
resolved "https://registry.yarnpkg.com/dugite/-/dugite-1.57.0.tgz#d0c5647a9cf5fd2bcfa81aab8c9f13eac19e0b64"
dependencies:
checksum "^0.1.1"
mkdirp "^0.5.1"

View file

@ -1,5 +1,53 @@
{
"releases": {
"1.0.14-beta1": [
"[New] Commit together with co-authors - #3879"
],
"1.0.13": [
"[New] Commit together with co-authors - #3879",
"[New] PhpStorm is now a supported external editor on macOS - #3749. Thanks @hubgit!",
"[Improved] Update embedded Git to 2.16.1 - #3617 #3828 #3871",
"[Improved] Blank slate view is now more responsive when zoomed - #3777",
"[Improved] Documentation fix for Open in Shell resource - #3799. Thanks @saschanaz!",
"[Improved] Improved error handling for Linux - #3732",
"[Improved] Allow links in unexpanded summary to be clickable - #3719. Thanks @koenpunt!",
"[Fixed] Update Electron to 1.7.11 to address security issue - #3846",
"[Fixed] Allow double dashes in branch name - #3599. Thanks @JQuinnie!",
"[Fixed] Sort the organization list - #3657. Thanks @j-f1!",
"[Fixed] Check out PRs from a fork - #3395",
"[Fixed] Confirm deleting branch when it has an open PR - #3615",
"[Fixed] Defer user/email validation in Preferences - #3722",
"[Fixed] Checkout progress did not include branch name - #3780",
"[Fixed] Don't block branch switching when in detached HEAD - #3807",
"[Fixed] Handle discarding submodule changes properly - #3647",
"[Fixed] Show tooltip with additional info about the build status - #3134",
"[Fixed] Update placeholders to support Linux distributions - #3150",
"[Fixed] Refresh local commit list when switching tabs - #3698"
],
"1.0.13-test1": [
"[Improved] Update embedded Git to 2.16.1 - #3617 #3828 #3871",
"[Fixed] Update Electron to 1.7.11 to address security issue - #3846",
"[Fixed] Allows double dashes in branch name - #3599. Thanks @JQuinnie!",
"[Fixed] Pull Request store may not have status defined - #3869",
"[Fixed] Render the Pull Request badge when no commit statuses found - #3608"
],
"1.0.13-beta1": [
"[New] PhpStorm is now a supported external editor on macOS - #3749. Thanks @hubgit!",
"[Improved] Blank slate view is now more responsive when zoomed - #3777",
"[Improved] Documentation fix for Open in Shell resource - #3799. Thanks @saschanaz!",
"[Improved] Improved error handling for Linux - #3732",
"[Improved] Allow links in unexpanded summary to be clickable - #3719. Thanks @koenpunt!",
"[Fixed] Sort the organization list - #3657. Thanks @j-f1!",
"[Fixed] Check out PRs from a fork - #3395",
"[Fixed] Confirm deleting branch when it has an open PR - #3615",
"[Fixed] Defer user/email validation in Preferences - #3722",
"[Fixed] Checkout progress did not include branch name - #3780",
"[Fixed] Don't block branch switching when in detached HEAD - #3807",
"[Fixed] Handle discarding submodule changes properly - #3647",
"[Fixed] Show tooltip with additional info about the build status - #3134",
"[Fixed] Update placeholders to support Linux distributions - #3150",
"[Fixed] Refresh local commit list when switching tabs - #3698"
],
"1.0.12": [
"[New] Syntax highlighting for Rust files - #3666. Thanks @subnomo!",
"[New] Syntax highlighting for Clojure cljc, cljs, and edn files - #3610. Thanks @mtkp!",

View file

@ -0,0 +1,43 @@
# Checking out pull requests from a forked repository
PR [#3602](https://github.com/desktop/desktop/pull/3602) introduced the ability to checkout a branch from a forked repository. In order to accomplish this, we needed a way to manage remotes on your behalf. This document is intended to detail the process we developed to make checking out PRs as frictionless as possible.
## Removing Remotes
One of the goals of our design was to ensure that we dont cause your remotes — `.git/refs/remotes` — to grow unbounded. We prevent this by cleaning up after ourselves. We determined that a remote is a candidate for removal when it meets the certain conditions:
* Start with our prefix
* The PR associated with the remote is closed
The implementation of the function that does this work can be found [here](https://github.com/desktop/desktop/blob/34a05b155ff69bb19cc4da5b2caa89856e3e63fb/app/src/lib/stores/pull-request-store.ts#L91-L110).
```ts
forkedRemotesToDelete(
remotes: ReadonlyArray<IRemote>,
openPullRequests: ReadonlyArray<PullRequest>
): ReadonlyArray<IRemote> {
const forkedRemotes = remotes.filter(remote =>
remote.name.startsWith(ForkedRemotePrefix)
)
const remotesOfPullRequests = new Set<string>()
openPullRequests.forEach(openPullRequest => {
const { gitHubRepository } = openPullRequest.head
if (gitHubRepository != null && gitHubRepository.cloneURL != null) {
remotesOfPullRequests.add(gitHubRepository.cloneURL)
}
})
const forkedRemotesToDelete = forkedRemotes.filter(
forkedRemote => !remotesOfPullRequests.has(forkedRemote.url)
)
return forkedRemotesToDelete
}
```
## Magic Remote Prefix
One of the main problems we needed to solve was determining which remotes are no longer needed and can be cleaned. We decided to prefix the remotes we add on your behalf with a magic string: `github-desktop-`
```ts
export const ForkedRemotePrefix = 'github-desktop-'
```
[Code](https://github.com/desktop/desktop/blob/34a05b155ff69bb19cc4da5b2caa89856e3e63fb/app/src/lib/stores/pull-request-store.ts#L26)
## What does this mean for me?
Doing this essentially gives us a namespace that we can safely work in. We chose the prefix `github-desktop-` because we are confident that your own remote names will never start with this prefix. This means that in order for GitHub Desktop to work as expected, you should never add a remote that starts with our prefix. We feel that this is an acceptable compromise.

View file

@ -34,7 +34,8 @@
"publish": "ts-node -P script script/publish.ts",
"clean-slate": "rimraf out node_modules app/node_modules && yarn",
"rebuild-hard:dev": "yarn clean-slate && yarn build:dev",
"rebuild-hard:prod": "yarn clean-slate && yarn build:prod"
"rebuild-hard:prod": "yarn clean-slate && yarn build:prod",
"changelog": "ts-node script/changelog/index.ts"
},
"author": {
"name": "GitHub, Inc.",
@ -69,6 +70,7 @@
"extract-text-webpack-plugin": "^3.0.0",
"fs-extra": "^2.1.2",
"html-webpack-plugin": "^2.30.1",
"json-pretty": "^0.0.1",
"klaw-sync": "^3.0.0",
"legal-eagle": "0.15.0",
"mocha": "^4.0.1",
@ -80,6 +82,7 @@
"request": "^2.72.0",
"rimraf": "^2.5.2",
"sass-loader": "^6.0.6",
"semver": "^5.5.0",
"spectron": "^3.7.2",
"style-loader": "^0.19.1",
"to-camel-case": "^1.0.0",
@ -124,6 +127,7 @@
"@types/react-transition-group": "1.1.1",
"@types/react-virtualized": "^9.7.4",
"@types/request": "^2.0.9",
"@types/semver": "^5.5.0",
"@types/strip-ansi": "^3.0.0",
"@types/temp": "^0.8.29",
"@types/to-camel-case": "^1.0.0",
@ -135,7 +139,7 @@
"@types/webpack-merge": "^4.1.0",
"@types/winston": "^2.2.0",
"@types/xml2js": "^0.4.0",
"electron": "1.7.10",
"electron": "1.7.11",
"electron-builder": "19.48.3",
"electron-mocha": "^5.0.0",
"electron-packager": "^10.1.0",

60
script/changelog/api.ts Normal file
View file

@ -0,0 +1,60 @@
import * as HTTPS from 'https'
export interface IAPIPR {
readonly title: string
readonly body: string
}
type GraphQLResponse = {
readonly data: {
readonly repository: {
readonly pullRequest: IAPIPR
}
}
}
export function fetchPR(id: number): Promise<IAPIPR | null> {
return new Promise((resolve, reject) => {
const options: HTTPS.RequestOptions = {
host: 'api.github.com',
protocol: 'https:',
path: '/graphql',
method: 'POST',
headers: {
Authorization: `bearer ${process.env.GITHUB_ACCESS_TOKEN}`,
'User-Agent': 'what-the-changelog',
},
}
const request = HTTPS.request(options, response => {
let received = ''
response.on('data', chunk => {
received += chunk
})
response.on('end', () => {
try {
const json: GraphQLResponse = JSON.parse(received)
const pr = json.data.repository.pullRequest
resolve(pr)
} catch (e) {
resolve(null)
}
})
})
const graphql = `
{
repository(owner: "desktop", name: "desktop") {
pullRequest(number: ${id}) {
title
body
}
}
}
`
request.write(JSON.stringify({ query: graphql }))
request.end()
})
}

15
script/changelog/index.ts Normal file
View file

@ -0,0 +1,15 @@
/// <reference path="../globals.d.ts" />
import { run } from './run'
if (!process.env.GITHUB_ACCESS_TOKEN) {
console.log('You need to provide a GITHUB_ACCESS_TOKEN environment variable')
process.exit(1)
}
process.on('unhandledRejection', error => {
console.error(error.message)
})
const args = process.argv.splice(2)
run(args)

151
script/changelog/run.ts Normal file
View file

@ -0,0 +1,151 @@
import { spawn } from './spawn'
import { fetchPR, IAPIPR } from './api'
import { sort as semverSort } from 'semver'
const jsonStringify: (obj: any) => string = require('json-pretty')
const PlaceholderChangeType = '???'
const OfficialOwner = 'desktop'
async function getLogLines(
previousVersion: string
): Promise<ReadonlyArray<string>> {
const log = await spawn('git', [
'log',
`...${previousVersion}`,
'--merges',
'--grep="Merge pull request"',
'--format=format:%s',
'-z',
'--',
])
return log.split('\0')
}
interface IParsedCommit {
readonly prID: number
readonly owner: string
}
function parseCommitTitle(line: string): IParsedCommit {
// E.g.: Merge pull request #2424 from desktop/fix-shrinkwrap-file
const re = /^Merge pull request #(\d+) from (.+?)\/.*$/
const matches = line.match(re)
if (!matches || matches.length !== 3) {
throw new Error(`Unable to parse '${line}'`)
}
const id = parseInt(matches[1], 10)
if (isNaN(id)) {
throw new Error(`Unable to parse PR number from '${line}': ${matches[1]}`)
}
return {
prID: id,
owner: matches[2],
}
}
function capitalized(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
function getChangelogEntry(commit: IParsedCommit, pr: IAPIPR): string {
let issueRef = ''
let type = PlaceholderChangeType
const description = capitalized(pr.title)
const re = /Fixes #(\d+)/gi
let match
do {
match = re.exec(pr.body)
if (match && match.length > 1) {
issueRef += ` #${match[1]}`
}
} while (match)
if (issueRef.length) {
type = 'Fixed'
} else {
issueRef = ` #${commit.prID}`
}
let attribution = ''
if (commit.owner !== OfficialOwner) {
attribution = `. Thanks @${commit.owner}!`
}
return `[${type}] ${description} -${issueRef}${attribution}`
}
async function getChangelogEntries(
lines: ReadonlyArray<string>
): Promise<ReadonlyArray<string>> {
const entries = []
for (const line of lines) {
try {
const commit = parseCommitTitle(line)
const pr = await fetchPR(commit.prID)
if (!pr) {
throw new Error(`Unable to get PR from API: ${commit.prID}`)
}
const entry = getChangelogEntry(commit, pr)
entries.push(entry)
} catch (e) {
console.warn('Unable to parse line, using the full message.', e)
entries.push(`[${PlaceholderChangeType}] ${line}`)
}
}
return entries
}
export async function run(args: ReadonlyArray<string>): Promise<void> {
try {
await spawn('git', ['--version'])
} catch {
throw new Error('Unable to find Git on your PATH, aborting...')
}
try {
await spawn('git', ['rev-parse', '--show-cdup'])
} catch {
throw new Error(
`The current directory '${process.cwd()}' is not a Git repository, aborting...`
)
}
if (args.length === 0) {
// work out the latest tag created in the repository
const allTags = await spawn('git', ['tag'])
const releaseTags = allTags
.split('\n')
.filter(tag => tag.startsWith('release-'))
.filter(tag => tag.indexOf('-linux') === -1)
.filter(tag => tag.indexOf('-test') === -1)
.map(tag => tag.substr(8))
const sortedTags = semverSort(releaseTags)
const latestTag = sortedTags[sortedTags.length - 1]
throw new Error(
`No tag specified to use as a starting point.\nThe latest tag specified is 'release-${latestTag}' - did you mean that?`
)
}
const previousVersion = args[0]
try {
await spawn('git', ['rev-parse', previousVersion])
} catch {
throw new Error(
`Unable to find ref '${previousVersion}' in your repository, aborting...`
)
}
const lines = await getLogLines(previousVersion)
const changelogEntries = await getChangelogEntries(lines)
console.log(jsonStringify(changelogEntries))
}

31
script/changelog/spawn.ts Normal file
View file

@ -0,0 +1,31 @@
import * as ChildProcess from 'child_process'
export function spawn(
cmd: string,
args: ReadonlyArray<string>
): Promise<string> {
return new Promise((resolve, reject) => {
const child = ChildProcess.spawn(cmd, args as string[], { shell: true })
let receivedData = ''
child.on('error', reject)
child.stdout.on('data', data => {
receivedData += data
})
child.on('close', (code, signal) => {
if (code === 0) {
resolve(receivedData)
} else {
reject(
new Error(
`'${cmd} ${args.join(
' '
)}' exited with code ${code}, signal ${signal}`
)
)
}
})
})
}

7
script/globals.d.ts vendored
View file

@ -39,3 +39,10 @@ type AppImageOptions = {
type ElectronInstallerAppImage = {
default: (options: AppImageOptions) => Promise<void>
}
declare namespace NodeJS {
// eslint-disable-next-line typescript/interface-name-prefix
interface Process extends EventEmitter {
on(event: 'unhandledRejection', listener: (error: Error) => void): this
}
}

View file

@ -263,6 +263,10 @@
"@types/form-data" "*"
"@types/node" "*"
"@types/semver@^5.5.0":
version "5.5.0"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
"@types/serve-static@*":
version "1.13.1"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.1.tgz#1d2801fa635d274cd97d4ec07e26b21b44127492"
@ -2419,9 +2423,9 @@ electron-winstaller@2.5.2:
lodash.template "^4.2.2"
temp "^0.8.3"
electron@1.7.10:
version "1.7.10"
resolved "https://registry.yarnpkg.com/electron/-/electron-1.7.10.tgz#3a3e83d965fd7fafe473be8ddf8f472561b6253d"
electron@1.7.11:
version "1.7.11"
resolved "https://registry.yarnpkg.com/electron/-/electron-1.7.11.tgz#993b6aa79e0e79a7cfcc369f4c813fbd9a0b08d9"
dependencies:
"@types/node" "^7.0.18"
electron-download "^3.0.1"
@ -4035,6 +4039,10 @@ json-parse-helpfulerror@^1.0.2:
dependencies:
jju "^1.1.0"
json-pretty@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/json-pretty/-/json-pretty-0.0.1.tgz#e0a36567490838781f712d250c04a2fb96334265"
json-schema-traverse@^0.3.0:
version "0.3.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
@ -6129,6 +6137,10 @@ semver-diff@^2.0.0:
version "5.4.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e"
semver@^5.5.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab"
semver@~5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"