Merge branch 'development' into tree-shake-octoicons

This commit is contained in:
Markus Olsson 2020-08-11 18:48:22 +02:00
commit 2d63447841
113 changed files with 3437 additions and 1906 deletions

View file

@ -1,85 +0,0 @@
version: 2
defaults: &defaults
working_directory: ~/desktop/desktop
macos:
xcode: '10.2.1'
jobs:
build:
<<: *defaults
parallelism: 1
shell: /bin/bash --login
environment:
CIRCLE_ARTIFACTS: /tmp/circleci-artifacts
CIRCLE_TEST_REPORTS: /tmp/circleci-test-results
steps:
- checkout
- run: mkdir -p $CIRCLE_ARTIFACTS $CIRCLE_TEST_REPORTS
- restore_cache:
keys:
# when lock file changes, use increasingly general patterns to restore cache
- yarn-packages-v1-{{ .Branch }}-{{ checksum "yarn.lock" }}
- yarn-packages-v1-{{ .Branch }}-
- run:
name: Install dependencies
command: yarn install --force
- save_cache:
key: yarn-packages-v1-{{ .Branch }}-{{ checksum "yarn.lock" }}
paths:
- ~/.cache/yarn
- vendor/bundle
- .eslintcache
- ~/.electron
- ~/Library/Caches/Yarn/v4
- run:
name: Linting
command: yarn lint && yarn validate-changelog && yarn check-modified
- run:
name: Build
command: yarn build:prod
- run:
name: Test
command: |
yarn test:setup
yarn test
- run:
name: Report to codecov
command: yarn test:report
when: always
- run:
name: Teardown
command:
find $HOME/Library/Developer/Xcode/DerivedData -name
'*.xcactivitylog' -exec cp {} $CIRCLE_ARTIFACTS/xcactivitylog \; ||
true
# Save test results
- store_test_results:
path: /tmp/circleci-test-results
# Save artifacts
- store_artifacts:
path: /tmp/circleci-artifacts
- store_artifacts:
path: /tmp/circleci-test-results
- persist_to_workspace:
root: ~/desktop/desktop
paths: .
deploy:
<<: *defaults
steps:
- attach_workspace:
at: ~/desktop/desktop
- run: yarn run publish
workflows:
version: 2
build-and-deploy:
jobs:
- build
- deploy:
requires:
- build
filters:
branches:
only: /^__release-.*/

View file

@ -3,3 +3,5 @@
# might be included when linting
app/coverage
script/coverage
node_modules
app/node_modules

View file

@ -5,6 +5,7 @@ plugins:
- babel
- react
- json
- jsdoc
settings:
react:
@ -13,6 +14,8 @@ settings:
extends:
- prettier
- prettier/react
- plugin:@typescript-eslint/recommended
- prettier/@typescript-eslint
rules:
##########
@ -32,6 +35,26 @@ rules:
custom:
regex: '^I[A-Z]'
match: true
- selector: class
format:
- PascalCase
- selector: variableLike
format: null
custom:
# Based on https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar we
# should probably be using the following expression here (newlines added for readability)
#
# ^(break|case|catch|class|const|continue|debugger|default|delete|do|else|export|
# extends|finally|for|function|if|import|in|instanceof|new|return|super|switch|this|
# throw|try|typeof|var|void|while|with|yield|enum|implements|interface|let|package|
# private|protected|public|static|await|abstract|boolean|byte|char|double|final|float|
# goto|int|long|native|short|synchronized|throws|transient|volatile|null|true|false)$
#
# But that'll cause a bunch of errors, for now we'll stick with replicating what the
# variable-name ban-keywords rule did for us in tslint
# see https://palantir.github.io/tslint/rules/variable-name/
regex: '^(any|Number|number|String|string|Boolean|boolean|Undefined|undefined)$'
match: false
'@typescript-eslint/consistent-type-assertions':
- error
- assertionStyle: 'as'
@ -45,8 +68,34 @@ rules:
- functions: false
variables: false
typedefs: false
# this rule now works but generates a lot of issues with the codebase
# '@typescript-eslint/member-ordering': error
'@typescript-eslint/member-ordering':
- error
- default:
- static-field
- static-method
- field
- abstract-method
- constructor
- method
'@typescript-eslint/no-extraneous-class': error
'@typescript-eslint/no-empty-interface': error
# Would love to be able to turn this on eventually
'@typescript-eslint/no-non-null-assertion': off
# This rule does a lot of good but right now it catches way
# too many cases, we're gonna want to pay down this debt
# incrementally if we want to enable it.
'@typescript-eslint/ban-types': off
# It'd be nice to be able to turn this on eventually
'@typescript-eslint/no-var-requires': off
# Don't particularly care about these
'@typescript-eslint/triple-slash-reference': off
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-explicit-any': off
'@typescript-eslint/no-inferrable-types': off
'@typescript-eslint/no-empty-function': off
# Babel
babel/no-invalid-this: error
@ -60,6 +109,23 @@ rules:
react/no-string-refs: error
react/jsx-uses-vars: error
react/jsx-uses-react: error
react/no-unused-state: error
# JSDoc
jsdoc/check-alignment: error
jsdoc/check-tag-names: error
jsdoc/check-types: error
jsdoc/implements-on-classes: error
jsdoc/newline-after-description: error
jsdoc/no-undefined-types: error
jsdoc/valid-types: error
# Would love to enable these at some point but
# they cause way to many issues now.
#jsdoc/check-param-names: error
#jsdoc/require-jsdoc:
# - error
# - publicOnly: true
###########
# BUILTIN #
@ -96,6 +162,12 @@ overrides:
strict:
- error
- never
- files: 'app/test/**/*'
rules:
'@typescript-eslint/no-non-null-assertion': off
- files: 'script/**/*'
rules:
'@typescript-eslint/no-non-null-assertion': off
parserOptions:
sourceType: module

