mirror of
https://github.com/desktop/desktop
synced 2024-09-19 08:02:22 +00:00
Merge branch 'master' into my-hands-are-typing
This commit is contained in:
commit
3c2042f7a6
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/app/test/fixtures/** -text
|
|
@ -1,4 +1,4 @@
|
|||
runtime = electron
|
||||
disturl = https://atom.io/download/electron
|
||||
target = 1.7.10
|
||||
target = 1.7.11
|
||||
arch = x64
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -29,3 +29,4 @@ export * from './revert'
|
|||
export * from './rm'
|
||||
export * from './mergetool'
|
||||
export * from './submodule'
|
||||
export * from './interpret-trailers'
|
||||
|
|
165
app/src/lib/git/interpret-trailers.ts
Normal file
165
app/src/lib/git/interpret-trailers.ts
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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}`
|
||||
}
|
||||
|
|
55
app/src/lib/stores/base-store.ts
Normal file
55
app/src/lib/stores/base-store.ts
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
120
app/src/lib/stores/repository-settings-store.ts
Normal file
120
app/src/lib/stores/repository-settings-store.ts
Normal 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`)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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
27
app/src/models/author.ts
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
18
app/src/models/git-author.ts
Normal file
18
app/src/models/git-author.ts
Normal 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}>`
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
817
app/src/ui/lib/author-input.tsx
Normal file
817
app/src/ui/lib/author-input.tsx
Normal 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} />
|
||||
}
|
||||
}
|
51
app/src/ui/lib/avatar-stack.tsx
Normal file
51
app/src/ui/lib/avatar-stack.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
94
app/src/ui/lib/commit-attribution.tsx
Normal file
94
app/src/ui/lib/commit-attribution.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
80
app/src/ui/lib/focus-container.tsx
Normal file
80
app/src/ui/lib/focus-container.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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') })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
67
app/styles/ui/_author-input.scss
Normal file
67
app/styles/ui/_author-input.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
133
app/styles/ui/_avatar-stack.scss
Normal file
133
app/styles/ui/_avatar-stack.scss
Normal 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;
|
||||
}
|
||||
}
|
53
app/styles/ui/_codemirror.scss
Normal file
53
app/styles/ui/_codemirror.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
5
app/styles/ui/_commit-attribution.scss
Normal file
5
app/styles/ui/_commit-attribution.scss
Normal file
|
@ -0,0 +1,5 @@
|
|||
@import '../mixins';
|
||||
|
||||
.commit-attribution-component {
|
||||
@include ellipsis;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -115,6 +115,9 @@
|
|||
}
|
||||
|
||||
&-meta-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@include ellipsis;
|
||||
margin-right: var(--spacing);
|
||||
font-size: var(--font-size-sm);
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
85
app/test/unit/repository-settings-store-test.ts
Normal file
85
app/test/unit/repository-settings-store-test.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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!",
|
||||
|
|
43
docs/technical/pull-requests.md
Normal file
43
docs/technical/pull-requests.md
Normal 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 don’t 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.
|
|
@ -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
60
script/changelog/api.ts
Normal 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
15
script/changelog/index.ts
Normal 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
151
script/changelog/run.ts
Normal 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
31
script/changelog/spawn.ts
Normal 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
7
script/globals.d.ts
vendored
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
18
yarn.lock
18
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue