mirror of
https://github.com/desktop/desktop
synced 2024-09-12 21:01:16 +00:00
Merge branch 'development' into tree-shake-octoicons
This commit is contained in:
commit
2d63447841
|
@ -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-.*/
|
|
@ -3,3 +3,5 @@
|
|||
# might be included when linting
|
||||
app/coverage
|
||||
script/coverage
|
||||
node_modules
|
||||
app/node_modules
|
||||
|
|
|
@ -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
81
.github/workflows/ci.yml
vendored
Normal 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
|
|
@ -1,4 +1,4 @@
|
|||
runtime = electron
|
||||
disturl = https://atom.io/download/electron
|
||||
target = 7.1.8
|
||||
target = 9.1.2
|
||||
arch = x64
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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': {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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'
|
||||
|
|
37
app/src/lib/fix-emoji-spacing.ts
Normal file
37
app/src/lib/fix-emoji-spacing.ts
Normal 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)
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
8
app/src/lib/globals.d.ts
vendored
8
app/src/lib/globals.d.ts
vendored
|
@ -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
|
||||
}
|
||||
|
|
8
app/src/lib/helpers/file-metadata.js
Normal file
8
app/src/lib/helpers/file-metadata.js
Normal 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({})
|
|
@ -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
|
||||
|
|
41
app/src/lib/is-application-bundle.ts
Normal file
41
app/src/lib/is-application-bundle.ts
Normal 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
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>()
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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)
|
||||
|
|
5
app/src/ui/autocompletion/common.ts
Normal file
5
app/src/ui/autocompletion/common.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* The default maximum number of hits to return from
|
||||
* either of the autocompletion providers.
|
||||
*/
|
||||
export const DefaultMaxHits = 25
|
|
@ -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) {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -107,6 +107,6 @@ export class DeleteBranch extends React.Component<
|
|||
)
|
||||
this.props.onDeleted(repository)
|
||||
|
||||
await dispatcher.closePopup()
|
||||
this.props.onDismissed()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,6 +57,6 @@ export class DeletePullRequest extends React.Component<IDeleteBranchProps, {}> {
|
|||
false
|
||||
)
|
||||
|
||||
return this.props.dispatcher.closePopup()
|
||||
return this.props.onDismissed()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -95,6 +95,8 @@ export class GenericGitAuthentication extends React.Component<
|
|||
}
|
||||
|
||||
private save = () => {
|
||||
this.props.onDismiss()
|
||||
|
||||
this.props.onSave(
|
||||
this.props.hostname,
|
||||
this.state.username,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,7 @@ export class InitializeLFS extends React.Component<IInitializeLFSProps, {}> {
|
|||
|
||||
private onInitialize = () => {
|
||||
this.props.onInitialize(this.props.repositories)
|
||||
this.props.onDismissed()
|
||||
}
|
||||
|
||||
private renderRepositories() {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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]}`
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 })
|
||||
|
|
70
app/src/ui/lib/radio-button.tsx
Normal file
70
app/src/ui/lib/radio-button.tsx
Normal 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)
|
||||
}
|
||||
}
|
159
app/src/ui/lib/ref-name-text-box.tsx
Normal file
159
app/src/ui/lib/ref-name-text-box.tsx
Normal 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
23
app/src/ui/lib/round.ts
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -337,7 +337,7 @@ export class Merge extends React.Component<IMergeProps, IMergeState> {
|
|||
branch.name,
|
||||
this.state.mergeStatus
|
||||
)
|
||||
this.props.dispatcher.closePopup()
|
||||
this.props.onDismissed()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -74,6 +74,7 @@ export class UntrustedCertificate extends React.Component<
|
|||
}
|
||||
|
||||
private onContinue = () => {
|
||||
this.props.onDismissed()
|
||||
this.props.onContinue(this.props.certificate)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>) => {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -24,5 +24,6 @@
|
|||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
10
app/styles/ui/_radio-button.scss
Normal file
10
app/styles/ui/_radio-button.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
.radio-button-component {
|
||||
& + .radio-button-component {
|
||||
margin-top: var(--spacing-half);
|
||||
}
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
margin-left: var(--spacing-half);
|
||||
}
|
||||
}
|
8
app/styles/ui/_ref-name-text-box.scss
Normal file
8
app/styles/ui/_ref-name-text-box.scss
Normal file
|
@ -0,0 +1,8 @@
|
|||
.ref-name-text-box {
|
||||
margin-bottom: var(--spacing);
|
||||
|
||||
.warning-helper-text {
|
||||
display: flex;
|
||||
margin-top: var(--spacing);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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(''),
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
38
app/test/unit/bytes-test.ts
Normal file
38
app/test/unit/bytes-test.ts
Normal 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')
|
||||
})
|
||||
})
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
28
app/test/unit/round-test.ts
Normal file
28
app/test/unit/round-test.ts
Normal 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
Loading…
Reference in a new issue