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

This commit is contained in:
Jed Fox 2018-02-14 17:04:23 -05:00
commit 72ea02412c
112 changed files with 1784 additions and 883 deletions

View file

@ -74,10 +74,7 @@ rules:
###########
prettier/prettier:
- error
- singleQuote: true
trailingComma: es5
semi: false
parser: typescript
- parser: typescript
no-restricted-syntax:
- error
# no-default-export

View file

@ -1,25 +1,5 @@
<!--
Have you read GitHub Desktop's Code of Conduct? By filing an Issue, you are
expected to comply with it, including treating everyone with respect:
https://github.com/desktop/desktop/blob/master/CODE_OF_CONDUCT.md
-->
<!--
Are you encountering an issue where the “Minimize” tooltip stays visible
when you click the minimize button in the window? If so, that is an issue
with Electron, the framework the app uses. Please subscribe to the issue
at this link for updates on the issue:
https://github.com/electron/electron/issues/9943
-->
<!--
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.

View file

@ -1,2 +1,16 @@
out/
dist/
node_modules/
npm-debug.log
yarn-error.log
app/node_modules/
.DS_Store
.awcache
.idea/
.eslintcache
app/static/common
app/test/fixtures
gemoji
*.json
*.md

4
.prettierrc.yml Normal file
View file

@ -0,0 +1,4 @@
singleQuote: true
trailingComma: es5
semi: false
proseWrap: always

