Merge branch 'master' into pr/3675

This commit is contained in:
Brendan Forster 2018-04-02 17:54:33 +10:00
commit 63c199877d
147 changed files with 3387 additions and 1892 deletions

View file

@ -5,6 +5,7 @@ plugins:
- babel
- react
- prettier
- json
extends:
- prettier

View file

@ -28,7 +28,7 @@ time as the team learns, listens and refines how we work with the community.
### Code of Conduct
This project adheres to the Contributor Covenant [code of conduct](CODE_OF_CONDUCT.md).
This project adheres to the Contributor Covenant [code of conduct](../CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code.
Please report unacceptable behavior to [opensource+desktop@github.com](mailto:opensource+desktop@github.com).
@ -58,7 +58,7 @@ reports :mag_right:.
Before creating bug reports, please check [this list](#before-submitting-a-bug-report)
as you might find out that you don't need to create one. When you are creating
a bug report, please [include as many details as possible](#how-do-i-submit-a-good-bug-report).
Fill out [the required template](./.github/ISSUE_TEMPLATE.md), the information
Fill out [the required template](ISSUE_TEMPLATE.md), the information
it asks for helps us resolve issues faster.
#### Before Submitting A Bug Report
@ -73,7 +73,7 @@ comment to the existing issue if there is extra information you can contribute.
Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/).
Simply create an issue on the [GitHub Desktop issue tracker](https://github.com/desktop/desktop/issues)
and fill out the provided [issue template](./.github/ISSUE_TEMPLATE.md).
and fill out the provided [issue template](ISSUE_TEMPLATE.md).
The information we are interested in includes:
@ -93,7 +93,7 @@ community understand your suggestion :pencil: and find related suggestions
Before creating enhancement suggestions, please check [this list](#before-submitting-an-enhancement-suggestion)
as you might find out that you don't need to create one. When you are creating
an enhancement suggestion, please [include as many details as possible](#how-do-i-submit-a-good-enhancement-suggestion).
Fill in [the template](./.github/ISSUE_TEMPLATE.md), including the steps
Fill in [the template](ISSUE_TEMPLATE.md), including the steps
that you imagine you would take if the feature you're requesting existed.
#### Before Submitting An Enhancement Suggestion
@ -151,7 +151,7 @@ pull requests.
| `enhancement` | [search](https://github.com/desktop/desktop/labels/enhancement) | Feature requests. |
| `bug` | [search](https://github.com/desktop/desktop/labels/bug) | Confirmed bugs or reports that are very likely to be bugs. |
| `more-information-needed` | [search](https://github.com/desktop/desktop/labels/more-information-needed) | More information needs to be collected about these problems or feature requests (e.g. steps to reproduce). |
| `needs-reproduction` | [search](https://github.com/desktop/desktop/labels/needs-reproduction) | Likely bugs, but haven't been reliably reproduced. |
| `reviewer-needs-to-reproduce` | [search](https://github.com/desktop/desktop/labels/reviewer-needs-to-reproduce) | Likely bugs, but haven't been reliably reproduced by a reviewer. |
| `stale` | [search](https://github.com/desktop/desktop/labels/stale) | Issues that are inactive and marked to be closed. |
| `macOS` | [search](https://github.com/desktop/desktop/labels/macOS) | Issues specific to macOS users. |
| `Windows` | [search](https://github.com/desktop/desktop/labels/Windows) | Issues specific to Windows users. |
@ -170,4 +170,5 @@ pull requests.
| Label name | :mag_right: | Description |
| --- | --- | --- |
| `infrastructure` | [search](https://github.com/desktop/desktop/labels/infrastructure) | Pull requests not related to the core application - documentation, dependencies, tooling, etc. |
| `ready-for-review` | [search](https://github.com/desktop/desktop/labels/ready-for-review) | Pull Requests that are ready to be reviewed by the maintainers. |

View file

@ -1,89 +1,65 @@
<!--
First and foremost, wed like to thank you for taking the time to contribute to our project. Before submitting your issue, please follow these steps:
Please summarize the issue in the title, and then use the template below to
fill out the details so we can reproduce the issue on our end.
1. Familiarize yourself with our contributing guide:
* https://github.com/desktop/desktop/blob/master/CONTRIBUTING.md#contributing-to-github-desktop
2. Check if your issue (and sometimes workaround) is in the known-issues doc:
* https://github.com/desktop/desktop/blob/master/docs/known-issues.md
3. Make sure your issue isnt a duplicate of another issue
4. If you have made it to this step, go ahead and fill out the template below
-->
### Description
[Description of the issue]
### Version
## Description
<!--
What version of GitHub Desktop are you running? This is displayed under the
`About GitHub Desktop` menu item. If you are running from source, include
the commit by running `git rev-parse HEAD` from your local repository.
Provide a detailed description of the behavior you're seeing or the behavior you'd like to see **below** this comment.
-->
**GitHub Desktop version:** [version here]
## Version
<!--
Place the version of GitHub Desktop you have installed **below** this comment. This is displayed under the 'About GitHub Desktop' menu item. If you are running from source, include the commit by running `git rev-parse HEAD` from the local repository.
-->
* GitHub Desktop:
<!--
Place the version of your operating system **below** this comment. The operating system you are running on may also help with reproducing the issue. If you are on macOS, launch 'About This Mac' and write down the OS version listed. If you are on Windows, open 'Command Prompt' and attach the output of this command: 'cmd /c ver'
-->
* Operating system:
The operating system you are running on may also help with reproducing the
issue:
- If you are on macOS, launch `About This Mac` and write down the OS version
listed.
- If you are on Windows, open `Command Prompt` and attach the output of this
command: `cmd /c ver`
## Steps to Reproduce
<!--
List the steps to reproduce your issue **below** this comment
ex,
1. `step 1`
2. `step 2`
3. `and so on…`
-->
**OS version:** [version here]
### Expected Behavior
<!-- What you expected to happen -->
### Steps to Reproduce
### Actual Behavior
<!-- What actually happens -->
1. [First step]
1. [Second step]
1. [and so on]
## Additional Information
<!--
Place any additional information, configuration, or data that might be necessary to reproduce the issue **below** this comment.
If the issue involves a specific public repository, including the information
about that repository will make it is easier to recreate the issue.
If you have screen shots or gifs that demonstrate the issue, please include them.
If you think screenshots or a GIF recording will help demonstrate the issue
better, feel free to add them here.
If the issue involves a specific public repository, including the information about it will make it easier to recreate the issue.
If you are dealing with a performance issue or regression, attaching a Timeline profile of the task will help the developers understand the runtime behavior of the application on your machine.
https://github.com/desktop/desktop/blob/master/docs/contributing/timeline-profile.md
-->
**Expected behavior:** [What you expected to happen]
**Actual behavior:** [What actually happened]
**Reproduces how often:** [What percentage of the time does it reproduce?]
### Logs
<!--
Attach your log file (You can simply drag your file here to insert it) to this issue. Please make sure the generated link to your log file is **below** this comment section otherwise it will not appear when you submit your issue.
There may be some relevant information in log files generated by GitHub
Desktop:
- If you are on macOS, attach the most recent log file from:
`~/Library/Application Support/GitHub Desktop/logs/*.desktop.production.log`
- If you are on Windows, attach the most recent log file from:
`%APPDATA%\GitHub Desktop\logs\*.desktop.production.log`
The log files are organized by date, so see if anything was generated for
today's date.
-->
#### Additional Information
<!--
Any additional information, configuration or data that might be necessary to
reproduce the issue.
If you are dealing with a performance issue or regression, attaching a
[Timeline profile](https://github.com/desktop/desktop/blob/master/docs/contributing/timeline-profile.md)
of the task will help the developers understand the runtime behavior of the
application on your machine.
macOS logs location: `~/Library/Application Support/GitHub Desktop/logs/*.desktop.production.log`
Windows logs location: `%APPDATA%\GitHub Desktop\logs\*.desktop.production.log`
The log files are organized by date, so see if anything was generated for today's date.
-->

13
.github/config.yml vendored
View file

@ -2,12 +2,17 @@
# *Required* Comment to reply with
requestInfoReplyComment: >
We require the template to be filled out on all new issues. We do this so that we can be certain we have
all the information we need to address your submission efficiently. This allows the maintainers to spend
more time fixing bugs, implementing enhancements, and reviewing and merging pull requests.
Thanks for reaching out!
We require the [template](https://github.com/desktop/desktop/blob/master/.github/CONTRIBUTING.md#how-do-i-submit-a-good-bug-report) to be filled out with all new issues. We do this so that
we can be certain we have all the information we need to address your
submission efficiently. This allows the maintainers to spend more time fixing
bugs, implementing enhancements, and reviewing and merging pull requests.
Thanks for understanding and meeting us halfway 😀
requestInfoLabelToAdd: more-information-needed
requestInfoOn:
pullRequest: false
issue: true
issue: true

1
.gitignore vendored
View file

@ -7,4 +7,5 @@ app/node_modules/
.DS_Store
.awcache
.idea/
.vs/
.eslintcache

View file

@ -57,7 +57,7 @@ install:
- yarn install --force
script:
- yarn lint && yarn build:prod && yarn test:setup && yarn test
- yarn lint && yarn validate-changelog && yarn build:prod && yarn test:setup && yarn test
after_failure:
- yarn test:review

View file

@ -48,13 +48,16 @@ First, please search the [open issues](https://github.com/desktop/desktop/issues
and [closed issues](https://github.com/desktop/desktop/issues?q=is%3Aclosed)
to see if your issue hasn't already been reported (it may also be fixed).
There is also a list of [known issues](https://github.com/desktop/desktop/blob/master/docs/known-issues.md)
that are being tracked against Desktop, and some of these issues have workarounds.
If you can't find an issue that matches what you're seeing, open a [new issue](https://github.com/desktop/desktop/issues/new)
and fill out the template to provide us with enough information to investigate
further.
## How can I contribute to GitHub Desktop?
The [CONTRIBUTING.md](./CONTRIBUTING.md) document will help you get setup and
The [CONTRIBUTING.md](./.github/CONTRIBUTING.md) document will help you get setup and
familiar with the source. The [documentation](docs/) folder also contains more
resources relevant to the project.

View file

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

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "1.0.14-beta4",
"version": "1.1.2-beta1",
"main": "./main.js",
"repository": {
"type": "git",
@ -25,7 +25,7 @@
"codemirror-mode-elixir": "1.1.1",
"deep-equal": "^1.0.1",
"dexie": "^2.0.0",
"dugite": "1.57.0",
"dugite": "1.61.0",
"electron-window-state": "^4.0.2",
"event-kit": "^2.0.0",
"file-uri-to-path": "0.0.2",
@ -37,11 +37,11 @@
"moment": "^2.17.1",
"mri": "^1.1.0",
"primer-support": "^4.0.0",
"react": "^15.6.2",
"react": "^16.2.0",
"react-addons-shallow-compare": "^15.6.2",
"react-dom": "^15.6.2",
"react-dom": "^16.2.0",
"react-transition-group": "^1.2.0",
"react-virtualized": "^9.10.1",
"react-virtualized": "^9.18.5",
"registry-js": "^1.0.7",
"runas": "^3.1.1",
"source-map-support": "^0.4.15",
@ -58,9 +58,7 @@
"devDependencies": {
"devtron": "^1.4.0",
"electron-debug": "^1.1.0",
"electron-devtools-installer": "^2.2.1",
"react-addons-perf": "15.4.2",
"react-addons-test-utils": "^15.6.2",
"electron-devtools-installer": "^2.2.3",
"style-loader": "^0.13.2",
"temp": "^0.8.3",
"webpack-hot-middleware": "^2.10.0"

View file

@ -343,11 +343,11 @@ export interface IRepositoryState {
readonly gitHubUsers: Map<string, IGitHubUser>
/** The commits loaded, keyed by their full SHA. */
readonly commits: Map<string, Commit>
readonly commitLookup: Map<string, Commit>
/**
* The ordered local commit SHAs. The commits themselves can be looked up in
* `commits.`
* `commitLookup.`
*/
readonly localCommitSHAs: ReadonlyArray<string>

View file

@ -65,13 +65,21 @@ export function compareDescending<T>(x: T, y: T): number {
return 0
}
/**
* Compares the two strings in a case-insensitive manner and returns a value
* indicating whether these are equal
*/
export function caseInsensitiveEquals(x: string, y: string): boolean {
return x.toLowerCase() === y.toLowerCase()
}
/**
* Compares the two strings in a case-insensitive manner and returns a value
* indicating whether one is greater than the other. When the return value is
* used in a sort operation the comparands will be sorted in ascending order.
*/
export function caseInsensitiveCompare(x: string, y: string): number {
return compare(x.toLowerCase(), y.toLocaleLowerCase())
return compare(x.toLowerCase(), y.toLowerCase())
}
/**
@ -80,5 +88,5 @@ export function caseInsensitiveCompare(x: string, y: string): number {
* used in a sort operation the comparands will be sorted in descending order.
*/
export function caseInsensitiveCompareDescending(x: string, y: string): number {
return compareDescending(x.toLowerCase(), y.toLocaleLowerCase())
return compareDescending(x.toLowerCase(), y.toLowerCase())
}

View file

@ -30,8 +30,8 @@ export interface IMentionableAssociation {
}
export class GitHubUserDatabase extends BaseDatabase {
public users: Dexie.Table<IGitHubUser, number>
public mentionables: Dexie.Table<IMentionableAssociation, number>
public users!: Dexie.Table<IGitHubUser, number>
public mentionables!: Dexie.Table<IMentionableAssociation, number>
public constructor(name: string, schemaVersion?: number) {
super(name, schemaVersion)

View file

@ -10,7 +10,7 @@ export interface IIssue {
}
export class IssuesDatabase extends BaseDatabase {
public issues: Dexie.Table<IIssue, number>
public issues!: Dexie.Table<IIssue, number>
public constructor(name: string, schemaVersion?: number) {
super(name, schemaVersion)

View file

@ -70,8 +70,8 @@ export interface IPullRequestStatus {
}
export class PullRequestDatabase extends BaseDatabase {
public pullRequests: Dexie.Table<IPullRequest, number>
public pullRequestStatus: Dexie.Table<IPullRequestStatus, number>
public pullRequests!: Dexie.Table<IPullRequest, number>
public pullRequestStatus!: Dexie.Table<IPullRequestStatus, number>
public constructor(name: string, schemaVersion?: number) {
super(name, schemaVersion)

View file

@ -30,13 +30,13 @@ export interface IDatabaseRepository {
/** The repositories database. */
export class RepositoriesDatabase extends BaseDatabase {
/** The local repositories table. */
public repositories: Dexie.Table<IDatabaseRepository, number>
public repositories!: Dexie.Table<IDatabaseRepository, number>
/** The GitHub repositories table. */
public gitHubRepositories: Dexie.Table<IDatabaseGitHubRepository, number>
public gitHubRepositories!: Dexie.Table<IDatabaseGitHubRepository, number>
/** The GitHub repository owners table. */
public owners: Dexie.Table<IDatabaseOwner, number>
public owners!: Dexie.Table<IDatabaseOwner, number>
/**
* Initialize a new repository database.

View file

@ -58,7 +58,7 @@ export class DiffParser {
* The offset into the text property where the current line starts (ie either zero
* or one character ahead of the last newline character).
*/
private ls: number
private ls!: number
/**
* Line end pointer.
@ -66,12 +66,12 @@ export class DiffParser {
* The offset into the text property where the current line ends (ie it points to
* the newline character) or -1 if the line boundary hasn't been determined yet
*/
private le: number
private le!: number
/**
* The text buffer containing the raw, unified diff output to be parsed
*/
private text: string
private text!: string
public constructor() {
this.reset()

View file

@ -487,8 +487,8 @@ export class Dispatcher {
}
/** Update the repository's issues from GitHub. */
public updateIssues(repository: GitHubRepository): Promise<void> {
return this.appStore._updateIssues(repository)
public refreshIssues(repository: GitHubRepository): Promise<void> {
return this.appStore._refreshIssues(repository)
}
/** End the Welcome flow. */
@ -806,6 +806,18 @@ export class Dispatcher {
} catch (e) {
rejectOAuthRequest(e)
}
if (__DARWIN__) {
// workaround for user reports that the application doesn't receive focus
// after completing the OAuth signin in the browser
const window = remote.getCurrentWindow()
if (!window.isFocused()) {
log.info(
`refocusing the main window after the OAuth flow is completed`
)
window.focus()
}
}
break
case 'open-repository-from-url':

View file

@ -26,8 +26,8 @@ function enableBetaFeatures(): boolean {
return enableDevelopmentFeatures() || __RELEASE_CHANNEL__ === 'beta'
}
/** Should PR integration be enabled? */
export function enablePRIntegration(): boolean {
/** Should the new Compare view be enabled? */
export function enableCompareBranch(): boolean {
return enableBetaFeatures()
}

View file

@ -76,9 +76,14 @@ export function tailByLine(
cb: (line: string) => void
): Disposable {
const tailer = new Tailer(path)
const disposable = tailer.onDataAvailable(stream => {
const onErrorDisposable = tailer.onError(error => {
log.warn(`Unable to tail path: ${path}`, error)
})
const onDataDisposable = tailer.onDataAvailable(stream => {
byline(stream).on('data', (buffer: Buffer) => {
if (disposable.disposed) {
if (onDataDisposable.disposed) {
return
}
@ -90,7 +95,8 @@ export function tailByLine(
tailer.start()
return new Disposable(() => {
disposable.dispose()
onDataDisposable.dispose()
onErrorDisposable.dispose()
tailer.stop()
})
}

View file

@ -245,6 +245,8 @@ function getDescriptionForError(error: DugiteError): string {
return 'This branch cannot be deleted from the remote repository because it is marked as protected.'
case DugiteError.ProtectedBranchRequiredStatus:
return 'The push was rejected by the remote server because a required status check has not been satisfied.'
case DugiteError.BranchRenameFailed:
return 'The branch could not be renamed.'
default:
return assertNever(error, `Unknown error: ${error}`)
}

View file

@ -17,6 +17,8 @@ import {
Image,
LineEndingsChange,
parseLineEndingText,
ILargeTextDiff,
IUnrenderableDiff,
} from '../../models/diff'
import { spawnAndComplete } from './spawn'
@ -24,23 +26,23 @@ import { spawnAndComplete } from './spawn'
import { DiffParser } from '../diff-parser'
/**
* V8 has a limit on the size of string it can create, and unless we want to
* V8 has a limit on the size of string it can create (~256MB), and unless we want to
* trigger an unhandled exception we need to do the encoding conversion by hand.
*
* This is a hard limit on how big a buffer can be and still be converted into
* a string.
*/
const MaxDiffBufferSize = 268435441
const MaxDiffBufferSize = 70e6 // 70MB in decimal
/**
* Where `MaxDiffBufferSize` is a hard limit, this is a suggested limit. Diffs
* bigger than this _could_ be displayed but it might cause some slowness.
*/
const MaxReasonableDiffSize = 3000000
const MaxReasonableDiffSize = MaxDiffBufferSize / 16 // ~4.375MB in decimal
/**
* The longest line length we should try to display. If a diff has a line longer
* than this, we probably shouldn't attempt it.
* than this, we probably shouldn't attempt it
*/
const MaxLineLength = 500000
@ -48,15 +50,15 @@ const MaxLineLength = 500000
* Utility function to check whether parsing this buffer is going to cause
* issues at runtime.
*
* @param output A buffer of binary text from a spawned process
* @param buffer A buffer of binary text from a spawned process
*/
function isValidBuffer(buffer: Buffer) {
return buffer.length < MaxDiffBufferSize
return buffer.length <= MaxDiffBufferSize
}
/** Is the buffer too large for us to reasonably represent? */
function isBufferTooLarge(buffer: Buffer) {
return !isValidBuffer(buffer) || buffer.length >= MaxReasonableDiffSize
return buffer.length >= MaxReasonableDiffSize
}
/** Is the diff too large for us to reasonably represent? */
@ -75,7 +77,14 @@ function isDiffTooLarge(diff: IRawDiff) {
/**
* Defining the list of known extensions we can render inside the app
*/
const imageFileExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico'])
const imageFileExtensions = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.ico',
'.webp',
])
/**
* Render the difference between a file in the given commit and its parent
@ -110,16 +119,8 @@ export async function getCommitDiff(
repository.path,
'getCommitDiff'
)
if (isBufferTooLarge(output)) {
return { kind: DiffType.TooLarge, length: output.length }
}
const diffText = diffFromRawDiffOutput(output)
if (isDiffTooLarge(diffText)) {
return { kind: DiffType.TooLarge, length: output.length }
}
return convertDiff(repository, file, diffText, commitish)
return buildDiff(output, repository, file, commitish)
}
/**
@ -196,20 +197,9 @@ export async function getWorkingDirectoryDiff(
'getWorkingDirectoryDiff',
successExitCodes
)
if (isBufferTooLarge(output)) {
// we know we can't transform this process output into a diff, so let's
// just return a placeholder for now that we can display to the user
// to say we're at the limits of the runtime
return { kind: DiffType.TooLarge, length: output.length }
}
const diffText = diffFromRawDiffOutput(output)
if (isDiffTooLarge(diffText)) {
return { kind: DiffType.TooLarge, length: output.length }
}
const lineEndingsChange = parseLineEndingsWarning(error)
return convertDiff(repository, file, diffText, 'HEAD', lineEndingsChange)
return buildDiff(output, repository, file, 'HEAD', lineEndingsChange)
}
async function getImageDiff(
@ -314,6 +304,9 @@ function getMediaType(extension: string) {
if (extension === '.ico') {
return 'image/x-icon'
}
if (extension === '.webp') {
return 'image/webp'
}
// fallback value as per the spec
return 'text/plain'
@ -365,6 +358,37 @@ function diffFromRawDiffOutput(output: Buffer): IRawDiff {
return parser.parse(pieces[pieces.length - 1])
}
function buildDiff(
buffer: Buffer,
repository: Repository,
file: FileChange,
commitish: string,
lineEndingsChange?: LineEndingsChange
): Promise<IDiff> {
if (!isValidBuffer(buffer)) {
// the buffer's diff is too large to be renderable in the UI
return Promise.resolve<IUnrenderableDiff>({ kind: DiffType.Unrenderable })
}
const diff = diffFromRawDiffOutput(buffer)
if (isBufferTooLarge(buffer) || isDiffTooLarge(diff)) {
// we don't want to render by default
// but we keep it as an option by
// passing in text and hunks
const largeTextDiff: ILargeTextDiff = {
kind: DiffType.LargeText,
text: diff.contents,
hunks: diff.hunks,
lineEndingsChange,
}
return Promise.resolve(largeTextDiff)
}
return convertDiff(repository, file, diff, commitish, lineEndingsChange)
}
/**
* Retrieve the binary contents of a blob from the object database
*
@ -381,11 +405,7 @@ export async function getBlobImage(
): Promise<Image> {
const extension = Path.extname(path)
const contents = await getBlobContents(repository, commitish, path)
const diff: Image = {
contents: contents.toString('base64'),
mediaType: getMediaType(extension),
}
return diff
return new Image(contents.toString('base64'), getMediaType(extension))
}
/**
* Retrieve the binary contents of a blob from the working directory
@ -403,9 +423,8 @@ export async function getWorkingDirectoryImage(
const contents = await fileSystem.readFile(
Path.join(repository.path, file.path)
)
const diff: Image = {
contents: contents.toString('base64'),
mediaType: getMediaType(Path.extname(file.path)),
}
return diff
return new Image(
contents.toString('base64'),
getMediaType(Path.extname(file.path))
)
}

View file

@ -3,11 +3,14 @@ import { Repository } from '../../models/repository'
import { Commit } from '../../models/commit'
import { Branch, BranchType } from '../../models/branch'
import { CommitIdentity } from '../../models/commit-identity'
import { ForkedRemotePrefix } from '../../models/remote'
import {
getTrailerSeparatorCharacters,
parseRawUnfoldedTrailers,
} from './interpret-trailers'
const ForksReferencesPrefix = `refs/remotes/${ForkedRemotePrefix}`
/** Get all the branches. */
export async function getBranches(
repository: Repository,
@ -102,6 +105,14 @@ export async function getBranches(
continue
}
if (ref.startsWith(ForksReferencesPrefix)) {
// hide refs from our known remotes as these are considered plumbing
// and can add noise to everywhere in the user interface where we
// display branches as forks will likely contain duplicates of the same
// ref names
continue
}
branches.push(
new Branch(name, upstream.length > 0 ? upstream : null, tip, type)
)

View file

@ -12,6 +12,14 @@ export interface ITrailer {
readonly value: string
}
/**
* Gets a value indicating whether the trailer token is
* Co-Authored-By. Does not validate the token value.
*/
export function isCoAuthoredByTrailer(trailer: ITrailer) {
return trailer.token.toLowerCase() === 'co-authored-by'
}
/**
* Parse a string containing only unfolded trailers produced by
* git-interpret-trailers --only-input --only-trailers --unfold or

View file

@ -1,6 +1,7 @@
import { git } from './core'
import { Repository } from '../../models/repository'
import { IRemote } from '../../models/remote'
import { findDefaultRemote } from '../stores/helpers/find-default-remote'
/** Get the remote names. */
export async function getRemotes(
@ -21,18 +22,7 @@ export async function getRemotes(
export async function getDefaultRemote(
repository: Repository
): Promise<IRemote | null> {
const remotes = await getRemotes(repository)
if (remotes.length === 0) {
return null
}
const remote = remotes.find(x => x.name === 'origin')
if (remote) {
return remote
}
return remotes[0]
return findDefaultRemote(await getRemotes(repository))
}
/** Add a new remote with the given URL. */
@ -40,8 +30,10 @@ export async function addRemote(
repository: Repository,
name: string,
url: string
): Promise<void> {
): Promise<IRemote> {
await git(['remote', 'add', name, url], repository.path, 'addRemote')
return { url, name }
}
/** Removes an existing remote, or silently errors if it doesn't exist */

View file

@ -1,10 +1,22 @@
import { git } from './core'
import { Repository } from '../../models/repository'
import { SubmoduleEntry } from '../../models/submodule'
import { pathExists } from '../file-system'
import * as Path from 'path'
export async function listSubmodules(
repository: Repository
): Promise<ReadonlyArray<SubmoduleEntry>> {
const [submodulesFile, submodulesDir] = await Promise.all([
pathExists(Path.join(repository.path, '.gitmodules')),
pathExists(Path.join(repository.path, '.git', 'modules')),
])
if (!submodulesFile && !submodulesDir) {
log.info('No submodules found. Skipping "git submodule status"')
return []
}
// We don't recurse when listing submodules here because we don't have a good
// story about managing these currently. So for now we're only listing
// changes to the top-level submodules to be consistent with `git status`

View file

@ -1,4 +1,5 @@
import { spawn } from 'child_process'
import * as Path from 'path'
export function isGitOnPath(): Promise<boolean> {
// Modern versions of macOS ship with a Git shim that guides you through
@ -11,12 +12,20 @@ export function isGitOnPath(): Promise<boolean> {
// adapted from http://stackoverflow.com/a/34953561/1363815
return new Promise<boolean>((resolve, reject) => {
const process = spawn('where', ['git'])
if (__WIN32__) {
const windowsRoot = process.env.SystemRoot || 'C:\\Windows'
const wherePath = Path.join(windowsRoot, 'System32', 'where.exe')
const cp = spawn(wherePath, ['git'])
cp.on('error', error => {
log.warn('Unable to spawn where.exe', error)
resolve(false)
})
// `where` will return 0 when the executable
// is found under PATH, or 1 if it cannot be found
process.on('close', function(code) {
cp.on('close', function(code) {
resolve(code === 0)
})
return

21
app/src/lib/menu-item.ts Normal file
View file

@ -0,0 +1,21 @@
export interface IMenuItem {
/** The user-facing label. */
readonly label?: string
/** The action to invoke when the user selects the item. */
readonly action?: () => void
/** The type of item. */
readonly type?: 'separator'
/** Is the menu item enabled? Defaults to true. */
readonly enabled?: boolean
/**
* The predefined behavior of the menu item.
*
* When specified the click property will be ignored.
* See https://electronjs.org/docs/api/menu-item#roles
*/
readonly role?: string
}

View file

@ -4,8 +4,11 @@ import * as Path from 'path'
import { CloningRepository } from '../models/cloning-repository'
import { Repository } from '../models/repository'
import { Account } from '../models/account'
import { IRemote } from '../models/remote'
import { getHTMLURL } from './api'
import { parseRemote } from './remote-parsing'
import { caseInsensitiveEquals } from './compare'
import { GitHubRepository } from '../models/github-repository'
export interface IMatchedGitHubRepository {
/**
@ -92,3 +95,56 @@ export function matchExistingRepository(
}) || null
)
}
/**
* Check whether or not a GitHub repository matches a given remote.
*
* @param gitHubRepository the repository containing information from the GitHub API
* @param remote the remote details found in the Git repository
*/
export function repositoryMatchesRemote(
gitHubRepository: GitHubRepository,
remote: IRemote
): boolean {
return (
urlMatchesRemote(gitHubRepository.htmlURL, remote) ||
urlMatchesRemote(gitHubRepository.cloneURL, remote)
)
}
/**
* Check whether or not a GitHub repository URL matches a given remote, by
* parsing and comparing the structure of the each URL.
*
* @param url a URL associated with the GitHub repository
* @param remote the remote details found in the Git repository
*/
export function urlMatchesRemote(url: string | null, remote: IRemote): boolean {
if (url == null) {
return false
}
const cloneUrl = parseRemote(url)
const remoteUrl = parseRemote(remote.url)
if (remoteUrl == null || cloneUrl == null) {
return false
}
if (!caseInsensitiveEquals(remoteUrl.hostname, cloneUrl.hostname)) {
return false
}
if (remoteUrl.owner == null || cloneUrl.owner == null) {
return false
}
if (remoteUrl.name == null || cloneUrl.name == null) {
return false
}
return (
caseInsensitiveEquals(remoteUrl.owner, cloneUrl.owner) &&
caseInsensitiveEquals(remoteUrl.name, cloneUrl.name)
)
}

View file

@ -1,7 +1,9 @@
import { spawn, ChildProcess } from 'child_process'
import * as Path from 'path'
import { assertNever } from '../fatal-error'
import { enumerateValues, HKEY, RegistryValueType } from 'registry-js'
import { pathExists } from '../file-system'
import { assertNever } from '../fatal-error'
import { IFoundShell } from './found-shell'
export enum Shell {
@ -43,75 +45,127 @@ export async function getAvailableShells(): Promise<
},
]
const powerShellPath = await findPowerShell()
if (powerShellPath != null) {
shells.push({
shell: Shell.PowerShell,
path: powerShellPath,
})
}
const hyperPath = await findHyper()
if (hyperPath != null) {
shells.push({
shell: Shell.Hyper,
path: hyperPath,
})
}
const gitBashPath = await findGitBash()
if (gitBashPath != null) {
shells.push({
shell: Shell.GitBash,
path: gitBashPath,
})
}
return shells
}
async function findPowerShell(): Promise<string | null> {
const powerShell = enumerateValues(
HKEY.HKEY_LOCAL_MACHINE,
'Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\PowerShell.exe'
)
if (powerShell.length > 0) {
const first = powerShell[0]
// NOTE:
// on Windows 7 these are both REG_SZ, which technically isn't supposed
// to contain unexpanded references to environment variables. But given
// it's also %SystemRoot% and we do the expanding here I think this is
// a fine workaround to do to support the maximum number of setups.
if (powerShell.length === 0) {
return null
}
if (
first.type === RegistryValueType.REG_EXPAND_SZ ||
first.type === RegistryValueType.REG_SZ
) {
const path = first.data.replace(
/^%SystemRoot%/i,
process.env.SystemRoot || 'C:\\Windows'
const first = powerShell[0]
// NOTE:
// on Windows 7 these are both REG_SZ, which technically isn't supposed
// to contain unexpanded references to environment variables. But given
// it's also %SystemRoot% and we do the expanding here I think this is
// a fine workaround to do to support the maximum number of setups.
if (
first.type === RegistryValueType.REG_EXPAND_SZ ||
first.type === RegistryValueType.REG_SZ
) {
const path = first.data.replace(
/^%SystemRoot%/i,
process.env.SystemRoot || 'C:\\Windows'
)
if (await pathExists(path)) {
return path
} else {
log.debug(
`[PowerShell] registry entry found but does not exist at '${path}'`
)
shells.push({
shell: Shell.PowerShell,
path,
})
}
}
return null
}
async function findHyper(): Promise<string | null> {
const hyper = enumerateValues(
HKEY.HKEY_CURRENT_USER,
'Software\\Classes\\Directory\\Background\\shell\\Hyper\\command'
)
if (hyper.length > 0) {
const first = hyper[0]
if (first.type === RegistryValueType.REG_SZ) {
// Registry key is structured as "{installationPath}\app-x.x.x\Hyper.exe" "%V"
// This regex is designed to get the path to the version-specific Hyper.
// commandPieces = ['"{installationPath}\app-x.x.x\Hyper.exe"', '"', '{installationPath}\app-x.x.x\Hyper.exe', ...]
const commandPieces = first.data.match(/(["'])(.*?)\1/)
const path = commandPieces
? commandPieces[2]
: process.env.LocalAppData.concat('\\hyper\\Hyper.exe') // fall back to the launcher in install root
shells.push({
shell: Shell.Hyper,
path: path,
})
if (hyper.length === 0) {
return null
}
const first = hyper[0]
if (first.type === RegistryValueType.REG_SZ) {
// Registry key is structured as "{installationPath}\app-x.x.x\Hyper.exe" "%V"
// This regex is designed to get the path to the version-specific Hyper.
// commandPieces = ['"{installationPath}\app-x.x.x\Hyper.exe"', '"', '{installationPath}\app-x.x.x\Hyper.exe', ...]
const commandPieces = first.data.match(/(["'])(.*?)\1/)
const path = commandPieces
? commandPieces[2]
: process.env.LocalAppData.concat('\\hyper\\Hyper.exe') // fall back to the launcher in install root
if (await pathExists(path)) {
return path
} else {
log.debug(`[Hyper] registry entry found but does not exist at '${path}'`)
}
}
const gitBash = enumerateValues(
return null
}
async function findGitBash(): Promise<string | null> {
const registryPath = enumerateValues(
HKEY.HKEY_LOCAL_MACHINE,
'SOFTWARE\\GitForWindows'
)
if (gitBash.length > 0) {
const installPathEntry = gitBash.find(e => e.name === 'InstallPath')
if (
installPathEntry &&
installPathEntry.type === RegistryValueType.REG_SZ
) {
shells.push({
shell: Shell.GitBash,
path: Path.join(installPathEntry.data, 'git-bash.exe'),
})
if (registryPath.length === 0) {
return null
}
const installPathEntry = registryPath.find(e => e.name === 'InstallPath')
if (installPathEntry && installPathEntry.type === RegistryValueType.REG_SZ) {
const path = Path.join(installPathEntry.data, 'git-bash.exe')
if (await pathExists(path)) {
return path
} else {
log.debug(
`[Git Bash] registry entry found but does not exist at '${path}'`
)
}
}
return shells
return null
}
export function launch(

View file

@ -37,11 +37,14 @@ export interface IDailyMeasures {
/** The number of partial commits. */
readonly partialCommits: number
/** The number of commits created with one or more co-authors. */
readonly coAuthoredCommits: number
}
export class StatsDatabase extends Dexie {
public launches: Dexie.Table<ILaunchStats, number>
public dailyMeasures: Dexie.Table<IDailyMeasures, number>
public launches!: Dexie.Table<ILaunchStats, number>
public dailyMeasures!: Dexie.Table<IDailyMeasures, number>
public constructor(name: string) {
super(name)

View file

@ -28,6 +28,7 @@ const DefaultDailyMeasures: IDailyMeasures = {
commits: 0,
partialCommits: 0,
openShellCount: 0,
coAuthoredCommits: 0,
}
interface ICalculatedStats {
@ -279,6 +280,13 @@ export class StatsStore {
}))
}
/** Record that a commit was created with one or more co-authors. */
public recordCoAuthoredCommit(): Promise<void> {
return this.updateDailyMeasures(m => ({
coAuthoredCommits: m.coAuthoredCommits + 1,
}))
}
/** Record that the user opened a shell. */
public recordOpenShell(): Promise<void> {
return this.updateDailyMeasures(m => ({

View file

@ -24,6 +24,16 @@ interface IEmail {
readonly visibility: EmailVisibility
}
function isKeyChainError(e: any) {
const error = e as Error
return (
error.message &&
error.message.startsWith(
'The user name or passphrase you entered is not correct'
)
)
}
/** The data-only interface for storage. */
interface IAccount {
readonly token: string
@ -83,7 +93,16 @@ export class AccountsStore extends BaseStore {
)
} catch (e) {
log.error(`Error adding account '${account.login}'`, e)
this.emitError(e)
if (__DARWIN__ && isKeyChainError(e)) {
this.emitError(
new Error(
`GitHub Desktop was unable to store the account token in the keychain. Please check you have unlocked access to the 'login' keychain.`
)
)
} else {
this.emitError(e)
}
return
}

View file

@ -16,6 +16,7 @@ import {
Progress,
ImageDiffType,
IRevertProgress,
IFetchProgress,
} from '../app-state'
import { Account } from '../../models/account'
import { Repository } from '../../models/repository'
@ -29,6 +30,7 @@ import { DiffSelection, DiffSelectionType, DiffType } from '../../models/diff'
import {
matchGitHubRepository,
IMatchedGitHubRepository,
repositoryMatchesRemote,
} from '../../lib/repository-matching'
import { API, getAccountForEndpoint, IAPIUser } from '../../lib/api'
import { caseInsensitiveCompare } from '../compare'
@ -75,6 +77,7 @@ import {
getMergeBase,
getRemotes,
ITrailer,
isCoAuthoredByTrailer,
} from '../git'
import { launchExternalEditor } from '../editors'
@ -91,7 +94,6 @@ import {
EmojiStore,
GitHubUserStore,
CloningRepositoriesStore,
ForkedRemotePrefix,
} from '../stores'
import { validatedRepositoryPath } from './helpers/validated-repository-path'
import { IGitAccount } from '../git/authentication'
@ -117,7 +119,7 @@ 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 { IRemote, ForkedRemotePrefix } from '../../models/remote'
import { IAuthor } from '../../models/author'
/**
@ -412,7 +414,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
},
commitAuthor: null,
gitHubUsers: new Map<string, IGitHubUser>(),
commits: new Map<string, Commit>(),
commitLookup: new Map<string, Commit>(),
localCommitSHAs: [],
aheadBehind: null,
remote: null,
@ -571,7 +573,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
}))
this.updateRepositoryState(repository, state => ({
commits: gitStore.commits,
commitLookup: gitStore.commitLookup,
localCommitSHAs: gitStore.localCommitSHAs,
aheadBehind: gitStore.aheadBehind,
remote: gitStore.remote,
@ -821,7 +823,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
const gitHubRepository = repository.gitHubRepository
if (gitHubRepository != null) {
this._updateIssues(gitHubRepository)
this._refreshIssues(gitHubRepository)
this.loadPullRequests(repository, async () => {
const promiseForPRs = this.pullRequestStore.fetchPullRequestsFromCache(
gitHubRepository
@ -869,14 +871,14 @@ export class AppStore extends TypedBaseStore<IAppState> {
return this._repositoryWithRefreshedGitHubRepository(repository)
}
public async _updateIssues(repository: GitHubRepository) {
public async _refreshIssues(repository: GitHubRepository) {
const user = getAccountForEndpoint(this.accounts, repository.endpoint)
if (!user) {
return
}
try {
await this._issuesStore.fetchIssues(repository, user)
await this._issuesStore.refreshIssues(repository, user)
} catch (e) {
log.warn(`Unable to fetch issues for ${repository.fullName}`, e)
}
@ -1399,6 +1401,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.statsStore.recordPartialCommit()
}
if (trailers != null && trailers.some(isCoAuthoredByTrailer)) {
this.statsStore.recordCoAuthoredCommit()
}
await this._refreshRepository(repository)
await this.refreshChangesSection(repository, {
includingStatus: true,
@ -1500,12 +1506,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
await Promise.all([
gitStore.loadCurrentRemote(),
gitStore.loadRemotes(),
gitStore.updateLastFetched(),
this.refreshAuthor(repository),
gitStore.loadContextualCommitMessage(),
refreshSectionPromise,
gitStore.loadUpstreamRemote(),
])
this._updateCurrentPullRequest(repository)
@ -2216,7 +2221,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
await gitStore.performFailableOperation(() =>
addRemote(repository, 'origin', apiRepository.clone_url)
)
await gitStore.loadCurrentRemote()
await gitStore.loadRemotes()
// skip pushing if the current branch is a detached HEAD or the repository
// is unborn
@ -2340,17 +2345,48 @@ export class AppStore extends TypedBaseStore<IAppState> {
)
}
/** Fetch the repository. */
/**
* Fetch all relevant remotes in the the repository.
*
* See gitStore.fetch for more details.
*
* Note that this method will not perform the fetch of the specified remote
* if _any_ fetches or pulls are currently in-progress.
*/
public _fetch(repository: Repository, fetchType: FetchType): Promise<void> {
return this.withAuthenticatingUser(repository, (repository, account) => {
return this.performFetch(repository, account, fetchType)
})
}
/**
* Fetch a particular remote in a repository.
*
* Note that this method will not perform the fetch of the specified remote
* if _any_ fetches or pulls are currently in-progress.
*/
private _fetchRemote(
repository: Repository,
remote: IRemote,
fetchType: FetchType
): Promise<void> {
return this.withAuthenticatingUser(repository, (repository, account) => {
return this.performFetch(repository, account, fetchType, [remote])
})
}
/**
* Fetch all relevant remotes or one or more given remotes in the repository.
*
* @param remotes Optional, one or more remotes to fetch if undefined all
* relevant remotes will be fetched. See gitStore.fetch for
* more detail on what constitutes a relevant remote.
*/
private async performFetch(
repository: Repository,
account: IGitAccount | null,
fetchType: FetchType
fetchType: FetchType,
remotes?: IRemote[]
): Promise<void> {
await this.withPushPull(repository, async () => {
const gitStore = this.getGitStore(repository)
@ -2360,12 +2396,23 @@ export class AppStore extends TypedBaseStore<IAppState> {
const refreshWeight = 0.1
const isBackgroundTask = fetchType === FetchType.BackgroundTask
await gitStore.fetch(account, isBackgroundTask, progress => {
const progressCallback = (progress: IFetchProgress) => {
this.updatePushPullFetchProgress(repository, {
...progress,
value: progress.value * fetchWeight,
})
})
}
if (remotes === undefined) {
await gitStore.fetch(account, isBackgroundTask, progressCallback)
} else {
await gitStore.fetchRemotes(
account,
remotes,
isBackgroundTask,
progressCallback
)
}
const refreshTitle = __DARWIN__
? 'Refreshing Repository'
@ -2392,6 +2439,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
if (fetchType === FetchType.UserInitiatedTask) {
this._refreshPullRequests(repository)
if (repository.gitHubRepository != null) {
this._refreshIssues(repository.gitHubRepository)
}
}
}
})
@ -2668,15 +2718,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
return this.signInStore.setTwoFactorOTP(otp)
}
public _setAppFocusState(isFocused: boolean): Promise<void> {
const changed = this.appIsFocused !== isFocused
this.appIsFocused = isFocused
if (changed) {
public async _setAppFocusState(isFocused: boolean): Promise<void> {
if (this.appIsFocused !== isFocused) {
this.appIsFocused = isFocused
this.emitUpdate()
}
return Promise.resolve()
}
/**
@ -2739,7 +2785,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
// the accounts field
const accounts = await this.accountsStore.getAll()
const repoState = selectedState.state
const commits = repoState.commits.values()
const commits = repoState.commitLookup.values()
this.loadAndCacheUsers(selectedState.repository, accounts, commits)
}
}
@ -2822,7 +2868,12 @@ export class AppStore extends TypedBaseStore<IAppState> {
await this.repositoriesStore.removeRepository(id)
}
this._showFoldout({ type: FoldoutType.Repository })
const allRepositories = await this.repositoriesStore.getAll()
if (allRepositories.length === 0) {
this._closeFoldout(FoldoutType.Repository)
} else {
this._showFoldout({ type: FoldoutType.Repository })
}
}
public async _cloneAgain(url: string, path: string): Promise<void> {
@ -3128,7 +3179,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
pr =>
pr.head.ref === upstream &&
pr.head.gitHubRepository != null &&
pr.head.gitHubRepository.cloneURL === remote.url
repositoryMatchesRemote(pr.head.gitHubRepository, remote)
) || null
return pr
@ -3233,7 +3284,16 @@ export class AppStore extends TypedBaseStore<IAppState> {
if (isRefInThisRepo) {
// We need to fetch FIRST because someone may have created a PR since the last fetch
await this._fetch(repository, FetchType.UserInitiatedTask)
const defaultRemote = await getDefaultRemote(repository)
// TODO: I think we could skip this fetch if we know that we have the branch locally
// already. That way we'd match the behavior of checking out a branch.
if (defaultRemote) {
await this._fetchRemote(
repository,
defaultRemote,
FetchType.UserInitiatedTask
)
}
await this._checkoutBranch(repository, head.ref)
} else if (head.gitHubRepository != null) {
const cloneURL = forceUnwrap(
@ -3244,11 +3304,11 @@ export class AppStore extends TypedBaseStore<IAppState> {
head.gitHubRepository.owner.login
)
const remotes = await getRemotes(repository)
const remote = remotes.find(r => r.name === remoteName)
const remote =
remotes.find(r => r.name === remoteName) ||
(await addRemote(repository, remoteName, cloneURL))
if (remote == null) {
await addRemote(repository, remoteName, cloneURL)
} else if (remote.url !== cloneURL) {
if (remote.url !== cloneURL) {
const error = new Error(
`Expected PR remote ${remoteName} url to be ${cloneURL} got ${
remote.url
@ -3259,19 +3319,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.emitError(error)
}
await this._fetchRemote(repository, remote, FetchType.UserInitiatedTask)
const gitStore = this.getGitStore(repository)
await this.withAuthenticatingUser(repository, async (repo, account) => {
await gitStore.fetchRemote(account, remoteName, false, progress => {
this.updatePushPullFetchProgress(repository, {
...progress,
value: progress.value,
})
})
})
this.updatePushPullFetchProgress(repository, null)
const localBranchName = `pr/${pullRequest.number}`
const doesBranchExist =
gitStore.allBranches.find(branch => branch.name === localBranchName) !=

View file

@ -18,7 +18,6 @@ import { queueWorkHigh } from '../../lib/queue-work'
import {
reset,
GitResetMode,
getDefaultRemote,
getRemotes,
fetch as fetchRepo,
fetchRefspec,
@ -26,7 +25,6 @@ import {
getBranches,
deleteRef,
IAheadBehind,
getBranchAheadBehind,
getCommits,
merge,
setRemoteURL,
@ -48,6 +46,7 @@ import {
mergeTrailers,
getTrailerSeparatorCharacters,
parseSingleUnfoldedTrailer,
isCoAuthoredByTrailer,
} from '../git'
import { IGitAccount } from '../git/authentication'
import { RetryAction, RetryActionType } from '../retry-actions'
@ -57,6 +56,7 @@ import {
findUpstreamRemote,
UpstreamRemoteName,
} from './helpers/find-upstream-remote'
import { findDefaultRemote } from './helpers/find-default-remote'
import { IAuthor } from '../../models/author'
import { formatCommitMessage } from '../format-commit-message'
import { GitAuthor } from '../../models/git-author'
@ -81,7 +81,7 @@ export class GitStore extends BaseStore {
private readonly shell: IAppShell
/** The commits keyed by their SHA. */
public readonly commits = new Map<string, Commit>()
public readonly commitLookup = new Map<string, Commit>()
private _history: ReadonlyArray<string> = new Array()
@ -109,6 +109,8 @@ export class GitStore extends BaseStore {
private _aheadBehind: IAheadBehind | null = null
private _defaultRemote: IRemote | null = null
private _remote: IRemote | null = null
private _upstream: IRemote | null = null
@ -280,7 +282,7 @@ export class GitStore extends BaseStore {
const commits = this._allBranches.map(b => b.tip)
for (const commit of commits) {
this.commits.set(commit.sha, commit)
this.commitLookup.set(commit.sha, commit)
}
this.emitNewCommitsLoaded(commits)
@ -447,7 +449,7 @@ export class GitStore extends BaseStore {
/** Store the given commits. */
private storeCommits(commits: ReadonlyArray<Commit>) {
for (const commit of commits) {
this.commits.set(commit.sha, commit)
this.commitLookup.set(commit.sha, commit)
}
}
@ -543,9 +545,7 @@ export class GitStore extends BaseStore {
// 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'
)
const coAuthorTrailers = foundTrailers.filter(isCoAuthoredByTrailer)
// This is the happy path, nothing more for us to do
if (coAuthorTrailers.length === 0) {
@ -714,7 +714,7 @@ export class GitStore extends BaseStore {
}
/**
* Fetch the default and upstream remote, using the given account for
* Fetch the default, current, and upstream remotes, using the given account for
* authentication.
*
* @param account - The account to use for authentication if needed.
@ -727,22 +727,34 @@ export class GitStore extends BaseStore {
backgroundTask: boolean,
progressCallback?: (fetchProgress: IFetchProgress) => void
): Promise<void> {
const remotes = []
const remote = this.remote
if (remote) {
remotes.push(remote)
// Use a map as a simple way of getting a unique set of remotes.
// Note that maps iterate in insertion order so the order in which
// we insert these will affect the order in which we fetch them
const remotes = new Map<string, IRemote>()
// We want to fetch the current remote first
if (this.remote) {
remotes.set(this.remote.name, this.remote)
}
const upstream = this.upstream
if (upstream) {
remotes.push(upstream)
// And then the default remote if it differs from the current
if (this.defaultRemote) {
remotes.set(this.defaultRemote.name, this.defaultRemote)
}
if (!remotes.length) {
return Promise.resolve()
// And finally the upstream if we're a fork
if (this.upstream) {
remotes.set(this.upstream.name, this.upstream)
}
return this.fetchRemotes(account, remotes, backgroundTask, progressCallback)
if (remotes.size > 0) {
await this.fetchRemotes(
account,
[...remotes.values()],
backgroundTask,
progressCallback
)
}
}
/**
@ -831,16 +843,6 @@ export class GitStore extends BaseStore {
}
}
/** Calculate the ahead/behind for the current branch. */
public async calculateAheadBehindForCurrentBranch(): Promise<void> {
if (this.tip.kind === TipState.Valid) {
const branch = this.tip.branch
this._aheadBehind = await getBranchAheadBehind(this.repository, branch)
}
this.emitUpdate()
}
public async loadStatus(): Promise<IStatusResult | null> {
const status = await this.performFailableOperation(() =>
getStatus(this.repository)
@ -856,7 +858,7 @@ export class GitStore extends BaseStore {
if (currentBranch || currentTip) {
if (currentTip && currentBranch) {
const cachedCommit = this.commits.get(currentTip)
const cachedCommit = this.commitLookup.get(currentTip)
const branchTipCommit =
cachedCommit ||
(await this.performFailableOperation(() =>
@ -888,45 +890,29 @@ export class GitStore extends BaseStore {
return status
}
/**
* Load the remote for the current branch, or the default remote if no
* tracking information found.
*/
public async loadCurrentRemote(): Promise<void> {
const tip = this.tip
public async loadRemotes(): Promise<void> {
const remotes = await getRemotes(this.repository)
this._defaultRemote = findDefaultRemote(remotes)
if (tip.kind === TipState.Valid) {
const branch = tip.branch
const currentRemoteName =
this.tip.kind === TipState.Valid && this.tip.branch.remote !== null
? this.tip.branch.remote
: null
if (branch.remote != null) {
const allRemotes = await getRemotes(this.repository)
const foundRemote = allRemotes.find(r => r.name === branch.remote)
// Load the remote that the current branch is tracking. If the branch
// is not tracking any remote or the remote which it's tracking has
// been removed we'll default to the default branch.
this._remote =
currentRemoteName !== null
? remotes.find(r => r.name === currentRemoteName) || this._defaultRemote
: this._defaultRemote
if (foundRemote) {
this._remote = foundRemote
}
}
}
if (this._remote == null) {
this._remote = await getDefaultRemote(this.repository)
}
this.emitUpdate()
}
/** Load the upstream remote if it exists. */
public async loadUpstreamRemote(): Promise<void> {
const parent =
this.repository.gitHubRepository &&
this.repository.gitHubRepository.parent
if (!parent) {
return
}
const remotes = await getRemotes(this.repository)
const upstream = findUpstreamRemote(parent, remotes)
this._upstream = upstream
this._upstream = parent ? findUpstreamRemote(parent, remotes) : null
this.emitUpdate()
}
@ -982,6 +968,11 @@ export class GitStore extends BaseStore {
return this._aheadBehind
}
/** Get the remote we're working with. */
public get defaultRemote(): IRemote | null {
return this._defaultRemote
}
/** Get the remote we're working with. */
public get remote(): IRemote | null {
return this._remote
@ -1060,7 +1051,7 @@ export class GitStore extends BaseStore {
await this.performFailableOperation(() =>
setRemoteURL(this.repository, name, url)
)
await this.loadCurrentRemote()
await this.loadRemotes()
this.emitUpdate()
}

View file

@ -0,0 +1,14 @@
import { IRemote } from '../../../models/remote'
/**
* Attempt to find the remote which we consider to be the "default"
* remote, i.e. in most cases the 'origin' remote.
*
* If no remotes are given this method will return null, if no "default"
* branch could be found the first remote is considered the default.
*
* @param remotes A list of remotes for a given repository
*/
export function findDefaultRemote(remotes: ReadonlyArray<IRemote>) {
return remotes.find(x => x.name === 'origin') || remotes[0] || null
}

View file

@ -48,10 +48,10 @@ export class IssuesStore {
}
/**
* Fetch the issues for the repository. This will delete any issues that have
* Refresh the issues for the current repository. This will delete any issues that have
* been closed and update or add any issues that have changed or been added.
*/
public async fetchIssues(repository: GitHubRepository, account: Account) {
public async refreshIssues(repository: GitHubRepository, account: Account) {
const api = API.fromAccount(account)
const lastUpdatedAt = await this.getLatestUpdatedAt(repository)

View file

@ -16,14 +16,7 @@ import {
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-'
import { IRemote, ForkedRemotePrefix } from '../../models/remote'
const Decrement = (n: number) => n - 1
const Increment = (n: number) => n + 1
@ -326,22 +319,29 @@ export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
const table = this.pullRequestDatabase.pullRequests
const prsToInsert = new Array<IPullRequest>()
let githubRepo: GitHubRepository | null = null
for (const pr of pullRequestsFromAPI) {
// Once the repo is found on first try, no need to keep looking
if (githubRepo == null && pr.head.repo != null) {
githubRepo = await this.repositoryStore.upsertGitHubRepository(
repository.endpoint,
pr.head.repo
// `pr.head.repo` represents the source of the pull request. It might be
// a branch associated with the current repository, or a fork of the
// current repository.
//
// In cases where the user has removed the fork of the repository after
// opening a pull request, this can be `null`, and the app will not store
// this pull request.
if (pr.head.repo == null) {
log.debug(
`Unable to store pull request #${pr.number} for repository ${
repository.fullName
} as it has no head repository associated with it`
)
continue
}
if (githubRepo == null) {
return fatalError(
"The PR doesn't seem to be associated with a GitHub repository"
)
}
const githubRepo = await this.repositoryStore.upsertGitHubRepository(
repository.endpoint,
pr.head.repo
)
const githubRepoDbId = forceUnwrap(
'PR cannot have non-existent repo',
@ -381,16 +381,7 @@ export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
})
}
if (prsToInsert.length <= 0) {
return
}
return this.pullRequestDatabase.transaction('rw', table, async () => {
// since all PRs come from the same repository
// using the base repoId of the fist element
// is sufficient here
const repoDbId = prsToInsert[0].base.repoId!
// we need to delete the stales PRs from the db
// so we remove all for a repo to avoid having to
// do diffing
@ -399,7 +390,9 @@ export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
.equals(repoDbId)
.delete()
await table.bulkAdd(prsToInsert)
if (prsToInsert.length > 0) {
await table.bulkAdd(prsToInsert)
}
})
}

View file

@ -49,8 +49,20 @@ export class RepositorySettingsStore extends BaseStore {
public async saveGitIgnore(text: string): Promise<void> {
const repository = this._repository
const ignorePath = Path.join(repository.path, '.gitignore')
const fileContents = await formatGitIgnoreContents(text, repository)
if (text === '') {
return new Promise<void>((resolve, reject) => {
FS.unlink(ignorePath, err => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
const fileContents = await formatGitIgnoreContents(text, repository)
return new Promise<void>((resolve, reject) => {
FS.writeFile(ignorePath, fileContents, err => {
if (err) {

View file

@ -31,6 +31,19 @@ export class Tailer {
return this.emitter.on('data', fn)
}
/**
* Register a function to be called whenever an error is reported by the underlying
* filesystem watcher.
*/
public onError(fn: (error: Error) => void): Disposable {
return this.emitter.on('error', fn)
}
private handleError(error: Error) {
this.state = null
this.emitter.emit('error', error)
}
/**
* Start tailing the file. This can only be called again after calling `stop`.
*/
@ -43,10 +56,12 @@ export class Tailer {
try {
const watcher = Fs.watch(this.path, this.onWatchEvent)
watcher.on('error', error => {
this.handleError(error)
})
this.state = { watcher, position: 0 }
} catch (e) {
log.debug(`unable to watch path: ${this.path}`, e)
this.state = null
} catch (error) {
this.handleError(error)
}
}

View file

@ -51,7 +51,7 @@ type LookupResult = {
*/
export class Tokenizer {
private readonly emoji: Map<string, string>
private readonly repository: GitHubRepository | null
private readonly repository: GitHubRepository | null = null
private _results = new Array<TokenResult>()
private _currentString = ''

View file

@ -1,6 +1,6 @@
import '../lib/logging/main/install'
import { app, Menu, MenuItem, ipcMain, BrowserWindow, shell } from 'electron'
import { app, Menu, ipcMain, BrowserWindow, shell } from 'electron'
import * as Fs from 'fs'
import { AppWindow } from './app-window'
@ -21,6 +21,8 @@ import {
} from '../lib/source-map-support'
import { now } from './now'
import { showUncaughtException } from './show-uncaught-exception'
import { IMenuItem } from '../lib/menu-item'
import { buildContextMenu } from './menu/build-context-menu'
enableSourceMaps()
@ -272,20 +274,10 @@ app.on('ready', () => {
ipcMain.on(
'show-contextual-menu',
(event: Electron.IpcMessageEvent, items: ReadonlyArray<any>) => {
const menu = new Menu()
const menuItems = items.map((item, i) => {
return new MenuItem({
label: item.label,
click: () => event.sender.send('contextual-menu-action', i),
type: item.type,
enabled: item.enabled,
})
})
for (const item of menuItems) {
menu.append(item)
}
(event: Electron.IpcMessageEvent, items: ReadonlyArray<IMenuItem>) => {
const menu = buildContextMenu(items, ix =>
event.sender.send('contextual-menu-action', ix)
)
const window = BrowserWindow.fromWebContents(event.sender)
menu.popup(window, { async: true })

View file

@ -0,0 +1,82 @@
import { IMenuItem } from '../../lib/menu-item'
import { Menu, MenuItem } from 'electron'
/**
* Gets a value indicating whether or not two roles are considered
* equal using a case-insensitive comparison.
*/
function roleEquals(x: string | undefined, y: string | undefined) {
return (x ? x.toLowerCase() : x) === (y ? y.toLowerCase() : y)
}
/**
* Get platform-specific edit menu items by leveraging Electron's
* built-in editMenu role.
*/
function getEditMenuItems(): ReadonlyArray<MenuItem> {
const menu = Menu.buildFromTemplate([{ role: 'editMenu' }]).items[0]
// Electron is violating its contract if there's no subMenu but
// we'd rather just ignore it than crash. It's not the end of
// the world if we don't have edit menu items.
const items = menu && menu.submenu ? menu.submenu.items : []
// We don't use styled inputs anywhere at the moment
// so let's skip this for now and when/if we do we
// can make it configurable from the callee
return items.filter(x => !roleEquals(x.role, 'pasteandmatchstyle'))
}
/**
* Create an Electron menu object for use in a context menu based on
* a template provided by the renderer.
*
* If the template contains a menu item with the role 'editMenu' the
* platform standard edit menu items will be inserted at the position
* of the 'editMenu' template.
*
* @param template One or more menu item templates as passed from
* the renderer.
* @param onClick A callback function for when one of the menu items
* constructed from the template is clicked. Callback
* is passed the index of the menu item in the template
* as the first argument and the template item itself
* as the second argument. Note that the callback will
* not be called when expanded/automatically created
* edit menu items are clicked.
*/
export function buildContextMenu(
template: ReadonlyArray<IMenuItem>,
onClick: (ix: number, item: IMenuItem) => void
): Menu {
const menuItems = new Array<MenuItem>()
for (const [ix, item] of template.entries()) {
// Special case editMenu in context menus. What we
// mean by this is that we want to insert all edit
// related menu items into the menu at this spot, we
// don't want a sub menu
if (roleEquals(item.role, 'editmenu')) {
menuItems.push(...getEditMenuItems())
} else {
// TODO: We're always overriding the click function here.
// It's possible that we might want to add a role-based
// menu item without a custom click function at some point
// in the future.
menuItems.push(
new MenuItem({
label: item.label,
type: item.type,
enabled: item.enabled,
role: item.role,
click: () => onClick(ix, item),
})
)
}
}
const menu = new Menu()
menuItems.forEach(x => menu.append(x))
return menu
}

View file

@ -6,43 +6,27 @@ import { getDotComAPIEndpoint, IAPIEmail } from '../lib/api'
* This contains a token that will be used for operations that require authentication.
*/
export class Account {
/** The access token used to perform operations on behalf of this account */
public readonly token: string
/** The login name for this account */
public readonly login: string
/** The server for this account - GitHub or a GitHub Enterprise instance */
public readonly endpoint: string
/** The current list of email addresses associated with the account */
public readonly emails: ReadonlyArray<IAPIEmail>
/** The profile URL to render for this account */
public readonly avatarURL: string
/** The database id for this account */
public readonly id: number
/** The friendly name associated with this account */
public readonly name: string
/** Create an account which can be used to perform unauthenticated API actions */
public static anonymous(): Account {
return new Account('', getDotComAPIEndpoint(), '', [], '', -1, '')
}
public constructor(
login: string,
endpoint: string,
token: string,
emails: ReadonlyArray<IAPIEmail>,
avatarURL: string,
id: number,
name: string
) {
this.login = login
this.endpoint = endpoint
this.token = token
this.emails = emails
this.avatarURL = avatarURL
this.id = id
this.name = name
}
/** The login name for this account */
public readonly login: string,
/** The server for this account - GitHub or a GitHub Enterprise instance */
public readonly endpoint: string,
/** The access token used to perform operations on behalf of this account */
public readonly token: string,
/** The current list of email addresses associated with the account */
public readonly emails: ReadonlyArray<IAPIEmail>,
/** The profile URL to render for this account */
public readonly avatarURL: string,
/** The database id for this account */
public readonly id: number,
/** The friendly name associated with this account */
public readonly name: string
) {}
public withToken(token: string): Account {
return new Account(

View file

@ -1,14 +1,18 @@
import { CommitIdentity } from './commit-identity'
import { ITrailer } from '../lib/git/interpret-trailers'
import { ITrailer, isCoAuthoredByTrailer } from '../lib/git/interpret-trailers'
import { GitAuthor } from './git-author'
import { GitHubRepository } from './github-repository'
import { getDotComAPIEndpoint } from '../lib/api'
/**
* Extract any Co-Authored-By trailers from an array of arbitrary
* trailers.
*/
function extractCoAuthors(trailers: ReadonlyArray<ITrailer>) {
const coAuthors = []
for (const trailer of trailers) {
if (trailer.token.toLowerCase() === 'co-authored-by') {
if (isCoAuthoredByTrailer(trailer)) {
const author = GitAuthor.parse(trailer.value)
if (author) {
coAuthors.push(author)

View file

@ -19,6 +19,11 @@ export class Image {
* The data URI media type, so the browser can render the image correctly
*/
public readonly mediaType: string
public constructor(contents: string, mediaType: string) {
this.contents = contents
this.mediaType = mediaType
}
}
/** each diff is made up of a number of hunks */
@ -46,16 +51,18 @@ export class DiffHunk {
}
export enum DiffType {
/** changes to a text file, which may be partially selected for commit */
/** Changes to a text file, which may be partially selected for commit */
Text,
/** changes to files of a known format, which can be viewed in the app */
/** Changes to files of a known format, which can be viewed in the app */
Image,
/** changes to an unknown file format, which Git is unable to present in a human-friendly format */
/** Changes to an unknown file format, which Git is unable to present in a human-friendly format */
Binary,
/** change to a repository which is included as a submodule of this repository */
/** Change to a repository which is included as a submodule of this repository */
Submodule,
/** diff too large to render in app */
TooLarge,
/** Diff is large enough to degrade ux if rendered */
LargeText,
/** Diff that will not be rendered */
Unrenderable,
}
/** indicate what a line in the diff represents */
@ -118,18 +125,27 @@ export interface IBinaryDiff {
readonly kind: DiffType.Binary
}
export interface IDiffTooLarge {
readonly kind: DiffType.TooLarge
/**
* The length of the diff output from Git which exceeds the runtime limits:
*
* 268435441 bytes = 256MB - 15 bytes
*/
readonly length: number
export interface ILargeTextDiff {
readonly kind: DiffType.LargeText
/** The unified text diff - including headers and context */
readonly text: string
/** The diff contents organized by hunk - how the git CLI outputs to the caller */
readonly hunks: ReadonlyArray<DiffHunk>
/** A warning from Git that the line endings have changed in this file and will affect the commit */
readonly lineEndingsChange?: LineEndingsChange
}
export interface IUnrenderableDiff {
readonly kind: DiffType.Unrenderable
}
/** The union of diff types that can be rendered in Desktop */
export type IDiff = ITextDiff | IImageDiff | IBinaryDiff | IDiffTooLarge
export type IDiff =
| ITextDiff
| IImageDiff
| IBinaryDiff
| ILargeTextDiff
| IUnrenderableDiff
/** track details related to each line in the diff */
export class DiffLine {

View file

@ -1,3 +1,10 @@
/**
* This is the magic remote name prefix
* for when we add a remote on behalf of
* the user.
*/
export const ForkedRemotePrefix = 'github-desktop-'
/** A remote as defined in Git. */
export interface IRemote {
readonly name: string

View file

@ -148,7 +148,7 @@ function createState(props: IMenuPaneProps): IMenuPaneState {
}
export class MenuPane extends React.Component<IMenuPaneProps, IMenuPaneState> {
private list: List | null
private list: List | null = null
public constructor(props: IMenuPaneProps) {
super(props)

View file

@ -122,7 +122,7 @@ export class App extends React.Component<IAppProps, IAppState> {
* modal dialog such as the preferences, or an error dialog.
*/
private get isShowingModal() {
return this.state.currentPopup || this.state.errors.length
return this.state.currentPopup !== null || this.state.errors.length > 0
}
public constructor(props: IAppProps) {
@ -535,11 +535,24 @@ export class App extends React.Component<IAppProps, IAppState> {
}
public componentDidMount() {
document.ondragover = document.ondrop = e => {
document.ondragover = e => {
if (this.isShowingModal) {
e.dataTransfer.dropEffect = 'none'
} else {
e.dataTransfer.dropEffect = 'copy'
}
e.preventDefault()
}
document.ondrop = e => {
e.preventDefault()
}
document.body.ondrop = e => {
if (this.isShowingModal) {
return
}
const files = e.dataTransfer.files
this.handleDragAndDrop(files)
e.preventDefault()
@ -867,9 +880,7 @@ export class App extends React.Component<IAppProps, IAppState> {
)
}
private onPopupDismissed = () => {
this.props.dispatcher.closePopup()
}
private onPopupDismissed = () => this.props.dispatcher.closePopup()
private onSignInDialogDismissed = () => {
this.props.dispatcher.resetSignInState()
@ -886,9 +897,8 @@ export class App extends React.Component<IAppProps, IAppState> {
)
}
private onUpdateAvailableDismissed = () => {
private onUpdateAvailableDismissed = () =>
this.props.dispatcher.setUpdateBannerVisibility(false)
}
private currentPopupContent(): JSX.Element | null {
// Hide any dialogs while we're displaying an error
@ -1250,9 +1260,7 @@ export class App extends React.Component<IAppProps, IAppState> {
this.props.dispatcher.performRetry(retryAction)
}
private onCheckForUpdates = () => {
this.checkForUpdates(false)
}
private onCheckForUpdates = () => this.checkForUpdates(false)
private showAcknowledgements = () => {
this.props.dispatcher.showPopup({ type: PopupType.Acknowledgements })
@ -1283,9 +1291,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return <FullScreenInfo windowState={this.state.windowState} />
}
private clearError = (error: Error) => {
this.props.dispatcher.clearError(error)
}
private clearError = (error: Error) => this.props.dispatcher.clearError(error)
private onConfirmDiscardChangesChanged = (value: boolean) => {
this.props.dispatcher.setConfirmDiscardChangesSetting(value)
@ -1332,7 +1338,6 @@ export class App extends React.Component<IAppProps, IAppState> {
onSelectionChanged={this.onSelectionChanged}
repositories={this.state.repositories}
onRemoveRepository={this.removeRepository}
onClose={this.onCloseRepositoryList}
onOpenInShell={this.openInShell}
onShowRepository={this.showRepository}
onOpenInExternalEditor={this.openInExternalEditor}
@ -1350,6 +1355,10 @@ export class App extends React.Component<IAppProps, IAppState> {
this.props.dispatcher.openShell(repository.path)
}
private openFileInExternalEditor = (path: string) => {
this.props.dispatcher.openInExternalEditor(path)
}
private openInExternalEditor = (
repository: Repository | CloningRepository
) => {
@ -1368,10 +1377,6 @@ export class App extends React.Component<IAppProps, IAppState> {
shell.showItemInFolder(repository.path)
}
private onCloseRepositoryList = () => {
this.props.dispatcher.closeFoldout(FoldoutType.Repository)
}
private onRepositoryDropdownStateChanged = (newState: DropdownState) => {
if (newState === 'open') {
this.props.dispatcher.showFoldout({ type: FoldoutType.Repository })
@ -1390,9 +1395,12 @@ export class App extends React.Component<IAppProps, IAppState> {
if (repository) {
icon = iconForRepository(repository)
title = repository.name
} else {
} else if (this.state.repositories.length > 0) {
icon = OcticonSymbol.repo
title = __DARWIN__ ? 'Select a Repository' : 'Select a repository'
} else {
icon = OcticonSymbol.repo
title = __DARWIN__ ? 'No Repositories' : 'No repositories'
}
const isOpen =
@ -1586,6 +1594,8 @@ export class App extends React.Component<IAppProps, IAppState> {
}
if (selectedState.type === SelectionType.Repository) {
const externalEditorLabel = this.state.selectedExternalEditor
return (
<RepositoryView
repository={selectedState.repository}
@ -1602,6 +1612,8 @@ export class App extends React.Component<IAppProps, IAppState> {
this.state.askForConfirmationOnDiscardChanges
}
accounts={this.state.accounts}
externalEditorLabel={externalEditorLabel}
onOpenInExternalEditor={this.openFileInExternalEditor}
/>
)
} else if (selectedState.type === SelectionType.CloningRepository) {

View file

@ -10,6 +10,7 @@ interface IRange {
}
import getCaretCoordinates = require('textarea-caret')
import { showContextualMenu } from '../main-process-proxy'
interface IAutocompletingTextInputProps<ElementType> {
/**
@ -24,6 +25,9 @@ interface IAutocompletingTextInputProps<ElementType> {
/** The current value of the input field. */
readonly value?: string
/** Disabled state for input field. */
readonly disabled?: boolean
/**
* Called when the user changes the value in the input field.
*/
@ -43,6 +47,12 @@ interface IAutocompletingTextInputProps<ElementType> {
* is mounted or unmounted.
*/
readonly onElementRef?: (elem: ElementType | null) => void
/**
* Optional callback to override the default edit context menu
* in the input field.
*/
readonly onContextMenu?: (event: React.MouseEvent<any>) => void
}
interface IAutocompletionState<T> {
@ -197,7 +207,7 @@ export abstract class AutocompletingTextInput<
const item = currentAutoCompletionState.items[row]
if (item) {
this.insertCompletion(item)
this.insertCompletion(item, 'mouseclick')
}
}
@ -231,16 +241,7 @@ export abstract class AutocompletingTextInput<
const item = items[row]
this.insertCompletion(item)
// This is pretty gross. Clicking on the list moves focus off the text area.
// Immediately moving focus back doesn't work. Gotta wait a runloop I guess?
window.setTimeout(() => {
const element = this.element
if (element) {
element.focus()
}
}, 0)
this.insertCompletion(item, 'mouseclick')
}
/**
@ -249,6 +250,15 @@ export abstract class AutocompletingTextInput<
*/
protected abstract getElementTagName(): 'textarea' | 'input'
private onContextMenu = (event: React.MouseEvent<any>) => {
if (this.props.onContextMenu) {
this.props.onContextMenu(event)
} else {
event.preventDefault()
showContextualMenu([{ role: 'editMenu' }])
}
}
private renderTextInput() {
const props = {
type: 'text',
@ -258,6 +268,8 @@ export abstract class AutocompletingTextInput<
onChange: this.onChange,
onKeyDown: this.onKeyDown,
onBlur: this.onBlur,
onContextMenu: this.onContextMenu,
disabled: this.props.disabled,
}
return React.createElement<React.HTMLAttributes<ElementType>, ElementType>(
@ -302,7 +314,17 @@ export abstract class AutocompletingTextInput<
)
}
private insertCompletion(item: Object) {
private setCursorPosition(newCaretPosition: number) {
if (this.element == null) {
log.warn('Unable to set cursor position when element is null')
return
}
this.element.selectionStart = newCaretPosition
this.element.selectionEnd = newCaretPosition
}
private insertCompletion(item: Object, source: 'mouseclick' | 'keyboard') {
const element = this.element!
const autocompletionState = this.state.autocompletionState!
const originalText = element.value
@ -310,17 +332,32 @@ export abstract class AutocompletingTextInput<
const autoCompleteText = autocompletionState.provider.getCompletionText(
item
)
const textWithAutoCompleteText =
originalText.substr(0, range.start - 1) + autoCompleteText + ' '
const newText =
originalText.substr(0, range.start - 1) +
autoCompleteText +
originalText.substr(range.start + range.length) +
' '
textWithAutoCompleteText + originalText.substr(range.start + range.length)
element.value = newText
if (this.props.onValueChanged) {
this.props.onValueChanged(newText)
}
const newCaretPosition = textWithAutoCompleteText.length
if (source === 'mouseclick') {
// we only need to re-focus on the text input when the autocomplete overlay
// steals focus due to the user clicking on a selection in the autocomplete list
window.setTimeout(() => {
element.focus()
this.setCursorPosition(newCaretPosition)
}, 0)
} else {
this.setCursorPosition(newCaretPosition)
}
this.close()
}
@ -385,7 +422,7 @@ export abstract class AutocompletingTextInput<
if (item) {
event.preventDefault()
this.insertCompletion(item)
this.insertCompletion(item, 'keyboard')
}
} else if (event.key === 'Escape') {
this.close()
@ -435,7 +472,18 @@ export abstract class AutocompletingTextInput<
this.props.onValueChanged(str)
}
const caretPosition = this.element!.selectionStart
const element = this.element
if (element === null) {
return
}
const caretPosition = element.selectionStart
if (caretPosition === null) {
return
}
const requestID = ++this.autocompletionRequestID
const autocompletionState = await this.attemptAutocompletion(
str,

View file

@ -52,7 +52,7 @@ export class IssuesAutocompletionProvider
text: string
): Promise<ReadonlyArray<IIssueHit>> {
this.updateIssuesScheduler.queue(() => {
this.dispatcher.updateIssues(this.repository)
this.dispatcher.refreshIssues(this.repository)
})
return this.issuesStore.getIssuesMatching(this.repository, text)

View file

@ -212,11 +212,11 @@ export class BranchList extends React.Component<
rowHeight={RowHeight}
filterText={this.props.filterText}
onFilterTextChanged={this.props.onFilterTextChanged}
onFilterKeyDown={this.props.onFilterKeyDown}
selectedItem={this.state.selectedItem}
renderItem={this.renderItem}
renderGroupHeader={this.renderGroupHeader}
onItemClick={this.onItemClick}
onFilterKeyDown={this.props.onFilterKeyDown}
onSelectionChanged={this.onSelectionChanged}
groups={this.state.groups}
invalidationProps={this.props.allBranches}
@ -239,7 +239,7 @@ export class BranchList extends React.Component<
if (this.props.canCreateNewBranch) {
return (
<Button className="new-branch-button" onClick={this.onCreateNewBranch}>
New
{__DARWIN__ ? 'New Branch' : 'New branch'}
</Button>
)
} else {

View file

@ -7,24 +7,22 @@ import { BranchList } from './branch-list'
import { TabBar } from '../tab-bar'
import { BranchesTab } from '../../models/branches-tab'
import { assertNever } from '../../lib/fatal-error'
import { enablePRIntegration } from '../../lib/feature-flag'
import { PullRequestList } from './pull-request-list'
import { PullRequestsLoading } from './pull-requests-loading'
import { NoPullRequests } from './no-pull-requests'
import { PullRequest } from '../../models/pull-request'
import { CSSTransitionGroup } from 'react-transition-group'
const PullRequestsLoadingCrossFadeInTimeout = 300
const PullRequestsLoadingCrossFadeOutTimeout = 200
interface IBranchesProps {
readonly defaultBranch: Branch | null
readonly currentBranch: Branch | null
readonly allBranches: ReadonlyArray<Branch>
readonly recentBranches: ReadonlyArray<Branch>
interface IBranchesContainerProps {
readonly dispatcher: Dispatcher
readonly repository: Repository
readonly selectedTab: BranchesTab
readonly allBranches: ReadonlyArray<Branch>
readonly defaultBranch: Branch | null
readonly currentBranch: Branch | null
readonly recentBranches: ReadonlyArray<Branch>
readonly pullRequests: ReadonlyArray<PullRequest>
/** The pull request associated with the current branch. */
@ -34,58 +32,36 @@ interface IBranchesProps {
readonly isLoadingPullRequests: boolean
}
interface IBranchesState {
interface IBranchesContainerState {
readonly selectedBranch: Branch | null
readonly selectedPullRequest: PullRequest | null
readonly filterText: string
readonly branchFilterText: string
readonly pullRequestFilterText: string
}
/** The unified Branches and Pull Requests component. */
export class BranchesContainer extends React.Component<
IBranchesProps,
IBranchesState
IBranchesContainerProps,
IBranchesContainerState
> {
public constructor(props: IBranchesProps) {
public constructor(props: IBranchesContainerProps) {
super(props)
this.state = {
selectedBranch: props.currentBranch,
selectedPullRequest: props.currentPullRequest,
filterText: '',
branchFilterText: '',
pullRequestFilterText: '',
}
}
private onItemClick = (branch: Branch) => {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
const currentBranch = this.props.currentBranch
if (currentBranch == null || currentBranch.name !== branch.name) {
this.props.dispatcher.checkoutBranch(this.props.repository, branch)
}
}
private onFilterKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
if (this.state.filterText.length === 0) {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
event.preventDefault()
}
}
}
private onFilterTextChanged = (filterText: string) => {
this.setState({ filterText })
}
private onBranchSelectionChanged = (selectedBranch: Branch | null) => {
this.setState({ selectedBranch })
}
private onPullRequestSelectionChanged = (
selectedPullRequest: PullRequest | null
) => {
this.setState({ selectedPullRequest })
public render() {
return (
<div className="branches-container">
{this.renderTabBar()}
{this.renderSelectedTab()}
</div>
)
}
private renderTabBar() {
@ -93,10 +69,6 @@ export class BranchesContainer extends React.Component<
return null
}
if (!enablePRIntegration()) {
return null
}
let countElement = null
if (this.props.pullRequests) {
countElement = (
@ -121,7 +93,7 @@ export class BranchesContainer extends React.Component<
private renderSelectedTab() {
let tab = this.props.selectedTab
if (!enablePRIntegration() || !this.props.repository.gitHubRepository) {
if (!this.props.repository.gitHubRepository) {
tab = BranchesTab.Branches
}
@ -133,10 +105,9 @@ export class BranchesContainer extends React.Component<
currentBranch={this.props.currentBranch}
allBranches={this.props.allBranches}
recentBranches={this.props.recentBranches}
onItemClick={this.onItemClick}
filterText={this.state.filterText}
onFilterKeyDown={this.onFilterKeyDown}
onFilterTextChanged={this.onFilterTextChanged}
onItemClick={this.onBranchItemClick}
filterText={this.state.branchFilterText}
onFilterTextChanged={this.onBranchFilterTextChanged}
selectedBranch={this.state.selectedBranch}
onSelectionChanged={this.onBranchSelectionChanged}
canCreateNewBranch={true}
@ -163,48 +134,62 @@ export class BranchesContainer extends React.Component<
}
private renderPullRequests() {
const pullRequests = this.props.pullRequests
if (pullRequests.length) {
return (
<PullRequestList
key="pr-list"
pullRequests={pullRequests}
onSelectionChanged={this.onPullRequestSelectionChanged}
selectedPullRequest={this.state.selectedPullRequest}
onItemClick={this.onPullRequestClicked}
onDismiss={this.onDismiss}
/>
)
} else if (this.props.isLoadingPullRequests) {
if (this.props.isLoadingPullRequests) {
return <PullRequestsLoading key="prs-loading" />
} else {
const repo = this.props.repository
const name = repo.gitHubRepository
? repo.gitHubRepository.fullName
: repo.name
const isOnDefaultBranch =
this.props.defaultBranch &&
this.props.currentBranch &&
this.props.defaultBranch.name === this.props.currentBranch.name
return (
<NoPullRequests
key="no-prs"
repositoryName={name}
isOnDefaultBranch={!!isOnDefaultBranch}
onCreateBranch={this.onCreateBranch}
onCreatePullRequest={this.onCreatePullRequest}
/>
)
}
const pullRequests = this.props.pullRequests
const repo = this.props.repository
const name = repo.gitHubRepository
? repo.gitHubRepository.fullName
: repo.name
const isOnDefaultBranch =
this.props.defaultBranch &&
this.props.currentBranch &&
this.props.defaultBranch.name === this.props.currentBranch.name
return (
<PullRequestList
key="pr-list"
pullRequests={pullRequests}
selectedPullRequest={this.state.selectedPullRequest}
repositoryName={name}
isOnDefaultBranch={!!isOnDefaultBranch}
onSelectionChanged={this.onPullRequestSelectionChanged}
onCreateBranch={this.onCreateBranch}
onCreatePullRequest={this.onCreatePullRequest}
filterText={this.state.pullRequestFilterText}
onFilterTextChanged={this.onPullRequestFilterTextChanged}
onItemClick={this.onPullRequestClicked}
onDismiss={this.onDismiss}
/>
)
}
private onTabClicked = (tab: BranchesTab) => {
this.props.dispatcher.changeBranchesTab(tab)
}
private onDismiss = () => {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
}
private onBranchItemClick = (branch: Branch) => {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
const currentBranch = this.props.currentBranch
if (currentBranch == null || currentBranch.name !== branch.name) {
this.props.dispatcher.checkoutBranch(this.props.repository, branch)
}
}
public render() {
return (
<div className="branches-container">
{this.renderTabBar()}
{this.renderSelectedTab()}
</div>
)
private onBranchSelectionChanged = (selectedBranch: Branch | null) => {
this.setState({ selectedBranch })
}
private onBranchFilterTextChanged = (text: string) => {
this.setState({ branchFilterText: text })
}
private onCreateBranchWithName = (name: string) => {
@ -220,15 +205,21 @@ export class BranchesContainer extends React.Component<
this.onCreateBranchWithName('')
}
private onPullRequestFilterTextChanged = (text: string) => {
this.setState({ pullRequestFilterText: text })
}
private onPullRequestSelectionChanged = (
selectedPullRequest: PullRequest | null
) => {
this.setState({ selectedPullRequest })
}
private onCreatePullRequest = () => {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
this.props.dispatcher.createPullRequest(this.props.repository)
}
private onTabClicked = (tab: BranchesTab) => {
this.props.dispatcher.changeBranchesTab(tab)
}
private onPullRequestClicked = (pullRequest: PullRequest) => {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
this.props.dispatcher.checkoutPullRequest(
@ -238,8 +229,4 @@ export class BranchesContainer extends React.Component<
this.onPullRequestSelectionChanged(pullRequest)
}
private onDismiss = () => {
this.props.dispatcher.closeFoldout(FoldoutType.Branch)
}
}

View file

@ -15,6 +15,9 @@ interface INoPullRequestsProps {
/** Is the default branch currently checked out? */
readonly isOnDefaultBranch: boolean
/** Is this component being rendered due to a search? */
readonly isSearch: boolean
/* Called when the user wants to create a new branch. */
readonly onCreateBranch: () => void
@ -28,18 +31,27 @@ export class NoPullRequests extends React.Component<INoPullRequestsProps, {}> {
return (
<div className="no-pull-requests">
<img src={BlankSlateImage} className="blankslate-image" />
<div className="title">You're all set!</div>
<div className="no-prs">
No open pull requests in <Ref>{this.props.repositoryName}</Ref>
</div>
{this.renderTitle()}
{this.renderCallToAction()}
</div>
)
}
private renderTitle() {
if (this.props.isSearch) {
return <div className="title">Sorry, I can't find that pull request!</div>
} else {
return (
<div>
<div className="title">You're all set!</div>
<div className="no-prs">
No open pull requests in <Ref>{this.props.repositoryName}</Ref>
</div>
</div>
)
}
}
private renderCallToAction() {
if (this.props.isOnDefaultBranch) {
return (

View file

@ -7,6 +7,7 @@ import {
} from '../lib/filter-list'
import { PullRequestListItem } from './pull-request-list-item'
import { PullRequest, PullRequestStatus } from '../../models/pull-request'
import { NoPullRequests } from './no-pull-requests'
interface IPullRequestListItem extends IFilterListItem {
readonly id: string
@ -28,23 +29,50 @@ interface IPullRequestListProps {
/** The pull requests to display. */
readonly pullRequests: ReadonlyArray<PullRequest>
/** The currently selected pull request */
readonly selectedPullRequest: PullRequest | null
/** The name of the repository. */
readonly repositoryName: string
/** Is the default branch currently checked out? */
readonly isOnDefaultBranch: boolean
/** The current filter text to render */
readonly filterText: string
/** Called when the user clicks on a pull request. */
readonly onItemClick: (pullRequest: PullRequest) => void
/** Called when the user wants to dismiss the foldout. */
readonly onDismiss: () => void
readonly selectedPullRequest: PullRequest | null
/** Callback to fire when the filter text is changed */
readonly onFilterTextChanged: (filterText: string) => void
/** Called when the user opts to create a branch */
readonly onCreateBranch: () => void
/** Called when the user opts to create a pull request */
readonly onCreatePullRequest: () => void
/** Callback fired when user selects a new pull request */
readonly onSelectionChanged?: (
pullRequest: PullRequest | null,
source: SelectionSource
) => void
/**
* Called when a key down happens in the filter field. Users have a chance to
* respond or cancel the default behavior by calling `preventDefault`.
*/
readonly onFilterKeyDown?: (
event: React.KeyboardEvent<HTMLInputElement>
) => void
}
interface IPullRequestListState {
readonly groupedItems: ReadonlyArray<IFilterListGroup<IPullRequestListItem>>
readonly filterText: string
readonly selectedItem: IPullRequestListItem | null
}
@ -82,7 +110,6 @@ export class PullRequestList extends React.Component<
this.state = {
groupedItems: [group],
filterText: '',
selectedItem,
}
}
@ -105,12 +132,25 @@ export class PullRequestList extends React.Component<
groups={this.state.groupedItems}
selectedItem={this.state.selectedItem}
renderItem={this.renderPullRequest}
filterText={this.state.filterText}
onFilterTextChanged={this.onFilterTextChanged}
filterText={this.props.filterText}
onFilterTextChanged={this.props.onFilterTextChanged}
invalidationProps={this.props.pullRequests}
onItemClick={this.onItemClick}
onSelectionChanged={this.onSelectionChanged}
onFilterKeyDown={this.onFilterKeyDown}
onFilterKeyDown={this.props.onFilterKeyDown}
renderNoItems={this.renderNoItems}
/>
)
}
private renderNoItems = () => {
return (
<NoPullRequests
isSearch={this.props.filterText.length > 0}
repositoryName={this.props.repositoryName}
isOnDefaultBranch={this.props.isOnDefaultBranch}
onCreateBranch={this.props.onCreateBranch}
onCreatePullRequest={this.props.onCreatePullRequest}
/>
)
}
@ -144,10 +184,6 @@ export class PullRequestList extends React.Component<
)
}
private onFilterTextChanged = (filterText: string) => {
this.setState({ filterText })
}
private onItemClick = (item: IPullRequestListItem) => {
if (this.props.onItemClick) {
this.props.onItemClick(item.pullRequest)
@ -165,15 +201,6 @@ export class PullRequestList extends React.Component<
)
}
}
private onFilterKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
if (this.state.filterText.length === 0) {
this.props.onDismiss()
event.preventDefault()
}
}
}
}
function createListItems(

View file

@ -1,37 +1,24 @@
import * as React from 'react'
import * as Path from 'path'
import { AppFileStatus, mapStatus, iconForStatus } from '../../models/status'
import { PathLabel } from '../lib/path-label'
import { Octicon } from '../octicons'
import { showContextualMenu, IMenuItem } from '../main-process-proxy'
import { Checkbox, CheckboxValue } from '../lib/checkbox'
const GitIgnoreFileName = '.gitignore'
const RestrictedFileExtensions = ['.cmd', '.exe', '.bat', '.sh']
interface IChangedFileProps {
readonly path: string
readonly status: AppFileStatus
readonly oldPath?: string
readonly include: boolean | null
readonly onIncludeChanged: (path: string, include: boolean) => void
readonly onDiscardChanges: (path: string) => void
/**
* Called to reveal a file in the native file manager.
* @param path The path of the file relative to the root of the repository
*/
readonly onRevealInFileManager: (path: string) => void
/**
* Called to open a file it its default application
* @param path The path of the file relative to the root of the repository
*/
readonly onOpenItem: (path: string) => void
readonly availableWidth: number
readonly onIgnore: (pattern: string) => void
readonly onIncludeChanged: (path: string, include: boolean) => void
/** Callback called when user right-clicks on an item */
readonly onContextMenu: (
path: string,
status: AppFileStatus,
event: React.MouseEvent<any>
) => void
}
/** a changed file in the working directory for a given repository */
@ -94,58 +81,7 @@ export class ChangedFile extends React.Component<IChangedFileProps, {}> {
)
}
private onContextMenu = (event: React.MouseEvent<any>) => {
event.preventDefault()
const extension = Path.extname(this.props.path)
const fileName = Path.basename(this.props.path)
const items: IMenuItem[] = [
{
label: __DARWIN__ ? 'Discard Changes…' : 'Discard changes…',
action: () => this.props.onDiscardChanges(this.props.path),
},
{ type: 'separator' },
{
label: 'Ignore',
action: () => this.props.onIgnore(this.props.path),
enabled: fileName !== GitIgnoreFileName,
},
]
if (extension.length) {
items.push({
label: __DARWIN__
? `Ignore All ${extension} Files`
: `Ignore all ${extension} files`,
action: () => this.props.onIgnore(`*${extension}`),
enabled: fileName !== GitIgnoreFileName,
})
}
const isSafeExtension = __WIN32__
? RestrictedFileExtensions.indexOf(extension.toLowerCase()) === -1
: true
const revealInFileManagerLabel = __DARWIN__
? 'Reveal in Finder'
: __WIN32__ ? 'Show in Explorer' : 'Show in your File Manager'
items.push(
{ type: 'separator' },
{
label: revealInFileManagerLabel,
action: () => this.props.onRevealInFileManager(this.props.path),
enabled: this.props.status !== AppFileStatus.Deleted,
},
{
label: __DARWIN__
? 'Open with Default Program'
: 'Open with default program',
action: () => this.props.onOpenItem(this.props.path),
enabled: isSafeExtension && this.props.status !== AppFileStatus.Deleted,
}
)
showContextualMenu(items)
private onContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
this.props.onContextMenu(this.props.path, this.props.status, event)
}
}

View file

@ -1,11 +1,13 @@
import * as React from 'react'
import * as Path from 'path'
import { CommitMessage } from './commit-message'
import { ChangedFile } from './changed-file'
import { List, ClickSource } from '../lib/list'
import {
WorkingDirectoryStatus,
WorkingDirectoryFileChange,
AppFileStatus,
} from '../../models/status'
import { DiffSelectionType } from '../../models/diff'
import { CommitIdentity } from '../../models/commit-identity'
@ -15,11 +17,17 @@ import { IGitHubUser } from '../../lib/databases'
import { Dispatcher } from '../../lib/dispatcher'
import { IAutocompletionProvider } from '../autocompletion'
import { Repository } from '../../models/repository'
import { showContextualMenu, IMenuItem } from '../main-process-proxy'
import { showContextualMenu } from '../main-process-proxy'
import { IAuthor } from '../../models/author'
import { ITrailer } from '../../lib/git/interpret-trailers'
import { IMenuItem } from '../../lib/menu-item'
const RowHeight = 29
const RestrictedFileExtensions = ['.cmd', '.exe', '.bat', '.sh']
const defaultEditorLabel = __DARWIN__
? 'Open in External Editor'
: 'Open in external editor'
const GitIgnoreFileName = '.gitignore'
interface IChangesListProps {
readonly repository: Repository
@ -85,6 +93,15 @@ interface IChangesListProps {
* the user has chosen to do so.
*/
readonly coAuthors: ReadonlyArray<IAuthor>
/** The name of the currently selected external editor */
readonly externalEditorLabel?: string
/**
* Called to open a file using the user's configured applications
* @param path The path of the file relative to the root of the repository
*/
readonly onOpenInExternalEditor: (path: string) => void
}
export class ChangesList extends React.Component<IChangesListProps, {}> {
@ -110,11 +127,8 @@ export class ChangesList extends React.Component<IChangesListProps, {}> {
include={includeAll}
key={file.id}
onIncludeChanged={this.props.onIncludeChanged}
onDiscardChanges={this.onDiscardChanges}
onRevealInFileManager={this.props.onRevealInFileManager}
onOpenItem={this.props.onOpenItem}
availableWidth={this.props.availableWidth}
onIgnore={this.props.onIgnore}
onContextMenu={this.onItemContextMenu}
/>
)
}
@ -158,6 +172,75 @@ export class ChangesList extends React.Component<IChangesListProps, {}> {
showContextualMenu(items)
}
private onItemContextMenu = (
path: string,
status: AppFileStatus,
event: React.MouseEvent<any>
) => {
event.preventDefault()
const extension = Path.extname(path)
const fileName = Path.basename(path)
const isSafeExtension = __WIN32__
? RestrictedFileExtensions.indexOf(extension.toLowerCase()) === -1
: true
const revealInFileManagerLabel = __DARWIN__
? 'Reveal in Finder'
: __WIN32__ ? 'Show in Explorer' : 'Show in your File Manager'
const openInExternalEditor = this.props.externalEditorLabel
? `Open in ${this.props.externalEditorLabel}`
: defaultEditorLabel
const items: IMenuItem[] = [
{
label: __DARWIN__ ? 'Discard Changes…' : 'Discard changes…',
action: () => this.onDiscardChanges(path),
},
{
label: __DARWIN__ ? 'Discard All Changes…' : 'Discard all changes…',
action: () => this.onDiscardAllChanges(),
},
{ type: 'separator' },
{
label: 'Ignore',
action: () => this.props.onIgnore(path),
enabled: fileName !== GitIgnoreFileName,
},
]
if (extension.length) {
items.push({
label: __DARWIN__
? `Ignore All ${extension} Files`
: `Ignore all ${extension} files`,
action: () => this.props.onIgnore(`*${extension}`),
enabled: fileName !== GitIgnoreFileName,
})
}
items.push(
{ type: 'separator' },
{
label: revealInFileManagerLabel,
action: () => this.props.onRevealInFileManager(path),
enabled: status !== AppFileStatus.Deleted,
},
{
label: openInExternalEditor,
action: () => this.props.onOpenInExternalEditor(path),
enabled: isSafeExtension && status !== AppFileStatus.Deleted,
},
{
label: __DARWIN__
? 'Open with Default Program'
: 'Open with default program',
action: () => this.props.onOpenItem(path),
enabled: isSafeExtension && status !== AppFileStatus.Deleted,
}
)
showContextualMenu(items)
}
public render() {
const fileList = this.props.workingDirectory.files
const selectedRow = fileList.findIndex(

View file

@ -18,10 +18,11 @@ 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 { showContextualMenu } from '../main-process-proxy'
import { Octicon, OcticonSymbol } from '../octicons'
import { ITrailer } from '../../lib/git/interpret-trailers'
import { IAuthor } from '../../models/author'
import { IMenuItem } from '../../lib/menu-item'
const addAuthorIcon = new OcticonSymbol(
12,
@ -328,6 +329,7 @@ export class CommitMessage extends React.Component<
onAuthorsUpdated={this.onCoAuthorsUpdated}
authors={this.props.coAuthors}
autoCompleteProvider={autocompletionProvider}
disabled={this.props.isCommitting}
/>
)
}
@ -345,22 +347,41 @@ export class CommitMessage extends React.Component<
: __DARWIN__ ? 'Add Co-Authors' : 'Add co-authors'
}
private getAddRemoveCoAuthorsMenuItem(): IMenuItem {
return {
label: this.toggleCoAuthorsText,
action: this.onToggleCoAuthors,
enabled:
this.props.repository.gitHubRepository !== null &&
!this.props.isCommitting,
}
}
private onContextMenu = (event: React.MouseEvent<any>) => {
if (event.defaultPrevented) {
return
}
event.preventDefault()
const items: IMenuItem[] = [this.getAddRemoveCoAuthorsMenuItem()]
showContextualMenu(items)
}
private onAutocompletingInputContextMenu = (event: React.MouseEvent<any>) => {
event.preventDefault()
const items: IMenuItem[] = [
{
label: this.toggleCoAuthorsText,
action: this.onToggleCoAuthors,
enabled: this.props.repository.gitHubRepository !== null,
},
this.getAddRemoveCoAuthorsMenuItem(),
{ type: 'separator' },
{ role: 'editMenu' },
]
showContextualMenu(items)
}
private onCoAuthorToggleButtonClick = (
e: React.MouseEvent<HTMLDivElement>
e: React.MouseEvent<HTMLButtonElement>
) => {
e.preventDefault()
this.onToggleCoAuthors()
@ -372,15 +393,15 @@ export class CommitMessage extends React.Component<
}
return (
<div
role="button"
<button
className="co-authors-toggle"
onClick={this.onCoAuthorToggleButtonClick}
tabIndex={-1}
aria-label={this.toggleCoAuthorsText}
disabled={this.props.isCommitting}
>
<Octicon symbol={addAuthorIcon} />
</div>
</button>
)
}
@ -436,7 +457,11 @@ export class CommitMessage extends React.Component<
return null
}
return <div className="action-bar">{this.renderCoAuthorToggleButton()}</div>
const className = classNames('action-bar', {
disabled: this.props.isCommitting,
})
return <div className={className}>{this.renderCoAuthorToggleButton()}</div>
}
public render() {
@ -471,6 +496,8 @@ export class CommitMessage extends React.Component<
value={this.state.summary}
onValueChanged={this.onSummaryChanged}
autocompletionProviders={this.props.autocompletionProviders}
onContextMenu={this.onAutocompletingInputContextMenu}
disabled={this.props.isCommitting}
/>
</div>
@ -486,6 +513,8 @@ export class CommitMessage extends React.Component<
autocompletionProviders={this.props.autocompletionProviders}
ref={this.onDescriptionFieldRef}
onElementRef={this.onDescriptionTextAreaRef}
onContextMenu={this.onAutocompletingInputContextMenu}
disabled={this.props.isCommitting}
/>
{this.renderActionBar()}
</FocusContainer>

View file

@ -48,12 +48,20 @@ interface IChangesSidebarProps {
readonly gitHubUserStore: GitHubUserStore
readonly askForConfirmationOnDiscardChanges: boolean
readonly accounts: ReadonlyArray<Account>
/** The name of the currently selected external editor */
readonly externalEditorLabel?: string
/**
* Called to open a file using the user's configured applications
* @param path The path of the file relative to the root of the repository
*/
readonly onOpenInExternalEditor: (path: string) => void
}
export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
private autocompletionProviders: ReadonlyArray<
IAutocompletionProvider<any>
> | null
> | null = null
public constructor(props: IChangesSidebarProps) {
super(props)
@ -305,6 +313,8 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
isCommitting={this.props.isCommitting}
showCoAuthoredBy={this.props.changes.showCoAuthoredBy}
coAuthors={this.props.changes.coAuthors}
externalEditorLabel={this.props.externalEditorLabel}
onOpenInExternalEditor={this.props.onOpenInExternalEditor}
/>
{this.renderMostRecentLocalCommit()}
</div>

View file

@ -27,9 +27,6 @@ interface ICloneGithubRepositoryProps {
/** Called when the destination path changes. */
readonly onPathChanged: (path: string) => void
/** Called when the dialog should be dismissed. */
readonly onDismissed: () => void
/**
* Called when the user should be prompted to choose a destination directory.
*/
@ -164,7 +161,6 @@ export class CloneGithubRepository extends React.Component<
renderItem={this.renderItem}
renderGroupHeader={this.renderGroupHeader}
onItemClick={this.onItemClicked}
onFilterKeyDown={this.onFilterKeyDown}
invalidationProps={this.state.repositories}
groups={this.state.repositories}
filterText={this.state.filterText}
@ -177,15 +173,6 @@ export class CloneGithubRepository extends React.Component<
this.setState({ filterText })
}
private onFilterKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
if (this.state.filterText.length === 0) {
this.props.onDismissed()
event.preventDefault()
}
}
}
private onItemClicked = (item: IClonableRepositoryListItem) => {
this.setState({ selectedItem: item })
this.props.onGitHubRepositorySelected(item.url)

View file

@ -185,7 +185,6 @@ export class CloneRepository extends React.Component<
onPathChanged={this.updateAndValidatePath}
onGitHubRepositorySelected={this.updateUrl}
onChooseDirectory={this.onChooseDirectory}
onDismissed={this.props.onDismissed}
shouldClearFilter={this.state.shouldClearFilter}
/>
)
@ -358,7 +357,7 @@ export class CloneRepository extends React.Component<
}
try {
this.cloneImpl(url, path)
this.cloneImpl(url.trim(), path)
} catch (e) {
log.error(`CloneRepostiory: clone failed to complete to ${path}`, e)
this.setState({ loading: false, error: e })

View file

@ -253,7 +253,7 @@ export class CreateBranch extends React.Component<
label="Name"
value={this.state.proposedName}
autoFocus={true}
onChange={this.onBranchNameChange}
onValueChanged={this.onBranchNameChange}
/>
</Row>
@ -277,9 +277,8 @@ export class CreateBranch extends React.Component<
)
}
private onBranchNameChange = (event: React.FormEvent<HTMLInputElement>) => {
const str = event.currentTarget.value
this.updateBranchName(str)
private onBranchNameChange = (name: string) => {
this.updateBranchName(name)
}
private updateBranchName(name: string) {

View file

@ -52,8 +52,8 @@ interface ICodeMirrorHostProps {
* A component hosting a CodeMirror instance
*/
export class CodeMirrorHost extends React.Component<ICodeMirrorHostProps, {}> {
private wrapper: HTMLDivElement | null
private codeMirror: CodeMirror.Editor | null
private wrapper: HTMLDivElement | null = null
private codeMirror: CodeMirror.Editor | null = null
/**
* Gets the internal CodeMirror instance or null if CodeMirror hasn't

View file

@ -57,7 +57,7 @@ export class ModifiedImageDiff extends React.Component<
IModifiedImageDiffProps,
IModifiedImageDiffState
> {
private container: HTMLElement | null
private container: HTMLElement | null = null
private readonly resizeObserver: ResizeObserver
private resizedTimeoutID: number | null = null

View file

@ -30,6 +30,7 @@ import {
ITextDiff,
DiffLine,
DiffLineType,
ILargeTextDiff,
} from '../../models/diff'
import { Dispatcher } from '../../lib/dispatcher/dispatcher'
@ -56,6 +57,7 @@ import { readPartialFile } from '../../lib/file-system'
import { DiffSyntaxMode, IDiffSyntaxModeSpec } from './diff-syntax-mode'
import { highlight } from '../../lib/highlighter/worker'
import { ITokens } from '../../lib/highlighter/types'
import { Button } from '../lib/button'
/** The longest line for which we'd try to calculate a line diff. */
const MaxIntraLineDiffStringLength = 4096
@ -70,6 +72,10 @@ const narrowNoNewlineSymbol = new OcticonSymbol(
8,
'm 16,1 0,3 c 0,0.55 -0.45,1 -1,1 l -3,0 0,2 -3,-3 3,-3 0,2 2,0 0,-2 2,0 z M 8,4 C 8,6.2 6.2,8 4,8 1.8,8 0,6.2 0,4 0,1.8 1.8,0 4,0 6.2,0 8,1.8 8,4 Z M 1.5,5.66 5.66,1.5 C 5.18,1.19 4.61,1 4,1 2.34,1 1,2.34 1,4 1,4.61 1.19,5.17 1.5,5.66 Z M 7,4 C 7,3.39 6.81,2.83 6.5,2.34 L 2.34,6.5 C 2.82,6.81 3.39,7 4,7 5.66,7 7,5.66 7,4 Z'
)
// image used when no diff is displayed
const NoDiffImage = encodePathAsUrl(__dirname, 'static/ufo-alert.svg')
type ChangedFile = WorkingDirectoryFileChange | CommittedFileChange
interface ILineFilters {
@ -300,10 +306,14 @@ interface IDiffProps {
readonly imageDiffType: ImageDiffType
}
interface IDiffState {
readonly forceShowLargeDiff: boolean
}
/** A component which renders a diff for a file. */
export class Diff extends React.Component<IDiffProps, {}> {
private codeMirror: Editor | null
private gutterWidth: number | null
export class Diff extends React.Component<IDiffProps, IDiffState> {
private codeMirror: Editor | null = null
private gutterWidth: number | null = null
/**
* We store the scroll position before reloading the same diff so that we can
@ -329,6 +339,14 @@ export class Diff extends React.Component<IDiffProps, {}> {
*/
private cachedGutterElements = new Map<number, DiffLineGutter>()
public constructor(props: IDiffProps) {
super(props)
this.state = {
forceShowLargeDiff: false,
}
}
public componentWillReceiveProps(nextProps: IDiffProps) {
// If we're reloading the same file, we want to save the current scroll
// position and restore it after the diff's been updated.
@ -426,6 +444,10 @@ export class Diff extends React.Component<IDiffProps, {}> {
}
}
public render() {
return this.renderDiff(this.props.diff)
}
public async initDiffSyntaxMode() {
const cm = this.codeMirror
const file = this.props.file
@ -952,6 +974,64 @@ export class Diff extends React.Component<IDiffProps, {}> {
return null
}
private renderLargeTextDiff() {
return (
<div className="panel empty large-diff">
<img src={NoDiffImage} />
<p>
The diff is too large to be displayed by default.
<br />
You can try to show it anyways, but performance may be negatively
impacted.
</p>
<Button onClick={this.showLargeDiff}>
{__DARWIN__ ? 'Show Diff' : 'Show diff'}
</Button>
</div>
)
}
private renderUnrenderableDiff() {
return (
<div className="panel empty large-diff">
<img src={NoDiffImage} />
<p>The diff is too large to be displayed.</p>
</div>
)
}
private renderLargeText(diff: ILargeTextDiff) {
// guaranteed to be set since this function won't be called if text or hunks are null
const textDiff: ITextDiff = {
text: diff.text!,
hunks: diff.hunks!,
kind: DiffType.Text,
lineEndingsChange: diff.lineEndingsChange,
}
return this.renderTextDiff(textDiff)
}
private renderText(diff: ITextDiff) {
if (diff.hunks.length === 0) {
if (this.props.file.status === AppFileStatus.New) {
return <div className="panel empty">The file is empty</div>
}
if (this.props.file.status === AppFileStatus.Renamed) {
return (
<div className="panel renamed">
The file was renamed but not changed
</div>
)
}
return <div className="panel empty">No content changes found</div>
}
return this.renderTextDiff(diff)
}
private renderBinaryFile() {
return (
<BinaryFile
@ -1044,52 +1124,27 @@ export class Diff extends React.Component<IDiffProps, {}> {
this.codeMirror = cmh === null ? null : cmh.getEditor()
}
public render() {
const diff = this.props.diff
if (diff.kind === DiffType.Image) {
return this.renderImage(diff)
}
if (diff.kind === DiffType.Binary) {
return this.renderBinaryFile()
}
if (diff.kind === DiffType.TooLarge) {
const BlankSlateImage = encodePathAsUrl(
__dirname,
'static/empty-no-file-selected.svg'
)
const diffSizeMB = Math.round(diff.length / (1024 * 1024))
return (
<div className="panel empty">
<img src={BlankSlateImage} className="blankslate-image" />
The diff returned by Git is {diffSizeMB}MB ({diff.length} bytes),
which is larger than what can be displayed in GitHub Desktop.
</div>
)
}
if (diff.kind === DiffType.Text) {
if (diff.hunks.length === 0) {
if (this.props.file.status === AppFileStatus.New) {
return <div className="panel empty">The file is empty</div>
}
if (this.props.file.status === AppFileStatus.Renamed) {
return (
<div className="panel renamed">
The file was renamed but not changed
</div>
)
}
return <div className="panel empty">No content changes found</div>
private renderDiff(diff: IDiff): JSX.Element | null {
switch (diff.kind) {
case DiffType.Text:
return this.renderText(diff)
case DiffType.Binary:
return this.renderBinaryFile()
case DiffType.Image:
return this.renderImage(diff)
case DiffType.LargeText: {
return this.state.forceShowLargeDiff
? this.renderLargeText(diff)
: this.renderLargeTextDiff()
}
return this.renderTextDiff(diff)
case DiffType.Unrenderable:
return this.renderUnrenderableDiff()
default:
return assertNever(diff, `Unsupported diff type: ${diff}`)
}
}
return null
private showLargeDiff = () => {
this.setState({ forceShowLargeDiff: true })
}
}

View file

@ -6,10 +6,11 @@ import { RichText } from '../lib/rich-text'
import { RelativeTime } from '../relative-time'
import { getDotComAPIEndpoint } from '../../lib/api'
import { clipboard } from 'electron'
import { showContextualMenu, IMenuItem } from '../main-process-proxy'
import { showContextualMenu } from '../main-process-proxy'
import { CommitAttribution } from '../lib/commit-attribution'
import { IGitHubUser } from '../../lib/databases/github-user-database'
import { AvatarStack } from '../lib/avatar-stack'
import { IMenuItem } from '../../lib/menu-item'
interface ICommitProps {
readonly gitHubRepository: GitHubRepository | null

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import { Repository } from '../../models/repository'
import { GitHubRepository } from '../../models/github-repository'
import { Commit } from '../../models/commit'
import { CommitListItem } from './commit-list-item'
import { List } from '../lib/list'
@ -8,26 +8,52 @@ import { IGitHubUser } from '../../lib/databases'
const RowHeight = 50
interface ICommitListProps {
readonly onCommitChanged: (commit: Commit) => void
readonly onScroll: (start: number, end: number) => void
readonly onRevertCommit: (commit: Commit) => void
readonly onViewCommitOnGitHub: (sha: string) => void
readonly repository: Repository
readonly history: ReadonlyArray<string>
readonly commits: Map<string, Commit>
/** The GitHub repository associated with this commit (if found) */
readonly gitHubRepository: GitHubRepository | null
/** The list of commits SHAs to display, in order. */
readonly commits: ReadonlyArray<string>
/** The commits loaded, keyed by their full SHA. */
readonly commitLookup: Map<string, Commit>
/** The SHA of the selected commit */
readonly selectedSHA: string | null
/** The lookup for GitHub users related to this repository */
readonly gitHubUsers: Map<string, IGitHubUser>
/** The emoji lookup to render images inline */
readonly emoji: Map<string, string>
/** The list of known local commits for the current branch */
readonly localCommitSHAs: ReadonlyArray<string>
/** Callback which fires when a commit has been selected in the list */
readonly onCommitSelected: (commit: Commit) => void
/** Callback that fires when a scroll event has occurred */
readonly onScroll: (start: number, end: number) => void
/** Callback to fire to revert a given commit in the current repository */
readonly onRevertCommit: (commit: Commit) => void
/** Callback to fire to open a given commit on GitHub */
readonly onViewCommitOnGitHub: (sha: string) => void
}
/** A component which displays the list of commits. */
export class CommitList extends React.Component<ICommitListProps, {}> {
private renderCommit = (row: number) => {
const sha = this.props.history[row]
const commit = this.props.commits.get(sha)
const sha = this.props.commits[row]
const commit = this.props.commitLookup.get(sha)
if (!commit) {
if (commit == null) {
if (__DEV__) {
log.warn(
`[CommitList]: the commit '${sha}' does not exist in the cache`
)
}
return null
}
@ -36,7 +62,7 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
return (
<CommitListItem
key={commit.sha}
gitHubRepository={this.props.repository.gitHubRepository}
gitHubRepository={this.props.gitHubRepository}
isLocal={isLocal}
commit={commit}
gitHubUsers={this.props.gitHubUsers}
@ -48,10 +74,10 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
}
private onRowChanged = (row: number) => {
const sha = this.props.history[row]
const commit = this.props.commits.get(sha)
const sha = this.props.commits[row]
const commit = this.props.commitLookup.get(sha)
if (commit) {
this.props.onCommitChanged(commit)
this.props.onCommitSelected(commit)
}
}
@ -68,25 +94,25 @@ export class CommitList extends React.Component<ICommitListProps, {}> {
return -1
}
return this.props.history.findIndex(s => s === sha)
return this.props.commits.findIndex(s => s === sha)
}
public render() {
if (this.props.history.length === 0) {
if (this.props.commits.length === 0) {
return <div className="panel blankslate">No history</div>
}
return (
<div id="commit-list">
<List
rowCount={this.props.history.length}
rowCount={this.props.commits.length}
rowHeight={RowHeight}
selectedRow={this.rowForSHA(this.props.selectedSHA)}
rowRenderer={this.renderCommit}
onSelectionChanged={this.onRowChanged}
onScroll={this.onScroll}
invalidationProps={{
history: this.props.history,
commits: this.props.commits,
gitHubUsers: this.props.gitHubUsers,
}}
/>

View file

@ -117,7 +117,7 @@ export class CommitSummary extends React.Component<
ICommitSummaryProps,
ICommitSummaryState
> {
private descriptionScrollViewRef: HTMLDivElement | null
private descriptionScrollViewRef: HTMLDivElement | null = null
private readonly resizeObserver: ResizeObserver | null = null
private updateOverflowTimeoutId: number | null = null

View file

@ -16,7 +16,7 @@ interface IHistorySidebarProps {
readonly history: IHistoryState
readonly gitHubUsers: Map<string, IGitHubUser>
readonly emoji: Map<string, string>
readonly commits: Map<string, Commit>
readonly commitLookup: Map<string, Commit>
readonly localCommitSHAs: ReadonlyArray<string>
readonly onRevertCommit: (commit: Commit) => void
readonly onViewCommitOnGitHub: (sha: string) => void
@ -26,7 +26,7 @@ interface IHistorySidebarProps {
export class HistorySidebar extends React.Component<IHistorySidebarProps, {}> {
private readonly loadChangedFilesScheduler = new ThrottledScheduler(200)
private onCommitChanged = (commit: Commit) => {
private onCommitSelected = (commit: Commit) => {
this.props.dispatcher.changeHistoryCommitSelection(
this.props.repository,
commit.sha
@ -53,11 +53,11 @@ export class HistorySidebar extends React.Component<IHistorySidebarProps, {}> {
public render() {
return (
<CommitList
repository={this.props.repository}
commits={this.props.commits}
history={this.props.history.history}
gitHubRepository={this.props.repository.gitHubRepository}
commitLookup={this.props.commitLookup}
commits={this.props.history.history}
selectedSHA={this.props.history.selection.sha}
onCommitChanged={this.onCommitChanged}
onCommitSelected={this.onCommitSelected}
onScroll={this.onScroll}
gitHubUsers={this.props.gitHubUsers}
emoji={this.props.emoji}

View file

@ -149,13 +149,15 @@ document.body.classList.add(`platform-${process.platform}`)
dispatcher.setAppFocusState(remote.getCurrentWindow().isFocused())
ipcRenderer.on('focus', () => {
const state = appStore.getState().selectedState
if (!state || state.type !== SelectionType.Repository) {
return
const { selectedState } = appStore.getState()
// Refresh the currently selected repository on focus (if
// we have a selected repository).
if (selectedState && selectedState.type === SelectionType.Repository) {
dispatcher.refreshRepository(selectedState.repository)
}
dispatcher.setAppFocusState(true)
dispatcher.refreshRepository(state.repository)
})
ipcRenderer.on('blur', () => {

View file

@ -3,10 +3,4 @@ export function installDevGlobals() {
const g: any = global
// Expose GitPerf as a global so it can be started.
g.GitPerf = require('./lib/git-perf')
// Expose Perf on the window so that the React Perf dev tool extension can
// find it.
const w: any = window
const Perf = require('react-addons-perf')
w.Perf = Perf
}

View file

@ -83,14 +83,14 @@ export class AuthenticationForm extends React.Component<
label="Username or email address"
disabled={disabled}
autoFocus={true}
onChange={this.onUsernameChange}
onValueChanged={this.onUsernameChange}
/>
<TextBox
label="Password"
type="password"
disabled={disabled}
onChange={this.onPasswordChange}
onValueChanged={this.onPasswordChange}
/>
{this.renderError()}
@ -121,7 +121,7 @@ export class AuthenticationForm extends React.Component<
className="forgot-password-link"
uri={this.props.forgotPasswordUrl}
>
Forgot password
Forgot password?
</LinkButton>
) : null}
</div>
@ -173,12 +173,12 @@ export class AuthenticationForm extends React.Component<
return <Errors>{error.message}</Errors>
}
private onUsernameChange = (event: React.FormEvent<HTMLInputElement>) => {
this.setState({ username: event.currentTarget.value })
private onUsernameChange = (username: string) => {
this.setState({ username })
}
private onPasswordChange = (event: React.FormEvent<HTMLInputElement>) => {
this.setState({ password: event.currentTarget.value })
private onPasswordChange = (password: string) => {
this.setState({ password })
}
private signInWithBrowser = (event?: React.MouseEvent<HTMLButtonElement>) => {

View file

@ -9,6 +9,8 @@ import { compare } from '../../lib/compare'
import { arrayEquals } from '../../lib/equality'
import { OcticonSymbol } from '../octicons'
import { IAuthor } from '../../models/author'
import { showContextualMenu } from '../main-process-proxy'
import { IMenuItem } from '../../lib/menu-item'
interface IAuthorInputProps {
/**
@ -36,6 +38,12 @@ interface IAuthorInputProps {
* input field.
*/
readonly onAuthorsUpdated: (authors: ReadonlyArray<IAuthor>) => void
/**
* Whether or not the input should be read-only and styled as being
* disabled. When disabled the component will not accept focus.
*/
readonly disabled: boolean
}
/**
@ -488,6 +496,12 @@ export class AuthorInput extends React.Component<IAuthorInputProps, {}> {
}
public componentWillReceiveProps(nextProps: IAuthorInputProps) {
const cm = this.editor
if (!cm) {
return
}
// 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.
@ -495,12 +509,13 @@ export class AuthorInput extends React.Component<IAuthorInputProps, {}> {
nextProps.authors !== this.props.authors &&
!arrayEquals(this.authors, nextProps.authors)
) {
const cm = this.editor
if (cm) {
cm.operation(() => {
this.reset(cm, nextProps.authors)
})
}
cm.operation(() => {
this.reset(cm, nextProps.authors)
})
}
if (nextProps.disabled !== this.props.disabled) {
cm.setOption('readOnly', nextProps.disabled ? 'nocursor' : false)
}
}
@ -702,6 +717,7 @@ export class AuthorInput extends React.Component<IAuthorInputProps, {}> {
'Ctrl-Enter': false,
'Cmd-Enter': false,
},
readOnly: this.props.disabled ? 'nocursor' : false,
hintOptions: {
completeOnSingleClick: true,
completeSingle: false,
@ -743,18 +759,50 @@ export class AuthorInput extends React.Component<IAuthorInputProps, {}> {
this.updateAuthors(cm)
})
const wrapperElem = cm.getWrapperElement()
// 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) => {
wrapperElem.addEventListener('keypress', (e: KeyboardEvent) => {
if (!e.defaultPrevented && e.key === 'Enter') {
e.preventDefault()
}
})
wrapperElem.addEventListener('contextmenu', e => {
this.onContextMenu(cm, e)
})
return cm
}
private onContextMenu(cm: Editor, e: PointerEvent) {
e.preventDefault()
const menu: IMenuItem[] = [
{ label: 'Undo', action: () => cm.getDoc().undo() },
{ label: 'Redo', action: () => cm.getDoc().redo() },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
]
if (__WIN32__) {
menu.push({ type: 'separator' })
}
menu.push({
label: __DARWIN__ ? 'Select All' : 'Select all',
action: () => {
cm.execCommand('selectAll')
},
})
showContextualMenu(menu)
}
private updateAuthors(cm: Editor) {
const markers = this.getAllHandleMarks(cm).sort(orderByPosition)
const authors = new Array<IAuthor>()
@ -811,7 +859,13 @@ export class AuthorInput extends React.Component<IAuthorInputProps, {}> {
}
public render() {
const className = classNames('author-input-component', this.props.className)
const className = classNames(
'author-input-component',
this.props.className,
{
disabled: this.props.disabled,
}
)
return <div className={className} ref={this.onContainerRef} />
}
}

View file

@ -116,18 +116,6 @@ export class Button extends React.Component<IButtonProps, {}> {
this.props.className
)
let ariaExpanded: string | undefined = undefined
if (this.props.ariaExpanded !== undefined) {
ariaExpanded = this.props.ariaExpanded ? 'true' : 'false'
}
let ariaHasPopup: string | undefined = undefined
if (this.props.ariaHasPopup !== undefined) {
ariaHasPopup = this.props.ariaHasPopup ? 'true' : 'false'
}
return (
<button
className={className}
@ -138,8 +126,8 @@ export class Button extends React.Component<IButtonProps, {}> {
tabIndex={this.props.tabIndex}
onMouseEnter={this.props.onMouseEnter}
role={this.props.role}
aria-expanded={ariaExpanded}
aria-haspopup={ariaHasPopup}
aria-expanded={this.props.ariaExpanded}
aria-haspopup={this.props.ariaHasPopup}
>
{this.props.children}
</button>

View file

@ -36,7 +36,7 @@ interface ICheckboxState {
/** A checkbox component which supports the mixed value. */
export class Checkbox extends React.Component<ICheckboxProps, ICheckboxState> {
private input: HTMLInputElement | null
private input: HTMLInputElement | null = null
private onChange = (event: React.FormEvent<HTMLInputElement>) => {
if (this.props.onChange) {

View file

@ -98,16 +98,16 @@ export class ConfigureGitUser extends React.Component<
<Form className="sign-in-form" onSubmit={this.save}>
<TextBox
label="Name"
placeholder="Hubot"
placeholder="Your Name"
value={this.state.name}
onChange={this.onNameChange}
onValueChanged={this.onNameChange}
/>
<TextBox
label="Email"
placeholder="hubot@github.com"
placeholder="your-email@example.com"
value={this.state.email}
onChange={this.onEmailChange}
onValueChanged={this.onEmailChange}
/>
<Row>
@ -131,16 +131,13 @@ export class ConfigureGitUser extends React.Component<
)
}
private onNameChange = (event: React.FormEvent<HTMLInputElement>) => {
private onNameChange = (name: string) => {
this.setState({
name: event.currentTarget.value,
email: this.state.email,
avatarURL: this.state.avatarURL,
name,
})
}
private onEmailChange = (event: React.FormEvent<HTMLInputElement>) => {
const email = event.currentTarget.value
private onEmailChange = (email: string) => {
const avatarURL = this.avatarURLForEmail(email)
this.setState({

View file

@ -121,7 +121,6 @@ interface IFilterListProps<T extends IFilterListItem> {
interface IFilterListState<T extends IFilterListItem> {
readonly rows: ReadonlyArray<IFilterListRow<T>>
readonly selectedRow: number
}
@ -144,7 +143,7 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
IFilterListState<T>
> {
private list: List | null = null
private filterInput: HTMLInputElement | null = null
private filterTextBox: TextBox | null = null
public constructor(props: IFilterListProps<T>) {
super(props)
@ -152,105 +151,10 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
this.state = createStateUpdate(props)
}
public render() {
return (
<div className={classnames('filter-list', this.props.className)}>
{this.props.renderPreList ? this.props.renderPreList() : null}
<Row className="filter-field-row">
<TextBox
type="search"
autoFocus={true}
placeholder="Filter"
className="filter-list-filter-field"
onChange={this.onFilterChanged}
onKeyDown={this.onKeyDown}
onInputRef={this.onInputRef}
value={this.props.filterText}
disabled={this.props.disabled}
/>
{this.props.renderPostFilter ? this.props.renderPostFilter() : null}
</Row>
<div className="filter-list-container">{this.renderContent()}</div>
</div>
)
}
private renderContent() {
if (this.state.rows.length === 0 && this.props.renderNoItems) {
return this.props.renderNoItems()
} else {
return (
<List
rowCount={this.state.rows.length}
rowRenderer={this.renderRow}
rowHeight={this.props.rowHeight}
selectedRow={this.state.selectedRow}
onSelectionChanged={this.onSelectionChanged}
onRowClick={this.onRowClick}
onRowKeyDown={this.onRowKeyDown}
canSelectRow={this.canSelectRow}
ref={this.onListRef}
invalidationProps={{
...this.props,
...this.props.invalidationProps,
}}
/>
)
}
}
public componentWillReceiveProps(nextProps: IFilterListProps<T>) {
this.setState(createStateUpdate(nextProps))
}
private onSelectionChanged = (index: number, source: SelectionSource) => {
this.setState({ selectedRow: index })
if (this.props.onSelectionChanged) {
const row = this.state.rows[index]
if (row.kind === 'item') {
this.props.onSelectionChanged(row.item, source)
}
}
}
private renderRow = (index: number) => {
const row = this.state.rows[index]
if (row.kind === 'item') {
return this.props.renderItem(row.item, row.matches)
} else if (this.props.renderGroupHeader) {
return this.props.renderGroupHeader(row.identifier)
} else {
return null
}
}
private onListRef = (instance: List | null) => {
this.list = instance
}
private onInputRef = (instance: HTMLInputElement | null) => {
this.filterInput = instance
if (
this.filterInput &&
this.props.filterText &&
this.props.filterText.length > 0
) {
this.filterInput.select()
}
}
private onFilterChanged = (event: React.FormEvent<HTMLInputElement>) => {
const text = event.currentTarget.value
if (this.props.onFilterTextChanged) {
this.props.onFilterTextChanged(text)
}
}
public componentDidUpdate(
prevProps: IFilterListProps<T>,
prevState: IFilterListState<T>
@ -284,6 +188,98 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
}
}
public componentDidMount() {
if (this.filterTextBox != null) {
this.filterTextBox.selectAll()
}
}
public render() {
return (
<div className={classnames('filter-list', this.props.className)}>
{this.props.renderPreList ? this.props.renderPreList() : null}
<Row className="filter-field-row">
<TextBox
ref={this.onTextBoxRef}
type="search"
autoFocus={true}
placeholder="Filter"
className="filter-list-filter-field"
onValueChanged={this.onFilterValueChanged}
onKeyDown={this.onKeyDown}
value={this.props.filterText}
disabled={this.props.disabled}
/>
{this.props.renderPostFilter ? this.props.renderPostFilter() : null}
</Row>
<div className="filter-list-container">{this.renderContent()}</div>
</div>
)
}
private renderContent() {
if (this.state.rows.length === 0 && this.props.renderNoItems) {
return this.props.renderNoItems()
} else {
return (
<List
ref={this.onListRef}
rowCount={this.state.rows.length}
rowRenderer={this.renderRow}
rowHeight={this.props.rowHeight}
selectedRow={this.state.selectedRow}
onSelectionChanged={this.onSelectionChanged}
onRowClick={this.onRowClick}
onRowKeyDown={this.onRowKeyDown}
canSelectRow={this.canSelectRow}
invalidationProps={{
...this.props,
...this.props.invalidationProps,
}}
/>
)
}
}
private renderRow = (index: number) => {
const row = this.state.rows[index]
if (row.kind === 'item') {
return this.props.renderItem(row.item, row.matches)
} else if (this.props.renderGroupHeader) {
return this.props.renderGroupHeader(row.identifier)
} else {
return null
}
}
private onTextBoxRef = (component: TextBox | null) => {
this.filterTextBox = component
}
private onListRef = (instance: List | null) => {
this.list = instance
}
private onFilterValueChanged = (text: string) => {
if (this.props.onFilterTextChanged) {
this.props.onFilterTextChanged(text)
}
}
private onSelectionChanged = (index: number, source: SelectionSource) => {
this.setState({ selectedRow: index })
if (this.props.onSelectionChanged) {
const row = this.state.rows[index]
if (row.kind === 'item') {
this.props.onSelectionChanged(row.item, source)
}
}
}
private canSelectRow = (index: number) => {
if (this.props.disabled) {
return false
@ -309,26 +305,30 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
return
}
let focusInput = false
const firstSelectableRow = list.nextSelectableRow('down', -1)
const lastSelectableRow = list.nextSelectableRow('up', 0)
let shouldFocus = false
if (event.key === 'ArrowUp' && row === firstSelectableRow) {
focusInput = true
shouldFocus = true
} else if (event.key === 'ArrowDown' && row === lastSelectableRow) {
focusInput = true
shouldFocus = true
}
if (focusInput) {
const input = this.filterInput
if (input) {
if (shouldFocus) {
const textBox = this.filterTextBox
if (textBox) {
event.preventDefault()
input.focus()
textBox.focus()
}
}
}
private onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const list = this.list
const key = event.key
if (!list) {
return
}
@ -341,7 +341,7 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
return
}
if (event.key === 'ArrowDown') {
if (key === 'ArrowDown') {
if (this.state.rows.length > 0) {
const selectedRow = list.nextSelectableRow('down', -1)
if (selectedRow != null) {
@ -352,7 +352,7 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
}
event.preventDefault()
} else if (event.key === 'ArrowUp') {
} else if (key === 'ArrowUp') {
if (this.state.rows.length > 0) {
const selectedRow = list.nextSelectableRow('up', 0)
if (selectedRow != null) {
@ -363,7 +363,7 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
}
event.preventDefault()
} else if (event.key === 'Enter') {
} else if (key === 'Enter') {
// no repositories currently displayed, bail out
if (!this.state.rows.length) {
return event.preventDefault()

View file

@ -210,7 +210,7 @@ export class List extends React.Component<IListProps, IListState> {
private lastScroll: 'grid' | 'fake' | null = null
private list: HTMLDivElement | null = null
private grid: React.Component<any, any> | null
private grid: React.Component<any, any> | null = null
private readonly resizeObserver: ResizeObserver | null = null
private updateSizeTimeoutId: number | null = null

View file

@ -1,5 +1,6 @@
import * as React from 'react'
import * as classNames from 'classnames'
import { showContextualMenu } from '../main-process-proxy'
interface ITextAreaProps {
/** The label for the textarea field. */
@ -58,6 +59,10 @@ export class TextArea extends React.Component<ITextAreaProps, {}> {
this.props.onValueChanged(event.currentTarget.value)
}
}
private onContextMenu = (event: React.MouseEvent<any>) => {
event.preventDefault()
showContextualMenu([{ role: 'editMenu' }])
}
public render() {
const className = classNames(
@ -78,6 +83,7 @@ export class TextArea extends React.Component<ITextAreaProps, {}> {
onChange={this.onChange}
onKeyDown={this.props.onKeyDown}
ref={this.props.onTextAreaRef}
onContextMenu={this.onContextMenu}
/>
</label>
)

View file

@ -2,6 +2,7 @@ import * as React from 'react'
import * as classNames from 'classnames'
import { createUniqueId, releaseUniqueId } from './id-pool'
import { LinkButton } from './link-button'
import { showContextualMenu } from '../main-process-proxy'
interface ITextBoxProps {
/** The label for the input field. */
@ -25,9 +26,6 @@ interface ITextBoxProps {
/** Whether the input field is disabled. */
readonly disabled?: boolean
/** Called when the user changes the value in the input field. */
readonly onChange?: (event: React.FormEvent<HTMLInputElement>) => void
/**
* Called when the user changes the value in the input field.
*
@ -46,9 +44,6 @@ interface ITextBoxProps {
/** The type of the input. Defaults to `text`. */
readonly type?: 'text' | 'search' | 'password'
/** A callback to receive the underlying `input` instance. */
readonly onInputRef?: (instance: HTMLInputElement | null) => void
/**
* An optional text for a link label element. A link label is, for the purposes
* of this control an anchor element that's rendered alongside (ie on the same)
@ -97,6 +92,8 @@ interface ITextBoxState {
/** An input element with app-standard styles. */
export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
private inputElement: HTMLInputElement | null = null
public componentWillMount() {
const friendlyName = this.props.label || this.props.placeholder
const inputId = createUniqueId(`TextBox_${friendlyName}`)
@ -116,26 +113,40 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
}
}
/**
* Selects all text (if any) in the inner text input element. Note that this method does not
* automatically move keyboard focus, see the focus method for that
*/
public selectAll() {
if (this.inputElement !== null) {
this.inputElement.select()
}
}
/**
* Programmatically moves keyboard focus to the inner text input element if it can be focused
* (i.e. if it's not disabled explicitly or implicitly through for example a fieldset).
*/
public focus() {
if (this.inputElement === null) {
return
}
this.inputElement.focus()
}
private onChange = (event: React.FormEvent<HTMLInputElement>) => {
const value = event.currentTarget.value
if (this.props.onChange) {
this.props.onChange(event)
}
const defaultPrevented = event.defaultPrevented
this.setState({ value }, () => {
if (this.props.onValueChanged && !defaultPrevented) {
if (this.props.onValueChanged) {
this.props.onValueChanged(value)
}
})
}
private onRef = (instance: HTMLInputElement | null) => {
if (this.props.onInputRef) {
this.props.onInputRef(instance)
}
private onInputRef = (element: HTMLInputElement | null) => {
this.inputElement = element
}
private renderLabelLink() {
@ -167,6 +178,32 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
)
}
private onContextMenu = (event: React.MouseEvent<any>) => {
event.preventDefault()
showContextualMenu([{ role: 'editMenu' }])
}
private onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (
this.state.value !== '' &&
this.props.type === 'search' &&
event.key === 'Escape'
) {
const value = ''
event.preventDefault()
this.setState({ value })
if (this.props.onValueChanged) {
this.props.onValueChanged(value)
}
}
if (this.props.onKeyDown !== undefined) {
this.props.onKeyDown(event)
}
}
public render() {
const className = classNames('text-box-component', this.props.className)
const inputId = this.props.label ? this.state.inputId : undefined
@ -177,15 +214,16 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
<input
id={inputId}
ref={this.onInputRef}
autoFocus={this.props.autoFocus}
disabled={this.props.disabled}
type={this.props.type}
placeholder={this.props.placeholder}
value={this.state.value}
onChange={this.onChange}
onKeyDown={this.props.onKeyDown}
ref={this.onRef}
onKeyDown={this.onKeyDown}
tabIndex={this.props.tabIndex}
onContextMenu={this.onContextMenu}
/>
</div>
)

View file

@ -1,7 +1,7 @@
/** A utility class which allows for throttling arbitrary functions */
export class ThrottledScheduler {
private delay: number
private timeoutId: number
private timeoutId: number | null = null
/**
* Initialize a new instance of the ThrottledScheduler class
@ -19,12 +19,14 @@ export class ThrottledScheduler {
* as no other functions are queued.
*/
public queue(func: Function) {
window.clearTimeout(this.timeoutId)
this.clear()
this.timeoutId = window.setTimeout(func, this.delay)
}
/** Resets the scheduler and unschedules queued callback (if any) */
public clear() {
window.clearTimeout(this.timeoutId)
if (this.timeoutId != null) {
window.clearTimeout(this.timeoutId)
}
}
}

View file

@ -2,6 +2,7 @@ import { ipcRenderer } from 'electron'
import { ExecutableMenuItem } from '../models/app-menu'
import { MenuIDs } from '../main-process/menu'
import { IMenuItemState } from '../lib/menu-update'
import { IMenuItem } from '../lib/menu-item'
/** Set the menu item's enabledness. */
export function updateMenuState(
@ -52,20 +53,6 @@ export function getAppMenu() {
ipcRenderer.send('get-app-menu')
}
export interface IMenuItem {
/** The user-facing label. */
readonly label?: string
/** The action to invoke when the user selects the item. */
readonly action?: () => void
/** The type of item. */
readonly type?: 'separator'
/** Is the menu item enabled? Defaults to true. */
readonly enabled?: boolean
}
/**
* There's currently no way for us to know when a contextual menu is closed (see
* https://github.com/electron/electron/issues/9441). So we'll store the latest

View file

@ -83,15 +83,6 @@ export class Merge extends React.Component<IMergeProps, IMergeState> {
this.setState({ filterText })
}
private onFilterKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
if (this.state.filterText.length === 0) {
this.props.onDismissed()
event.preventDefault()
}
}
}
private onSelectionChanged = async (selectedBranch: Branch | null) => {
if (selectedBranch) {
this.setState({ selectedBranch })
@ -161,7 +152,6 @@ export class Merge extends React.Component<IMergeProps, IMergeState> {
defaultBranch={this.props.defaultBranch}
recentBranches={this.props.recentBranches}
filterText={this.state.filterText}
onFilterKeyDown={this.onFilterKeyDown}
onFilterTextChanged={this.onFilterTextChanged}
selectedBranch={selectedBranch}
onSelectionChanged={this.onSelectionChanged}

View file

@ -10,6 +10,10 @@ export function iconForRepository(repository: Repository | CloningRepository) {
return OcticonSymbol.desktopDownload
}
if (repository.missing) {
return OcticonSymbol.alert
}
const gitHubRepo = repository.gitHubRepository
if (!gitHubRepo) {
return OcticonSymbol.deviceDesktop

View file

@ -78,12 +78,12 @@ export class PublishRepository extends React.Component<
this.props.onSettingsChanged(newSettings)
}
private onNameChange = (event: React.FormEvent<HTMLInputElement>) => {
this.updateSettings({ name: event.currentTarget.value })
private onNameChange = (name: string) => {
this.updateSettings({ name })
}
private onDescriptionChange = (event: React.FormEvent<HTMLInputElement>) => {
this.updateSettings({ description: event.currentTarget.value })
private onDescriptionChange = (description: string) => {
this.updateSettings({ description })
}
private onPrivateChange = (event: React.FormEvent<HTMLInputElement>) => {
@ -146,7 +146,7 @@ export class PublishRepository extends React.Component<
label="Name"
value={this.props.settings.name}
autoFocus={true}
onChange={this.onNameChange}
onValueChanged={this.onNameChange}
/>
</Row>
@ -154,7 +154,7 @@ export class PublishRepository extends React.Component<
<TextBox
label="Description"
value={this.props.settings.description}
onChange={this.onDescriptionChange}
onValueChanged={this.onDescriptionChange}
/>
</Row>

View file

@ -39,7 +39,7 @@ export class RelativeTime extends React.Component<
IRelativeTimeProps,
IRelativeTimeState
> {
private timer: number | null
private timer: number | null = null
public constructor(props: IRelativeTimeProps) {
super(props)

View file

@ -47,8 +47,7 @@ export class RenameBranch extends React.Component<
label="Name"
autoFocus={true}
value={this.state.newName}
onChange={this.onNameChange}
onKeyDown={this.onKeyDown}
onValueChanged={this.onNameChange}
/>
</Row>
{renderBranchNameWarning(
@ -69,14 +68,8 @@ export class RenameBranch extends React.Component<
)
}
private onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
this.props.dispatcher.closePopup()
}
}
private onNameChange = (event: React.FormEvent<HTMLInputElement>) => {
this.setState({ newName: event.currentTarget.value })
private onNameChange = (name: string) => {
this.setState({ newName: name })
}
private cancel = () => {

View file

@ -40,9 +40,6 @@ interface IRepositoriesListProps {
/** The current external editor selected by the user */
readonly externalEditorLabel?: string
/** Called when the repositories list should be closed. */
readonly onClose: () => void
/** The label for the user's preferred shell. */
readonly shellLabel: string
@ -107,15 +104,6 @@ export class RepositoriesList extends React.Component<
this.props.onSelectionChanged(item.repository)
}
private onFilterKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
if (this.props.filterText.length === 0) {
this.props.onClose()
event.preventDefault()
}
}
}
public render() {
if (this.props.repositories.length < 1) {
return this.noRepositories()
@ -149,7 +137,6 @@ export class RepositoriesList extends React.Component<
renderItem={this.renderItem}
renderGroupHeader={this.renderGroupHeader}
onItemClick={this.onItemClick}
onFilterKeyDown={this.onFilterKeyDown}
groups={groups}
invalidationProps={{
repositories: this.props.repositories,

View file

@ -1,8 +1,9 @@
import * as React from 'react'
import { Repository } from '../../models/repository'
import { Octicon, iconForRepository } from '../octicons'
import { showContextualMenu, IMenuItem } from '../main-process-proxy'
import { showContextualMenu } from '../main-process-proxy'
import { Repositoryish } from './group-repositories'
import { IMenuItem } from '../../lib/menu-item'
import { HighlightText } from '../lib/highlight-text'
const defaultEditorLabel = __DARWIN__

View file

@ -2,6 +2,7 @@ import * as React from 'react'
import { DialogContent } from '../dialog'
import { TextArea } from '../lib/text-area'
import { LinkButton } from '../lib/link-button'
import { Ref } from '../lib/ref'
interface IGitIgnoreProps {
readonly text: string | null
@ -15,14 +16,14 @@ export class GitIgnore extends React.Component<IGitIgnoreProps, {}> {
return (
<DialogContent>
<p>
The .gitignore file controls which files are tracked by Git and which
are ignored. Check out{' '}
Editing <Ref>.gitignore</Ref>. This file specifies intentionally
untracked files that Git should ignore. Files already tracked by Git
are not affected.{' '}
<LinkButton onClick={this.props.onShowExamples}>
git-scm.com
</LinkButton>{' '}
for more information about the file format, or simply ignore a file by
right clicking on it in the uncommitted changes view.
Learn more
</LinkButton>
</p>
<TextArea
placeholder="Ignored files"
value={this.props.text || ''}

View file

@ -190,7 +190,7 @@ export class RepositorySettings extends React.Component<
try {
await this.props.dispatcher.saveGitIgnore(
this.props.repository,
this.state.ignoreText || ''
this.state.ignoreText
)
} catch (e) {
log.error(

View file

@ -35,6 +35,15 @@ interface IRepositoryProps {
readonly imageDiffType: ImageDiffType
readonly askForConfirmationOnDiscardChanges: boolean
readonly accounts: ReadonlyArray<Account>
/** The name of the currently selected external editor */
readonly externalEditorLabel?: string
/**
* Called to open a file using the user's configured applications
* @param path The path of the file relative to the root of the repository
*/
readonly onOpenInExternalEditor: (path: string) => void
}
const enum Tab {
@ -76,7 +85,7 @@ export class RepositoryView extends React.Component<IRepositoryProps, {}> {
localCommitSHAs.length > 0 ? localCommitSHAs[0] : null
const mostRecentLocalCommit =
(mostRecentLocalCommitSHA
? this.props.state.commits.get(mostRecentLocalCommitSHA)
? this.props.state.commitLookup.get(mostRecentLocalCommitSHA)
: null) || null
// -1 Because of right hand side border
@ -101,6 +110,8 @@ export class RepositoryView extends React.Component<IRepositoryProps, {}> {
this.props.askForConfirmationOnDiscardChanges
}
accounts={this.props.accounts}
externalEditorLabel={this.props.externalEditorLabel}
onOpenInExternalEditor={this.props.onOpenInExternalEditor}
/>
)
}
@ -113,7 +124,7 @@ export class RepositoryView extends React.Component<IRepositoryProps, {}> {
history={this.props.state.historyState}
gitHubUsers={this.props.state.gitHubUsers}
emoji={this.props.emoji}
commits={this.props.state.commits}
commitLookup={this.props.state.commitLookup}
localCommitSHAs={this.props.state.localCommitSHAs}
onRevertCommit={this.onRevertCommit}
onViewCommitOnGitHub={this.props.onViewCommitOnGitHub}
@ -190,7 +201,7 @@ export class RepositoryView extends React.Component<IRepositoryProps, {}> {
dispatcher={this.props.dispatcher}
history={this.props.state.historyState}
emoji={this.props.emoji}
commits={this.props.state.commits}
commits={this.props.state.commitLookup}
commitSummaryWidth={this.props.commitSummaryWidth}
gitHubUsers={this.props.state.gitHubUsers}
imageDiffType={this.props.imageDiffType}

View file

@ -13,8 +13,8 @@ export class Resizable extends React.Component<IResizableProps, {}> {
maximumWidth: 350,
}
private startWidth: number | null
private startX: number
private startWidth: number | null = null
private startX: number | null = null
/**
* Returns the current width as determined by props.
@ -55,7 +55,7 @@ export class Resizable extends React.Component<IResizableProps, {}> {
* Handler for when the user moves the mouse while dragging
*/
private handleDragMove = (e: MouseEvent) => {
if (!this.startWidth) {
if (this.startWidth == null || this.startX == null) {
return
}

View file

@ -203,10 +203,16 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
value={this.state.password}
type="password"
onValueChanged={this.onPasswordChanged}
labelLinkText="Forgot password?"
labelLinkUri={state.forgotPasswordUrl}
/>
</Row>
<Row>
<LinkButton
className="forgot-password-link-sign-in"
uri={state.forgotPasswordUrl}
>
Forgot password?
</LinkButton>
</Row>
<div className="horizontal-rule">
<span className="horizontal-rule-content">or</span>
@ -239,6 +245,7 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
onValueChanged={this.onOTPTokenChanged}
labelLinkText={`What's this?`}
labelLinkUri="https://help.github.com/articles/providing-your-2fa-authentication-code/"
autoFocus={true}
/>
</Row>
</DialogContent>

View file

@ -8,7 +8,6 @@ import { IRepositoryState } from '../../lib/app-state'
import { BranchesContainer, PullRequestBadge } from '../branches'
import { assertNever } from '../../lib/fatal-error'
import { BranchesTab } from '../../models/branches-tab'
import { enablePRIntegration } from '../../lib/feature-flag'
import { PullRequest } from '../../models/pull-request'
interface IBranchDropdownProps {
@ -104,7 +103,7 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
} else if (tip.kind === TipState.Unborn) {
title = tip.ref
tooltip = `Current branch is ${tip.ref}`
canOpen = false
canOpen = branchesState.allBranches.length > 0
} else if (tip.kind === TipState.Detached) {
title = `On ${tip.currentSha.substr(0, 7)}`
tooltip = 'Currently on a detached HEAD'
@ -163,10 +162,6 @@ export class BranchDropdown extends React.Component<IBranchDropdownProps> {
return null
}
if (!enablePRIntegration()) {
return null
}
return <PullRequestBadge number={pr.number} status={pr.status} />
}
}

View file

@ -145,6 +145,10 @@ export class ToolbarDropdown extends React.Component<
this.state = { clientRect: null }
}
private get isOpen() {
return this.props.dropdownState === 'open'
}
private dropdownIcon(state: DropdownState): OcticonSymbol {
// @TODO: Remake triangle octicon in a 12px version,
// right now it's scaled badly on normal dpi monitors.
@ -249,6 +253,13 @@ export class ToolbarDropdown extends React.Component<
}
}
private onFoldoutKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (!event.defaultPrevented && this.isOpen && event.key === 'Escape') {
event.preventDefault()
this.props.onDropdownStateChanged('closed', 'keyboard')
}
}
private renderDropdownContents = (): JSX.Element | null => {
if (this.props.dropdownState !== 'open') {
return null
@ -265,7 +276,11 @@ export class ToolbarDropdown extends React.Component<
tabIndex={-1}
onClick={this.handleOverlayClick}
/>
<div className="foldout" style={this.getFoldoutStyle()}>
<div
className="foldout"
style={this.getFoldoutStyle()}
onKeyDown={this.onFoldoutKeyDown}
>
{this.props.dropdownContentRenderer()}
</div>
</div>

View file

@ -0,0 +1,12 @@
<svg width="56" height="61" viewBox="0 0 56 61" xmlns="http://www.w3.org/2000/svg">
<title>
ufo-alert
</title>
<g fill="#C9DEF2" fill-rule="nonzero">
<path d="M43.324 1.08c8.09 1.42 13.497 9.126 12.078 17.215-1.326 7.57-8.16 12.79-15.66 12.267-3.827 3.423-8.51 4.283-11.832 4.42-.664.03-1.255-.498-.214-1.07 2.043-1.125 3.344-3.464 4.11-6.312-4.31-3.255-6.694-8.752-5.696-14.44C27.53 5.07 35.235-.337 43.324 1.08zm-.44 20.775v-4h-4v4h4zm0-5v-8h-4v8h4zm-23.758 18.33a8.918 8.918 0 0 0-16.62 6.466l2.777 7.14 16.624-6.468-2.778-7.14-.002.002zm3.606 7.89l-17.554 6.83a.5.5 0 0 1-.647-.284l-2.957-7.603c-1.986-5.105.54-10.853 5.646-12.84 5.103-1.986 10.85.542 12.84 5.647l2.96 7.605a.5.5 0 0 1-.287.647z"/>
<path d="M5.583 41.92c-1.98-4.183-.11-8.474 4.21-9.61a.5.5 0 0 0-.256-.967C4.6 32.64 2.435 37.61 4.68 42.35a.5.5 0 0 0 .903-.43zm20.197 3.644l-1.24-3.19L3.37 50.61l1.242 3.19 21.168-8.236zm.83.75l-22.1 8.6a.5.5 0 0 1-.648-.285l-1.604-4.125a.5.5 0 0 1 .284-.647l22.1-8.6a.5.5 0 0 1 .648.285l1.604 4.123a.5.5 0 0 1-.285.647z"/>
<path d="M23.604 44.037l-9.914 3.857a.5.5 0 1 0 .362.932l9.914-3.857a.5.5 0 0 0-.362-.934zm-1.77 4.136l-12.55 4.883 1.024 1.435 11.742-4.567-.216-1.75zm.94 2.54l-12.466 4.85a.5.5 0 0 1-.588-.174L8.098 53.11a.5.5 0 0 1 .225-.756L22.07 47.01a.5.5 0 0 1 .678.403l.342 2.772a.5.5 0 0 1-.315.527z"/>
<path d="M13.34 56.504l-.473-2.38a.5.5 0 1 0-.98.196l.473 2.38a.5.5 0 1 0 .98-.196zm9.738-2.582a.885.885 0 1 0-1.514.918.885.885 0 0 0 1.514-.918zm.855-.518a1.885 1.885 0 0 1-3.223 1.955 1.886 1.886 0 0 1 3.223-1.957z"/>
<path d="M14.078 57.922a.885.885 0 1 0-1.514.918.885.885 0 0 0 1.514-.918zm.855-.518a1.885 1.885 0 1 1-3.223 1.955 1.886 1.886 0 0 1 3.223-1.957zM22.03 52.937l-1.26-2.074a.5.5 0 1 0-.853.52l1.26 2.073a.5.5 0 1 0 .853-.52z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -174,7 +174,7 @@
--toolbar-background-color: $gray-900;
--toolbar-text-color: $white;
--toolbar-text-secondary-color: $gray-400;
--toolbar-text-secondary-color: $gray-300;
--toolbar-button-color: var(--toolbar-text-color);
--toolbar-button-background-color: transparent;

View file

@ -14,18 +14,23 @@
padding: 0 var(--spacing-half);
&:focus {
outline: none;
border-color: var(--focus-color);
box-shadow: 0 0 0 1px var(--text-field-focus-shadow-color);
@include textboxish-focus-styles;
}
}
// This is kept separate from textboxish because we need to be able
// to apply it conditionally when running on Windows, the disabled
// styles on macOS are fine.
@mixin textboxish-focus-styles {
outline: none;
border-color: var(--focus-color);
box-shadow: 0 0 0 1px var(--text-field-focus-shadow-color);
}
@mixin textboxish-disabled-styles {
background: var(--box-alt-background-color);
color: var(--text-secondary-color);
}
@mixin textboxish-disabled {
&:disabled {
background: var(--box-alt-background-color);
color: var(--text-secondary-color);
@include textboxish-disabled-styles;
}
}

View file

@ -1,9 +1,13 @@
@import '../mixins';
.author-input-component {
&.disabled .CodeMirror {
@include textboxish-disabled-styles;
}
.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);
@include textboxish;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
@ -53,9 +57,7 @@
}
.CodeMirror-focused {
outline: none;
border-color: var(--focus-color);
box-shadow: 0 0 0 1px var(--text-field-focus-shadow-color);
@include textboxish-focus-styles;
border-top-width: 1px;
border-top-style: solid;
margin-top: -1px;

View file

@ -82,16 +82,33 @@
.callout:not(:last-child) {
border-right: var(--base-border);
}
// Custom image sizing explicitly for the dedicated
// blank slate view (a.k.a the no-repositories view).
.blankslate-image {
max-width: 400px;
width: 80%;
flex-grow: 0.6;
// Background image url is set in react since we need to
// calculate it at runtime.
background-repeat: no-repeat;
background-position: center bottom;
background-size: contain;
}
}
.blankslate-image {
width: 80%;
flex-grow: 0.6;
// Background image url is set in react since we need to
// calculate it at runtime.
background-repeat: no-repeat;
background-position: center bottom;
background-size: contain;
// For all views other than the dedicated blank slate
// view. Yes, this is confusing.
.ui-view:not(#blank-slate) .blankslate-image {
width: 50%;
min-width: 400px;
max-width: 800px;
}
// In the foldout we want the image to fill the
// full width of the container (minus padding).
.foldout .blankslate-image {
width: 100%;
}
// When there's not enough space to show the blankslate

Some files were not shown because too many files have changed in this diff Show more