81
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,81 @@
name: CI
on:
push:
branches:
- development
- __release-*
pull_request:
jobs:
build:
name: ${{ matrix.friendlyName }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-10.15, windows-2019]
include:
- os: macos-10.15
friendlyName: macOS
- os: windows-2019
friendlyName: Windows
timeout-minutes: 30
steps:
- uses: actions/checkout@v2
with:
submodules: recursive
- name: Use Node.js 12.8.1
uses: actions/setup-node@v1
with:
node-version: 12.8.1
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
${{ runner.os }}-yarn-
- name: Install and build dependencies
run: yarn
- name: Pre-build checks (Linting)
run: |
yarn lint
yarn validate-changelog
yarn check-modified
- name: Build production app
run: yarn build:prod
env:
DESKTOP_OAUTH_CLIENT_ID: ${{ secrets.DESKTOP_OAUTH_CLIENT_ID }}
DESKTOP_OAUTH_CLIENT_SECRET:
${{ secrets.DESKTOP_OAUTH_CLIENT_SECRET }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
DESKTOPBOT_TOKEN: ${{ secrets.DESKTOPBOT_TOKEN }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Prepare testing environment
run: yarn test:setup
- name: Run unit tests
run: yarn test:unit:cov
- name: Run script tests
run: yarn test:script:cov
- name: Run integration tests
run: yarn test:integration
- name: Publish production app
run: yarn run publish
env:
DESKTOPBOT_TOKEN: ${{ secrets.DESKTOPBOT_TOKEN }}
WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
DEPLOYMENT_SECRET: ${{ secrets.DEPLOYMENT_SECRET }}
S3_KEY: ${{ secrets.S3_KEY }}
S3_SECRET: ${{ secrets.S3_SECRET }}
S3_BUCKET: github-desktop
timeout-minutes: 5

View file

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

View file

@ -6,16 +6,7 @@ module.exports = {
testMatch: ['**/integration/**/*-test.ts{,x}'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupTestFrameworkScriptFile: '<rootDir>/test/setup-test-framework.ts',
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: '.',
outputName: 'junit-integration-tests.xml',
},
],
],
reporters: ['default', '<rootDir>../script/jest-actions-reporter.js'],
globals: {
'ts-jest': {
useBabelrc: true,

View file

@ -20,16 +20,7 @@ module.exports = {
// ignore index files
'!**/index.ts',
],
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: '.',
outputName: 'junit-unit-tests.xml',
},
],
],
reporters: ['default', '<rootDir>../script/jest-actions-reporter.js'],
coverageReporters: ['text-summary', 'json', 'html', 'cobertura'],
globals: {
'ts-jest': {

View file

@ -3,7 +3,7 @@
"productName": "GitHub Desktop",
"bundleID": "com.github.GitHubClient",
"companyName": "GitHub, Inc.",
"version": "2.5.4-beta1",
"version": "2.5.4-beta2",
"main": "./main.js",
"repository": {
"type": "git",
@ -30,12 +30,13 @@
"dugite": "1.91.3",
"electron-window-state": "^5.0.3",
"event-kit": "^2.0.0",
"file-metadata": "^1.0.0",
"file-uri-to-path": "0.0.2",
"file-url": "^2.0.2",
"fs-admin": "^0.12.0",
"fs-admin": "^0.15.0",
"fs-extra": "^7.0.1",
"fuzzaldrin-plus": "^0.6.0",
"keytar": "^5.0.0",
"keytar": "^6.0.1",
"mem": "^4.3.0",
"memoize-one": "^4.0.3",
"moment": "^2.24.0",
@ -46,7 +47,6 @@
"queue": "^4.4.2",
"quick-lru": "^3.0.0",
"react": "^16.8.4",
"react-addons-shallow-compare": "^15.6.2",
"react-css-transition-replace": "^3.0.3",
"react-dom": "^16.8.4",
"react-transition-group": "^1.2.0",
@ -66,8 +66,8 @@
},
"devDependencies": {
"devtron": "^1.4.0",
"electron-debug": "^3.0.1",
"electron-devtools-installer": "^2.2.4",
"electron-debug": "^3.1.0",
"electron-devtools-installer": "^3.1.1",
"temp": "^0.8.3",
"webpack-hot-middleware": "^2.10.0"
}

View file

@ -15,6 +15,8 @@ import { LinkButton } from '../ui/lib/link-button'
import { getVersion } from '../ui/lib/app-proxy'
import { getOS } from '../lib/get-os'
// This is a weird one, let's leave it as a placeholder
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface ICrashAppProps {}
interface ICrashAppState {

View file

@ -502,14 +502,14 @@ function toGitHubIsoDateString(date: Date) {
* An object for making authenticated requests to the GitHub API
*/
export class API {
private endpoint: string
private token: string
/** Create a new API client from the given account. */
public static fromAccount(account: Account): API {
return new API(account.endpoint, account.token)
}
private endpoint: string
private token: string
/** Create a new API client for the endpoint, authenticated with the token. */
public constructor(endpoint: string, token: string) {
this.endpoint = endpoint

View file

@ -7,8 +7,31 @@ export interface IAppShell {
readonly moveItemToTrash: (path: string) => boolean
readonly beep: () => void
readonly openExternal: (path: string) => Promise<boolean>
readonly openItem: (path: string) => boolean
/**
* Reveals the specified file using the operating
* system default application.
* Do not use this method with non-validated paths.
*
* @param path - The path of the file to open
*/
readonly openPath: (path: string) => Promise<string>
/**
* Reveals the specified file on the operating system
* default file explorer. If a folder is passed, it will
* open its parent folder and preselect the passed folder.
*
* @param path - The path of the file to show
*/
readonly showItemInFolder: (path: string) => void
/**
* Reveals the specified folder on the operating
* system default file explorer.
* Do not use this method with non-validated paths.
*
* @param path - The path of the folder to open
*/
readonly showFolderContents: (path: string) => void
}
export const shell: IAppShell = {
@ -29,7 +52,10 @@ export const shell: IAppShell = {
showItemInFolder: path => {
ipcRenderer.send('show-item-in-folder', { path })
},
openItem: electronShell.openItem,
showFolderContents: path => {
ipcRenderer.send('show-folder-contents', { path })
},
openPath: electronShell.openPath,
}
/**

View file

@ -28,6 +28,13 @@ export class IssuesDatabase extends BaseDatabase {
clearIssues
)
}
public getIssuesForRepository(gitHubRepositoryID: number) {
return this.issues
.where('gitHubRepositoryID')
.equals(gitHubRepositoryID)
.toArray()
}
}
function clearIssues(transaction: Dexie.Transaction) {

View file

@ -20,10 +20,10 @@ import { assertNever } from '../lib/fatal-error'
// in which case s defaults to 1
const diffHeaderRe = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
const DiffPrefixAdd: '+' = '+'
const DiffPrefixDelete: '-' = '-'
const DiffPrefixContext: ' ' = ' '
const DiffPrefixNoNewline: '\\' = '\\'
const DiffPrefixAdd = '+' as const
const DiffPrefixDelete = '-' as const
const DiffPrefixContext = ' ' as const
const DiffPrefixNoNewline = '\\' as const
type DiffLinePrefix =
| typeof DiffPrefixAdd
@ -221,9 +221,9 @@ export class DiffParser {
* We currently only extract the line number information and
* ignore any hunk headings.
*
* Example hunk header:
* Example hunk header (text within ``):
*
* @@ -84,10 +82,8 @@ export function parseRawDiff(lines: ReadonlyArray<string>): Diff {
* `@@ -84,10 +82,8 @@ export function parseRawDiff(lines: ReadonlyArray<string>): Diff {`
*
* Where everything after the last @@ is what's known as the hunk, or section, heading
*/

View file

@ -27,6 +27,7 @@ export function enableProgressBarOnIcon(): boolean {
}
/** Should the app enable beta features? */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore: this will be used again in the future
function enableBetaFeatures(): boolean {
return enableDevelopmentFeatures() || __RELEASE_CHANNEL__ === 'beta'

View file

@ -0,0 +1,37 @@
// This module renders an element with an emoji using
// a non system-default font to workaround an Chrome
// issue that causes unexpected spacing on emojis.
// More info:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1113293
const container = document.createElement('div')
container.style.setProperty('visibility', 'hidden')
container.style.setProperty('position', 'absolute')
// Keep this array synced with the font size variables
// in _variables.scss
const fontSizes = [
'--font-size',
'--font-size-sm',
'--font-size-md',
'--font-size-lg',
'--font-size-xl',
'--font-size-xxl',
'--font-size-xs',
]
for (const fontSize of fontSizes) {
const span = document.createElement('span')
span.style.setProperty('font-size', `var(${fontSize}`)
span.style.setProperty('font-family', 'Arial', 'important')
span.textContent = '🤦🏿‍♀️'
container.appendChild(span)
}
document.body.appendChild(container)
// Read the dimensions of the element to force the browser to do a layout.
container.offsetHeight.toString()
// Browser has rendered the emojis, now we can remove them.
document.body.removeChild(container)

View file

@ -454,7 +454,6 @@ export function gitRebaseArguments() {
/**
* Returns the SHA of the passed in IGitResult
* @param result
*/
export function parseCommitSHA(result: IGitResult): string {
return result.stdout.split(']')[0].split(' ')[1]

View file

@ -2,7 +2,8 @@ import { spawnAndComplete } from './spawn'
import { getCaptures } from '../helpers/regex'
/**
* returns a list of files with conflict markers present
* Returns a list of files with conflict markers present
*
* @param repositoryPath filepath to repository
* @returns filepaths with their number of conflicted markers
*/

View file

@ -455,7 +455,8 @@ export async function getWorkingDirectoryImage(
}
/**
* list the modified binary files' paths in the given repository
* List the modified binary files' paths in the given repository
*
* @param repository to run git operation in
* @param ref ref (sha, branch, etc) to compare the working index against
*

View file

@ -172,6 +172,7 @@ declare const log: IDesktopLogger
declare namespace NodeJS {
interface Process extends EventEmitter {
once(event: 'exit', listener: Function): this
once(event: 'uncaughtException', listener: (error: Error) => void): this
on(event: 'uncaughtException', listener: (error: Error) => void): this
on(
@ -184,7 +185,6 @@ declare namespace NodeJS {
context?: { [key: string]: string }
): this
removeListener(event: 'exit', listener: Function): this
once(event: 'exit', listener: Function): this
}
}
@ -225,3 +225,9 @@ declare class ResizeObserver {
public disconnect(): void
public observe(e: HTMLElement): void
}
declare module 'file-metadata' {
// eslint-disable-next-line no-restricted-syntax
function fileMetadata(path: string): Promise<plist.PlistObject>
export = fileMetadata
}

View file

@ -0,0 +1,8 @@
/**
* Hack: The file-metadata plugin has substantial dependencies
* (plist, DOMParser, etc) and it's only applicable on macOS.
*
* Therefore, when compiling on other platforms, we replace it
* with this tiny shim. See webpack.common.ts.
*/
module.exports = () => Promise.resolve({})

View file

@ -1,7 +1,9 @@
/**
* get all regex captures within a body of text
* Get all regex captures within a body of text
*
* @param text string to search
* @param re regex to search with. must have global option and one capture
*
* @returns arrays of strings captured by supplied regex
*/
export function getCaptures(
@ -17,7 +19,8 @@ export function getCaptures(
}
/**
* get all regex matches within a body of text
* Get all regex matches within a body of text
*
* @param text string to search
* @param re regex to search with. must have global option
* @returns set of strings captured by supplied regex

View file

@ -0,0 +1,41 @@
import getFileMetadata from 'file-metadata'
/**
* Attempts to determine if the provided path is an application bundle or not.
*
* macOS differs from the other platforms we support in that a directory can
* also be an application and therefore executable making it unsafe to open
* directories on macOS as we could conceivably end up launching an application.
*
* This application uses file metadata (the `mdls` tool to be exact) to
* determine whether a path is actually an application bundle or otherwise
* executable.
*
* NOTE: This method will always return false when not running on macOS.
*/
export async function isApplicationBundle(path: string): Promise<boolean> {
if (process.platform !== 'darwin') {
return false
}
const metadata = await getFileMetadata(path)
if (metadata['contentType'] === 'com.apple.application-bundle') {
return true
}
const contentTypeTree = metadata['contentTypeTree']
if (Array.isArray(contentTypeTree)) {
for (const contentType of contentTypeTree) {
switch (contentType) {
case 'com.apple.application-bundle':
case 'com.apple.application':
case 'public.executable':
return true
}
}
}
return false
}

View file

@ -1,5 +1,6 @@
import * as Path from 'path'
import fileUrl from 'file-url'
import { realpath } from 'fs-extra'
/**
* Resolve and encode the path information into a URL.
@ -10,3 +11,154 @@ export function encodePathAsUrl(...pathSegments: string[]): string {
const path = Path.resolve(...pathSegments)
return fileUrl(path)
}
/**
* Resolve one or more path sequences into an absolute path underneath
* or at the given root path.
*
* The path segments are expected to be relative paths although
* providing an absolute path is also supported. In the case of an
* absolute path segment this method will essentially only verify
* that the absolute path is equal to or deeper in the directory
* tree than the root path.
*
* If the fully resolved path does not reside underneath the root path
* this method will return null.
*
* @param rootPath The path to the root path. The resolved path
* is guaranteed to reside at, or underneath this
* path.
* @param pathSegments One or more paths to join with the root path
* @param options A subset of the Path module. Requires the join,
* resolve, and normalize path functions. Defaults
* to the platform specific path functions but can
* be overriden by providing either Path.win32 or
* Path.posix
*/
async function _resolveWithin(
rootPath: string,
pathSegments: string[],
options: {
join: (...pathSegments: string[]) => string
normalize: (p: string) => string
resolve: (...pathSegments: string[]) => string
} = Path
) {
// An empty root path would let all relative
// paths through.
if (rootPath.length === 0) {
return null
}
const { join, normalize, resolve } = options
const normalizedRoot = normalize(rootPath)
const normalizedRelative = normalize(join(...pathSegments))
// Null bytes has no place in paths.
if (
normalizedRoot.indexOf('\0') !== -1 ||
normalizedRelative.indexOf('\0') !== -1
) {
return null
}
// Resolve to an absolute path. Note that this will not contain
// any directory traversal segments.
const resolved = resolve(normalizedRoot, normalizedRelative)
const realRoot = await realpath(normalizedRoot)
const realResolved = await realpath(resolved)
return realResolved.startsWith(realRoot) ? resolved : null
}
/**
* Resolve one or more path sequences into an absolute path underneath
* or at the given root path.
*
* The path segments are expected to be relative paths although
* providing an absolute path is also supported. In the case of an
* absolute path segment this method will essentially only verify
* that the absolute path is equal to or deeper in the directory
* tree than the root path.
*
* If the fully resolved path does not reside underneath the root path
* this method will return null.
*
* This method will resolve paths using the current platform path
* structure.
*
* @param rootPath The path to the root path. The resolved path
* is guaranteed to reside at, or underneath this
* path.
* @param pathSegments One or more paths to join with the root path
*/
export function resolveWithin(
rootPath: string,
...pathSegments: string[]
): Promise<string | null> {
return _resolveWithin(rootPath, pathSegments)
}
/**
* Resolve one or more path sequences into an absolute path underneath
* or at the given root path.
*
* The path segments are expected to be relative paths although
* providing an absolute path is also supported. In the case of an
* absolute path segment this method will essentially only verify
* that the absolute path is equal to or deeper in the directory
* tree than the root path.
*
* If the fully resolved path does not reside underneath the root path
* this method will return null.
*
* This method will resolve paths using POSIX path syntax.
*
* @param rootPath The path to the root path. The resolved path
* is guaranteed to reside at, or underneath this
* path.
* @param pathSegments One or more paths to join with the root path
*/
export function resolveWithinPosix(
rootPath: string,
...pathSegments: string[]
): Promise<string | null> {
return _resolveWithin(rootPath, pathSegments, Path.posix)
}
/**
* Resolve one or more path sequences into an absolute path underneath
* or at the given root path.
*
* The path segments are expected to be relative paths although
* providing an absolute path is also supported. In the case of an
* absolute path segment this method will essentially only verify
* that the absolute path is equal to or deeper in the directory
* tree than the root path.
*
* If the fully resolved path does not reside underneath the root path
* this method will return null.
*
* This method will resolve paths using Windows path syntax.
*
* @param rootPath The path to the root path. The resolved path
* is guaranteed to reside at, or underneath this
* path.
* @param pathSegments One or more paths to join with the root path
*/
export function resolveWithinWin32(
rootPath: string,
...pathSegments: string[]
): Promise<string | null> {
return _resolveWithin(rootPath, pathSegments, Path.win32)
}
export const win32 = {
resolveWithin: resolveWithinWin32,
}
export const posix = {
resolveWithin: resolveWithinPosix,
}

View file

@ -54,11 +54,17 @@ function createProgressProcessCallback(
progressCallback: (progress: IGitProgress | IGitOutput) => void
): (process: ChildProcess) => void {
return process => {
let lfsProgressActive = false
if (lfsProgressPath) {
const lfsParser = new GitLFSProgressParser()
const disposable = tailByLine(lfsProgressPath, line => {
const progress = lfsParser.parse(line)
progressCallback(progress)
if (progress.kind === 'progress') {
lfsProgressActive = true
progressCallback(progress)
}
})
process.on('close', () => {
@ -81,7 +87,33 @@ function createProgressProcessCallback(
// `stderr` will be undefined and the error will be emitted asynchronously
if (process.stderr) {
byline(process.stderr).on('data', (line: string) => {
progressCallback(parser.parse(line))
const progress = parser.parse(line)
if (lfsProgressActive) {
// While we're sending LFS progress we don't want to mix
// any non-progress events in with the output or we'll get
// flickering between the indeterminate LFS progress and
// the regular progress.
if (progress.kind === 'context') {
return
}
const { title, done } = progress.details
// The 'Filtering content' title happens while the LFS
// filter is running and when it's done we know that the
// filter is done but until then we don't want to display
// it for the same reason that we don't want to display
// the context above.
if (title === 'Filtering content') {
if (done) {
lfsProgressActive = false
}
return
}
}
progressCallback(progress)
})
}
}

View file

@ -1,6 +1,7 @@
import * as FSE from 'fs-extra'
import { getTempFilePath } from '../file-system'
import { IGitProgress, IGitProgressInfo, IGitOutput } from './git'
import { formatBytes } from '../../ui/lib/bytes'
/** Create the Git LFS progress reporting file and return the path. */
export async function createLFSProgressFile(): Promise<string> {
@ -16,56 +17,87 @@ export async function createLFSProgressFile(): Promise<string> {
// `<direction> <current>/<total files> <downloaded>/<total> <name>`
const LFSProgressLineRe = /^(.+?)\s{1}(\d+)\/(\d+)\s{1}(\d+)\/(\d+)\s{1}(.+)$/
interface IFileProgress {
/**
* The number of bytes that have been transferred
* for this file
*/
readonly transferred: number
/**
* The total size of the file in bytes
*/
readonly size: number
/**
* Whether this file has been transferred fully
*/
readonly done: boolean
}
/** The progress parser for Git LFS. */
export class GitLFSProgressParser {
/**
* A map keyed on the name of each file that LFS has reported
* progress on with the last seen progress as the value.
*/
private readonly files = new Map<string, IFileProgress>()
/** Parse the progress line. */
public parse(line: string): IGitProgress | IGitOutput {
const cannotParseResult: IGitOutput = {
kind: 'context',
text: 'Downloading Git LFS file…',
percent: 0,
}
const matches = line.match(LFSProgressLineRe)
if (!matches || matches.length !== 7) {
return cannotParseResult
return { kind: 'context', percent: 0, text: line }
}
const direction = matches[1]
const current = parseInt(matches[2], 10)
const totalFiles = parseInt(matches[3], 10)
const downloadedBytes = parseInt(matches[4], 10)
const totalBytes = parseInt(matches[5], 10)
const name = matches[6]
const estimatedFileCount = parseInt(matches[3], 10)
const fileTransferred = parseInt(matches[4], 10)
const fileSize = parseInt(matches[5], 10)
const fileName = matches[6]
if (
!direction ||
!current ||
!totalFiles ||
!downloadedBytes ||
!totalBytes ||
!name
isNaN(estimatedFileCount) ||
isNaN(fileTransferred) ||
isNaN(fileSize)
) {
return cannotParseResult
return { kind: 'context', percent: 0, text: line }
}
if (
isNaN(current) ||
isNaN(totalFiles) ||
isNaN(downloadedBytes) ||
isNaN(totalBytes)
) {
return cannotParseResult
this.files.set(fileName, {
transferred: fileTransferred,
size: fileSize,
done: fileTransferred === fileSize,
})
let totalTransferred = 0
let totalEstimated = 0
let finishedFiles = 0
// When uploading LFS files the estimate is accurate but not
// when downloading so we'll choose whichever is biggest of the estimate
// and the actual number of files we've seen
const fileCount = Math.max(estimatedFileCount, this.files.size)
for (const file of this.files.values()) {
totalTransferred += file.transferred
totalEstimated += file.size
finishedFiles += file.done ? 1 : 0
}
const transferProgress = `${formatBytes(
totalTransferred,
1
)} / ${formatBytes(totalEstimated, 1)}`
const verb = this.directionToHumanFacingVerb(direction)
const info: IGitProgressInfo = {
title: `${verb} "${name}" (${downloadedBytes} of ${totalBytes})…`,
value: downloadedBytes,
total: totalBytes,
title: `${verb} "${fileName}"`,
value: totalTransferred,
total: totalEstimated,
percent: 0,
done: false,
text: line,
text: `${verb} ${fileName} (${finishedFiles} out of an estimated ${fileCount} completed, ${transferProgress})`,
}
return {

View file

@ -314,6 +314,8 @@ const shellKey = 'shell'
// switching between apps does not result in excessive fetching in the app
const BackgroundFetchMinimumInterval = 30 * 60 * 1000
const MaxInvalidFoldersToDisplay = 3
export class AppStore extends TypedBaseStore<IAppState> {
private readonly gitStoreCache: GitStoreCache
@ -4909,6 +4911,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
): Promise<ReadonlyArray<Repository>> {
const addedRepositories = new Array<Repository>()
const lfsRepositories = new Array<Repository>()
const invalidPaths: Array<string> = []
for (const path of paths) {
const validatedPath = await validatedRepositoryPath(path)
if (validatedPath) {
@ -4933,11 +4937,14 @@ export class AppStore extends TypedBaseStore<IAppState> {
lfsRepositories.push(refreshedRepo)
}
} else {
const error = new Error(`${path} isn't a git repository.`)
this.emitError(error)
invalidPaths.push(path)
}
}
if (invalidPaths.length > 0) {
this.emitError(new Error(this.getInvalidRepoPathsMessage(invalidPaths)))
}
if (lfsRepositories.length > 0) {
this._showPopup({
type: PopupType.InitializeLFS,
@ -4993,6 +5000,23 @@ export class AppStore extends TypedBaseStore<IAppState> {
}
}
private getInvalidRepoPathsMessage(
invalidPaths: ReadonlyArray<string>
): string {
if (invalidPaths.length === 1) {
return `${invalidPaths} isn't a Git repository.`
}
return `The following paths aren't Git repositories:\n\n${invalidPaths
.slice(0, MaxInvalidFoldersToDisplay)
.map(path => `- ${path}`)
.join('\n')}${
invalidPaths.length > MaxInvalidFoldersToDisplay
? `\n\n(and ${invalidPaths.length - MaxInvalidFoldersToDisplay} more)`
: ''
}`
}
private async withAuthenticatingUser<T>(
repository: Repository,
fn: (repository: Repository, account: IGitAccount | null) => Promise<T>

View file

@ -151,15 +151,15 @@ export class CommitStatusStore {
*/
private readonly limit = pLimit(MaxConcurrentFetches)
private onAccountsUpdated = (accounts: ReadonlyArray<Account>) => {
this.accounts = accounts
}
public constructor(accountsStore: AccountsStore) {
accountsStore.getAll().then(this.onAccountsUpdated)
accountsStore.onDidUpdate(this.onAccountsUpdated)
}
private readonly onAccountsUpdated = (accounts: ReadonlyArray<Account>) => {
this.accounts = accounts
}
/**
* Called to ensure that background refreshing is running and fetching
* updated commit statuses for active subscriptions. The intention is

View file

@ -104,7 +104,7 @@ export class GitStore extends BaseStore {
public pullWithRebase?: boolean
private _history: ReadonlyArray<string> = new Array()
private _history: ReadonlyArray<string> = []
private readonly requestsInFight = new Set<string>()

View file

@ -9,6 +9,7 @@ import {
import { compare } from '../compare'
import { BaseStore } from './base-store'
import { getStealthEmailForUser, getLegacyStealthEmailForUser } from '../email'
import { DefaultMaxHits } from '../../ui/autocompletion/common'
/** Don't fetch mentionables more often than every 10 minutes */
const MaxFetchFrequency = 10 * 60 * 1000
@ -116,10 +117,7 @@ export class GitHubUserStore extends BaseStore {
response.etag
)
if (
this.queryCache !== null &&
this.queryCache.repository.dbID === repository.dbID
) {
if (this.queryCache?.repository.dbID === repository.dbID) {
this.queryCache = null
this.clearCachePruneTimeout()
}
@ -140,6 +138,9 @@ export class GitHubUserStore extends BaseStore {
* they matched. Search strings start with username and are followed
* by real name. Only the first substring hit is considered
*
* @param repository The GitHubRepository for which to look up
* mentionables.
*
* @param text A string to use when looking for a matching
* user. A user is considered a hit if this text
* matches any subtext of the username or real name
@ -149,7 +150,7 @@ export class GitHubUserStore extends BaseStore {
public async getMentionableUsersMatching(
repository: GitHubRepository,
query: string,
maxHits: number = 100
maxHits: number = DefaultMaxHits
): Promise<ReadonlyArray<IMentionableUser>> {
assertPersisted(repository)
@ -164,8 +165,7 @@ export class GitHubUserStore extends BaseStore {
const needle = query.toLowerCase()
// Simple substring comparison on login and real name
for (let i = 0; i < users.length && hits.length < maxHits; i++) {
const user = users[i]
for (const user of users) {
const ix = `${user.login} ${user.name}`
.trim()
.toLowerCase()
@ -185,6 +185,7 @@ export class GitHubUserStore extends BaseStore {
.sort(
(x, y) => compare(x.ix, y.ix) || compare(x.user.login, y.user.login)
)
.slice(0, maxHits)
.map(h => h.user)
}

View file

@ -151,7 +151,7 @@ let _skewInterval: number | null = null
*/
function skewInterval(): number {
if (_skewInterval !== null) {
return _skewInterval!
return _skewInterval
}
// We don't need cryptographically secure random numbers for

View file

@ -2,14 +2,34 @@ import { IssuesDatabase, IIssue } from '../databases/issues-database'
import { API, IAPIIssue } from '../api'
import { Account } from '../../models/account'
import { GitHubRepository } from '../../models/github-repository'
import { fatalError } from '../fatal-error'
import { compare, compareDescending } from '../compare'
import { DefaultMaxHits } from '../../ui/autocompletion/common'
/** The hard limit on the number of issue results we'd ever return. */
const IssueResultsHardLimit = 100
/** An autocompletion hit for an issue. */
export interface IIssueHit {
/** The title of the issue. */
readonly title: string
/** The issue's number. */
readonly number: number
}
/**
* The max time (in milliseconds) that we'll keep a mentionable query
* cache around before pruning it.
*/
const QueryCacheTimeout = 60 * 1000
interface IQueryCache {
readonly repository: GitHubRepository
readonly issues: ReadonlyArray<IIssueHit>
}
/** The store for GitHub issues. */
export class IssuesStore {
private db: IssuesDatabase
private queryCache: IQueryCache | null = null
private pruneQueryCacheTimeoutId: number | null = null
/** Initialize the store with the given database. */
public constructor(db: IssuesDatabase) {
@ -24,18 +44,13 @@ export class IssuesStore {
private async getLatestUpdatedAt(
repository: GitHubRepository
): Promise<Date | null> {
const gitHubRepositoryID = repository.dbID
if (!gitHubRepositoryID) {
return fatalError(
"Cannot get issues for a repository that hasn't been inserted into the database!"
)
}
assertPersisted(repository)
const db = this.db
const latestUpdatedIssue = await db.issues
.where('[gitHubRepositoryID+updated_at]')
.between([gitHubRepositoryID], [gitHubRepositoryID + 1], true, false)
.between([repository.dbID], [repository.dbID + 1], true, false)
.last()
if (!latestUpdatedIssue || !latestUpdatedIssue.updated_at) {
@ -79,19 +94,14 @@ export class IssuesStore {
issues: ReadonlyArray<IAPIIssue>,
repository: GitHubRepository
): Promise<void> {
const gitHubRepositoryID = repository.dbID
if (!gitHubRepositoryID) {
fatalError(
`Cannot store issues for a repository that hasn't been inserted into the database!`
)
}
assertPersisted(repository)
const issuesToDelete = issues.filter(i => i.state === 'closed')
const issuesToUpsert = issues
.filter(i => i.state === 'open')
.map<IIssue>(i => {
return {
gitHubRepositoryID,
gitHubRepositoryID: repository.dbID,
number: i.number,
title: i.title,
updated_at: i.updated_at,
@ -114,7 +124,7 @@ export class IssuesStore {
await this.db.transaction('rw', this.db.issues, async () => {
for (const issue of issuesToDelete) {
const existing = await findIssueInRepositoryByNumber(
gitHubRepositoryID,
repository.dbID,
issue.number
)
if (existing) {
@ -124,7 +134,7 @@ export class IssuesStore {
for (const issue of issuesToUpsert) {
const existing = await findIssueInRepositoryByNumber(
gitHubRepositoryID,
repository.dbID,
issue.number
)
if (existing) {
@ -134,50 +144,90 @@ export class IssuesStore {
}
}
})
if (this.queryCache?.repository.dbID === repository.dbID) {
this.queryCache = null
this.clearCachePruneTimeout()
}
}
private async getAllIssueHitsFor(repository: GitHubRepository) {
assertPersisted(repository)
const hits = await this.db.getIssuesForRepository(repository.dbID)
return hits.map(i => ({ number: i.number, title: i.title }))
}
/** Get issues whose title or number matches the text. */
public async getIssuesMatching(
repository: GitHubRepository,
text: string
): Promise<ReadonlyArray<IIssue>> {
const gitHubRepositoryID = repository.dbID
if (!gitHubRepositoryID) {
fatalError(
"Cannot get issues for a repository that hasn't been inserted into the database!"
)
}
text: string,
maxHits = DefaultMaxHits
): Promise<ReadonlyArray<IIssueHit>> {
assertPersisted(repository)
const issues =
this.queryCache?.repository.dbID === repository.dbID
? this.queryCache?.issues
: await this.getAllIssueHitsFor(repository)
this.setQueryCache(repository, issues)
if (!text.length) {
const issues = await this.db.issues
.where('gitHubRepositoryID')
.equals(gitHubRepositoryID)
.limit(IssueResultsHardLimit)
.reverse()
.sortBy('number')
return issues
.slice()
.sort((x, y) => compareDescending(x.number, y.number))
.slice(0, maxHits)
}
const MaxScore = 1
const score = (i: IIssue) => {
if (i.number.toString().startsWith(text)) {
return MaxScore
}
const hits = []
const needle = text.toLowerCase()
if (i.title.toLowerCase().includes(text.toLowerCase())) {
return MaxScore - 0.1
}
for (const issue of issues) {
const ix = `${issue.number} ${issue.title}`
.trim()
.toLowerCase()
.indexOf(needle)
return 0
if (ix >= 0) {
hits.push({ hit: { number: issue.number, title: issue.title }, ix })
}
}
const issuesCollection = await this.db.issues
.where('gitHubRepositoryID')
.equals(gitHubRepositoryID)
.filter(i => score(i) > 0)
// Sort hits primarily based on how early in the text the match
// was found and then secondarily using alphabetic order.
return hits
.sort((x, y) => compare(x.ix, y.ix) || compare(x.hit.title, y.hit.title))
.slice(0, maxHits)
.map(h => h.hit)
}
const issues = await issuesCollection.limit(IssueResultsHardLimit).toArray()
private setQueryCache(
repository: GitHubRepository,
issues: ReadonlyArray<IIssueHit>
) {
this.clearCachePruneTimeout()
this.queryCache = { repository, issues }
this.pruneQueryCacheTimeoutId = window.setTimeout(() => {
this.pruneQueryCacheTimeoutId = null
this.queryCache = null
}, QueryCacheTimeout)
}
return issues.sort((a, b) => score(b) - score(a))
private clearCachePruneTimeout() {
if (this.pruneQueryCacheTimeoutId !== null) {
clearTimeout(this.pruneQueryCacheTimeoutId)
this.pruneQueryCacheTimeoutId = null
}
}
}
function assertPersisted(
repo: GitHubRepository
): asserts repo is GitHubRepository & { dbID: number } {
if (repo.dbID === null) {
throw new Error(
`Need a GitHubRepository that's been inserted into the database`
)
}
}

View file

@ -41,9 +41,8 @@ export class AppWindow {
// Disable auxclick event
// See https://developers.google.com/web/updates/2016/10/auxclick
disableBlinkFeatures: 'Auxclick',
// Enable, among other things, the ResizeObserver
experimentalFeatures: true,
nodeIntegration: true,
enableRemoteModule: true,
},
acceptFirstMouse: true,
}

View file

@ -36,13 +36,6 @@ export class CrashWindow {
// Disable auxclick event
// See https://developers.google.com/web/updates/2016/10/auxclick
disableBlinkFeatures: 'Auxclick',
// Explicitly disable experimental features for the crash process
// since, theoretically it might be these features that caused the
// the crash in the first place. As of writing we don't use any
// components that relies on experimental features in the crash
// process but our components which relies on ResizeObserver should
// be able to degrade gracefully.
experimentalFeatures: false,
nodeIntegration: true,
},
}

View file

@ -16,7 +16,7 @@ import { fatalError } from '../lib/fatal-error'
import { IMenuItemState } from '../lib/menu-update'
import { LogLevel } from '../lib/logging/log-level'
import { log as writeLog } from './log'
import { openDirectorySafe } from './shell'
import { UNSAFE_openDirectory } from './shell'
import { reportError } from './exception-reporting'
import {
enableSourceMaps,
@ -27,8 +27,23 @@ import { showUncaughtException } from './show-uncaught-exception'
import { ISerializableMenuItem } from '../lib/menu-item'
import { buildContextMenu } from './menu/build-context-menu'
import { sendNonFatalException } from '../lib/helpers/non-fatal-exception'
import { stat } from 'fs-extra'
import { isApplicationBundle } from '../lib/is-application-bundle'
app.setAppLogsPath()
/**
* While testing Electron 9 on Windows we were seeing fairly
* consistent hangs that seem similar to the following issues
*
* https://github.com/electron/electron/issues/24173
* https://github.com/electron/electron/issues/23910
* https://github.com/electron/electron/issues/24338
*
* TODO: Try removing when upgrading to Electron vNext
*/
app.allowRendererProcessReuse = false
enableSourceMaps()
let mainWindow: AppWindow | null = null
@ -385,7 +400,7 @@ app.on('ready', () => {
const menuItem = currentMenu.getMenuItemById(id)
if (menuItem) {
const window = BrowserWindow.fromWebContents(event.sender)
const window = BrowserWindow.fromWebContents(event.sender) || undefined
const fakeEvent = { preventDefault: () => {}, sender: event.sender }
menuItem.click(fakeEvent, window, event.sender)
}
@ -450,8 +465,8 @@ app.on('ready', () => {
): Promise<ReadonlyArray<number> | null> => {
return new Promise(resolve => {
const menu = buildContextMenu(items, indices => resolve(indices))
const window = BrowserWindow.fromWebContents(event.sender) || undefined
const window = BrowserWindow.fromWebContents(event.sender)
menu.popup({ window, callback: () => resolve(null) })
})
}
@ -546,20 +561,66 @@ app.on('ready', () => {
ipcMain.on(
'show-item-in-folder',
(event: Electron.IpcMainEvent, { path }: { path: string }) => {
Fs.stat(path, (err, stats) => {
Fs.stat(path, err => {
if (err) {
log.error(`Unable to find file at '${path}'`, err)
return
}
if (!__DARWIN__ && stats.isDirectory()) {
openDirectorySafe(path)
} else {
shell.showItemInFolder(path)
}
shell.showItemInFolder(path)
})
}
)
ipcMain.on(
'show-folder-contents',
async (event: Electron.IpcMainEvent, { path }: { path: string }) => {
const stats = await stat(path).catch(err => {
log.error(`Unable to retrieve file information for ${path}`, err)
return null
})
if (!stats) {
return
}
if (!stats.isDirectory()) {
log.error(
`Trying to get the folder contents of a non-folder at '${path}'`
)
shell.showItemInFolder(path)
return
}
// On Windows and Linux we can count on a directory being just a
// directory.
if (!__DARWIN__) {
UNSAFE_openDirectory(path)
return
}
// On macOS a directory might also be an app bundle and if it is
// and we attempt to open it we're gonna execute that app which
// it far from ideal so we'll look up the metadata for the path
// and attempt to determine whether it's an app bundle or not.
//
// If we fail loading the metadata we'll assume it's an app bundle
// out of an abundance of caution.
const isBundle = await isApplicationBundle(path).catch(err => {
log.error(`Failed to load metadata for path '${path}'`, err)
return true
})
if (isBundle) {
log.info(
`Preventing direct open of path '${path}' as it appears to be an application bundle`
)
shell.showItemInFolder(path)
} else {
UNSAFE_openDirectory(path)
}
}
)
})
app.on('activate', () => {

View file

@ -4,7 +4,7 @@ import { MenuEvent } from './menu-event'
import { truncateWithEllipsis } from '../../lib/truncate-with-ellipsis'
import { getLogDirectoryPath } from '../../lib/logging/get-log-path'
import { ensureDir } from 'fs-extra'
import { openDirectorySafe } from '../shell'
import { UNSAFE_openDirectory } from '../shell'
import { enableCreateGitHubIssueFromMenu } from '../../lib/feature-flag'
import { MenuLabelsEvent } from '../../models/menu-labels'
import { DefaultEditorLabel } from '../../ui/lib/context-menu'
@ -245,7 +245,7 @@ export function buildDefaultMenu({
// chorded shortcuts, but this menu item is not a user-facing feature
// so we are going to keep this one around.
accelerator: 'CmdOrCtrl+Alt+R',
click(item: any, focusedWindow: Electron.BrowserWindow) {
click(item: any, focusedWindow: Electron.BrowserWindow | undefined) {
if (focusedWindow) {
focusedWindow.reload()
}
@ -260,7 +260,7 @@ export function buildDefaultMenu({
accelerator: (() => {
return __DARWIN__ ? 'Alt+Command+I' : 'Ctrl+Shift+I'
})(),
click(item: any, focusedWindow: Electron.BrowserWindow) {
click(item: any, focusedWindow: Electron.BrowserWindow | undefined) {
if (focusedWindow) {
focusedWindow.webContents.toggleDevTools()
}
@ -495,7 +495,7 @@ export function buildDefaultMenu({
const logPath = getLogDirectoryPath()
ensureDir(logPath)
.then(() => {
openDirectorySafe(logPath)
UNSAFE_openDirectory(logPath)
})
.catch(err => {
log.error('Failed opening logs directory', err)
@ -590,7 +590,7 @@ function getStashedChangesLabel(isStashedChangesVisible: boolean): string {
type ClickHandler = (
menuItem: Electron.MenuItem,
browserWindow: Electron.BrowserWindow,
browserWindow: Electron.BrowserWindow | undefined,
event: Electron.Event
) => void

View file

@ -8,9 +8,14 @@ import { shell } from 'electron'
* window, which may confuse users. As a workaround, we will fallback to using
* shell.openExternal for macOS until it can be fixed upstream.
*
* CAUTION: This method should never be used to open user-provided or derived
* paths. It's sole use is to open _directories_ that we know to be safe, no
* verification is performed to ensure that the provided path isn't actually
* an executable.
*
* @param path directory to open
*/
export function openDirectorySafe(path: string) {
export function UNSAFE_openDirectory(path: string) {
if (__DARWIN__) {
const directoryURL = Url.format({
pathname: path,
@ -22,6 +27,19 @@ export function openDirectorySafe(path: string) {
.openExternal(directoryURL)
.catch(err => log.error(`Failed to open directory (${path})`, err))
} else {
shell.openItem(path)
// Add a trailing slash to the directory path.
//
// On Windows, if there's a file and a directory with the
// same name (e.g `C:\MyFolder\foo` and `C:\MyFolder\foo.exe`),
// when executing shell.openItem(`C:\MyFolder\foo`) then the EXE file
// will get opened.
// We can avoid this by adding a final backslash at the end of the path.
const pathname = __WIN32__ && !path.endsWith('\\') ? `${path}\\` : path
shell.openPath(pathname).then(err => {
if (err !== '') {
log.error(`Failed to open directory (${path}): ${err}`)
}
})
}
}

View file

@ -289,8 +289,6 @@ export class CommittedFileChange extends FileChange {
/** the state of the working directory for a repository */
export class WorkingDirectoryStatus {
private readonly fileIxById = new Map<string, number>()
/** Create a new status with the given files. */
public static fromFiles(
files: ReadonlyArray<WorkingDirectoryFileChange>
@ -298,6 +296,7 @@ export class WorkingDirectoryStatus {
return new WorkingDirectoryStatus(files, getIncludeAllState(files))
}
private readonly fileIxById = new Map<string, number>()
/**
* @param files The list of changes in the repository's working directory.
* @param includeAll Update the include checkbox state of the form.

View file

@ -121,6 +121,7 @@ import { DeleteTag } from './delete-tag'
import { ChooseForkSettings } from './choose-fork-settings'
import { DiscardSelection } from './discard-changes/discard-selection-dialog'
import { LocalChangesOverwrittenDialog } from './local-changes-overwritten/local-changes-overwritten-dialog'
import memoizeOne from 'memoize-one'
const MinuteInMilliseconds = 1000 * 60
const HourInMilliseconds = MinuteInMilliseconds * 60
@ -189,9 +190,9 @@ export class App extends React.Component<IAppProps, IAppState> {
* passed popupType, so it can be used in render() without creating
* multiple instances when the component gets re-rendered.
*/
private getOnPopupDismissedFn = (popupType: PopupType) => {
private getOnPopupDismissedFn = memoizeOne((popupType: PopupType) => {
return () => this.onPopupDismissed(popupType)
}
})
public constructor(props: IAppProps) {
super(props)
@ -1274,19 +1275,13 @@ export class App extends React.Component<IAppProps, IAppState> {
)
}
private onPopupDismissed = (popupType?: PopupType) => {
private onPopupDismissed = (popupType: PopupType) => {
return this.props.dispatcher.closePopup(popupType)
}
private onSignInDialogDismissed = () => {
this.props.dispatcher.resetSignInState()
this.onPopupDismissed()
}
private onContinueWithUntrustedCertificate = (
certificate: Electron.Certificate
) => {
this.props.dispatcher.closePopup()
showCertificateTrustDialog(
certificate,
'Could not securely connect to the server, because its certificate is not trusted. Attackers might be trying to steal your information.\n\nTo connect unsafely, which may put your data at risk, you can “Always trust” the certificate and try again.'
@ -1308,6 +1303,8 @@ export class App extends React.Component<IAppProps, IAppState> {
return null
}
const onPopupDismissedFn = this.getOnPopupDismissedFn(popup.type)
switch (popup.type) {
case PopupType.RenameBranch:
const stash =
@ -1322,7 +1319,7 @@ export class App extends React.Component<IAppProps, IAppState> {
repository={popup.repository}
branch={popup.branch}
stash={stash}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.DeleteBranch:
@ -1333,7 +1330,7 @@ export class App extends React.Component<IAppProps, IAppState> {
repository={popup.repository}
branch={popup.branch}
existsOnRemote={popup.existsOnRemote}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onDeleted={this.onBranchDeleted}
/>
)
@ -1358,7 +1355,7 @@ export class App extends React.Component<IAppProps, IAppState> {
}
showDiscardChangesSetting={showSetting}
discardingAllChanges={discardingAllChanges}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onConfirmDiscardChangesChanged={this.onConfirmDiscardChangesChanged}
/>
)
@ -1371,7 +1368,7 @@ export class App extends React.Component<IAppProps, IAppState> {
file={popup.file}
diff={popup.diff}
selection={popup.selection}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.Preferences:
@ -1394,7 +1391,7 @@ export class App extends React.Component<IAppProps, IAppState> {
selectedExternalEditor={this.state.selectedExternalEditor}
optOutOfUsageTracking={this.state.optOutOfUsageTracking}
enterpriseAccount={this.getEnterpriseAccount()}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
selectedShell={this.state.selectedShell}
selectedTheme={this.state.selectedTheme}
automaticallySwitchTheme={this.state.automaticallySwitchTheme}
@ -1424,7 +1421,7 @@ export class App extends React.Component<IAppProps, IAppState> {
recentBranches={state.branchesState.recentBranches}
currentBranch={currentBranch}
initialBranch={branch}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1438,7 +1435,7 @@ export class App extends React.Component<IAppProps, IAppState> {
remote={state.remote}
dispatcher={this.props.dispatcher}
repository={repository}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1448,14 +1445,14 @@ export class App extends React.Component<IAppProps, IAppState> {
key="sign-in"
signInState={this.state.signInState}
dispatcher={this.props.dispatcher}
onDismissed={this.onSignInDialogDismissed}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.AddRepository:
return (
<AddExistingRepository
key="add-existing-repository"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
path={popup.path}
/>
@ -1464,7 +1461,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<CreateRepository
key="create-repository"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
initialPath={popup.path}
/>
@ -1476,7 +1473,7 @@ export class App extends React.Component<IAppProps, IAppState> {
dotComAccount={this.getDotComAccount()}
enterpriseAccount={this.getEnterpriseAccount()}
initialURL={popup.initialURL}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
selectedTab={this.state.selectedCloneRepositoryTab}
onTabSelected={this.onCloneRepositoriesTabSelected}
@ -1491,7 +1488,7 @@ export class App extends React.Component<IAppProps, IAppState> {
const repository = popup.repository
if (branchesState.tip.kind === TipState.Unknown) {
this.props.dispatcher.closePopup()
onPopupDismissedFn()
return null
}
@ -1518,7 +1515,7 @@ export class App extends React.Component<IAppProps, IAppState> {
allBranches={branchesState.allBranches}
repository={repository}
upstreamGitHubRepository={upstreamGhRepo}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
initialName={popup.initialName || ''}
currentBranchProtected={currentBranchProtected}
@ -1532,7 +1529,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<InstallGit
key="install-git"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onOpenShell={this.onOpenShellIgnoreWarning}
path={popup.path}
/>
@ -1543,7 +1540,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<About
key="about"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
applicationName={getName()}
applicationVersion={version}
onCheckForUpdates={this.onCheckForUpdates}
@ -1558,7 +1555,7 @@ export class App extends React.Component<IAppProps, IAppState> {
dispatcher={this.props.dispatcher}
repository={popup.repository}
accounts={this.state.accounts}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.UntrustedCertificate:
@ -1567,7 +1564,7 @@ export class App extends React.Component<IAppProps, IAppState> {
key="untrusted-certificate"
certificate={popup.certificate}
url={popup.url}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onContinue={this.onContinueWithUntrustedCertificate}
/>
)
@ -1575,7 +1572,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<Acknowledgements
key="acknowledgements"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
applicationVersion={getVersion()}
/>
)
@ -1585,14 +1582,14 @@ export class App extends React.Component<IAppProps, IAppState> {
key="confirm-remove-repository"
repository={popup.repository}
onConfirmation={this.onConfirmRepoRemoval}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.TermsAndConditions:
return (
<TermsAndConditions
key="terms-and-conditions"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.PushBranchCommits:
@ -1604,22 +1601,19 @@ export class App extends React.Component<IAppProps, IAppState> {
branch={popup.branch}
unPushedCommits={popup.unPushedCommits}
onConfirm={this.openCreatePullRequestInBrowser}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.CLIInstalled:
return (
<CLIInstalled
key="cli-installed"
onDismissed={this.onPopupDismissed}
/>
<CLIInstalled key="cli-installed" onDismissed={onPopupDismissedFn} />
)
case PopupType.GenericGitAuthentication:
return (
<GenericGitAuthentication
key="generic-git-authentication"
hostname={popup.hostname}
onDismiss={this.onPopupDismissed}
onDismiss={onPopupDismissedFn}
onSave={this.onSaveCredentials}
retryAction={popup.retryAction}
/>
@ -1632,7 +1626,7 @@ export class App extends React.Component<IAppProps, IAppState> {
<EditorError
key="editor-error"
message={popup.message}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
showPreferencesDialog={this.onShowAdvancedPreferences}
viewPreferences={openPreferences}
suggestAtom={suggestAtom}
@ -1643,7 +1637,7 @@ export class App extends React.Component<IAppProps, IAppState> {
<ShellError
key="shell-error"
message={popup.message}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
showPreferencesDialog={this.onShowAdvancedPreferences}
/>
)
@ -1652,7 +1646,7 @@ export class App extends React.Component<IAppProps, IAppState> {
<InitializeLFS
key="initialize-lfs"
repositories={popup.repositories}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onInitialize={this.initializeLFS}
/>
)
@ -1660,7 +1654,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<AttributeMismatch
key="lsf-attribute-mismatch"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onUpdateExistingFilters={this.updateExistingLFSFilters}
/>
)
@ -1670,7 +1664,7 @@ export class App extends React.Component<IAppProps, IAppState> {
key="upstream-already-exists"
repository={popup.repository}
existingRemote={popup.existingRemote}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onUpdate={this.onUpdateExistingUpstreamRemote}
onIgnore={this.onIgnoreExistingUpstreamRemote}
/>
@ -1681,7 +1675,7 @@ export class App extends React.Component<IAppProps, IAppState> {
key="release-notes"
emoji={this.state.emoji}
newRelease={popup.newRelease}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.DeletePullRequest:
@ -1691,7 +1685,7 @@ export class App extends React.Component<IAppProps, IAppState> {
dispatcher={this.props.dispatcher}
repository={popup.repository}
branch={popup.branch}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
pullRequest={popup.pullRequest}
/>
)
@ -1719,7 +1713,7 @@ export class App extends React.Component<IAppProps, IAppState> {
dispatcher={this.props.dispatcher}
repository={popup.repository}
workingDirectory={workingDirectory}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
openFileInExternalEditor={this.openFileInExternalEditor}
resolvedExternalEditor={this.state.resolvedExternalEditor}
openRepositoryInShell={this.openInShell}
@ -1734,7 +1728,7 @@ export class App extends React.Component<IAppProps, IAppState> {
<OversizedFiles
key="oversized-files"
oversizedFiles={popup.oversizedFiles}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
context={popup.context}
repository={popup.repository}
@ -1762,7 +1756,7 @@ export class App extends React.Component<IAppProps, IAppState> {
key="abort-merge-warning"
dispatcher={this.props.dispatcher}
repository={popup.repository}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
ourBranch={popup.ourBranch}
theirBranch={popup.theirBranch}
/>
@ -1773,7 +1767,8 @@ export class App extends React.Component<IAppProps, IAppState> {
<UsageStatsChange
key="usage-stats-change"
onOpenUsageDataUrl={this.openUsageDataUrl}
onDismissed={this.onUsageReportingDismissed}
onSetStatsOptOut={this.onSetStatsOptOut}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.CommitConflictsWarning:
@ -1784,7 +1779,7 @@ export class App extends React.Component<IAppProps, IAppState> {
files={popup.files}
repository={popup.repository}
context={popup.context}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.PushNeedsPull:
@ -1793,7 +1788,7 @@ export class App extends React.Component<IAppProps, IAppState> {
key="push-needs-pull"
dispatcher={this.props.dispatcher}
repository={popup.repository}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
case PopupType.RebaseFlow: {
@ -1831,6 +1826,7 @@ export class App extends React.Component<IAppProps, IAppState> {
openFileInExternalEditor={this.openFileInExternalEditor}
dispatcher={this.props.dispatcher}
onFlowEnded={this.onRebaseFlowEnded}
onDismissed={onPopupDismissedFn}
workingDirectory={workingDirectory}
progress={progress}
step={step}
@ -1855,7 +1851,7 @@ export class App extends React.Component<IAppProps, IAppState> {
repository={popup.repository}
upstreamBranch={popup.upstreamBranch}
askForConfirmationOnForcePush={askForConfirmationOnForcePush}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1882,7 +1878,7 @@ export class App extends React.Component<IAppProps, IAppState> {
currentBranch={currentBranch}
branchToCheckout={branchToCheckout}
hasAssociatedStash={hasAssociatedStash}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1894,7 +1890,7 @@ export class App extends React.Component<IAppProps, IAppState> {
dispatcher={this.props.dispatcher}
repository={repository}
branchToCheckout={branchToCheckout}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1907,7 +1903,7 @@ export class App extends React.Component<IAppProps, IAppState> {
dispatcher={this.props.dispatcher}
repository={repository}
stash={stash}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1917,7 +1913,7 @@ export class App extends React.Component<IAppProps, IAppState> {
key="create-tutorial-repository-dialog"
account={popup.account}
progress={popup.progress}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onCreateTutorialRepository={this.onCreateTutorialRepository}
/>
)
@ -1926,7 +1922,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<ConfirmExitTutorial
key="confirm-exit-tutorial"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onContinue={this.onExitTutorialToHomeScreen}
/>
)
@ -1934,7 +1930,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.PushRejectedDueToMissingWorkflowScope:
return (
<WorkflowPushRejectedDialog
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
rejectedPath={popup.rejectedPath}
dispatcher={this.props.dispatcher}
repository={popup.repository}
@ -1943,7 +1939,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.SAMLReauthRequired:
return (
<SAMLReauthRequiredDialog
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
organizationName={popup.organizationName}
endpoint={popup.endpoint}
retryAction={popup.retryAction}
@ -1953,7 +1949,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.CreateFork:
return (
<CreateForkDialog
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
repository={popup.repository}
account={popup.account}
@ -1962,7 +1958,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.SChannelNoRevocationCheck:
return (
<SChannelNoRevocationCheckDialog
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
url={popup.url}
/>
)
@ -1971,7 +1967,7 @@ export class App extends React.Component<IAppProps, IAppState> {
<CreateTag
key="create-tag"
repository={popup.repository}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
targetCommitSha={popup.targetCommitSha}
initialName={popup.initialName}
@ -1984,7 +1980,7 @@ export class App extends React.Component<IAppProps, IAppState> {
<DeleteTag
key="delete-tag"
repository={popup.repository}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
tagName={popup.tagName}
/>
@ -1994,7 +1990,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<ChooseForkSettings
repository={popup.repository}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
/>
)
@ -2014,7 +2010,7 @@ export class App extends React.Component<IAppProps, IAppState> {
dispatcher={this.props.dispatcher}
hasExistingStash={existingStash !== null}
retryAction={popup.retryAction}
onDismissed={this.getOnPopupDismissedFn(popup.type)}
onDismissed={onPopupDismissedFn}
/>
)
default:
@ -2025,11 +2021,11 @@ export class App extends React.Component<IAppProps, IAppState> {
private onExitTutorialToHomeScreen = () => {
const tutorialRepository = this.getSelectedTutorialRepository()
if (!tutorialRepository) {
return
return false
}
this.props.dispatcher.pauseTutorial(tutorialRepository)
this.props.dispatcher.closePopup()
return true
}
private onCreateTutorialRepository = (account: Account) => {
@ -2073,14 +2069,12 @@ export class App extends React.Component<IAppProps, IAppState> {
}
private onRebaseFlowEnded = (repository: Repository) => {
this.props.dispatcher.closePopup()
this.props.dispatcher.endRebaseFlow(repository)
}
private onUsageReportingDismissed = (optOut: boolean) => {
private onSetStatsOptOut = (optOut: boolean) => {
this.props.appStore.setStatsOptOut(optOut, true)
this.props.appStore.markUsageStatsNoteSeen()
this.onPopupDismissed()
this.props.appStore._reportStats()
}
@ -2098,12 +2092,10 @@ export class App extends React.Component<IAppProps, IAppState> {
private updateExistingLFSFilters = () => {
this.props.dispatcher.installGlobalLFSFilters(true)
this.onPopupDismissed()
}
private initializeLFS = (repositories: ReadonlyArray<Repository>) => {
this.props.dispatcher.installLFSHooks(repositories)
this.onPopupDismissed()
}
private onCloneRepositoriesTabSelected = (tab: CloneRepositoryTab) => {
@ -2123,7 +2115,6 @@ export class App extends React.Component<IAppProps, IAppState> {
private onOpenShellIgnoreWarning = (path: string) => {
this.props.dispatcher.openShell(path, true)
this.onPopupDismissed()
}
private onSaveCredentials = async (
@ -2132,8 +2123,6 @@ export class App extends React.Component<IAppProps, IAppState> {
password: string,
retryAction: RetryAction
) => {
this.onPopupDismissed()
await this.props.dispatcher.saveGenericGitCredentials(
hostname,
username,
@ -2270,7 +2259,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return
}
shell.showItemInFolder(repository.path)
shell.showFolderContents(repository.path)
}
private onRepositoryDropdownStateChanged = (newState: DropdownState) => {

View file

@ -108,6 +108,12 @@ export abstract class AutocompletingTextInput<
/** The identifier for each autocompletion request. */
private autocompletionRequestID = 0
/**
* To be implemented by subclasses. It must return the element tag name which
* should correspond to the ElementType over which it is parameterized.
*/
protected abstract getElementTagName(): 'textarea' | 'input'
public constructor(props: IAutocompletingTextInputProps<ElementType>) {
super(props)
@ -254,12 +260,6 @@ export abstract class AutocompletingTextInput<
this.insertCompletion(item, 'mouseclick')
}
/**
* To be implemented by subclasses. It must return the element tag name which
* should correspond to the ElementType over which it is parameterized.
*/
protected abstract getElementTagName(): 'textarea' | 'input'
private onContextMenu = (event: React.MouseEvent<any>) => {
if (this.props.onContextMenu) {
this.props.onContextMenu(event)

View file

@ -0,0 +1,5 @@
/**
* The default maximum number of hits to return from
* either of the autocompletion providers.
*/
export const DefaultMaxHits = 25

View file

@ -1,6 +1,7 @@
import * as React from 'react'
import { IAutocompletionProvider } from './index'
import { compare } from '../../lib/compare'
import { DefaultMaxHits } from './common'
/**
* Interface describing a autocomplete match for the given search
@ -29,7 +30,7 @@ export class EmojiAutocompletionProvider
implements IAutocompletionProvider<IEmojiHit> {
public readonly kind = 'emoji'
private emoji: Map<string, string>
private readonly emoji: Map<string, string>
public constructor(emoji: Map<string, string>) {
this.emoji = emoji
@ -40,15 +41,16 @@ export class EmojiAutocompletionProvider
}
public async getAutocompletionItems(
text: string
text: string,
maxHits = DefaultMaxHits
): Promise<ReadonlyArray<IEmojiHit>> {
// Empty strings is falsy, this is the happy path to avoid
// sorting and matching when the user types a ':'. We want
// to open the popup with suggestions as fast as possible.
if (!text) {
return Array.from(this.emoji.keys()).map<IEmojiHit>(emoji => {
return { emoji: emoji, matchStart: 0, matchLength: 0 }
})
// This is the happy path to avoid sorting and matching
// when the user types a ':'. We want to open the popup
// with suggestions as fast as possible.
if (text.length === 0) {
return [...this.emoji.keys()]
.map(emoji => ({ emoji, matchStart: 0, matchLength: 0 }))
.slice(0, maxHits)
}
const results = new Array<IEmojiHit>()
@ -72,12 +74,14 @@ export class EmojiAutocompletionProvider
//
// If both those start and length are equal we sort
// alphabetically
return results.sort(
(x, y) =>
compare(x.matchStart, y.matchStart) ||
compare(x.emoji.length, y.emoji.length) ||
compare(x.emoji, y.emoji)
)
return results
.sort(
(x, y) =>
compare(x.matchStart, y.matchStart) ||
compare(x.emoji.length, y.emoji.length) ||
compare(x.emoji, y.emoji)
)
.slice(0, maxHits)
}
public renderItem(hit: IEmojiHit) {

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import { IAutocompletionProvider } from './index'
import { IssuesStore } from '../../lib/stores'
import { IssuesStore, IIssueHit } from '../../lib/stores/issues-store'
import { Dispatcher } from '../dispatcher'
import { GitHubRepository } from '../../models/github-repository'
import { ThrottledScheduler } from '../lib/throttled-scheduler'
@ -8,15 +8,6 @@ import { ThrottledScheduler } from '../lib/throttled-scheduler'
/** The interval we should use to throttle the issues update. */
const UpdateIssuesThrottleInterval = 1000 * 60
/** An autocompletion hit for an issue. */
export interface IIssueHit {
/** The title of the issue. */
readonly title: string
/** The issue's number. */
readonly number: number
}
/** The autocompletion provider for issues in a GitHub repository. */
export class IssuesAutocompletionProvider
implements IAutocompletionProvider<IIssueHit> {

View file

@ -56,8 +56,14 @@ interface IBranchesContainerProps {
}
interface IBranchesContainerState {
readonly selectedBranch: Branch | null
/**
* A copy of the last seen currentPullRequest property
* from props. Used in order to be able to detect when
* the selected PR in props changes in getDerivedStateFromProps
*/
readonly currentPullRequest: PullRequest | null
readonly selectedPullRequest: PullRequest | null
readonly selectedBranch: Branch | null
readonly branchFilterText: string
}
@ -66,12 +72,27 @@ export class BranchesContainer extends React.Component<
IBranchesContainerProps,
IBranchesContainerState
> {
public static getDerivedStateFromProps(
props: IBranchesContainerProps,
state: IBranchesContainerProps
): Partial<IBranchesContainerState> | null {
if (state.currentPullRequest !== props.currentPullRequest) {
return {
currentPullRequest: props.currentPullRequest,
selectedPullRequest: props.currentPullRequest,
}
}
return null
}
public constructor(props: IBranchesContainerProps) {
super(props)
this.state = {
selectedBranch: props.currentBranch,
selectedPullRequest: props.currentPullRequest,
currentPullRequest: props.currentPullRequest,
branchFilterText: '',
}
}
@ -188,7 +209,7 @@ export class BranchesContainer extends React.Component<
<PullRequestList
key="pr-list"
pullRequests={this.props.pullRequests}
selectedPullRequest={this.props.currentPullRequest}
selectedPullRequest={this.state.selectedPullRequest}
isOnDefaultBranch={!!isOnDefaultBranch}
onSelectionChanged={this.onPullRequestSelectionChanged}
onCreateBranch={this.onCreateBranch}

View file

@ -123,6 +123,7 @@ interface IChangesListProps {
/**
* Called to open a file it its default application
*
* @param path The path of the file relative to the root of the repository
*/
readonly onOpenItem: (path: string) => void

View file

@ -443,8 +443,13 @@ export class CommitMessage extends React.Component<
return <div className={className}>{this.renderCoAuthorToggleButton()}</div>
}
private renderPermissionsCommitWarning = (branch: string) => {
const { showBranchProtected, showNoWriteAccess, repository } = this.props
private renderPermissionsCommitWarning() {
const {
showBranchProtected,
showNoWriteAccess,
repository,
branch,
} = this.props
if (showNoWriteAccess) {
return (
@ -455,6 +460,14 @@ export class CommitMessage extends React.Component<
</PermissionsCommitWarning>
)
} else if (showBranchProtected) {
if (branch === null) {
// If the branch is null that means we haven't loaded the tip yet or
// we're on a detached head. We shouldn't ever end up here with
// showBranchProtected being true without a branch but who knows
// what fun and exiting edge cases the future might hold
return null
}
return (
<PermissionsCommitWarning>
<strong>{branch}</strong> is a protected branch. Want to{' '}
@ -480,8 +493,6 @@ export class CommitMessage extends React.Component<
}
public render() {
const branchName = this.props.branch ? this.props.branch : 'master'
const isSummaryWhiteSpace = this.state.summary.match(/^\s+$/g)
const buttonEnabled =
this.canCommit() && !this.props.isCommitting && !isSummaryWhiteSpace
@ -500,6 +511,19 @@ export class CommitMessage extends React.Component<
'nudge-arrow-left': this.props.shouldNudge,
})
const branchName = this.props.branch
const commitVerb = loading ? 'Committing' : 'Commit'
const commitTitle =
branchName !== null ? `${commitVerb} to ${branchName}` : commitVerb
const commitButtonContents =
branchName !== null ? (
<>
{commitVerb} to <strong>{branchName}</strong>
</>
) : (
commitVerb
)
return (
<div
id="commit-message"
@ -545,7 +569,7 @@ export class CommitMessage extends React.Component<
{this.renderCoAuthorInput()}
{this.renderPermissionsCommitWarning(branchName)}
{this.renderPermissionsCommitWarning()}
<Button
type="submit"
@ -554,9 +578,7 @@ export class CommitMessage extends React.Component<
disabled={!buttonEnabled}
>
{loading}
<span title={`Commit to ${branchName}`}>
{loading ? 'Committing' : 'Commit'} to <strong>{branchName}</strong>
</span>
<span title={commitTitle}>{commitButtonContents}</span>
</Button>
</div>
)

View file

@ -75,7 +75,7 @@ export class OversizedFiles extends React.Component<IOversizedFilesProps> {
}
private onSubmit = async () => {
this.props.dispatcher.closePopup()
this.props.onDismissed()
await this.props.dispatcher.commitIncludedChanges(
this.props.repository,

View file

@ -262,6 +262,7 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
/**
* Open file with default application.
*
* @param path The path of the file relative to the root of the repository
*/
private onOpenItem = (path: string) => {

View file

@ -2,9 +2,7 @@ import * as React from 'react'
import { Repository } from '../../models/repository'
import { Dispatcher } from '../dispatcher'
import { sanitizedRefName } from '../../lib/sanitize-ref-name'
import { Branch, StartPoint } from '../../models/branch'
import { TextBox } from '../lib/text-box'
import { Row } from '../lib/row'
import { Ref } from '../lib/ref'
import { LinkButton } from '../lib/link-button'
@ -20,10 +18,7 @@ import {
IValidBranch,
} from '../../models/tip'
import { assertNever } from '../../lib/fatal-error'
import {
renderBranchNameWarning,
renderBranchNameExistsOnRemoteWarning,
} from '../lib/branch-name-warnings'
import { renderBranchNameExistsOnRemoteWarning } from '../lib/branch-name-warnings'
import { getStartPoint } from '../../lib/create-branch'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { startTimer } from '../lib/timing'
@ -32,6 +27,7 @@ import {
UncommittedChangesStrategyKind,
} from '../../models/uncommitted-changes-strategy'
import { GitHubRepository } from '../../models/github-repository'
import { RefNameTextBox } from '../lib/ref-name-text-box'
interface ICreateBranchProps {
readonly repository: Repository
@ -49,8 +45,7 @@ interface ICreateBranchProps {
interface ICreateBranchState {
readonly currentError: Error | null
readonly proposedName: string
readonly sanitizedName: string
readonly branchName: string
readonly startPoint: StartPoint
/**
@ -94,8 +89,7 @@ export class CreateBranch extends React.Component<
this.state = {
currentError: null,
proposedName: props.initialName,
sanitizedName: '',
branchName: props.initialName,
startPoint,
isCreatingBranch: false,
tipAtCreateStart: props.tip,
@ -103,12 +97,6 @@ export class CreateBranch extends React.Component<
}
}
public componentDidMount() {
if (this.state.proposedName.length) {
this.updateBranchName(this.state.proposedName)
}
}
public componentWillReceiveProps(nextProps: ICreateBranchProps) {
this.setState({
startPoint: getStartPoint(nextProps, this.state.startPoint),
@ -183,9 +171,9 @@ export class CreateBranch extends React.Component<
public render() {
const disabled =
this.state.proposedName.length <= 0 ||
this.state.branchName.length <= 0 ||
!!this.state.currentError ||
/^\s*$/.test(this.state.sanitizedName)
/^\s*$/.test(this.state.branchName)
const error = this.state.currentError
return (
@ -200,22 +188,14 @@ export class CreateBranch extends React.Component<
{error ? <DialogError>{error.message}</DialogError> : null}
<DialogContent>
<Row>
<TextBox
label="Name"
value={this.state.proposedName}
autoFocus={true}
onValueChanged={this.onBranchNameChange}
/>
</Row>
{renderBranchNameWarning(
this.state.proposedName,
this.state.sanitizedName
)}
<RefNameTextBox
label="Name"
initialValue={this.props.initialName}
onValueChange={this.onBranchNameChange}
/>
{renderBranchNameExistsOnRemoteWarning(
this.state.sanitizedName,
this.state.branchName,
this.props.allBranches
)}
@ -236,24 +216,22 @@ export class CreateBranch extends React.Component<
this.updateBranchName(name)
}
private updateBranchName(name: string) {
const sanitizedName = sanitizedRefName(name)
private updateBranchName(branchName: string) {
const alreadyExists =
this.props.allBranches.findIndex(b => b.name === sanitizedName) > -1
this.props.allBranches.findIndex(b => b.name === branchName) > -1
const currentError = alreadyExists
? new Error(`A branch named ${sanitizedName} already exists`)
? new Error(`A branch named ${branchName} already exists`)
: null
this.setState({
proposedName: name,
sanitizedName,
branchName,
currentError,
})
}
private createBranch = async () => {
const name = this.state.sanitizedName
const name = this.state.branchName
let startPoint: string | null = null
let noTrack = false

View file

@ -2,16 +2,12 @@ import * as React from 'react'
import { Repository } from '../../models/repository'
import { Dispatcher } from '../dispatcher'
import { sanitizedRefName } from '../../lib/sanitize-ref-name'
import { TextBox } from '../lib/text-box'
import { Row } from '../lib/row'
import { Dialog, DialogError, DialogContent, DialogFooter } from '../dialog'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { startTimer } from '../lib/timing'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { Ref } from '../lib/ref'
import { RefNameTextBox } from '../lib/ref-name-text-box'
interface ICreateTagProps {
readonly repository: Repository
@ -23,8 +19,7 @@ interface ICreateTagProps {
}
interface ICreateTagState {
readonly proposedName: string
readonly sanitizedName: string
readonly tagName: string
/**
* Note: once tag creation has been initiated this value stays at true
@ -45,18 +40,15 @@ export class CreateTag extends React.Component<
public constructor(props: ICreateTagProps) {
super(props)
const proposedName = props.initialName || ''
this.state = {
proposedName,
sanitizedName: sanitizedRefName(proposedName),
tagName: props.initialName || '',
isCreatingTag: false,
}
}
public render() {
const error = this.getCurrentError()
const disabled = error !== null || this.state.proposedName.length === 0
const disabled = error !== null || this.state.tagName.length === 0
return (
<Dialog
@ -70,16 +62,11 @@ export class CreateTag extends React.Component<
{error && <DialogError>{error}</DialogError>}
<DialogContent>
<Row>
<TextBox
label="Name"
value={this.state.proposedName}
autoFocus={true}
onValueChanged={this.updateTagName}
/>
</Row>
{this.renderTagNameWarning()}
<RefNameTextBox
label="Name"
initialValue={this.props.initialName}
onValueChange={this.updateTagName}
/>
</DialogContent>
<DialogFooter>
@ -92,44 +79,19 @@ export class CreateTag extends React.Component<
)
}
private renderTagNameWarning() {
const { proposedName, sanitizedName } = this.state
if (proposedName !== sanitizedName) {
return (
<Row className="warning-helper-text">
<Octicon symbol={OcticonSymbol.alert} />
<p>
Will be created as <Ref>{sanitizedName}</Ref>.
</p>
</Row>
)
} else {
return null
}
}
private getCurrentError(): JSX.Element | null {
const { sanitizedName, proposedName } = this.state
if (sanitizedName.length > MaxTagNameLength) {
if (this.state.tagName.length > MaxTagNameLength) {
return (
<>The tag name cannot be longer than {MaxTagNameLength} characters</>
)
}
// Show an error if the sanitization logic causes the tag name to be an empty
// string (we only want to show this if the user has already typed something).
if (proposedName.length > 0 && sanitizedName.length === 0) {
return <>Invalid tag name.</>
}
const alreadyExists =
this.props.localTags && this.props.localTags.has(sanitizedName)
this.props.localTags && this.props.localTags.has(this.state.tagName)
if (alreadyExists) {
return (
<>
A tag named <Ref>{sanitizedName}</Ref> already exists
A tag named <Ref>{this.state.tagName}</Ref> already exists
</>
)
}
@ -137,15 +99,14 @@ export class CreateTag extends React.Component<
return null
}
private updateTagName = (name: string) => {
private updateTagName = (tagName: string) => {
this.setState({
proposedName: name,
sanitizedName: sanitizedRefName(name),
tagName,
})
}
private createTag = async () => {
const name = this.state.sanitizedName
const name = this.state.tagName
const repository = this.props.repository
if (name.length > 0) {

View file

@ -107,6 +107,6 @@ export class DeleteBranch extends React.Component<
)
this.props.onDeleted(repository)
await dispatcher.closePopup()
this.props.onDismissed()
}
}

View file

@ -57,6 +57,6 @@ export class DeletePullRequest extends React.Component<IDeleteBranchProps, {}> {
false
)
return this.props.dispatcher.closePopup()
return this.props.onDismissed()
}
}

View file

@ -2,8 +2,6 @@ import * as React from 'react'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
interface IDialogErrorProps {}
/**
* A component used for displaying short error messages inline
* in a dialog. These error messages (there can be more than one)
@ -15,7 +13,7 @@ interface IDialogErrorProps {}
*
* Provide `children` to display content inside the error dialog.
*/
export class DialogError extends React.Component<IDialogErrorProps, {}> {
export class DialogError extends React.Component {
public render() {
return (
<div className="dialog-error">

View file

@ -96,6 +96,15 @@ function cancelActiveSelection(cm: Editor) {
* A component hosting a CodeMirror instance
*/
export class CodeMirrorHost extends React.Component<ICodeMirrorHostProps, {}> {
private static updateDoc(cm: Editor, value: string | Doc) {
if (typeof value === 'string') {
cm.setValue(value)
} else {
cancelActiveSelection(cm)
cm.swapDoc(value)
}
}
private wrapper: HTMLDivElement | null = null
private codeMirror: Editor | null = null
@ -108,15 +117,6 @@ export class CodeMirrorHost extends React.Component<ICodeMirrorHostProps, {}> {
private resizeDebounceId: number | null = null
private lastKnownWidth: number | null = null
private static updateDoc(cm: Editor, value: string | Doc) {
if (typeof value === 'string') {
cm.setValue(value)
} else {
cancelActiveSelection(cm)
cm.swapDoc(value)
}
}
public constructor(props: ICodeMirrorHostProps) {
super(props)

View file

@ -2,7 +2,7 @@ import * as React from 'react'
import { ImageContainer } from './image-container'
import { ICommonImageDiffProperties } from './modified-image-diff'
import { ISize } from './sizing'
import { formatBytes, Sign } from '../../lib/bytes'
import { formatBytes } from '../../lib/bytes'
import classNames from 'classnames'
interface ITwoUpProps extends ICommonImageDiffProperties {
@ -26,6 +26,7 @@ export class TwoUp extends React.Component<ITwoUpProps, {}> {
this.props.current.bytes
)
const diffBytes = this.props.current.bytes - this.props.previous.bytes
const diffBytesSign = diffBytes >= 0 ? '+' : '-'
return (
<div className="image-diff-container" ref={this.props.onContainerRef}>
@ -69,7 +70,7 @@ export class TwoUp extends React.Component<ITwoUpProps, {}> {
})}
>
{diffBytes !== 0
? `${formatBytes(diffBytes, Sign.Forced)} (${diffPercent})`
? `${diffBytesSign}${formatBytes(diffBytes)} (${diffPercent})`
: 'No size difference'}
</span>
</div>

View file

@ -108,8 +108,6 @@ export class SeamlessDiffSwitcher extends React.Component<
ISeamlessDiffSwitcherProps,
ISeamlessDiffSwitcherState
> {
private slowLoadingTimeoutId: number | null = null
public static getDerivedStateFromProps(
props: ISeamlessDiffSwitcherProps,
state: ISeamlessDiffSwitcherState
@ -127,6 +125,8 @@ export class SeamlessDiffSwitcher extends React.Component<
}
}
private slowLoadingTimeoutId: number | null = null
public constructor(props: ISeamlessDiffSwitcherProps) {
super(props)

View file

@ -1,6 +1,5 @@
import { remote } from 'electron'
import { Disposable, IDisposable } from 'event-kit'
import * as Path from 'path'
import { IAPIOrganization, IAPIRefStatus, IAPIRepository } from '../../lib/api'
import { shell } from '../../lib/app-shell'
@ -97,6 +96,7 @@ import { RebaseFlowStep, RebaseStep } from '../../models/rebase-flow-step'
import { IStashEntry } from '../../models/stash-entry'
import { WorkflowPreferences } from '../../models/workflow-preferences'
import { enableForkSettings } from '../../lib/feature-flag'
import { resolveWithin } from '../../lib/path'
/**
* An error handler function.
@ -1802,10 +1802,15 @@ export class Dispatcher {
}
if (filepath != null) {
const fullPath = Path.join(repository.path, filepath)
// because Windows uses different path separators here
const normalized = Path.normalize(fullPath)
shell.showItemInFolder(normalized)
const resolved = await resolveWithin(repository.path, filepath)
if (resolved !== null) {
shell.showItemInFolder(resolved)
} else {
log.error(
`Prevented attempt to open path outside of the repository root: ${filepath}`
)
}
}
}

View file

@ -95,6 +95,8 @@ export class GenericGitAuthentication extends React.Component<
}
private save = () => {
this.props.onDismiss()
this.props.onSave(
this.props.hostname,
this.state.username,

View file

@ -118,13 +118,6 @@ export class CommitSummary extends React.Component<
private updateOverflowTimeoutId: NodeJS.Immediate | null = null
private descriptionRef: HTMLDivElement | null = null
private onHideWhitespaceInDiffChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
const value = event.currentTarget.checked
this.props.onHideWhitespaceInDiffChanged(value)
}
public constructor(props: ICommitSummaryProps) {
super(props)
@ -151,6 +144,13 @@ export class CommitSummary extends React.Component<
}
}
private onHideWhitespaceInDiffChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
const value = event.currentTarget.checked
this.props.onHideWhitespaceInDiffChanged(value)
}
private onResized = () => {
if (this.descriptionRef) {
const descriptionBottom = this.descriptionRef.getBoundingClientRect()

View file

@ -44,6 +44,7 @@ interface ISelectedCommitProps {
/**
* Called to open a file using the user's configured applications
*
* @param path The path of the file relative to the root of the repository
*/
readonly onOpenInExternalEditor: (path: string) => void
@ -210,6 +211,7 @@ export class SelectedCommit extends React.Component<
/**
* Open file with default application.
*
* @param path The path of the file relative to the root of the repository
*/
private onOpenItem = (path: string) => {

View file

@ -101,6 +101,13 @@ if (!process.env.TEST_ENV) {
require('../../styles/desktop.scss')
}
// TODO (electron): Remove this once
// https://bugs.chromium.org/p/chromium/issues/detail?id=1113293
// gets fixed and propagated to electron.
if (__DARWIN__) {
require('../lib/fix-emoji-spacing')
}
let currentState: IAppState | null = null
let lastUnhandledRejection: string | null = null
let lastUnhandledRejectionTime: Date | null = null

View file

@ -51,7 +51,7 @@ export class AttributeMismatch extends React.Component<
private showGlobalGitConfig = () => {
const path = this.state.globalGitConfigPath
if (path) {
shell.openItem(path)
shell.openPath(path)
}
}
@ -65,7 +65,7 @@ export class AttributeMismatch extends React.Component<
: 'Update existing Git LFS filters?'
}
onDismissed={this.props.onDismissed}
onSubmit={this.props.onUpdateExistingFilters}
onSubmit={this.onSumit}
>
<DialogContent>
<p>
@ -86,4 +86,9 @@ export class AttributeMismatch extends React.Component<
</Dialog>
)
}
private onSumit = () => {
this.props.onUpdateExistingFilters()
this.props.onDismissed()
}
}

View file

@ -52,6 +52,7 @@ export class InitializeLFS extends React.Component<IInitializeLFSProps, {}> {
private onInitialize = () => {
this.props.onInitialize(this.props.repositories)
this.props.onDismissed()
}
private renderRepositories() {

View file

@ -7,32 +7,6 @@ import * as OcticonSymbol from '../octicons/octicons.generated'
import { Ref } from './ref'
import { IStashEntry } from '../../models/stash-entry'
export function renderBranchNameWarning(
proposedName: string,
sanitizedName: string
) {
if (proposedName.length > 0 && /^\s*$/.test(sanitizedName)) {
return (
<Row className="warning-helper-text">
<Octicon symbol={OcticonSymbol.alert} />
<p>
<Ref>{proposedName}</Ref> is not a valid branch name.
</p>
</Row>
)
} else if (proposedName !== sanitizedName) {
return (
<Row className="warning-helper-text">
<Octicon symbol={OcticonSymbol.alert} />
<p>
Will be created as <Ref>{sanitizedName}</Ref>.
</p>
</Row>
)
} else {
return null
}
}
export function renderBranchHasRemoteWarning(branch: Branch) {
if (branch.upstream != null) {
return (

View file

@ -1,27 +1,32 @@
/**
* Number sign display mode
*/
export const enum Sign {
Normal,
Forced,
}
import { round } from './round'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
/**
* Display bytes in human readable format like:
* 23GB
* -43B
* It's also possible to force sign in order to get the
* plus sign in case of positive numbers like:
* +23GB
* -43B
* Formats a number of bytes into a human readable string.
*
* This method will uses the IEC representation for orders
* of magnitute (KiB/MiB rather than MB/KB) in order to match
* the format that Git uses.
*
* Example output:
*
* 23 GiB
* -43 B
*
* @param bytes - The number of bytes to reformat into human
* readable form
* @param decimals - The number of decimals to round the result
* to, defaults to zero
* @param fixed - Whether to always include the desired number
* of decimals even though the number could be
* made more compact by removing trailing zeroes.
*/
export const formatBytes = (bytes: number, signType: Sign = Sign.Normal) => {
export function formatBytes(bytes: number, decimals = 0, fixed = true) {
if (!Number.isFinite(bytes)) {
return 'Unknown'
return `${bytes}`
}
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
const sizeIndex = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024))
const sign = signType === Sign.Forced && bytes > 0 ? '+' : ''
const value = Math.round(bytes / Math.pow(1024, sizeIndex))
return `${sign}${value}${sizes[sizeIndex]}`
const unitIx = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024))
const value = round(bytes / Math.pow(1024, unitIx), decimals)
return `${fixed ? value.toFixed(decimals) : value} ${units[unitIx]}`
}

View file

@ -35,7 +35,6 @@ interface IConfigureGitUserState {
readonly name: string
readonly email: string
readonly avatarURL: string | null
/**
* If unable to save Git configuration values (name, email)
@ -67,7 +66,6 @@ export class ConfigureGitUser extends React.Component<
globalUserEmail: null,
name: '',
email: '',
avatarURL: null,
}
}

View file

@ -427,6 +427,7 @@ function getBranchForResolution(
* Calculates the number of merge conclicts in a file from the number of markers
* divides by three and rounds up since each conflict is indicated by three separate markers
* (`<<<<<`, `>>>>>`, and `=====`)
*
* @param conflictMarkers number of conflict markers in a file
*/
function calculateConflicts(conflictMarkers: number) {

View file

@ -63,9 +63,9 @@ export class FancyTextBox extends React.Component<
this.setState({ isFocused: true })
}
private onBlur = () => {
private onBlur = (value: string) => {
if (this.props.onBlur !== undefined) {
this.props.onBlur()
this.props.onBlur(value)
}
this.setState({ isFocused: false })

View file

@ -0,0 +1,70 @@
import * as React from 'react'
import { createUniqueId, releaseUniqueId } from './id-pool'
interface IRadioButtonProps<T> {
/**
* Called when the user selects this radio button.
*
* The function will be called with the value of the RadioButton
* and the original event that triggered the change.
*/
readonly onSelected: (
value: T,
event: React.FormEvent<HTMLInputElement>
) => void
/**
* Whether the radio button is selected.
*/
readonly checked: boolean
/**
* The label of the radio button.
*/
readonly label: string | JSX.Element
/**
* The value of the radio button.
*/
readonly value: T
}
interface IRadioButtonState {
readonly inputId: string
}
export class RadioButton<T extends string> extends React.Component<
IRadioButtonProps<T>,
IRadioButtonState
> {
public constructor(props: IRadioButtonProps<T>) {
super(props)
this.state = {
inputId: createUniqueId(`RadioButton_${this.props.value}`),
}
}
public componentWillUnmount() {
releaseUniqueId(this.state.inputId)
}
public render() {
return (
<div className="radio-button-component">
<input
type="radio"
id={this.state.inputId}
value={this.props.value}
checked={this.props.checked}
onChange={this.onSelected}
/>
<label htmlFor={this.state.inputId}>{this.props.label}</label>
</div>
)
}
private onSelected = (evt: React.FormEvent<HTMLInputElement>) => {
this.props.onSelected(this.props.value, evt)
}
}

View file

@ -0,0 +1,159 @@
import * as React from 'react'
import { sanitizedRefName } from '../../lib/sanitize-ref-name'
import { TextBox } from './text-box'
import { Octicon, OcticonSymbol } from '../octicons'
import { Ref } from './ref'
interface IRefNameProps {
/**
* The initial value for the ref name.
*
* Note that updates to this prop will be ignored.
*/
readonly initialValue?: string
/**
* The label of the text box.
*/
readonly label?: string | JSX.Element
/**
* Called when the user changes the ref name.
*
* A sanitized value for the ref name is passed.
*/
readonly onValueChange?: (sanitizedValue: string) => void
/**
* Called when the user-entered ref name is not valid.
*
* This gives the opportunity to the caller to specify
* a custom warning message explaining that the sanitized
* value will be used instead.
*/
readonly renderWarningMessage?: (
sanitizedValue: string,
proposedValue: string
) => JSX.Element | string
/**
* Callback used when the component loses focus.
*
* A sanitized value for the ref name is passed.
*/
readonly onBlur?: (sanitizedValue: string) => void
}
interface IRefNameState {
readonly proposedValue: string
readonly sanitizedValue: string
}
export class RefNameTextBox extends React.Component<
IRefNameProps,
IRefNameState
> {
public constructor(props: IRefNameProps) {
super(props)
const proposedValue = props.initialValue || ''
this.state = {
proposedValue,
sanitizedValue: sanitizedRefName(proposedValue),
}
}
public componentDidMount() {
if (
this.state.sanitizedValue !== this.props.initialValue &&
this.props.onValueChange !== undefined
) {
this.props.onValueChange(this.state.sanitizedValue)
}
}
public render() {
return (
<div className="ref-name-text-box">
<TextBox
label={this.props.label}
value={this.state.proposedValue}
onValueChanged={this.onValueChange}
onBlur={this.onBlur}
/>
{this.renderRefValueWarning()}
</div>
)
}
private onValueChange = (proposedValue: string) => {
const sanitizedValue = sanitizedRefName(proposedValue)
const previousSanitizedValue = this.state.sanitizedValue
this.setState({ proposedValue, sanitizedValue })
if (sanitizedValue === previousSanitizedValue) {
return
}
if (this.props.onValueChange === undefined) {
return
}
this.props.onValueChange(sanitizedValue)
}
private onBlur = (proposedValue: string) => {
if (this.props.onBlur !== undefined) {
// It's possible (although rare) that we receive the onBlur
// event before the sanitized value has been committed to the
// state so we need to use the value received from the onBlur
// event instead of the one stored in state.
this.props.onBlur(sanitizedRefName(proposedValue))
}
}
private renderRefValueWarning() {
const { proposedValue, sanitizedValue } = this.state
if (proposedValue === sanitizedValue) {
return null
}
const renderWarningMessage =
this.props.renderWarningMessage ?? this.defaultRenderWarningMessage
return (
<div className="warning-helper-text">
<Octicon symbol={OcticonSymbol.alert} />
<p>{renderWarningMessage(sanitizedValue, proposedValue)}</p>
</div>
)
}
private defaultRenderWarningMessage(
sanitizedValue: string,
proposedValue: string
) {
// If the proposed value ends up being sanitized as
// an empty string we show a message saying that the
// proposed value is invalid.
if (sanitizedValue.length === 0) {
return (
<>
<Ref>{proposedValue}</Ref> is not a valid name.
</>
)
}
return (
<>
Will be created as <Ref>{sanitizedValue}</Ref>.
</>
)
}
}

23
app/src/ui/lib/round.ts Normal file
View file

@ -0,0 +1,23 @@
/**
* Round a number to the desired number of decimals.
*
* This differs from toFixed in that it toFixed returns a
* string which will always contain exactly two decimals even
* though the number might be an integer.
*
* See https://stackoverflow.com/a/11832950/2114
*
* @param value The number to round to the number of
* decimals specified
* @param decimals The number of decimals to round to. Ex:
* 2: 1234.56789 => 1234.57
* 3: 1234.56789 => 1234.568
*/
export function round(value: number, decimals: number) {
if (decimals <= 0) {
return Math.round(value)
}
const factor = Math.pow(10, decimals)
return Math.round((value + Number.EPSILON) * factor) / factor
}

View file

@ -82,8 +82,10 @@ export interface ITextBoxProps {
/**
* Callback used when the component loses focus.
*
* The function is called with the current text value of the text input.
*/
readonly onBlur?: () => void
readonly onBlur?: (value: string) => void
/**
* Callback used when the user has cleared the search text.
@ -252,7 +254,7 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
value === ''
) {
if (this.props.onBlur) {
this.props.onBlur()
this.props.onBlur(value)
if (this.inputElement !== null) {
this.inputElement.blur()
}
@ -299,7 +301,7 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
private onBlur = (event: React.FocusEvent<HTMLInputElement>) => {
if (this.props.onBlur !== undefined) {
this.props.onBlur()
this.props.onBlur(event.target.value)
}
}
}

View file

@ -15,9 +15,9 @@ export interface IUiActivityMonitor {
* to the event handler will be a value indicating the
* kind of action detected (mouse/pointer, keyboard etc).
*
* @return A disposable object which, when disposed will
* terminate the subscription and prevent any
* further calls to the handler.
* @returns A disposable object which, when disposed will
* terminate the subscription and prevent any
* further calls to the handler.
*/
onActivity(handler: (kind: UiActivityKind) => void): Disposable
}
@ -40,9 +40,9 @@ export class UiActivityMonitor implements IUiActivityMonitor {
* to the event handler will be a value indicating the
* kind of action detected (mouse/pointer, keyboard etc).
*
* @return A disposable object which, when disposed will
* terminate the subscription and prevent any
* further calls to the handler.
* @returns A disposable object which, when disposed will
* terminate the subscription and prevent any
* further calls to the handler.
*/
public onActivity(handler: (kind: UiActivityKind) => void): Disposable {
const emitterDisposable = this.emitter.on('activity', handler)

View file

@ -337,7 +337,7 @@ export class Merge extends React.Component<IMergeProps, IMergeState> {
branch.name,
this.state.mergeStatus
)
this.props.dispatcher.closePopup()
this.props.onDismissed()
}
/**

View file

@ -5,6 +5,7 @@ import { LinkButton } from '../lib/link-button'
import { SamplesURL } from '../../lib/stats'
import { UncommittedChangesStrategyKind } from '../../models/uncommitted-changes-strategy'
import { enableSchannelCheckRevokeOptOut } from '../../lib/feature-flag'
import { RadioButton } from '../lib/radio-button'
interface IAdvancedPreferencesProps {
readonly optOutOfUsageTracking: boolean
@ -84,10 +85,8 @@ export class Advanced extends React.Component<
}
private onUncommittedChangesStrategyKindChanged = (
event: React.FormEvent<HTMLInputElement>
value: UncommittedChangesStrategyKind
) => {
const value = event.currentTarget.value as UncommittedChangesStrategyKind
this.setState({ uncommittedChangesStrategyKind: value })
this.props.onUncommittedChangesStrategyKindChanged(value)
}
@ -113,53 +112,36 @@ export class Advanced extends React.Component<
<DialogContent>
<div className="advanced-section">
<h2>If I have changes and I switch branches...</h2>
<div className="radio-component">
<input
type="radio"
id={UncommittedChangesStrategyKind.AskForConfirmation}
value={UncommittedChangesStrategyKind.AskForConfirmation}
checked={
this.state.uncommittedChangesStrategyKind ===
UncommittedChangesStrategyKind.AskForConfirmation
}
onChange={this.onUncommittedChangesStrategyKindChanged}
/>
<label htmlFor={UncommittedChangesStrategyKind.AskForConfirmation}>
Ask me where I want the changes to go
</label>
</div>
<div className="radio-component">
<input
type="radio"
id={UncommittedChangesStrategyKind.MoveToNewBranch}
value={UncommittedChangesStrategyKind.MoveToNewBranch}
checked={
this.state.uncommittedChangesStrategyKind ===
UncommittedChangesStrategyKind.MoveToNewBranch
}
onChange={this.onUncommittedChangesStrategyKindChanged}
/>
<label htmlFor={UncommittedChangesStrategyKind.MoveToNewBranch}>
Always bring my changes to my new branch
</label>
</div>
<div className="radio-component">
<input
type="radio"
id={UncommittedChangesStrategyKind.StashOnCurrentBranch}
value={UncommittedChangesStrategyKind.StashOnCurrentBranch}
checked={
this.state.uncommittedChangesStrategyKind ===
UncommittedChangesStrategyKind.StashOnCurrentBranch
}
onChange={this.onUncommittedChangesStrategyKindChanged}
/>
<label
htmlFor={UncommittedChangesStrategyKind.StashOnCurrentBranch}
>
Always stash and leave my changes on the current branch
</label>
</div>
<RadioButton
value={UncommittedChangesStrategyKind.AskForConfirmation}
checked={
this.state.uncommittedChangesStrategyKind ===
UncommittedChangesStrategyKind.AskForConfirmation
}
label="Ask me where I want the changes to go"
onSelected={this.onUncommittedChangesStrategyKindChanged}
/>
<RadioButton
value={UncommittedChangesStrategyKind.MoveToNewBranch}
checked={
this.state.uncommittedChangesStrategyKind ===
UncommittedChangesStrategyKind.MoveToNewBranch
}
label="Always bring my changes to my new branch"
onSelected={this.onUncommittedChangesStrategyKindChanged}
/>
<RadioButton
value={UncommittedChangesStrategyKind.StashOnCurrentBranch}
checked={
this.state.uncommittedChangesStrategyKind ===
UncommittedChangesStrategyKind.StashOnCurrentBranch
}
label="Always stash and leave my changes on the current branch"
onSelected={this.onUncommittedChangesStrategyKindChanged}
/>
</div>
<div className="advanced-section">
<h2>Show a confirmation dialog before...</h2>

View file

@ -62,7 +62,6 @@ interface IPreferencesState {
readonly confirmRepositoryRemoval: boolean
readonly confirmDiscardChanges: boolean
readonly confirmForcePush: boolean
readonly automaticallySwitchTheme: boolean
readonly uncommittedChangesStrategyKind: UncommittedChangesStrategyKind
readonly availableEditors: ReadonlyArray<ExternalEditor>
readonly selectedExternalEditor: ExternalEditor | null
@ -101,7 +100,6 @@ export class Preferences extends React.Component<
confirmDiscardChanges: false,
confirmForcePush: false,
uncommittedChangesStrategyKind: uncommittedChangesStrategyKindDefault,
automaticallySwitchTheme: false,
selectedExternalEditor: this.props.selectedExternalEditor,
availableShells: [],
selectedShell: this.props.selectedShell,

View file

@ -69,18 +69,11 @@ interface IRebaseFlowProps {
readonly openFileInExternalEditor: (path: string) => void
readonly resolvedExternalEditor: string | null
readonly openRepositoryInShell: (repository: Repository) => void
readonly onDismissed: () => void
}
/** A component for initiating and performing a rebase of the current branch. */
export class RebaseFlow extends React.Component<IRebaseFlowProps> {
public constructor(props: IRebaseFlowProps) {
super(props)
this.state = {
userHasResolvedConflicts: false,
}
}
private moveToShowConflictedFileState = (step: ConfirmAbortStep) => {
const { conflictState } = step
this.props.dispatcher.setRebaseFlowStep(this.props.repository, {
@ -152,6 +145,7 @@ export class RebaseFlow extends React.Component<IRebaseFlowProps> {
}
private onFlowEnded = () => {
this.props.onDismissed()
this.props.onFlowEnded(this.props.repository)
}

View file

@ -3,17 +3,14 @@ import * as React from 'react'
import { Dispatcher } from '../dispatcher'
import { Repository } from '../../models/repository'
import { Branch } from '../../models/branch'
import { sanitizedRefName } from '../../lib/sanitize-ref-name'
import { TextBox } from '../lib/text-box'
import { Row } from '../lib/row'
import { Dialog, DialogContent, DialogFooter } from '../dialog'
import {
renderBranchNameWarning,
renderBranchHasRemoteWarning,
renderStashWillBeLostWarning,
} from '../lib/branch-name-warnings'
import { IStashEntry } from '../../models/stash-entry'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { RefNameTextBox } from '../lib/ref-name-text-box'
interface IRenameBranchProps {
readonly dispatcher: Dispatcher
@ -38,8 +35,6 @@ export class RenameBranch extends React.Component<
}
public render() {
const disabled =
!this.state.newName.length || /^\s*$/.test(this.state.newName)
return (
<Dialog
id="rename-branch"
@ -48,17 +43,11 @@ export class RenameBranch extends React.Component<
onSubmit={this.renameBranch}
>
<DialogContent>
<Row>
<TextBox
label="Name"
value={this.state.newName}
onValueChanged={this.onNameChange}
/>
</Row>
{renderBranchNameWarning(
this.state.newName,
sanitizedRefName(this.state.newName)
)}
<RefNameTextBox
label="Name"
initialValue={this.props.branch.name}
onValueChange={this.onNameChange}
/>
{renderBranchHasRemoteWarning(this.props.branch)}
{renderStashWillBeLostWarning(this.props.stash)}
</DialogContent>
@ -66,7 +55,7 @@ export class RenameBranch extends React.Component<
<DialogFooter>
<OkCancelButtonGroup
okButtonText={`Rename ${this.props.branch.name}`}
okButtonDisabled={disabled}
okButtonDisabled={this.state.newName.length === 0}
/>
</DialogFooter>
</Dialog>
@ -78,11 +67,10 @@ export class RenameBranch extends React.Component<
}
private renameBranch = () => {
const name = sanitizedRefName(this.state.newName)
this.props.dispatcher.renameBranch(
this.props.repository,
this.props.branch,
name
this.state.newName
)
this.props.onDismissed()
}

View file

@ -3,6 +3,7 @@ import { DialogContent } from '../dialog'
import { ForkContributionTarget } from '../../models/workflow-preferences'
import { RepositoryWithForkedGitHubRepository } from '../../models/repository'
import { ForkSettingsDescription } from './fork-contribution-target-description'
import { RadioButton } from '../lib/radio-button'
interface IForkSettingsProps {
readonly forkContributionTarget: ForkContributionTarget
@ -12,11 +13,6 @@ interface IForkSettingsProps {
) => void
}
enum RadioButtonId {
Parent = 'ForkContributionTargetParent',
Self = 'ForkContributionTargetSelf',
}
/** A view for creating or modifying the repository's gitignore file */
export class ForkSettings extends React.Component<IForkSettingsProps, {}> {
public render() {
@ -24,33 +20,23 @@ export class ForkSettings extends React.Component<IForkSettingsProps, {}> {
<DialogContent>
<h2>I'll be using this fork</h2>
<div className="radio-component">
<input
type="radio"
id={RadioButtonId.Parent}
value={ForkContributionTarget.Parent}
checked={
this.props.forkContributionTarget ===
ForkContributionTarget.Parent
}
onChange={this.onForkContributionTargetChanged}
/>
<label htmlFor={RadioButtonId.Parent}>
To contribute to the parent repository
</label>
</div>
<div className="radio-component">
<input
type="radio"
id={RadioButtonId.Self}
value={ForkContributionTarget.Self}
checked={
this.props.forkContributionTarget === ForkContributionTarget.Self
}
onChange={this.onForkContributionTargetChanged}
/>
<label htmlFor={RadioButtonId.Self}>For my own purposes</label>
</div>
<RadioButton
value={ForkContributionTarget.Parent}
checked={
this.props.forkContributionTarget === ForkContributionTarget.Parent
}
label="To contribute to the parent repository"
onSelected={this.onForkContributionTargetChanged}
/>
<RadioButton
value={ForkContributionTarget.Self}
checked={
this.props.forkContributionTarget === ForkContributionTarget.Self
}
label="For my own purposes"
onSelected={this.onForkContributionTargetChanged}
/>
<ForkSettingsDescription
repository={this.props.repository}
@ -60,11 +46,7 @@ export class ForkSettings extends React.Component<IForkSettingsProps, {}> {
)
}
private onForkContributionTargetChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
const value = event.currentTarget.value as ForkContributionTarget
private onForkContributionTargetChanged = (value: ForkContributionTarget) => {
this.props.onForkContributionTargetChanged(value)
}
}

View file

@ -88,7 +88,6 @@ interface IRepositoryViewProps {
}
interface IRepositoryViewState {
readonly sidebarHasFocusWithin: boolean
readonly changesListScrollTop: number
readonly compareListScrollTop: number
}
@ -109,7 +108,6 @@ export class RepositoryView extends React.Component<
super(props)
this.state = {
sidebarHasFocusWithin: false,
changesListScrollTop: 0,
compareListScrollTop: 0,
}
@ -164,7 +162,14 @@ export class RepositoryView extends React.Component<
private renderChangesSidebar(): JSX.Element {
const tip = this.props.state.branchesState.tip
const branch = tip.kind === TipState.Valid ? tip.branch : null
let branchName: string | null = null
if (tip.kind === TipState.Valid) {
branchName = tip.branch.name
} else if (tip.kind === TipState.Unborn) {
branchName = tip.ref
}
const localCommitSHAs = this.props.state.localCommitSHAs
const mostRecentLocalCommitSHA =
@ -188,7 +193,7 @@ export class RepositoryView extends React.Component<
repository={this.props.repository}
dispatcher={this.props.dispatcher}
changes={this.props.state.changesState}
branch={branch ? branch.name : null}
branch={branchName}
commitAuthor={this.props.state.commitAuthor}
emoji={this.props.emoji}
mostRecentLocalCommit={mostRecentLocalCommit}
@ -282,9 +287,6 @@ export class RepositoryView extends React.Component<
}
private onSidebarFocusWithinChanged = (sidebarHasFocusWithin: boolean) => {
// this lets us know that focus is somewhere within the sidebar
this.setState({ sidebarHasFocusWithin })
if (
sidebarHasFocusWithin === false &&
this.props.state.selectedSection === RepositorySectionTab.History

View file

@ -57,7 +57,7 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
nextProps.signInState &&
nextProps.signInState.kind === SignInStep.Success
) {
this.props.onDismissed()
this.onDismissed()
}
}
}
@ -89,7 +89,7 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
this.props.dispatcher.setSignInOTP(this.state.otpToken)
break
case SignInStep.Success:
this.props.onDismissed()
this.onDismissed()
break
default:
assertNever(state, `Unknown sign in step ${stepKind}`)
@ -322,7 +322,7 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
id="sign-in"
title={title}
disabled={disabled}
onDismissed={this.props.onDismissed}
onDismissed={this.onDismissed}
onSubmit={this.onSubmit}
loading={state.loading}
>
@ -332,4 +332,9 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
</Dialog>
)
}
private onDismissed = () => {
this.props.dispatcher.resetSignInState()
this.props.onDismissed()
}
}

View file

@ -5,7 +5,7 @@ import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
interface IConfirmExitTutorialProps {
readonly onDismissed: () => void
readonly onContinue: () => void
readonly onContinue: () => boolean
}
export class ConfirmExitTutorial extends React.Component<
@ -17,7 +17,7 @@ export class ConfirmExitTutorial extends React.Component<
<Dialog
title={__DARWIN__ ? 'Exit Tutorial' : 'Exit tutorial'}
onDismissed={this.props.onDismissed}
onSubmit={this.props.onContinue}
onSubmit={this.onContinue}
type="normal"
>
<DialogContent>
@ -34,4 +34,12 @@ export class ConfirmExitTutorial extends React.Component<
</Dialog>
)
}
private onContinue = () => {
const dismissPopup = this.props.onContinue()
if (dismissPopup) {
this.props.onDismissed()
}
}
}

View file

@ -1,8 +1,6 @@
import * as React from 'react'
import classNames from 'classnames'
interface IUiViewProps extends React.HTMLProps<HTMLDivElement> {}
/**
* High order component for housing a View.
*
@ -16,7 +14,7 @@ interface IUiViewProps extends React.HTMLProps<HTMLDivElement> {}
* Examples of what's not a View include the Changes and History tabs
* as these are contained within the <Repository /> view
*/
export class UiView extends React.Component<IUiViewProps, {}> {
export class UiView extends React.Component<React.HTMLProps<HTMLDivElement>> {
public render() {
const className = classNames(this.props.className, 'ui-view')
const props = { ...this.props, className }

View file

@ -74,6 +74,7 @@ export class UntrustedCertificate extends React.Component<
}
private onContinue = () => {
this.props.onDismissed()
this.props.onContinue(this.props.certificate)
}
}

View file

@ -5,7 +5,8 @@ import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
interface IUsageStatsChangeProps {
readonly onDismissed: (optOut: boolean) => void
readonly onSetStatsOptOut: (optOut: boolean) => void
readonly onDismissed: () => void
readonly onOpenUsageDataUrl: () => void
}
@ -98,7 +99,8 @@ export class UsageStatsChange extends React.Component<
}
private onDismissed = () => {
this.props.onDismissed(this.state.optOutOfUsageTracking)
this.props.onSetStatsOptOut(this.state.optOutOfUsageTracking)
this.props.onDismissed()
}
private viewMoreInfo = (e: React.MouseEvent<HTMLButtonElement>) => {

View file

@ -35,6 +35,8 @@
@import 'ui/configure-git-user';
@import 'ui/form';
@import 'ui/text-box';
@import 'ui/ref-name-text-box';
@import 'ui/radio-button';
@import 'ui/button';
@import 'ui/select';
@import 'ui/row';

View file

@ -48,8 +48,9 @@ $overlay-background-color: rgba(0, 0, 0, 0.4);
// 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;
$emoji_fallback_fonts: 'Apple Color Emoji', 'Segoe UI', 'Segoe UI Emoji', 'Segoe UI Symbol';
--font-family-sans-serif: system-ui, sans-serif, #{$emoji_fallback_fonts};
--font-family-monospace: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace, #{$emoji_fallback_fonts};
/**
* Font weight to use for semibold text
@ -62,6 +63,7 @@ $overlay-background-color: rgba(0, 0, 0, 0.4);
--font-weight-light: 300;
// Pixel value used to responsively scale all typography. Applied to the `<html>` element.
// When adding a new font-size variable, please update the fix-emoji-spacing.ts file.
--font-size: 12px;
--font-size-sm: 11px;
--font-size-md: 14px;

View file

@ -24,5 +24,6 @@
flex-direction: column;
flex: 1 1 auto;
height: 100%;
min-height: 0;
}
}

View file

@ -14,21 +14,6 @@
.checkbox-component:not(:last-child) {
margin-bottom: var(--spacing-half);
}
.radio-component {
&:not(:last-child) {
margin-bottom: var(--spacing-half);
}
input {
margin: 0;
// Only add a right margin if there's a label attached to it
&:not(:last-child) {
margin-right: var(--spacing-half);
}
}
}
}
}

View file

@ -0,0 +1,10 @@
.radio-button-component {
& + .radio-button-component {
margin-top: var(--spacing-half);
}
label {
margin: 0;
margin-left: var(--spacing-half);
}
}

View file

@ -0,0 +1,8 @@
.ref-name-text-box {
margin-bottom: var(--spacing);
.warning-helper-text {
display: flex;
margin-top: var(--spacing);
}
}

View file

@ -7,6 +7,7 @@
flex: 1 1 auto;
border-top: var(--base-border);
height: 100%;
min-height: 0;
> .focus-container {
display: flex;
@ -24,6 +25,7 @@
.panel {
height: 100%;
flex: 1 1 auto;
min-height: 0;
}
}
}

View file

@ -9,12 +9,14 @@
flex-grow: 1;
display: flex;
flex-direction: column;
min-height: 0;
.stashed-changes-button {
@include ellipsis;
min-height: 29px;
display: flex;
align-items: center;
flex-grow: 1;
padding: 0 var(--spacing);
width: 100%;

View file

@ -31,15 +31,4 @@
margin-bottom: 0;
}
}
.radio-component {
&:not(:last-child) {
margin-bottom: var(--spacing-half);
}
input {
margin: 0;
margin-right: var(--spacing-half);
}
}
}

View file

@ -29,6 +29,7 @@ interface IGitHubRepoFixtureOptions {
/**
* Makes a fairly standard `GitHubRepository` for use in tests.
* Ensures a unique `dbID` for each, during a test run.
*
* @param options
* @returns a new GitHubRepository model
*/

View file

@ -11,8 +11,9 @@ export const shell: IAppShell = {
},
beep: () => {},
showItemInFolder: (path: string) => {},
showFolderContents: (path: string) => {},
openExternal: (path: string) => {
return Promise.resolve(true)
},
openItem: (path: string) => true,
openPath: (path: string) => Promise.resolve(''),
}

View file

@ -39,10 +39,17 @@ describe('App', function (this: any) {
})
it('opens a window on launch', async () => {
await app.client.waitUntil(() => app.browserWindow.isVisible(), 5000)
await app.client.waitUntil(
() => Promise.resolve(app.browserWindow.isVisible()),
{ timeout: 5000 }
)
const count = await app.client.getWindowCount()
expect(count).toBe(1)
// When running tests against development versions of Desktop
// (which usually happens locally when developing), the number
// of windows will be greater than 1, since the devtools are
// considered a window.
expect(count).toBeGreaterThan(0)
const window = app.browserWindow
expect(window.isVisible()).resolves.toBe(true)

View file

@ -0,0 +1,38 @@
import { formatBytes } from '../../src/ui/lib/bytes'
describe('formatBytes', () => {
it('rounds to the desired number decimals', () => {
expect(formatBytes(1342177280, 2)).toEqual('1.25 GiB')
expect(formatBytes(1342177280, 1)).toEqual('1.3 GiB')
expect(formatBytes(1342177280, 0)).toEqual('1 GiB')
expect(formatBytes(1879048192, 2)).toEqual('1.75 GiB')
expect(formatBytes(1879048192, 1)).toEqual('1.8 GiB')
expect(formatBytes(1879048192, 0)).toEqual('2 GiB')
})
it('uses the correct units', () => {
expect(formatBytes(1023)).toEqual('1023 B')
expect(formatBytes(1024)).toEqual('1 KiB')
// N.B this codifies the current behavior, I personally
// wouldn't object to formatBytes(1048575) returning 1 MiB
expect(formatBytes(1048575, 3)).toEqual('1023.999 KiB')
expect(formatBytes(1048575)).toEqual('1024 KiB')
expect(formatBytes(1048576)).toEqual('1 MiB')
expect(formatBytes(1073741823)).toEqual('1024 MiB')
expect(formatBytes(1073741824)).toEqual('1 GiB')
expect(formatBytes(1099511627775)).toEqual('1024 GiB')
expect(formatBytes(1099511627776)).toEqual('1 TiB')
})
it("doesn't attempt to format NaN", () => {
expect(formatBytes(NaN)).toEqual('NaN')
})
it("doesn't attempt to format Infinity", () => {
expect(formatBytes(Infinity)).toEqual('Infinity')
})
})

View file

@ -26,15 +26,10 @@ describe('git/worktree', () => {
expect(result).toHaveLength(1)
})
it('contains the head and path of the main repository', async () => {
const { path } = repository
it('contains the head of the main repository', async () => {
const result = await listWorkTrees(repository)
const first = result[0]
expect(first.head).toBe('0000000000000000000000000000000000000000')
// we use realpathSync here because git and windows/macOS report different
// paths even though they are the same folder
expect(realpathSync(first.path)).toBe(realpathSync(path))
})
})

View file

@ -1,4 +1,9 @@
import { encodePathAsUrl } from '../../src/lib/path'
import { encodePathAsUrl, resolveWithin } from '../../src/lib/path'
import { resolve, basename, join } from 'path'
import { promises } from 'fs'
import { tmpdir } from 'os'
const { rmdir, mkdtemp, symlink, unlink } = promises
describe('path', () => {
describe('encodePathAsUrl', () => {
@ -27,4 +32,65 @@ describe('path', () => {
})
}
})
describe('resolveWithin', async () => {
const root = process.cwd()
it('fails for paths outside of the root', async () => {
expect(await resolveWithin(root, join('..'))).toBeNull()
expect(await resolveWithin(root, join('..', '..'))).toBeNull()
})
it('succeeds for paths that traverse out, and then back into, the root', async () => {
expect(await resolveWithin(root, join('..', basename(root)))).toEqual(
root
)
})
it('fails for paths containing null bytes', async () => {
expect(await resolveWithin(root, 'foo\0bar')).toBeNull()
})
it('succeeds for absolute relative paths as long as they stay within the root', async () => {
const parent = resolve(root, '..')
expect(await resolveWithin(parent, root)).toEqual(root)
})
if (!__WIN32__) {
it('fails for paths that use a symlink to traverse outside of the root', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'path-test'))
const symlinkName = 'dangerzone'
const symlinkPath = join(tempDir, symlinkName)
try {
await symlink(resolve(tempDir, '..', '..'), symlinkPath)
expect(await resolveWithin(tempDir, symlinkName)).toBeNull()
} finally {
await unlink(symlinkPath)
await rmdir(tempDir)
}
})
it('succeeds for paths that use a symlink to traverse outside of the root and then back again', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'path-test'))
const symlinkName = 'dangerzone'
const symlinkPath = join(tempDir, symlinkName)
try {
await symlink(resolve(tempDir, '..', '..'), symlinkPath)
const throughSymlinkPath = join(
symlinkName,
basename(resolve(tempDir, '..')),
basename(tempDir)
)
expect(await resolveWithin(tempDir, throughSymlinkPath)).toBe(
resolve(tempDir, throughSymlinkPath)
)
} finally {
await unlink(symlinkPath)
await rmdir(tempDir)
}
})
}
})
})

View file

@ -0,0 +1,28 @@
import { round } from '../../src/ui/lib/round'
describe('round', () => {
it('rounds to the desired number decimals', () => {
expect(round(1.23456789, 0)).toBe(1)
expect(round(1.23456789, 1)).toBe(1.2)
expect(round(1.23456789, 2)).toBe(1.23)
expect(round(1.23456789, 3)).toBe(1.235)
expect(round(1.23456789, 4)).toBe(1.2346)
expect(round(1.23456789, 5)).toBe(1.23457)
expect(round(1.23456789, 6)).toBe(1.234568)
})
it("doesn't attempt to round NaN", () => {
expect(round(NaN, 1)).toBeNaN()
})
it("doesn't attempt to round infinity", () => {
expect(round(Infinity, 1)).not.toBeFinite()
expect(round(-Infinity, 1)).not.toBeFinite()
})
it("doesn't attempt to round to less than zero decimals", () => {
expect(round(1.23456789, 0)).toBe(1)
expect(round(1.23456789, -1)).toBe(1)
expect(round(1.23456789, -2)).toBe(1)
})
})

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