Merge pull request #860 from desktop/popups-for-realz

The People's Dialog
This commit is contained in:
Josh Abernathy 2017-02-10 10:01:37 -05:00 committed by GitHub
commit 2198b6a812
36 changed files with 1594 additions and 340 deletions

View file

@ -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 {

View file

@ -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()

View file

@ -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)

View file

@ -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
View 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>
)
}
}

View file

@ -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>
)
}

View file

@ -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()

View 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>
)
}
}

View 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>
)
}
}

View 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>
)
}
}

View 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>
)
}
}

View 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>
)
}
}

View file

@ -0,0 +1,4 @@
export * from './content'
export * from './dialog'
export * from './error'
export * from './footer'

View file

@ -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>&nbsp;</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()

View 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>
)
}
}

View file

@ -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

View file

@ -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>
)
}

View file

@ -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>
)
}
}

View file

@ -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>
)
}

View file

@ -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>
)
}
}

View file

@ -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 })
}

View file

@ -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>
)
}

View file

@ -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)
}
}

View file

@ -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>
}
}

View file

@ -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)
}
}

View file

@ -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 })
}

View file

@ -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>
)
}

View file

@ -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";

View file

@ -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
View 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;
}
}
}

View file

@ -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);
}
}

View file

@ -1,7 +1,4 @@
#preferences {
min-width: 320px;
min-height: 325px;
.avatar {
width: 32px;
height: 32px;

View file

@ -1,5 +0,0 @@
#update-available {
.button-component {
margin: var(--spacing-half);
}
}

115
docs/dialogs.md Normal file
View 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.

View 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))
}
}
}

View file

@ -5,6 +5,7 @@
"tslint-rules/"
],
"rules": {
"button-group-order": true,
"class-name": true,
"curly": true,
"indent": [