Merge branch 'master' into preferences

This commit is contained in:
joshaber 2016-12-05 11:42:35 -05:00
commit 6ff8baa33e
27 changed files with 430 additions and 307 deletions

View file

@ -36,7 +36,7 @@
"username": "^2.2.2"
},
"devDependencies": {
"fs-extra": "^0.30.0",
"fs-extra": "^1.0.0",
"react-addons-perf": "15.3.2",
"react-addons-test-utils": "15.3.2",
"style-loader": "^0.13.1",

View file

@ -399,7 +399,7 @@ export class AppStore {
private onGitStoreLoadedCommits(repository: Repository, commits: ReadonlyArray<Commit>) {
for (const commit of commits) {
this.gitHubUserStore._loadAndCacheUser(this.users, repository, commit.sha, commit.authorEmail)
this.gitHubUserStore._loadAndCacheUser(this.users, repository, commit.sha, commit.author.email)
}
}

View file

@ -2,6 +2,7 @@ import { git } from './core'
import { Repository } from '../../models/repository'
import { Commit } from '../../models/commit'
import { Branch, BranchType } from '../../models/branch'
import { CommitIdentity } from '../../models/commit-identity'
/** Get all the branches. */
export async function getBranches(repository: Repository, prefix: string, type: BranchType): Promise<ReadonlyArray<Branch>> {
@ -13,9 +14,7 @@ export async function getBranches(repository: Repository, prefix: string, type:
'%(refname:short)',
'%(upstream:short)',
'%(objectname)', // SHA
'%(authorname)',
'%(authoremail)',
'%(authordate)',
'%(author)',
'%(parent)', // parent SHAs
'%(subject)',
'%(body)',
@ -35,21 +34,19 @@ export async function getBranches(repository: Repository, prefix: string, type:
const name = pieces[0].trim()
const upstream = pieces[1]
const sha = pieces[2]
const authorName = pieces[3]
// author email is wrapped in arrows e.g. <hubot@github.com>
const authorEmailRaw = pieces[4]
const authorEmail = authorEmailRaw.substring(1, authorEmailRaw.length - 1)
const authorDateText = pieces[5]
const authorDate = new Date(authorDateText)
const authorIdentity = pieces[3]
const author = CommitIdentity.parseIdentity(authorIdentity)
const parentSHAs = pieces[6].split(' ')
if (!author) {
throw new Error(`Couldn't parse author identity ${authorIdentity}`)
}
const summary = pieces[7]
const parentSHAs = pieces[4].split(' ')
const summary = pieces[5]
const body = pieces[6]
const body = pieces[8]
const tip = new Commit(sha, summary, body, authorName, authorEmail, authorDate, parentSHAs)
const tip = new Commit(sha, summary, body, author, parentSHAs)
return new Branch(name, upstream.length > 0 ? upstream : null, tip, type)
})

View file

@ -2,6 +2,7 @@ import { git } from './core'
import { FileStatus, FileChange } from '../../models/status'
import { Repository } from '../../models/repository'
import { Commit } from '../../models/commit'
import { CommitIdentity } from '../../models/commit-identity'
import { mapStatus } from './status'
/**
@ -14,13 +15,22 @@ export async function getCommits(repository: Repository, revisionRange: string,
'%H', // SHA
'%s', // summary
'%b', // body
'%an', // author name
'%ae', // author email
'%aI', // author date, ISO-8601
// author identity string, matching format of GIT_AUTHOR_IDENT.
// author name <author email> <author date>
// author date format dependent on --date arg, should be raw
'%an <%ae> %ad',
'%P', // parent SHAs
].join(`%x${delimiter}`)
const result = await git([ 'log', revisionRange, `--max-count=${limit}`, `--pretty=${prettyFormat}`, '-z', '--no-color', ...additionalArgs ], repository.path, 'getCommits', { successExitCodes: new Set([ 0, 128 ]) })
const result = await git([
'log',
revisionRange,
`--date=raw`,
`--max-count=${limit}`,
`--pretty=${prettyFormat}`,
'-z',
'--no-color', ...additionalArgs,
], repository.path, 'getCommits', { successExitCodes: new Set([ 0, 128 ]) })
// if the repository has an unborn HEAD, return an empty history of commits
if (result.exitCode === 128) {
@ -37,12 +47,16 @@ export async function getCommits(repository: Repository, revisionRange: string,
const sha = pieces[0]
const summary = pieces[1]
const body = pieces[2]
const authorName = pieces[3]
const authorEmail = pieces[4]
const parsedDate = Date.parse(pieces[5])
const authorDate = new Date(parsedDate)
const parentSHAs = pieces[6].split(' ')
return new Commit(sha, summary, body, authorName, authorEmail, authorDate, parentSHAs)
const authorIdentity = pieces[3]
const parentSHAs = pieces[4].split(' ')
const author = CommitIdentity.parseIdentity(authorIdentity)
if (!author) {
throw new Error(`Couldn't parse author identity ${authorIdentity}`)
}
return new Commit(sha, summary, body, author, parentSHAs)
})
return commits

View file

@ -1,10 +1,13 @@
/**
* A tuple of name and email for the author or commit
* A tuple of name, email, and date for the author or commit
* info in a commit.
*/
export class CommitIdentity {
public readonly name: string
public readonly email: string
public readonly date: Date
public readonly tzOffset: number
/**
* Parses a Git ident string (GIT_AUTHOR_IDENT or GIT_COMMITTER_IDENT)
@ -12,24 +15,44 @@ export class CommitIdentity {
*/
public static parseIdentity(identity: string): CommitIdentity | null {
// See fmt_ident in ident.c:
// https://github.com/git/git/blob/3ef7618e616e023cf04180e30d77c9fa5310f964/ident.c#L346
// https://github.com/git/git/blob/3ef7618e6/ident.c#L346
//
// Format is "NAME <EMAIL> DATE"
// Markus Olsson <j.markus.olsson@gmail.com> 1475670580 +0200
//
// Note that `git var` will strip any < and > from the name and email, see:
// https://github.com/git/git/blob/3ef7618e616e023cf04180e30d77c9fa5310f964/ident.c#L396
const m = identity.match(/^(.*?) <(.*?)>/)
// https://github.com/git/git/blob/3ef7618e6/ident.c#L396
//
// Note also that this expects a date formatted with the RAW option in git see:
// https://github.com/git/git/blob/35f6318d4/date.c#L191
//
const m = identity.match(/^(.*?) <(.*?)> (\d+) (\+|-)?(\d{2})(\d{2})/)
if (!m) { return null }
const name = m[1]
const email = m[2]
// The date is specified as seconds from the epoch,
// Date() expects milliseconds since the epoch.
const date = new Date(parseInt(m[3], 10) * 1000)
return new CommitIdentity(name, email)
// The RAW option never uses alphanumeric timezone identifiers and in my
// testing I've never found it to omit the leading + for a positive offset
// but the docs for strprintf seems to suggest it might on some systems so
// we're playing it safe.
const tzSign = m[4] === '-' ? '-' : '+'
const tzHH = m[5]
const tzmm = m[6]
const tzMinutes = parseInt(tzHH, 10) * 60 + parseInt(tzmm, 10)
const tzOffset = tzMinutes * (tzSign === '-' ? -1 : 1)
return new CommitIdentity(name, email, date, tzOffset)
}
public constructor(name: string, email: string) {
public constructor(name: string, email: string, date: Date, tzOffset?: number) {
this.name = name
this.email = email
this.date = date
this.tzOffset = tzOffset || (new Date()).getTimezoneOffset()
}
}

View file

@ -1,3 +1,5 @@
import { CommitIdentity } from './commit-identity'
/** A git commit. */
export class Commit {
/** The commit's SHA. */
@ -9,25 +11,20 @@ export class Commit {
/** The commit message without the first line and CR. */
public readonly body: string
/** The commit author's name */
public readonly authorName: string
/** The commit author's email address */
public readonly authorEmail: string
/** The commit timestamp (with timezone information) */
public readonly authorDate: Date
/**
* Information about the author of this commit.
* includes name, email and date.
*/
public readonly author: CommitIdentity
/** The SHAs for the parents of the commit. */
public readonly parentSHAs: ReadonlyArray<string>
public constructor(sha: string, summary: string, body: string, authorName: string, authorEmail: string, authorDate: Date, parentSHAs: ReadonlyArray<string>) {
public constructor(sha: string, summary: string, body: string, author: CommitIdentity, parentSHAs: ReadonlyArray<string>) {
this.sha = sha
this.summary = summary
this.body = body
this.authorName = authorName
this.authorEmail = authorEmail
this.authorDate = authorDate
this.author = author
this.parentSHAs = parentSHAs
}
}

View file

@ -24,6 +24,10 @@ export class DiffLine {
public withNoTrailingNewLine(noTrailingNewLine: boolean): DiffLine {
return new DiffLine(this.text, this.type, this.oldLineNumber, this.newLineNumber, noTrailingNewLine)
}
public isIncludeableLine() {
return this.type === DiffLineType.Add || this.type === DiffLineType.Delete
}
}
/** details about the start and end of a diff hunk */

View file

@ -695,6 +695,7 @@ export class App extends React.Component<IAppProps, IAppState> {
private onSelectionChanged = (repository: Repository | CloningRepository) => {
this.props.dispatcher.selectRepository(repository)
this.props.dispatcher.closeFoldout()
if (repository instanceof Repository) {
this.props.dispatcher.refreshGitHubRepositoryInfo(repository)

View file

@ -68,7 +68,7 @@ export class Branches extends React.Component<IBranchesProps, IBranchesState> {
return <BranchListItem
name={branch.name}
isCurrentBranch={branch.name === currentBranchName}
lastCommitDate={commit ? commit.authorDate : null}/>
lastCommitDate={commit ? commit.author.date : null}/>
} else {
return <div className='branches-list-content branches-list-label'>{item.label}</div>
}

View file

@ -17,7 +17,7 @@ interface IUndoCommitProps {
/** The Undo Commit component. */
export class UndoCommit extends React.Component<IUndoCommitProps, void> {
public render() {
const authorDate = this.props.commit.authorDate
const authorDate = this.props.commit.author.date
return (
<div id='undo-commit'>
<div className='commit-info'>

View file

@ -1,9 +1,16 @@
import * as React from 'react'
import { DiffLine, DiffLineType } from '../../models/diff'
import { selectedLineClass } from './selection/selection'
import { Diff, DiffHunk, DiffLine, DiffLineType } from '../../models/diff'
import { hoverCssClass, selectedLineClass } from './selection/selection'
import { assertNever } from '../../lib/fatal-error'
import * as classNames from 'classnames'
/**
* The area in pixels either side of the right-edge of the diff gutter to
* use to detect when a group of lines should be highlighted, instead of
* a single line.
*/
const EdgeDetectionSize = 10
/** The props for the diff gutter. */
interface IDiffGutterProps {
/** The line being represented by the gutter. */
@ -14,18 +21,72 @@ interface IDiffGutterProps {
* should be rendered as selected or not.
*/
readonly isIncluded: boolean
/**
* The line number relative to the unified diff output
*/
readonly index: number
/**
* Indicate whether the diff should handle user interactions
*/
readonly readOnly: boolean
/**
* The diff currently displayed in the app
*/
readonly diff: Diff
/**
* Callback to apply hover effect to lines belonging to a given hunk
*/
readonly updateHunkHoverState: (hunk: DiffHunk, active: boolean) => void
/**
* Callback to query whether a selection gesture is currently underway
*
* If this returns true, the element will attempt to update the hover state.
* Otherwise, this will defer to the active selection gesture to update
* the visual state of the gutter.
*/
readonly isSelectionEnabled: () => boolean
/**
* Callback to signal when the mouse button is pressed on this element
*/
readonly onMouseDown: (index: number, isHunkSelection: boolean) => void
/**
* Callback to signal when the mouse is hovering over this element
*/
readonly onMouseMove: (index: number) => void
/**
* Callback to signal when the mouse button is released on this element
*/
readonly onMouseUp: (index: number) => void
}
// TODO: this doesn't consider mouse events outside the right edge
function isMouseInHunkSelectionZone(ev: MouseEvent): boolean {
// MouseEvent is not generic, but getBoundingClientRect should be
// available for all HTML elements
// docs: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
const element: any = ev.currentTarget
const offset: ClientRect = element.getBoundingClientRect()
const relativeLeft = ev.clientX - offset.left
const edge = offset.width - EdgeDetectionSize
return relativeLeft >= edge
}
/** The gutter for a diff's line. */
export class DiffLineGutter extends React.Component<IDiffGutterProps, void> {
/** Can this line be selected for inclusion/exclusion? */
private isIncludableLine(): boolean {
return this.props.line.type === DiffLineType.Add || this.props.line.type === DiffLineType.Delete
}
private isIncluded(): boolean {
return this.isIncludableLine() && this.props.isIncluded
}
private elem_?: HTMLSpanElement
private getLineClassName(): string {
const type = this.props.line.type
@ -46,9 +107,140 @@ export class DiffLineGutter extends React.Component<IDiffGutterProps, void> {
return classNames('diff-line-gutter', lineClass, selectedClass)
}
private mouseEnterHandler = (ev: MouseEvent) => {
ev.preventDefault()
const isHunkSelection = isMouseInHunkSelectionZone(ev)
this.updateHoverState(isHunkSelection, true)
}
private mouseLeaveHandler = (ev: MouseEvent) => {
ev.preventDefault()
const isHunkSelection = isMouseInHunkSelectionZone(ev)
this.updateHoverState(isHunkSelection, false)
}
private updateHoverState(isHunkSelection: boolean, isActive: boolean) {
if (isHunkSelection) {
const hunk = this.props.diff.diffHunkForIndex(this.props.index)
if (!hunk) {
console.error('unable to find hunk for given line in diff')
return
}
this.props.updateHunkHoverState(hunk, isActive)
} else {
this.setHover(isActive)
}
}
private mouseMoveHandler = (ev: MouseEvent) => {
ev.preventDefault()
const hunk = this.props.diff.diffHunkForIndex(this.props.index)
if (!hunk) {
console.error('unable to find hunk for given line in diff')
return
}
const isHunkSelection = isMouseInHunkSelectionZone(ev)
const isSelectionActive = this.props.isSelectionEnabled()
// selection is not active, perform highlighting based on mouse position
if (isHunkSelection && isSelectionActive) {
this.props.updateHunkHoverState(hunk, true)
} else {
// clear hunk selection in case hunk was previously higlighted
this.props.updateHunkHoverState(hunk, false)
this.setHover(true)
}
this.props.onMouseMove(this.props.index)
}
private mouseUpHandler = (ev: MouseEvent) => {
ev.preventDefault()
this.props.onMouseUp(this.props.index)
}
private mouseDownHandler = (ev: MouseEvent) => {
ev.preventDefault()
const isHunkSelection = isMouseInHunkSelectionZone(ev)
this.props.onMouseDown(this.props.index, isHunkSelection)
}
private applyEventHandlers = (elem: HTMLSpanElement) => {
// read-only diffs do not support any interactivity
if (this.props.readOnly) {
return
}
// ignore anything from diff context rows
if (!this.props.line.isIncludeableLine()) {
return
}
if (elem) {
this.elem_ = elem
elem.addEventListener('mouseenter', this.mouseEnterHandler)
elem.addEventListener('mouseleave', this.mouseLeaveHandler)
elem.addEventListener('mousemove', this.mouseMoveHandler)
elem.addEventListener('mousedown', this.mouseDownHandler)
elem.addEventListener('mouseup', this.mouseUpHandler)
} else {
// this callback fires a second time when the DOM element
// is unmounted, so we can use this as a chance to cleanup
if (this.elem_) {
this.elem_.removeEventListener('mouseenter', this.mouseEnterHandler)
this.elem_.removeEventListener('mouseleave', this.mouseLeaveHandler)
this.elem_.removeEventListener('mousemove', this.mouseMoveHandler)
this.elem_.removeEventListener('mousedown', this.mouseDownHandler)
this.elem_.removeEventListener('mouseup', this.mouseUpHandler)
}
}
}
public isIncluded(): boolean {
return this.props.line.isIncludeableLine() && this.props.isIncluded
}
public setHover(visible: boolean) {
if (visible) {
this.setClass(hoverCssClass)
} else {
this.unsetClass(hoverCssClass)
}
}
public setSelected(visible: boolean) {
if (visible) {
this.setClass(selectedLineClass)
} else {
this.unsetClass(selectedLineClass)
}
}
private setClass(cssClass: string) {
if (this.elem_) {
this.elem_.classList.add(cssClass)
}
}
private unsetClass(cssClass: string) {
if (this.elem_) {
this.elem_.classList.remove(cssClass)
}
}
public render() {
return (
<span className={this.getLineClass()}>
<span className={this.getLineClass()}
ref={this.applyEventHandlers}>
<span className='diff-line-number before'>{this.props.line.oldLineNumber || ' '}</span>
<span className='diff-line-number after'>{this.props.line.newLineNumber || ' '}</span>
</span>

View file

@ -12,7 +12,7 @@ import { CodeMirrorHost } from './code-mirror-host'
import { Repository } from '../../models/repository'
import { FileChange, WorkingDirectoryFileChange, FileStatus } from '../../models/status'
import { DiffHunk, DiffLine, DiffLineType, Diff as DiffModel, DiffSelection, ImageDiff } from '../../models/diff'
import { DiffHunk, Diff as DiffModel, DiffSelection, ImageDiff } from '../../models/diff'
import { Dispatcher } from '../../lib/dispatcher/dispatcher'
import { DiffLineGutter } from './diff-line-gutter'
@ -21,7 +21,6 @@ import { getDiffMode } from './diff-mode'
import { ISelectionStrategy } from './selection/selection-strategy'
import { DragDropSelection } from './selection/drag-drop-selection-strategy'
import { HunkSelection } from './selection/hunk-selection-strategy'
import { hoverCssClass, selectedLineClass } from './selection/selection'
import { fatalError } from '../../lib/fatal-error'
@ -30,6 +29,21 @@ if (__DARWIN__) {
require('codemirror/addon/scroll/simplescrollbars')
}
/**
* normalize the line endings in the diff so that the CodeMirror editor
* will display the unified diff correctly
*/
function formatLineEnding(text: string): string {
if (text.endsWith('\n')) {
return text
} else if (text.endsWith('\r')) {
return text + '\n'
} else {
return text + '\r\n'
}
}
/** The props for the Diff component. */
interface IDiffProps {
readonly repository: Repository
@ -80,8 +94,7 @@ export class Diff extends React.Component<IDiffProps, void> {
/**
* a local cache of gutter elements, keyed by the row in the diff
*/
private cachedGutterElements = new Map<number, HTMLSpanElement>()
private cachedGutterElements = new Map<number, DiffLineGutter>()
public componentWillReceiveProps(nextProps: IDiffProps) {
// If we're reloading the same file, we want to save the current scroll
@ -114,22 +127,15 @@ export class Diff extends React.Component<IDiffProps, void> {
const diff = nextProps.diff
this.cachedGutterElements.forEach((element, index) => {
const childSpan = element.children[0] as HTMLSpanElement
if (!childSpan) {
if (!element) {
console.error('expected DOM element for diff gutter not found')
return
}
const line = diff.diffLineForIndex(index)
const isIncludable = line
? line.type === DiffLineType.Add || line.type === DiffLineType.Delete
: false
if (selection.isSelected(index) && isIncludable) {
childSpan.classList.add(selectedLineClass)
} else {
childSpan.classList.remove(selectedLineClass)
}
const isIncludable = line ? line.isIncludeableLine() : false
const isSelected = selection.isSelected(index) && isIncludable
element.setSelected(isSelected)
})
}
}
@ -141,20 +147,55 @@ export class Diff extends React.Component<IDiffProps, void> {
private dispose() {
this.codeMirror = null
this.lineCleanup.forEach((disposable) => disposable.dispose())
this.lineCleanup.forEach(disposable => disposable.dispose())
this.lineCleanup.clear()
}
private onMouseDown(index: number, selected: boolean, isHunkSelection: boolean) {
if (this.props.readOnly) {
private updateHunkHoverState = (hunk: DiffHunk, show: boolean) => {
const start = hunk.unifiedDiffStart
hunk.lines.forEach((line, index) => {
if (line.isIncludeableLine()) {
const row = start + index
this.highlightLine(row, show)
}
})
}
private highlightLine = (row: number, include: boolean) => {
const element = this.cachedGutterElements.get(row)
if (!element) {
// element not currently cached by the editor, don't try and update it
return
}
element.setHover(include)
}
private onMouseUp = () => {
if (!this.props.onIncludeChanged) {
return
}
if (!this.selection) {
return
}
this.props.onIncludeChanged(this.selection.done())
// operation is completed, clean this up
this.selection = null
}
private onMouseDown = (index: number, isHunkSelection: boolean) => {
if (!(this.props.file instanceof WorkingDirectoryFileChange)) {
fatalError('must not start selection when selected file is not a WorkingDirectoryFileChange')
return
}
const snapshot = this.props.file.selection
const selected = snapshot.isSelected(index)
const desiredSelection = !selected
if (isHunkSelection) {
@ -174,77 +215,12 @@ export class Diff extends React.Component<IDiffProps, void> {
this.selection.paint(this.cachedGutterElements)
}
private onMouseUp(index: number) {
if (this.props.readOnly || !this.props.onIncludeChanged) {
return
private onMouseMove = (index: number) => {
if (this.selection) {
this.selection.update(index)
this.selection.paint(this.cachedGutterElements)
}
if (!(this.props.file instanceof WorkingDirectoryFileChange)) {
fatalError('must not complete selection when selected file is not a WorkingDirectoryFileChange')
return
}
const selection = this.selection
if (!selection) {
return
}
selection.apply(this.props.onIncludeChanged)
// operation is completed, clean this up
this.selection = null
}
private isIncludableLine(line: DiffLine): boolean {
return line.type === DiffLineType.Add || line.type === DiffLineType.Delete
}
private isMouseInHunkSelectionZone(ev: MouseEvent): boolean {
// MouseEvent is not generic, but getBoundingClientRect should be
// available for all HTML elements
// docs: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
const element: any = ev.currentTarget
const offset: ClientRect = element.getBoundingClientRect()
const relativeLeft = ev.clientX - offset.left
const edge = offset.width - 10
return relativeLeft >= edge
}
private highlightHunk(hunk: DiffHunk, show: boolean) {
const start = hunk.unifiedDiffStart
hunk.lines.forEach((line, index) => {
if (this.isIncludableLine(line)) {
const row = start + index
this.highlightLine(row, show)
}
})
}
private highlightLine(row: number, include: boolean) {
const element = this.cachedGutterElements.get(row)
if (!element) {
// no point trying to render this element, as it's not
// currently cached by the editor
return
}
const childSpan = element.children[0] as HTMLSpanElement
if (!childSpan) {
console.error('expected DOM element for diff gutter not found')
return
}
if (include) {
childSpan.classList.add(hoverCssClass)
} else {
childSpan.classList.remove(hoverCssClass)
}
}
}
public renderLine = (instance: any, line: any, element: HTMLElement) => {
@ -258,7 +234,7 @@ export class Diff extends React.Component<IDiffProps, void> {
this.lineCleanup.delete(line)
}
const index = instance.getLineNumber(line)
const index = instance.getLineNumber(line) as number
const hunk = this.props.diff.diffHunkForIndex(index)
if (hunk) {
const relativeIndex = index - hunk.unifiedDiffStart
@ -268,99 +244,33 @@ export class Diff extends React.Component<IDiffProps, void> {
const reactContainer = document.createElement('span')
const mouseEnterHandler = (ev: MouseEvent) => {
ev.preventDefault()
if (!this.isIncludableLine(diffLine)) {
return
}
if (this.isMouseInHunkSelectionZone(ev)) {
this.highlightHunk(hunk, true)
} else {
this.highlightLine(index, true)
}
}
const mouseLeaveHandler = (ev: MouseEvent) => {
ev.preventDefault()
if (!this.isIncludableLine(diffLine)) {
return
}
if (this.isMouseInHunkSelectionZone(ev)) {
this.highlightHunk(hunk, false)
} else {
this.highlightLine(index, false)
}
}
const mouseDownHandler = (ev: MouseEvent) => {
ev.preventDefault()
const isHunkSelection = this.isMouseInHunkSelectionZone(ev)
let isIncluded = false
if (this.props.file instanceof WorkingDirectoryFileChange) {
isIncluded = this.props.file.selection.isSelected(index)
}
this.onMouseDown(index, isIncluded, isHunkSelection)
}
const mouseMoveHandler = (ev: MouseEvent) => {
ev.preventDefault()
// ignoring anything from diff context rows
if (!this.isIncludableLine(diffLine)) {
return
}
// if selection is active, perform highlighting
if (!this.selection) {
// clear hunk selection in case transitioning from hunk->line
this.highlightHunk(hunk, false)
if (this.isMouseInHunkSelectionZone(ev)) {
this.highlightHunk(hunk, true)
} else {
this.highlightLine(index, true)
}
return
}
this.selection.update(index)
this.selection.paint(this.cachedGutterElements)
}
const mouseUpHandler = (ev: UIEvent) => {
ev.preventDefault()
this.onMouseUp(index)
}
if (!this.props.readOnly) {
reactContainer.addEventListener('mouseenter', mouseEnterHandler)
reactContainer.addEventListener('mouseleave', mouseLeaveHandler)
reactContainer.addEventListener('mousemove', mouseMoveHandler)
reactContainer.addEventListener('mousedown', mouseDownHandler)
reactContainer.addEventListener('mouseup', mouseUpHandler)
}
this.cachedGutterElements.set(index, reactContainer)
let isIncluded = false
if (this.props.file instanceof WorkingDirectoryFileChange) {
isIncluded = this.props.file.selection.isSelected(index)
}
const cache = this.cachedGutterElements
ReactDOM.render(
<DiffLineGutter
line={diffLine}
isIncluded={isIncluded}
/>, reactContainer)
index={index}
readOnly={this.props.readOnly}
diff={this.props.diff}
updateHunkHoverState={this.updateHunkHoverState}
isSelectionEnabled={this.isSelectionEnabled}
onMouseUp={this.onMouseUp}
onMouseDown={this.onMouseDown}
onMouseMove={this.onMouseMove} />,
reactContainer,
function (this: DiffLineGutter) {
if (this !== undefined) {
cache.set(index, this)
}
}
)
element.insertBefore(reactContainer, diffLineElement)
// Hack(ish?). In order to be a real good citizen we need to unsubscribe from
@ -378,17 +288,8 @@ export class Diff extends React.Component<IDiffProps, void> {
//
// See https://facebook.github.io/react/blog/2015/10/01/react-render-and-top-level-api.html
const gutterCleanup = new Disposable(() => {
this.cachedGutterElements.delete(index)
if (!this.props.readOnly) {
reactContainer.removeEventListener('mouseenter', mouseEnterHandler)
reactContainer.removeEventListener('mouseleave', mouseLeaveHandler)
reactContainer.removeEventListener('mousedown', mouseDownHandler)
reactContainer.removeEventListener('mousemove', mouseMoveHandler)
reactContainer.removeEventListener('mouseup', mouseUpHandler)
}
ReactDOM.unmountComponentAtNode(reactContainer)
line.off('delete', deleteHandler)
@ -446,20 +347,6 @@ export class Diff extends React.Component<IDiffProps, void> {
return null
}
/**
* normalize the line endings in the diff so that the CodeMirror Editor
* will display the unified diff correctly
*/
private formatLineEnding(text: string): string {
if (text.endsWith('\n')) {
return text
} else if (text.endsWith('\r')) {
return text + '\n'
} else {
return text + '\r\n'
}
}
private getAndStoreCodeMirrorInstance = (cmh: CodeMirrorHost) => {
this.codeMirror = cmh === null ? null : cmh.getEditor()
}
@ -479,7 +366,7 @@ export class Diff extends React.Component<IDiffProps, void> {
let diffText = ''
this.props.diff.hunks.forEach(hunk => {
hunk.lines.forEach(l => diffText += this.formatLineEnding(l.text))
hunk.lines.forEach(l => diffText += formatLineEnding(l.text))
})
const options: IEditorConfigurationExtra = {

View file

@ -1,6 +1,6 @@
import { DiffSelection } from '../../../models/diff'
import { ISelectionStrategy } from './selection-strategy'
import { selectedLineClass } from './selection'
import { DiffLineGutter } from '../diff-line-gutter'
import { compare } from '../../../lib/compare'
import { range } from '../../../lib/range'
@ -68,7 +68,7 @@ export class DragDropSelection implements ISelectionStrategy {
/**
* apply the selection strategy result to the current diff
*/
public apply(onIncludeChanged: (diffSelection: DiffSelection) => void) {
public done(): DiffSelection {
const length = (this.upperIndex - this.lowerIndex) + 1
const newSelection = this.snapshot.withRangeSelection(
@ -76,14 +76,14 @@ export class DragDropSelection implements ISelectionStrategy {
length,
this.desiredSelection)
onIncludeChanged(newSelection)
return newSelection
}
/**
* Compute the range of lines to repaint, based on how far the user
* has moved their cursor
*/
private determineDirtyRange(elements: Map<number, HTMLSpanElement>): { start: number, end: number} {
private determineDirtyRange(elements: Map<number, DiffLineGutter>): { start: number, end: number} {
// as user can go back and forth when doing drag-and-drop, we should
// update rows outside the current selected range
@ -123,7 +123,7 @@ export class DragDropSelection implements ISelectionStrategy {
/**
* repaint the current diff gutter to visualize the current state
*/
public paint(elements: Map<number, HTMLSpanElement>) {
public paint(elements: Map<number, DiffLineGutter>) {
const { start, end } = this.determineDirtyRange(elements)
@ -135,17 +135,7 @@ export class DragDropSelection implements ISelectionStrategy {
}
const selected = this.getIsSelected(row)
const childSpan = element.children[0] as HTMLSpanElement
if (!childSpan) {
console.error('expected DOM element for diff gutter not found')
return
}
if (selected) {
childSpan.classList.add(selectedLineClass)
} else {
childSpan.classList.remove(selectedLineClass)
}
element.setSelected(selected)
})
}

View file

@ -1,7 +1,7 @@
import { DiffSelection } from '../../../models/diff'
import { ISelectionStrategy } from './selection-strategy'
import { DiffLineGutter } from '../diff-line-gutter'
import { range } from '../../../lib/range'
import { selectedLineClass } from './selection'
/** apply hunk selection to the current diff */
export class HunkSelection implements ISelectionStrategy {
@ -21,7 +21,7 @@ export class HunkSelection implements ISelectionStrategy {
// no-op
}
public paint(elements: Map<number, HTMLSpanElement>) {
public paint(elements: Map<number, DiffLineGutter>) {
range(this._start, this._end).forEach(row => {
const element = elements.get(row)
@ -30,28 +30,17 @@ export class HunkSelection implements ISelectionStrategy {
return
}
// HACK: Don't update classes for non-selectable lines
const classList = element.children[0].classList
if (!classList.contains('diff-add') && !classList.contains('diff-delete')) {
if (!element.isIncluded()) {
return
}
const selected = this._desiredSelection
const childSpan = element.children[0] as HTMLSpanElement
if (!childSpan) {
console.error('expected DOM element for diff gutter not found')
return
}
if (selected) {
childSpan.classList.add(selectedLineClass)
} else {
childSpan.classList.remove(selectedLineClass)
}
element.setSelected(selected)
})
}
public apply(onIncludeChanged: (diffSelection: DiffSelection) => void) {
public done(): DiffSelection {
const length = (this._end - this._start) + 1
@ -60,6 +49,6 @@ export class HunkSelection implements ISelectionStrategy {
length,
this._desiredSelection)
onIncludeChanged(newSelection)
return newSelection
}
}

View file

@ -1,4 +1,5 @@
import { DiffSelection } from '../../../models/diff'
import { DiffLineGutter } from '../diff-line-gutter'
export interface ISelectionStrategy {
/**
@ -8,9 +9,9 @@ export interface ISelectionStrategy {
/**
* repaint the current diff gutter to visualize the current state
*/
paint: (elements: Map<number, HTMLSpanElement>) => void
paint: (elements: Map<number, DiffLineGutter>) => void
/**
* apply the selection strategy result to the current diff
* get the diff selection now that the gesture is complete
*/
apply: (onIncludeChanged: (diffSelection: DiffSelection) => void) => void
done: () => DiffSelection
}

View file

@ -14,15 +14,15 @@ interface ICommitProps {
/** A component which displays a single commit in a commit list. */
export class CommitListItem extends React.Component<ICommitProps, void> {
public render() {
const authorDate = this.props.commit.authorDate
const authorDate = this.props.commit.author.date
const avatarURL = this.props.avatarURL || DefaultAvatarURL
return (
<div className='commit'>
<img className='avatar' src={avatarURL}/>
<div className='info'>
<EmojiText className='summary' emoji={this.props.emoji}>{this.props.commit.summary}</EmojiText>
<div className='byline' title={this.props.commit.authorDate.toString()}>
<RelativeTime date={authorDate} /> by {this.props.commit.authorName}
<div className='byline'>
<RelativeTime date={authorDate} /> by {this.props.commit.author.name}
</div>
</div>
</div>

View file

@ -25,7 +25,7 @@ export class CommitList extends React.Component<ICommitListProps, void> {
const sha = this.props.history[row]
const commit = this.props.commits.get(sha)
if (commit) {
const gitHubUser = this.props.gitHubUsers.get(commit.authorEmail.toLowerCase()) || null
const gitHubUser = this.props.gitHubUsers.get(commit.author.email.toLowerCase()) || null
const avatarURL = gitHubUser ? gitHubUser.avatarURL : null
return <CommitListItem key={commit.sha} commit={commit} avatarURL={avatarURL} emoji={this.props.emoji}/>
} else {

View file

@ -62,7 +62,7 @@ export class History extends React.Component<IHistoryProps, void> {
summary={commit.summary}
body={commit.body}
sha={commit.sha}
authorName={commit.authorName}
authorName={commit.author.name}
files={this.props.history.changedFiles}
emoji={this.props.emoji}
/>

View file

@ -53,10 +53,17 @@
line-height: normal;
display: flex;
height: 100%;
.before {
border-right: 1px solid var(--diff-border-color);
}
.after {
border-right: 4px solid var(--diff-border-color);
}
}
.diff-line-number {
border-right: 1px solid var(--diff-border-color);
display: inline-block;
color: var(--diff-line-number-color);
width: var(--diff-line-number-column-width);

View file

@ -1,6 +1,6 @@
import * as path from 'path'
const fs = require('fs-extra')
import * as fs from 'fs-extra'
const temp = require('temp').track()
import { Repository } from '../src/models/repository'

View file

@ -1,5 +1,6 @@
import * as chai from 'chai'
const expect = chai.expect
import { expect, use as chaiUse } from 'chai'
chaiUse(require('chai-datetime'))
import { CommitIdentity } from '../../src/models/commit-identity'
@ -11,6 +12,15 @@ describe('CommitIdentity', () => {
expect(identity!.name).to.equal('Markus Olsson')
expect(identity!.email).to.equal('markus@github.com')
expect(identity!.date).to.equalTime(new Date('2016-10-05T12:29:40.000Z'))
})
it('parses timezone information', () => {
const identity1 = CommitIdentity.parseIdentity('Markus Olsson <markus@github.com> 1475670580 +0130')
expect(identity1!.tzOffset).to.equal(90)
const identity2 = CommitIdentity.parseIdentity('Markus Olsson <markus@github.com> 1475670580 -0245')
expect(identity2!.tzOffset).to.equal(-165)
})
it('parses even if the email address isn\'t a normal email', () => {
@ -20,5 +30,14 @@ describe('CommitIdentity', () => {
expect(identity!.name).to.equal('Markus Olsson')
expect(identity!.email).to.equal('Markus Olsson')
})
it('parses even if the email address is broken', () => {
// https://github.com/git/git/blob/3ef7618e616e023cf04180e30d77c9fa5310f964/ident.c#L292-L296
const identity = CommitIdentity.parseIdentity('Markus Olsson <Markus >Olsson> 1475670580 +0200')
expect(identity).not.to.be.null
expect(identity!.name).to.equal('Markus Olsson')
expect(identity!.email).to.equal('Markus >Olsson')
})
})
})

View file

@ -15,7 +15,7 @@ import { GitProcess } from 'git-kitchen-sink'
import { FileStatus, WorkingDirectoryFileChange } from '../../../src/models/status'
import { DiffSelectionType, DiffSelection } from '../../../src/models/diff'
const fs = require('fs-extra')
import * as fs from 'fs-extra'
const temp = require('temp').track()
describe('git/commit', () => {

View file

@ -60,10 +60,9 @@ describe('git/for-each-ref', () => {
expect(currentBranch!.tip.sha).to.equal('dfa96676b65e1c0ed43ca25492252a5e384c8efd')
expect(currentBranch!.tip.summary).to.equal('this is a commit title')
expect(currentBranch!.tip.body).to.contain('lucky last')
expect(currentBranch!.tip.authorName).to.equal('Brendan Forster')
expect(currentBranch!.tip.authorEmail).to.equal('brendan@github.com')
const date = currentBranch!.tip.authorDate
expect(date).to.equalDate(new Date('Tue Oct 18 16:23:42 2016 +1100'))
expect(currentBranch!.tip.author.name).to.equal('Brendan Forster')
expect(currentBranch!.tip.author.email).to.equal('brendan@github.com')
expect(currentBranch!.tip.author.date).to.equalDate(new Date('Tue Oct 18 16:23:42 2016 +1100'))
expect(currentBranch!.tip.parentSHAs.length).to.equal(1)
})

View file

@ -2,7 +2,7 @@ import * as chai from 'chai'
const expect = chai.expect
import * as path from 'path'
const fs = require('fs-extra')
import * as fs from 'fs-extra'
import { Repository } from '../../../src/models/repository'
import { FileStatus, WorkingDirectoryFileChange } from '../../../src/models/status'

View file

@ -7,7 +7,7 @@ import { setupFixtureRepository, setupEmptyRepository } from '../../fixture-help
import { FileStatus } from '../../../src/models/status'
import { GitProcess } from 'git-kitchen-sink'
const fs = require('fs-extra')
import * as fs from 'fs-extra'
const temp = require('temp').track()
describe('git/status', () => {

View file

@ -4,10 +4,12 @@ const expect = chai.expect
import { groupedAndFilteredBranches } from '../../src/ui/branches/grouped-and-filtered-branches'
import { Branch, BranchType } from '../../src/models/branch'
import { Commit } from '../../src/models/commit'
import { CommitIdentity } from '../../src/models/commit-identity'
describe('Branches grouping', () => {
const commit = new Commit('300acef', 'summary', 'body', 'Hubot', 'hubot@github.com', new Date(), [])
const author = new CommitIdentity('Hubot', 'hubot@github.com', new Date())
const commit = new Commit('300acef', 'summary', 'body', author, [])
const currentBranch = new Branch('master', null, commit, BranchType.Local)
const defaultBranch = new Branch('master', null, commit, BranchType.Local)

View file

@ -46,7 +46,7 @@
"electron-winstaller": "^2.3.0",
"express": "^4.13.4",
"extract-text-webpack-plugin": "^1.0.1",
"fs-extra": "^0.30.0",
"fs-extra": "^1.0.0",
"got": "^6.3.0",
"html-webpack-plugin": "^2.21.0",
"mocha": "^3.0.2",
@ -76,6 +76,7 @@
"@types/electron": "^1.3.15",
"@types/electron-window-state": "^2.0.27",
"@types/event-kit": "^1.2.28",
"@types/fs-extra": "0.0.35",
"@types/keytar": "^3.0.28",
"@types/mocha": "^2.2.29",
"@types/node": "^6.0.31",