View file

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

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "1.0.14-beta1",
"version": "1.0.14-beta4",
"main": "./main.js",
"repository": {
"type": "git",
@ -22,6 +22,7 @@
"chalk": "^2.3.0",
"classnames": "^2.2.5",
"codemirror": "^5.31.0",
"codemirror-mode-elixir": "1.1.1",
"deep-equal": "^1.0.1",
"dexie": "^2.0.0",
"dugite": "1.57.0",
@ -31,6 +32,7 @@
"file-url": "^2.0.2",
"front-matter": "^2.1.2",
"fs-extra": "^2.1.2",
"fuzzaldrin-plus": "^0.6.0",
"keytar": "^4.0.4",
"moment": "^2.17.1",
"mri": "^1.1.0",

View file

@ -5,7 +5,8 @@
@import '../../../styles/ui/button';
@import '../../../styles/ui/scroll';
#desktop-crash-container, #crash-app {
#desktop-crash-container,
#crash-app {
height: 100%;
}
@ -35,7 +36,6 @@ pre.error {
}
header {
margin-bottom: var(--spacing-double);
display: flex;
flex: none;

View file

@ -108,6 +108,10 @@ extensionMIMEMap.set('.edn', 'text/x-clojure')
import 'codemirror/mode/rust/rust'
extensionMIMEMap.set('.rs', 'text/x-rustsrc')
import 'codemirror-mode-elixir'
extensionMIMEMap.set('.ex', 'text/x-elixir')
extensionMIMEMap.set('.exs', 'text/x-elixir')
function guessMimeType(contents: string) {
if (contents.startsWith('<?xml')) {
return 'text/xml'

View file

@ -80,12 +80,6 @@ export interface IAPIUser {
readonly type: 'User' | 'Organization'
}
/**
* An expression that validates a GitHub.com or GitHub Enterprise
* username
*/
export const validLoginExpression = /^[a-z0-9]+(-[a-z0-9]+)*$/i
/** The users we get from the mentionables endpoint. */
export interface IAPIMentionableUser {
readonly avatar_url: string
@ -567,7 +561,10 @@ export class API {
*/
public async fetchUser(login: string): Promise<IAPIUser | null> {
try {
const response = await this.request('GET', `users/${login}`)
const response = await this.request(
'GET',
`users/${encodeURIComponent(login)}`
)
if (response.status === 404) {
return null

View file

@ -13,9 +13,14 @@
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comparison_Operators
*
* This method can be chained using `||` for more complex sorting
* logic. Ex
* logic. Example:
*
* arr.sort((x, y) => compare(x.firstName, y.firstName) || compare(x.lastName, y.lastName))
* ```ts
* arr.sort(
* (x, y) =>
* compare(x.firstName, y.firstName) || compare(x.lastName, y.lastName)
* )
* ```
*
*/
export function compare<T>(x: T, y: T): number {

View file

@ -0,0 +1,38 @@
import Dexie from 'dexie'
export abstract class BaseDatabase extends Dexie {
private schemaVersion: number | undefined
public constructor(name: string, schemaVersion: number | undefined) {
super(name)
this.schemaVersion = schemaVersion
}
/**
* Register the version of the schema only if `targetVersion` is less than
* `version` or is `undefined`.
*
* targetVersion - The version of the schema that is being targetted. If not
* provided, the given version will be registered.
* version - The version being registered.
* schema - The schema to register.
* upgrade - An upgrade function to call after upgrading to the given
* version.
*/
protected async conditionalVersion(
version: number,
schema: { [key: string]: string | null },
upgrade?: (t: Dexie.Transaction) => Promise<void>
) {
if (this.schemaVersion != null && this.schemaVersion < version) {
return
}
const dexieVersion = this.version(version).stores(schema)
if (upgrade != null) {
await dexieVersion.upgrade(upgrade)
}
}
}

View file

@ -1,7 +1,5 @@
import Dexie from 'dexie'
// NB: This _must_ be incremented whenever the DB key scheme changes.
const DatabaseVersion = 2
import { BaseDatabase } from './base-database'
export interface IGitHubUser {
/**
@ -31,18 +29,18 @@ export interface IMentionableAssociation {
readonly repositoryID: number
}
export class GitHubUserDatabase extends Dexie {
export class GitHubUserDatabase extends BaseDatabase {
public users: Dexie.Table<IGitHubUser, number>
public mentionables: Dexie.Table<IMentionableAssociation, number>
public constructor(name: string) {
super(name)
public constructor(name: string, schemaVersion?: number) {
super(name, schemaVersion)
this.version(1).stores({
this.conditionalVersion(1, {
users: '++id, &[endpoint+email]',
})
this.version(DatabaseVersion).stores({
this.conditionalVersion(2, {
users: '++id, [endpoint+email], [endpoint+login]',
mentionables: '++id, repositoryID, &[userID+repositoryID]',
})

View file

@ -1,7 +1,5 @@
import Dexie from 'dexie'
// NB: This _must_ be incremented whenever the DB key scheme changes.
const DatabaseVersion = 2
import { BaseDatabase } from './base-database'
export interface IIssue {
readonly id?: number
@ -11,41 +9,49 @@ export interface IIssue {
readonly updated_at?: string
}
export class IssuesDatabase extends Dexie {
export class IssuesDatabase extends BaseDatabase {
public issues: Dexie.Table<IIssue, number>
public constructor(name: string) {
super(name)
public constructor(name: string, schemaVersion?: number) {
super(name, schemaVersion)
this.version(1).stores({
this.conditionalVersion(1, {
issues: '++id, &[gitHubRepositoryID+number], gitHubRepositoryID, number',
})
this.version(DatabaseVersion)
.stores({
this.conditionalVersion(
2,
{
issues:
'++id, &[gitHubRepositoryID+number], gitHubRepositoryID, number, [gitHubRepositoryID+updated_at]',
})
.upgrade(t => {
// Clear deprecated localStorage keys, we compute the since parameter
// using the database now.
Object.keys(localStorage)
.filter(key => /^IssuesStore\/\d+\/lastFetch$/.test(key))
.forEach(key => localStorage.removeItem(key))
// Unfortunately we have to clear the issues in order to maintain
// data consistency in the database. The issues table is only supposed
// to store 'open' issues and if we kept the existing issues (which)
// don't have an updated_at field around the initial query for
// max(updated_at) would return null, causing us to fetch all _open_
// issues which in turn means we wouldn't be able to detect if we
// have any issues in the database that have been closed since the
// last time we fetched. Not only that, these closed issues wouldn't
// be updated to include the updated_at field unless they were actually
// modified at a later date.
//
// TL;DR; This is the safest approach
return t.table('issues').clear()
})
},
clearIssues
)
}
}
function clearIssues(transaction: Dexie.Transaction) {
// Clear deprecated localStorage keys, we compute the since parameter
// using the database now.
clearDeprecatedKeys()
// Unfortunately we have to clear the issues in order to maintain
// data consistency in the database. The issues table is only supposed
// to store 'open' issues and if we kept the existing issues (which)
// don't have an updated_at field around the initial query for
// max(updated_at) would return null, causing us to fetch all _open_
// issues which in turn means we wouldn't be able to detect if we
// have any issues in the database that have been closed since the
// last time we fetched. Not only that, these closed issues wouldn't
// be updated to include the updated_at field unless they were actually
// modified at a later date.
//
// TL;DR; This is the safest approach
return transaction.table('issues').clear()
}
function clearDeprecatedKeys() {
Object.keys(localStorage)
.filter(key => /^IssuesStore\/\d+\/lastFetch$/.test(key))
.forEach(key => localStorage.removeItem(key))
}

View file

@ -1,5 +1,6 @@
import Dexie from 'dexie'
import { APIRefState, IAPIRefStatusItem } from '../api'
import { BaseDatabase } from './base-database'
export interface IPullRequestRef {
/**
@ -65,26 +66,47 @@ export interface IPullRequestStatus {
* if the database object was created prior to status support
* being added in #3588
*/
readonly statuses: ReadonlyArray<IAPIRefStatusItem> | undefined
readonly statuses?: ReadonlyArray<IAPIRefStatusItem>
}
export class PullRequestDatabase extends Dexie {
export class PullRequestDatabase extends BaseDatabase {
public pullRequests: Dexie.Table<IPullRequest, number>
public pullRequestStatus: Dexie.Table<IPullRequestStatus, number>
public constructor(name: string) {
super(name)
public constructor(name: string, schemaVersion?: number) {
super(name, schemaVersion)
this.version(1).stores({
this.conditionalVersion(1, {
pullRequests: 'id++, base.repoId',
})
this.version(2).stores({
this.conditionalVersion(2, {
pullRequestStatus: 'id++, &[sha+pullRequestId]',
})
this.version(3).stores({
this.conditionalVersion(3, {
pullRequestStatus: 'id++, &[sha+pullRequestId], pullRequestId',
})
// we need to run the upgrade function to ensure we add
// a status field to all previous records
this.conditionalVersion(4, {}, this.addStatusesField)
}
private addStatusesField = async (transaction: Dexie.Transaction) => {
const table = this.pullRequestStatus
await table.toCollection().modify(async prStatus => {
if (prStatus.statuses == null) {
const newPrStatus = { statuses: [], ...prStatus }
await table
.where('[sha+pullRequestId]')
.equals([prStatus.sha, prStatus.pullRequestId])
.delete()
await table.add(newPrStatus)
}
})
}
}

View file

@ -1,4 +1,5 @@
import Dexie from 'dexie'
import { BaseDatabase } from './base-database'
export interface IDatabaseOwner {
readonly id?: number | null
@ -27,7 +28,7 @@ export interface IDatabaseRepository {
}
/** The repositories database. */
export class RepositoriesDatabase extends Dexie {
export class RepositoriesDatabase extends BaseDatabase {
/** The local repositories table. */
public repositories: Dexie.Table<IDatabaseRepository, number>
@ -45,15 +46,15 @@ export class RepositoriesDatabase extends Dexie {
* database will be created with the latest version.
*/
public constructor(name: string, schemaVersion?: number) {
super(name)
super(name, schemaVersion)
this.conditionalVersion(schemaVersion, 1, {
this.conditionalVersion(1, {
repositories: '++id, &path',
gitHubRepositories: '++id, name',
owners: '++id, login',
})
this.conditionalVersion(schemaVersion, 2, {
this.conditionalVersion(2, {
owners: '++id, &[endpoint+login]',
})
@ -61,48 +62,16 @@ export class RepositoriesDatabase extends Dexie {
// version and its upgrade callback only happens *after* the schema's been
// changed. So we need to prepare for it by removing any old data now
// which will violate it.
this.conditionalVersion(
schemaVersion,
3,
{},
removeDuplicateGitHubRepositories
)
this.conditionalVersion(3, {}, removeDuplicateGitHubRepositories)
this.conditionalVersion(schemaVersion, 4, {
this.conditionalVersion(4, {
gitHubRepositories: '++id, name, &[ownerID+name]',
})
this.conditionalVersion(schemaVersion, 5, {
this.conditionalVersion(5, {
gitHubRepositories: '++id, name, &[ownerID+name], cloneURL',
})
}
/**
* Register the version of the schema only if `targetVersion` is less than
* `version` or is `undefined`.
*
* targetVersion - The version of the schema that is being targetted. If not
* provided, the given version will be registered.
* version - The version being registered.
* schema - The schema to register.
* upgrade - An upgrade function to call after upgrading to the given
* version.
*/
private conditionalVersion(
targetVersion: number | undefined,
version: number,
schema: { [key: string]: string | null },
upgrade?: (t: Dexie.Transaction) => void
) {
if (targetVersion && targetVersion < version) {
return
}
const dexieVersion = this.version(version).stores(schema)
if (upgrade) {
dexieVersion.upgrade(upgrade)
}
}
}
/**
@ -120,6 +89,7 @@ function removeDuplicateGitHubRepositories(transaction: Dexie.Transaction) {
// We can be sure `id` isn't null since we just got it from the
// database.
const id = repo.id!
table.delete(id)
} else {
seenKeys.add(key)

View file

@ -1091,11 +1091,6 @@ export class Dispatcher {
return this.appStore._openCreatePullRequestInBrowser(repository, branch)
}
/** Refresh the list of open pull requests for the repository. */
public refreshPullRequests(repository: Repository): Promise<void> {
return this.appStore._refreshPullRequests(repository)
}
/**
* Update the existing `upstream` remote to point to the repository's parent.
*/

View file

@ -10,6 +10,8 @@ export enum ExternalEditor {
SublimeText = 'Sublime Text',
BBEdit = 'BBEdit',
PhpStorm = 'PhpStorm',
RubyMine = 'RubyMine',
TextMate = 'TextMate',
}
export function parse(label: string): ExternalEditor | null {
@ -31,7 +33,12 @@ export function parse(label: string): ExternalEditor | null {
if (label === ExternalEditor.PhpStorm) {
return ExternalEditor.PhpStorm
}
if (label === ExternalEditor.RubyMine) {
return ExternalEditor.RubyMine
}
if (label === ExternalEditor.TextMate) {
return ExternalEditor.TextMate
}
return null
}
@ -54,6 +61,10 @@ function getBundleIdentifiers(editor: ExternalEditor): ReadonlyArray<string> {
return ['com.barebones.bbedit']
case ExternalEditor.PhpStorm:
return ['com.jetbrains.PhpStorm']
case ExternalEditor.RubyMine:
return ['com.jetbrains.RubyMine']
case ExternalEditor.TextMate:
return ['com.macromates.TextMate']
default:
return assertNever(editor, `Unknown external editor: ${editor}`)
}
@ -82,6 +93,10 @@ function getExecutableShim(
return Path.join(installPath, 'Contents', 'Helpers', 'bbedit_tool')
case ExternalEditor.PhpStorm:
return Path.join(installPath, 'Contents', 'MacOS', 'phpstorm')
case ExternalEditor.RubyMine:
return Path.join(installPath, 'Contents', 'MacOS', 'rubymine')
case ExternalEditor.TextMate:
return Path.join(installPath, 'Contents', 'Resources', 'mate')
default:
return assertNever(editor, `Unknown external editor: ${editor}`)
}
@ -123,6 +138,8 @@ export async function getAvailableEditors(): Promise<
sublimePath,
bbeditPath,
phpStormPath,
rubyMinePath,
textMatePath,
] = await Promise.all([
findApplication(ExternalEditor.Atom),
findApplication(ExternalEditor.VisualStudioCode),
@ -130,6 +147,8 @@ export async function getAvailableEditors(): Promise<
findApplication(ExternalEditor.SublimeText),
findApplication(ExternalEditor.BBEdit),
findApplication(ExternalEditor.PhpStorm),
findApplication(ExternalEditor.RubyMine),
findApplication(ExternalEditor.TextMate),
])
if (atomPath) {
@ -159,5 +178,13 @@ export async function getAvailableEditors(): Promise<
results.push({ editor: ExternalEditor.PhpStorm, path: phpStormPath })
}
if (rubyMinePath) {
results.push({ editor: ExternalEditor.RubyMine, path: rubyMinePath })
}
if (textMatePath) {
results.push({ editor: ExternalEditor.TextMate, path: textMatePath })
}
return results
}

47
app/src/lib/fuzzy-find.ts Normal file
View file

@ -0,0 +1,47 @@
import * as fuzzAldrin from 'fuzzaldrin-plus'
import { compareDescending } from './compare'
const options: fuzzAldrin.IFilterOptions = {
allowErrors: true,
isPath: true,
pathSeparator: '-',
}
function score(str: string, query: string, maxScore: number) {
return fuzzAldrin.score(str, query, undefined, options) / maxScore
}
export interface IMatch<T> {
/** `0 <= score <= 1` */
score: number
item: T
matches: ReadonlyArray<number>
}
export type KeyFunction<T> = (item: T) => string
export function match<T, _K extends keyof T>(
query: string,
items: ReadonlyArray<T>,
getKey: _K | KeyFunction<T>
): ReadonlyArray<IMatch<T>> {
// matching `query` against itself is a perfect match.
const maxScore = score(query, query, 1)
const result = items
.map((item): IMatch<T> => {
const key: string =
typeof getKey === 'function'
? (getKey as KeyFunction<T>)(item)
: String(item[getKey])
return {
score: score(key, query, maxScore),
item,
matches: fuzzAldrin.match(key, query, undefined, options),
}
})
.filter(({ matches }) => matches.length > 0)
.sort(({ score: left }, { score: right }) => compareDescending(left, right))
return result
}

View file

@ -101,6 +101,10 @@ export async function getCommitDiff(
file.path,
]
if (file.oldPath != null) {
args.push(file.oldPath)
}
const { output } = await spawnAndComplete(
args,
repository.path,

View file

@ -11,9 +11,17 @@ export async function listSubmodules(
const result = await git(
['submodule', 'status', '--'],
repository.path,
'listSubmodules'
'listSubmodules',
{
successExitCodes: new Set([0, 128]),
}
)
if (result.exitCode === 128) {
// unable to parse submodules in repository, giving up
return []
}
const submodules = new Array<SubmoduleEntry>()
for (const entry of result.stdout.split('\n')) {

View file

@ -8,7 +8,7 @@ import { getDotComAPIEndpoint } from './api'
* @param email The email address associated with a user
* @param size The size (in pixels) of the avatar to render
*/
export function generateGravatarUrl(email: string, size: number = 200): string {
export function generateGravatarUrl(email: string, size: number = 60): string {
const input = email.trim().toLowerCase()
const hash = crypto
.createHash('md5')

View file

@ -1,4 +1,4 @@
import { spawn } from 'child_process'
import { spawn, ChildProcess } from 'child_process'
import { assertNever } from '../fatal-error'
import { IFoundShell } from './found-shell'
@ -76,11 +76,11 @@ export async function getAvailableShells(): Promise<
return shells
}
export async function launch(
export function launch(
foundShell: IFoundShell<Shell>,
path: string
): Promise<void> {
): ChildProcess {
const bundleID = getBundleID(foundShell.shell)
const commandArgs = ['-b', bundleID, path]
await spawn('open', commandArgs)
return spawn('open', commandArgs)
}

View file

@ -1,4 +1,4 @@
import { spawn } from 'child_process'
import { spawn, ChildProcess } from 'child_process'
import { pathExists } from '../file-system'
import { assertNever } from '../fatal-error'
import { IFoundShell } from './found-shell'
@ -99,26 +99,22 @@ export async function getAvailableShells(): Promise<
return shells
}
export async function launch(
shell: IFoundShell<Shell>,
export function launch(
foundShell: IFoundShell<Shell>,
path: string
): Promise<void> {
if (shell.shell === Shell.Urxvt) {
const commandArgs = ['-cd', path]
await spawn(shell.path, commandArgs)
): ChildProcess {
const shell = foundShell.shell
switch (shell) {
case Shell.Urxvt:
return spawn(foundShell.path, ['-cd', path])
case Shell.Konsole:
return spawn(foundShell.path, ['--workdir', path])
case Shell.Xterm:
return spawn(foundShell.path, ['-e', '/bin/bash'], { cwd: path })
case Shell.Tilix:
case Shell.Gnome:
return spawn(foundShell.path, ['--working-directory', path])
default:
return assertNever(shell, `Unknown shell: ${shell}`)
}
if (shell.shell === Shell.Konsole) {
const commandArgs = ['--workdir', path]
await spawn(shell.path, commandArgs)
}
if (shell.shell === Shell.Xterm) {
const commandArgs = ['-e', '/bin/bash']
const commandOptions = { cwd: path }
await spawn(shell.path, commandArgs, commandOptions)
}
const commandArgs = ['--working-directory', path]
await spawn(shell.path, commandArgs)
}

View file

@ -1,3 +1,5 @@
import { ChildProcess } from 'child_process'
import * as Darwin from './darwin'
import * as Win32 from './win32'
import * as Linux from './linux'
@ -71,7 +73,11 @@ export async function findShellOrDefault(shell: Shell): Promise<FoundShell> {
}
/** Launch the given shell at the path. */
export async function launchShell(shell: FoundShell, path: string) {
export async function launchShell(
shell: FoundShell,
path: string,
onError: (error: Error) => void
): Promise<void> {
// We have to manually cast the wider `Shell` type into the platform-specific
// type. This is less than ideal, but maybe the best we can do without
// platform-specific build targets.
@ -85,15 +91,46 @@ export async function launchShell(shell: FoundShell, path: string) {
)
}
let cp: ChildProcess | null = null
if (__DARWIN__) {
return Darwin.launch(shell as IFoundShell<Darwin.Shell>, path)
cp = Darwin.launch(shell as IFoundShell<Darwin.Shell>, path)
} else if (__WIN32__) {
return Win32.launch(shell as IFoundShell<Win32.Shell>, path)
cp = Win32.launch(shell as IFoundShell<Win32.Shell>, path)
} else if (__LINUX__) {
return Linux.launch(shell as IFoundShell<Linux.Shell>, path)
cp = Linux.launch(shell as IFoundShell<Linux.Shell>, path)
}
return Promise.reject(
`Platform not currently supported for launching shells: ${process.platform}`
)
if (cp != null) {
addErrorTracing(shell.shell, cp, onError)
return Promise.resolve()
} else {
return Promise.reject(
`Platform not currently supported for launching shells: ${
process.platform
}`
)
}
}
function addErrorTracing(
shell: Shell,
cp: ChildProcess,
onError: (error: Error) => void
) {
cp.stderr.on('data', chunk => {
const text = chunk instanceof Buffer ? chunk.toString() : chunk
log.debug(`[${shell}] stderr: '${text}'`)
})
cp.on('error', err => {
log.debug(`[${shell}] an error was encountered`, err)
onError(err)
})
cp.on('exit', code => {
if (code !== 0) {
log.debug(`[${shell}] exit code: ${code}`)
}
})
}

View file

@ -114,52 +114,36 @@ export async function getAvailableShells(): Promise<
return shells
}
function addErrorTracing(context: string, cp: ChildProcess) {
cp.stderr.on('data', chunk => {
const text = chunk instanceof Buffer ? chunk.toString() : chunk
log.debug(`[${context}] stderr: '${text}'`)
})
cp.on('exit', code => {
if (code !== 0) {
log.debug(`[${context}] exit code: ${code}`)
}
})
}
export async function launch(
export function launch(
foundShell: IFoundShell<Shell>,
path: string
): Promise<void> {
): ChildProcess {
const shell = foundShell.shell
if (shell === Shell.PowerShell) {
const psCommand = `"Set-Location -LiteralPath '${path}'"`
const cp = spawn(
'START',
['powershell', '-NoExit', '-Command', psCommand],
{
switch (shell) {
case Shell.PowerShell:
const psCommand = `"Set-Location -LiteralPath '${path}'"`
return spawn('START', ['powershell', '-NoExit', '-Command', psCommand], {
shell: true,
cwd: path,
}
)
addErrorTracing(`PowerShell`, cp)
} else if (shell === Shell.Hyper) {
const cp = spawn(`"${foundShell.path}"`, [`"${path}"`], {
shell: true,
cwd: path,
})
addErrorTracing(`Hyper`, cp)
} else if (shell === Shell.GitBash) {
const cp = spawn(`"${foundShell.path}"`, [`--cd="${path}"`], {
shell: true,
cwd: path,
})
addErrorTracing(`Git Bash`, cp)
} else if (shell === Shell.Cmd) {
const cp = spawn('START', ['cmd'], { shell: true, cwd: path })
addErrorTracing(`CMD`, cp)
} else {
assertNever(shell, `Unknown shell: ${shell}`)
})
case Shell.Hyper:
const hyperPath = `"${foundShell.path}"`
log.info(`launching ${shell} at path: ${hyperPath}`)
return spawn(hyperPath, [`"${path}"`], {
shell: true,
cwd: path,
})
case Shell.GitBash:
const gitBashPath = `"${foundShell.path}"`
log.info(`launching ${shell} at path: ${gitBashPath}`)
return spawn(gitBashPath, [`--cd="${path}"`], {
shell: true,
cwd: path,
})
case Shell.Cmd:
return spawn('START', ['cmd'], { shell: true, cwd: path })
default:
return assertNever(shell, `Unknown shell: ${shell}`)
}
}

View file

@ -406,7 +406,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
defaultBranch: null,
allBranches: new Array<Branch>(),
recentBranches: new Array<Branch>(),
openPullRequests: [],
openPullRequests: new Array<PullRequest>(),
currentPullRequest: null,
isLoadingPullRequests: false,
},
@ -792,14 +792,15 @@ export class AppStore extends TypedBaseStore<IAppState> {
const previouslySelectedRepository = this.selectedRepository
this.selectedRepository = repository
this.emitUpdate()
this.emitUpdate()
this.stopBackgroundFetching()
this.stopPullRequestUpdater()
if (!repository) {
if (repository == null) {
return Promise.resolve(null)
}
if (!(repository instanceof Repository)) {
return Promise.resolve(null)
}
@ -815,12 +816,37 @@ export class AppStore extends TypedBaseStore<IAppState> {
return Promise.resolve(null)
}
const gitHubRepository = repository.gitHubRepository
if (gitHubRepository) {
this._updateIssues(gitHubRepository)
}
this._refreshRepository(repository)
await this._refreshRepository(repository)
const gitHubRepository = repository.gitHubRepository
if (gitHubRepository != null) {
this._updateIssues(gitHubRepository)
this.loadPullRequests(repository, async () => {
const promiseForPRs = this.pullRequestStore.fetchPullRequestsFromCache(
gitHubRepository
)
const isLoading = this.pullRequestStore.isFetchingPullRequests(
gitHubRepository
)
const prs = await promiseForPRs
if (prs.length > 0) {
this.updateBranchesState(repository, state => {
return {
openPullRequests: prs,
isLoadingPullRequests: isLoading,
}
})
} else {
this._refreshPullRequests(repository)
}
this._updateCurrentPullRequest(repository)
this.emitUpdate()
})
}
// The selected repository could have changed while we were refreshing.
if (this.selectedRepository !== repository) {
@ -835,10 +861,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.startBackgroundFetching(repository, !previouslySelectedRepository)
this.startPullRequestUpdater(repository)
this.refreshMentionables(repository)
this.addUpstreamRemoteIfNeeded(repository)
this._refreshPullRequests(repository)
return this._repositoryWithRefreshedGitHubRepository(repository)
}
@ -2364,7 +2390,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
} finally {
this.updatePushPullFetchProgress(repository, null)
if (fetchType !== FetchType.BackgroundTask) {
if (fetchType === FetchType.UserInitiatedTask) {
this._refreshPullRequests(repository)
}
}
@ -2488,7 +2514,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
try {
const match = await findShellOrDefault(this.selectedShell)
await launchShell(match, path)
await launchShell(match, path, error => this._pushError(error))
} catch (error) {
this.emitError(error)
}
@ -3027,7 +3053,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
await this._openInBrowser(baseURL)
}
public async _refreshPullRequests(repository: Repository): Promise<void> {
private async loadPullRequests(
repository: Repository,
loader: (account: Account) => void
) {
const gitHubRepository = repository.gitHubRepository
if (gitHubRepository == null) {
@ -3043,12 +3072,18 @@ export class AppStore extends TypedBaseStore<IAppState> {
return
}
await this.pullRequestStore.refreshPullRequests(repository, account)
this.updateMenuItemLabels(repository)
await loader(account)
}
public async _refreshPullRequests(repository: Repository): Promise<void> {
return this.loadPullRequests(repository, async account => {
await this.pullRequestStore.fetchAndCachePullRequests(repository, account)
this.updateMenuItemLabels(repository)
})
}
private async onPullRequestStoreUpdated(gitHubRepository: GitHubRepository) {
const pullRequests = await this.pullRequestStore.getPullRequests(
const promiseForPRs = this.pullRequestStore.fetchPullRequestsFromCache(
gitHubRepository
)
const isLoading = this.pullRequestStore.isFetchingPullRequests(
@ -3064,15 +3099,15 @@ export class AppStore extends TypedBaseStore<IAppState> {
return
}
const prs = await promiseForPRs
this.updateBranchesState(repository, state => {
return {
openPullRequests: pullRequests,
openPullRequests: prs,
isLoadingPullRequests: isLoading,
}
})
this._updateCurrentPullRequest(repository)
this.emitUpdate()
}
@ -3083,21 +3118,20 @@ export class AppStore extends TypedBaseStore<IAppState> {
remote: IRemote
): PullRequest | null {
const upstream = branch.upstreamWithoutRemote
if (!upstream) {
if (upstream == null) {
return null
}
for (const pr of pullRequests) {
if (
pr.head.ref === upstream &&
pr.head.gitHubRepository &&
pr.head.gitHubRepository.cloneURL === remote.url
) {
return pr
}
}
const pr =
pullRequests.find(
pr =>
pr.head.ref === upstream &&
pr.head.gitHubRepository != null &&
pr.head.gitHubRepository.cloneURL === remote.url
) || null
return null
return pr
}
private _updateCurrentPullRequest(repository: Repository) {
@ -3228,9 +3262,16 @@ export class AppStore extends TypedBaseStore<IAppState> {
const gitStore = this.getGitStore(repository)
await this.withAuthenticatingUser(repository, async (repo, account) => {
await gitStore.fetchRemote(account, remoteName, false)
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

@ -62,14 +62,14 @@ export class PullRequestUpdater {
TimeoutHandles.PullRequest,
window.setTimeout(() => {
this.store.refreshPullRequests(this.repository, this.account)
this.store.fetchAndCachePullRequests(this.repository, this.account)
}, PullRequestInterval)
)
this.timeoutHandles.set(
TimeoutHandles.Status,
window.setTimeout(() => {
this.store.refreshPullRequestStatuses(githubRepo, this.account)
this.store.fetchPullRequestStatuses(githubRepo, this.account)
}, StatusInterval)
)
}
@ -112,8 +112,8 @@ export class PullRequestUpdater {
this.repository.gitHubRepository
)
await this.store.refreshPullRequestStatuses(githubRepo, this.account)
const prs = await this.store.getPullRequests(githubRepo)
await this.store.fetchPullRequestStatuses(githubRepo, this.account)
const prs = await this.store.fetchPullRequestsFromCache(githubRepo)
for (const pr of prs) {
const status = pr.status

View file

@ -25,12 +25,14 @@ import { IRemote } from '../../models/remote'
*/
export const ForkedRemotePrefix = 'github-desktop-'
const Decrement = (n: number) => n - 1
const Increment = (n: number) => n + 1
/** The store for GitHub Pull Requests. */
export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
private readonly pullRequestDatabase: PullRequestDatabase
private readonly repositoriesStore: RepositoriesStore
private activeFetchCountPerRepository = new Map<number, number>()
private readonly repositoryStore: RepositoriesStore
private readonly activeFetchCountPerRepository = new Map<number, number>()
public constructor(
db: PullRequestDatabase,
@ -39,11 +41,11 @@ export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
super()
this.pullRequestDatabase = db
this.repositoriesStore = repositoriesStore
this.repositoryStore = repositoriesStore
}
/** Loads all pull requests against the given repository. */
public async refreshPullRequests(
public async fetchAndCachePullRequests(
repository: Repository,
account: Account
): Promise<void> {
@ -51,45 +53,159 @@ export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
'Can only refresh pull requests for GitHub repositories',
repository.gitHubRepository
)
const api = API.fromAccount(account)
const apiClient = API.fromAccount(account)
this.changeActiveFetchCount(githubRepo, c => c + 1)
this.updateActiveFetchCount(githubRepo, Increment)
try {
const raw = await api.fetchPullRequests(
const apiResult = await apiClient.fetchPullRequests(
githubRepo.owner.login,
githubRepo.name,
'open'
)
await this.writePRs(raw, githubRepo)
await this.cachePullRequests(apiResult, githubRepo)
const prs = await this.getPullRequests(githubRepo)
const prs = await this.fetchPullRequestsFromCache(githubRepo)
await this.refreshStatusForPRs(prs, githubRepo, account)
await this.fetchAndCachePullRequestStatus(prs, githubRepo, account)
await this.pruneForkedRemotes(repository, prs)
this.emitUpdate(githubRepo)
} catch (error) {
log.warn(`Error refreshing pull requests for '${repository.name}'`, error)
this.emitError(error)
} finally {
this.changeActiveFetchCount(githubRepo, c => c - 1)
this.updateActiveFetchCount(githubRepo, Decrement)
}
}
/** Is the store currently fetching the list of open pull requests? */
public isFetchingPullRequests(repository: GitHubRepository): boolean {
const repoDbId = forceUnwrap(
'Cannot fetch PRs for a repository which is not in the database',
repository.dbID
)
const currentCount = this.activeFetchCountPerRepository.get(repoDbId) || 0
return currentCount > 0
}
/** Loads the status for the given pull request. */
public async fetchPullRequestStatus(
repository: GitHubRepository,
account: Account,
pullRequest: PullRequest
): Promise<void> {
await this.fetchAndCachePullRequestStatus(
[pullRequest],
repository,
account
)
}
/** Loads the status for all pull request against a given repository. */
public async fetchPullRequestStatuses(
repository: GitHubRepository,
account: Account
): Promise<void> {
const prs = await this.fetchPullRequestsFromCache(repository)
await this.fetchAndCachePullRequestStatus(prs, repository, account)
}
/** Gets the pull requests against the given repository. */
public async fetchPullRequestsFromCache(
repository: GitHubRepository
): Promise<ReadonlyArray<PullRequest>> {
const gitHubRepositoryID = repository.dbID
if (gitHubRepositoryID == null) {
return fatalError(
"Cannot get pull requests for a repository that hasn't been inserted into the database!"
)
}
const records = await this.pullRequestDatabase.pullRequests
.where('base.repoId')
.equals(gitHubRepositoryID)
.reverse()
.sortBy('number')
const result = new Array<PullRequest>()
for (const record of records) {
const repositoryDbId = record.head.repoId
let githubRepository: GitHubRepository | null = null
if (repositoryDbId != null) {
githubRepository = await this.repositoryStore.findGitHubRepositoryByID(
repositoryDbId
)
}
// We know the base repo ID can't be null since it's the repository we
// fetched the PR from in the first place.
const parentRepositoryDbId = forceUnwrap(
'A pull request cannot have a null base repo id',
record.base.repoId
)
const parentGitGubRepository: GitHubRepository | null = await this.repositoryStore.findGitHubRepositoryByID(
parentRepositoryDbId
)
const parentGitHubRepository = forceUnwrap(
'PR cannot have a null base repo',
parentGitGubRepository
)
// We can be certain the PR ID is valid since we just got it from the
// database.
const pullRequestDbId = forceUnwrap(
'PR cannot have a null ID after being retrieved from the database',
record.id
)
const pullRequestStatus = await this.findPullRequestStatus(
record.head.sha,
pullRequestDbId
)
result.push(
new PullRequest(
pullRequestDbId,
new Date(record.createdAt),
pullRequestStatus,
record.title,
record.number,
new PullRequestRef(
record.head.ref,
record.head.sha,
githubRepository
),
new PullRequestRef(
record.base.ref,
record.base.sha,
parentGitHubRepository
),
record.author
)
)
}
return result
}
private async pruneForkedRemotes(
repository: Repository,
pullRequests: ReadonlyArray<PullRequest>
) {
const remotes = await getRemotes(repository)
const forkedRemotesToDelete = this.forkedRemotesToDelete(
remotes,
pullRequests
)
const forkedRemotesToDelete = this.getRemotesToDelete(remotes, pullRequests)
await this.deleteForkedRemotes(repository, forkedRemotesToDelete)
await this.deleteRemotes(repository, forkedRemotesToDelete)
}
private forkedRemotesToDelete(
private getRemotesToDelete(
remotes: ReadonlyArray<IRemote>,
openPullRequests: ReadonlyArray<PullRequest>
): ReadonlyArray<IRemote> {
@ -97,131 +213,76 @@ export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
remote.name.startsWith(ForkedRemotePrefix)
)
const remotesOfPullRequests = new Set<string>()
openPullRequests.forEach(openPullRequest => {
const { gitHubRepository } = openPullRequest.head
openPullRequests.forEach(pr => {
const { gitHubRepository } = pr.head
if (gitHubRepository != null && gitHubRepository.cloneURL != null) {
remotesOfPullRequests.add(gitHubRepository.cloneURL)
}
})
const forkedRemotesToDelete = forkedRemotes.filter(
const result = forkedRemotes.filter(
forkedRemote => !remotesOfPullRequests.has(forkedRemote.url)
)
return forkedRemotesToDelete
return result
}
private async deleteForkedRemotes(
private async deleteRemotes(
repository: Repository,
remotes: ReadonlyArray<IRemote>
) {
for (const remote of remotes) {
await removeRemote(repository, remote.name)
}
const promises: Array<Promise<void>> = []
remotes.forEach(r => promises.push(removeRemote(repository, r.name)))
await Promise.all(promises)
}
private changeActiveFetchCount(
private updateActiveFetchCount(
repository: GitHubRepository,
fn: (count: number) => number
update: (count: number) => number
) {
const key = forceUnwrap(
const repoDbId = forceUnwrap(
'Cannot fetch PRs for a repository which is not in the database',
repository.dbID
)
const currentCount = this.activeFetchCountPerRepository.get(key) || 0
const newCount = fn(currentCount)
this.activeFetchCountPerRepository.set(key, newCount)
const currentCount = this.activeFetchCountPerRepository.get(repoDbId) || 0
const newCount = update(currentCount)
this.activeFetchCountPerRepository.set(repoDbId, newCount)
this.emitUpdate(repository)
}
/** Is the store currently fetching the list of open pull requests? */
public isFetchingPullRequests(repository: GitHubRepository): boolean {
const key = forceUnwrap(
'Cannot fetch PRs for a repository which is not in the database',
repository.dbID
)
const currentCount = this.activeFetchCountPerRepository.get(key) || 0
return currentCount > 0
}
/** Loads the status for the given pull request. */
public async refreshSinglePullRequestStatus(
repository: GitHubRepository,
account: Account,
pullRequest: PullRequest
): Promise<void> {
await this.refreshStatusForPRs([pullRequest], repository, account)
}
/** Loads the status for all pull request against a given repository. */
public async refreshPullRequestStatuses(
repository: GitHubRepository,
account: Account
): Promise<void> {
const prs = await this.getPullRequests(repository)
await this.refreshStatusForPRs(prs, repository, account)
}
private async refreshStatusForPRs(
private async fetchAndCachePullRequestStatus(
pullRequests: ReadonlyArray<PullRequest>,
repository: GitHubRepository,
account: Account
): Promise<void> {
const api = API.fromAccount(account)
const apiClient = API.fromAccount(account)
const statuses: Array<IPullRequestStatus> = []
const prs: Array<PullRequest> = []
for (const pr of pullRequests) {
const apiStatus = await api.fetchCombinedRefStatus(
const combinedRefStatus = await apiClient.fetchCombinedRefStatus(
repository.owner.login,
repository.name,
pr.head.sha
)
const combinedRefStatuses = apiStatus.statuses.map(x => {
return {
id: x.id,
state: x.state,
}
})
const status = new PullRequestStatus(
pr.number,
apiStatus.state,
apiStatus.total_count,
pr.head.sha,
combinedRefStatuses
)
statuses.push({
pullRequestId: pr.id,
state: apiStatus.state,
totalCount: apiStatus.total_count,
state: combinedRefStatus.state,
totalCount: combinedRefStatus.total_count,
sha: pr.head.sha,
statuses: apiStatus.statuses,
statuses: combinedRefStatus.statuses,
})
prs.push(
new PullRequest(
pr.id,
pr.created,
status,
pr.title,
pr.number,
pr.head,
pr.base,
pr.author
)
)
}
await this.writePRStatus(statuses)
await this.cachePullRequestStatuses(statuses)
this.emitUpdate(repository)
}
private async getPRStatusById(
private async findPullRequestStatus(
sha: string,
pullRequestId: number
): Promise<PullRequestStatus | null> {
@ -251,146 +312,115 @@ export class PullRequestStore extends TypedBaseStore<GitHubRepository> {
)
}
private async writePRs(
pullRequests: ReadonlyArray<IAPIPullRequest>,
private async cachePullRequests(
pullRequestsFromAPI: ReadonlyArray<IAPIPullRequest>,
repository: GitHubRepository
): Promise<void> {
const repoId = repository.dbID
const repoDbId = repository.dbID
if (!repoId) {
fatalError(
if (repoDbId == null) {
return fatalError(
"Cannot store pull requests for a repository that hasn't been inserted into the database!"
)
return
}
const table = this.pullRequestDatabase.pullRequests
const prsToInsert = new Array<IPullRequest>()
let githubRepo: GitHubRepository | null = null
const insertablePRs = new Array<IPullRequest>()
for (const pr of pullRequests) {
let headRepo: GitHubRepository | null = null
if (pr.head.repo) {
headRepo = await this.repositoriesStore.findOrPutGitHubRepository(
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
)
}
// We know the base repo isn't null since that's where we got the PR from
// in the first place.
const baseRepo = await this.repositoriesStore.findOrPutGitHubRepository(
repository.endpoint,
forceUnwrap('PR cannot have a null base repo', pr.base.repo)
if (githubRepo == null) {
return fatalError(
"The PR doesn't seem to be associated with a GitHub repository"
)
}
const githubRepoDbId = forceUnwrap(
'PR cannot have non-existent repo',
githubRepo.dbID
)
insertablePRs.push({
// We know the base repo isn't null since that's where we got the PR from
// in the first place.
const parentRepo = forceUnwrap(
'PR cannot have a null base repo',
pr.base.repo
)
const parentGitHubRepo = await this.repositoryStore.upsertGitHubRepository(
repository.endpoint,
parentRepo
)
const parentGitHubRepoDbId = forceUnwrap(
'PR cannot have a null parent database id',
parentGitHubRepo.dbID
)
prsToInsert.push({
number: pr.number,
title: pr.title,
createdAt: pr.created_at,
head: {
ref: pr.head.ref,
sha: pr.head.sha,
repoId: headRepo ? headRepo.dbID! : null,
repoId: githubRepoDbId,
},
base: {
ref: pr.base.ref,
sha: pr.base.sha,
repoId: forceUnwrap('PR cannot have a null base repo', baseRepo.dbID),
repoId: parentGitHubRepoDbId,
},
author: pr.user.login,
})
}
await this.pullRequestDatabase.transaction('rw', table, async () => {
await table.clear()
return await table.bulkAdd(insertablePRs)
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
await table
.where('base.repoId')
.equals(repoDbId)
.delete()
await table.bulkAdd(prsToInsert)
})
}
private async writePRStatus(
private async cachePullRequestStatuses(
statuses: Array<IPullRequestStatus>
): Promise<void> {
const table = this.pullRequestDatabase.pullRequestStatus
await this.pullRequestDatabase.transaction('rw', table, async () => {
for (const status of statuses) {
const existing = await table
const record = await table
.where('[sha+pullRequestId]')
.equals([status.sha, status.pullRequestId])
.first()
if (existing) {
await table.put({ id: existing.id, ...status })
} else {
if (record == null) {
await table.add(status)
} else {
await table.put({ id: record.id, ...status })
}
}
})
}
/** Gets the pull requests against the given repository. */
public async getPullRequests(
repository: GitHubRepository
): Promise<ReadonlyArray<PullRequest>> {
const gitHubRepositoryID = repository.dbID
if (!gitHubRepositoryID) {
return fatalError(
"Cannot get pull requests for a repository that hasn't been inserted into the database!"
)
}
const raw = await this.pullRequestDatabase.pullRequests
.where('base.repoId')
.equals(gitHubRepositoryID)
.reverse()
.sortBy('number')
const pullRequests = new Array<PullRequest>()
for (const pr of raw) {
const headId = pr.head.repoId
let head: GitHubRepository | null = null
if (headId) {
head = await this.repositoriesStore.findGitHubRepositoryByID(headId)
}
// We know the base repo ID can't be null since it's the repository we
// fetched the PR from in the first place.
const baseId = forceUnwrap(
'PR cannot have a null base repo id',
pr.base.repoId
)
const base = forceUnwrap(
'PR cannot have a null base repo',
await this.repositoriesStore.findGitHubRepositoryByID(baseId)
)
// We can be certain the PR ID is valid since we just got it from the
// database.
const prID = forceUnwrap(
'PR cannot have a null ID after being retrieved from the database',
pr.id
)
const pullRequestStatus = await this.getPRStatusById(pr.head.sha, prID)
const pullRequest = new PullRequest(
prID,
new Date(pr.createdAt),
pullRequestStatus,
pr.title,
pr.number,
new PullRequestRef(pr.head.ref, pr.head.sha, head),
new PullRequestRef(pr.base.ref, pr.base.sha, base),
pr.author
)
pullRequests.push(pullRequest)
}
return pullRequests
}
}

View file

@ -21,7 +21,7 @@ export class RepositoriesStore extends BaseStore {
}
/** Find the matching GitHub repository or add it if it doesn't exist. */
public async findOrPutGitHubRepository(
public async upsertGitHubRepository(
endpoint: string,
apiRepository: IAPIRepository
): Promise<GitHubRepository> {
@ -36,7 +36,8 @@ export class RepositoriesStore extends BaseStore {
.equals(apiRepository.clone_url)
.limit(1)
.first()
if (!gitHubRepository) {
if (gitHubRepository == null) {
return this.putGitHubRepository(endpoint, apiRepository)
} else {
return this.buildGitHubRepository(gitHubRepository)
@ -49,7 +50,8 @@ export class RepositoriesStore extends BaseStore {
dbRepo: IDatabaseGitHubRepository
): Promise<GitHubRepository> {
const owner = await this.db.owners.get(dbRepo.ownerID)
if (!owner) {
if (owner == null) {
throw new Error(`Couldn't find the owner for ${dbRepo.name}`)
}
@ -128,26 +130,27 @@ export class RepositoriesStore extends BaseStore {
this.db.owners,
async () => {
const repos = await this.db.repositories.toArray()
const existing = repos.find(r => r.path === path)
let id: number
const record = repos.find(r => r.path === path)
let recordId: number
let gitHubRepo: GitHubRepository | null = null
if (existing) {
id = existing.id!
if (existing.gitHubRepositoryID) {
if (record != null) {
recordId = record.id!
if (record.gitHubRepositoryID != null) {
gitHubRepo = await this.findGitHubRepositoryByID(
existing.gitHubRepositoryID
record.gitHubRepositoryID
)
}
} else {
id = await this.db.repositories.add({
recordId = await this.db.repositories.add({
path,
gitHubRepositoryID: null,
missing: false,
})
}
return new Repository(path, id, gitHubRepo, false)
return new Repository(path, recordId, gitHubRepo, false)
}
)

View file

@ -110,11 +110,16 @@ async function formatGitIgnoreContents(
return
}
const linesEndInCRLF = autocrlf === 'true'
if (linesEndInCRLF) {
if (autocrlf == null) {
// fallback to Git default behaviour
resolve(`${text}\n`)
} else {
resolve(`${text}\r\n`)
const linesEndInCRLF = autocrlf === 'true'
if (linesEndInCRLF) {
resolve(`${text}\n`)
} else {
resolve(`${text}\r\n`)
}
}
})
}

View file

@ -52,6 +52,7 @@ export class AppWindow {
// Enable, among other things, the ResizeObserver
experimentalFeatures: true,
},
acceptFirstMouse: true,
}
if (__DARWIN__) {

View file

@ -28,7 +28,7 @@ function getFallbackAvatarUrlForAuthor(
) {
return `https://avatars.githubusercontent.com/u/e?email=${encodeURIComponent(
author.email
)}&s=40`
)}&s=60`
}
return generateGravatarUrl(author.email)

View file

@ -1512,7 +1512,7 @@ export class App extends React.Component<IAppProps, IAppState> {
private renderBranchToolbarButton(): JSX.Element | null {
const selection = this.state.selectedState
if (!selection || selection.type !== SelectionType.Repository) {
if (selection == null || selection.type !== SelectionType.Repository) {
return null
}

View file

@ -5,7 +5,6 @@ import { GitHubUserStore } from '../../lib/stores'
import { GitHubRepository } from '../../models/github-repository'
import { Account } from '../../models/account'
import { IGitHubUser } from '../../lib/databases/index'
import { validLoginExpression } from '../../lib/api'
/** An autocompletion hit for a user. */
export interface IUserHit {
@ -105,15 +104,6 @@ export class UserAutocompletionProvider
return null
}
// Since we might be looking up stuff in the API it's
// important we sanitize this input or someone could lead with
// ../ and then start GETing random resources in the API.
// Not that they should be able to do any harm with just GET
// but still, it ain't cool
if (!validLoginExpression.test(login)) {
return null
}
const user = await this.gitHubUserStore.getByLogin(this.account, login)
if (!user) {

View file

@ -143,7 +143,10 @@ export class BranchList extends React.Component<
this.state = createState(props)
}
private renderItem = (item: IBranchListItem) => {
private renderItem = (
item: IBranchListItem,
matches: ReadonlyArray<number>
) => {
const branch = item.branch
const commit = branch.tip
const currentBranchName = this.props.currentBranch
@ -154,7 +157,7 @@ export class BranchList extends React.Component<
name={branch.name}
isCurrentBranch={branch.name === currentBranchName}
lastCommitDate={commit ? commit.author.date : null}
filterText={this.props.filterText}
matches={matches}
/>
)
}

View file

@ -2,6 +2,7 @@ import * as React from 'react'
import * as moment from 'moment'
import { Octicon, OcticonSymbol } from '../octicons'
import { HighlightText } from '../lib/highlight-text'
interface IBranchProps {
readonly name: string
@ -10,34 +11,12 @@ interface IBranchProps {
/** The date may be null if we haven't loaded the tip commit yet. */
readonly lastCommitDate: Date | null
/** The current filter text to render */
readonly filterText: string
/** The characters in the branch name to highlight */
readonly matches: ReadonlyArray<number>
}
/** The branch component. */
export class BranchListItem extends React.Component<IBranchProps, {}> {
private renderHighlightedName(name: string) {
const filterText = this.props.filterText
const matchStart = name.indexOf(filterText)
const matchLength = filterText.length
if (matchStart === -1) {
return (
<div className="name" title={name}>
{name}
</div>
)
}
return (
<div className="name" title={name}>
{name.substr(0, matchStart)}
<mark>{name.substr(matchStart, matchLength)}</mark>
{name.substr(matchStart + matchLength)}
</div>
)
}
public render() {
const lastCommitDate = this.props.lastCommitDate
const isCurrentBranch = this.props.isCurrentBranch
@ -51,7 +30,9 @@ export class BranchListItem extends React.Component<IBranchProps, {}> {
return (
<div className="branches-list-item">
<Octicon className="icon" symbol={icon} />
{this.renderHighlightedName(name)}
<div className="name" title={name}>
<HighlightText text={name} highlight={this.props.matches} />
</div>
<div className="description" title={infoTitle}>
{date}
</div>

View file

@ -4,6 +4,7 @@ import * as classNames from 'classnames'
import { Octicon, OcticonSymbol } from '../octicons'
import { CIStatus } from './ci-status'
import { PullRequestStatus } from '../../models/pull-request'
import { HighlightText } from '../lib/highlight-text'
export interface IPullRequestListItemProps {
/** The title. */
@ -29,6 +30,9 @@ export interface IPullRequestListItemProps {
* inside the list item.
*/
readonly loading?: boolean
/** The characters in the PR title to highlight */
readonly matches: ReadonlyArray<number>
}
/** Pull requests as rendered in the Pull Requests list. */
@ -57,7 +61,7 @@ export class PullRequestListItem extends React.Component<
<Octicon className="icon" symbol={OcticonSymbol.gitPullRequest} />
<div className="info">
<div className="title" title={title}>
{title}
<HighlightText text={title || ''} highlight={this.props.matches} />
</div>
<div className="subtitle" title={subtitle}>
{subtitle}

View file

@ -115,7 +115,10 @@ export class PullRequestList extends React.Component<
)
}
private renderPullRequest = (item: IPullRequestListItem) => {
private renderPullRequest = (
item: IPullRequestListItem,
matches: ReadonlyArray<number>
) => {
const pr = item.pullRequest
const refStatuses = pr.status != null ? pr.status.statuses : []
const status =
@ -136,6 +139,7 @@ export class PullRequestList extends React.Component<
created={pr.created}
author={pr.author}
status={status}
matches={matches}
/>
)
}

View file

@ -22,6 +22,7 @@ const prLoadingItemProps: IPullRequestListItemProps = {
created: new Date(0),
number: 0,
title: '',
matches: [],
status: {
sha: '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
totalCount: 1,

View file

@ -15,6 +15,7 @@ import {
groupRepositories,
YourRepositoriesIdentifier,
} from './group-repositories'
import { HighlightText } from '../lib/highlight-text'
interface ICloneGithubRepositoryProps {
/** The account to clone from. */
@ -36,6 +37,9 @@ interface ICloneGithubRepositoryProps {
/** Called when a repository is selected. */
readonly onGitHubRepositorySelected: (url: string) => void
/** Should the component clear the filter text on render? */
readonly shouldClearFilter: boolean
}
interface ICloneGithubRepositoryState {
@ -114,6 +118,12 @@ export class CloneGithubRepository extends React.Component<
}
public componentWillReceiveProps(nextProps: ICloneGithubRepositoryProps) {
if (nextProps.shouldClearFilter) {
this.setState({
filterText: '',
})
}
if (nextProps.account.id !== this.props.account.id) {
this.loadRepositories(nextProps.account)
}
@ -205,12 +215,15 @@ export class CloneGithubRepository extends React.Component<
)
}
private renderItem = (item: IClonableRepositoryListItem) => {
private renderItem = (
item: IClonableRepositoryListItem,
matches: ReadonlyArray<number>
) => {
return (
<div className="clone-repository-list-item">
<Octicon className="icon" symbol={item.icon} />
<div className="name" title={name}>
{item.text}
<div className="name" title={item.text}>
<HighlightText text={item.text} highlight={matches} />
</div>
</div>
)

View file

@ -61,6 +61,9 @@ interface ICloneRepositoryState {
* The repository identifier that was last parsed from the user-entered URL.
*/
readonly lastParsedIdentifier: IRepositoryIdentifier | null
/** Should the component clear the filter text on render? */
readonly shouldClearFilter: boolean
}
/** The component for cloning a repository. */
@ -77,9 +80,16 @@ export class CloneRepository extends React.Component<
loading: false,
error: null,
lastParsedIdentifier: null,
shouldClearFilter: false,
}
}
public componentWillReceiveProps(nextProps: ICloneRepositoryProps) {
this.setState({
shouldClearFilter: this.props.selectedTab !== nextProps.selectedTab,
})
}
public componentDidMount() {
const initialURL = this.props.initialURL
if (initialURL) {
@ -176,6 +186,7 @@ export class CloneRepository extends React.Component<
onGitHubRepositorySelected={this.updateUrl}
onChooseDirectory={this.onChooseDirectory}
onDismissed={this.props.onDismissed}
shouldClearFilter={this.state.shouldClearFilter}
/>
)
}

View file

@ -5,7 +5,7 @@ import { CommitListItem } from './commit-list-item'
import { List } from '../lib/list'
import { IGitHubUser } from '../../lib/databases'
const RowHeight = 48
const RowHeight = 50
interface ICommitListProps {
readonly onCommitChanged: (commit: Commit) => void

View file

@ -293,7 +293,10 @@ export class CommitSummary extends React.Component<
/>
<ul className="commit-summary-meta">
<li className="commit-summary-meta-item" aria-label="Author">
<li
className="commit-summary-meta-item without-truncation"
aria-label="Author"
>
<AvatarStack users={this.state.avatarUsers} />
<CommitAttribution
gitHubRepository={this.props.repository.gitHubRepository}

View file

@ -4,7 +4,7 @@ import * as URL from 'url'
import * as classNames from 'classnames'
import { UserAutocompletionProvider, IUserHit } from '../autocompletion'
import { Editor, Doc, Position } from 'codemirror'
import { validLoginExpression, getDotComAPIEndpoint } from '../../lib/api'
import { getDotComAPIEndpoint } from '../../lib/api'
import { compare } from '../../lib/compare'
import { arrayEquals } from '../../lib/equality'
import { OcticonSymbol } from '../octicons'
@ -629,9 +629,9 @@ export class AuthorInput extends React.Component<IAuthorInputProps, {}> {
needle
)
const exactMatch =
hits.length === 1 &&
hits[0].username.toLowerCase() === needle.toLowerCase()
const exactMatch = hits.some(
hit => hit.username.toLowerCase() === needle.toLowerCase()
)
const existingUsernames = new Set(this.authors.map(x => x.username))
@ -646,7 +646,7 @@ export class AuthorInput extends React.Component<IAuthorInputProps, {}> {
hint: this.applyCompletion,
}))
if (!exactMatch && needle.length > 0 && validLoginExpression.test(needle)) {
if (!exactMatch && needle.length > 0) {
list.push({
text: `@${needle}`,
username: needle,

View file

@ -5,6 +5,8 @@ import { List, SelectionSource as ListSelectionSource } from '../lib/list'
import { TextBox } from '../lib/text-box'
import { Row } from '../lib/row'
import { match, IMatch } from '../../lib/fuzzy-find'
/** An item in the filter list. */
export interface IFilterListItem {
/** The text which represents the item. This is used for filtering. */
@ -31,6 +33,8 @@ interface IFlattenedGroup {
interface IFlattenedItem<T extends IFilterListItem> {
readonly kind: 'item'
readonly item: T
/** Array of indexes in `item.text` that should be highlighted */
readonly matches: ReadonlyArray<number>
}
/**
@ -55,7 +59,10 @@ interface IFilterListProps<T extends IFilterListItem> {
readonly selectedItem: T | null
/** Called to render each visible item. */
readonly renderItem: (item: T) => JSX.Element | null
readonly renderItem: (
item: T,
matches: ReadonlyArray<number>
) => JSX.Element | null
/** Called to render header for the group with the given identifier. */
readonly renderGroupHeader?: (identifier: string) => JSX.Element | null
@ -213,7 +220,7 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
private renderRow = (index: number) => {
const row = this.state.rows[index]
if (row.kind === 'item') {
return this.props.renderItem(row.item)
return this.props.renderItem(row.item, row.matches)
} else if (this.props.renderGroupHeader) {
return this.props.renderGroupHeader(row.identifier)
} else {
@ -384,9 +391,9 @@ function createStateUpdate<T extends IFilterListItem>(
const filter = (props.filterText || '').toLowerCase()
for (const group of props.groups) {
const items = group.items.filter(i => {
return i.text.toLowerCase().includes(filter)
})
const items: ReadonlyArray<IMatch<T>> = filter
? match(filter, group.items, 'text')
: group.items.map(item => ({ score: 1, matches: [], item }))
if (!items.length) {
continue
@ -396,8 +403,8 @@ function createStateUpdate<T extends IFilterListItem>(
flattenedRows.push({ kind: 'group', identifier: group.identifier })
}
for (const item of items) {
flattenedRows.push({ kind: 'item', item })
for (const { item, matches } of items) {
flattenedRows.push({ kind: 'item', item, matches })
}
}

View file

@ -0,0 +1,43 @@
import * as React from 'react'
// broken for SFCs: https://github.com/Microsoft/tslint-microsoft-contrib/issues/339
/* tslint:disable react-unused-props-and-state */
interface IHighlightTextProps {
/** The text to render */
readonly text: string
/** The characters in `text` to highlight */
readonly highlight: ReadonlyArray<number>
}
export const HighlightText: React.SFC<IHighlightTextProps> = ({
text,
highlight,
}) => (
<span>
{
text
.split('')
.map((ch, i): [string, boolean] => [ch, highlight.includes(i)])
.concat([['', false]])
.reduce(
(state, [ch, matched], i, arr) => {
if (matched === state.matched && i < arr.length - 1) {
state.str += ch
} else {
const Component = state.matched ? 'mark' : 'span'
state.result.push(<Component key={i}>{state.str}</Component>)
state.str = ch
state.matched = matched
}
return state
},
{
matched: false,
str: '',
result: new Array<React.ReactElement<any>>(),
}
).result
}
</span>
)

View file

@ -60,7 +60,10 @@ export class RepositoriesList extends React.Component<
IRepositoriesListProps,
{}
> {
private renderItem = (item: IRepositoryListItem) => {
private renderItem = (
item: IRepositoryListItem,
matches: ReadonlyArray<number>
) => {
const repository = item.repository
return (
<RepositoryListItem
@ -73,7 +76,7 @@ export class RepositoriesList extends React.Component<
onOpenInExternalEditor={this.props.onOpenInExternalEditor}
externalEditorLabel={this.props.externalEditorLabel}
shellLabel={this.props.shellLabel}
filterText={this.props.filterText}
matches={matches}
/>
)
}

View file

@ -3,6 +3,7 @@ import { Repository } from '../../models/repository'
import { Octicon, iconForRepository } from '../octicons'
import { showContextualMenu, IMenuItem } from '../main-process-proxy'
import { Repositoryish } from './group-repositories'
import { HighlightText } from '../lib/highlight-text'
const defaultEditorLabel = __DARWIN__
? 'Open in External Editor'
@ -32,8 +33,8 @@ interface IRepositoryListItemProps {
/** The label for the user's preferred shell. */
readonly shellLabel: string
/** The text entered by the user to filter their repository list */
readonly filterText: string
/** The characters in the repository name to highlight */
readonly matches: ReadonlyArray<number>
}
/** A repository item. */
@ -41,24 +42,6 @@ export class RepositoryListItem extends React.Component<
IRepositoryListItemProps,
{}
> {
private renderHighlightedName(name: string) {
const filterText = this.props.filterText
const matchStart = name.indexOf(filterText)
const matchLength = filterText.length
if (matchStart === -1) {
return <span>{name}</span>
}
return (
<span>
{name.substr(0, matchStart)}
<mark>{name.substr(matchStart, matchLength)}</mark>
{name.substr(matchStart + matchLength)}
</span>
)
}
public render() {
const repository = this.props.repository
const path = repository.path
@ -83,7 +66,10 @@ export class RepositoryListItem extends React.Component<
<div className="name">
{prefix ? <span className="prefix">{prefix}</span> : null}
{this.renderHighlightedName(repository.name)}
<HighlightText
text={repository.name}
highlight={this.props.matches}
/>
</div>
</div>
)
@ -96,7 +82,7 @@ export class RepositoryListItem extends React.Component<
) {
return (
nextProps.repository.id !== this.props.repository.id ||
nextProps.filterText !== this.props.filterText
nextProps.matches !== this.props.matches
)
} else {
return true

View file

@ -29,7 +29,8 @@ html {
// We never want the window to be scrollable, everything has to fit
// or be placed into a scrollable container.
html, body {
html,
body {
height: 100%;
width: 100%;
margin: 0;
@ -58,7 +59,9 @@ body {
}
:not(input):not(textarea) {
&, &::after, &::before {
&,
&::after,
&::before {
-webkit-user-select: none;
user-select: none;
cursor: default;
@ -81,9 +84,14 @@ img {
// margin for easier control within type scales as it avoids margin collapsing.
//
// From: https://github.com/twbs/bootstrap/blob/a0f10e6dcb9aef2d8e36e57f3c8b1b06034a8877/scss/_reboot.scss
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
margin-bottom: .5rem;
margin-bottom: 0.5rem;
}
// Regardless of platform behavior we never want buttons to be

View file

@ -1,5 +1,5 @@
@import "mixins/platform";
@import "mixins/ellipsis";
@import "mixins/octicon-status";
@import "mixins/textboxish";
@import "mixins/checkboard-background";
@import 'mixins/platform';
@import 'mixins/ellipsis';
@import 'mixins/octicon-status';
@import 'mixins/textboxish';
@import 'mixins/checkboard-background';

View file

@ -1,61 +1,61 @@
@import "ui/app";
@import "ui/app-menu";
@import "ui/scroll";
@import "ui/window/title-bar";
@import "ui/window/app-menu-bar";
@import "ui/window/focus";
@import "ui/window/zoom-info";
@import "ui/window/toast-notification";
@import "ui/file-list";
@import "ui/octicons";
@import "ui/list";
@import "ui/repository-list";
@import "ui/changes";
@import "ui/cloning-repository-view";
@import "ui/diff";
@import "ui/history";
@import "ui/repository";
@import "ui/resizable";
@import "ui/toolbar/toolbar";
@import "ui/toolbar/button";
@import "ui/toolbar/dropdown";
@import "ui/tab-bar";
@import "ui/panel";
@import "ui/popup";
@import "ui/progress";
@import "ui/branches";
@import "ui/emoji";
@import "ui/ui-view";
@import "ui/autocompletion";
@import "ui/welcome";
@import "ui/foldout";
@import "ui/preferences";
@import "ui/path-text";
@import "ui/configure-git-user";
@import "ui/form";
@import "ui/text-box";
@import "ui/button";
@import "ui/select";
@import "ui/row";
@import "ui/text-area";
@import "ui/checkbox";
@import "ui/errors";
@import "ui/dialog";
@import "ui/add-repository";
@import "ui/discard-changes";
@import "ui/filter-list";
@import "ui/missing-repository-view";
@import "ui/horizontal-rule";
@import "ui/about";
@import "ui/avatar";
@import "ui/call-to-action";
@import "ui/acknowledgements";
@import "ui/update-notification";
@import "ui/vertical-segmented-control";
@import "ui/blank-slate";
@import "ui/terms-and-conditions";
@import "ui/ref";
@import "ui/monospaced";
@import 'ui/app';
@import 'ui/app-menu';
@import 'ui/scroll';
@import 'ui/window/title-bar';
@import 'ui/window/app-menu-bar';
@import 'ui/window/focus';
@import 'ui/window/zoom-info';
@import 'ui/window/toast-notification';
@import 'ui/file-list';
@import 'ui/octicons';
@import 'ui/list';
@import 'ui/repository-list';
@import 'ui/changes';
@import 'ui/cloning-repository-view';
@import 'ui/diff';
@import 'ui/history';
@import 'ui/repository';
@import 'ui/resizable';
@import 'ui/toolbar/toolbar';
@import 'ui/toolbar/button';
@import 'ui/toolbar/dropdown';
@import 'ui/tab-bar';
@import 'ui/panel';
@import 'ui/popup';
@import 'ui/progress';
@import 'ui/branches';
@import 'ui/emoji';
@import 'ui/ui-view';
@import 'ui/autocompletion';
@import 'ui/welcome';
@import 'ui/foldout';
@import 'ui/preferences';
@import 'ui/path-text';
@import 'ui/configure-git-user';
@import 'ui/form';
@import 'ui/text-box';
@import 'ui/button';
@import 'ui/select';
@import 'ui/row';
@import 'ui/text-area';
@import 'ui/checkbox';
@import 'ui/errors';
@import 'ui/dialog';
@import 'ui/add-repository';
@import 'ui/discard-changes';
@import 'ui/filter-list';
@import 'ui/missing-repository-view';
@import 'ui/horizontal-rule';
@import 'ui/about';
@import 'ui/avatar';
@import 'ui/call-to-action';
@import 'ui/acknowledgements';
@import 'ui/update-notification';
@import 'ui/vertical-segmented-control';
@import 'ui/blank-slate';
@import 'ui/terms-and-conditions';
@import 'ui/ref';
@import 'ui/monospaced';
@import 'ui/initialize-lfs';
@import 'ui/ci-status';
@import 'ui/pull-request-badge';

View file

@ -36,8 +36,11 @@
// Typography
//
// Font, line-height, and color for body text, headings, and more.
--font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif;
--font-family-monospace: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-family-sans-serif: -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', Arial, sans-serif;
--font-family-monospace: Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
/**
* Font weight to use for semibold text

View file

@ -1,4 +1,4 @@
@import "~react-virtualized/styles.css";
@import "~codemirror/lib/codemirror.css";
@import "~codemirror/theme/solarized.css";
@import "~codemirror/addon/scroll/simplescrollbars.css"
@import '~react-virtualized/styles.css';
@import '~codemirror/lib/codemirror.css';
@import '~codemirror/theme/solarized.css';
@import '~codemirror/addon/scroll/simplescrollbars.css';

View file

@ -1,9 +1,9 @@
@import "vendor";
@import 'vendor';
@import "variables";
@import "mixins";
@import 'variables';
@import 'mixins';
@import "globals";
@import "type";
@import 'globals';
@import 'type';
@import "ui"
@import 'ui';

View file

@ -1,12 +1,26 @@
@mixin octicon-status {
.status {
&-new { fill: var(--color-new); }
&-copied { fill: var(--color-new); }
&-modified { fill: var(--color-modified); }
&-renamed { fill: var(--color-renamed); }
&-deleted { fill: var(--color-deleted); }
&-conflicted { fill: var(--color-conflicted); }
&-new {
fill: var(--color-new);
}
&-copied {
fill: var(--color-new);
}
&-modified {
fill: var(--color-modified);
}
&-renamed {
fill: var(--color-renamed);
}
&-deleted {
fill: var(--color-deleted);
}
&-conflicted {
fill: var(--color-conflicted);
}
}
.line-endings { fill: var(--color-conflicted); }
.line-endings {
fill: var(--color-conflicted);
}
}

View file

@ -1,4 +1,4 @@
@import "./platform";
@import './platform';
// Essentially all the styles needed to transform a text box
// input element into something that doesn't look horrendous.

View file

@ -1,11 +1,11 @@
dialog#about {
.dialog-content {
.row-component {
justify-content: center;
}
p, h2 {
p,
h2 {
text-align: center;
}
@ -40,7 +40,6 @@ dialog#about {
}
.update-status {
.octicon.spin {
// Make sure the spinner is aligned with the text.
align-self: center;

View file

@ -1,4 +1,4 @@
@import "../mixins";
@import '../mixins';
#add-repository {
width: 350px;
@ -52,8 +52,8 @@
padding: 0;
.row-component:not(:last-child) {
margin: 0;
}
margin: 0;
}
}
&.clone-generic-repository-content {

View file

@ -1,4 +1,4 @@
@import "../mixins";
@import '../mixins';
#app-menu-foldout {
height: 100%;
@ -37,7 +37,6 @@
}
&:not(:last-child) {
// We want list items in previous menus to behave as if they have focus
// even though they don't, ie we want the selected+focus state
// to be in effect for all parent selected menu items as well as
@ -68,7 +67,9 @@
width: 100%;
min-width: 0;
&.disabled { opacity: 0.3; }
&.disabled {
opacity: 0.3;
}
.label {
flex-grow: 1;

View file

@ -1,7 +1,6 @@
@import "../mixins";
@import '../mixins';
#desktop-app {
// This is just a dummy wrapper needed because react doesn't like
// being installed into <body>, see https://github.com/facebook/react/issues/3207
&-container {
@ -23,6 +22,6 @@
&-contents {
display: flex;
flex-direction: column;
flex-grow: 1
flex-grow: 1;
}
}

View file

@ -26,7 +26,9 @@
box-shadow: 0 0 0 1px var(--secondary-button-focus-shadow-color);
}
&:disabled { opacity: 0.6; }
&:disabled {
opacity: 0.6;
}
.octicon {
vertical-align: middle;

View file

@ -1,6 +1,6 @@
@import "changes/commit-message";
@import "changes/changes-list";
@import "changes/sidebar";
@import "changes/undo-commit";
@import "changes/changes-view";
@import "changes/no-changes";
@import 'changes/commit-message';
@import 'changes/changes-list';
@import 'changes/sidebar';
@import 'changes/undo-commit';
@import 'changes/changes-view';
@import 'changes/no-changes';

View file

@ -4,7 +4,6 @@
align-items: center;
input {
margin: 0;
// Only add a right margin if there's a label attached to it

View file

@ -1,7 +1,6 @@
@import "../mixins";
@import '../mixins';
#cloning-repository-view {
/* The view's position in relation to its parent, ie full
* width, vertically centered... */
justify-content: center;

View file

@ -1,14 +1,13 @@
@import "../mixins";
@import "dialogs/merge";
@import "dialogs/publish-repository";
@import "dialogs/repository-settings";
@import '../mixins';
@import 'dialogs/merge';
@import 'dialogs/publish-repository';
@import 'dialogs/repository-settings';
// The styles herein attempt to follow a flow where margins are only applied
// to the bottom of elements (with the exception of the last child). This to
// allow easy layout using generalized components and elements such as <Row>
// and <p>.
dialog {
// These are custom version of the alert and stop octicons that have been
// scaled and adjusted to render crisply at 24px.
//
@ -38,7 +37,6 @@ dialog {
// The modal class here is the transition name for the react css transition
// group which allows us to apply an animation when the popup appears.
&.modal {
&-enter {
opacity: 1;
transform: scale(0.75);
@ -74,7 +72,7 @@ dialog {
opacity: 0.01;
transform: scale(0.25);
transition: opacity 100ms ease-in,
transform 100ms var(--easing-ease-in-back);
transform 100ms var(--easing-ease-in-back);
&::backdrop {
opacity: 0.01;
@ -143,7 +141,9 @@ dialog {
// Let the button deal with all mouse events.
// Without this the octicon resets the cursor when
// hovering over the <path>.
.octicon { pointer-events: none; }
.octicon {
pointer-events: none;
}
&:hover {
color: var(--text-color);
@ -155,7 +155,8 @@ dialog {
}
}
&.warning, &.error {
&.warning,
&.error {
.dialog-content {
position: relative;
margin-left: var(--spacing-double);
@ -167,7 +168,10 @@ dialog {
// Ensure that the dialog contents always have room for the icon,
// account for two double spacers at top and bottom plus the 5px
// icon offset (margin-top) and the size of the icon itself.
min-height: calc(var(--spacing-double) * 2 + var(--spacing-half) + var(--dialog-icon-size));
min-height: calc(
var(--spacing-double) * 2 + var(--spacing-half) +
var(--dialog-icon-size)
);
// We're creating an opaque 24 by 24px div with the background color
// that we want the icon to appear in and then apply the icon path
@ -220,7 +224,8 @@ dialog {
}
}
h2, h3 {
h2,
h3 {
font-weight: var(--font-weight-semibold);
margin-top: 0;
margin-bottom: var(--spacing);
@ -230,15 +235,22 @@ dialog {
}
}
h2 { font-size: var(--font-size-md); }
h3 { font-size: var(--font-size); }
h2 {
font-size: var(--font-size-md);
}
h3 {
font-size: var(--font-size);
}
ul, ol {
ul,
ol {
margin-top: 0;
padding-left: var(--spacing-double);
list-style-position: outside;
&:last-child { margin-bottom: 0; }
&:last-child {
margin-bottom: 0;
}
li {
margin-bottom: var(--spacing);
@ -248,7 +260,6 @@ dialog {
}
.dialog-footer {
display: flex;
flex-direction: column;
@ -316,12 +327,24 @@ dialog {
}
}
&#preferences { width: 450px; }
&#about { width: 450px; }
&#create-repository { width: 400px; }
&#create-branch { width: 400px; }
&#push-branch-commits { width: 450px; }
&#publish-branch { width: 450px; }
&#preferences {
width: 450px;
}
&#about {
width: 450px;
}
&#create-repository {
width: 400px;
}
&#create-branch {
width: 400px;
}
&#push-branch-commits {
width: 450px;
}
&#publish-branch {
width: 450px;
}
&#generic-git-auth {
width: 450px;
}
@ -343,13 +366,16 @@ dialog {
text-align: center;
}
.forgot-password-row, .what-is-this-row {
.forgot-password-row,
.what-is-this-row {
font-size: var(--font-size-sm);
justify-content: flex-end;
}
}
&#add-existing-repository { width: 400px; }
&#add-existing-repository {
width: 400px;
}
&#initialize-lfs {
width: 400px;

View file

@ -1,7 +1,6 @@
@import "../mixins";
@import '../mixins';
.file-list {
// this value affects virtualized lists, and without it
// you'll see react-virtualized just skip rendering
// as the available vertical space is computed as zero
@ -33,7 +32,10 @@
width: 20px;
}
input, .status { flex-shrink: 0; }
input,
.status {
flex-shrink: 0;
}
.path {
display: flex;
@ -48,6 +50,8 @@
flex-shrink: 0;
}
.octicon { vertical-align: text-bottom; }
.octicon {
vertical-align: text-bottom;
}
}
}

View file

@ -2,7 +2,6 @@
z-index: var(--foldout-z-index);
.overlay {
// No focus styles whatsovever for the overlay.
// The overlay has a tab index of -1 so that clicking on it
// won't immediately trigger the lost focus event on the app

View file

@ -1,4 +1,4 @@
@import "history/history";
@import "history/commit-list";
@import "history/commit-summary";
@import "history/file-list";
@import 'history/history';
@import 'history/commit-list';
@import 'history/commit-summary';
@import 'history/file-list';

View file

@ -1,7 +1,6 @@
@import "../mixins";
@import '../mixins';
#missing-repository-view {
/* The view's position in relation to its parent, ie full
* width, vertically centered... */
justify-content: center;

View file

@ -3,5 +3,7 @@
.path-text-component {
@include ellipsis;
.dirname { color: var(--text-secondary-color); }
.dirname {
color: var(--text-secondary-color);
}
}

View file

@ -1,6 +1,5 @@
#preferences {
.accounts-tab {
.account-info {
.avatar {
// 32px for the image + 2 on each side for the base border.

View file

@ -12,9 +12,13 @@ progress {
}
&:indeterminate {
background-image:
-webkit-linear-gradient(-45deg, transparent 33%, var(--text-color) 33%,
var(--text-color) 66%, transparent 66%);
background-image: -webkit-linear-gradient(
-45deg,
transparent 33%,
var(--text-color) 33%,
var(--text-color) 66%,
transparent 66%
);
background-size: 25px 10px, 100% 100%, 100% 100%;
-webkit-animation: progress-indeterminate-animation 5s linear infinite;
@ -22,5 +26,7 @@ progress {
}
@-webkit-keyframes progress-indeterminate-animation {
100% { background-position: 100px 0px; }
100% {
background-position: 100px 0px;
}
}

View file

@ -1,8 +1,7 @@
@import "../mixins";
@import '../mixins';
/** A React component holding the currently selected repository */
#repository {
display: flex;
flex-direction: row;
flex: 1;

View file

@ -1,5 +1,4 @@
.resizable-component {
display: flex;
flex-direction: column;
flex-shrink: 0;

View file

@ -1,4 +1,4 @@
@import "../mixins";
@import '../mixins';
@include win32-context {
// Windows native scrollbars are just too outdated to look at so we'll
@ -50,7 +50,8 @@
}
// When someone hovers over, or presses the bar we'll expand it to 8px
&:hover, &:active {
&:hover,
&:active {
border-width: 1px;
background-color: var(--scroll-bar-thumb-background-color-active);
cursor: pointer;

View file

@ -1,4 +1,4 @@
@import "../mixins";
@import '../mixins';
.select-component {
display: flex;

View file

@ -53,7 +53,7 @@
}
}
&.switch &-item {
&.switch &-item {
// Reset styles from global buttons
cursor: pointer;
border: none;

View file

@ -5,7 +5,12 @@
height: 250px;
overflow: scroll;
p, ol, ul, li, h2, h3 {
p,
ol,
ul,
li,
h2,
h3 {
cursor: text;
user-select: text;
}

View file

@ -1,4 +1,4 @@
@import "../mixins";
@import '../mixins';
.text-box-component {
display: flex;

View file

@ -23,23 +23,23 @@
}
.close {
position: absolute;
right: var(--spacing);
border: 0;
height: 16px;
width: 16px;
margin: 0;
padding: 0;
background: transparent;
position: absolute;
right: var(--spacing);
border: 0;
height: 16px;
width: 16px;
margin: 0;
padding: 0;
background: transparent;
color: var(--text-secondary-color);
color: var(--text-secondary-color);
&:hover {
color: var(--text-color);
}
&:hover {
color: var(--text-color);
}
&:focus {
outline: 0;
}
&:focus {
outline: 0;
}
}
}

View file

@ -1,7 +1,6 @@
@import "../mixins";
@import '../mixins';
fieldset.vertical-segmented-control {
// Reset styles for fieldset
border: none;
margin: 0;

View file

@ -53,7 +53,8 @@
}
}
input, button {
input,
button {
font-size: var(--font-size-md);
height: auto;
}

View file

@ -1,4 +1,4 @@
@import "../../mixins";
@import '../../mixins';
#changes-list {
min-height: 0;
@ -35,10 +35,10 @@
@include ellipsis;
}
input[type=checkbox] {
input[type='checkbox'] {
flex-grow: 0;
flex-shrink: 0;
}
}
}
}
}

View file

@ -15,7 +15,6 @@ dialog#merge {
}
.dialog-content {
padding: 0;
.filter-field-row {
@ -31,14 +30,15 @@ dialog#merge {
.list-item {
padding: 0 var(--spacing-double);
.filter-list-group-header, .branches-list-item {
.filter-list-group-header,
.branches-list-item {
padding: 0;
}
}
}
.dialog-footer {
button[type=submit] {
button[type='submit'] {
height: auto;
width: 100%;
padding: var(--spacing-half);

View file

@ -20,13 +20,14 @@
.info {
display: flex;
flex-direction: column;
margin-top: -5px;
margin-top: -4px;
overflow: hidden;
width: 100%;
.description {
display: flex;
flex-direction: row;
margin-top: 3px;
}
.summary {

View file

@ -1,8 +1,7 @@
@import "../../mixins";
@import '../../mixins';
/** A React component holding the selected commit's detailed information */
#commit-summary {
display: flex;
flex-direction: column;
@ -24,7 +23,6 @@
}
&.expanded {
.commit-summary-description-container {
border-bottom: none;
}
@ -80,8 +78,14 @@
flex: 1;
&:before {
content: "";
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0px, rgba(255, 255, 255, 0) 40px, rgba(255, 255, 255, 0.5) 40px, white 60px);
content: '';
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0px,
rgba(255, 255, 255, 0) 40px,
rgba(255, 255, 255, 0.5) 40px,
white 60px
);
position: absolute;
height: 100%;
width: 100%;
@ -92,11 +96,14 @@
// Enable text selection inside the title and description elements.
&-title,
&-description {
span, a {
span,
a {
-webkit-user-select: text;
user-select: text;
}
span { cursor: text; }
span {
cursor: text;
}
}
&-description {
@ -114,11 +121,15 @@
padding: 0 var(--spacing) var(--spacing);
}
&-meta-item:not(.without-truncation) {
@include ellipsis;
}
&-meta-item {
display: flex;
flex-direction: row;
min-width: 0;
@include ellipsis;
margin-right: var(--spacing);
font-size: var(--font-size-sm);

View file

@ -1,4 +1,4 @@
@import "../../mixins";
@import '../../mixins';
/** A React component holding the currently selected repository's history */
#history {
@ -31,4 +31,16 @@
// look right alongside blank diff
border-right: var(--base-border);
}
.panel {
display: flex;
flex: 1;
&.empty,
&.renamed,
&.binary {
justify-content: center;
align-items: center;
}
}
}

View file

@ -1,5 +1,4 @@
.toolbar-button {
// Make sure the contents shrink beyond their intrinsic width
// See https://css-tricks.com/flexbox-truncated-text/
min-width: 0;
@ -17,7 +16,7 @@
// explicitly use > here to only target the direct descendant button since
// there might be buttons in foldouts which would otherwise be affected
// as well.
&>button {
& > button {
// Reset styles from global buttons
-webkit-appearance: none;
border: none;
@ -28,7 +27,9 @@
margin: 0;
padding: 0;
&:active { box-shadow: none; }
&:active {
box-shadow: none;
}
&:focus {
background-color: var(--toolbar-button-focus-background-color);
@ -74,8 +75,7 @@
width: 100%;
}
&>button {
& > button {
position: relative;
display: flex;
@ -124,7 +124,10 @@
position: relative;
}
.title, .description { @include ellipsis }
.title,
.description {
@include ellipsis;
}
.progress {
position: absolute;
@ -135,7 +138,7 @@
background: var(--toolbar-button-progress-color);
transform-origin: left;
pointer-events: none;
transition: transform .3s var(--easing-ease-out-quint);
transition: transform 0.3s var(--easing-ease-out-quint);
}
}
@ -144,6 +147,8 @@
// progress we want the text to be slightly legible so we'll make it
// opaque. Since a toolbar button with progress also shows a spinner
// there's plenty of indication that it can't be used.
&:disabled { opacity: 1; }
&:disabled {
opacity: 1;
}
}
}

View file

@ -1,16 +1,15 @@
.toolbar-dropdown {
// Make sure the contents shrink beyond their intrinsic width
// See https://css-tricks.com/flexbox-truncated-text/
min-width: 0;
&>.toolbar-button {
& > .toolbar-button {
width: 100%;
height: 100%;
}
&.open {
&>.toolbar-button > button {
& > .toolbar-button > button {
color: var(--toolbar-button-active-color);
background-color: var(--toolbar-button-active-background-color);

View file

@ -1,4 +1,4 @@
@import "../../mixins";
@import '../../mixins';
/** A React component holding the main application toolbar component. */
#desktop-app-toolbar {
@ -25,17 +25,21 @@
flex-direction: row;
flex-shrink: 0;
&>:last-child {
& > :last-child {
flex-grow: 1;
}
}
.toolbar-button {
&.push-pull-button { width: 230px; }
&.push-pull-button {
width: 230px;
}
}
.toolbar-dropdown {
&.branch-button { width: 230px; }
&.branch-button {
width: 230px;
}
}
.toolbar-button {
@ -45,10 +49,15 @@
}
}
@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } }
@keyframes spin {
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
.spin {
animation:spin 1s linear infinite;
animation: spin 1s linear infinite;
}
.ahead-behind {
@ -78,7 +87,9 @@
// Only add left margin if both ahead and behind are
// showing at the same time.
&:nth-child(2) { margin-left: var(--spacing-half); }
&:nth-child(2) {
margin-left: var(--spacing-half);
}
// We're using arrowSmallUp and arrowSmallDown which are
// both exactly 6px wide. Let's use that so that spacing

View file

@ -2,7 +2,7 @@
display: flex;
.toolbar-button > button {
padding: 0 var(--spacing);
padding: 0 var(--spacing);
border: 0;
.access-key.highlight {
@ -13,7 +13,8 @@
.toolbar-dropdown:not(.open) > .toolbar-button > button {
color: var(--toolbar-button-secondary-color);
&:hover,&:focus {
&:hover,
&:focus {
color: var(--toolbar-button-color);
}
}

View file

@ -1,4 +1,4 @@
@import "../../mixins";
@import '../../mixins';
#desktop-app-title-bar {
-webkit-app-region: drag;
@ -11,7 +11,7 @@
@include darwin {
height: var(--darwin-title-bar-height);
background: linear-gradient(to bottom, #3b3f46 0%,#2b2e33 100%);
background: linear-gradient(to bottom, #3b3f46 0%, #2b2e33 100%);
border-bottom: 1px solid #000;
}
@ -28,7 +28,6 @@
}
.resize-handle {
position: absolute;
top: 0px;
left: 0px;
@ -50,7 +49,6 @@
// automatically even for borderless window so we only render
// controls on Windows.
.window-controls {
flex-grow: 0;
flex-shrink: 0;
margin-left: auto;
@ -77,7 +75,9 @@
background-color: transparent;
transition: background-color 0.25s ease;
&:focus { outline: none; }
&:focus {
outline: none;
}
&:hover {
background-color: #888;
@ -110,7 +110,9 @@
}
/* https://css-tricks.com/cascading-svg-fill-color/ */
svg { fill: currentColor; }
svg {
fill: currentColor;
}
}
}
}

View file

@ -37,12 +37,18 @@
}
.toast-animation {
&-appear { transform: scale(0.25); opacity: 0.1; }
&-appear {
transform: scale(0.25);
opacity: 0.1;
}
&-appear-active {
transform: scale(1);
opacity: 1;
transition: all 100ms ease-out;
}
&-leave-active { opacity: 0; transition: all 250ms ease-out; }
&-leave-active {
opacity: 0;
transition: all 250ms ease-out;
}
}

View file

@ -11,7 +11,7 @@
top: 0;
left: 0;
&>div {
& > div {
padding: var(--spacing);
min-width: 100px;
background: rgba($gray-900, 0.6);
@ -27,15 +27,25 @@
}
.zoom-in {
&-appear { transform: scale(0.25); opacity: 0; }
&-appear {
transform: scale(0.25);
opacity: 0;
}
}
.zoom-out {
&-appear { transform: scale(1.75); opacity: 0; }
&-appear {
transform: scale(1.75);
opacity: 0;
}
}
.zoom-in, .zoom-out {
&-leave-active { opacity: 0; transition: opacity 100ms ease-out; }
.zoom-in,
.zoom-out {
&-leave-active {
opacity: 0;
transition: opacity 100ms ease-out;
}
&-appear-active {
transform: scale(1);
opacity: 1;

View file

@ -0,0 +1,40 @@
import { expect } from 'chai'
import {
PullRequestDatabase,
IPullRequestStatus,
} from '../../src/lib/databases'
describe('PullRequestDatabase', () => {
it("adds statuses key to records that don't have one on upgrade", async () => {
const databaseName = 'TestPullRequestDatabase'
let database = new PullRequestDatabase(databaseName, 3)
await database.delete()
await database.open()
const prStatus: IPullRequestStatus = {
pullRequestId: 1,
state: 'success',
totalCount: 1,
sha: 'sha',
}
await database.pullRequestStatus.add(prStatus)
const prStatusFromDb = await database.pullRequestStatus.get(1)
expect(prStatusFromDb).to.not.be.undefined
expect(prStatusFromDb!.pullRequestId).to.equal(prStatus.pullRequestId)
database.close()
database = new PullRequestDatabase(databaseName, 4)
await database.open()
const upgradedPrStatusFromDb = await database.pullRequestStatus.get(1)
expect(upgradedPrStatusFromDb).is.not.undefined
expect(upgradedPrStatusFromDb!.pullRequestId).to.equal(
prStatus.pullRequestId
)
expect(upgradedPrStatusFromDb!.statuses).is.not.undefined
await database.delete()
})
})

View file

@ -8,7 +8,6 @@ import { expect } from 'chai'
import { RepositorySettingsStore } from '../../src/lib/stores'
import { setupEmptyRepository } from '../helpers/repositories'
import { getStatus } from '../../src/lib/git'
import { Repository } from '../../src/models/repository'
import { pathExists } from '../../src/lib/file-system'
describe('RepositorySettingsStore', () => {
@ -48,13 +47,10 @@ describe('RepositorySettingsStore', () => {
expect(files.length).to.equal(0)
})
describe('autocrlf and safecrlf', () => {
let repo: Repository
let sut: RepositorySettingsStore
beforeEach(async () => {
repo = await setupEmptyRepository()
sut = new RepositorySettingsStore(repo)
describe('autocrlf and safecrlf are true', () => {
it('appends CRLF to file', async () => {
const repo = await setupEmptyRepository()
const sut = new RepositorySettingsStore(repo)
await GitProcess.exec(
['config', '--local', 'core.autocrlf', 'true'],
@ -64,9 +60,7 @@ describe('RepositorySettingsStore', () => {
['config', '--local', 'core.safecrlf', 'true'],
repo.path
)
})
it('appends newline to file', async () => {
const path = repo.path
await sut.saveGitIgnore('node_modules')
@ -82,4 +76,34 @@ describe('RepositorySettingsStore', () => {
expect(contents!.endsWith('\r\n'))
})
})
describe('autocrlf and safecrlf are unset', () => {
it('appends LF to file', async () => {
const repo = await setupEmptyRepository()
const sut = new RepositorySettingsStore(repo)
// ensure this repository only ever sticks to LF
await GitProcess.exec(['config', '--local', 'core.eol', 'lf'], repo.path)
// do not do any conversion of line endings when committing
await GitProcess.exec(
['config', '--local', 'core.autocrlf', 'input'],
repo.path
)
const path = repo.path
await sut.saveGitIgnore('node_modules')
await GitProcess.exec(['add', '.gitignore'], path)
const commit = await GitProcess.exec(
['commit', '-m', 'create the ignore file'],
path
)
const contents = await sut.readGitIgnore()
expect(commit.exitCode).to.equal(0)
expect(contents!.endsWith('\n'))
})
})
})

View file

@ -0,0 +1,60 @@
import { expect } from 'chai'
import { getNextVersionNumber } from '../../../../script/draft-release/version'
describe('getNextVersionNumber', () => {
describe('production', () => {
const channel = 'production'
it('increments the patch number', () => {
expect(getNextVersionNumber('1.0.1', channel)).to.equal('1.0.2')
})
describe("doesn't care for", () => {
it('beta versions', () => {
const version = '1.0.1-beta1'
expect(() => getNextVersionNumber(version, channel)).to.throw(
`Unable to draft production release using beta version '${version}'`
)
})
it('test versions', () => {
const version = '1.0.1-test42'
expect(() => getNextVersionNumber('1.0.1-test42', channel)).to.throw(
`Unable to draft production release using test version '${version}'`
)
})
})
})
describe('beta', () => {
const channel = 'beta'
describe('when a beta version is used', () => {
it('the beta tag is incremented', () => {
expect(getNextVersionNumber('1.1.2-beta3', channel)).to.equal(
'1.1.2-beta4'
)
})
it('handles multiple digits', () => {
expect(getNextVersionNumber('1.1.2-beta99', channel)).to.equal(
'1.1.2-beta100'
)
})
})
describe('when a production version is used', () => {
it('increments the patch and returns the first beta', () => {
expect(getNextVersionNumber('1.0.1', channel)).to.equal('1.0.2-beta1')
})
})
describe("doesn't care for", () => {
it('test versions', () => {
const version = '1.0.1-test1'
expect(() => getNextVersionNumber(version, channel)).to.throw(
`Unable to draft beta release using test version '${version}'`
)
})
})
})
})

View file

@ -155,7 +155,13 @@ co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
codemirror@^5.31.0:
codemirror-mode-elixir@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/codemirror-mode-elixir/-/codemirror-mode-elixir-1.1.1.tgz#cc5b79bf5f93b6da426e32364a673a681391416c"
dependencies:
codemirror "^5.20.2"
codemirror@^5.20.2, codemirror@^5.31.0:
version "5.33.0"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.33.0.tgz#462ad9a6fe8d38b541a9536a3997e1ef93b40c6a"
@ -434,6 +440,10 @@ fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
fuzzaldrin-plus@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/fuzzaldrin-plus/-/fuzzaldrin-plus-0.6.0.tgz#832f6489fbe876769459599c914a670ec22947ee"
getpass@^0.1.1:
version "0.1.7"
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"

View file

@ -1,5 +1,27 @@
{
"releases": {
"1.0.14-beta4": [
"[New] Syntax highlighting for Elixir files - #3774. Thanks @joaovitoras!",
"[Fixed] Crash when unable to launch shell - #3954",
"[Fixed] Support legacy usernames as co-authors - #3897",
"[Improved] Enable fuzzy search in the repository, branch, PR, and clone FilterLists - #911. Thanks @j-f1!",
"[Improved] Tidy up commit summary and description layout in commit list - #3922. Thanks @willnode!"
],
"1.0.14-beta3": [
"[Added] Add TextMate support for macOS - #3910. Thanks @caiofbpa!",
"[Fixed] Handle Git errors when .gitmodules are malformed - #3912",
"[Fixed] Clear repository filter when switching tabs - #3787. Thanks @reyronald!",
"[Fixed] Prevent duplicate entries in co-author autocomplete list - #3887",
"[Improved] Show progress when initializing remote for fork - #3953"
],
"1.0.14-beta2": [
"[Added] Add RubyMine support for macOS - #3883. Thanks @gssbzn!",
"[Fixed] Allow window to accept single click on focus - #3843",
"[Fixed] Expanded avatar list hidden behind commit details - #3884",
"[Fixed] Renames not detected when viewing commit diffs - #3673",
"[Fixed] Ignore action assumes CRLF when core.autocrlf is unset - #3514",
"[Improved] Use smaller default size when rendering Gravatar avatars - #3911"
],
"1.0.14-beta1": [
"[New] Commit together with co-authors - #3879"
],

View file

@ -12,12 +12,77 @@ We have three channels to which we can release: `production`, `beta`, and `test`
## The Process
1. Ensure the release notes for `$version` in [`changelog.json`](../../changelog.json) are up-to-date.
1. Bump `version` in [`app/package.json`](../../app/package.json) to `$version`.
1. Commit & push the changes.
1. Run `.release! desktop/YOUR_BRANCH to {production|beta|test}`.
* We're using `.release` with a bang so that we don't have to wait for any current CI on the branch to finish. This might feel a little wrong, but it's OK since making the release itself will also run CI.
1. If you're releasing a production update, release a beta update for the next version too, so that beta users are on the latest release.
From a clean working directory, set the `GITHUB_ACCESS_TOKEN` environment variable to a valid [Personal Access Token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/) and run:
```shellsession
$ yarn draft-release (production|beta)
```
This command will then explain the next steps:
```shellsession
Here's what you should do next:
1. Ensure the app/package.json 'version' is set to '1.0.14-beta2'
2. Add this to changelog.json as a starting point:
{
"1.0.14-beta2": [
"[???] Add RubyMine support for macOS - #3883. Thanks @gssbzn!",
"[???] Allow window to accept single click on focus - #3843",
"[???] Drop unnecessary comments before issue template - #3906",
"[???] First-class changelog script for generating release notes - #3888",
"[???] Fix expanded avatar stack overflow - #3884",
"[???] Switch to a saner default gravatar size - #3911",
"[Fixed] Add a repository settings store - #934",
"[Fixed] Ensure renames are detected when viewing commit diffs - #3673",
"[Fixed] Line endings are hard, lets go shopping - #3514",
]
}
3. Update the release notes so they make sense and only contain user-facing changes
4. Commit the changes and push them to GitHub
5. Read this to perform the release: https://github.com/desktop/desktop/blob/master/docs/process/releasing-updates.md
```
To walk through this:
- the script works out the next version from what was previously published, based on the channel
- you should ensure the `version` in `app/package.json` is set to the new version
- then, take the draft changelog generated by the script and add it to the `releases` element in `changelog.json`
The draft changelog covers everything that's been merged, and probably need some love. It's your job from here to:
- remove any entries of contributions that don't affect the end user
- for issues prefixed with `[???]`, look at the linked PR or issue and change the prefix to one of `[New]`, `[Fixed]`, `[Improved]`, `[Added]` or `[Removed]` based on what best represents the change
- edit the remaining entries so they make sense
- sort the entries so that the prefixes are ordered in this way: `[New]`, `[Fixed]`, `[Improved]`, `[Added]`, `[Removed]`
Here's an example of the previous changelog draft afterit has been edited:
```json
{
"1.0.14-beta2": [
"[Added] Add RubyMine support for macOS - #3883. Thanks @gssbzn!",
"[Fixed] Allow window to accept single click on focus - #3843",
"[Fixed] Expanded avatar list hidden behind commit details - #3884",
"[Fixed] Renames not detected when viewing commit diffs - #3673",
"[Fixed] Ignore action assumes CRLF when core.autocrlf is unset - #3514",
"[Improved] Use smaller default size when rendering Gravatar avatars - #3911",
]
}
```
Once you are happy with those changes, commit and push them to GitHub. Get the others on the team to :thumbsup: them if you're not sure.
When you feel ready to start the deployment, run this command in Chat:
```
.release! desktop/YOUR_BRANCH to {production|beta|test}
```
We're using `.release` with a bang so that we don't have to wait for any current CI on the branch to finish. This might feel a little wrong, but it's OK since making the release itself will also run CI.
If you're releasing a production update, release a beta update for the next version too, so that beta users are on the latest release.
## Error Reporting

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