mirror of
https://github.com/desktop/desktop
synced 2024-10-30 15:45:17 +00:00
Merge pull request #860 from desktop/popups-for-realz
The People's Dialog
This commit is contained in:
commit
2198b6a812
36 changed files with 1594 additions and 340 deletions
|
@ -175,9 +175,6 @@ export interface IRepositoryState {
|
|||
|
||||
/** The date the repository was last fetched. */
|
||||
readonly lastFetched: Date | null
|
||||
|
||||
/** The current text of the gitignore file at the root of the repository */
|
||||
readonly gitIgnoreText: string | null
|
||||
}
|
||||
|
||||
export interface IBranchesState {
|
||||
|
|
|
@ -227,7 +227,6 @@ export class AppStore {
|
|||
remote: null,
|
||||
pushPullInProgress: false,
|
||||
lastFetched: null,
|
||||
gitIgnoreText: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -345,7 +344,6 @@ export class AppStore {
|
|||
aheadBehind: gitStore.aheadBehind,
|
||||
remote: gitStore.remote,
|
||||
lastFetched: gitStore.lastFetched,
|
||||
gitIgnoreText: gitStore.gitIgnoreText,
|
||||
}
|
||||
))
|
||||
|
||||
|
@ -1313,25 +1311,23 @@ export class AppStore {
|
|||
return openShell(path)
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _setGitIgnoreText(repository: Repository, text: string): Promise<void> {
|
||||
const gitStore = this.getGitStore(repository)
|
||||
await gitStore.setGitIgnoreText(text)
|
||||
|
||||
return this._refreshRepository(repository)
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _refreshGitIgnore(repository: Repository): Promise<void> {
|
||||
const gitStore = this.getGitStore(repository)
|
||||
return gitStore.refreshGitIgnoreText()
|
||||
}
|
||||
|
||||
/** Takes a URL and opens it using the system default application */
|
||||
public _openInBrowser(url: string) {
|
||||
return shell.openExternal(url)
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _saveGitIgnore(repository: Repository, text: string): Promise<void> {
|
||||
const gitStore = this.getGitStore(repository)
|
||||
return gitStore.saveGitIgnore(text)
|
||||
}
|
||||
|
||||
/** This shouldn't be called directly. See `Dispatcher`. */
|
||||
public async _readGitIgnore(repository: Repository): Promise<string | null> {
|
||||
const gitStore = this.getGitStore(repository)
|
||||
return gitStore.readGitIgnore()
|
||||
}
|
||||
|
||||
/** Has the user opted out of stats reporting? */
|
||||
public getStatsOptOut(): boolean {
|
||||
return this.statsStore.getOptOut()
|
||||
|
|
|
@ -475,16 +475,6 @@ export class Dispatcher {
|
|||
return this.appStore._setRemoteURL(repository, name, url)
|
||||
}
|
||||
|
||||
/** Write the given rules to the gitignore file at the root of the repository. */
|
||||
public setGitIgnoreText(repository: Repository, text: string): Promise<void> {
|
||||
return this.appStore._setGitIgnoreText(repository, text)
|
||||
}
|
||||
|
||||
/** Populate the current root gitignore text into the application state */
|
||||
public refreshGitIgnore(repository: Repository): Promise<void> {
|
||||
return this.appStore._refreshGitIgnore(repository)
|
||||
}
|
||||
|
||||
/** Open the URL in a browser */
|
||||
public openInBrowser(url: string) {
|
||||
return this.appStore._openInBrowser(url)
|
||||
|
@ -500,6 +490,28 @@ export class Dispatcher {
|
|||
return this.appStore._openShell(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the given content to the repository's root .gitignore.
|
||||
*
|
||||
* If the repository root doesn't contain a .gitignore file one
|
||||
* will be created, otherwise the current file will be overwritten.
|
||||
*/
|
||||
public async saveGitIgnore(repository: Repository, text: string): Promise<void> {
|
||||
await this.appStore._saveGitIgnore(repository, text)
|
||||
await this.appStore._refreshRepository(repository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the contents of the repository's .gitignore.
|
||||
*
|
||||
* Returns a promise which will either be rejected or resolved
|
||||
* with the contents of the file. If there's no .gitignore file
|
||||
* in the repository root the promise will resolve with null.
|
||||
*/
|
||||
public async readGitIgnore(repository: Repository): Promise<string | null> {
|
||||
return this.appStore._readGitIgnore(repository)
|
||||
}
|
||||
|
||||
/** Set whether the user has opted out of stats reporting. */
|
||||
public setStatsOptOut(optOut: boolean): Promise<void> {
|
||||
return this.appStore._setStatsOptOut(optOut)
|
||||
|
|
|
@ -72,8 +72,6 @@ export class GitStore {
|
|||
|
||||
private _lastFetched: Date | null = null
|
||||
|
||||
private _gitIgnoreText: string | null = null
|
||||
|
||||
public constructor(repository: Repository) {
|
||||
this.repository = repository
|
||||
}
|
||||
|
@ -470,29 +468,6 @@ export class GitStore {
|
|||
})
|
||||
}
|
||||
|
||||
/** The current contents of the gitignore file at the root of the repository */
|
||||
public get gitIgnoreText(): string | null { return this._gitIgnoreText }
|
||||
|
||||
/** Populate the current root gitignore text into the application state */
|
||||
public refreshGitIgnoreText(): Promise<void> {
|
||||
const path = Path.join(this.repository.path, '.gitignore')
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
Fs.readFile(path, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
// TODO: what if this is a real error and we can't read the file?
|
||||
this._gitIgnoreText = null
|
||||
} else {
|
||||
// ensure we assign something to the current text
|
||||
this._gitIgnoreText = data || null
|
||||
}
|
||||
|
||||
resolve()
|
||||
|
||||
this.emitUpdate()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Merge the named branch into the current branch. */
|
||||
public merge(branch: string): Promise<void> {
|
||||
return this.performFailableOperation(() => merge(this.repository, branch))
|
||||
|
@ -506,45 +481,73 @@ export class GitStore {
|
|||
this.emitUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the contents of the repository .gitignore.
|
||||
*
|
||||
* Returns a promise which will either be rejected or resolved
|
||||
* with the contents of the file. If there's no .gitignore file
|
||||
* in the repository root the promise will resolve with null.
|
||||
*/
|
||||
public async readGitIgnore(): Promise<string | null> {
|
||||
const repository = this.repository
|
||||
const ignorePath = Path.join(repository.path, '.gitignore')
|
||||
|
||||
return new Promise<string | null>((resolve, reject) => {
|
||||
Fs.readFile(ignorePath, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
resolve(null)
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
} else {
|
||||
resolve(data)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the given content to the repository root .gitignore.
|
||||
*
|
||||
* If the repository root doesn't contain a .gitignore file one
|
||||
* will be created, otherwise the current file will be overwritten.
|
||||
*/
|
||||
public async saveGitIgnore(text: string): Promise<void> {
|
||||
const repository = this.repository
|
||||
const ignorePath = Path.join(repository.path, '.gitignore')
|
||||
const fileContents = ensureTrailingNewline(text)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
Fs.writeFile(ignorePath, fileContents, err => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Ignore the given path or pattern. */
|
||||
public async ignore(pattern: string): Promise<void> {
|
||||
await this.refreshGitIgnoreText()
|
||||
|
||||
const text = this.gitIgnoreText || ''
|
||||
const currentContents = this.ensureTrailingNewline(text)
|
||||
const newText = this.ensureTrailingNewline(`${currentContents}${pattern}`)
|
||||
await this.setGitIgnoreText(newText)
|
||||
const text = await this.readGitIgnore() || ''
|
||||
const currentContents = ensureTrailingNewline(text)
|
||||
const newText = ensureTrailingNewline(`${currentContents}${pattern}`)
|
||||
await this.saveGitIgnore(newText)
|
||||
|
||||
await removeFromIndex(this.repository, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureTrailingNewline(text: string): string {
|
||||
// mixed line endings might be an issue here
|
||||
if (!text.endsWith('\n')) {
|
||||
const linesEndInCRLF = text.indexOf('\r\n')
|
||||
return linesEndInCRLF === -1
|
||||
? `${text}\n`
|
||||
: `${text}\r\n`
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/** Overwrite the current .gitignore contents (if exists) */
|
||||
public async setGitIgnoreText(text: string): Promise<void> {
|
||||
const gitIgnorePath = Path.join(this.repository.path, '.gitignore')
|
||||
const fileContents = this.ensureTrailingNewline(text)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
Fs.writeFile(gitIgnorePath, fileContents, err => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.refreshGitIgnoreText()
|
||||
})
|
||||
})
|
||||
function ensureTrailingNewline(text: string): string {
|
||||
// mixed line endings might be an issue here
|
||||
if (!text.endsWith('\n')) {
|
||||
const linesEndInCRLF = text.indexOf('\r\n')
|
||||
return linesEndInCRLF === -1
|
||||
? `${text}\n`
|
||||
: `${text}\r\n`
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
|
112
app/src/ui/app-error.tsx
Normal file
112
app/src/ui/app-error.tsx
Normal file
|
@ -0,0 +1,112 @@
|
|||
import * as React from 'react'
|
||||
import * as ReactCSSTransitionGroup from 'react-addons-css-transition-group'
|
||||
|
||||
import { IAppError } from '../lib/app-state'
|
||||
import { Button } from './lib/button'
|
||||
import { ButtonGroup } from './lib/button-group'
|
||||
import { Dialog, DialogContent, DialogFooter } from './dialog'
|
||||
import { dialogTransitionEnterTimeout, dialogTransitionLeaveTimeout } from './app'
|
||||
|
||||
interface IAppErrorProps {
|
||||
/** The list of queued, app-wide, errors */
|
||||
readonly errors: ReadonlyArray<IAppError>
|
||||
|
||||
/**
|
||||
* A callback which is used whenever a particular error
|
||||
* has been shown to, and been dismissed by, the user.
|
||||
*/
|
||||
readonly onClearError: (error: IAppError) => void
|
||||
}
|
||||
|
||||
interface IAppErrorState {
|
||||
/** The currently displayed error or null if no error is shown */
|
||||
readonly error: IAppError | null
|
||||
|
||||
/**
|
||||
* Whether or not the dialog and its buttons are disabled.
|
||||
* This is used when the dialog is transitioning out of view.
|
||||
*/
|
||||
readonly disabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A component which renders application-wide errors as dialogs. Only one error
|
||||
* is shown per dialog and if multiple errors are queued up they will be shown
|
||||
* in the order they were queued.
|
||||
*/
|
||||
export class AppError extends React.Component<IAppErrorProps, IAppErrorState> {
|
||||
|
||||
public constructor(props: IAppErrorProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
error: props.errors[0] || null,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillReceiveProps(nextProps: IAppErrorProps) {
|
||||
const error = nextProps.errors[0] || null
|
||||
|
||||
// We keep the currently shown error until it has disappeared
|
||||
// from the first spot in the application error queue.
|
||||
if (error !== this.state.error) {
|
||||
this.setState({ error, disabled: false })
|
||||
}
|
||||
}
|
||||
|
||||
private onDismissed = () => {
|
||||
const currentError = this.state.error
|
||||
|
||||
if (currentError) {
|
||||
this.setState({ error: null, disabled: true })
|
||||
|
||||
// Give some time for the dialog to nicely transition
|
||||
// out before we clear the error and, potentially, deal
|
||||
// with the next error in the queue.
|
||||
setTimeout(() => {
|
||||
this.props.onClearError(currentError)
|
||||
}, dialogTransitionLeaveTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
private renderDialog() {
|
||||
|
||||
const error = this.state.error
|
||||
|
||||
if (!error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id='app-error'
|
||||
type='error'
|
||||
title='Error'
|
||||
onDismissed={this.onDismissed}
|
||||
disabled={this.state.disabled}
|
||||
>
|
||||
<DialogContent>
|
||||
{error.message}
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit'>Close</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<ReactCSSTransitionGroup
|
||||
transitionName='modal'
|
||||
component='div'
|
||||
transitionEnterTimeout={dialogTransitionEnterTimeout}
|
||||
transitionLeaveTimeout={dialogTransitionLeaveTimeout}
|
||||
>
|
||||
{this.renderDialog()}
|
||||
</ReactCSSTransitionGroup>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import * as classNames from 'classnames'
|
||||
import * as ReactCSSTransitionGroup from 'react-addons-css-transition-group'
|
||||
import { ipcRenderer, remote, shell } from 'electron'
|
||||
|
||||
import { RepositoriesList } from './repositories-list'
|
||||
|
@ -10,7 +11,6 @@ import { Repository } from '../models/repository'
|
|||
import { MenuEvent, MenuIDs } from '../main-process/menu'
|
||||
import { assertNever } from '../lib/fatal-error'
|
||||
import { IAppState, RepositorySection, PopupType, FoldoutType, SelectionType } from '../lib/app-state'
|
||||
import { Popuppy } from './popuppy'
|
||||
import { Branches } from './branches'
|
||||
import { RenameBranch } from './rename-branch'
|
||||
import { DeleteBranch } from './delete-branch'
|
||||
|
@ -30,10 +30,10 @@ import { Preferences } from './preferences'
|
|||
import { User } from '../models/user'
|
||||
import { TipState } from '../models/tip'
|
||||
import { shouldRenderApplicationMenu } from './lib/features'
|
||||
import { Button } from './lib/button'
|
||||
import { Form } from './lib/form'
|
||||
import { Merge } from './merge-branch'
|
||||
import { RepositorySettings } from './repository-settings'
|
||||
import { AppError } from './app-error'
|
||||
import { IAppError } from '../lib/app-state'
|
||||
|
||||
/** The interval at which we should check for updates. */
|
||||
const UpdateCheckInterval = 1000 * 60 * 60 * 4
|
||||
|
@ -45,6 +45,9 @@ interface IAppProps {
|
|||
readonly appStore: AppStore
|
||||
}
|
||||
|
||||
export const dialogTransitionEnterTimeout = 250
|
||||
export const dialogTransitionLeaveTimeout = 100
|
||||
|
||||
export class App extends React.Component<IAppProps, IAppState> {
|
||||
|
||||
/**
|
||||
|
@ -54,6 +57,14 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
*/
|
||||
private lastKeyPressed: string | null = null
|
||||
|
||||
/**
|
||||
* Gets a value indicating whether or not we're currently showing a
|
||||
* modal dialog such as the preferences, or an error dialog.
|
||||
*/
|
||||
private get isShowingModal() {
|
||||
return this.state.currentPopup || this.state.errors.length
|
||||
}
|
||||
|
||||
public constructor(props: IAppProps) {
|
||||
super(props)
|
||||
|
||||
|
@ -170,6 +181,12 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
}
|
||||
|
||||
private onMenuEvent(name: MenuEvent): any {
|
||||
|
||||
// Don't react to menu events when an error dialog is shown.
|
||||
if (this.state.errors.length) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'push': return this.push()
|
||||
case 'pull': return this.pull()
|
||||
|
@ -360,6 +377,8 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
private onWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) { return }
|
||||
|
||||
if (this.isShowingModal) { return }
|
||||
|
||||
if (shouldRenderApplicationMenu()) {
|
||||
if (event.key === 'Alt') {
|
||||
this.props.dispatcher.setAppMenuToolbarButtonHighlightState(true)
|
||||
|
@ -513,7 +532,17 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
)
|
||||
}
|
||||
|
||||
private onPopupDismissed = () => {
|
||||
this.props.dispatcher.closePopup()
|
||||
}
|
||||
|
||||
private currentPopupContent(): JSX.Element | null {
|
||||
|
||||
// Hide any dialogs while we're displaying an error
|
||||
if (this.state.errors.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const popup = this.state.currentPopup
|
||||
if (!popup) { return null }
|
||||
|
||||
|
@ -524,18 +553,21 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
} else if (popup.type === PopupType.DeleteBranch) {
|
||||
return <DeleteBranch dispatcher={this.props.dispatcher}
|
||||
repository={popup.repository}
|
||||
branch={popup.branch}/>
|
||||
branch={popup.branch}
|
||||
onDismissed={this.onPopupDismissed}/>
|
||||
} else if (popup.type === PopupType.ConfirmDiscardChanges) {
|
||||
return <DiscardChanges repository={popup.repository}
|
||||
dispatcher={this.props.dispatcher}
|
||||
files={popup.files}/>
|
||||
files={popup.files}
|
||||
onDismissed={this.onPopupDismissed}/>
|
||||
} else if (popup.type === PopupType.UpdateAvailable) {
|
||||
return <UpdateAvailable dispatcher={this.props.dispatcher}/>
|
||||
} else if (popup.type === PopupType.Preferences) {
|
||||
return <Preferences
|
||||
dispatcher={this.props.dispatcher}
|
||||
dotComUser={this.getDotComUser()}
|
||||
enterpriseUser={this.getEnterpriseUser()}/>
|
||||
enterpriseUser={this.getEnterpriseUser()}
|
||||
onDismissed={this.onPopupDismissed}/>
|
||||
} else if (popup.type === PopupType.MergeBranch) {
|
||||
const repository = popup.repository
|
||||
const state = this.props.appStore.getRepositoryState(repository)
|
||||
|
@ -543,64 +575,47 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
dispatcher={this.props.dispatcher}
|
||||
repository={repository}
|
||||
branches={state.branchesState.allBranches}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
/>
|
||||
}
|
||||
else if (popup.type === PopupType.RepositorySettings) {
|
||||
const repository = popup.repository
|
||||
const state = this.props.appStore.getRepositoryState(repository)
|
||||
|
||||
// ensure the latest version of the gitignore is retrieved
|
||||
// before we switch to this view
|
||||
this.props.dispatcher.refreshGitIgnore(repository)
|
||||
|
||||
return <RepositorySettings
|
||||
remote={state.remote}
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={repository}
|
||||
gitIgnoreText={state.gitIgnoreText}
|
||||
onDismissed={this.onPopupDismissed}
|
||||
/>
|
||||
}
|
||||
|
||||
return assertNever(popup, `Unknown popup type: ${popup}`)
|
||||
}
|
||||
|
||||
private onPopupOverlayClick = () => { this.props.dispatcher.closePopup() }
|
||||
|
||||
private renderPopup(): JSX.Element | null {
|
||||
let content = this.renderErrors()
|
||||
if (!content) {
|
||||
content = this.currentPopupContent()
|
||||
}
|
||||
|
||||
if (!content) { return null }
|
||||
|
||||
private renderPopup() {
|
||||
return (
|
||||
<div className='fill-window'>
|
||||
<div className='fill-window popup-overlay' onClick={this.onPopupOverlayClick}></div>
|
||||
<Popuppy>{content}</Popuppy>
|
||||
</div>
|
||||
<ReactCSSTransitionGroup
|
||||
transitionName='modal'
|
||||
component='div'
|
||||
transitionEnterTimeout={dialogTransitionEnterTimeout}
|
||||
transitionLeaveTimeout={dialogTransitionLeaveTimeout}
|
||||
>
|
||||
{this.currentPopupContent()}
|
||||
</ReactCSSTransitionGroup>
|
||||
)
|
||||
}
|
||||
|
||||
private clearErrors = () => {
|
||||
const errors = this.state.errors
|
||||
|
||||
for (const error of errors) {
|
||||
this.props.dispatcher.clearError(error)
|
||||
}
|
||||
private clearError = (error: IAppError) => {
|
||||
this.props.dispatcher.clearError(error)
|
||||
}
|
||||
|
||||
private renderErrors() {
|
||||
const errors = this.state.errors
|
||||
if (!errors.length) { return null }
|
||||
|
||||
const msgs = errors.map(e => e.message)
|
||||
private renderAppError() {
|
||||
return (
|
||||
<Form>
|
||||
{msgs.map((msg, i) => <pre className='popup-error-output' key={i}>{msg}</pre>)}
|
||||
|
||||
<Button onClick={this.clearErrors}>OK</Button>
|
||||
</Form>
|
||||
<AppError
|
||||
errors={this.state.errors}
|
||||
onClearError={this.clearError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -610,6 +625,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
{this.renderToolbar()}
|
||||
{this.renderRepository()}
|
||||
{this.renderPopup()}
|
||||
{this.renderAppError()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,32 +3,40 @@ import * as React from 'react'
|
|||
import { Dispatcher } from '../../lib/dispatcher'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { Branch } from '../../models/branch'
|
||||
import { Form } from '../lib/form'
|
||||
import { Button } from '../lib/button'
|
||||
import { ButtonGroup } from '../lib/button-group'
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
|
||||
interface IDeleteBranchProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly repository: Repository
|
||||
readonly branch: Branch
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
export class DeleteBranch extends React.Component<IDeleteBranchProps, void> {
|
||||
public render() {
|
||||
return (
|
||||
<Form onSubmit={this.cancel}>
|
||||
<div>Delete branch "{this.props.branch.name}"?</div>
|
||||
<div>This cannot be undone.</div>
|
||||
|
||||
<Button type='submit'>Cancel</Button>
|
||||
<Button onClick={this.deleteBranch}>Delete</Button>
|
||||
</Form>
|
||||
<Dialog
|
||||
id='delete-branch'
|
||||
title={__DARWIN__ ? 'Delete Branch' : 'Delete branch'}
|
||||
type='warning'
|
||||
onDismissed={this.props.onDismissed}
|
||||
>
|
||||
<DialogContent>
|
||||
<p>Delete branch "{this.props.branch.name}"?</p>
|
||||
<p>This cannot be undone.</p>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit'>Cancel</Button>
|
||||
<Button onClick={this.deleteBranch}>Delete</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private cancel = () => {
|
||||
this.props.dispatcher.closePopup()
|
||||
}
|
||||
|
||||
private deleteBranch = () => {
|
||||
this.props.dispatcher.deleteBranch(this.props.repository, this.props.branch)
|
||||
this.props.dispatcher.closePopup()
|
||||
|
|
21
app/src/ui/dialog/content.tsx
Normal file
21
app/src/ui/dialog/content.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import * as React from 'react'
|
||||
|
||||
/**
|
||||
* A container component for content (ie non-header, non-footer) in a Dialog.
|
||||
* This component should only be used once in any given dialog.
|
||||
*
|
||||
* If a dialog implements a tabbed interface where each tab is a child component
|
||||
* the child components _should_ render the DialogContent component themselves
|
||||
* to avoid excessive nesting and to ensure that styles applying to phrasing
|
||||
* content in the dialog get applied consistently.
|
||||
*/
|
||||
export class DialogContent extends React.Component<void, void> {
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div className='dialog-content'>
|
||||
{this.props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
250
app/src/ui/dialog/dialog.tsx
Normal file
250
app/src/ui/dialog/dialog.tsx
Normal file
|
@ -0,0 +1,250 @@
|
|||
import * as React from 'react'
|
||||
import * as classNames from 'classnames'
|
||||
import { DialogHeader } from './header'
|
||||
|
||||
/**
|
||||
* The time (in milliseconds) from when the dialog is mounted
|
||||
* until it can be dismissed. See the isAppearing property in
|
||||
* IDialogState for more information.
|
||||
*/
|
||||
const dismissGracePeriodMs = 250
|
||||
|
||||
interface IDialogProps {
|
||||
/**
|
||||
* An optional dialog title. Most, if not all dialogs should have
|
||||
* this. When present the Dialog renders a DialogHeader element
|
||||
* containing an icon (if the type prop warrants it), the title itself
|
||||
* and a close button (if the dialog is dismissable).
|
||||
*
|
||||
* By omitting this consumers may use their own custom DialogHeader
|
||||
* for when the default component doesn't cut it.
|
||||
*/
|
||||
readonly title?: string
|
||||
|
||||
/**
|
||||
* Whether or not the dialog should be dismissable. A dismissable dialog
|
||||
* can be dismissed either by clicking on the backdrop or by clicking
|
||||
* the close button in the header (if a header was specified). Dismissal
|
||||
* will trigger the onDismissed event which callers must handle and pass
|
||||
* on to the dispatcher in order to close the dialog.
|
||||
*
|
||||
* A non-dismissable dialog can only be closed by means of the component
|
||||
* implementing a dialog. An example would be a critical error or warning
|
||||
* that requires explicit user action by for example clicking on a button.
|
||||
*
|
||||
* Defaults to true if omitted.
|
||||
*/
|
||||
readonly dismissable?: boolean
|
||||
|
||||
/**
|
||||
* Event triggered when the dialog is dismissed by the user in the
|
||||
* ways described in the dismissable prop.
|
||||
*/
|
||||
readonly onDismissed: () => void
|
||||
|
||||
/**
|
||||
* An optional id for the rendered dialog element.
|
||||
*/
|
||||
readonly id?: string
|
||||
|
||||
/**
|
||||
* An optional dialog type. A warning or error dialog type triggers custom
|
||||
* styling of the dialog, see _dialog.scss for more detail.
|
||||
*
|
||||
* Defaults to 'normal' if omitted
|
||||
*/
|
||||
readonly type?: 'normal' | 'warning' | 'error'
|
||||
|
||||
/**
|
||||
* An event triggered when the dialog form is submitted. All dialogs contain
|
||||
* a top-level form element which can be triggered through a submit button.
|
||||
*
|
||||
* Consumers should handle this rather than subscribing to the onClick event
|
||||
* on the button itself since there may be other ways of submitting a specific
|
||||
* form (such as Ctrl+Enter).
|
||||
*/
|
||||
readonly onSubmit?: () => void
|
||||
|
||||
/**
|
||||
* An optional className to be applied to the rendered dialog element.
|
||||
*/
|
||||
readonly className?: string
|
||||
|
||||
/**
|
||||
* Whether or not the dialog should be disabled. All dialogs wrap their
|
||||
* content in a <fieldset> element which, when disabled, causes all descendant
|
||||
* form elements and buttons to also become disabled. This is useful for
|
||||
* consumers implementing a typical save dialog where the save action isn't
|
||||
* instantaneous (such as a sign in dialog) and they need to ensure that the
|
||||
* user doesn't continue mutating the form state or click buttons while the
|
||||
* save/submit action is in progress. Note that this does not prevent the
|
||||
* dialog from being dismissed.
|
||||
*/
|
||||
readonly disabled?: boolean
|
||||
}
|
||||
|
||||
interface IDialogState {
|
||||
/**
|
||||
* When a dialog is shown we wait for a few hundred milliseconds before
|
||||
* acknowledging a dismissal in order to avoid people accidentally dismissing
|
||||
* dialogs that appear as they're doing other things. Since the entire
|
||||
* backdrop of a dialog can be clicked to dismiss all it takes is one rogue
|
||||
* click and the dialog is gone. This is less than ideal if we're in the
|
||||
* middle of displaying an important error message.
|
||||
*
|
||||
* This state boolean is used to keep track of whether we're still in that
|
||||
* grace period or not.
|
||||
*/
|
||||
readonly isAppearing: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A general purpose, versatile, dialog component which utilizes the new
|
||||
* <dialog> element. See https://demo.agektmr.com/dialog/
|
||||
*
|
||||
* A dialog is opened as a modal that prevents keyboard or pointer access to
|
||||
* underlying elements. It's not possible to use the tab key to move focus
|
||||
* out of the dialog without first dismissing it.
|
||||
*/
|
||||
export class Dialog extends React.Component<IDialogProps, IDialogState> {
|
||||
|
||||
private dialogElement?: HTMLElement
|
||||
private dismissGraceTimeoutId?: number
|
||||
|
||||
public constructor(props: IDialogProps) {
|
||||
super(props)
|
||||
this.state = { isAppearing: true }
|
||||
}
|
||||
|
||||
private clearDismissGraceTimeout() {
|
||||
if (this.dismissGraceTimeoutId !== undefined) {
|
||||
clearTimeout(this.dismissGraceTimeoutId)
|
||||
this.dismissGraceTimeoutId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDismissGraceTimeout() {
|
||||
this.clearDismissGraceTimeout()
|
||||
this.dismissGraceTimeoutId = window.setTimeout(this.onDismissGraceTimer, dismissGracePeriodMs)
|
||||
}
|
||||
|
||||
private onDismissGraceTimer = () => {
|
||||
this.setState({ isAppearing: false })
|
||||
}
|
||||
|
||||
private isDismissable() {
|
||||
return this.props.dismissable === undefined || this.props.dismissable
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
// This cast to any is necessary since React doesn't know about the
|
||||
// dialog element yet.
|
||||
(this.dialogElement as any).showModal()
|
||||
|
||||
this.setState({ isAppearing: true })
|
||||
this.scheduleDismissGraceTimeout()
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.clearDismissGraceTimeout()
|
||||
}
|
||||
|
||||
private onDialogCancel = (e: Event) => {
|
||||
e.preventDefault()
|
||||
this.onDismiss()
|
||||
}
|
||||
|
||||
private onDialogClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
|
||||
if (!this.isDismissable) {
|
||||
return
|
||||
}
|
||||
|
||||
// Figure out if the user clicked on the backdrop or in the dialog itself.
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
|
||||
// http://stackoverflow.com/a/26984690/2114
|
||||
const isInDialog =
|
||||
rect.top <= e.clientY &&
|
||||
e.clientY <= rect.top + rect.height &&
|
||||
rect.left <= e.clientX &&
|
||||
e.clientX <= rect.left + rect.width
|
||||
|
||||
if (!isInDialog) {
|
||||
e.preventDefault()
|
||||
this.onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private onDialogRef = (e: HTMLElement | undefined) => {
|
||||
// We need to explicitly subscribe to and unsubscribe from the dialog
|
||||
// element as react doesn't yet understand the element and which events
|
||||
// it has.
|
||||
if (!e) {
|
||||
if (this.dialogElement) {
|
||||
this.dialogElement.removeEventListener('cancel', this.onDialogCancel)
|
||||
}
|
||||
} else {
|
||||
e.addEventListener('cancel', this.onDialogCancel)
|
||||
}
|
||||
|
||||
this.dialogElement = e
|
||||
}
|
||||
|
||||
private onDismiss = () => {
|
||||
if (this.isDismissable() && !this.state.isAppearing) {
|
||||
if (this.props.onDismissed) {
|
||||
this.props.onDismissed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (this.props.onSubmit) {
|
||||
this.props.onSubmit()
|
||||
} else {
|
||||
this.onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private renderHeader() {
|
||||
if (!this.props.title) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogHeader
|
||||
title={this.props.title}
|
||||
dismissable={this.isDismissable()}
|
||||
onDismissed={this.onDismiss}
|
||||
type={this.props.type}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
|
||||
const className = classNames({
|
||||
error: this.props.type === 'error',
|
||||
warning: this.props.type === 'warning',
|
||||
}, this.props.className)
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={this.onDialogRef}
|
||||
id={this.props.id}
|
||||
onClick={this.onDialogClick}
|
||||
className={className}
|
||||
autoFocus>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<fieldset disabled={this.props.disabled}>
|
||||
{this.renderHeader()}
|
||||
{this.props.children}
|
||||
</fieldset>
|
||||
</form>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
}
|
26
app/src/ui/dialog/error.tsx
Normal file
26
app/src/ui/dialog/error.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import * as React from 'react'
|
||||
import { Octicon, OcticonSymbol } from '../octicons'
|
||||
|
||||
/**
|
||||
* A component used for displaying short error messages inline
|
||||
* in a dialog. These error messages (there can be more than one)
|
||||
* should be rendered as the first child of the <Dialog> component
|
||||
* and support arbitrary content.
|
||||
*
|
||||
* The content (error message) is paired with a stop icon and receive
|
||||
* special styling.
|
||||
*/
|
||||
export class DialogError extends React.Component<void, void> {
|
||||
|
||||
public render() {
|
||||
|
||||
return (
|
||||
<div className='dialog-error'>
|
||||
<Octicon symbol={OcticonSymbol.stop} />
|
||||
<div>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
16
app/src/ui/dialog/footer.tsx
Normal file
16
app/src/ui/dialog/footer.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import * as React from 'react'
|
||||
|
||||
/**
|
||||
* A container component for footer content in a Dialog.
|
||||
* This component should only be used at most once in any given dialog and it
|
||||
* should be rendered as the last child of that dialog.
|
||||
*/
|
||||
export class DialogFooter extends React.Component<void, void> {
|
||||
public render() {
|
||||
return (
|
||||
<div className='dialog-footer'>
|
||||
{this.props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
81
app/src/ui/dialog/header.tsx
Normal file
81
app/src/ui/dialog/header.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import * as React from 'react'
|
||||
import { Octicon, OcticonSymbol } from '../octicons'
|
||||
import { assertNever } from '../../lib/fatal-error'
|
||||
|
||||
interface IDialogHeaderProps {
|
||||
/**
|
||||
* The dialog title text. Will be rendered top and center in a dialog.
|
||||
*/
|
||||
readonly title: string
|
||||
|
||||
/**
|
||||
* Whether or not the implementing dialog is dismissable. This controls
|
||||
* whether or not the dialog header renders a close button or not.
|
||||
*/
|
||||
readonly dismissable: boolean
|
||||
|
||||
/**
|
||||
* Event triggered when the dialog is dismissed by the user in the
|
||||
* ways described in the dismissable prop.
|
||||
*/
|
||||
readonly onDismissed?: () => void
|
||||
|
||||
/**
|
||||
* An optional type of dialog header. If the type is error or warning
|
||||
* an applicable icon will be rendered top left in the dialog.
|
||||
*
|
||||
* Defaults to 'normal' if omitted.
|
||||
*/
|
||||
readonly type?: 'normal' | 'warning' | 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* A high-level component for Dialog headers.
|
||||
*
|
||||
* This component should typically not be used by consumers as the title prop
|
||||
* of the Dialog component should suffice. There are, however, cases where
|
||||
* custom content needs to be rendered in a dialog and in that scenario it
|
||||
* might be necessary to use this component directly.
|
||||
*/
|
||||
export class DialogHeader extends React.Component<IDialogHeaderProps, void> {
|
||||
|
||||
private onCloseButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (this.props.onDismissed) {
|
||||
this.props.onDismissed()
|
||||
}
|
||||
}
|
||||
|
||||
private renderCloseButton() {
|
||||
if (!this.props.dismissable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<button className='close' tabIndex={-1} onClick={this.onCloseButtonClick}>
|
||||
<Octicon symbol={OcticonSymbol.x} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
private renderIcon() {
|
||||
if (this.props.type === undefined || this.props.type === 'normal') {
|
||||
return null
|
||||
} else if (this.props.type === 'error') {
|
||||
return <Octicon className='icon' symbol={OcticonSymbol.stop} />
|
||||
} else if (this.props.type === 'warning') {
|
||||
return <Octicon className='icon' symbol={OcticonSymbol.alert} />
|
||||
}
|
||||
|
||||
return assertNever(this.props.type, `Unknown dialog header type ${this.props.type}`)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<header className='dialog-header'>
|
||||
{this.renderIcon()}
|
||||
<h1>{this.props.title}</h1>
|
||||
{this.renderCloseButton()}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
}
|
4
app/src/ui/dialog/index.ts
Normal file
4
app/src/ui/dialog/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from './content'
|
||||
export * from './dialog'
|
||||
export * from './error'
|
||||
export * from './footer'
|
|
@ -3,13 +3,16 @@ import * as React from 'react'
|
|||
import { Repository } from '../../models/repository'
|
||||
import { Dispatcher } from '../../lib/dispatcher'
|
||||
import { WorkingDirectoryFileChange } from '../../models/status'
|
||||
import { Form } from '../lib/form'
|
||||
import { Button } from '../lib/button'
|
||||
import { ButtonGroup } from '../lib/button-group'
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
import { PathText } from '../lib/path-text'
|
||||
|
||||
interface IDiscardChangesProps {
|
||||
readonly repository: Repository
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly files: ReadonlyArray<WorkingDirectoryFileChange>
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -23,34 +26,43 @@ export class DiscardChanges extends React.Component<IDiscardChangesProps, void>
|
|||
public render() {
|
||||
const trashName = __DARWIN__ ? 'Trash' : 'Recycle Bin'
|
||||
return (
|
||||
<Form className='discard-changes' onSubmit={this.cancel}>
|
||||
<div>{ __DARWIN__ ? 'Confirm Discard Changes' : 'Confirm discard changes'}</div>
|
||||
<div>
|
||||
<Dialog
|
||||
id='discard-changes'
|
||||
title={ __DARWIN__ ? 'Confirm Discard Changes' : 'Confirm discard changes'}
|
||||
onDismissed={this.props.onDismissed}
|
||||
type='warning'
|
||||
>
|
||||
<DialogContent>
|
||||
{this.renderFileList()}
|
||||
<p>Changes can be restored by retrieving them from the {trashName}.</p>
|
||||
</DialogContent>
|
||||
|
||||
<div>Changes can be restored by retrieving them from the {trashName}.</div>
|
||||
</div>
|
||||
|
||||
<Button type='submit'>Cancel</Button>
|
||||
<Button onClick={this.discard}>{__DARWIN__ ? 'Discard Changes' : 'Discard changes'}</Button>
|
||||
</Form>
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit'>Cancel</Button>
|
||||
<Button onClick={this.discard}>{__DARWIN__ ? 'Discard Changes' : 'Discard changes'}</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private renderFileList() {
|
||||
if (this.props.files.length > MaxFilesToList) {
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
Are you sure you want to discard all changes?
|
||||
<div> </div>
|
||||
</div>
|
||||
</p>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div>Are you sure you want to discard all changes to:
|
||||
<div>
|
||||
<p>Are you sure you want to discard all changes to:</p>
|
||||
<ul>
|
||||
{this.props.files.map(p =>
|
||||
<li className='file-name' key={p.id}>{p.path}</li>
|
||||
<li className='file-name' key={p.id}>
|
||||
<PathText path={p.path} />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -58,10 +70,6 @@ export class DiscardChanges extends React.Component<IDiscardChangesProps, void>
|
|||
}
|
||||
}
|
||||
|
||||
private cancel = () => {
|
||||
this.props.dispatcher.closePopup()
|
||||
}
|
||||
|
||||
private discard = () => {
|
||||
this.props.dispatcher.discardChanges(this.props.repository, this.props.files)
|
||||
this.props.dispatcher.closePopup()
|
||||
|
|
49
app/src/ui/lib/button-group.tsx
Normal file
49
app/src/ui/lib/button-group.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import * as React from 'react'
|
||||
import { Button, IButtonProps } from './button'
|
||||
|
||||
/**
|
||||
* A component for rendering primary and secondary buttons in
|
||||
* a dialog, form or foldout in the platform specific order.
|
||||
*
|
||||
* Ie, on Windows we expect the button order to be Ok, Cancel
|
||||
* whereas on Mac we expect it to be Cancel, Ok. This component,
|
||||
* coupled with the button-group-order tslint rule ensures that
|
||||
* we adhere to platform conventions.
|
||||
*
|
||||
* See https://www.nngroup.com/articles/ok-cancel-or-cancel-ok/
|
||||
*
|
||||
* Non-button content in this component is prohibited and will
|
||||
* not render.
|
||||
*/
|
||||
export class ButtonGroup extends React.Component<void, void> {
|
||||
public render() {
|
||||
|
||||
const buttons = new Array<React.ReactElement<IButtonProps>>()
|
||||
|
||||
React.Children.forEach(this.props.children, c => {
|
||||
if (typeof(c) !== 'string' && typeof(c) !== 'number') {
|
||||
if (c.type === Button) {
|
||||
buttons.push(c as React.ReactElement<IButtonProps>)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Flip the order of the buttons if they don't appear to be
|
||||
// in the correct order. The tslint rule button-group-order
|
||||
// _should_ ensure that it's always Ok, Cancel in markup but
|
||||
// we're a little bit more lax here.
|
||||
if (buttons.length > 1) {
|
||||
if (__DARWIN__ && buttons[0].props.type === 'submit') {
|
||||
buttons.reverse()
|
||||
} else if (__WIN32__ && buttons[buttons.length - 1].props.type === 'submit') {
|
||||
buttons.reverse()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='button-group'>
|
||||
{buttons}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from 'react'
|
||||
import * as classNames from 'classnames'
|
||||
|
||||
interface IButtonProps {
|
||||
export interface IButtonProps {
|
||||
/** A function to call on click. */
|
||||
readonly onClick?: () => void
|
||||
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
import * as React from 'react'
|
||||
import { Form } from '../lib/form'
|
||||
import { Select } from '../lib/select'
|
||||
import { Button } from '../lib/button'
|
||||
import { Dispatcher } from '../../lib/dispatcher'
|
||||
import { Branch } from '../../models/branch'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { getAheadBehind } from '../../lib/git'
|
||||
import { ButtonGroup } from '../lib/button-group'
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
import { Row } from '../lib/row'
|
||||
|
||||
interface IMergeProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly repository: Repository
|
||||
readonly branches: ReadonlyArray<Branch>
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
interface IMergeState {
|
||||
|
@ -46,18 +49,27 @@ export class Merge extends React.Component<IMergeProps, IMergeState> {
|
|||
const disabled = !selectedBranch
|
||||
const countPlural = this.state.commitCount === 1 ? 'commit' : 'commits'
|
||||
return (
|
||||
<Form onSubmit={this.merge}>
|
||||
<Select label='From' onChange={this.onBranchChange} value={selectedValue || undefined}>
|
||||
{this.props.branches.map(b => <option key={b.name} value={b.name}>{b.name}</option>)}
|
||||
</Select>
|
||||
<Dialog
|
||||
title={__DARWIN__ ? 'Merge Branch' : 'Merge branch'}
|
||||
onDismissed={this.props.onDismissed}
|
||||
onSubmit={this.merge}
|
||||
>
|
||||
<DialogContent>
|
||||
<Row>
|
||||
<Select label='From' onChange={this.onBranchChange} value={selectedValue || undefined}>
|
||||
{this.props.branches.map(b => <option key={b.name} value={b.name}>{b.name}</option>)}
|
||||
</Select>
|
||||
</Row>
|
||||
|
||||
<div>This will bring in {this.state.commitCount} {countPlural}.</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<Button onClick={this.cancel}>Cancel</Button>
|
||||
<Button type='submit' disabled={disabled}>Merge</Button>
|
||||
</Form>
|
||||
<p>This will bring in {this.state.commitCount} {countPlural}.</p>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit' disabled={disabled}>Merge</Button>
|
||||
<Button onClick={this.cancel}>Cancel</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import * as React from 'react'
|
||||
|
||||
/**
|
||||
* A terrible, horrible, no good, very bad component for presenting modal
|
||||
* popups.
|
||||
*/
|
||||
export class Popuppy extends React.Component<any, any> {
|
||||
public render() {
|
||||
return (
|
||||
<div className='popup'>
|
||||
<div className='popup-content'>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import { Button } from '../lib/button'
|
|||
import { SignIn } from '../lib/sign-in'
|
||||
import { assertNever } from '../../lib/fatal-error'
|
||||
import { getDotComAPIEndpoint } from '../../lib/api'
|
||||
import { DialogContent } from '../dialog'
|
||||
|
||||
interface IAccountsProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
|
@ -20,13 +21,13 @@ enum SignInType {
|
|||
export class Accounts extends React.Component<IAccountsProps, void> {
|
||||
public render() {
|
||||
return (
|
||||
<div>
|
||||
<DialogContent>
|
||||
<h2>GitHub.com</h2>
|
||||
{this.props.dotComUser ? this.renderUser(this.props.dotComUser) : this.renderSignIn(SignInType.DotCom)}
|
||||
|
||||
<h2>Enterprise</h2>
|
||||
{this.props.enterpriseUser ? this.renderUser(this.props.enterpriseUser) : this.renderSignIn(SignInType.Enterprise)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,17 +1,45 @@
|
|||
import * as React from 'react'
|
||||
import { ConfigureGitUser } from '../lib/configure-git-user'
|
||||
import { User } from '../../models/user'
|
||||
import { TextBox } from '../lib/text-box'
|
||||
import { Row } from '../lib/row'
|
||||
import { DialogContent } from '../dialog'
|
||||
|
||||
interface IGitProps {
|
||||
readonly users: ReadonlyArray<User>
|
||||
readonly name: string
|
||||
readonly email: string
|
||||
|
||||
readonly onNameChanged: (name: string) => void
|
||||
readonly onEmailChanged: (email: string) => void
|
||||
}
|
||||
|
||||
export class Git extends React.Component<IGitProps, void> {
|
||||
|
||||
private onNameChanged = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
this.props.onNameChanged(e.currentTarget.value)
|
||||
}
|
||||
|
||||
private onEmailChanged = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
this.props.onEmailChanged(e.currentTarget.value)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div>
|
||||
<ConfigureGitUser users={this.props.users}/>
|
||||
</div>
|
||||
<DialogContent>
|
||||
<Row>
|
||||
<TextBox
|
||||
label='Name'
|
||||
value={this.props.name}
|
||||
onChange={this.onNameChanged}
|
||||
autoFocus
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<TextBox
|
||||
label='Email'
|
||||
value={this.props.email}
|
||||
onChange={this.onEmailChanged}
|
||||
/>
|
||||
</Row>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,11 +5,16 @@ import { TabBar } from '../tab-bar'
|
|||
import { Accounts } from './accounts'
|
||||
import { Git } from './git'
|
||||
import { assertNever } from '../../lib/fatal-error'
|
||||
import { Button } from '../lib/button'
|
||||
import { ButtonGroup } from '../lib/button-group'
|
||||
import { Dialog, DialogFooter } from '../dialog'
|
||||
import { getGlobalConfigValue, setGlobalConfigValue } from '../../lib/git/config'
|
||||
|
||||
interface IPreferencesProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly dotComUser: User | null
|
||||
readonly enterpriseUser: User | null
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
enum PreferencesTab {
|
||||
|
@ -19,6 +24,8 @@ enum PreferencesTab {
|
|||
|
||||
interface IPreferencesState {
|
||||
readonly selectedIndex: PreferencesTab
|
||||
readonly committerName: string,
|
||||
readonly committerEmail: string
|
||||
}
|
||||
|
||||
/** The app-level preferences component. */
|
||||
|
@ -26,44 +33,108 @@ export class Preferences extends React.Component<IPreferencesProps, IPreferences
|
|||
public constructor(props: IPreferencesProps) {
|
||||
super(props)
|
||||
|
||||
this.state = { selectedIndex: PreferencesTab.Accounts }
|
||||
this.state = {
|
||||
selectedIndex: PreferencesTab.Accounts,
|
||||
committerName: '',
|
||||
committerEmail: '',
|
||||
}
|
||||
}
|
||||
|
||||
public async componentWillMount() {
|
||||
let committerName = await getGlobalConfigValue('user.name')
|
||||
let committerEmail = await getGlobalConfigValue('user.email')
|
||||
|
||||
if (!committerName || !committerEmail) {
|
||||
const user = this.props.dotComUser || this.props.enterpriseUser
|
||||
|
||||
if (user) {
|
||||
|
||||
if (!committerName) {
|
||||
committerName = user.login
|
||||
}
|
||||
|
||||
if (!committerEmail && user.emails.length) {
|
||||
committerEmail = user.emails[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
committerName = committerName || ''
|
||||
committerEmail = committerEmail || ''
|
||||
|
||||
this.setState({ committerName, committerEmail })
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div id='preferences'>
|
||||
<Dialog
|
||||
id='preferences'
|
||||
title={__DARWIN__ ? 'Preferences' : 'Options'}
|
||||
onDismissed={this.props.onDismissed}
|
||||
onSubmit={this.onSave}
|
||||
>
|
||||
<TabBar onTabClicked={this.onTabClicked} selectedIndex={this.state.selectedIndex}>
|
||||
<span>Accounts</span>
|
||||
<span>Git</span>
|
||||
</TabBar>
|
||||
|
||||
{this.renderActiveTab()}
|
||||
</div>
|
||||
{this.renderFooter()}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
private renderActiveTab() {
|
||||
const index = this.state.selectedIndex
|
||||
switch (index) {
|
||||
case PreferencesTab.Accounts: return <Accounts {...this.props}/>
|
||||
case PreferencesTab.Accounts:
|
||||
return <Accounts {...this.props}/>
|
||||
case PreferencesTab.Git: {
|
||||
const users: User[] = []
|
||||
const dotComUser = this.props.dotComUser
|
||||
if (dotComUser) {
|
||||
users.push(dotComUser)
|
||||
}
|
||||
|
||||
const enterpriseUser = this.props.enterpriseUser
|
||||
if (enterpriseUser) {
|
||||
users.push(enterpriseUser)
|
||||
}
|
||||
|
||||
return <Git users={users}/>
|
||||
return <Git
|
||||
name={this.state.committerName}
|
||||
email={this.state.committerEmail}
|
||||
onNameChanged={this.onCommitterNameChanged}
|
||||
onEmailChanged={this.onCommitterEmailChanged}
|
||||
/>
|
||||
}
|
||||
default: return assertNever(index, `Unknown tab index: ${index}`)
|
||||
}
|
||||
}
|
||||
|
||||
private onCommitterNameChanged = (committerName: string) => {
|
||||
this.setState({ committerName })
|
||||
}
|
||||
|
||||
|
||||
private onCommitterEmailChanged = (committerEmail: string) => {
|
||||
this.setState({ committerEmail })
|
||||
}
|
||||
|
||||
private renderFooter() {
|
||||
const index = this.state.selectedIndex
|
||||
switch (index) {
|
||||
case PreferencesTab.Accounts: return null
|
||||
case PreferencesTab.Git: {
|
||||
return (
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit'>Save</Button>
|
||||
<Button onClick={this.props.onDismissed}>Cancel</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
)
|
||||
}
|
||||
default: return assertNever(index, `Unknown tab index: ${index}`)
|
||||
}
|
||||
}
|
||||
|
||||
private onSave = async () => {
|
||||
await setGlobalConfigValue('user.name', this.state.committerName)
|
||||
await setGlobalConfigValue('user.email', this.state.committerEmail)
|
||||
|
||||
this.props.onDismissed()
|
||||
}
|
||||
|
||||
private onTabClicked = (index: number) => {
|
||||
this.setState({ selectedIndex: index })
|
||||
}
|
||||
|
|
|
@ -4,9 +4,10 @@ import { Dispatcher } from '../../lib/dispatcher'
|
|||
import { Repository } from '../../models/repository'
|
||||
import { Branch } from '../../models/branch'
|
||||
import { sanitizedBranchName } from '../create-branch/sanitized-branch-name'
|
||||
import { Form } from '../lib/form'
|
||||
import { TextBox } from '../lib/text-box'
|
||||
import { Button } from '../lib/button'
|
||||
import { ButtonGroup } from '../lib/button-group'
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
|
||||
interface IRenameBranchProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
|
@ -37,19 +38,30 @@ export class RenameBranch extends React.Component<IRenameBranchProps, IRenameBra
|
|||
public render() {
|
||||
const disabled = !this.state.newName.length
|
||||
return (
|
||||
<Form onSubmit={this.renameBranch}>
|
||||
<TextBox
|
||||
label='Name'
|
||||
autoFocus={true}
|
||||
value={this.state.newName}
|
||||
onChange={this.onNameChange}
|
||||
onKeyDown={this.onKeyDown}/>
|
||||
<Dialog
|
||||
id='rename-branch'
|
||||
title={ __DARWIN__ ? 'Rename Branch' : 'Rename branch'}
|
||||
onDismissed={this.cancel}
|
||||
onSubmit={this.renameBranch}
|
||||
>
|
||||
<DialogContent>
|
||||
<TextBox
|
||||
label='Name'
|
||||
autoFocus={true}
|
||||
value={this.state.newName}
|
||||
onChange={this.onNameChange}
|
||||
onKeyDown={this.onKeyDown}/>
|
||||
|
||||
{this.renderError()}
|
||||
{this.renderError()}
|
||||
</DialogContent>
|
||||
|
||||
<Button onClick={this.cancel}>Cancel</Button>
|
||||
<Button type='submit' disabled={disabled}>Rename {this.props.branch.name}</Button>
|
||||
</Form>
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit' disabled={disabled}>Rename {this.props.branch.name}</Button>
|
||||
<Button onClick={this.cancel}>Cancel</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,67 +1,38 @@
|
|||
import * as React from 'react'
|
||||
import { Dispatcher } from '../../lib/dispatcher'
|
||||
import { Repository } from '../../models/repository'
|
||||
|
||||
import { Form } from '../lib/form'
|
||||
import { DialogContent } from '../dialog'
|
||||
import { TextArea } from '../lib/text-area'
|
||||
import { Button } from '../lib/button'
|
||||
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
|
||||
interface IGitIgnoreProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly repository: Repository
|
||||
readonly text: string | null
|
||||
}
|
||||
|
||||
interface IGitIgnoreState {
|
||||
readonly text: string
|
||||
readonly onIgnoreTextChanged: (text: string) => void
|
||||
readonly onShowExamples: () => void
|
||||
}
|
||||
|
||||
/** A view for creating or modifying the repository's gitignore file */
|
||||
export class GitIgnore extends React.Component<IGitIgnoreProps, IGitIgnoreState> {
|
||||
|
||||
public constructor(props: IGitIgnoreProps) {
|
||||
super(props)
|
||||
|
||||
const text = this.props.text || ''
|
||||
this.state = { text }
|
||||
}
|
||||
export class GitIgnore extends React.Component<IGitIgnoreProps, void> {
|
||||
|
||||
public render() {
|
||||
|
||||
const existing = this.props.text
|
||||
const current = this.state.text
|
||||
// disable the submit button if the gitignore text isn't changed
|
||||
const disabled = existing !== null && current === existing
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<div>Ignored files (.gitignore)</div>
|
||||
<DialogContent>
|
||||
<p>
|
||||
The .gitignore file controls which files are tracked by Git and which
|
||||
are ignored. Check out <LinkButton onClick={this.props.onShowExamples}>git-scm.com</LinkButton> for
|
||||
more information about the file format, or simply ignore a file by
|
||||
right clicking on it in the uncommitted changes view.
|
||||
</p>
|
||||
<TextArea
|
||||
placeholder='Ignored files'
|
||||
value={this.state.text}
|
||||
value={this.props.text || ''}
|
||||
onChange={this.onChange}
|
||||
rows={6} />
|
||||
|
||||
<hr/>
|
||||
|
||||
<Button type='submit' onClick={this.save} disabled={disabled}>Save</Button>
|
||||
<Button onClick={this.close}>Cancel</Button>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
private close = () => {
|
||||
this.props.dispatcher.closePopup()
|
||||
}
|
||||
|
||||
private onChange = (event: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const text = event.currentTarget.value
|
||||
this.setState({ text })
|
||||
}
|
||||
|
||||
private save = () => {
|
||||
this.props.dispatcher.setGitIgnoreText(this.props.repository, this.state.text)
|
||||
this.close()
|
||||
this.props.onIgnoreTextChanged(text)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import * as React from 'react'
|
||||
import { DialogContent } from '../dialog'
|
||||
|
||||
export class GitLFS extends React.Component<void, void> {
|
||||
public render() {
|
||||
return <div>LFS</div>
|
||||
return <DialogContent>LFS</DialogContent>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,29 +1,14 @@
|
|||
import * as React from 'react'
|
||||
import { Dispatcher } from '../../lib/dispatcher'
|
||||
import { IRemote } from '../../models/remote'
|
||||
import { Form } from '../lib/form'
|
||||
import { TextBox } from '../lib/text-box'
|
||||
import { Button } from '../lib/button'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { DialogContent } from '../dialog'
|
||||
|
||||
interface IRemoteProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly remote: IRemote | null
|
||||
readonly repository: Repository
|
||||
readonly onRemoteUrlChanged: (url: string) => void
|
||||
}
|
||||
|
||||
interface IRemoteState {
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
export class Remote extends React.Component<IRemoteProps, IRemoteState> {
|
||||
public constructor(props: IRemoteProps) {
|
||||
super(props)
|
||||
|
||||
const url = props.remote ? props.remote.url : ''
|
||||
this.state = { url }
|
||||
}
|
||||
|
||||
export class Remote extends React.Component<IRemoteProps, void> {
|
||||
public render() {
|
||||
const remote = this.props.remote
|
||||
if (!remote) {
|
||||
|
@ -31,32 +16,15 @@ export class Remote extends React.Component<IRemoteProps, IRemoteState> {
|
|||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={this.save}>
|
||||
<DialogContent>
|
||||
<div>Primary remote repository ({remote.name})</div>
|
||||
<TextBox placeholder='Remote URL' value={this.state.url} onChange={this.onChange}/>
|
||||
|
||||
<hr/>
|
||||
|
||||
<Button type='submit'>Save</Button>
|
||||
<Button onClick={this.close}>Cancel</Button>
|
||||
</Form>
|
||||
<TextBox placeholder='Remote URL' value={remote.url} onChange={this.onChange}/>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
private close = () => {
|
||||
this.props.dispatcher.closePopup()
|
||||
}
|
||||
|
||||
private onChange = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
const url = event.currentTarget.value
|
||||
this.setState({ url })
|
||||
}
|
||||
|
||||
private save = () => {
|
||||
const remote = this.props.remote
|
||||
if (!remote) { return }
|
||||
|
||||
this.props.dispatcher.setRemoteURL(this.props.repository, remote.name, this.state.url)
|
||||
this.close()
|
||||
this.props.onRemoteUrlChanged(url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,12 +7,15 @@ import { assertNever } from '../../lib/fatal-error'
|
|||
import { IRemote } from '../../models/remote'
|
||||
import { Dispatcher } from '../../lib/dispatcher'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { Button } from '../lib/button'
|
||||
import { ButtonGroup } from '../lib/button-group'
|
||||
import { Dialog, DialogError, DialogFooter } from '../dialog'
|
||||
|
||||
interface IRepositorySettingsProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
readonly repository: Repository
|
||||
readonly remote: IRemote | null
|
||||
readonly gitIgnoreText: string | null
|
||||
readonly repository: Repository
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
|
||||
enum RepositorySettingsTab {
|
||||
|
@ -23,18 +26,59 @@ enum RepositorySettingsTab {
|
|||
|
||||
interface IRepositorySettingsState {
|
||||
readonly selectedTab: RepositorySettingsTab
|
||||
readonly remote: IRemote | null
|
||||
readonly ignoreText: string | null
|
||||
readonly ignoreTextHasChanged: boolean
|
||||
readonly disabled: boolean
|
||||
readonly errors?: ReadonlyArray<JSX.Element | string>
|
||||
}
|
||||
|
||||
export class RepositorySettings extends React.Component<IRepositorySettingsProps, IRepositorySettingsState> {
|
||||
public constructor(props: IRepositorySettingsProps) {
|
||||
super(props)
|
||||
|
||||
this.state = { selectedTab: RepositorySettingsTab.Remote }
|
||||
this.state = {
|
||||
selectedTab: RepositorySettingsTab.Remote,
|
||||
remote: props.remote,
|
||||
ignoreText: null,
|
||||
ignoreTextHasChanged: false,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
public async componentWillMount() {
|
||||
try {
|
||||
const ignoreText = await this.props.dispatcher.readGitIgnore(this.props.repository)
|
||||
this.setState({ ignoreText })
|
||||
} catch (e) {
|
||||
this.setState({ errors: [ `Could not read .gitignore: ${e}` ] })
|
||||
}
|
||||
}
|
||||
|
||||
private renderErrors(): JSX.Element[] | null {
|
||||
const errors = this.state.errors
|
||||
|
||||
if (!errors || !errors.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return errors.map((err, ix) => {
|
||||
const key = `err-${ix}`
|
||||
return <DialogError key={key}>{err}</DialogError>
|
||||
})
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div id='preferences'>
|
||||
<Dialog
|
||||
id='repository-settings'
|
||||
title={__DARWIN__ ? 'Repository Settings' : 'Repository settings'}
|
||||
onDismissed={this.props.onDismissed}
|
||||
onSubmit={this.onSubmit}
|
||||
disabled={this.state.disabled}
|
||||
>
|
||||
{this.renderErrors()}
|
||||
|
||||
<TabBar onTabClicked={this.onTabClicked} selectedIndex={this.state.selectedTab}>
|
||||
<span>Remote</span>
|
||||
<span>Ignored Files</span>
|
||||
|
@ -42,7 +86,13 @@ export class RepositorySettings extends React.Component<IRepositorySettingsProps
|
|||
</TabBar>
|
||||
|
||||
{this.renderActiveTab()}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit'>Save</Button>
|
||||
<Button onClick={this.props.onDismissed}>Cancel</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -50,17 +100,18 @@ export class RepositorySettings extends React.Component<IRepositorySettingsProps
|
|||
const tab = this.state.selectedTab
|
||||
switch (tab) {
|
||||
case RepositorySettingsTab.Remote: {
|
||||
return <Remote
|
||||
remote={this.props.remote}
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={this.props.repository}
|
||||
/>
|
||||
return (
|
||||
<Remote
|
||||
remote={this.state.remote}
|
||||
onRemoteUrlChanged={this.onRemoteUrlChanged}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case RepositorySettingsTab.IgnoredFiles: {
|
||||
return <GitIgnore
|
||||
dispatcher={this.props.dispatcher}
|
||||
repository={this.props.repository}
|
||||
text={this.props.gitIgnoreText}
|
||||
text={this.state.ignoreText}
|
||||
onIgnoreTextChanged={this.onIgnoreTextChanged}
|
||||
onShowExamples={this.onShowGitIgnoreExamples}
|
||||
/>
|
||||
}
|
||||
case RepositorySettingsTab.GitLFS: {
|
||||
|
@ -71,6 +122,60 @@ export class RepositorySettings extends React.Component<IRepositorySettingsProps
|
|||
return assertNever(tab, `Unknown tab type: ${tab}`)
|
||||
}
|
||||
|
||||
private onShowGitIgnoreExamples = () => {
|
||||
this.props.dispatcher.openInBrowser('https://git-scm.com/docs/gitignore')
|
||||
}
|
||||
|
||||
private onSubmit = async () => {
|
||||
|
||||
this.setState({ disabled: true, errors: undefined })
|
||||
const errors = new Array<JSX.Element | string>()
|
||||
|
||||
if (this.state.remote && this.props.remote) {
|
||||
if (this.state.remote.url !== this.props.remote.url) {
|
||||
try {
|
||||
await this.props.dispatcher.setRemoteURL(
|
||||
this.props.repository,
|
||||
this.props.remote.name,
|
||||
this.state.remote.url
|
||||
)
|
||||
} catch (e) {
|
||||
errors.push(`Failed saving the remote URL: ${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.ignoreTextHasChanged && this.state.ignoreText !== null) {
|
||||
try {
|
||||
await this.props.dispatcher.saveGitIgnore(this.props.repository, this.state.ignoreText || '')
|
||||
} catch (e) {
|
||||
errors.push(`Failed saving the .gitignore file: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.length) {
|
||||
this.props.onDismissed()
|
||||
} else {
|
||||
this.setState({ disabled: false, errors })
|
||||
}
|
||||
}
|
||||
|
||||
private onRemoteUrlChanged = (url: string) => {
|
||||
|
||||
const remote = this.props.remote
|
||||
|
||||
if (!remote) {
|
||||
return
|
||||
}
|
||||
|
||||
const newRemote = { ...remote, url }
|
||||
this.setState({ remote: newRemote })
|
||||
}
|
||||
|
||||
private onIgnoreTextChanged = (text: string) => {
|
||||
this.setState({ ignoreText: text, ignoreTextHasChanged: true })
|
||||
}
|
||||
|
||||
private onTabClicked = (index: number) => {
|
||||
this.setState({ selectedTab: index })
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import * as React from 'react'
|
|||
import { Button } from '../lib/button'
|
||||
import { Dispatcher } from '../../lib/dispatcher'
|
||||
import { updateStore } from '../lib/update-store'
|
||||
import { ButtonGroup } from '../lib/button-group'
|
||||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
|
||||
interface IUpdateAvailableProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
|
@ -14,12 +16,23 @@ interface IUpdateAvailableProps {
|
|||
export class UpdateAvailable extends React.Component<IUpdateAvailableProps, void> {
|
||||
public render() {
|
||||
return (
|
||||
<div id='update-available'>
|
||||
GitHub Desktop will be updated after it restarts!
|
||||
<Dialog
|
||||
id='update-available'
|
||||
title={__DARWIN__ ? 'Update Available' : 'Update available'}
|
||||
onSubmit={this.updateNow}
|
||||
onDismissed={this.dismiss}
|
||||
>
|
||||
<DialogContent>
|
||||
GitHub Desktop will be updated after it restarts!
|
||||
</DialogContent>
|
||||
|
||||
<Button onClick={this.updateNow}>Update Now</Button>
|
||||
<Button onClick={this.dismiss}>I prefer living in the past</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit'>{__DARWIN__ ? 'Update Now' : 'Update now'}</Button>
|
||||
<Button onClick={this.dismiss}>Cancel</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,6 @@
|
|||
@import "ui/autocompletion";
|
||||
@import "ui/welcome";
|
||||
@import "ui/foldout";
|
||||
@import "ui/update-available";
|
||||
@import "ui/preferences";
|
||||
@import "ui/path-text";
|
||||
@import "ui/configure-git-user";
|
||||
|
@ -38,6 +37,7 @@
|
|||
@import "ui/errors";
|
||||
@import "ui/publish-repository";
|
||||
@import "ui/clone-repository";
|
||||
@import "ui/dialog";
|
||||
@import "ui/add-repository";
|
||||
@import "ui/expand-foldout-button";
|
||||
@import "ui/discard-changes";
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
// used outside of this scope.
|
||||
$blue: #3d76c2;
|
||||
$yellow: #d0b44c;
|
||||
$lightYellow: #f6a623;
|
||||
$red: #bf2b00;
|
||||
$lightRed: #f04747;
|
||||
$green: #6cc644;
|
||||
$darkGray: #5f717f;
|
||||
$darkerGray: #3b3f46;
|
||||
|
@ -160,8 +162,8 @@
|
|||
--border-radius: 3px;
|
||||
--base-border: 1px solid var(--box-border-color);
|
||||
|
||||
--shadow-color: rgba(0,0,0,0.07);
|
||||
--base-box-shadow: 0 1px 2px var(--shadow-color);
|
||||
--shadow-color: rgba(71,83,95,0.19);
|
||||
--base-box-shadow: 0 2px 7px var(--shadow-color);
|
||||
|
||||
--toolbar-height: 52px;
|
||||
|
||||
|
@ -250,4 +252,14 @@
|
|||
--error-color: $red;
|
||||
--form-error-background: #fddede;
|
||||
--form-error-border-color: #d3b2b2;
|
||||
|
||||
/** Dialog */
|
||||
--dialog-warning-color: $lightYellow;
|
||||
--dialog-error-color: $lightRed;
|
||||
|
||||
// http://easings.net/#easeOutBack
|
||||
--easing-ease-out-back: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
// http://easings.net/#easeInBack
|
||||
--easing-ease-in-back: cubic-bezier(0.6, -0.28, 0.735, 0.045);
|
||||
}
|
||||
|
|
241
app/styles/ui/_dialog.scss
Normal file
241
app/styles/ui/_dialog.scss
Normal file
|
@ -0,0 +1,241 @@
|
|||
@import "../mixins";
|
||||
|
||||
// The styles herein attempt to follow a flow where margins are only applied
|
||||
// to the bottom of elements (with the exception of the last child). This to
|
||||
// allow easy layout using generalized components and elements such as <Row>
|
||||
// and <p>.
|
||||
dialog {
|
||||
border: var(--base-border);
|
||||
box-shadow: var(--base-box-shadow);
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
color: var(--text-color);
|
||||
background: var(--background-color);
|
||||
|
||||
// This resets the properties we animate in the transition group
|
||||
// such that if there's ever a race condition between the JS timing
|
||||
// and the CSS transition we'll always revert back to a known good
|
||||
// state.
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
|
||||
// The modal class here is the transition name for the react css transition
|
||||
// group which allows us to apply an animation when the popup appears.
|
||||
&.modal {
|
||||
|
||||
&-enter {
|
||||
opacity: 1;
|
||||
transform: scale(0.75);
|
||||
}
|
||||
|
||||
|
||||
|
||||
&-enter-active {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition: transform 250ms var(--easing-ease-out-back);
|
||||
}
|
||||
|
||||
&-leave {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
|
||||
&::backdrop {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&-leave-active {
|
||||
opacity: 0.01;
|
||||
transform: scale(0.25);
|
||||
transition: opacity 100ms ease-in,
|
||||
transform 100ms var(--easing-ease-in-back);
|
||||
|
||||
&::backdrop {
|
||||
opacity: 0.01;
|
||||
transition: opacity 100ms ease-in;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
background: #000;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
// The dialog embeds a fieldset as the first child of the form element
|
||||
// in order to be able to disable all form elements and buttons in one
|
||||
// swoop. This resets all styles for that fieldset.
|
||||
& > form > fieldset {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
height: 50px;
|
||||
border-bottom: var(--base-border);
|
||||
position: relative;
|
||||
|
||||
// There's at most three elements in the header,
|
||||
// 1. The icon (optional, only for errors, warnings, etc)
|
||||
// 2. The title, a h1 element which is always present if the
|
||||
// header element is visible
|
||||
// 3. The close button (optional, hidden when dialog isn't dismissable).
|
||||
//
|
||||
// In order to correctly center the title in all scenarios we lay out the
|
||||
// children in a flexbox but we position the icon and close button
|
||||
// absolutely to the left and right side leaving the h1 all available
|
||||
// width. We then add a 40px margin on each side of the h1 ensuring that
|
||||
// even in the scenario where only one of the two optional elements is
|
||||
// visible the h1 stays centered horizontally.
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
svg.icon {
|
||||
position: absolute;
|
||||
left: var(--spacing);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: var(--font-weight-light);
|
||||
font-size: var(--font-size-md);
|
||||
|
||||
// See comment above. 40px is enough space to clear both the icon
|
||||
// and the close button with their respective margins.
|
||||
margin: 0 40px;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
flex-grow: 1;
|
||||
|
||||
@include ellipsis;
|
||||
}
|
||||
|
||||
button.close {
|
||||
position: absolute;
|
||||
right: var(--spacing);
|
||||
top: var(--spacing);
|
||||
|
||||
border: 0;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
|
||||
color: var(--text-secondary-color);
|
||||
cursor: pointer;
|
||||
|
||||
// Let the button deal with all mouse events.
|
||||
// Without this the octicon resets the cursor when
|
||||
// hovering over the <path>.
|
||||
.octicon { pointer-events: none; }
|
||||
|
||||
&:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
margin: var(--spacing-double);
|
||||
|
||||
// This allows for using <Rows> to structure content within dialog content.
|
||||
// All Rows that are direct descendants of dialog content except for the
|
||||
// last one receive a bottom margin.
|
||||
.row-component:not(:last-child) {
|
||||
margin-bottom: var(--spacing);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--spacing);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
|
||||
border-top: var(--base-border);
|
||||
padding: var(--spacing-double);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
margin-right: var(--spacing-half);
|
||||
min-width: 120px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom branding of a warning dialog. Used for dangerous actions
|
||||
&.warning {
|
||||
.dialog-header {
|
||||
color: var(--dialog-warning-color);
|
||||
}
|
||||
|
||||
border-top: 3px solid var(--dialog-warning-color);
|
||||
}
|
||||
|
||||
// Custom branding of an error dialog. Used for displaying errors
|
||||
&.error {
|
||||
.dialog-header {
|
||||
color: var(--dialog-error-color);
|
||||
}
|
||||
|
||||
border-top: 3px solid var(--dialog-error-color);
|
||||
}
|
||||
|
||||
// Inline error component rendered at the top of the dialog just below
|
||||
// the header (if the dialog has one).
|
||||
.dialog-error {
|
||||
display: flex;
|
||||
padding: var(--spacing);
|
||||
align-items: center;
|
||||
|
||||
background: var(--form-error-background);
|
||||
border-bottom: 1px solid var(--form-error-border-color);
|
||||
color: var(--error-color);
|
||||
|
||||
> .octicon {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--spacing);
|
||||
}
|
||||
}
|
||||
|
||||
// Repository settings has long phrasing content in the gitignore
|
||||
// tab so we'll constrain it to 400px.
|
||||
&#repository-settings { width: 400px; }
|
||||
|
||||
&#app-error {
|
||||
.dialog-content {
|
||||
font-family: var(--font-family-monospace);
|
||||
-webkit-user-select: auto;
|
||||
user-select: auto;
|
||||
cursor: text;
|
||||
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,12 @@
|
|||
.discard-changes {
|
||||
#discard-changes {
|
||||
.file-name {
|
||||
font-family: var(--font-family-monospace);
|
||||
}
|
||||
|
||||
.dialog-content ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: var(--spacing);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
#preferences {
|
||||
min-width: 320px;
|
||||
min-height: 325px;
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
#update-available {
|
||||
.button-component {
|
||||
margin: var(--spacing-half);
|
||||
}
|
||||
}
|
115
docs/dialogs.md
Normal file
115
docs/dialogs.md
Normal file
|
@ -0,0 +1,115 @@
|
|||
# Dialog
|
||||
|
||||
Dialogs are the high-level component used to render popups such as Preferences,
|
||||
and repository setting as well as error messages. They're built upon the new
|
||||
`dialog` html element and are shown as modals which means that tab navigation
|
||||
are constrained to within the dialog itself.
|
||||
|
||||
## General structure
|
||||
|
||||
```html
|
||||
<Dialog title='Title'>
|
||||
<TabBar>...</TabBar>
|
||||
<DialogContent>
|
||||
...
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit'>Ok</Button>
|
||||
<Button>Cancel</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
Dialogs should, when practical, render errors caused by its actions inline as
|
||||
opposed to opening an error dialog. An example of this is the Preferences dialog.
|
||||
If the dialog fails to write to the .gitignore or git config files as part of
|
||||
persisting changes it renders a short error message inline in the dialog using
|
||||
the `DialogError` component.
|
||||
|
||||
The `DialogError` component, if used, must be the first child element of the
|
||||
Dialog itself.
|
||||
|
||||
```html
|
||||
<Dialog title='Preferences'>
|
||||
<DialogError>Could not save ignore file. EPERM Something something</DialogError>
|
||||
<TabBar>...</TabBar>
|
||||
<DialogContent>
|
||||
...
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit'>Ok</Button>
|
||||
<Button>Cancel</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
The content inside of the DialogError should be primarily text based. Avoid using
|
||||
the term 'Error' inside the text as that should be evident already based on the
|
||||
styling of the `DialogError` component.
|
||||
|
||||
## Best practices
|
||||
|
||||
### DO: Let children render the DialogContent component
|
||||
|
||||
If you're using a one-child-per-tab approach you should render the DialogContent
|
||||
as the top-level element in those children instead of wrapping children inside the
|
||||
DialogContent element. This avoid needless nesting and lets us leverage generic
|
||||
dialog/form/row styles in a more straightforward way.
|
||||
|
||||
#### Example (good)
|
||||
|
||||
|
||||
```html
|
||||
<!-- SomeComponent.tsx -->
|
||||
<Dialog title='Title'>
|
||||
<TabBar>...</TabBar>
|
||||
{this.renderActiveTab()}
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit'>Ok</Button>
|
||||
<Button>Cancel</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
<!-- ChildComponent.tsx -->
|
||||
<DialogContent>
|
||||
my fancy content
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
#### Example (bad)
|
||||
|
||||
|
||||
```html
|
||||
<!-- SomeComponent.tsx -->
|
||||
<Dialog title='Title'>
|
||||
<TabBar>...</TabBar>
|
||||
<DialogContent>
|
||||
{this.renderActiveTab()}
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<ButtonGroup>
|
||||
<Button type='submit'>Ok</Button>
|
||||
<Button>Cancel</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
<!-- ChildComponent.tsx -->
|
||||
<div>
|
||||
my fancy content
|
||||
</div>
|
||||
```
|
||||
|
||||
### DO: Use Row components to lay out content
|
||||
|
||||
The `Row` component receives a bottom margin, when used as an immediate
|
||||
child of `DialogContent`, making it an excellent tool for structuring content.
|
||||
|
||||
If the content is primary text, as opposed to form component the `<p>` element
|
||||
should be used instead of the `Row` component.
|
122
tslint-rules/buttonGroupOrderRule.ts
Normal file
122
tslint-rules/buttonGroupOrderRule.ts
Normal file
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* button-group-order
|
||||
*
|
||||
* This custom tslint rule is highly specific to GitHub Desktop and attempts
|
||||
* to enforce a consistent order for buttons inside of a <ButtonGroup>
|
||||
* component.
|
||||
*
|
||||
* Example
|
||||
*
|
||||
* <ButtonGroup>
|
||||
* <Button>Cancel</Button>
|
||||
* <Button type='submit'>Ok</Button>
|
||||
* </ButtonGroup>
|
||||
*
|
||||
* The example above will trigger a tslint error since we want to enforce
|
||||
* a consistent order of Ok/Cancel-style buttons (the button captions vary)
|
||||
* such that the primary action precedes any secondary actions.
|
||||
*
|
||||
* See https://www.nngroup.com/articles/ok-cancel-or-cancel-ok/
|
||||
*
|
||||
* We've opted for using the Windows order of OK, Cancel in our codebase, the
|
||||
* actual order at runtime will vary depending on platform.
|
||||
*
|
||||
*/
|
||||
|
||||
import * as ts from 'typescript'
|
||||
import * as Lint from 'tslint/lib/lint'
|
||||
|
||||
export class Rule extends Lint.Rules.AbstractRule {
|
||||
public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
|
||||
if (sourceFile.languageVariant === ts.LanguageVariant.JSX) {
|
||||
return this.applyWithWalker(new ButtonGroupOrderWalker(sourceFile, this.getOptions()))
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ButtonGroupOrderWalker extends Lint.RuleWalker {
|
||||
|
||||
/**
|
||||
* Visit the node and ensure any button children are in the correct order.
|
||||
*/
|
||||
protected visitJsxElement(node: ts.JsxElement): void {
|
||||
super.visitJsxElement(node)
|
||||
|
||||
if (node.openingElement.tagName.getText() !== 'ButtonGroup') {
|
||||
return
|
||||
}
|
||||
|
||||
const buttons = new Array<ts.JsxOpeningLikeElement>()
|
||||
|
||||
// Assert that only <Button> elements and whitespace are allowed inside
|
||||
// the ButtonGroup.
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const child = node.children[i]
|
||||
|
||||
if (child.kind === ts.SyntaxKind.JsxText) {
|
||||
// Whitespace is okay.
|
||||
if (/^\s*$/.test(child.getText())) {
|
||||
continue
|
||||
}
|
||||
} else if (child.kind === ts.SyntaxKind.JsxElement) {
|
||||
if (child.openingElement.tagName.getText() === 'Button') {
|
||||
buttons.push(child.openingElement)
|
||||
continue
|
||||
}
|
||||
} else if (child.kind === ts.SyntaxKind.JsxSelfClosingElement) {
|
||||
if (child.tagName.getText() === 'Button') {
|
||||
buttons.push(child)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const start = child.getStart()
|
||||
const width = child.getWidth()
|
||||
const error = `Forbidden child content, expected <Button>.`
|
||||
const explanation = 'ButtonGroups should only contain <Button> elements'
|
||||
|
||||
const message = `${error} ${explanation}`
|
||||
|
||||
this.addFailure(this.createFailure(start, width, message))
|
||||
}
|
||||
|
||||
// If we've emitted any errors we'll bail here rather than try to emit
|
||||
// any errors with button order.
|
||||
if (this.getFailures().length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (buttons.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
const buttonsWithTypeAttr = buttons.map(b => {
|
||||
const typeAttr = b.attributes.find(a =>
|
||||
a.kind === ts.SyntaxKind.JsxAttribute && a.name.getText() === 'type'
|
||||
) as ts.JsxAttribute | undefined
|
||||
|
||||
let value = undefined
|
||||
|
||||
if (typeAttr && typeAttr.initializer && typeAttr.initializer.kind === ts.SyntaxKind.StringLiteral) {
|
||||
value = typeAttr.initializer.text
|
||||
}
|
||||
|
||||
return [ b, value ]
|
||||
})
|
||||
|
||||
const primaryButtonIx = buttonsWithTypeAttr.findIndex(x => x[1] === 'submit')
|
||||
|
||||
if (primaryButtonIx !== -1 && primaryButtonIx !== 0) {
|
||||
const start = node.getStart()
|
||||
const width = node.getWidth()
|
||||
const error = `Wrong button order in ButtonGroup.`
|
||||
const explanation = 'ButtonGroups should have the primary button as its first child'
|
||||
|
||||
const message = `${error} ${explanation}`
|
||||
|
||||
this.addFailure(this.createFailure(start, width, message))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
"tslint-rules/"
|
||||
],
|
||||
"rules": {
|
||||
"button-group-order": true,
|
||||
"class-name": true,
|
||||
"curly": true,
|
||||
"indent": [
|
||||
|
|
Loading…
Reference in a new issue