Merge branch 'development' into releases/3.2.4

This commit is contained in:
Sergio Padrino 2023-06-13 16:43:41 +02:00
commit 5d8f7c7b72
96 changed files with 1610 additions and 887 deletions

View file

@ -186,6 +186,21 @@ rules:
- selector: ExportDefaultDeclaration
message: Use of default exports is forbidden
###########
# jsx-a11y #
###########
# autofocus is fine when it is being used to set focus to something in a way
# that doesn't skip any context or inputs. For example, in a named dialog, if you had
# a close button, a heading reflecting the name, and a labelled text input,
# focusing the text input wouldn't disadvantage a user because they're not
# missing anything of importance before it. The problem is when it is used on
# larger web pages, e.g. to focus a form field in the main content, entirely
# skipping the header and often much else besides.
jsx-a11y/no-autofocus:
- warn
- ignoreNonDOM: true
overrides:
- files: '*.d.ts'
rules:

View file

@ -37,7 +37,7 @@ jobs:
private_key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }}
- name: Create Release Pull Request
uses: peter-evans/create-pull-request@v5.0.0
uses: peter-evans/create-pull-request@v5.0.1
if: |
startsWith(github.ref, 'refs/heads/releases/') && !contains(github.ref, 'test')
with:

View file

@ -17,6 +17,7 @@
},
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@github/alive-client": "^0.0.2",
"app-path": "^3.3.0",
"byline": "^5.0.0",

View file

