Merge branch 'development' into remove-requires

This commit is contained in:
Markus Olsson 2020-08-06 09:51:47 +02:00
commit 9e6b448130
46 changed files with 1861 additions and 1209 deletions

View file

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

View file

@ -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": "^2.0.0",
"file-url": "^2.0.2",
"fs-admin": "^0.12.0",
"fs-extra": "^7.0.1",
"fuzzaldrin-plus": "^0.6.0",
"keytar": "^5.0.0",
"keytar": "^5.6.0",
"mem": "^4.3.0",
"memoize-one": "^4.0.3",
"moment": "^2.24.0",
@ -46,7 +47,6 @@
"queue": "^5.0.0",
"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",

View file

@ -7,8 +7,31 @@ export interface IAppShell {
readonly moveItemToTrash: (path: string) => boolean
readonly beep: () => void
readonly openExternal: (path: string) => Promise<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 openItem: (path: string) => boolean
/**
* 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,6 +52,9 @@ export const shell: IAppShell = {
showItemInFolder: path => {
ipcRenderer.send('show-item-in-folder', { path })
},
showFolderContents: path => {
ipcRenderer.send('show-folder-contents', { path })
},
openItem: electronShell.openItem,
}

View file

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

View file

@ -225,3 +225,9 @@ declare class ResizeObserver {
public disconnect(): void
public observe(e: HTMLElement): void
}
declare module 'file-metadata' {
// eslint-disable-next-line no-restricted-syntax
function fileMetadata(path: string): Promise<plist.PlistObject>
export = fileMetadata
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
}
@ -149,7 +147,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 +162,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 +182,7 @@ export class GitHubUserStore extends BaseStore {
.sort(
(x, y) => compare(x.ix, y.ix) || compare(x.user.login, y.user.login)
)
.slice(0, maxHits)
.map(h => h.user)
}

View file

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

View file

@ -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,6 +27,8 @@ 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()
enableSourceMaps()
@ -385,7 +387,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 +452,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 +548,66 @@ app.on('ready', () => {
ipcMain.on(
'show-item-in-folder',
(event: Electron.IpcMainEvent, { path }: { path: string }) => {
Fs.stat(path, (err, stats) => {
Fs.stat(path, err => {
if (err) {
log.error(`Unable to find file at '${path}'`, err)
return
}
if (!__DARWIN__ && stats.isDirectory()) {
openDirectorySafe(path)
} else {
shell.showItemInFolder(path)
}
shell.showItemInFolder(path)
})
}
)
ipcMain.on(
'show-folder-contents',
async (event: Electron.IpcMainEvent, { path }: { path: string }) => {
const stats = await stat(path).catch(err => {
log.error(`Unable to retrieve file information for ${path}`, err)
return null
})
if (!stats) {
return
}
if (!stats.isDirectory()) {
log.error(
`Trying to get the folder contents of a non-folder at '${path}'`
)
shell.showItemInFolder(path)
return
}
// On Windows and Linux we can count on a directory being just a
// directory.
if (!__DARWIN__) {
UNSAFE_openDirectory(path)
return
}
// On macOS a directory might also be an app bundle and if it is
// and we attempt to open it we're gonna execute that app which
// it far from ideal so we'll look up the metadata for the path
// and attempt to determine whether it's an app bundle or not.
//
// If we fail loading the metadata we'll assume it's an app bundle
// out of an abundance of caution.
const isBundle = await isApplicationBundle(path).catch(err => {
log.error(`Failed to load metadata for path '${path}'`, err)
return true
})
if (isBundle) {
log.info(
`Preventing direct open of path '${path}' as it appears to be an application bundle`
)
shell.showItemInFolder(path)
} else {
UNSAFE_openDirectory(path)
}
}
)
})
app.on('activate', () => {

View file

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

View file

@ -8,9 +8,14 @@ import { shell } from 'electron'
* window, which may confuse users. As a workaround, we will fallback to using
* shell.openExternal for macOS until it can be fixed upstream.
*
* CAUTION: This method should never be used to open user-provided or derived
* paths. It's sole use is to open _directories_ that we know to be safe, no
* verification is performed to ensure that the provided path isn't actually
* an executable.
*
* @param path directory to open
*/
export function openDirectorySafe(path: string) {
export function UNSAFE_openDirectory(path: string) {
if (__DARWIN__) {
const directoryURL = Url.format({
pathname: path,
@ -22,6 +27,15 @@ 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.openItem(pathname)
}
}

View file

@ -120,6 +120,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
@ -188,9 +189,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)
@ -1273,19 +1274,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.'
@ -1307,6 +1302,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 =
@ -1321,7 +1318,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:
@ -1332,7 +1329,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}
/>
)
@ -1357,7 +1354,7 @@ export class App extends React.Component<IAppProps, IAppState> {
}
showDiscardChangesSetting={showSetting}
discardingAllChanges={discardingAllChanges}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onConfirmDiscardChangesChanged={this.onConfirmDiscardChangesChanged}
/>
)
@ -1370,7 +1367,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:
@ -1393,7 +1390,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}
@ -1423,7 +1420,7 @@ export class App extends React.Component<IAppProps, IAppState> {
recentBranches={state.branchesState.recentBranches}
currentBranch={currentBranch}
initialBranch={branch}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1437,7 +1434,7 @@ export class App extends React.Component<IAppProps, IAppState> {
remote={state.remote}
dispatcher={this.props.dispatcher}
repository={repository}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1447,14 +1444,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}
/>
@ -1463,7 +1460,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}
/>
@ -1475,7 +1472,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}
@ -1490,7 +1487,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
}
@ -1517,7 +1514,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}
@ -1531,7 +1528,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}
/>
@ -1542,7 +1539,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}
@ -1557,7 +1554,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:
@ -1566,7 +1563,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}
/>
)
@ -1574,7 +1571,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<Acknowledgements
key="acknowledgements"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
applicationVersion={getVersion()}
/>
)
@ -1584,14 +1581,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:
@ -1603,22 +1600,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}
/>
@ -1631,7 +1625,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}
@ -1642,7 +1636,7 @@ export class App extends React.Component<IAppProps, IAppState> {
<ShellError
key="shell-error"
message={popup.message}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
showPreferencesDialog={this.onShowAdvancedPreferences}
/>
)
@ -1651,7 +1645,7 @@ export class App extends React.Component<IAppProps, IAppState> {
<InitializeLFS
key="initialize-lfs"
repositories={popup.repositories}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onInitialize={this.initializeLFS}
/>
)
@ -1659,7 +1653,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<AttributeMismatch
key="lsf-attribute-mismatch"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onUpdateExistingFilters={this.updateExistingLFSFilters}
/>
)
@ -1669,7 +1663,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}
/>
@ -1680,7 +1674,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:
@ -1690,7 +1684,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}
/>
)
@ -1718,7 +1712,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}
@ -1733,7 +1727,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}
@ -1761,7 +1755,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}
/>
@ -1772,7 +1766,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:
@ -1783,7 +1778,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:
@ -1792,7 +1787,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: {
@ -1830,6 +1825,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}
@ -1854,7 +1850,7 @@ export class App extends React.Component<IAppProps, IAppState> {
repository={popup.repository}
upstreamBranch={popup.upstreamBranch}
askForConfirmationOnForcePush={askForConfirmationOnForcePush}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1881,7 +1877,7 @@ export class App extends React.Component<IAppProps, IAppState> {
currentBranch={currentBranch}
branchToCheckout={branchToCheckout}
hasAssociatedStash={hasAssociatedStash}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1893,7 +1889,7 @@ export class App extends React.Component<IAppProps, IAppState> {
dispatcher={this.props.dispatcher}
repository={repository}
branchToCheckout={branchToCheckout}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1906,7 +1902,7 @@ export class App extends React.Component<IAppProps, IAppState> {
dispatcher={this.props.dispatcher}
repository={repository}
stash={stash}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
/>
)
}
@ -1916,7 +1912,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}
/>
)
@ -1925,7 +1921,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<ConfirmExitTutorial
key="confirm-exit-tutorial"
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
onContinue={this.onExitTutorialToHomeScreen}
/>
)
@ -1933,7 +1929,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}
@ -1942,7 +1938,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}
@ -1952,7 +1948,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}
@ -1961,7 +1957,7 @@ export class App extends React.Component<IAppProps, IAppState> {
case PopupType.SChannelNoRevocationCheck:
return (
<SChannelNoRevocationCheckDialog
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
url={popup.url}
/>
)
@ -1970,7 +1966,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}
@ -1983,7 +1979,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}
/>
@ -1993,7 +1989,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return (
<ChooseForkSettings
repository={popup.repository}
onDismissed={this.onPopupDismissed}
onDismissed={onPopupDismissedFn}
dispatcher={this.props.dispatcher}
/>
)
@ -2013,7 +2009,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:
@ -2024,11 +2020,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) => {
@ -2072,14 +2068,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()
}
@ -2097,12 +2091,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) => {
@ -2122,7 +2114,6 @@ export class App extends React.Component<IAppProps, IAppState> {
private onOpenShellIgnoreWarning = (path: string) => {
this.props.dispatcher.openShell(path, true)
this.onPopupDismissed()
}
private onSaveCredentials = async (
@ -2131,8 +2122,6 @@ export class App extends React.Component<IAppProps, IAppState> {
password: string,
retryAction: RetryAction
) => {
this.onPopupDismissed()
await this.props.dispatcher.saveGenericGitCredentials(
hostname,
username,
@ -2269,7 +2258,7 @@ export class App extends React.Component<IAppProps, IAppState> {
return
}
shell.showItemInFolder(repository.path)
shell.showFolderContents(repository.path)
}
private onRepositoryDropdownStateChanged = (newState: DropdownState) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -69,6 +69,7 @@ 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. */
@ -152,6 +153,7 @@ export class RebaseFlow extends React.Component<IRebaseFlowProps> {
}
private onFlowEnded = () => {
this.props.onDismissed()
this.props.onFlowEnded(this.props.repository)
}

View file

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

View file

@ -56,7 +56,7 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
nextProps.signInState &&
nextProps.signInState.kind === SignInStep.Success
) {
this.props.onDismissed()
this.onDismissed()
}
}
}
@ -88,7 +88,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}`)
@ -321,7 +321,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}
>
@ -331,4 +331,9 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
</Dialog>
)
}
private onDismissed = () => {
this.props.dispatcher.resetSignInState()
this.props.onDismissed()
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ export const shell: IAppShell = {
},
beep: () => {},
showItemInFolder: (path: string) => {},
showFolderContents: (path: string) => {},
openExternal: (path: string) => {
return Promise.resolve(true)
},

View file

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

View file

@ -67,17 +67,41 @@ const commonConfig: webpack.Configuration = {
},
}
export const main = merge({}, commonConfig, {
entry: { main: path.resolve(__dirname, 'src/main-process/main') },
target: 'electron-main',
plugins: [
new webpack.DefinePlugin(
Object.assign({}, replacements, {
__PROCESS_KIND__: JSON.stringify('main'),
})
),
],
})
// 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 a tiny shim instead.
const shimFileMetadata = {
resolve: {
alias: {
'file-metadata': path.resolve(
__dirname,
'src',
'lib',
'helpers',
'file-metadata.js'
),
},
},
}
export const main = merge(
{},
commonConfig,
{
entry: { main: path.resolve(__dirname, 'src/main-process/main') },
target: 'electron-main',
plugins: [
new webpack.DefinePlugin(
Object.assign({}, replacements, {
__PROCESS_KIND__: JSON.stringify('main'),
})
),
],
},
process.platform !== 'darwin' ? shimFileMetadata : {}
)
export const renderer = merge({}, commonConfig, {
entry: { renderer: path.resolve(__dirname, 'src/ui/index') },

View file

@ -53,12 +53,12 @@ ansi-styles@^3.1.0:
dependencies:
color-convert "^1.9.0"
app-path@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/app-path/-/app-path-3.2.0.tgz#06d426e0c988885264e0aa0a766dfa2723491633"
integrity sha512-PQPaKXi64FZuofJkrtaO3I5RZESm9Yjv7tkeJaDz4EZMeBBfGtr5MyQ3m5AC7F0HVrISBLatPxAAAgvbe418fQ==
app-path@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/app-path/-/app-path-2.2.0.tgz#2af5c2b544a40e15fc1ac55548314397460845d0"
integrity sha1-KvXCtUSkDhX8GsVVSDFDl0YIRdA=
dependencies:
execa "^1.0.0"
execa "^0.4.0"
aproba@^1.0.3:
version "1.2.0"
@ -96,6 +96,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
base64-js@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1"
integrity sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=
bl@^1.0.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c"
@ -263,17 +268,6 @@ cross-spawn-async@^2.1.1:
lru-cache "^4.0.0"
which "^1.2.8"
cross-spawn@^6.0.0:
version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
dependencies:
nice-try "^1.0.4"
path-key "^2.0.1"
semver "^5.5.0"
shebang-command "^1.2.0"
which "^1.2.9"
cross-unzip@0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/cross-unzip/-/cross-unzip-0.0.2.tgz#5183bc47a09559befcf98cc4657964999359372f"
@ -477,19 +471,6 @@ execa@^0.4.0:
path-key "^1.0.0"
strip-eof "^1.0.0"
execa@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8"
integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==
dependencies:
cross-spawn "^6.0.0"
get-stream "^4.0.0"
is-stream "^1.1.0"
npm-run-path "^2.0.0"
p-finally "^1.0.0"
signal-exit "^3.0.0"
strip-eof "^1.0.0"
expand-template@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c"
@ -500,7 +481,7 @@ eyes@0.1.x:
resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0"
integrity sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=
fbjs@^0.8.16, fbjs@^0.8.4:
fbjs@^0.8.16:
version "0.8.16"
resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db"
integrity sha1-XmdDL1UNxBtXK/VYR7ispk5TN9s=
@ -513,10 +494,17 @@ fbjs@^0.8.16, fbjs@^0.8.4:
setimmediate "^1.0.5"
ua-parser-js "^0.7.9"
file-uri-to-path@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-2.0.0.tgz#7b415aeba227d575851e0a5b0c640d7656403fba"
integrity sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==
file-metadata@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/file-metadata/-/file-metadata-1.0.0.tgz#fb3f063667d1fa80e9b6594a9c0b6557d1a0c015"
integrity sha512-ipgdCeX/rx+ar60f3lMYy6dPDaxhYou442tEXn0OrHxX23vD8ABvVUjKal6+h9bBHkgjFMs57Cmc68O0zGAtKQ==
dependencies:
plist "^2.1.0"
file-uri-to-path@0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-0.0.2.tgz#37cdd1b5b905404b3f05e1b23645be694ff70f82"
integrity sha1-N83RtbkFQEs/BeGyNkW+aU/3D4I=
file-url@^2.0.2:
version "2.0.2"
@ -576,7 +564,7 @@ gauge@~2.7.3:
strip-ansi "^3.0.1"
wide-align "^1.1.0"
get-stream@^4.0.0, get-stream@^4.1.0:
get-stream@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==
@ -677,7 +665,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@~2.0.3:
inherits@2, inherits@~2.0.0, inherits@~2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
@ -764,12 +752,12 @@ keyboardevents-areequal@^0.2.1:
resolved "https://registry.yarnpkg.com/keyboardevents-areequal/-/keyboardevents-areequal-0.2.2.tgz#88191ec738ce9f7591c25e9056de928b40277194"
integrity sha512-Nv+Kr33T0mEjxR500q+I6IWisOQ0lK1GGOncV0kWE6n4KFmpcu7RUX5/2B0EUtX51Cb0HjZ9VJsSY3u4cBa0kw==
keytar@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/keytar/-/keytar-5.0.0.tgz#c89b6b7a4608fd7af633d9f8474b1a7eb97cbe6f"
integrity sha512-a5UheK59YOlJf9i+2Osaj/kkH6mK0RCHVMtJ84u6ZfbfRIbOJ/H4b5VlOF/LgNHF6s78dRSBzZnvIuPiBKv6wg==
keytar@^5.6.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/keytar/-/keytar-5.6.0.tgz#7b5d4bd043d17211163640be6c4a27a49b12bb39"
integrity sha512-ueulhshHSGoryfRXaIvTj0BV1yB0KddBGhGoqCxSN9LR1Ks1GKuuCdVhF+2/YOs5fMl6MlTI9On1a4DHDXoTow==
dependencies:
nan "2.14.0"
nan "2.14.1"
prebuild-install "5.3.3"
keyv@^3.0.0:
@ -918,7 +906,12 @@ ms@^2.1.1:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
nan@2.14.0, nan@^2.10.0, nan@^2.13.2:
nan@2.14.1:
version "2.14.1"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
nan@^2.10.0, nan@^2.13.2:
version "2.14.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
@ -928,11 +921,6 @@ napi-build-utils@^1.0.1:
resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.1.tgz#1381a0f92c39d66bf19852e7873432fc2123e508"
integrity sha512-boQj1WFgQH3v4clhu3mTNfP+vOBxorDlE8EKiMjUlLG3C4qAESnn9AxIOkFgTR2c9LtzNjPrjS60cT27ZKBhaA==
nice-try@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
node-abi@^2.7.0:
version "2.7.1"
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.7.1.tgz#a8997ae91176a5fbaa455b194976e32683cda643"
@ -965,13 +953,6 @@ npm-run-path@^1.0.0:
dependencies:
path-key "^1.0.0"
npm-run-path@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=
dependencies:
path-key "^2.0.0"
npmlog@^4.0.1:
version "4.1.2"
resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
@ -1026,11 +1007,6 @@ p-defer@^1.0.0:
resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c"
integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=
p-finally@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
p-is-promise@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e"
@ -1058,10 +1034,14 @@ path-key@^1.0.0:
resolved "https://registry.yarnpkg.com/path-key/-/path-key-1.0.0.tgz#5d53d578019646c0d68800db4e146e6bdc2ac7af"
integrity sha1-XVPVeAGWRsDWiADbThRua9wqx68=
path-key@^2.0.0, path-key@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
plist@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/plist/-/plist-2.1.0.tgz#57ccdb7a0821df21831217a3cad54e3e146a1025"
integrity sha1-V8zbeggh3yGDEhejytVOPhRqECU=
dependencies:
base64-js "1.2.0"
xmlbuilder "8.2.2"
xmldom "0.1.x"
prebuild-install@5.3.3:
version "5.3.3"
@ -1193,12 +1173,12 @@ querystring@^0.2.0:
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=
queue@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/queue/-/queue-5.0.1.tgz#17b7d8b8f9ecd563b8b160d4f469b78f5573a368"
integrity sha512-c3KGXGbjY5KMHfemu1HN57Fz/7ECA4TPgCJ3u0io25z2vBpgppHo5SQOkScDRU5iXP4HWE7hKk+Cteb6+p/wew==
queue@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/queue/-/queue-4.4.2.tgz#5a9733d9a8b8bd1b36e934bc9c55ab89b28e29c7"
integrity sha512-fSMRXbwhMwipcDZ08enW2vl+YDmAmhcNcr43sCJL8DIg+CFOsoRLG23ctxA+fwNk1w55SePSiS7oqQQSgQoVJQ==
dependencies:
inherits "~2.0.3"
inherits "~2.0.0"
quick-lru@^3.0.0:
version "3.0.0"
@ -1215,14 +1195,6 @@ rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-addons-shallow-compare@^15.6.2:
version "15.6.2"
resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.2.tgz#198a00b91fc37623db64a28fd17b596ba362702f"
integrity sha1-GYoAuR/DdiPbZKKP0XtZa6NicC8=
dependencies:
fbjs "^0.8.4"
object-assign "^4.1.0"
react-css-transition-replace@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/react-css-transition-replace/-/react-css-transition-replace-3.0.3.tgz#23d3ed17f54e41435c0485300adb75d2e6e24aad"
@ -1378,11 +1350,6 @@ semver@^5.4.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
semver@^5.5.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
set-blocking@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
@ -1393,18 +1360,6 @@ setimmediate@^1.0.5:
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=
shebang-command@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=
dependencies:
shebang-regex "^1.0.0"
shebang-regex@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
signal-exit@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
@ -1671,7 +1626,7 @@ which-pm-runs@^1.0.0:
resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb"
integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=
which@^1.2.8, which@^1.2.9:
which@^1.2.8:
version "1.3.0"
resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a"
integrity sha512-xcJpopdamTuY5duC/KnTTNBraPK54YwpenP4lzxU8H91GudWpFv38u0CKjclE1Wi2EH2EDz5LRcHcKbCIzqGyg==
@ -1722,6 +1677,16 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
xmlbuilder@8.2.2:
version "8.2.2"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773"
integrity sha1-aSSGc0ELS6QuGmE2VR0pIjNap3M=
xmldom@0.1.x:
version "0.1.31"
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
xtend@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"

View file

@ -56,6 +56,7 @@
},
"dependencies": {
"@primer/octicons": "^9.1.0",
"@types/plist": "^3.0.2",
"@typescript-eslint/eslint-plugin": "3.3.0",
"@typescript-eslint/parser": "3.3.0",
"airbnb-browser-shims": "^3.0.0",
@ -68,7 +69,7 @@
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"chalk": "^2.2.0",
"clean-webpack-plugin": "^0.1.19",
"codecov": "^3.6.5",
"codecov": "^3.7.1",
"cross-env": "^5.0.5",
"css-loader": "^2.1.0",
"eslint": "^5.16.0",
@ -99,7 +100,7 @@
"rimraf": "^2.5.2",
"sass-loader": "^7.0.1",
"semver": "^5.5.0",
"spectron": "5.0.0",
"spectron": "10.0.1",
"stop-build": "^1.1.0",
"style-loader": "^0.21.0",
"to-camel-case": "^1.0.0",
@ -137,7 +138,7 @@
"@types/glob": "^5.0.35",
"@types/html-webpack-plugin": "^2.30.3",
"@types/jest": "^23.3.1",
"@types/keytar": "^4.0.0",
"@types/keytar": "^4.4.2",
"@types/klaw-sync": "^6.0.0",
"@types/legal-eagle": "^0.15.0",
"@types/memoize-one": "^3.1.1",
@ -162,7 +163,6 @@
"@types/untildify": "^3.0.0",
"@types/username": "^3.0.0",
"@types/uuid": "^3.4.0",
"@types/webdriverio": "^4.13.0",
"@types/webpack": "^4.4.0",
"@types/webpack-bundle-analyzer": "^2.9.2",
"@types/webpack-dev-middleware": "^2.0.1",
@ -170,10 +170,9 @@
"@types/webpack-merge": "^4.1.3",
"@types/winston": "^2.2.0",
"@types/xml2js": "^0.4.0",
"electron": "7.1.8",
"electron": "8.4.0",
"electron-builder": "22.4.0",
"electron-packager": "^14.2.1",
"electron-winstaller": "4.0.0",
"tsconfig-paths": "^3.9.0"
"electron-winstaller": "4.0.0"
}
}

1763
yarn.lock

File diff suppressed because it is too large Load diff