@ -27,15 +27,15 @@ interface IGitRemoteURL {
const remoteRegexes: ReadonlyArray<{ protocol: GitProtocol; regex: RegExp }> = [
{
protocol: 'https',
regex: new RegExp('^https?://(?:.+@)?(.+)/(.+)/(.+?)(?:/|.git/?)?$'),
regex: new RegExp('^https?://(?:.+@)?(.+)/([^/]+)/([^/]+?)(?:/|.git/?)?$'),
},
{
protocol: 'ssh',
regex: new RegExp('^git@(.+):(.+)/(.+?)(?:/|.git)?$'),
regex: new RegExp('^git@(.+):([^/]+)/([^/]+?)(?:/|.git)?$'),
},
{
protocol: 'ssh',
regex: new RegExp('^git:(.+)/(.+)/(.+?)(?:/|.git)?$'),
regex: new RegExp('^git:(.+)/([^/]+)/([^/]+?)(?:/|.git)?$'),
},
{
protocol: 'ssh',

View file

@ -1,11 +1,33 @@
import { debounce } from 'lodash'
import React, { Component } from 'react'
interface IAriaLiveContainerProps {
/**
* Whether or not the component should make an invisible change to the content
* in order to force the screen reader to read the content again.
* There is a common pattern that we may need to announce a message in
* response to user input. Unfortunately, aria-live announcements are
* interrupted by continued user input. We can force a rereading of a message
* by appending an invisible character when the user finishes their input.
*
* For example, we have a search filter for a list of branches and we need to
* announce how may results are found. Say a list of branches and the user
* types "ma", the message becomes "1 result", but if they continue to type
* "main" the message will have been interrupted.
*
* This prop allows us to pass in when the user input changes. This can either
* be directly passing in the user input on change or a boolean representing
* when we want the message re-read. We can append the invisible character to
* force the screen reader to read the message again after each input. To
* prevent the message from being read too much, we debounce the message.
*/
readonly shouldForceChange?: boolean
readonly trackedUserInput?: string | boolean
/** Optional id that can be used to associate the message to a control */
readonly id?: string
}
interface IAriaLiveContainerState {
/** The generated message for the screen reader */
readonly message: JSX.Element | null
}
/**
@ -18,26 +40,55 @@ interface IAriaLiveContainerProps {
* the screen reader to read the content again. This is useful when the content
* is the same but the screen reader should read it again.
*/
export class AriaLiveContainer extends Component<IAriaLiveContainerProps> {
private shouldForceChange: boolean = false
export class AriaLiveContainer extends Component<
IAriaLiveContainerProps,
IAriaLiveContainerState
> {
private suffix: string = ''
private onTrackedInputChanged = debounce((message: JSX.Element | null) => {
this.setState({ message })
}, 1000)
public constructor(props: IAriaLiveContainerProps) {
super(props)
this.state = {
message: null,
}
}
public componentDidUpdate(prevProps: IAriaLiveContainerProps) {
this.shouldForceChange = prevProps.shouldForceChange ?? false
if (prevProps.trackedUserInput === this.props.trackedUserInput) {
return
}
this.onTrackedInputChanged(this.buildMessage())
}
public componentWillUnmount() {
this.onTrackedInputChanged.cancel()
}
private buildMessage() {
this.suffix = this.suffix === '' ? '\u00A0' : ''
return (
<>
{this.props.children}
{this.suffix}
</>
)
}
public render() {
const shouldForceChange = this.shouldForceChange
this.shouldForceChange = false
if (shouldForceChange) {
this.suffix = this.suffix === '' ? '\u00A0' : ''
}
return (
<div className="sr-only" aria-live="polite" aria-atomic="true">
{this.props.children}
{this.suffix}
<div
id={this.props.id}
className="sr-only"
aria-live="polite"
aria-atomic="true"
>
{this.state.message}
</div>
)
}

View file

@ -1,4 +1,6 @@
import * as React from 'react'
import * as Path from 'path'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import {
IAppState,
@ -2424,6 +2426,7 @@ export class App extends React.Component<IAppProps, IAppState> {
showSideBySideDiff={showSideBySideDiff}
currentBranchHasPullRequest={currentBranchHasPullRequest}
onDismissed={onPopupDismissedFn}
onOpenInExternalEditor={this.onOpenInExternalEditor}
/>
)
}
@ -2761,6 +2764,16 @@ export class App extends React.Component<IAppProps, IAppState> {
this.props.dispatcher.openInExternalEditor(repository.path)
}
private onOpenInExternalEditor = (path: string) => {
const repository = this.state.selectedState?.repository
if (repository === undefined) {
return
}
const fullPath = Path.join(repository.path, path)
this.props.dispatcher.openInExternalEditor(fullPath)
}
private showRepository = (repository: Repository | CloningRepository) => {
if (!(repository instanceof Repository)) {
return
@ -2908,6 +2921,10 @@ export class App extends React.Component<IAppProps, IAppState> {
if (tip.kind === TipState.Valid && tip.branch.upstreamRemoteName !== null) {
remoteName = tip.branch.upstreamRemoteName
if (tip.branch.upstreamWithoutRemote !== tip.branch.name) {
remoteName = tip.branch.upstream
}
}
const currentFoldout = this.state.currentFoldout
@ -3188,7 +3205,7 @@ export class App extends React.Component<IAppProps, IAppState> {
accounts={state.accounts}
externalEditorLabel={externalEditorLabel}
resolvedExternalEditor={state.resolvedExternalEditor}
onOpenInExternalEditor={this.openFileInExternalEditor}
onOpenInExternalEditor={this.onOpenInExternalEditor}
appMenu={state.appMenuState[0]}
currentTutorialStep={state.currentOnboardingTutorialStep}
onExitTutorial={this.onExitTutorial}

View file

@ -8,16 +8,21 @@ import {
import { IAutocompletionProvider } from './index'
import { fatalError } from '../../lib/fatal-error'
import classNames from 'classnames'
import getCaretCoordinates from 'textarea-caret'
import { showContextualMenu } from '../../lib/menu-item'
import { AriaLiveContainer } from '../accessibility/aria-live-container'
import { createUniqueId, releaseUniqueId } from '../lib/id-pool'
import {
Popover,
PopoverAnchorPosition,
PopoverDecoration,
} from '../lib/popover'
interface IRange {
readonly start: number
readonly length: number
}
import getCaretCoordinates from 'textarea-caret'
import { showContextualMenu } from '../../lib/menu-item'
import { AriaLiveContainer } from '../accessibility/aria-live-container'
interface IAutocompletingTextInputProps<ElementType, AutocompleteItemType> {
/**
* An optional className to be applied to the rendered
@ -28,6 +33,9 @@ interface IAutocompletingTextInputProps<ElementType, AutocompleteItemType> {
/** Element ID for the input field. */
readonly elementId?: string
/** Content of an optional invisible label element for screen readers. */
readonly screenReaderLabel?: string
/** The placeholder for the input field. */
readonly placeholder?: string
@ -40,12 +48,6 @@ interface IAutocompletingTextInputProps<ElementType, AutocompleteItemType> {
/** Indicates if input field should be required */
readonly required?: boolean
/**
* Indicates if input field should be considered a combobox by assistive
* technologies. Optional. Default: false
*/
readonly isCombobox?: boolean
/** Indicates if input field applies spellcheck */
readonly spellcheck?: boolean
@ -121,6 +123,24 @@ interface IAutocompletingTextInputState<T> {
* matching autocompletion providers.
*/
readonly autocompletionState: IAutocompletionState<T> | null
/** Coordinates of the caret in the input/textarea element */
readonly caretCoordinates: ReturnType<typeof getCaretCoordinates> | null
/**
* An automatically generated id for the text element used to reference
* it from the label element. This is generated once via the id pool when the
* component is mounted and then released once the component unmounts.
*/
readonly uniqueInternalElementId?: string
/**
* An automatically generated id for the autocomplete container element used
* to reference it from the ARIA autocomplete-related attributes. This is
* generated once via the id pool when the component is mounted and then
* released once the component unmounts.
*/
readonly autocompleteContainerId?: string
}
/** A text area which provides autocompletions as the user types. */
@ -132,7 +152,7 @@ export abstract class AutocompletingTextInput<
IAutocompletingTextInputState<AutocompleteItemType>
> {
private element: ElementType | null = null
private shouldForceAriaLiveMessage = false
private invisibleCaretRef = React.createRef<HTMLDivElement>()
/** The identifier for each autocompletion request. */
private autocompletionRequestID = 0
@ -150,6 +170,27 @@ export abstract class AutocompletingTextInput<
this.state = {
autocompletionState: null,
caretCoordinates: null,
}
}
public componentWillMount() {
const elementId = createUniqueId('autocompleting-text-input')
const autocompleteContainerId = createUniqueId('autocomplete-container')
this.setState({
uniqueInternalElementId: elementId,
autocompleteContainerId,
})
}
public componentWillUnmount() {
if (this.state.uniqueInternalElementId) {
releaseUniqueId(this.state.uniqueInternalElementId)
}
if (this.state.autocompleteContainerId) {
releaseUniqueId(this.state.autocompleteContainerId)
}
}
@ -164,6 +205,10 @@ export abstract class AutocompletingTextInput<
}
}
private get elementId() {
return this.props.elementId ?? this.state.uniqueInternalElementId
}
private renderItem = (row: number): JSX.Element | null => {
const state = this.state.autocompletionState
if (!state) {
@ -190,45 +235,10 @@ export abstract class AutocompletingTextInput<
return null
}
const element = this.element!
let coordinates = getCaretCoordinates(element, state.range.start)
coordinates = {
...coordinates,
top: coordinates.top - element.scrollTop,
left: coordinates.left - element.scrollLeft,
}
const rect = element.getBoundingClientRect()
const popupAbsoluteTop = rect.top + coordinates.top
const popupAbsoluteLeft = rect.left + coordinates.left
const left = popupAbsoluteLeft
const selectedRow = state.selectedItem
? items.indexOf(state.selectedItem)
: -1
// The maximum height we can use for the popup without it extending beyond
// the Window bounds.
let maxHeight: number
let belowElement: boolean = true
if (
element.ownerDocument !== null &&
element.ownerDocument.defaultView !== null
) {
const windowHeight = element.ownerDocument.defaultView.innerHeight
const spaceToBottomOfWindow = windowHeight - popupAbsoluteTop - YOffset
if (
spaceToBottomOfWindow < DefaultPopupHeight &&
popupAbsoluteTop >= DefaultPopupHeight
) {
maxHeight = DefaultPopupHeight
belowElement = false
} else {
maxHeight = Math.min(DefaultPopupHeight, spaceToBottomOfWindow)
}
} else {
maxHeight = DefaultPopupHeight
}
// The height needed to accommodate all the matched items without overflowing
//
// Magic number warning! The autocompletion-popup container adds a border
@ -236,8 +246,7 @@ export abstract class AutocompletingTextInput<
// without overflowing and triggering the scrollbar.
const noOverflowItemHeight = RowHeight * items.length
const height = Math.min(noOverflowItemHeight, maxHeight)
const top = popupAbsoluteTop + (belowElement ? YOffset + 1 : -height)
const minHeight = RowHeight * Math.min(items.length, 3)
// Use the completion text as invalidation props so that highlighting
// will update as you type even though the number of items matched
@ -249,9 +258,17 @@ export abstract class AutocompletingTextInput<
const className = classNames('autocompletion-popup', state.provider.kind)
return (
<div className={className} style={{ top, left, height }}>
<Popover
anchor={this.invisibleCaretRef.current}
anchorPosition={PopoverAnchorPosition.BottomLeft}
decoration={PopoverDecoration.None}
maxHeight={Math.min(DefaultPopupHeight, noOverflowItemHeight)}
minHeight={minHeight}
trapFocus={false}
className={className}
>
<List
accessibleListId="autocomplete-container"
accessibleListId={this.state.autocompleteContainerId}
ref={this.onAutocompletionListRef}
rowCount={items.length}
rowHeight={RowHeight}
@ -264,7 +281,7 @@ export abstract class AutocompletingTextInput<
onSelectedRowChanged={this.onSelectedRowChanged}
invalidationProps={searchText}
/>
</div>
</Popover>
)
}
@ -377,8 +394,8 @@ export abstract class AutocompletingTextInput<
const props = {
type: 'text',
id: this.props.elementId,
role: this.props.isCombobox ? ('combobox' as const) : undefined,
id: this.elementId,
role: 'combobox',
placeholder: this.props.placeholder,
value: this.props.value,
ref: this.onRef,
@ -394,8 +411,8 @@ export abstract class AutocompletingTextInput<
'aria-expanded': autocompleteVisible,
'aria-autocomplete': 'list' as const,
'aria-haspopup': 'listbox' as const,
'aria-controls': 'autocomplete-container',
'aria-owns': 'autocomplete-container',
'aria-controls': this.state.autocompleteContainerId,
'aria-owns': this.state.autocompleteContainerId,
'aria-activedescendant': this.getActiveAutocompleteItemId(),
}
@ -405,6 +422,53 @@ export abstract class AutocompletingTextInput<
)
}
private updateCaretCoordinates = () => {
const element = this.element
if (!element) {
this.setState({ caretCoordinates: null })
return
}
const selectionEnd = element.selectionEnd
if (selectionEnd === null) {
this.setState({ caretCoordinates: null })
return
}
const caretCoordinates = getCaretCoordinates(element, selectionEnd)
this.setState({
caretCoordinates: {
top: caretCoordinates.top - element.scrollTop,
left: caretCoordinates.left - element.scrollLeft,
height: caretCoordinates.height,
},
})
}
private renderInvisibleCaret = () => {
const { caretCoordinates } = this.state
if (!caretCoordinates) {
return null
}
return (
<div
style={{
backgroundColor: 'transparent',
width: 2,
height: YOffset,
position: 'absolute',
left: caretCoordinates.left,
top: caretCoordinates.top,
}}
ref={this.invisibleCaretRef}
>
&nbsp;
</div>
)
}
private onBlur = (e: React.FocusEvent<ElementType>) => {
this.close()
}
@ -421,6 +485,7 @@ export abstract class AutocompletingTextInput<
private onRef = (ref: ElementType | null) => {
this.element = ref
this.updateCaretCoordinates()
if (this.props.onElementRef) {
this.props.onElementRef(ref)
}
@ -444,9 +509,6 @@ export abstract class AutocompletingTextInput<
}
)
const shouldForceAriaLiveMessage = this.shouldForceAriaLiveMessage
this.shouldForceAriaLiveMessage = false
const autoCompleteItems = this.state.autocompletionState?.items ?? []
const suggestionsMessage =
@ -457,8 +519,16 @@ export abstract class AutocompletingTextInput<
return (
<div className={className}>
{this.renderAutocompletions()}
{this.props.screenReaderLabel && (
<label className="sr-only" htmlFor={this.elementId}>
{this.props.screenReaderLabel}
</label>
)}
{this.renderTextInput()}
<AriaLiveContainer shouldForceChange={shouldForceAriaLiveMessage}>
{this.renderInvisibleCaret()}
<AriaLiveContainer
trackedUserInput={this.state.autocompletionState?.rangeText}
>
{autoCompleteItems.length > 0 ? suggestionsMessage : ''}
</AriaLiveContainer>
</div>
@ -651,6 +721,8 @@ export abstract class AutocompletingTextInput<
this.props.onValueChanged(str)
}
this.updateCaretCoordinates()
return this.open(str)
}
@ -679,7 +751,6 @@ export abstract class AutocompletingTextInput<
return
}
this.shouldForceAriaLiveMessage = true
this.setState({ autocompletionState })
}
}

View file

@ -5,13 +5,11 @@ import { IMatches } from '../../lib/fuzzy-find'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { HighlightText } from '../lib/highlight-text'
import { showContextualMenu } from '../../lib/menu-item'
import { dragAndDropManager } from '../../lib/drag-and-drop-manager'
import { DragType, DropTargetType } from '../../models/drag-drop'
import { TooltippedContent } from '../lib/tooltipped-content'
import { RelativeTime } from '../relative-time'
import classNames from 'classnames'
import { generateBranchContextMenuItems } from './branch-list-item-context-menu'
interface IBranchListItemProps {
/** The name of the branch */
@ -26,13 +24,6 @@ interface IBranchListItemProps {
/** The characters in the branch name to highlight */
readonly matches: IMatches
/** Specifies whether the branch is local */
readonly isLocal: boolean
readonly onRenameBranch?: (branchName: string) => void
readonly onDeleteBranch?: (branchName: string) => void
/** When a drag element has landed on a branch that is not current */
readonly onDropOntoBranch?: (branchName: string) => void
@ -59,30 +50,6 @@ export class BranchListItem extends React.Component<
this.state = { isDragInProgress: false }
}
private onContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
/*
There are multiple instances in the application where a branch list item
is rendered. We only want to be able to rename or delete them on the
branch dropdown menu. Thus, other places simply will not provide these
methods, such as the merge and rebase logic.
*/
const { onRenameBranch, onDeleteBranch, name, isLocal } = this.props
if (onRenameBranch === undefined && onDeleteBranch === undefined) {
return
}
const items = generateBranchContextMenuItems({
name,
isLocal,
onRenameBranch,
onDeleteBranch,
})
showContextualMenu(items)
}
private onMouseEnter = () => {
if (dragAndDropManager.isDragInProgress) {
this.setState({ isDragInProgress: true })
@ -133,7 +100,6 @@ export class BranchListItem extends React.Component<
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
onContextMenu={this.onContextMenu}
className={className}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import { Branch } from '../../models/branch'
import { Branch, BranchType } from '../../models/branch'
import { assertNever } from '../../lib/fatal-error'
@ -20,6 +20,8 @@ import {
} from './group-branches'
import { NoBranches } from './no-branches'
import { SelectionDirection, ClickSource } from '../lib/list'
import { generateBranchContextMenuItems } from './branch-list-item-context-menu'
import { showContextualMenu } from '../../lib/menu-item'
const RowHeight = 30
@ -113,6 +115,12 @@ interface IBranchListProps {
/** Optional: No branches message */
readonly noBranchesMessage?: string | JSX.Element
/** Optional: Callback for if rename context menu should exist */
readonly onRenameBranch?: (branchName: string) => void
/** Optional: Callback for if delete context menu should exist */
readonly onDeleteBranch?: (branchName: string) => void
}
interface IBranchListState {
@ -200,10 +208,34 @@ export class BranchList extends React.Component<
hideFilterRow={this.props.hideFilterRow}
onFilterListResultsChanged={this.props.onFilterListResultsChanged}
renderPreList={this.props.renderPreList}
onItemContextMenu={this.onBranchContextMenu}
/>
)
}
private onBranchContextMenu = (
item: IBranchListItem,
event: React.MouseEvent<HTMLDivElement>
) => {
event.preventDefault()
const { onRenameBranch, onDeleteBranch } = this.props
if (onRenameBranch === undefined && onDeleteBranch === undefined) {
return
}
const { type, name } = item.branch
const isLocal = type === BranchType.Local
const items = generateBranchContextMenuItems({
name,
isLocal,
onRenameBranch,
onDeleteBranch,
})
showContextualMenu(items)
}
private onBranchesFilterListRef = (
filterList: FilterList<IBranchListItem> | null
) => {

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import { Branch, BranchType } from '../../models/branch'
import { Branch } from '../../models/branch'
import { IBranchListItem } from './group-branches'
import { BranchListItem } from './branch-list-item'
@ -10,8 +10,6 @@ export function renderDefaultBranch(
item: IBranchListItem,
matches: IMatches,
currentBranch: Branch | null,
onRenameBranch?: (branchName: string) => void,
onDeleteBranch?: (branchName: string) => void,
onDropOntoBranch?: (branchName: string) => void,
onDropOntoCurrentBranch?: () => void
): JSX.Element {
@ -22,11 +20,8 @@ export function renderDefaultBranch(
<BranchListItem
name={branch.name}
isCurrentBranch={branch.name === currentBranchName}
isLocal={branch.type === BranchType.Local}
lastCommitDate={commit ? commit.author.date : null}
matches={matches}
onRenameBranch={onRenameBranch}
onDeleteBranch={onDeleteBranch}
onDropOntoBranch={onDropOntoBranch}
onDropOntoCurrentBranch={onDropOntoCurrentBranch}
/>

View file

@ -206,8 +206,6 @@ export class BranchesContainer extends React.Component<
item,
matches,
this.props.currentBranch,
this.props.onRenameBranch,
this.props.onDeleteBranch,
this.onDropOntoBranch,
this.onDropOntoCurrentBranch
)
@ -239,6 +237,8 @@ export class BranchesContainer extends React.Component<
DragType.Commit
)}
renderPreList={this.renderPreList}
onRenameBranch={this.props.onRenameBranch}
onDeleteBranch={this.props.onDeleteBranch}
/>
)

View file

@ -14,6 +14,8 @@ interface IPullRequestBadgeProps {
/** The GitHub repository to use when looking up commit status. */
readonly repository: GitHubRepository
readonly onBadgeRef?: (ref: HTMLDivElement | null) => void
/** The GitHub repository to use when looking up commit status. */
readonly onBadgeClick?: () => void
@ -57,6 +59,7 @@ export class PullRequestBadge extends React.Component<
private onRef = (badgeRef: HTMLDivElement) => {
this.badgeRef = badgeRef
this.props.onBadgeRef?.(badgeRef)
}
private onBadgeClick = (

View file

@ -36,13 +36,17 @@ export class ChangeRepositoryAlias extends React.Component<
title={
__DARWIN__ ? `${verb} Repository Alias` : `${verb} repository alias`
}
ariaDescribedBy="change-repository-alias-description"
onDismissed={this.props.onDismissed}
onSubmit={this.changeAlias}
>
<DialogContent>
<p>Choose a new alias for the repository "{nameOf(repository)}". </p>
<p id="change-repository-alias-description">
Choose a new alias for the repository "{nameOf(repository)}".{' '}
</p>
<p>
<TextBox
ariaLabel="Alias"
value={this.state.newAlias}
onValueChanged={this.onNameChanged}
/>

View file

@ -146,11 +146,19 @@ interface IChangesListProps {
readonly changesListScrollTop?: number
/**
* Called to open a file it its default application
* Called to open a file in its default application
*
* @param path The path of the file relative to the root of the repository
*/
readonly onOpenItem: (path: string) => void
/**
* Called to open a file in the default external editor
*
* @param path The path of the file relative to the root of the repository
*/
readonly onOpenItemInExternalEditor: (path: string) => void
/**
* The currently checked out branch (null if no branch is checked out).
*/
@ -196,13 +204,6 @@ interface IChangesListProps {
/** The name of the currently selected external editor */
readonly externalEditorLabel?: string
/**
* Callback to open a selected file using the configured external editor
*
* @param fullPath The full path to the file on disk
*/
readonly onOpenInExternalEditor: (fullPath: string) => void
readonly stashEntry: IStashEntry | null
readonly isShowingStashEntry: boolean
@ -493,7 +494,7 @@ export class ChangesList extends React.Component<
file: WorkingDirectoryFileChange,
enabled: boolean
): IMenuItem => {
const { externalEditorLabel, repository } = this.props
const { externalEditorLabel } = this.props
const openInExternalEditor = externalEditorLabel
? `Open in ${externalEditorLabel}`
@ -502,8 +503,7 @@ export class ChangesList extends React.Component<
return {
label: openInExternalEditor,
action: () => {
const fullPath = Path.join(repository.path, file.path)
this.props.onOpenInExternalEditor(fullPath)
this.props.onOpenItemInExternalEditor(file.path)
},
enabled,
}
@ -901,6 +901,12 @@ export class ChangesList extends React.Component<
)
}
private onRowDoubleClick = (row: number) => {
const file = this.props.workingDirectory.files[row]
this.props.onOpenItemInExternalEditor(file.path)
}
private onRowKeyDown = (
_row: number,
event: React.KeyboardEvent<HTMLDivElement>
@ -981,6 +987,7 @@ export class ChangesList extends React.Component<
isCommitting: isCommitting,
}}
onRowClick={this.props.onRowClick}
onRowDoubleClick={this.onRowDoubleClick}
onScroll={this.onScroll}
setScrollTop={this.props.changesListScrollTop}
onRowKeyDown={this.onRowKeyDown}

View file

@ -23,6 +23,13 @@ interface IChangesProps {
readonly isCommitting: boolean
readonly hideWhitespaceInDiff: boolean
/**
* Callback to open a selected file using the configured external editor
*
* @param fullPath The full path to the file on disk
*/
readonly onOpenInExternalEditor: (fullPath: string) => void
/**
* Called when the user requests to open a binary file in an the
* system-assigned application for said file type.

View file

@ -2,20 +2,29 @@ import React from 'react'
import { Select } from '../lib/select'
import { Button } from '../lib/button'
import { Row } from '../lib/row'
import { Popover, PopoverCaretPosition } from '../lib/popover'
import {
Popover,
PopoverAnchorPosition,
PopoverDecoration,
} from '../lib/popover'
import { IAvatarUser } from '../../models/avatar'
import { Avatar } from '../lib/avatar'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { LinkButton } from '../lib/link-button'
import { ToggledtippedContent } from '../lib/toggletipped-content'
import { TooltipDirection } from '../lib/tooltip'
import { OkCancelButtonGroup } from '../dialog'
import { getConfigValue } from '../../lib/git/config'
import { Repository } from '../../models/repository'
import classNames from 'classnames'
interface ICommitMessageAvatarState {
readonly isPopoverOpen: boolean
/** Currently selected account email address. */
readonly accountEmail: string
/** Whether the git configuration is local to the repository or global */
readonly isGitConfigLocal: boolean
}
interface ICommitMessageAvatarProps {
@ -37,6 +46,11 @@ interface ICommitMessageAvatarProps {
/** Preferred email address from the user's account. */
readonly preferredAccountEmail: string
/**
* The currently selected repository
*/
readonly repository: Repository
readonly onUpdateEmail: (email: string) => void
/**
@ -44,6 +58,12 @@ interface ICommitMessageAvatarProps {
* repository settings dialog
*/
readonly onOpenRepositorySettings: () => void
/**
* Called when the user has requested to see the Git tab in the user settings
* dialog
*/
readonly onOpenGitSettings: () => void
}
/**
@ -55,7 +75,7 @@ export class CommitMessageAvatar extends React.Component<
ICommitMessageAvatarState
> {
private avatarButtonRef: HTMLButtonElement | null = null
private popoverRef = React.createRef<Popover>()
private warningBadgeRef = React.createRef<HTMLDivElement>()
public constructor(props: ICommitMessageAvatarProps) {
super(props)
@ -63,27 +83,30 @@ export class CommitMessageAvatar extends React.Component<
this.state = {
isPopoverOpen: false,
accountEmail: this.props.preferredAccountEmail,
isGitConfigLocal: false,
}
this.determineGitConfigLocation()
}
public componentDidUpdate(prevProps: ICommitMessageAvatarProps) {
if (
this.props.user?.name !== prevProps.user?.name ||
this.props.user?.email !== prevProps.user?.email
) {
this.determineGitConfigLocation()
}
}
private getTitle(): string | JSX.Element | undefined {
const { user } = this.props
private async determineGitConfigLocation() {
const isGitConfigLocal = await this.isGitConfigLocal()
this.setState({ isGitConfigLocal })
}
if (user === undefined) {
return 'Unknown user'
}
const { name, email } = user
if (user.name) {
return (
<>
Committing as <strong>{name}</strong> {email}
</>
)
}
return email
private isGitConfigLocal = async () => {
const { repository } = this.props
const localName = await getConfigValue(repository, 'user.name', true)
const localEmail = await getConfigValue(repository, 'user.email', true)
return localName !== null || localEmail !== null
}
private onButtonRef = (buttonRef: HTMLButtonElement | null) => {
@ -91,30 +114,27 @@ export class CommitMessageAvatar extends React.Component<
}
public render() {
const { warningBadgeVisible, user } = this.props
const ariaLabel = warningBadgeVisible
? 'Commit may be misattributed. View warning.'
: 'View commit author information'
const classes = classNames('commit-message-avatar-component', {
misattributed: warningBadgeVisible,
})
return (
<div className="commit-message-avatar-component">
{this.props.warningBadgeVisible && (
<Button
className="avatar-button"
ariaLabel="Commit may be misattributed. View warning."
onButtonRef={this.onButtonRef}
onClick={this.onAvatarClick}
>
{this.renderWarningBadge()}
<Avatar user={this.props.user} title={null} />
</Button>
)}
{!this.props.warningBadgeVisible && (
<ToggledtippedContent
tooltip={this.getTitle()}
direction={TooltipDirection.NORTH}
ariaLabel="Show Commit Author Details"
>
<Avatar user={this.props.user} title={null} />
</ToggledtippedContent>
)}
<div className={classes}>
<Button
className="avatar-button"
ariaLabel={ariaLabel}
onButtonRef={this.onButtonRef}
onClick={this.onAvatarClick}
>
{warningBadgeVisible && this.renderWarningBadge()}
<Avatar user={user} title={null} />
</Button>
{this.state.isPopoverOpen && this.renderPopover()}
</div>
)
@ -122,7 +142,7 @@ export class CommitMessageAvatar extends React.Component<
private renderWarningBadge() {
return (
<div className="warning-badge">
<div className="warning-badge" ref={this.warningBadgeRef}>
<Octicon symbol={OcticonSymbol.alert} />
</div>
)
@ -147,10 +167,6 @@ export class CommitMessageAvatar extends React.Component<
}
private onAvatarClick = (event: React.FormEvent<HTMLButtonElement>) => {
if (this.props.warningBadgeVisible === false) {
return
}
event.preventDefault()
if (this.state.isPopoverOpen) {
this.closePopover()
@ -159,25 +175,48 @@ export class CommitMessageAvatar extends React.Component<
}
}
private getPopoverPosition(): React.CSSProperties | undefined {
if (!this.avatarButtonRef) {
return
}
private renderGitConfigPopover() {
const { user } = this.props
const { isGitConfigLocal } = this.state
const defaultPopoverHeight = 278
const popoverHeight =
this.popoverRef.current?.containerDivRef.current?.clientHeight ??
defaultPopoverHeight
const buttonHeight = this.avatarButtonRef.clientHeight
const buttonWidth = this.avatarButtonRef.clientWidth
const rect = this.avatarButtonRef.getBoundingClientRect()
const top = rect.top - popoverHeight + buttonHeight / 2
const left = rect.left + buttonWidth / 2
const location = isGitConfigLocal ? 'local' : 'global'
const locationDesc = isGitConfigLocal ? 'for your repository' : ''
const settingsName = __DARWIN__ ? 'preferences' : 'options'
const settings = isGitConfigLocal
? 'repository settings'
: `git ${settingsName}`
const buttonText = __DARWIN__ ? 'Open Git Settings' : 'Open git settings'
return { top, left }
return (
<>
<p>{user && user.name && `Email: ${user.email}`}</p>
<p>
You can update your {location} git configuration {locationDesc} in
your {settings}.
</p>
{!isGitConfigLocal && (
<p className="secondary-text">
You can also set an email local to this repository from the{' '}
<LinkButton onClick={this.onRepositorySettingsClick}>
repository settings
</LinkButton>
.
</p>
)}
<Row className="button-row">
<OkCancelButtonGroup
okButtonText={buttonText}
onOkButtonClick={this.onOpenGitSettings}
onCancelButtonClick={this.onIgnoreClick}
/>
</Row>
</>
)
}
private renderPopover() {
private renderMisattributedCommitPopover() {
const accountTypeSuffix = this.props.isEnterpriseAccount
? ' Enterprise'
: ''
@ -190,16 +229,7 @@ export class CommitMessageAvatar extends React.Component<
: ''
return (
<Popover
caretPosition={PopoverCaretPosition.LeftBottom}
onClickOutside={this.closePopover}
ariaLabelledby="misattributed-commit-popover-header"
style={this.getPopoverPosition()}
ref={this.popoverRef}
>
<h3 id="misattributed-commit-popover-header">
This commit will be misattributed
</h3>
<>
<Row>
<div>
The email in your global Git config (
@ -243,6 +273,54 @@ export class CommitMessageAvatar extends React.Component<
{updateEmailTitle}
</Button>
</Row>
</>
)
}
private getCommittingAsTitle(): string | JSX.Element | undefined {
const { user } = this.props
if (user === undefined) {
return 'Unknown user'
}
const { name, email } = user
if (name) {
return (
<>
Committing as <strong>{name}</strong>
</>
)
}
return <>Committing with {email}</>
}
private renderPopover() {
const { warningBadgeVisible } = this.props
return (
<Popover
anchor={
warningBadgeVisible
? this.warningBadgeRef.current
: this.avatarButtonRef
}
anchorPosition={PopoverAnchorPosition.RightBottom}
decoration={PopoverDecoration.Balloon}
onClickOutside={this.closePopover}
ariaLabelledby="commit-avatar-popover-header"
>
<h3 id="commit-avatar-popover-header">
{warningBadgeVisible
? 'This commit will be misattributed'
: this.getCommittingAsTitle()}
</h3>
{warningBadgeVisible
? this.renderMisattributedCommitPopover()
: this.renderGitConfigPopover()}
</Popover>
)
}
@ -252,6 +330,15 @@ export class CommitMessageAvatar extends React.Component<
this.props.onOpenRepositorySettings()
}
private onOpenGitSettings = () => {
this.closePopover()
if (this.state.isGitConfigLocal) {
this.props.onOpenRepositorySettings()
} else {
this.props.onOpenGitSettings()
}
}
private onIgnoreClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
this.closePopover()

View file

@ -36,6 +36,7 @@ import { isEmptyOrWhitespace } from '../../lib/is-empty-or-whitespace'
import { TooltipDirection } from '../lib/tooltip'
import { pick } from '../../lib/pick'
import { ToggledtippedContent } from '../lib/toggletipped-content'
import { PreferencesTab } from '../../models/preferences'
const addAuthorIcon = {
w: 18,
@ -457,6 +458,8 @@ export class CommitMessage extends React.Component<
}
onUpdateEmail={this.onUpdateUserEmail}
onOpenRepositorySettings={this.onOpenRepositorySettings}
onOpenGitSettings={this.onOpenGitSettings}
repository={repository}
/>
)
}
@ -474,6 +477,13 @@ export class CommitMessage extends React.Component<
})
}
private onOpenGitSettings = () => {
this.props.onShowPopup({
type: PopupType.Preferences,
initialSelectedTab: PreferencesTab.Git,
})
}
private get isCoAuthorInputEnabled() {
return this.props.repository.gitHubRepository !== null
}
@ -861,6 +871,8 @@ export class CommitMessage extends React.Component<
'nudge-arrow-left': this.props.shouldNudge === true,
})
const { placeholder, isCommitting, commitSpellcheckEnabled } = this.props
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<div
@ -875,8 +887,9 @@ export class CommitMessage extends React.Component<
<AutocompletingInput
required={true}
screenReaderLabel="Commit summary"
className={summaryInputClassName}
placeholder={this.props.placeholder}
placeholder={placeholder}
value={this.state.summary}
onValueChanged={this.onSummaryChanged}
onElementRef={this.onSummaryInputRef}
@ -884,8 +897,8 @@ export class CommitMessage extends React.Component<
this.state.commitMessageAutocompletionProviders
}
onContextMenu={this.onAutocompletingInputContextMenu}
disabled={this.props.isCommitting === true}
spellcheck={this.props.commitSpellcheckEnabled}
disabled={isCommitting === true}
spellcheck={commitSpellcheckEnabled}
/>
{showSummaryLengthHint && this.renderSummaryLengthHint()}
</div>
@ -896,6 +909,7 @@ export class CommitMessage extends React.Component<
>
<AutocompletingTextArea
className={descriptionClassName}
screenReaderLabel="Commit description"
placeholder="Description"
value={this.state.description || ''}
onValueChanged={this.onDescriptionChanged}
@ -905,8 +919,8 @@ export class CommitMessage extends React.Component<
ref={this.onDescriptionFieldRef}
onElementRef={this.onDescriptionTextAreaRef}
onContextMenu={this.onAutocompletingInputContextMenu}
disabled={this.props.isCommitting === true}
spellcheck={this.props.commitSpellcheckEnabled}
disabled={isCommitting === true}
spellcheck={commitSpellcheckEnabled}
/>
{this.renderActionBar()}
</FocusContainer>

View file

@ -249,6 +249,14 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
const fullPath = Path.join(this.props.repository.path, path)
openFile(fullPath, this.props.dispatcher)
}
/**
* Called to open a file in the default external editor
*
* @param path The path of the file relative to the root of the repository
*/
private onOpenItemInExternalEditor = (path: string) => {
this.props.onOpenInExternalEditor(path)
}
/**
* Toggles the selection of a given working directory file.
@ -413,7 +421,7 @@ export class ChangesSidebar extends React.Component<IChangesSidebarProps, {}> {
showCoAuthoredBy={showCoAuthoredBy}
coAuthors={coAuthors}
externalEditorLabel={this.props.externalEditorLabel}
onOpenInExternalEditor={this.props.onOpenInExternalEditor}
onOpenItemInExternalEditor={this.onOpenItemInExternalEditor}
onChangesListScrolled={this.props.onChangesListScrolled}
changesListScrollTop={this.props.changesListScrollTop}
stashEntry={this.props.changes.stashEntry}

View file

@ -12,7 +12,11 @@ import {
} from '../../lib/ci-checks/ci-checks'
import { Octicon, syncClockwise } from '../octicons'
import { APICheckConclusion, IAPIWorkflowJobStep } from '../../lib/api'
import { Popover, PopoverCaretPosition } from '../lib/popover'
import {
Popover,
PopoverAnchorPosition,
PopoverDecoration,
} from '../lib/popover'
import { CICheckRunList } from './ci-check-run-list'
import { encodePathAsUrl } from '../../lib/path'
import { PopupType } from '../../models/popup'
@ -42,9 +46,7 @@ interface ICICheckRunPopoverProps {
/** The pull request's number. */
readonly prNumber: number
/** The bottom of the pull request badge so we can position popover relative
* to it. */
readonly badgeBottom: number
readonly anchor: HTMLElement | null
/** Callback for when popover closes */
readonly closePopover: (event?: MouseEvent) => void
@ -207,20 +209,6 @@ export class CICheckRunPopover extends React.PureComponent<
})
}
private getPopoverPositioningStyles = (): React.CSSProperties => {
const top = this.props.badgeBottom + 10
return { top }
}
private getListHeightStyles = (): React.CSSProperties => {
const headerHeight = 55
return {
maxHeight: `${
window.innerHeight - (this.props.badgeBottom + headerHeight + 20)
}px`,
}
}
private renderRerunButton = () => {
const { checkRuns } = this.state
if (!supportsRerunningChecks(this.props.repository.endpoint)) {
@ -380,10 +368,7 @@ export class CICheckRunPopover extends React.PureComponent<
}
return (
<div
className="ci-check-run-list-container"
style={this.getListHeightStyles()}
>
<div className="ci-check-run-list-container">
<CICheckRunList
checkRuns={checkRuns}
loadingActionLogs={loadingActionLogs}
@ -406,13 +391,16 @@ export class CICheckRunPopover extends React.PureComponent<
return (
<div className="ci-check-list-popover">
<Popover
anchor={this.props.anchor}
anchorPosition={PopoverAnchorPosition.Bottom}
decoration={PopoverDecoration.Balloon}
ariaLabelledby="ci-check-run-header"
caretPosition={PopoverCaretPosition.Top}
onClickOutside={this.props.closePopover}
style={this.getPopoverPositioningStyles()}
>
{this.renderHeader()}
{this.renderList()}
<div className="ci-check-run-list-wrapper">
{this.renderHeader()}
{this.renderList()}
</div>
</Popover>
</div>
)

View file

@ -37,7 +37,6 @@ export class CloneGenericRepository extends React.Component<
placeholder="URL or username/repository"
value={this.props.url}
onValueChanged={this.onUrlChanged}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
label={
<span>

View file

@ -16,7 +16,7 @@ import * as OcticonSymbol from '../octicons/octicons.generated'
export class DialogError extends React.Component {
public render() {
return (
<div className="dialog-error">
<div className="dialog-error" role="alert">
<Octicon symbol={OcticonSymbol.stop} />
<div>{this.props.children}</div>
</div>

View file

@ -3,7 +3,11 @@ import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { RadioButton } from '../lib/radio-button'
import { Popover, PopoverCaretPosition } from '../lib/popover'
import {
Popover,
PopoverAnchorPosition,
PopoverDecoration,
} from '../lib/popover'
interface IDiffOptionsProps {
readonly isInteractiveDiff: boolean
@ -28,6 +32,7 @@ export class DiffOptions extends React.Component<
IDiffOptionsState
> {
private diffOptionsRef = React.createRef<HTMLDivElement>()
private gearIconRef = React.createRef<HTMLSpanElement>()
public constructor(props: IDiffOptionsProps) {
super(props)
@ -77,7 +82,9 @@ export class DiffOptions extends React.Component<
return (
<div className="diff-options-component" ref={this.diffOptionsRef}>
<button onClick={this.onButtonClick}>
<Octicon symbol={OcticonSymbol.gear} />
<span ref={this.gearIconRef}>
<Octicon symbol={OcticonSymbol.gear} />
</span>
<Octicon symbol={OcticonSymbol.triangleDown} />
</button>
{this.state.isPopoverOpen && this.renderPopover()}
@ -89,7 +96,9 @@ export class DiffOptions extends React.Component<
return (
<Popover
ariaLabelledby="diff-options-popover-header"
caretPosition={PopoverCaretPosition.TopRight}
anchor={this.gearIconRef.current}
anchorPosition={PopoverAnchorPosition.BottomRight}
decoration={PopoverDecoration.Balloon}
onClickOutside={this.closePopover}
>
<h3 id="diff-options-popover-header">

View file

@ -34,7 +34,6 @@ export class DiffSearchInput extends React.Component<
<TextBox
placeholder="Search..."
type="search"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
onValueChanged={this.onChange}
onKeyDown={this.onKeyDown}

View file

@ -82,6 +82,13 @@ interface IDiffProps {
*/
readonly onOpenBinaryFile: (fullPath: string) => void
/**
* Callback to open a selected file using the configured external editor
*
* @param fullPath The full path to the file on disk
*/
readonly onOpenInExternalEditor?: (fullPath: string) => void
/** Called when the user requests to open a submodule. */
readonly onOpenSubmodule?: (fullPath: string) => void

View file

@ -15,7 +15,7 @@ import { narrowNoNewlineSymbol } from './text-diff'
import { shallowEquals, structuralEquals } from '../../lib/equality'
import { DiffHunkExpansionType } from '../../models/diff'
import { DiffExpansionKind } from './text-diff-expansion'
import { PopoverCaretPosition } from '../lib/popover'
import { PopoverAnchorPosition } from '../lib/popover'
import { WhitespaceHintPopover } from './whitespace-hint-popover'
import { TooltippedContent } from '../lib/tooltipped-content'
import { TooltipDirection } from '../lib/tooltip'
@ -173,7 +173,10 @@ export class SideBySideDiffRow extends React.Component<
return (
<div className="row context">
<div className="before">
{this.renderLineNumbers([beforeLineNumber, afterLineNumber])}
{this.renderLineNumbers(
[beforeLineNumber, afterLineNumber],
undefined
)}
{this.renderContentFromString(row.content, row.beforeTokens)}
</div>
</div>
@ -183,11 +186,11 @@ export class SideBySideDiffRow extends React.Component<
return (
<div className="row context">
<div className="before">
{this.renderLineNumber(beforeLineNumber)}
{this.renderLineNumber(beforeLineNumber, DiffColumn.Before)}
{this.renderContentFromString(row.content, row.beforeTokens)}
</div>
<div className="after">
{this.renderLineNumber(afterLineNumber)}
{this.renderLineNumber(afterLineNumber, DiffColumn.After)}
{this.renderContentFromString(row.content, row.afterTokens)}
</div>
</div>
@ -202,7 +205,11 @@ export class SideBySideDiffRow extends React.Component<
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className={afterClasses}>
{this.renderLineNumbers([undefined, lineNumber], isSelected)}
{this.renderLineNumbers(
[undefined, lineNumber],
DiffColumn.After,
isSelected
)}
{this.renderHunkHandle()}
{this.renderContent(row.data)}
{this.renderWhitespaceHintPopover(DiffColumn.After)}
@ -214,12 +221,12 @@ export class SideBySideDiffRow extends React.Component<
return (
<div className="row added" onMouseEnter={this.onMouseEnterLineNumber}>
<div className={beforeClasses}>
{this.renderLineNumber()}
{this.renderLineNumber(undefined, DiffColumn.Before)}
{this.renderContentFromString('')}
{this.renderWhitespaceHintPopover(DiffColumn.Before)}
</div>
<div className={afterClasses}>
{this.renderLineNumber(lineNumber, isSelected)}
{this.renderLineNumber(lineNumber, DiffColumn.After, isSelected)}
{this.renderContent(row.data)}
{this.renderWhitespaceHintPopover(DiffColumn.After)}
</div>
@ -236,7 +243,11 @@ export class SideBySideDiffRow extends React.Component<
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className={beforeClasses}>
{this.renderLineNumbers([lineNumber, undefined], isSelected)}
{this.renderLineNumbers(
[lineNumber, undefined],
DiffColumn.Before,
isSelected
)}
{this.renderHunkHandle()}
{this.renderContent(row.data)}
{this.renderWhitespaceHintPopover(DiffColumn.Before)}
@ -251,12 +262,12 @@ export class SideBySideDiffRow extends React.Component<
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className={beforeClasses}>
{this.renderLineNumber(lineNumber, isSelected)}
{this.renderLineNumber(lineNumber, DiffColumn.Before, isSelected)}
{this.renderContent(row.data)}
{this.renderWhitespaceHintPopover(DiffColumn.Before)}
</div>
<div className={afterClasses}>
{this.renderLineNumber()}
{this.renderLineNumber(undefined, DiffColumn.After)}
{this.renderContentFromString('')}
{this.renderWhitespaceHintPopover(DiffColumn.After)}
</div>
@ -272,7 +283,11 @@ export class SideBySideDiffRow extends React.Component<
className={beforeClasses}
onMouseEnter={this.onMouseEnterLineNumber}
>
{this.renderLineNumber(before.lineNumber, before.isSelected)}
{this.renderLineNumber(
before.lineNumber,
DiffColumn.Before,
before.isSelected
)}
{this.renderContent(before)}
{this.renderWhitespaceHintPopover(DiffColumn.Before)}
</div>
@ -280,7 +295,11 @@ export class SideBySideDiffRow extends React.Component<
className={afterClasses}
onMouseEnter={this.onMouseEnterLineNumber}
>
{this.renderLineNumber(after.lineNumber, after.isSelected)}
{this.renderLineNumber(
after.lineNumber,
DiffColumn.After,
after.isSelected
)}
{this.renderContent(after)}
{this.renderWhitespaceHintPopover(DiffColumn.After)}
</div>
@ -455,21 +474,33 @@ export class SideBySideDiffRow extends React.Component<
)
}
private getLineNumbersContainerID(column: DiffColumn) {
return `line-numbers-${this.props.numRow}-${column}`
}
/**
* Renders the line number box.
*
* @param lineNumbers Array with line numbers to display.
* @param column Column to which the line number belongs.
* @param isSelected Whether the line has been selected.
* If undefined is passed, the line is treated
* as non-selectable.
*/
private renderLineNumbers(
lineNumbers: Array<number | undefined>,
column: DiffColumn | undefined,
isSelected?: boolean
) {
const wrapperID =
column === undefined ? undefined : this.getLineNumbersContainerID(column)
if (!this.props.isDiffSelectable || isSelected === undefined) {
return (
<div className="line-number" style={{ width: this.lineGutterWidth }}>
<div
id={wrapperID}
className="line-number"
style={{ width: this.lineGutterWidth }}
>
{lineNumbers.map((lineNumber, index) => (
<span key={index}>{lineNumber}</span>
))}
@ -480,6 +511,7 @@ export class SideBySideDiffRow extends React.Component<
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
id={wrapperID}
className={classNames('line-number', 'selectable', 'hoverable', {
'line-selected': isSelected,
hover: this.props.isHunkHovered,
@ -499,22 +531,21 @@ export class SideBySideDiffRow extends React.Component<
if (this.state.showWhitespaceHint !== column) {
return
}
const caretPosition =
column === DiffColumn.Before
? PopoverCaretPosition.RightTop
: PopoverCaretPosition.LeftTop
const style: React.CSSProperties = {
[column === DiffColumn.Before ? 'marginRight' : 'marginLeft']:
this.lineGutterWidth + 10,
marginTop: -10,
const elementID = `line-numbers-${this.props.numRow}-${column}`
const anchor = document.getElementById(elementID)
if (anchor === null) {
return
}
const anchorPosition =
column === DiffColumn.Before
? PopoverAnchorPosition.LeftTop
: PopoverAnchorPosition.RightTop
return (
<WhitespaceHintPopover
caretPosition={caretPosition}
style={style}
anchor={anchor}
anchorPosition={anchorPosition}
onHideWhitespaceInDiffChanged={this.props.onHideWhitespaceInDiffChanged}
onDismissed={this.onWhitespaceHintClose}
/>
@ -529,22 +560,21 @@ export class SideBySideDiffRow extends React.Component<
* Renders the line number box.
*
* @param lineNumber Line number to display.
* @param column Column to which the line number belongs.
* @param isSelected Whether the line has been selected.
* If undefined is passed, the line is treated
* as non-selectable.
*/
private renderLineNumber(lineNumber?: number, isSelected?: boolean) {
return this.renderLineNumbers([lineNumber], isSelected)
private renderLineNumber(
lineNumber: number | undefined,
column: DiffColumn,
isSelected?: boolean
) {
return this.renderLineNumbers([lineNumber], column, isSelected)
}
private getDiffColumn(targetElement?: Element): DiffColumn | null {
const { row, showSideBySideDiff } = this.props
// On unified diffs we don't have columns so we always use "before" to not
// mess up with line selections.
if (!showSideBySideDiff) {
return DiffColumn.Before
}
const { row } = this.props
switch (row.type) {
case DiffRowType.Added:

View file

@ -1,4 +1,5 @@
import * as React from 'react'
import { Repository } from '../../models/repository'
import {
ITextDiff,

View file

@ -56,7 +56,7 @@ import {
import { createOcticonElement } from '../octicons/octicon'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { WhitespaceHintPopover } from './whitespace-hint-popover'
import { PopoverCaretPosition } from '../lib/popover'
import { PopoverAnchorPosition } from '../lib/popover'
import { HiddenBidiCharsWarning } from './hidden-bidi-chars-warning'
// This is a custom version of the no-newline octicon that's exactly as
@ -982,7 +982,7 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
) {
const marker = lineInfo.gutterMarkers[diffGutterName]
if (marker instanceof HTMLElement) {
this.updateGutterMarker(marker, hunk, diffLine)
this.updateGutterMarker(lineInfo.line, marker, hunk, diffLine)
}
} else {
batchedOps.push(() => {
@ -1037,6 +1037,10 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
: canSelect(file) && file.selection.isSelected(index)
}
private getGutterLineID(index: number) {
return `diff-line-gutter-${index}`
}
private getGutterLineClassNameInfo(
hunk: DiffHunk,
diffLine: DiffLine
@ -1175,7 +1179,7 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
)
}
this.updateGutterMarker(marker, hunk, diffLine)
this.updateGutterMarker(index, marker, hunk, diffLine)
return marker
}
@ -1257,6 +1261,7 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
}
private updateGutterMarker(
index: number,
marker: HTMLElement,
hunk: DiffHunk,
diffLine: DiffLine
@ -1270,6 +1275,8 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
}
}
marker.id = this.getGutterLineID(index)
const hunkExpandWholeHandle = marker.getElementsByClassName(
'hunk-expand-whole-handle'
)[0]
@ -1419,39 +1426,22 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
container.style.position = 'absolute'
const scroller = cm.getScrollerElement()
const diffSize = getLineWidthFromDigitCount(
getNumberOfDigits(this.state.diff.maxLineNumber)
)
const lineY = cm.heightAtLine(index, 'local')
// We're positioning relative to the scroll container, not the
// sizer or lines so we'll have to account for the gutter width and
// the hunk handle.
const style: React.CSSProperties = { left: diffSize * 2 + 10 }
let caretPosition = PopoverCaretPosition.LeftTop
// Offset down by 10px to align the popover arrow.
container.style.top = `${lineY - 10}px`
// If the line is further than 50% down the viewport we'll flip the
// popover to point upwards so as to not get hidden beneath (or above)
// the scroll boundary.
if (lineY - scroller.scrollTop > scroller.clientHeight / 2) {
caretPosition = PopoverCaretPosition.LeftBottom
style.bottom = -35
}
scroller.appendChild(container)
this.whitespaceHintContainer = container
ReactDOM.render(
<WhitespaceHintPopover
caretPosition={caretPosition}
anchor={document.getElementById(this.getGutterLineID(index))}
anchorPosition={PopoverAnchorPosition.RightTop}
onDismissed={this.unmountWhitespaceHint}
onHideWhitespaceInDiffChanged={
this.props.onHideWhitespaceInDiffChanged
}
style={style}
/>,
container
)

View file

@ -1,27 +1,29 @@
import * as React from 'react'
import {
Popover,
PopoverCaretPosition,
PopoverAnchorPosition,
PopoverAppearEffect,
PopoverDecoration,
} from '../lib/popover'
import { OkCancelButtonGroup } from '../dialog'
interface IWhitespaceHintPopoverProps {
readonly caretPosition: PopoverCaretPosition
readonly anchor: HTMLElement | null
readonly anchorPosition: PopoverAnchorPosition
/** Called when the user changes the hide whitespace in diffs setting. */
readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void
readonly onDismissed: () => void
readonly style: React.CSSProperties
}
export class WhitespaceHintPopover extends React.Component<IWhitespaceHintPopoverProps> {
public render() {
return (
<Popover
caretPosition={this.props.caretPosition}
anchor={this.props.anchor}
anchorPosition={this.props.anchorPosition}
decoration={PopoverDecoration.Balloon}
onMousedownOutside={this.onDismissed}
className={'whitespace-hint'}
style={this.props.style}
appearEffect={PopoverAppearEffect.Shake}
ariaLabelledby="whitespace-hint-header"
>

View file

@ -7,6 +7,7 @@ import { RetryAction } from '../../models/retry-actions'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { Ref } from '../lib/ref'
import { LinkButton } from '../lib/link-button'
import { PasswordTextBox } from '../lib/password-text-box'
interface IGenericGitAuthenticationProps {
/** The hostname with which the user tried to authenticate. */
@ -61,7 +62,6 @@ export class GenericGitAuthentication extends React.Component<
<Row>
<TextBox
label="Username"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
value={this.state.username}
onValueChanged={this.onUsernameChange}
@ -69,9 +69,8 @@ export class GenericGitAuthentication extends React.Component<
</Row>
<Row>
<TextBox
<PasswordTextBox
label="Password"
type="password"
value={this.state.password}
onValueChanged={this.onPasswordChange}
/>

View file

@ -1,13 +1,14 @@
import * as React from 'react'
import { CommittedFileChange } from '../../models/status'
import { List } from '../lib/list'
import { ClickSource, List } from '../lib/list'
import { CommittedFileItem } from './committed-file-item'
interface IFileListProps {
readonly files: ReadonlyArray<CommittedFileChange>
readonly selectedFile: CommittedFileChange | null
readonly onSelectedFileChanged: (file: CommittedFileChange) => void
readonly onRowDoubleClick: (row: number, source: ClickSource) => void
readonly availableWidth: number
readonly onContextMenu?: (
file: CommittedFileChange,
@ -47,6 +48,7 @@ export class FileList extends React.Component<IFileListProps> {
rowHeight={29}
selectedRows={[this.rowForFile(this.props.selectedFile)]}
onSelectedRowChanged={this.onSelectedRowChanged}
onRowDoubleClick={this.props.onRowDoubleClick}
/>
</div>
)

View file

@ -116,6 +116,13 @@ export class SelectedCommits extends React.Component<
this.props.dispatcher.changeFileSelection(this.props.repository, file)
}
private onRowDoubleClick = (row: number) => {
const files = this.props.changesetData.files
const file = files[row]
this.props.onOpenInExternalEditor(file.path)
}
private onHistoryRef = (ref: HTMLDivElement | null) => {
this.historyRef = ref
}
@ -252,6 +259,7 @@ export class SelectedCommits extends React.Component<
selectedFile={this.props.selectedFile}
availableWidth={availableWidth}
onContextMenu={this.onContextMenu}
onRowDoubleClick={this.onRowDoubleClick}
/>
)
}
@ -389,7 +397,7 @@ export class SelectedCommits extends React.Component<
},
{
label: openInExternalEditor,
action: () => this.props.onOpenInExternalEditor(fullPath),
action: () => this.props.onOpenInExternalEditor(file.path),
enabled: fileExistsOnDisk,
},
{

View file

@ -9,6 +9,7 @@ import { TextBox } from './text-box'
import { Errors } from './errors'
import { getDotComAPIEndpoint } from '../../lib/api'
import { HorizontalRule } from './horizontal-rule'
import { PasswordTextBox } from './password-text-box'
/** Text to let the user know their browser will send them back to GH Desktop */
export const BrowserRedirectMessage =
@ -107,14 +108,12 @@ export class AuthenticationForm extends React.Component<
disabled={disabled}
required={true}
displayInvalidState={false}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
autoFocus={this.props.endpoint === getDotComAPIEndpoint()}
onValueChanged={this.onUsernameChange}
/>
<TextBox
<PasswordTextBox
label="Password"
type="password"
disabled={disabled}
required={true}
displayInvalidState={false}
@ -196,6 +195,7 @@ export class AuthenticationForm extends React.Component<
type="submit"
className="button-with-icon"
onClick={this.signInWithBrowser}
autoFocus={true}
>
Sign in using your browser
<Octicon symbol={OcticonSymbol.linkExternal} />

View file

@ -187,7 +187,6 @@ export class AuthorInput extends React.Component<
<AutocompletingInput<UserHit>
elementId="author-input"
placeholder="@username"
isCombobox={true}
alwaysAutocomplete={true}
autocompletionProviders={[this.props.autoCompleteProvider]}
autocompleteItemFilter={this.getAutocompleteItemFilter(

View file

@ -8,6 +8,42 @@ import { TooltippedContent } from './tooltipped-content'
import { TooltipDirection } from './tooltip'
import { supportsAvatarsAPI } from '../../lib/endpoint-capabilities'
/**
* This maps contains avatar URLs that have failed to load and
* the last time they failed to load (in milliseconds since the epoc)
*
* This is used to prevent us from retrying to load avatars where the
* server returned an error (or was unreachable). Since browsers doesn't
* cache the error itself and since we re-mount our image tags when
* scrolling through our virtualized lists we can end up making a lot
* of redundant requests to the server when it's busy or down. So
* when an avatar fails to load we'll remember that and not attempt
* to load it again for a while (see RetryLimit)
*/
const FailingAvatars = new Map<string, number>()
/**
* Don't attempt to load an avatar that failed to load more than
* once every 5 minutes
*/
const RetryLimit = 5 * 60 * 1000
function pruneExpiredFailingAvatars() {
const expired = new Array<string>()
for (const [url, lastError] of FailingAvatars.entries()) {
if (Date.now() - lastError > RetryLimit) {
expired.push(url)
} else {
// Map is sorted by insertion order so we can bail out early assuming
// we can trust the clock (which I know we can't but it's good enough)
break
}
}
expired.forEach(url => FailingAvatars.delete(url))
}
interface IAvatarProps {
/** The user whose avatar should be displayed. */
readonly user?: IAvatarUser
@ -29,6 +65,7 @@ interface IAvatarProps {
interface IAvatarState {
readonly user?: IAvatarUser
readonly candidates: ReadonlyArray<string>
readonly imageLoaded: boolean
}
/**
@ -148,7 +185,7 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
const { user, size } = props
if (!shallowEquals(user, state.user)) {
const candidates = getAvatarUrlCandidates(user, size)
return { user, candidates }
return { user, candidates, imageLoaded: false }
}
return null
}
@ -160,6 +197,7 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
this.state = {
user,
candidates: getAvatarUrlCandidates(user, size),
imageLoaded: false,
}
}
@ -195,37 +233,32 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
}
private onImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
if (this.state.candidates.length > 0) {
this.setState({ candidates: this.state.candidates.slice(1) })
const { candidates } = this.state
if (candidates.length > 0) {
this.setState({
candidates: candidates.filter(x => x !== e.currentTarget.src),
imageLoaded: false,
})
}
}
private onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
this.setState({ imageLoaded: true })
}
public render() {
const title = this.getTitle()
const { user } = this.props
const { imageLoaded } = this.state
const alt = user
? `Avatar for ${user.name || user.email}`
: `Avatar for unknown user`
if (this.state.candidates.length === 0) {
return (
<Octicon
symbol={DefaultAvatarSymbol}
className="avatar"
title={title}
/>
)
}
const src = this.state.candidates[0]
const img = (
<img className="avatar" src={src} alt={alt} onError={this.onImageError} />
)
if (title === undefined) {
return img
}
const now = Date.now()
const src = this.state.candidates.find(c => {
const lastFailed = FailingAvatars.get(c)
return lastFailed === undefined || now - lastFailed > RetryLimit
})
return (
<TooltippedContent
@ -235,13 +268,43 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
direction={TooltipDirection.NORTH}
tagName="div"
>
{img}
{!imageLoaded && (
<Octicon symbol={DefaultAvatarSymbol} className="avatar" />
)}
{src && (
<img
className="avatar"
// This is critical for the functionality of onImageRef, we need a
// new Image element for each unique url.
key={src}
ref={this.onImageRef}
src={src}
alt={alt}
onLoad={this.onImageLoad}
onError={this.onImageError}
style={{ display: imageLoaded ? undefined : 'none' }}
/>
)}
</TooltippedContent>
)
}
private onImageRef = (img: HTMLImageElement | null) => {
// This is different from the onImageLoad react event handler because we're
// never unsubscribing from this. If we were to use the react event handler
// we'd miss errors that happen after the Avatar component (or img
// component) has unmounted. We use a `key` on the img element to ensure
// we're always using a new img element for each unique url.
img?.addEventListener('error', () => {
// Keep the map sorted on last failure, see pruneExpiredFailingAvatars
FailingAvatars.delete(img.src)
FailingAvatars.set(img.src, Date.now())
})
}
public componentDidMount() {
window.addEventListener('online', this.onInternetConnected)
pruneExpiredFailingAvatars()
}
public componentWillUnmount() {
@ -249,6 +312,10 @@ export class Avatar extends React.Component<IAvatarProps, IAvatarState> {
}
private onInternetConnected = () => {
// Let's assume us being offline was the reason for failing to
// load the avatars
FailingAvatars.clear()
// If we've been offline and therefore failed to load an avatar
// we'll automatically retry when the user becomes connected again.
if (this.state.candidates.length === 0) {

View file

@ -122,6 +122,18 @@ export interface IButtonProps {
* bounds. Typically this is used in conjunction with an ellipsis CSS ruleset.
*/
readonly onlyShowTooltipWhenOverflowed?: boolean
/** The aria-pressed attribute indicates the current "pressed" state of a
* toggle button.
*
* Accessibility notes: Do not change the contents of the label on a toggle
* button when the state changes. If a button label says "pause", do not
* change it to "play" when pressed.
* */
readonly ariaPressed?: boolean
/** Whether the input field should auto focus when mounted. */
readonly autoFocus?: boolean
}
/**
@ -176,6 +188,8 @@ export class Button extends React.Component<IButtonProps, {}> {
aria-disabled={disabled ? 'true' : undefined}
aria-label={this.props.ariaLabel}
aria-haspopup={this.props.ariaHaspopup}
aria-pressed={this.props.ariaPressed}
autoFocus={this.props.autoFocus}
>
{tooltip && (
<Tooltip

View file

@ -21,6 +21,7 @@ import { RadioButton } from './radio-button'
import { Select } from './select'
import { GitEmailNotFoundWarning } from './git-email-not-found-warning'
import { getDotComAPIEndpoint } from '../../lib/api'
import { Loading } from './loading'
interface IConfigureGitUserProps {
/** The logged-in accounts. */
@ -53,6 +54,8 @@ interface IConfigureGitUserState {
* choice to delete the lock file.
*/
readonly existingLockFilePath?: string
readonly loadingGitConfig: boolean
}
/**
@ -82,6 +85,7 @@ export class ConfigureGitUser extends React.Component<
gitHubName: account?.name || account?.login || '',
gitHubEmail:
this.account !== null ? lookupPreferredEmail(this.account) : '',
loadingGitConfig: true,
}
}
@ -113,6 +117,7 @@ export class ConfigureGitUser extends React.Component<
prevState.manualEmail.length === 0
? globalUserEmail || ''
: prevState.manualEmail,
loadingGitConfig: false,
}),
() => {
// Chances are low that we actually have an account at mount-time
@ -276,6 +281,7 @@ export class ConfigureGitUser extends React.Component<
checked={this.state.useGitHubAuthorInfo}
onSelected={this.onUseGitHubInfoSelected}
value="github-account"
autoFocus={true}
/>
<RadioButton
label="Configure manually"
@ -298,7 +304,7 @@ export class ConfigureGitUser extends React.Component<
label="Name"
placeholder="Your Name"
value={this.state.gitHubName}
disabled={true}
readOnly={true}
/>
<Select
@ -324,20 +330,30 @@ export class ConfigureGitUser extends React.Component<
private renderGitConfigForm() {
return (
<Form className="sign-in-form" onSubmit={this.save}>
<TextBox
label="Name"
placeholder="Your Name"
value={this.state.manualName}
onValueChanged={this.onNameChange}
/>
{this.state.loadingGitConfig && (
<div className="git-config-loading">
<Loading /> Checking for an existing git config
</div>
)}
{!this.state.loadingGitConfig && (
<>
<TextBox
label="Name"
placeholder="Your Name"
onValueChanged={this.onNameChange}
value={this.state.manualName}
autoFocus={true}
/>
<TextBox
type="email"
label="Email"
placeholder="your-email@example.com"
value={this.state.manualEmail}
onValueChanged={this.onEmailChange}
/>
<TextBox
type="email"
label="Email"
placeholder="your-email@example.com"
value={this.state.manualEmail}
onValueChanged={this.onEmailChange}
/>
</>
)}
{this.account !== null && (
<GitEmailNotFoundWarning

View file

@ -55,7 +55,6 @@ export class EnterpriseServerEntry extends React.Component<
<Form onSubmit={this.onSubmit}>
<TextBox
label="Enterprise or AE address"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
disabled={disableEntry}
onValueChanged={this.onServerAddressChanged}

View file

@ -41,7 +41,6 @@ export class FancyTextBox extends React.Component<
value={this.props.value}
onFocus={this.onFocus}
onBlur={this.onBlur}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={this.props.autoFocus}
disabled={this.props.disabled}
type={this.props.type}

View file

@ -12,6 +12,7 @@ import { TextBox } from '../lib/text-box'
import { Row } from '../lib/row'
import { match, IMatch, IMatches } from '../../lib/fuzzy-find'
import { AriaLiveContainer } from '../accessibility/aria-live-container'
/** An item in the filter list. */
export interface IFilterListItem {
@ -154,11 +155,24 @@ interface IFilterListProps<T extends IFilterListItem> {
/** If true, we do not render the filter. */
readonly hideFilterRow?: boolean
/**
* A handler called whenever a context menu event is received on the
* row container element.
*
* The context menu is invoked when a user right clicks the row or
* uses keyboard shortcut.s
*/
readonly onItemContextMenu?: (
item: T,
event: React.MouseEvent<HTMLDivElement>
) => void
}
interface IFilterListState<T extends IFilterListItem> {
readonly rows: ReadonlyArray<IFilterListRow<T>>
readonly selectedRow: number
readonly filterValue: string
}
/**
@ -250,7 +264,6 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
<TextBox
ref={this.onTextBoxRef}
type="search"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
placeholder={this.props.placeholderText || 'Filter'}
className="filter-list-filter-field"
@ -277,8 +290,14 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
}
public render() {
const itemRows = this.state.rows.filter(row => row.kind === 'item')
const resultsPluralized = itemRows.length === 1 ? 'result' : 'results'
return (
<div className={classnames('filter-list', this.props.className)}>
<AriaLiveContainer trackedUserInput={this.state.filterValue}>
{itemRows.length} {resultsPluralized}
</AriaLiveContainer>
{this.props.renderPreList ? this.props.renderPreList() : null}
{this.renderFilterRow()}
@ -343,6 +362,7 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
onSelectedRowChanged={this.onSelectedRowChanged}
onRowClick={this.onRowClick}
onRowKeyDown={this.onRowKeyDown}
onRowContextMenu={this.onRowContextMenu}
canSelectRow={this.canSelectRow}
invalidationProps={{
...this.props,
@ -419,6 +439,23 @@ export class FilterList<T extends IFilterListItem> extends React.Component<
}
}
private onRowContextMenu = (
index: number,
source: React.MouseEvent<HTMLDivElement>
) => {
if (!this.props.onItemContextMenu) {
return
}
const row = this.state.rows[index]
if (row.kind !== 'item') {
return
}
this.props.onItemContextMenu(row.item, source)
}
private onRowKeyDown = (row: number, event: React.KeyboardEvent<any>) => {
const list = this.list
if (!list) {
@ -577,7 +614,7 @@ function createStateUpdate<T extends IFilterListItem>(
selectedRow = flattenedRows.findIndex(i => i.kind === 'item')
}
return { rows: flattenedRows, selectedRow }
return { rows: flattenedRows, selectedRow, filterValue: filter }
}
function getItemFromRowIndex<T extends IFilterListItem>(

View file

@ -18,6 +18,8 @@ interface IGitConfigUserFormProps {
readonly onNameChanged: (name: string) => void
readonly onEmailChanged: (email: string) => void
readonly isLoadingGitConfig: boolean
}
interface IGitConfigUserFormState {
@ -49,7 +51,8 @@ export class GitConfigUserForm extends React.Component<
this.state = {
emailIsOther:
this.accountEmails.length > 0 &&
!this.accountEmails.includes(this.props.email),
!this.accountEmails.includes(this.props.email) &&
!this.props.isLoadingGitConfig,
}
}
@ -71,7 +74,8 @@ export class GitConfigUserForm extends React.Component<
this.setState({
emailIsOther:
this.accountEmails.length > 0 &&
!this.accountEmails.includes(this.props.email),
!this.accountEmails.includes(this.props.email) &&
!this.props.isLoadingGitConfig,
})
}

View file

@ -5,7 +5,7 @@ import { getDotComAPIEndpoint } from '../../lib/api'
import { isAttributableEmailFor } from '../../lib/email'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { debounce } from 'lodash'
import { AriaLiveContainer } from '../accessibility/aria-live-container'
interface IGitEmailNotFoundWarningProps {
/** The account the commit should be attributed to. */
@ -15,41 +15,11 @@ interface IGitEmailNotFoundWarningProps {
readonly email: string
}
interface IGitEmailNotFoundWarningState {
/** The generated message debounced for the screen reader */
readonly debouncedMessage: JSX.Element | null
}
/**
* A component which just displays a warning to the user if their git config
* email doesn't match any of the emails in their GitHub (Enterprise) account.
*/
export class GitEmailNotFoundWarning extends React.Component<
IGitEmailNotFoundWarningProps,
IGitEmailNotFoundWarningState
> {
private onEmailChanged = debounce((message: JSX.Element | null) => {
this.setState({ debouncedMessage: message })
}, 1000)
public constructor(props: IGitEmailNotFoundWarningProps) {
super(props)
this.state = {
debouncedMessage: this.buildMessage(),
}
}
public componentDidUpdate(prevProps: IGitEmailNotFoundWarningProps) {
if (prevProps.email !== this.props.email) {
this.onEmailChanged(this.buildMessage())
}
}
public componentWillUnmount() {
this.onEmailChanged.cancel()
}
export class GitEmailNotFoundWarning extends React.Component<IGitEmailNotFoundWarningProps> {
private buildMessage() {
const { accounts, email } = this.props
@ -93,7 +63,6 @@ export class GitEmailNotFoundWarning extends React.Component<
public render() {
const { accounts, email } = this.props
const { debouncedMessage } = this.state
if (accounts.length === 0 || email.trim().length === 0) {
return null
@ -108,14 +77,12 @@ export class GitEmailNotFoundWarning extends React.Component<
<>
<div className="git-email-not-found-warning">{this.buildMessage()}</div>
<div
<AriaLiveContainer
id="git-email-not-found-warning-for-screen-readers"
className="sr-only"
aria-live="polite"
aria-atomic="true"
trackedUserInput={this.props.email}
>
{debouncedMessage}
</div>
{this.buildMessage()}
</AriaLiveContainer>
</>
)
}

View file

@ -32,6 +32,9 @@ interface IListRowProps {
/** callback to fire when the row is clicked */
readonly onRowClick: (index: number, e: React.MouseEvent<any>) => void
/** callback to fire when the row is double clicked */
readonly onRowDoubleClick: (index: number, e: React.MouseEvent<any>) => void
/** callback to fire when the row receives a keyboard event */
readonly onRowKeyDown: (index: number, e: React.KeyboardEvent<any>) => void
@ -82,6 +85,10 @@ export class ListRow extends React.Component<IListRowProps, {}> {
this.props.onRowClick(this.props.rowIndex, e)
}
private onRowDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
this.props.onRowDoubleClick(this.props.rowIndex, e)
}
private onRowKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
this.props.onRowKeyDown(this.props.rowIndex, e)
}
@ -128,6 +135,7 @@ export class ListRow extends React.Component<IListRowProps, {}> {
onMouseDown={this.onRowMouseDown}
onMouseUp={this.onRowMouseUp}
onClick={this.onRowClick}
onDoubleClick={this.onRowDoubleClick}
onKeyDown={this.onRowKeyDown}
style={style}
onFocus={this.onFocus}

View file

@ -113,6 +113,8 @@ interface IListProps {
*/
readonly onRowClick?: (row: number, source: ClickSource) => void
readonly onRowDoubleClick?: (row: number, source: IMouseClickSource) => void
/**
* This prop defines the behaviour of the selection of items within this list.
* - 'single' : (default) single list-item selection. [shift] and [ctrl] have
@ -943,6 +945,7 @@ export class List extends React.Component<IListProps, IListState> {
rowIndex={rowIndex}
selected={selected}
onRowClick={this.onRowClick}
onRowDoubleClick={this.onRowDoubleClick}
onRowKeyDown={this.onRowKeyDown}
onRowMouseDown={this.onRowMouseDown}
onRowMouseUp={this.onRowMouseUp}
@ -1300,6 +1303,14 @@ export class List extends React.Component<IListProps, IListState> {
}
}
private onRowDoubleClick = (row: number, event: React.MouseEvent<any>) => {
if (!this.props.onRowDoubleClick) {
return
}
this.props.onRowDoubleClick(row, { kind: 'mouseclick', event })
}
private onScroll = ({
scrollTop,
clientHeight,

View file

@ -0,0 +1,53 @@
import * as React from 'react'
import { ITextBoxProps, TextBox } from './text-box'
import { Button } from './button'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
interface IPasswordTextBoxState {
/**
* Whether or not the password is currently visible in the underlying input
*/
readonly showPassword: boolean
}
/** An password input element with app-standard styles and a button for toggling
* the visibility of the user password. */
export class PasswordTextBox extends React.Component<
ITextBoxProps,
IPasswordTextBoxState
> {
private textBoxRef = React.createRef<TextBox>()
public constructor(props: ITextBoxProps) {
super(props)
this.state = { showPassword: false }
}
private onTogglePasswordVisibility = () => {
this.setState({ showPassword: !this.state.showPassword })
this.textBoxRef.current!.focus()
}
public render() {
const buttonIcon = this.state.showPassword
? OcticonSymbol.eye
: OcticonSymbol.eyeClosed
const type = this.state.showPassword ? 'text' : 'password'
const props: ITextBoxProps = { ...this.props, ...{ type } }
return (
<div className="password-text-box">
<TextBox {...props} ref={this.textBoxRef} />
<Button
ariaLabel="Toggle password visibility"
tooltip="Toggle password visibility"
onClick={this.onTogglePasswordVisibility}
ariaPressed={this.state.showPassword}
>
<Octicon symbol={buttonIcon} />
</Button>
</div>
)
}
}

View file

@ -1,11 +1,10 @@
import * as React from 'react'
import { Button } from './button'
import { Popover, PopoverCaretPosition } from './popover'
import { Popover, PopoverAnchorPosition, PopoverDecoration } from './popover'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import classNames from 'classnames'
const defaultPopoverContentHeight = 300
const maxPopoverContentHeight = 500
interface IPopoverDropdownProps {
@ -17,7 +16,6 @@ interface IPopoverDropdownProps {
interface IPopoverDropdownState {
readonly showPopover: boolean
readonly popoverContentHeight: number
}
/**
@ -35,36 +33,6 @@ export class PopoverDropdown extends React.Component<
this.state = {
showPopover: false,
popoverContentHeight: defaultPopoverContentHeight,
}
}
public componentDidMount() {
this.calculateDropdownListHeight()
}
public componentDidUpdate() {
this.calculateDropdownListHeight()
}
private calculateDropdownListHeight = () => {
if (this.invokeButtonRef === null) {
return
}
const windowHeight = window.innerHeight
const bottomOfButton = this.invokeButtonRef.getBoundingClientRect().bottom
const listHeaderHeight = 75
const calcMaxHeight = Math.round(
windowHeight - bottomOfButton - listHeaderHeight
)
const popoverContentHeight =
calcMaxHeight > maxPopoverContentHeight
? maxPopoverContentHeight
: calcMaxHeight
if (popoverContentHeight !== this.state.popoverContentHeight) {
this.setState({ popoverContentHeight })
}
}
@ -86,29 +54,30 @@ export class PopoverDropdown extends React.Component<
}
const { contentTitle } = this.props
const { popoverContentHeight } = this.state
const contentStyle = { height: `${popoverContentHeight}px` }
return (
<Popover
className="popover-dropdown-popover"
caretPosition={PopoverCaretPosition.TopLeft}
anchor={this.invokeButtonRef}
anchorPosition={PopoverAnchorPosition.BottomLeft}
maxHeight={maxPopoverContentHeight}
decoration={PopoverDecoration.Balloon}
onClickOutside={this.closePopover}
aria-labelledby="popover-dropdown-header"
>
<div className="popover-dropdown-header">
<span id="popover-dropdown-header">{contentTitle}</span>
<div className="popover-dropdown-wrapper">
<div className="popover-dropdown-header">
<span id="popover-dropdown-header">{contentTitle}</span>
<button
className="close"
onClick={this.closePopover}
aria-label="close"
>
<Octicon symbol={OcticonSymbol.x} />
</button>
</div>
<div className="popover-dropdown-content" style={contentStyle}>
{this.props.children}
<button
className="close"
onClick={this.closePopover}
aria-label="close"
>
<Octicon symbol={OcticonSymbol.x} />
</button>
</div>
<div className="popover-dropdown-content">{this.props.children}</div>
</div>
</Popover>
)

View file

@ -2,49 +2,94 @@ import * as React from 'react'
import FocusTrap from 'focus-trap-react'
import { Options as FocusTrapOptions } from 'focus-trap'
import classNames from 'classnames'
import {
ComputePositionReturn,
autoUpdate,
computePosition,
} from '@floating-ui/react-dom'
import {
arrow,
flip,
offset,
Placement,
shift,
Side,
size,
} from '@floating-ui/core'
import { assertNever } from '../../lib/fatal-error'
/**
* Position of the caret relative to the pop up. It's composed by 2 dimensions:
* - The first one is the edge on which the caret will rest.
* - The second one is the alignment of the caret within that edge.
* Position of the popover relative to its anchor element. It's composed by 2
* dimensions:
* - The first one is the edge of the anchor element from which the popover will
* be displayed.
* - The second one is the alignment of the popover within that edge.
*
* Example: TopRight means the caret will be in the top edge, on its right side.
*
* **Note:** If new positions are added to this enum, the value given to them
* is prepended with `popover-caret-` to create a class name which defines, in
* `app/styles/ui/_popover.scss`, where the caret is located for that specific
* position.
* Example: BottomRight means the popover will be in the bottom edge of the
* anchor element, on its right side.
**/
export enum PopoverCaretPosition {
export enum PopoverAnchorPosition {
Top = 'top',
TopRight = 'top-right',
TopLeft = 'top-left',
Left = 'left',
LeftTop = 'left-top',
LeftBottom = 'left-bottom',
Bottom = 'bottom',
BottomLeft = 'bottom-left',
BottomRight = 'bottom-right',
Right = 'right',
RightTop = 'right-top',
None = 'none',
RightBottom = 'right-bottom',
}
export enum PopoverAppearEffect {
Shake = 'shake',
}
export enum PopoverDecoration {
None = 'none',
Balloon = 'balloon',
}
const TipSize = 8
const TipCornerPadding = TipSize
const ScreenBorderPadding = 10
interface IPopoverProps {
readonly onClickOutside?: (event?: MouseEvent) => void
readonly onMousedownOutside?: (event?: MouseEvent) => void
/** The position of the caret or pointer towards the content to which the the
* popover refers. If the caret position is not provided, the popup will have
* no caret. */
readonly caretPosition?: PopoverCaretPosition
/** Element to anchor the popover to */
readonly anchor: HTMLElement | null
/** The position of the popover relative to the anchor. */
readonly anchorPosition: PopoverAnchorPosition
/**
* The position of the tip or pointer of the popover relative to the side at
* which the tip is presented. Optional. Default: Center
*/
readonly className?: string
readonly style?: React.CSSProperties
readonly appearEffect?: PopoverAppearEffect
readonly ariaLabelledby?: string
readonly trapFocus?: boolean // Default: true
readonly decoration?: PopoverDecoration // Default: none
/** Maximum height decided by clients of Popover */
readonly maxHeight?: number
/** Minimum height decided by clients of Popover */
readonly minHeight?: number
}
export class Popover extends React.Component<IPopoverProps> {
interface IPopoverState {
readonly position: ComputePositionReturn | null
}
export class Popover extends React.Component<IPopoverProps, IPopoverState> {
private focusTrapOptions: FocusTrapOptions
public containerDivRef = React.createRef<HTMLDivElement>()
private containerDivRef = React.createRef<HTMLDivElement>()
private contentDivRef = React.createRef<HTMLDivElement>()
private tipDivRef = React.createRef<HTMLDivElement>()
private floatingCleanUp: (() => void) | null = null
public constructor(props: IPopoverProps) {
super(props)
@ -54,11 +99,88 @@ export class Popover extends React.Component<IPopoverProps> {
escapeDeactivates: true,
onDeactivate: this.props.onClickOutside,
}
this.state = { position: null }
}
private async setupPosition() {
this.floatingCleanUp?.()
this.floatingCleanUp = null
const { anchor } = this.props
if (
anchor === null ||
anchor === undefined ||
this.containerDivRef.current === null
) {
return
}
this.floatingCleanUp = autoUpdate(
anchor,
this.containerDivRef.current,
this.updatePosition
)
}
private updatePosition = async () => {
const { anchor, decoration, maxHeight } = this.props
const containerDiv = this.containerDivRef.current
const contentDiv = this.contentDivRef.current
if (
anchor === null ||
anchor === undefined ||
containerDiv === null ||
contentDiv === null
) {
return
}
const tipDiv = this.tipDivRef.current
const middleware = [
offset(decoration === PopoverDecoration.Balloon ? TipSize : 0),
shift({ padding: ScreenBorderPadding }),
flip({ padding: ScreenBorderPadding }),
size({
apply({ availableHeight, availableWidth }) {
Object.assign(contentDiv.style, {
maxHeight:
maxHeight === undefined
? `${availableHeight}px`
: `${Math.min(availableHeight, maxHeight)}px`,
maxWidth: `${availableWidth}px`,
})
},
padding: ScreenBorderPadding,
}),
]
if (decoration === PopoverDecoration.Balloon && tipDiv) {
middleware.push(arrow({ element: tipDiv, padding: TipCornerPadding }))
}
const position = await computePosition(anchor, containerDiv, {
strategy: 'fixed',
placement: this.getFloatingPlacementForAnchorPosition(),
middleware,
})
this.setState({ position })
}
public componentDidMount() {
document.addEventListener('click', this.onDocumentClick)
document.addEventListener('mousedown', this.onDocumentMouseDown)
this.setupPosition()
}
public componentDidUpdate(prevProps: IPopoverProps) {
if (prevProps.anchor !== this.props.anchor) {
this.setupPosition()
}
}
public componentWillUnmount() {
@ -67,7 +189,7 @@ export class Popover extends React.Component<IPopoverProps> {
}
private onDocumentClick = (event: MouseEvent) => {
const { current: ref } = this.containerDivRef
const ref = this.containerDivRef.current
const { target } = event
if (
@ -82,7 +204,7 @@ export class Popover extends React.Component<IPopoverProps> {
}
private onDocumentMouseDown = (event: MouseEvent) => {
const { current: ref } = this.containerDivRef
const ref = this.containerDivRef.current
const { target } = event
if (
@ -97,31 +219,163 @@ export class Popover extends React.Component<IPopoverProps> {
}
public render() {
const {
trapFocus,
className,
appearEffect,
ariaLabelledby,
children,
decoration,
maxHeight,
minHeight,
} = this.props
const cn = classNames(
'popover-component',
this.getClassNameForCaret(),
this.props.className,
this.props.appearEffect && `appear-${this.props.appearEffect}`
decoration === PopoverDecoration.Balloon && 'popover-component',
className,
appearEffect && `appear-${appearEffect}`
)
return (
<FocusTrap active={true} focusTrapOptions={this.focusTrapOptions}>
const { position } = this.state
// Make sure the popover *always* has at least `position: fixed` set, otherwise
// it can cause weird layout glitches.
const style: React.CSSProperties = {
position: 'fixed',
zIndex: 17, // same as --foldout-z-index
height: 'auto',
}
const contentStyle: React.CSSProperties = {
overflow: 'hidden',
width: '100%',
}
let tipStyle: React.CSSProperties = {}
if (position) {
style.top = position.y === undefined ? undefined : `${position.y}px`
style.left = position.x === undefined ? undefined : `${position.x}px`
contentStyle.minHeight =
minHeight === undefined ? undefined : `${minHeight}px`
contentStyle.height =
maxHeight === undefined ? undefined : `${maxHeight}px`
const arrow = position.middlewareData.arrow
if (arrow) {
const side: Side = position.placement.split('-')[0] as Side
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[side]
const angle = {
top: '270deg',
right: '0deg',
bottom: '90deg',
left: '180deg',
}[side]
tipStyle = {
top: arrow.y,
left: arrow.x,
transform: `rotate(${angle})`,
[staticSide]: this.tipDivRef.current
? `${-this.tipDivRef.current.offsetWidth}px`
: undefined,
}
}
}
const content = (
<div
className={cn}
style={style}
ref={this.containerDivRef}
aria-labelledby={ariaLabelledby}
role="dialog"
>
<div
className={cn}
ref={this.containerDivRef}
style={this.props.style}
aria-labelledby={this.props.ariaLabelledby}
role="dialog"
className="popover-content"
style={contentStyle}
ref={this.contentDivRef}
>
{this.props.children}
{children}
</div>
</FocusTrap>
{decoration === PopoverDecoration.Balloon && (
<div
className="popover-tip"
style={{
position: 'absolute',
width: TipSize * 2,
height: TipSize * 2,
...tipStyle,
}}
ref={this.tipDivRef}
>
<div
className="popover-tip-border"
style={{
position: 'absolute',
right: 1,
width: 0,
height: 0,
borderWidth: `${TipSize}px`,
borderRightWidth: `${TipSize - 1}px`,
}}
/>
<div
className="popover-tip-background"
style={{
position: 'absolute',
right: 0,
width: 0,
height: 0,
borderWidth: `${TipSize}px`,
borderRightWidth: `${TipSize - 1}px`,
}}
/>
</div>
)}
</div>
)
return trapFocus !== false ? (
<FocusTrap focusTrapOptions={this.focusTrapOptions}>{content}</FocusTrap>
) : (
content
)
}
private getClassNameForCaret() {
return `popover-caret-${
this.props.caretPosition ?? PopoverCaretPosition.None
}`
private getFloatingPlacementForAnchorPosition(): Placement {
const { anchorPosition } = this.props
switch (anchorPosition) {
case PopoverAnchorPosition.Top:
return 'top'
case PopoverAnchorPosition.TopLeft:
return 'top-start'
case PopoverAnchorPosition.TopRight:
return 'top-end'
case PopoverAnchorPosition.Left:
return 'left'
case PopoverAnchorPosition.LeftTop:
return 'left-start'
case PopoverAnchorPosition.LeftBottom:
return 'left-end'
case PopoverAnchorPosition.Right:
return 'right'
case PopoverAnchorPosition.RightTop:
return 'right-start'
case PopoverAnchorPosition.RightBottom:
return 'right-end'
case PopoverAnchorPosition.Bottom:
return 'bottom'
case PopoverAnchorPosition.BottomLeft:
return 'bottom-start'
case PopoverAnchorPosition.BottomRight:
return 'bottom-end'
default:
assertNever(anchorPosition, 'Unknown anchor position')
}
}
}

View file

@ -30,6 +30,9 @@ interface IRadioButtonProps<T> {
/** Optional: The tab index of the radio button */
readonly tabIndex?: number
/** Whether the textarea field should auto focus when mounted. */
readonly autoFocus?: boolean
}
interface IRadioButtonState {
@ -62,6 +65,7 @@ export class RadioButton<T extends string> extends React.Component<
checked={this.props.checked}
onChange={this.onSelected}
tabIndex={this.props.tabIndex}
autoFocus={this.props.autoFocus}
/>
<label htmlFor={this.state.inputId}>
{this.props.label ?? this.props.children}

View file

@ -74,7 +74,6 @@ export class TextArea extends React.Component<ITextAreaProps, {}> {
{this.props.label}
<textarea
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={this.props.autoFocus}
className={this.props.textareaClassName}
disabled={this.props.disabled}

View file

@ -25,6 +25,9 @@ export interface ITextBoxProps {
/** Whether the input field is disabled. */
readonly disabled?: boolean
/** Whether the input field is read-only. */
readonly readOnly?: boolean
/** Indicates if input field should be required */
readonly required?: boolean
@ -256,16 +259,15 @@ export class TextBox extends React.Component<ITextBoxProps, ITextBoxState> {
})}
>
{label && <label htmlFor={inputId}>{label}</label>}
<input
id={inputId}
ref={this.onInputRef}
onFocus={this.onFocus}
onBlur={this.onBlur}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={this.props.autoFocus}
disabled={this.props.disabled}
type={this.props.type}
readOnly={this.props.readOnly}
type={this.props.type ?? 'text'}
placeholder={this.props.placeholder}
value={this.state.value}
onChange={this.onChange}

View file

@ -113,7 +113,7 @@ export class ToggledtippedContent extends React.Component<
{children}
{this.state.tooltipVisible && (
<AriaLiveContainer
shouldForceChange={this.shouldForceAriaLiveMessage}
trackedUserInput={this.shouldForceAriaLiveMessage}
>
{tooltip}
</AriaLiveContainer>

View file

@ -78,7 +78,6 @@ export class TwoFactorAuthentication extends React.Component<
<TextBox
label="Authentication code"
disabled={textEntryDisabled}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
onValueChanged={this.onOTPChange}
/>

View file

@ -129,26 +129,28 @@ export class NoRepositoriesView extends React.Component<
public render() {
return (
<UiView id="no-repositories">
<header>
<h1>Let's get started!</h1>
<p>Add a repository to GitHub Desktop to start collaborating</p>
</header>
<section aria-label="Let's get started!">
<header>
<h1>Let's get started!</h1>
<p>Add a repository to GitHub Desktop to start collaborating</p>
</header>
<div className="content">
{this.renderGetStartedActions()}
{this.renderRepositoryList()}
</div>
<div className="content">
{this.renderGetStartedActions()}
{this.renderRepositoryList()}
</div>
<img
className="no-repositories-graphic-top"
src={WelcomeLeftTopImageUri}
alt=""
/>
<img
className="no-repositories-graphic-bottom"
src={WelcomeLeftBottomImageUri}
alt=""
/>
<img
className="no-repositories-graphic-top"
src={WelcomeLeftTopImageUri}
alt=""
/>
<img
className="no-repositories-graphic-bottom"
src={WelcomeLeftBottomImageUri}
alt=""
/>
</section>
</UiView>
)
}
@ -180,6 +182,12 @@ export class NoRepositoriesView extends React.Component<
}
}
private isUserSignedIn() {
return (
this.props.dotComAccount !== null || this.props.enterpriseAccount !== null
)
}
private getSelectedAccount() {
const { selectedTab } = this.state
if (selectedTab === AccountTab.dotCom) {
@ -346,24 +354,22 @@ export class NoRepositoriesView extends React.Component<
symbol: OcticonSymbolType,
title: string,
onClick: () => void,
type?: 'submit'
type?: 'submit',
autoFocus?: boolean
) {
return (
<li>
<Button onClick={onClick} type={type}>
<span>
<Button onClick={onClick} type={type} autoFocus={autoFocus}>
<Octicon symbol={symbol} />
<div>{title}</div>
</Button>
</li>
</span>
)
}
private renderTutorialRepositoryButton() {
// No tutorial if you're not signed in.
if (
this.props.dotComAccount === null &&
this.props.enterpriseAccount === null
) {
if (!this.isUserSignedIn()) {
return null
}
@ -394,7 +400,9 @@ export class NoRepositoriesView extends React.Component<
__DARWIN__
? 'Clone a Repository from the Internet…'
: 'Clone a repository from the Internet…',
this.onShowClone
this.onShowClone,
undefined,
!this.isUserSignedIn()
)
}
@ -421,12 +429,12 @@ export class NoRepositoriesView extends React.Component<
private renderGetStartedActions() {
return (
<div className="content-pane">
<ul className="button-group">
<div className="button-group">
{this.renderTutorialRepositoryButton()}
{this.renderCloneButton()}
{this.renderCreateRepositoryButton()}
{this.renderAddExistingRepositoryButton()}
</ul>
</div>
<div className="drag-drop-info">
<Octicon symbol={OcticonSymbol.lightBulb} />

View file

@ -61,6 +61,13 @@ interface IOpenPullRequestDialogProps {
/** Label for selected external editor */
readonly externalEditorLabel?: string
/**
* Callback to open a selected file using the configured external editor
*
* @param fullPath The full path to the file on disk
*/
readonly onOpenInExternalEditor: (fullPath: string) => void
/** Width to use for the files list pane in the files changed view */
readonly fileListWidth: IConstrainedValue
@ -168,6 +175,7 @@ export class OpenPullRequestDialog extends React.Component<IOpenPullRequestDialo
selectedFile={file}
showSideBySideDiff={this.props.showSideBySideDiff}
repository={repository}
onOpenInExternalEditor={this.props.onOpenInExternalEditor}
/>
)
}

View file

@ -57,6 +57,13 @@ interface IPullRequestFilesChangedProps {
/** If the latest commit of the pull request is not local, this will contain
* it's SHA */
readonly nonLocalCommitSHA: string | null
/**
* Callback to open a selected file using the configured external editor
*
* @param fullPath The full path to the file on disk
*/
readonly onOpenInExternalEditor: (fullPath: string) => void
}
interface IPullRequestFilesChangedState {
@ -224,6 +231,13 @@ export class PullRequestFilesChanged extends React.Component<
)
}
private onRowDoubleClick = (row: number) => {
const files = this.props.files
const file = files[row]
this.props.onOpenInExternalEditor(file.path)
}
private renderHeader() {
const { hideWhitespaceInDiff } = this.props
const { showSideBySideDiff } = this.state
@ -261,6 +275,7 @@ export class PullRequestFilesChanged extends React.Component<
selectedFile={selectedFile}
availableWidth={clamp(fileListWidth)}
onContextMenu={this.onFileContextMenu}
onRowDoubleClick={this.onRowDoubleClick}
/>
</Resizable>
)

View file

@ -11,6 +11,7 @@ interface IGitProps {
readonly name: string
readonly email: string
readonly defaultBranch: string
readonly isLoadingGitConfig: boolean
readonly dotComAccount: Account | null
readonly enterpriseAccount: Account | null
@ -40,17 +41,29 @@ export class Git extends React.Component<IGitProps, IGitState> {
super(props)
this.state = {
defaultBranchIsOther: !SuggestedBranchNames.includes(
this.props.defaultBranch
),
defaultBranchIsOther: this.isDefaultBranchOther(),
}
}
private isDefaultBranchOther = () => {
return (
!this.props.isLoadingGitConfig &&
!SuggestedBranchNames.includes(this.props.defaultBranch)
)
}
public componentDidUpdate(prevProps: IGitProps) {
if (this.props.defaultBranch === prevProps.defaultBranch) {
return
}
this.setState({
defaultBranchIsOther: this.isDefaultBranchOther(),
})
// Focus the text input that allows the user to enter a custom
// branch name when the user has selected "Other...".
if (
this.props.defaultBranch !== prevProps.defaultBranch &&
this.props.defaultBranch === OtherNameForDefaultBranch &&
this.defaultBranchInputRef.current !== null
) {
@ -72,6 +85,7 @@ export class Git extends React.Component<IGitProps, IGitState> {
<GitConfigUserForm
email={this.props.email}
name={this.props.name}
isLoadingGitConfig={this.props.isLoadingGitConfig}
enterpriseAccount={this.props.enterpriseAccount}
dotComAccount={this.props.dotComAccount}
onEmailChanged={this.props.onEmailChanged}
@ -106,11 +120,13 @@ export class Git extends React.Component<IGitProps, IGitState> {
<div className="default-branch-component">
<h2>Default branch name for new repositories</h2>
{SuggestedBranchNames.map((branchName: string) => (
{SuggestedBranchNames.map((branchName: string, i: number) => (
<RadioButton
key={branchName}
checked={
!defaultBranchIsOther && this.props.defaultBranch === branchName
(!defaultBranchIsOther &&
this.props.defaultBranch === branchName) ||
(this.props.isLoadingGitConfig && i === 0)
}
value={branchName}
label={branchName}

View file

@ -99,6 +99,8 @@ interface IPreferencesState {
readonly repositoryIndicatorsEnabled: boolean
readonly initiallySelectedTheme: ApplicationTheme
readonly isLoadingGitConfig: boolean
}
/** The app-level preferences component. */
@ -134,6 +136,7 @@ export class Preferences extends React.Component<
selectedShell: this.props.selectedShell,
repositoryIndicatorsEnabled: this.props.repositoryIndicatorsEnabled,
initiallySelectedTheme: this.props.selectedTheme,
isLoadingGitConfig: true,
}
}
@ -190,6 +193,7 @@ export class Preferences extends React.Component<
uncommittedChangesStrategy: this.props.uncommittedChangesStrategy,
availableShells,
availableEditors,
isLoadingGitConfig: false,
})
}
@ -329,6 +333,7 @@ export class Preferences extends React.Component<
onNameChanged={this.onCommitterNameChanged}
onEmailChanged={this.onCommitterEmailChanged}
onDefaultBranchChanged={this.onDefaultBranchChanged}
isLoadingGitConfig={this.state.isLoadingGitConfig}
/>
</>
)

View file

@ -23,6 +23,7 @@ import { encodePathAsUrl } from '../../lib/path'
import { TooltippedContent } from '../lib/tooltipped-content'
import memoizeOne from 'memoize-one'
import { KeyboardShortcut } from '../keyboard-shortcut/keyboard-shortcut'
import { generateRepositoryListContextMenu } from '../repositories-list/repository-list-item-context-menu'
const BlankSlateImage = encodePathAsUrl(__dirname, 'static/empty-no-repo.svg')
@ -75,6 +76,10 @@ interface IRepositoriesListProps {
readonly dispatcher: Dispatcher
}
interface IRepositoriesListState {
readonly newRepositoryMenuExpanded: boolean
}
const RowHeight = 29
/**
@ -101,7 +106,7 @@ function findMatchingListItem(
/** The list of user-added repositories. */
export class RepositoriesList extends React.Component<
IRepositoriesListProps,
{}
IRepositoriesListState
> {
/**
* A memoized function for grouping repositories for display
@ -130,6 +135,14 @@ export class RepositoriesList extends React.Component<
*/
private getSelectedListItem = memoizeOne(findMatchingListItem)
public constructor(props: IRepositoriesListProps) {
super(props)
this.state = {
newRepositoryMenuExpanded: false,
}
}
private renderItem = (item: IRepositoryListItem, matches: IMatches) => {
const repository = item.repository
return (
@ -137,18 +150,6 @@ export class RepositoriesList extends React.Component<
key={repository.id}
repository={repository}
needsDisambiguation={item.needsDisambiguation}
askForConfirmationOnRemoveRepository={
this.props.askForConfirmationOnRemoveRepository
}
onRemoveRepository={this.props.onRemoveRepository}
onShowRepository={this.props.onShowRepository}
onViewOnGitHub={this.props.onViewOnGitHub}
onOpenInShell={this.props.onOpenInShell}
onOpenInExternalEditor={this.props.onOpenInExternalEditor}
onChangeRepositoryAlias={this.onChangeRepositoryAlias}
onRemoveRepositoryAlias={this.onRemoveRepositoryAlias}
externalEditorLabel={this.props.externalEditorLabel}
shellLabel={this.props.shellLabel}
matches={matches}
aheadBehind={item.aheadBehind}
changedFilesCount={item.changedFilesCount}
@ -193,6 +194,30 @@ export class RepositoriesList extends React.Component<
this.props.onSelectionChanged(item.repository)
}
private onItemContextMenu = (
item: IRepositoryListItem,
event: React.MouseEvent<HTMLDivElement>
) => {
event.preventDefault()
const items = generateRepositoryListContextMenu({
onRemoveRepository: this.props.onRemoveRepository,
onShowRepository: this.props.onShowRepository,
onOpenInShell: this.props.onOpenInShell,
onOpenInExternalEditor: this.props.onOpenInExternalEditor,
askForConfirmationOnRemoveRepository:
this.props.askForConfirmationOnRemoveRepository,
externalEditorLabel: this.props.externalEditorLabel,
onChangeRepositoryAlias: this.onChangeRepositoryAlias,
onRemoveRepositoryAlias: this.onRemoveRepositoryAlias,
onViewOnGitHub: this.props.onViewOnGitHub,
repository: item.repository,
shellLabel: this.props.shellLabel,
})
showContextualMenu(items)
}
public render() {
const baseGroups = this.getRepositoryGroups(
this.props.repositories,
@ -233,6 +258,7 @@ export class RepositoriesList extends React.Component<
repositories: this.props.repositories,
filterText: this.props.filterText,
}}
onItemContextMenu={this.onItemContextMenu}
/>
</div>
)
@ -243,6 +269,7 @@ export class RepositoriesList extends React.Component<
<Button
className="new-repository-button"
onClick={this.onNewRepositoryButtonClick}
ariaExpanded={this.state.newRepositoryMenuExpanded}
>
Add
<Octicon symbol={OcticonSymbol.triangleDown} />
@ -292,7 +319,10 @@ export class RepositoriesList extends React.Component<
},
]
showContextualMenu(items)
this.setState({ newRepositoryMenuExpanded: true })
showContextualMenu(items).then(() => {
this.setState({ newRepositoryMenuExpanded: false })
})
}
private onCloneRepository = () => {

View file

@ -3,7 +3,6 @@ import * as React from 'react'
import { Repository } from '../../models/repository'
import { Octicon, iconForRepository } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { showContextualMenu } from '../../lib/menu-item'
import { Repositoryish } from './group-repositories'
import { HighlightText } from '../lib/highlight-text'
import { IMatches } from '../../lib/fuzzy-find'
@ -12,44 +11,13 @@ import classNames from 'classnames'
import { createObservableRef } from '../lib/observable-ref'
import { Tooltip } from '../lib/tooltip'
import { TooltippedContent } from '../lib/tooltipped-content'
import { generateRepositoryListContextMenu } from './repository-list-item-context-menu'
interface IRepositoryListItemProps {
readonly repository: Repositoryish
/** Whether the user has enabled the setting to confirm removing a repository from the app */
readonly askForConfirmationOnRemoveRepository: boolean
/** Called when the repository should be removed. */
readonly onRemoveRepository: (repository: Repositoryish) => void
/** Called when the repository should be shown in Finder/Explorer/File Manager. */
readonly onShowRepository: (repository: Repositoryish) => void
/** Called when the repository should be opened on GitHub in the default web browser. */
readonly onViewOnGitHub: (repository: Repositoryish) => void
/** Called when the repository should be shown in the shell. */
readonly onOpenInShell: (repository: Repositoryish) => void
/** Called when the repository should be opened in an external editor */
readonly onOpenInExternalEditor: (repository: Repositoryish) => void
/** Called when the repository alias should be changed */
readonly onChangeRepositoryAlias: (repository: Repository) => void
/** Called when the repository alias should be removed */
readonly onRemoveRepositoryAlias: (repository: Repository) => void
/** The current external editor selected by the user */
readonly externalEditorLabel?: string
/** Does the repository need to be disambiguated in the list? */
readonly needsDisambiguation: boolean
/** The label for the user's preferred shell. */
readonly shellLabel: string
/** The characters in the repository name to highlight */
readonly matches: IMatches
@ -86,11 +54,7 @@ export class RepositoryListItem extends React.Component<
})
return (
<div
onContextMenu={this.onContextMenu}
className="repository-list-item"
ref={this.listItemRef}
>
<div className="repository-list-item" ref={this.listItemRef}>
<Tooltip target={this.listItemRef}>{this.renderTooltip()}</Tooltip>
<Octicon
@ -144,27 +108,6 @@ export class RepositoryListItem extends React.Component<
return true
}
}
private onContextMenu = (event: React.MouseEvent<any>) => {
event.preventDefault()
const items = generateRepositoryListContextMenu({
onRemoveRepository: this.props.onRemoveRepository,
onShowRepository: this.props.onShowRepository,
onOpenInShell: this.props.onOpenInShell,
onOpenInExternalEditor: this.props.onOpenInExternalEditor,
askForConfirmationOnRemoveRepository:
this.props.askForConfirmationOnRemoveRepository,
externalEditorLabel: this.props.externalEditorLabel,
onChangeRepositoryAlias: this.props.onChangeRepositoryAlias,
onRemoveRepositoryAlias: this.props.onRemoveRepositoryAlias,
onViewOnGitHub: this.props.onViewOnGitHub,
repository: this.props.repository,
shellLabel: this.props.shellLabel,
})
showContextualMenu(items)
}
}
const renderRepoIndicators: React.FunctionComponent<{

View file

@ -14,6 +14,7 @@ interface IGitConfigProps {
readonly email: string
readonly globalName: string
readonly globalEmail: string
readonly isLoadingGitConfig: boolean
readonly onGitConfigLocationChanged: (value: GitConfigLocation) => void
readonly onNameChanged: (name: string) => void
@ -78,6 +79,7 @@ export class GitConfig extends React.Component<IGitConfigProps> {
disabled={this.props.gitConfigLocation === GitConfigLocation.Global}
onEmailChanged={this.props.onEmailChanged}
onNameChanged={this.props.onNameChanged}
isLoadingGitConfig={this.props.isLoadingGitConfig}
/>
</div>
</DialogContent>

View file

@ -28,7 +28,7 @@ export class GitIgnore extends React.Component<IGitIgnoreProps, {}> {
placeholder="Ignored files"
value={this.props.text || ''}
onValueChanged={this.props.onIgnoreTextChanged}
rows={6}
textareaClassName="gitignore"
/>
</DialogContent>
)

View file

@ -65,6 +65,7 @@ interface IRepositorySettingsState {
readonly initialCommitterEmail: string | null
readonly errors?: ReadonlyArray<JSX.Element | string>
readonly forkContributionTarget: ForkContributionTarget
readonly isLoadingGitConfig: boolean
}
export class RepositorySettings extends React.Component<
@ -91,6 +92,7 @@ export class RepositorySettings extends React.Component<
initialGitConfigLocation: GitConfigLocation.Global,
initialCommitterName: null,
initialCommitterEmail: null,
isLoadingGitConfig: true,
}
}
@ -143,6 +145,7 @@ export class RepositorySettings extends React.Component<
initialGitConfigLocation: gitConfigLocation,
initialCommitterName: localCommitterName,
initialCommitterEmail: localCommitterEmail,
isLoadingGitConfig: false,
})
}
@ -202,28 +205,16 @@ export class RepositorySettings extends React.Component<
<div className="active-tab">{this.renderActiveTab()}</div>
</div>
{this.renderFooter()}
<DialogFooter>
<OkCancelButtonGroup
okButtonText="Save"
okButtonDisabled={this.state.saveDisabled}
/>
</DialogFooter>
</Dialog>
)
}
private renderFooter() {
const tab = this.state.selectedTab
const remote = this.state.remote
if (tab === RepositorySettingsTab.Remote && !remote) {
return null
}
return (
<DialogFooter>
<OkCancelButtonGroup
okButtonText="Save"
okButtonDisabled={this.state.saveDisabled}
/>
</DialogFooter>
)
}
private renderActiveTab() {
const tab = this.state.selectedTab
switch (tab) {
@ -277,6 +268,7 @@ export class RepositorySettings extends React.Component<
globalEmail={this.state.globalCommitterEmail}
onNameChanged={this.onCommitterNameChanged}
onEmailChanged={this.onCommitterEmailChanged}
isLoadingGitConfig={this.state.isLoadingGitConfig}
/>
)
}

View file

@ -380,6 +380,7 @@ export class RepositoryView extends React.Component<
onOpenSubmodule={this.onOpenSubmodule}
onChangeImageDiffType={this.onChangeImageDiffType}
onHideWhitespaceInDiffChanged={this.onHideWhitespaceInDiffChanged}
onOpenInExternalEditor={this.props.onOpenInExternalEditor}
/>
)
}
@ -519,6 +520,7 @@ export class RepositoryView extends React.Component<
this.props.askForConfirmationOnDiscardChanges
}
onDiffOptionsOpened={this.onDiffOptionsOpened}
onOpenInExternalEditor={this.props.onOpenInExternalEditor}
/>
)
}

View file

@ -1,4 +1,3 @@
/* eslint-disable jsx-a11y/no-autofocus */
import * as React from 'react'
import { Dispatcher } from '../dispatcher'
import {
@ -21,6 +20,7 @@ import { getDotComAPIEndpoint } from '../../lib/api'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { Button } from '../lib/button'
import { HorizontalRule } from '../lib/horizontal-rule'
import { PasswordTextBox } from '../lib/password-text-box'
interface ISignInProps {
readonly dispatcher: Dispatcher
@ -250,10 +250,9 @@ export class SignIn extends React.Component<ISignInProps, ISignInState> {
/>
</Row>
<Row>
<TextBox
<PasswordTextBox
label="Password"
value={this.state.password}
type="password"
onValueChanged={this.onPasswordChanged}
/>
</Row>

View file

@ -2,8 +2,8 @@ import * as React from 'react'
import { Dialog, DialogContent, DialogFooter } from '../dialog'
import { Row } from '../lib/row'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { TextBox } from '../lib/text-box'
import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { PasswordTextBox } from '../lib/password-text-box'
interface ISSHKeyPassphraseProps {
readonly keyPath: string
@ -43,10 +43,9 @@ export class SSHKeyPassphrase extends React.Component<
>
<DialogContent>
<Row>
<TextBox
<PasswordTextBox
label={`Enter passphrase for key '${this.props.keyPath}':`}
value={this.state.passphrase}
type="password"
onValueChanged={this.onValueChanged}
/>
</Row>

View file

@ -2,8 +2,8 @@ import * as React from 'react'
import { Dialog, DialogContent, DialogFooter } from '../dialog'
import { Row } from '../lib/row'
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
import { TextBox } from '../lib/text-box'
import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { PasswordTextBox } from '../lib/password-text-box'
interface ISSHUserPasswordProps {
readonly username: string
@ -43,10 +43,9 @@ export class SSHUserPassword extends React.Component<
>
<DialogContent>
<Row>
<TextBox
<PasswordTextBox
label={`Enter password for '${this.props.username}':`}
value={this.state.password}
type="password"
onValueChanged={this.onValueChanged}
/>
</Row>

View file

@ -53,6 +53,13 @@ interface IStashDiffViewerProps {
/** Called when the user requests to open a submodule. */
readonly onOpenSubmodule: (fullPath: string) => void
/**
* Called to open a file using the user's configured applications
*
* @param path The path of the file relative to the root of the repository
*/
readonly onOpenInExternalEditor: (path: string) => void
}
/**
@ -64,11 +71,23 @@ export class StashDiffViewer extends React.PureComponent<IStashDiffViewerProps>
private onSelectedFileChanged = (file: CommittedFileChange) =>
this.props.dispatcher.selectStashedFile(this.props.repository, file)
private onRowDoubleClick = (row: number) => {
const files = this.getFiles()
const file = files[row]
this.props.onOpenInExternalEditor(file.path)
}
private onResize = (width: number) =>
this.props.dispatcher.setStashedFilesWidth(width)
private onReset = () => this.props.dispatcher.resetStashedFilesWidth()
private getFiles = () =>
this.props.stashEntry.files.kind === StashedChangesLoadStates.Loaded
? this.props.stashEntry.files.files
: new Array<CommittedFileChange>()
public render() {
const {
stashEntry,
@ -83,10 +102,7 @@ export class StashDiffViewer extends React.PureComponent<IStashDiffViewerProps>
onChangeImageDiffType,
onOpenSubmodule,
} = this.props
const files =
stashEntry.files.kind === StashedChangesLoadStates.Loaded
? stashEntry.files.files
: new Array<CommittedFileChange>()
const files = this.getFiles()
const diffComponent =
selectedStashedFile !== null ? (
@ -133,6 +149,7 @@ export class StashDiffViewer extends React.PureComponent<IStashDiffViewerProps>
onSelectedFileChanged={this.onSelectedFileChanged}
selectedFile={selectedStashedFile}
availableWidth={availableWidth}
onRowDoubleClick={this.onRowDoubleClick}
/>
</Resizable>
{diffComponent}

View file

@ -64,23 +64,12 @@ interface IBranchDropdownProps {
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
readonly emoji: Map<string, string>
}
interface IBranchDropdownState {
readonly badgeBottom: number
}
/**
* A drop down for selecting the currently checked out branch.
*/
export class BranchDropdown extends React.Component<
IBranchDropdownProps,
IBranchDropdownState
> {
public constructor(props: IBranchDropdownProps) {
super(props)
this.state = {
badgeBottom: 0,
}
}
export class BranchDropdown extends React.Component<IBranchDropdownProps> {
private badgeRef: HTMLElement | null = null
private renderBranchFoldout = (): JSX.Element | null => {
const repositoryState = this.props.repositoryState
@ -306,10 +295,6 @@ export class BranchDropdown extends React.Component<
this.openPopover()
}
private updateBadgeBottomPosition = (badgeBottom: number) => {
this.setState({ badgeBottom })
}
private openPopover = () => {
this.props.dispatcher.setShowCIStatusPopover(true)
}
@ -361,12 +346,16 @@ export class BranchDropdown extends React.Component<
dispatcher={this.props.dispatcher}
repository={pr.base.gitHubRepository}
branchName={currentBranchName}
badgeBottom={this.state.badgeBottom}
anchor={this.badgeRef}
closePopover={this.closePopover}
/>
)
}
private onBadgeRef = (ref: HTMLSpanElement | null) => {
this.badgeRef = ref
}
private renderPullRequestInfo() {
const pr = this.props.currentPullRequest
@ -379,8 +368,8 @@ export class BranchDropdown extends React.Component<
number={pr.pullRequestNumber}
dispatcher={this.props.dispatcher}
repository={pr.base.gitHubRepository}
onBadgeRef={this.onBadgeRef}
onBadgeClick={this.onBadgeClick}
onBadgeBottomPositionUpdate={this.updateBadgeBottomPosition}
/>
)
}

View file

@ -111,6 +111,13 @@ export interface IToolbarButtonProps {
readonly ariaExpanded?: boolean
readonly ariaHaspopup?: AriaHasPopupType
/**
* Typically the contents of a button serve the purpose of describing the
* buttons use. However, ariaLabel can be used if the contents do not suffice.
* Such as when a button wraps an image and there is no text.
*/
readonly ariaLabel?: string
/**
* Whether to only show the tooltip when the tooltip target overflows its
* bounds. Typically this is used in conjunction with an ellipsis CSS ruleset.
@ -224,6 +231,7 @@ export class ToolbarButton extends React.Component<IToolbarButtonProps, {}> {
role={this.props.role}
ariaExpanded={this.props.ariaExpanded}
ariaHaspopup={this.props.ariaHaspopup}
ariaLabel={this.props.ariaLabel}
>
{progress}
{icon}

View file

@ -195,6 +195,13 @@ export interface IToolbarDropdownProps {
* the tooltip.
*/
readonly isOverflowed?: ((target: TooltipTarget) => boolean) | boolean
/**
* Typically the contents of a button serve the purpose of describing the
* buttons use. However, ariaLabel can be used if the contents do not suffice.
* Such as when a button wraps an image and there is no text.
*/
readonly ariaLabel?: string
}
interface IToolbarDropdownState {
@ -256,6 +263,9 @@ export class ToolbarDropdown extends React.Component<
<ToolbarButton
className="toolbar-dropdown-arrow-button"
onClick={this.onToggleDropdownClick}
ariaExpanded={this.isOpen}
ariaHaspopup={true}
ariaLabel={this.props.ariaLabel}
>
{dropdownIcon}
</ToolbarButton>
@ -418,14 +428,11 @@ export class ToolbarDropdown extends React.Component<
this.props.className
)
const ariaExpanded = this.props.dropdownState === 'open' ? 'true' : 'false'
return (
<div
className={className}
onKeyDown={this.props.onKeyDown}
role={this.props.role}
aria-expanded={ariaExpanded}
onDragOver={this.props.onDragOver}
ref={this.rootDiv}
>
@ -450,6 +457,11 @@ export class ToolbarDropdown extends React.Component<
this.props.onlyShowTooltipWhenOverflowed
}
isOverflowed={this.props.isOverflowed}
ariaExpanded={
this.props.dropdownStyle === ToolbarDropdownStyle.MultiOption
? undefined
: this.isOpen
}
ariaHaspopup={this.props.buttonAriaHaspopup}
>
{this.props.children}

View file

@ -179,6 +179,7 @@ export class PushPullButton extends React.Component<IPushPullButtonProps> {
buttonClassName: 'push-pull-button',
style: ToolbarButtonStyle.Subtitle,
dropdownStyle: ToolbarDropdownStyle.MultiOption,
ariaLabel: 'Push, pull, fetch options',
dropdownState: this.props.isDropdownOpen ? 'open' : 'closed',
onDropdownStateChanged: this.props.onDropdownStateChanged,
}

View file

@ -269,7 +269,7 @@ export class TutorialPanel extends React.Component<
private onPreferencesClick = () => {
this.props.dispatcher.showPopup({
type: PopupType.Preferences,
initialSelectedTab: PreferencesTab.Advanced,
initialSelectedTab: PreferencesTab.Integrations,
})
}
}

View file

@ -14,7 +14,7 @@ interface IConfigureGitProps {
export class ConfigureGit extends React.Component<IConfigureGitProps, {}> {
public render() {
return (
<div id="configure-git">
<section id="configure-git" aria-label="Configure Git">
<h1 className="welcome-title">Configure Git</h1>
<p className="welcome-text">
This is used to identify the commits you create. Anyone will be able
@ -28,7 +28,7 @@ export class ConfigureGit extends React.Component<IConfigureGitProps, {}> {
>
<Button onClick={this.cancel}>Cancel</Button>
</ConfigureGitUser>
</div>
</section>
)
}

View file

@ -24,13 +24,16 @@ export class SignInEnterprise extends React.Component<
}
return (
<div id="sign-in-enterprise">
<section
id="sign-in-enterprise"
aria-label="Sign in to your GitHub Enterprise"
>
<h1 className="welcome-title">Sign in to your GitHub Enterprise</h1>
<SignIn signInState={state} dispatcher={this.props.dispatcher}>
<Button onClick={this.cancel}>Cancel</Button>
</SignIn>
</div>
</section>
)
}

View file

@ -26,24 +26,19 @@ interface IStartProps {
export class Start extends React.Component<IStartProps, {}> {
public render() {
return (
<div id="start">
<section
id="start"
aria-label="Welcome to GitHub Desktop"
aria-describedby="start-description"
>
<h1 className="welcome-title">Welcome to GitHub&nbsp;Desktop</h1>
{!this.props.loadingBrowserAuth ? (
<>
<p className="welcome-text">
<p id="start-description" className="welcome-text">
GitHub Desktop is a seamless way to contribute to projects on
GitHub and GitHub Enterprise. Sign in below to get started with
your existing projects.
</p>
<p className="welcome-text">
New to GitHub?{' '}
<LinkButton
uri={CreateAccountURL}
className="create-account-link"
>
Create your free account.
</LinkButton>
</p>
</>
) : (
<p>{BrowserRedirectMessage}</p>
@ -55,6 +50,7 @@ export class Start extends React.Component<IStartProps, {}> {
className="button-with-icon"
disabled={this.props.loadingBrowserAuth}
onClick={this.signInWithBrowser}
autoFocus={true}
>
{this.props.loadingBrowserAuth && <Loading />}
Sign in to GitHub.com
@ -69,6 +65,12 @@ export class Start extends React.Component<IStartProps, {}> {
)}
</div>
<div className="skip-action-container">
<p className="welcome-text">
New to GitHub?{' '}
<LinkButton uri={CreateAccountURL} className="create-account-link">
Create your free account.
</LinkButton>
</p>
<LinkButton className="skip-button" onClick={this.skip}>
Skip this step
</LinkButton>
@ -90,7 +92,7 @@ export class Start extends React.Component<IStartProps, {}> {
Learn more about user metrics.
</LinkButton>
</div>
</div>
</section>
)
}

View file

@ -1,6 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>GitHub Desktop</title>
<meta charset="UTF-8" />
</head>
<body>

View file

@ -38,6 +38,7 @@
@import 'ui/configure-git-user';
@import 'ui/form';
@import 'ui/text-box';
@import 'ui/password-text-box';
@import 'ui/ref-name-text-box';
@import 'ui/radio-button';
@import 'ui/button';

View file

@ -12,8 +12,6 @@
.autocompletion-popup {
display: flex;
position: fixed;
z-index: var(--popup-z-index);
width: 250px;
border-radius: var(--border-radius);

View file

@ -1,6 +1,8 @@
.commit-message-avatar-component {
// With this, the popover's absolute position will be relative to its parent
position: relative;
width: var(--text-field-height);
height: var(--text-field-height);
.avatar-button {
// override default button styles
@ -16,7 +18,12 @@
background-color: none;
border-radius: 50%;
margin-right: var(--spacing-half);
.avatar {
flex-shrink: 0;
width: 100%;
height: 100%;
}
}
.toggletip {
@ -48,12 +55,6 @@
align-items: center;
}
h3 {
display: flex;
flex-direction: row;
align-items: center;
}
section + section {
margin-top: var(--spacing);
}
@ -65,6 +66,11 @@
.button-row {
justify-content: flex-end;
button {
min-width: 120px;
margin-right: var(--spacing-half);
}
}
.secondary-text {
@ -83,31 +89,7 @@
margin-bottom: var(--spacing);
}
position: fixed;
width: 300px;
margin-left: 5px;
margin-top: 5px;
@media (max-height: 500px) {
bottom: 5px;
top: unset !important;
&.popover-caret-left-bottom {
&::before,
&::after {
bottom: 138px;
}
}
}
@media (max-height: 330px) {
&.popover-caret-left-bottom {
&::before,
&::after {
bottom: 118px;
}
}
}
}
.link-button-component {

View file

@ -34,4 +34,9 @@
border-bottom: none;
}
}
.git-config-loading {
color: var(--text-secondary-color);
height: 107px; // Height of name/email input to prevent jumping
}
}

View file

@ -407,7 +407,7 @@ dialog {
width: 600px;
.dialog-content {
min-height: 340px;
min-height: 375px;
}
// This is to ensure that the dialog content is accessible when zoomed in

View file

@ -66,11 +66,6 @@
}
.popover-component {
position: absolute;
left: 28px;
margin-left: -250px;
top: 27px;
width: 250px;
}
}

View file

@ -104,7 +104,6 @@
}
.popover-component.whitespace-hint {
position: absolute;
width: 225px;
}
}

View file

@ -24,6 +24,7 @@
display: flex;
flex-shrink: 0;
flex-grow: 1;
height: 0;
& > .content-pane {
display: flex;
@ -43,13 +44,12 @@
}
}
ul.button-group {
list-style-type: none;
.button-group {
margin: 0;
padding: 0;
flex-grow: 1;
li {
span {
margin-bottom: var(--spacing);
width: 100%;

View file

@ -0,0 +1,36 @@
.password-text-box {
position: relative;
margin-bottom: 0;
display: flex;
flex-grow: 1;
.text-box-component {
input {
padding-right: var(--spacing-triple);
}
margin-bottom: 0 !important;
}
button {
position: absolute;
bottom: 0;
right: 0;
width: 16px;
margin-right: 0 !important;
height: var(--text-field-height);
display: flex;
justify-content: center;
align-items: center;
padding: 0 15px !important;
color: var(--text-secondary-color);
// Removing the default button styles
overflow: inherit;
text-overflow: inherit;
white-space: inherit;
font-family: inherit;
font-size: inherit;
border: none;
background-color: inherit !important;
}
}

View file

@ -12,11 +12,18 @@
}
.popover-dropdown-popover {
position: absolute;
min-height: 200px;
width: 365px;
padding: 0;
margin-top: 25px;
.popover-content {
padding: 0;
}
.popover-dropdown-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.popover-dropdown-header {
padding: var(--spacing);
@ -31,6 +38,8 @@
.popover-dropdown-content {
display: flex;
flex-grow: 1;
min-height: 0;
}
}
}

View file

@ -2,31 +2,16 @@
font-size: var(--font-size);
font-family: var(--font-family-sans-serif);
z-index: var(--foldout-z-index);
background: var(--background-color);
color: var(--text-color);
border-radius: var(--border-radius);
border: var(--base-border);
padding: var(--spacing-double);
box-shadow: var(--base-box-shadow);
&::before,
&::after {
position: absolute;
display: inline-block;
content: '';
pointer-events: none;
}
&::before {
border: 8px solid transparent;
}
&::after {
border: 7px solid transparent;
.popover-content {
padding: var(--spacing-double);
border-radius: var(--border-radius);
}
& > p:first-of-type,
@ -87,130 +72,17 @@
}
}
// Carets
.popover-component.popover-caret-top-right {
&::before,
&::after {
right: 20px;
.popover-tip {
* {
border-style: solid;
border-color: transparent;
}
&::before {
top: -16px;
margin-right: -9px;
border-bottom-color: var(--box-border-color);
}
&::after {
top: -14px;
margin-right: -8px;
border-bottom-color: var(--background-color);
}
}
.popover-component.popover-caret-top-left {
&::before,
&::after {
left: 20px;
}
&::before {
top: -16px;
margin-right: -9px;
border-bottom-color: var(--box-border-color);
}
&::after {
top: -14px;
margin-right: -8px;
border-bottom-color: var(--background-color);
}
}
.popover-component.popover-caret-top {
&::before,
&::after {
position: absolute;
left: 50%;
display: inline-block;
content: '';
pointer-events: none;
}
&::before {
top: -16px;
margin-right: -9px;
border: 8px solid transparent;
border-bottom-color: var(--box-border-color);
}
&::after {
top: -14px;
margin-right: -8px;
border: 7px solid transparent;
border-bottom-color: var(--background-color);
}
}
.popover-component.popover-caret-left-top {
&::before,
&::after {
top: 20px;
}
&::before {
left: -16px;
margin-top: -9px;
border-right-color: var(--box-border-color);
}
&::after {
left: -14px;
margin-top: -8px;
.popover-tip-background {
border-right-color: var(--background-color);
}
}
.popover-component.popover-caret-left-bottom {
&::before,
&::after {
bottom: 20px;
}
&::before {
left: -16px;
margin-bottom: -9px;
.popover-tip-border {
border-right-color: var(--box-border-color);
}
&::after {
left: -14px;
margin-bottom: -8px;
border-right-color: var(--background-color);
}
}
.popover-component.popover-caret-right-top {
&::before,
&::after {
top: 20px;
}
&::before {
right: -16px;
margin-top: -9px;
border-left-color: var(--box-border-color);
}
&::after {
right: -14px;
margin-top: -8px;
border-left-color: var(--background-color);
}
}
.popover-component.popover-caret-none {
&::before,
&::after {
display: none;
}
}

View file

@ -63,7 +63,6 @@
.row {
.popover-component.whitespace-hint {
position: fixed;
width: 275px;
}
}

View file

@ -32,6 +32,10 @@
-webkit-mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/></svg>');
-webkit-mask-repeat: no-repeat;
}
&:read-only {
@include textboxish-disabled-styles;
}
}
&:not(.no-invalid-state) :not(:focus):invalid {

View file

@ -16,6 +16,7 @@
position: relative;
display: flex;
flex-direction: row;
column-gap: var(--spacing-half);
.summary-field {
flex: 1;
@ -26,12 +27,6 @@
}
}
.avatar {
flex-shrink: 0;
width: var(--text-field-height);
height: var(--text-field-height);
}
&.with-length-hint input {
padding-right: 20px;
}

View file

@ -1,14 +1,20 @@
.ci-check-list-popover {
.popover-component {
position: absolute;
padding: 0px;
width: 440px;
top: 60;
margin-left: -302px;
&::after {
border-bottom-color: var(--box-alt-background-color);
.popover-content {
padding: 0px;
}
.popover-tip-background {
border-right-color: var(--box-alt-background-color);
}
}
.ci-check-run-list-wrapper {
display: flex;
flex-direction: column;
max-height: inherit;
}
.ci-check-run-list-header {

View file

@ -1,6 +1,10 @@
#repository-settings {
width: 600px;
.dialog-content {
min-height: 305px;
}
.no-remote {
justify-content: space-between;
@ -37,4 +41,8 @@
display: inline;
}
}
textarea.gitignore {
height: 130px;
}
}

View file

@ -6,27 +6,6 @@
flex: 1;
min-height: 0;
.popover-component {
position: absolute;
left: 100%;
top: 73px;
width: 284px;
.call-to-action-bubble {
font-weight: var(--font-weight-semibold);
display: inline-block;
font-size: var(--font-size-xs);
border: 1px solid var(--call-to-action-bubble-border-color);
color: var(--call-to-action-bubble-color);
padding: 1px 5px;
border-radius: var(--border-radius);
margin-left: var(--spacing-third);
}
img {
width: 100%;
}
}
.draggable,
.commit {
width: 100%;

View file

@ -155,7 +155,7 @@ body > .tooltip,
flex-direction: row;
padding: var(--spacing-half) 0;
img {
.avatar {
width: 32px;
height: 32px;
margin-right: var(--spacing);

View file

@ -96,4 +96,34 @@ describe('URL remote parsing', () => {
expect(remote!.owner).toBe('hubot')
expect(remote!.name).toBe('repo')
})
it('does not parse invalid HTTP URLs when missing repo name', () => {
const remote = parseRemote('https://github.com/someuser//')
expect(remote).toBeNull()
})
it('does not parse invalid SSH URLs when missing repo name ', () => {
const remote = parseRemote('git@github.com:hubot/')
expect(remote).toBeNull()
})
it('does not parse invalid git URLs when missing repo name', () => {
const remote = parseRemote('git:github.com/hubot/')
expect(remote).toBeNull()
})
it('does not parse invalid HTTP URLs when missing repo owner', () => {
const remote = parseRemote('https://github.com//somerepo')
expect(remote).toBeNull()
})
it('does not parse invalid SSH URLs when missing repo owner', () => {
const remote = parseRemote('git@github.com:/somerepo')
expect(remote).toBeNull()
})
it('does not parse invalid git URLs when missing repo owner', () => {
const remote = parseRemote('git:github.com/hubot/')
expect(remote).toBeNull()
})
})

View file

@ -30,6 +30,25 @@
enabled "2.0.x"
kuler "^2.0.0"
"@floating-ui/core@^1.2.6":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.6.tgz#d21ace437cc919cdd8f1640302fa8851e65e75c0"
integrity sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==
"@floating-ui/dom@^1.2.7":
version "1.2.7"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.7.tgz#c123e4db014b07b97e996cd459245fa217049c6b"
integrity sha512-DyqylONj1ZaBnzj+uBnVfzdjjCkFCL2aA9ESHLyUOGSqb03RpbLMImP1ekIQXYs4KLk9jAjJfZAU8hXfWSahEg==
dependencies:
"@floating-ui/core" "^1.2.6"
"@floating-ui/react-dom@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.0.tgz#7514baac526c818892bbcc84e1c3115008c029f9"
integrity sha512-Ke0oU3SeuABC2C4OFu2mSAwHIP5WUiV98O9YWoHV4Q5aT6E9k06DV0Khi5uYspR8xmmBk08t8ZDcz3TR3ARkEg==
dependencies:
"@floating-ui/dom" "^1.2.7"
"@github/alive-client@^0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@github/alive-client/-/alive-client-0.0.2.tgz#77ccf103483f87930ca25ee8d69c4ab7b86be53f"

View file

@ -7,6 +7,28 @@
"[Fixed] Multi-commit diffing produces the same results whether you select up to down or down to up - #15883",
"[Removed] Remove support for Windows 7, 8, and 8.1 - #16566"
],
"3.2.4-beta1": [
"[Fixed] Entering in double forward slash does not target directory in cloning dialog - #15842. Thanks @IgnazioGul!",
"[Fixed] In the \"No Repositories\" screen, controls at the bottom stay inside window when it is resized - #16502. Thanks @samuelko123!",
"[Fixed] Link to editor settings on the tutorial screen - #16636. Thanks @IgnazioGul!",
"[Fixed] Close Squash Commit Message dialog on squash start - #16605",
"[Fixed] Multi-commit diffing produces the same results whether you select up to down or down to up - #15883",
"[Fixed] The misattributed commit avatar popover no longer causes the changes list to have scrollbars - #16684",
"[Fixed] Autocompletion list is always visible regardless of its position on the screen - #16650 #16609",
"[Improved] Improve screen reader support of the \"Create Alias\" dialog - #16802",
"[Improved] Screen readers announce the number of results in filtered lists (like repositories, branches or pull requests) - #16779",
"[Improved] Screen readers announce expanded/collapsed state of dropdowns - #16781",
"[Improved] The context menu for a branches list items can be invoked by keyboard shortcuts - #16760",
"[Improved] The context menu for a repository list item can be invoked by keyboard shortcuts - #16758",
"[Improved] Make floating elements more responsive as the window or the UI are resized - #16717",
"[Improved] Adds committing avatar popover to see git configuration and ability to open git configuration settings - #16640",
"[Improved] Password inputs have a visibility toggle. - #16714",
"[Improved] Welcome flow screen change in context is announced - #16698",
"[Improved] Focus the sign in with browser button on opening the enterprise server login screen - #16706",
"[Improved] Show the remote branch name if it does not match the local branch name - #13591. Thanks @samuelko123!",
"[Improved] Reduce retries of avatars that fail to load - #16592",
"[Removed] Remove support for Windows 7, 8, and 8.1 - #16566"
],
"3.2.3": [
"[New] Add fetch and force-push actions in a dropdown as an alternative to the main Pull/Push/Publish action button - #15907",
"[New] Get notified when someone comments your pull requests - #16226",