mirror of
https://github.com/desktop/desktop
synced 2024-08-28 12:40:55 +00:00
Merge branch 'development' into reuse-workflow
This commit is contained in:
commit
9a6d45122f
|
@ -96,3 +96,7 @@ export function enablePullRequestQuickView(): boolean {
|
|||
export function enableMoveStash(): boolean {
|
||||
return enableBetaFeatures()
|
||||
}
|
||||
|
||||
export function enableSectionList(): boolean {
|
||||
return enableDevelopmentFeatures()
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { RowIndexPath } from '../ui/lib/list/list-row-index-path'
|
||||
import { Commit } from './commit'
|
||||
import { GitHubRepository } from './github-repository'
|
||||
|
||||
|
@ -51,7 +52,7 @@ export type CommitTarget = {
|
|||
export type ListInsertionPointTarget = {
|
||||
type: DropTargetType.ListInsertionPoint
|
||||
data: DragData
|
||||
index: number
|
||||
index: RowIndexPath
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -22,6 +22,8 @@ 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'
|
||||
import { enableSectionList } from '../../lib/feature-flag'
|
||||
import { SectionFilterList } from '../lib/section-filter-list'
|
||||
|
||||
const RowHeight = 30
|
||||
|
||||
|
@ -168,7 +170,10 @@ export class BranchList extends React.Component<
|
|||
IBranchListProps,
|
||||
IBranchListState
|
||||
> {
|
||||
private branchFilterList: FilterList<IBranchListItem> | null = null
|
||||
private branchFilterList:
|
||||
| FilterList<IBranchListItem>
|
||||
| SectionFilterList<IBranchListItem>
|
||||
| null = null
|
||||
|
||||
public constructor(props: IBranchListProps) {
|
||||
super(props)
|
||||
|
@ -186,7 +191,32 @@ export class BranchList extends React.Component<
|
|||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
return enableSectionList() ? (
|
||||
<SectionFilterList<IBranchListItem>
|
||||
ref={this.onBranchesFilterListRef}
|
||||
className="branches-list"
|
||||
rowHeight={RowHeight}
|
||||
filterText={this.props.filterText}
|
||||
onFilterTextChanged={this.props.onFilterTextChanged}
|
||||
onFilterKeyDown={this.props.onFilterKeyDown}
|
||||
selectedItem={this.state.selectedItem}
|
||||
renderItem={this.renderItem}
|
||||
renderGroupHeader={this.renderGroupHeader}
|
||||
onItemClick={this.onItemClick}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
onEnterPressedWithoutFilteredItems={this.onCreateNewBranch}
|
||||
groups={this.state.groups}
|
||||
invalidationProps={this.props.allBranches}
|
||||
renderPostFilter={this.onRenderNewButton}
|
||||
renderNoItems={this.onRenderNoItems}
|
||||
filterTextBox={this.props.textbox}
|
||||
hideFilterRow={this.props.hideFilterRow}
|
||||
onFilterListResultsChanged={this.props.onFilterListResultsChanged}
|
||||
renderPreList={this.props.renderPreList}
|
||||
onItemContextMenu={this.onBranchContextMenu}
|
||||
getGroupAriaLabel={this.getGroupAriaLabel}
|
||||
/>
|
||||
) : (
|
||||
<FilterList<IBranchListItem>
|
||||
ref={this.onBranchesFilterListRef}
|
||||
className="branches-list"
|
||||
|
@ -237,7 +267,10 @@ export class BranchList extends React.Component<
|
|||
}
|
||||
|
||||
private onBranchesFilterListRef = (
|
||||
filterList: FilterList<IBranchListItem> | null
|
||||
filterList:
|
||||
| FilterList<IBranchListItem>
|
||||
| SectionFilterList<IBranchListItem>
|
||||
| null
|
||||
) => {
|
||||
this.branchFilterList = filterList
|
||||
}
|
||||
|
@ -257,6 +290,15 @@ export class BranchList extends React.Component<
|
|||
}
|
||||
}
|
||||
|
||||
private getGroupAriaLabel = (group: number) => {
|
||||
const GroupIdentifiers: ReadonlyArray<BranchGroupIdentifier> = [
|
||||
'default',
|
||||
'recent',
|
||||
'other',
|
||||
]
|
||||
return this.getGroupLabel(GroupIdentifiers[group])
|
||||
}
|
||||
|
||||
private renderGroupHeader = (label: string) => {
|
||||
const identifier = this.parseHeader(label)
|
||||
|
||||
|
|
|
@ -15,6 +15,8 @@ import { HighlightText } from '../lib/highlight-text'
|
|||
import { ClickSource } from '../lib/list'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
import { Ref } from '../lib/ref'
|
||||
import { enableSectionList } from '../../lib/feature-flag'
|
||||
import { SectionFilterList } from '../lib/section-filter-list'
|
||||
|
||||
interface ICloneableRepositoryFilterListProps {
|
||||
/** The account to clone from. */
|
||||
|
@ -157,26 +159,34 @@ export class CloneableRepositoryFilterList extends React.PureComponent<ICloneabl
|
|||
const { repositories, account, selectedItem } = this.props
|
||||
|
||||
const groups = this.getRepositoryGroups(repositories, account.login)
|
||||
const selectedListItem = this.getSelectedListItem(groups, selectedItem)
|
||||
const getGroupAriaLabel = (group: number) => {
|
||||
const groupIdentifier = groups[group].identifier
|
||||
return groupIdentifier === YourRepositoriesIdentifier
|
||||
? this.getYourRepositoriesLabel()
|
||||
: groupIdentifier
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterList<ICloneableRepositoryListItem>
|
||||
className="clone-github-repo"
|
||||
rowHeight={RowHeight}
|
||||
selectedItem={selectedListItem}
|
||||
renderItem={this.renderItem}
|
||||
renderGroupHeader={this.renderGroupHeader}
|
||||
onSelectionChanged={this.onSelectionChanged}
|
||||
invalidationProps={groups}
|
||||
groups={groups}
|
||||
filterText={this.props.filterText}
|
||||
onFilterTextChanged={this.props.onFilterTextChanged}
|
||||
renderNoItems={this.renderNoItems}
|
||||
renderPostFilter={this.renderPostFilter}
|
||||
onItemClick={this.props.onItemClicked ? this.onItemClick : undefined}
|
||||
placeholderText="Filter your repositories"
|
||||
/>
|
||||
)
|
||||
const selectedListItem = this.getSelectedListItem(groups, selectedItem)
|
||||
const ListComponent = enableSectionList() ? SectionFilterList : FilterList
|
||||
const filterListProps: typeof ListComponent['prototype']['props'] = {
|
||||
className: 'clone-github-repo',
|
||||
rowHeight: RowHeight,
|
||||
selectedItem: selectedListItem,
|
||||
renderItem: this.renderItem,
|
||||
renderGroupHeader: this.renderGroupHeader,
|
||||
onSelectionChanged: this.onSelectionChanged,
|
||||
invalidationProps: groups,
|
||||
groups: groups,
|
||||
filterText: this.props.filterText,
|
||||
onFilterTextChanged: this.props.onFilterTextChanged,
|
||||
renderNoItems: this.renderNoItems,
|
||||
renderPostFilter: this.renderPostFilter,
|
||||
onItemClick: this.props.onItemClicked ? this.onItemClick : undefined,
|
||||
placeholderText: 'Filter your repositories',
|
||||
getGroupAriaLabel,
|
||||
}
|
||||
|
||||
return <ListComponent {...filterListProps} />
|
||||
}
|
||||
|
||||
private onItemClick = (
|
||||
|
@ -206,10 +216,14 @@ export class CloneableRepositoryFilterList extends React.PureComponent<ICloneabl
|
|||
}
|
||||
}
|
||||
|
||||
private getYourRepositoriesLabel = () => {
|
||||
return __DARWIN__ ? 'Your Repositories' : 'Your repositories'
|
||||
}
|
||||
|
||||
private renderGroupHeader = (identifier: string) => {
|
||||
let header = identifier
|
||||
if (identifier === YourRepositoriesIdentifier) {
|
||||
header = __DARWIN__ ? 'Your Repositories' : 'Your repositories'
|
||||
header = this.getYourRepositoriesLabel()
|
||||
}
|
||||
return (
|
||||
<div className="clone-repository-list-content clone-repository-list-group-header">
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Disposable } from 'event-kit'
|
|||
import * as React from 'react'
|
||||
import { dragAndDropManager } from '../../../lib/drag-and-drop-manager'
|
||||
import { DragData, DragType, DropTargetType } from '../../../models/drag-drop'
|
||||
import { RowIndexPath } from './list-row-index-path'
|
||||
|
||||
enum InsertionFeedbackType {
|
||||
None,
|
||||
|
@ -13,11 +14,11 @@ enum InsertionFeedbackType {
|
|||
|
||||
interface IListItemInsertionOverlayProps {
|
||||
readonly onDropDataInsertion?: (
|
||||
insertionIndex: number,
|
||||
insertionIndex: RowIndexPath,
|
||||
data: DragData
|
||||
) => void
|
||||
|
||||
readonly itemIndex: number
|
||||
readonly itemIndex: RowIndexPath
|
||||
readonly dragType: DragType
|
||||
}
|
||||
|
||||
|
@ -188,7 +189,10 @@ export class ListItemInsertionOverlay extends React.PureComponent<
|
|||
let index = this.props.itemIndex
|
||||
|
||||
if (this.state.feedbackType === InsertionFeedbackType.Bottom) {
|
||||
index++
|
||||
index = {
|
||||
...index,
|
||||
row: index.row + 1,
|
||||
}
|
||||
}
|
||||
this.props.onDropDataInsertion(index, dragAndDropManager.dragData)
|
||||
}
|
||||
|
|
92
app/src/ui/lib/list/list-row-index-path.ts
Normal file
92
app/src/ui/lib/list/list-row-index-path.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
export type RowIndexPath = {
|
||||
readonly row: number
|
||||
readonly section: number
|
||||
}
|
||||
|
||||
export const InvalidRowIndexPath: RowIndexPath = { section: -1, row: -1 }
|
||||
|
||||
export function rowIndexPathEquals(a: RowIndexPath, b: RowIndexPath): boolean {
|
||||
return a.section === b.section && a.row === b.row
|
||||
}
|
||||
|
||||
export function getTotalRowCount(rowCount: ReadonlyArray<number>) {
|
||||
return rowCount.reduce((sum, count) => sum + count, 0)
|
||||
}
|
||||
|
||||
export function rowIndexPathToGlobalIndex(
|
||||
indexPath: RowIndexPath,
|
||||
rowCount: ReadonlyArray<number>
|
||||
): number | null {
|
||||
if (!isValidRow(indexPath, rowCount)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let index = 0
|
||||
|
||||
for (let section = 0; section < indexPath.section; section++) {
|
||||
index += rowCount[section]
|
||||
}
|
||||
|
||||
index += indexPath.row
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
export function globalIndexToRowIndexPath(
|
||||
index: number,
|
||||
rowCount: ReadonlyArray<number>
|
||||
): RowIndexPath | null {
|
||||
if (index < 0 || index >= getTotalRowCount(rowCount)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let section = 0
|
||||
let row = index
|
||||
|
||||
while (row >= rowCount[section]) {
|
||||
row -= rowCount[section]
|
||||
section++
|
||||
}
|
||||
|
||||
return { section, row }
|
||||
}
|
||||
|
||||
export function isValidRow(
|
||||
indexPath: RowIndexPath,
|
||||
rowCount: ReadonlyArray<number>
|
||||
) {
|
||||
return (
|
||||
indexPath.section >= 0 &&
|
||||
indexPath.section < rowCount.length &&
|
||||
indexPath.row >= 0 &&
|
||||
indexPath.row < rowCount[indexPath.section]
|
||||
)
|
||||
}
|
||||
|
||||
export function getFirstRowIndexPath(
|
||||
rowCount: ReadonlyArray<number>
|
||||
): RowIndexPath | null {
|
||||
if (rowCount.length > 0) {
|
||||
for (let section = 0; section < rowCount.length; section++) {
|
||||
if (rowCount[section] > 0) {
|
||||
return { section, row: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getLastRowIndexPath(
|
||||
rowCount: ReadonlyArray<number>
|
||||
): RowIndexPath | null {
|
||||
if (rowCount.length > 0) {
|
||||
for (let section = rowCount.length - 1; section >= 0; section--) {
|
||||
if (rowCount[section] > 0) {
|
||||
return { section, row: rowCount[section] - 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
|
@ -1,12 +1,16 @@
|
|||
import * as React from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { RowIndexPath } from './list-row-index-path'
|
||||
|
||||
interface IListRowProps {
|
||||
/** whether or not the section to which this row belongs has a header */
|
||||
readonly sectionHasHeader: boolean
|
||||
|
||||
/** the total number of row in this list */
|
||||
readonly rowCount: number
|
||||
|
||||
/** the index of the row in the list */
|
||||
readonly rowIndex: number
|
||||
readonly rowIndex: RowIndexPath
|
||||
|
||||
/** custom styles to provide to the row */
|
||||
readonly style?: React.CSSProperties
|
||||
|
@ -21,39 +25,51 @@ interface IListRowProps {
|
|||
readonly selected?: boolean
|
||||
|
||||
/** callback to fire when the DOM element is created */
|
||||
readonly onRowRef?: (index: number, element: HTMLDivElement | null) => void
|
||||
readonly onRowRef?: (
|
||||
index: RowIndexPath,
|
||||
element: HTMLDivElement | null
|
||||
) => void
|
||||
|
||||
/** callback to fire when the row receives a mousedown event */
|
||||
readonly onRowMouseDown: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowMouseDown: (
|
||||
index: RowIndexPath,
|
||||
e: React.MouseEvent<any>
|
||||
) => void
|
||||
|
||||
/** callback to fire when the row receives a mouseup event */
|
||||
readonly onRowMouseUp: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowMouseUp: (index: RowIndexPath, e: React.MouseEvent<any>) => void
|
||||
|
||||
/** callback to fire when the row is clicked */
|
||||
readonly onRowClick: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowClick: (index: RowIndexPath, e: React.MouseEvent<any>) => void
|
||||
|
||||
/** callback to fire when the row is double clicked */
|
||||
readonly onRowDoubleClick: (index: number, e: React.MouseEvent<any>) => void
|
||||
readonly onRowDoubleClick: (
|
||||
index: RowIndexPath,
|
||||
e: React.MouseEvent<any>
|
||||
) => void
|
||||
|
||||
/** callback to fire when the row receives a keyboard event */
|
||||
readonly onRowKeyDown: (index: number, e: React.KeyboardEvent<any>) => void
|
||||
readonly onRowKeyDown: (
|
||||
index: RowIndexPath,
|
||||
e: React.KeyboardEvent<any>
|
||||
) => void
|
||||
|
||||
/** called when the row (or any of its descendants) receives focus */
|
||||
readonly onRowFocus?: (
|
||||
index: number,
|
||||
index: RowIndexPath,
|
||||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
/** called when the row (and all of its descendants) loses focus */
|
||||
readonly onRowBlur?: (
|
||||
index: number,
|
||||
index: RowIndexPath,
|
||||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
/** Called back for when the context menu is invoked (user right clicks of
|
||||
* uses keyboard shortcuts) */
|
||||
readonly onContextMenu?: (
|
||||
index: number,
|
||||
index: RowIndexPath,
|
||||
e: React.MouseEvent<HTMLDivElement>
|
||||
) => void
|
||||
|
||||
|
@ -106,12 +122,23 @@ export class ListRow extends React.Component<IListRowProps, {}> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
const selected = this.props.selected
|
||||
const className = classNames(
|
||||
const {
|
||||
selected,
|
||||
selectable,
|
||||
className,
|
||||
style,
|
||||
rowCount,
|
||||
id,
|
||||
tabIndex,
|
||||
rowIndex,
|
||||
children,
|
||||
sectionHasHeader,
|
||||
} = this.props
|
||||
const rowClassName = classNames(
|
||||
'list-item',
|
||||
{ selected },
|
||||
{ 'not-selectable': this.props.selectable === false },
|
||||
this.props.className
|
||||
{ 'not-selectable': selectable === false },
|
||||
className
|
||||
)
|
||||
// react-virtualized gives us an explicit pixel width for rows, but that
|
||||
// width doesn't take into account whether or not the scroll bar needs
|
||||
|
@ -120,29 +147,43 @@ export class ListRow extends React.Component<IListRowProps, {}> {
|
|||
// *But* the parent Grid uses `autoContainerWidth` which means its width
|
||||
// *does* reflect any width needed by the scroll bar. So we should just use
|
||||
// that width.
|
||||
const style = { ...this.props.style, width: '100%' }
|
||||
const fullWidthStyle = { ...style, width: '100%' }
|
||||
|
||||
let ariaSetSize: number | undefined = rowCount
|
||||
let ariaPosInSet: number | undefined = rowIndex.row + 1
|
||||
if (sectionHasHeader) {
|
||||
if (rowIndex.row === 0) {
|
||||
ariaSetSize = undefined
|
||||
ariaPosInSet = undefined
|
||||
} else {
|
||||
ariaSetSize -= 1
|
||||
ariaPosInSet -= 1
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={this.props.id}
|
||||
role="option"
|
||||
aria-setsize={this.props.rowCount}
|
||||
aria-posinset={this.props.rowIndex + 1}
|
||||
aria-selected={this.props.selectable ? this.props.selected : undefined}
|
||||
className={className}
|
||||
tabIndex={this.props.tabIndex}
|
||||
id={id}
|
||||
role={
|
||||
sectionHasHeader && rowIndex.row === 0 ? 'presentation' : 'option'
|
||||
}
|
||||
aria-setsize={ariaSetSize}
|
||||
aria-posinset={ariaPosInSet}
|
||||
aria-selected={selectable ? selected : undefined}
|
||||
className={rowClassName}
|
||||
tabIndex={tabIndex}
|
||||
ref={this.onRef}
|
||||
onMouseDown={this.onRowMouseDown}
|
||||
onMouseUp={this.onRowMouseUp}
|
||||
onClick={this.onRowClick}
|
||||
onDoubleClick={this.onRowDoubleClick}
|
||||
onKeyDown={this.onRowKeyDown}
|
||||
style={style}
|
||||
style={fullWidthStyle}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onContextMenu={this.onContextMenu}
|
||||
>
|
||||
{this.props.children}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import { range } from '../../../lib/range'
|
|||
import { ListItemInsertionOverlay } from './list-item-insertion-overlay'
|
||||
import { DragData, DragType } from '../../../models/drag-drop'
|
||||
import memoizeOne from 'memoize-one'
|
||||
import { RowIndexPath } from './list-row-index-path'
|
||||
import { sendNonFatalException } from '../../../lib/helpers/non-fatal-exception'
|
||||
|
||||
/**
|
||||
|
@ -580,11 +581,11 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
}
|
||||
|
||||
private onRowKeyDown = (
|
||||
rowIndex: number,
|
||||
indexPath: RowIndexPath,
|
||||
event: React.KeyboardEvent<any>
|
||||
) => {
|
||||
if (this.props.onRowKeyDown) {
|
||||
this.props.onRowKeyDown(rowIndex, event)
|
||||
this.props.onRowKeyDown(indexPath.row, event)
|
||||
}
|
||||
|
||||
const hasModifier =
|
||||
|
@ -645,21 +646,27 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
})
|
||||
}
|
||||
|
||||
private onRowFocus = (index: number, e: React.FocusEvent<HTMLDivElement>) => {
|
||||
this.focusRow = index
|
||||
private onRowFocus = (
|
||||
indexPath: RowIndexPath,
|
||||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => {
|
||||
this.focusRow = indexPath.row
|
||||
}
|
||||
|
||||
private onRowBlur = (index: number, e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (this.focusRow === index) {
|
||||
private onRowBlur = (
|
||||
indexPath: RowIndexPath,
|
||||
e: React.FocusEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (this.focusRow === indexPath.row) {
|
||||
this.focusRow = -1
|
||||
}
|
||||
}
|
||||
|
||||
private onRowContextMenu = (
|
||||
row: number,
|
||||
indexPath: RowIndexPath,
|
||||
e: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
this.props.onRowContextMenu?.(row, e)
|
||||
this.props.onRowContextMenu?.(indexPath.row, e)
|
||||
}
|
||||
|
||||
/** Convenience method for invoking canSelectRow callback when it exists */
|
||||
|
@ -867,14 +874,17 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
}
|
||||
}
|
||||
|
||||
private onRowRef = (rowIndex: number, element: HTMLDivElement | null) => {
|
||||
private onRowRef = (
|
||||
indexPath: RowIndexPath,
|
||||
element: HTMLDivElement | null
|
||||
) => {
|
||||
if (element === null) {
|
||||
this.rowRefs.delete(rowIndex)
|
||||
this.rowRefs.delete(indexPath.row)
|
||||
} else {
|
||||
this.rowRefs.set(rowIndex, element)
|
||||
this.rowRefs.set(indexPath.row, element)
|
||||
}
|
||||
|
||||
if (rowIndex === this.focusRow) {
|
||||
if (indexPath.row === this.focusRow) {
|
||||
// The currently focused row is going being unmounted so we'll move focus
|
||||
// programmatically to the grid so that keyboard navigation still works
|
||||
if (element === null) {
|
||||
|
@ -925,8 +935,8 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
const element =
|
||||
this.props.insertionDragType !== undefined ? (
|
||||
<ListItemInsertionOverlay
|
||||
onDropDataInsertion={this.props.onDropDataInsertion}
|
||||
itemIndex={rowIndex}
|
||||
onDropDataInsertion={this.onDropDataInsertion}
|
||||
itemIndex={{ section: 0, row: rowIndex }}
|
||||
dragType={this.props.insertionDragType}
|
||||
>
|
||||
{row}
|
||||
|
@ -943,7 +953,8 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
id={id}
|
||||
onRowRef={this.onRowRef}
|
||||
rowCount={this.props.rowCount}
|
||||
rowIndex={rowIndex}
|
||||
rowIndex={{ section: 0, row: rowIndex }}
|
||||
sectionHasHeader={false}
|
||||
selected={selected}
|
||||
onRowClick={this.onRowClick}
|
||||
onRowDoubleClick={this.onRowDoubleClick}
|
||||
|
@ -1144,7 +1155,12 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
}
|
||||
}
|
||||
|
||||
private onRowMouseDown = (row: number, event: React.MouseEvent<any>) => {
|
||||
private onRowMouseDown = (
|
||||
indexPath: RowIndexPath,
|
||||
event: React.MouseEvent<any>
|
||||
) => {
|
||||
const { row } = indexPath
|
||||
|
||||
if (this.canSelectRow(row)) {
|
||||
if (this.props.onRowMouseDown) {
|
||||
this.props.onRowMouseDown(row, event)
|
||||
|
@ -1237,7 +1253,12 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
}
|
||||
}
|
||||
|
||||
private onRowMouseUp = (row: number, event: React.MouseEvent<any>) => {
|
||||
private onRowMouseUp = (
|
||||
indexPath: RowIndexPath,
|
||||
event: React.MouseEvent<any>
|
||||
) => {
|
||||
const { row } = indexPath
|
||||
|
||||
if (!this.canSelectRow(row)) {
|
||||
return
|
||||
}
|
||||
|
@ -1299,27 +1320,37 @@ export class List extends React.Component<IListProps, IListState> {
|
|||
}
|
||||
}
|
||||
|
||||
private onRowClick = (row: number, event: React.MouseEvent<any>) => {
|
||||
if (this.canSelectRow(row) && this.props.onRowClick) {
|
||||
private onDropDataInsertion = (indexPath: RowIndexPath, data: DragData) => {
|
||||
this.props.onDropDataInsertion?.(indexPath.row, data)
|
||||
}
|
||||
|
||||
private onRowClick = (
|
||||
indexPath: RowIndexPath,
|
||||
event: React.MouseEvent<any>
|
||||
) => {
|
||||
if (this.canSelectRow(indexPath.row) && this.props.onRowClick) {
|
||||
const rowCount = this.props.rowCount
|
||||
|
||||
if (row < 0 || row >= rowCount) {
|
||||
if (indexPath.row < 0 || indexPath.row >= rowCount) {
|
||||
log.debug(
|
||||
`[List.onRowClick] unable to onRowClick for row ${row} as it is outside the bounds of the array [0, ${rowCount}]`
|
||||
`[List.onRowClick] unable to onRowClick for row ${indexPath.row} as it is outside the bounds of the array [0, ${rowCount}]`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onRowClick(row, { kind: 'mouseclick', event })
|
||||
this.props.onRowClick(indexPath.row, { kind: 'mouseclick', event })
|
||||
}
|
||||
}
|
||||
|
||||
private onRowDoubleClick = (row: number, event: React.MouseEvent<any>) => {
|
||||
private onRowDoubleClick = (
|
||||
indexPath: RowIndexPath,
|
||||
event: React.MouseEvent<any>
|
||||
) => {
|
||||
if (!this.props.onRowDoubleClick) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onRowDoubleClick(row, { kind: 'mouseclick', event })
|
||||
this.props.onRowDoubleClick(indexPath.row, { kind: 'mouseclick', event })
|
||||
}
|
||||
|
||||
private onScroll = ({
|
||||
|
|
191
app/src/ui/lib/list/section-list-selection.ts
Normal file
191
app/src/ui/lib/list/section-list-selection.ts
Normal file
|
@ -0,0 +1,191 @@
|
|||
import * as React from 'react'
|
||||
import {
|
||||
getTotalRowCount,
|
||||
globalIndexToRowIndexPath,
|
||||
InvalidRowIndexPath,
|
||||
isValidRow,
|
||||
RowIndexPath,
|
||||
rowIndexPathEquals,
|
||||
rowIndexPathToGlobalIndex,
|
||||
} from './list-row-index-path'
|
||||
|
||||
export type SelectionDirection = 'up' | 'down'
|
||||
|
||||
interface ISelectRowAction {
|
||||
/**
|
||||
* The vertical direction use when searching for a selectable row.
|
||||
*/
|
||||
readonly direction: SelectionDirection
|
||||
|
||||
/**
|
||||
* The starting row index to search from.
|
||||
*/
|
||||
readonly row: RowIndexPath
|
||||
|
||||
/**
|
||||
* A flag to indicate or not to look beyond the last or first
|
||||
* row (depending on direction) such that given the last row and
|
||||
* a downward direction will consider the first row as a
|
||||
* candidate or given the first row and an upward direction
|
||||
* will consider the last row as a candidate.
|
||||
*
|
||||
* Defaults to true if not set.
|
||||
*/
|
||||
readonly wrap?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection change event
|
||||
* originating from a pointer device clicking or pressing on an item.
|
||||
*/
|
||||
export interface IMouseClickSource {
|
||||
readonly kind: 'mouseclick'
|
||||
readonly event: React.MouseEvent<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection change event
|
||||
* originating from a pointer device hovering over an item.
|
||||
* Only applicable when selectedOnHover is set.
|
||||
*/
|
||||
export interface IHoverSource {
|
||||
readonly kind: 'hover'
|
||||
readonly event: React.MouseEvent<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection change event
|
||||
* originating from a keyboard
|
||||
*/
|
||||
export interface IKeyboardSource {
|
||||
readonly kind: 'keyboard'
|
||||
readonly event: React.KeyboardEvent<any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface describing a user initiated selection of all list
|
||||
* items (usually by clicking the Edit > Select all menu item in
|
||||
* the application window). This is highly specific to GitHub Desktop
|
||||
*/
|
||||
export interface ISelectAllSource {
|
||||
readonly kind: 'select-all'
|
||||
}
|
||||
|
||||
/** A type union of possible sources of a selection changed event */
|
||||
export type SelectionSource =
|
||||
| IMouseClickSource
|
||||
| IHoverSource
|
||||
| IKeyboardSource
|
||||
| ISelectAllSource
|
||||
|
||||
/**
|
||||
* Determine the next selectable row, given the direction and a starting
|
||||
* row index. Whether a row is selectable or not is determined using
|
||||
* the `canSelectRow` function, which defaults to true if not provided.
|
||||
*
|
||||
* Returns null if no row can be selected or if the only selectable row is
|
||||
* identical to the given row parameter.
|
||||
*/
|
||||
export function findNextSelectableRow(
|
||||
rowCount: ReadonlyArray<number>,
|
||||
action: ISelectRowAction,
|
||||
canSelectRow: (indexPath: RowIndexPath) => boolean = row => true
|
||||
): RowIndexPath | null {
|
||||
const totalRowCount = getTotalRowCount(rowCount)
|
||||
if (totalRowCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { direction, row } = action
|
||||
const wrap = action.wrap === undefined ? true : action.wrap
|
||||
const rowIndex = rowIndexPathEquals(InvalidRowIndexPath, row)
|
||||
? -1
|
||||
: rowIndexPathToGlobalIndex(row, rowCount)
|
||||
|
||||
if (rowIndex === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Ensure the row value is in the range between 0 and rowCount - 1
|
||||
//
|
||||
// If the row falls outside this range, use the direction
|
||||
// given to choose a suitable value:
|
||||
//
|
||||
// - move in an upward direction -> select last row
|
||||
// - move in a downward direction -> select first row
|
||||
//
|
||||
let currentRow = isValidRow(row, rowCount)
|
||||
? rowIndex
|
||||
: direction === 'up'
|
||||
? totalRowCount - 1
|
||||
: 0
|
||||
|
||||
// handle specific case from switching from filter text to list
|
||||
//
|
||||
// locking currentRow to [0,rowCount) above means that the below loops
|
||||
// will skip over the first entry
|
||||
if (direction === 'down' && rowIndexPathEquals(row, InvalidRowIndexPath)) {
|
||||
currentRow = -1
|
||||
}
|
||||
|
||||
const delta = direction === 'up' ? -1 : 1
|
||||
|
||||
// Iterate through all rows (starting offset from the
|
||||
// given row and ending on and including the given row)
|
||||
for (let i = 0; i < totalRowCount; i++) {
|
||||
currentRow += delta
|
||||
|
||||
if (currentRow >= totalRowCount) {
|
||||
// We've hit rock bottom, wrap around to the top
|
||||
// if we're allowed to or give up.
|
||||
if (wrap) {
|
||||
currentRow = 0
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else if (currentRow < 0) {
|
||||
// We've reached the top, wrap around to the bottom
|
||||
// if we're allowed to or give up
|
||||
if (wrap) {
|
||||
currentRow = totalRowCount - 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const currentRowIndexPath = globalIndexToRowIndexPath(currentRow, rowCount)
|
||||
if (
|
||||
currentRowIndexPath !== null &&
|
||||
!rowIndexPathEquals(row, currentRowIndexPath) &&
|
||||
canSelectRow(currentRowIndexPath)
|
||||
) {
|
||||
return currentRowIndexPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last selectable row in either direction, used
|
||||
* for moving to the first or last selectable row in a list,
|
||||
* i.e. Home/End key navigation.
|
||||
*/
|
||||
export function findLastSelectableRow(
|
||||
direction: SelectionDirection,
|
||||
rowCount: ReadonlyArray<number>,
|
||||
canSelectRow: (indexPath: RowIndexPath) => boolean
|
||||
): RowIndexPath | null {
|
||||
const totalRowCount = getTotalRowCount(rowCount)
|
||||
let i = direction === 'up' ? 0 : totalRowCount - 1
|
||||
const delta = direction === 'up' ? 1 : -1
|
||||
|
||||
for (; i >= 0 && i < totalRowCount; i += delta) {
|
||||
const indexPath = globalIndexToRowIndexPath(i, rowCount)
|
||||
if (indexPath !== null && canSelectRow(indexPath)) {
|
||||
return indexPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
1681
app/src/ui/lib/list/section-list.tsx
Normal file
1681
app/src/ui/lib/list/section-list.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
@ -87,7 +87,7 @@ export function findNextSelectableRow(
|
|||
}
|
||||
|
||||
const { direction, row } = action
|
||||
const wrap = action.wrap === undefined ? true : action.wrap
|
||||
const wrap = action.wrap ?? true
|
||||
|
||||
// Ensure the row value is in the range between 0 and rowCount - 1
|
||||
//
|
||||
|
|
696
app/src/ui/lib/section-filter-list.tsx
Normal file
696
app/src/ui/lib/section-filter-list.tsx
Normal file
|
@ -0,0 +1,696 @@
|
|||
import * as React from 'react'
|
||||
import classnames from 'classnames'
|
||||
|
||||
import { SectionList, ClickSource } from '../lib/list/section-list'
|
||||
import {
|
||||
findNextSelectableRow,
|
||||
SelectionDirection,
|
||||
} from '../lib/list/section-list-selection'
|
||||
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'
|
||||
import {
|
||||
InvalidRowIndexPath,
|
||||
RowIndexPath,
|
||||
rowIndexPathEquals,
|
||||
} from './list/list-row-index-path'
|
||||
import {
|
||||
IFilterListGroup,
|
||||
IFilterListItem,
|
||||
SelectionSource,
|
||||
} from './filter-list'
|
||||
|
||||
interface IFlattenedGroup {
|
||||
readonly kind: 'group'
|
||||
readonly identifier: string
|
||||
}
|
||||
|
||||
interface IFlattenedItem<T extends IFilterListItem> {
|
||||
readonly kind: 'item'
|
||||
readonly item: T
|
||||
/** Array of indexes in `item.text` that should be highlighted */
|
||||
readonly matches: IMatches
|
||||
}
|
||||
|
||||
/**
|
||||
* A row in the list. This is used internally after the user-provided groups are
|
||||
* flattened.
|
||||
*/
|
||||
type IFilterListRow<T extends IFilterListItem> =
|
||||
| IFlattenedGroup
|
||||
| IFlattenedItem<T>
|
||||
|
||||
interface ISectionFilterListProps<T extends IFilterListItem> {
|
||||
/** A class name for the wrapping element. */
|
||||
readonly className?: string
|
||||
|
||||
/** The height of the rows. */
|
||||
readonly rowHeight: number
|
||||
|
||||
/** The ordered groups to display in the list. */
|
||||
readonly groups: ReadonlyArray<IFilterListGroup<T>>
|
||||
|
||||
/** The selected item. */
|
||||
readonly selectedItem: T | null
|
||||
|
||||
/** Called to render each visible item. */
|
||||
readonly renderItem: (item: T, matches: IMatches) => JSX.Element | null
|
||||
|
||||
/** Called to render header for the group with the given identifier. */
|
||||
readonly renderGroupHeader?: (identifier: string) => JSX.Element | null
|
||||
|
||||
/** Called to render content before/above the filter and list. */
|
||||
readonly renderPreList?: () => JSX.Element | null
|
||||
|
||||
/**
|
||||
* This function will be called when a pointer device is pressed and then
|
||||
* released on a selectable row. Note that this follows the conventions
|
||||
* of button elements such that pressing Enter or Space on a keyboard
|
||||
* while focused on a particular row will also trigger this event. Consumers
|
||||
* can differentiate between the two using the source parameter.
|
||||
*
|
||||
* Note that this event handler will not be called for keyboard events
|
||||
* if `event.preventDefault()` was called in the onRowKeyDown event handler.
|
||||
*
|
||||
* Consumers of this event do _not_ have to call event.preventDefault,
|
||||
* when this event is subscribed to the list will automatically call it.
|
||||
*/
|
||||
readonly onItemClick?: (item: T, source: ClickSource) => void
|
||||
|
||||
/**
|
||||
* This function will be called when the selection changes as a result of a
|
||||
* user keyboard or mouse action (i.e. not when props change). This function
|
||||
* will not be invoked when an already selected row is clicked on.
|
||||
*
|
||||
* @param selectedItem - The item that was just selected
|
||||
* @param source - The kind of user action that provoked the change,
|
||||
* either a pointer device press, or a keyboard event
|
||||
* (arrow up/down)
|
||||
*/
|
||||
readonly onSelectionChanged?: (
|
||||
selectedItem: T | null,
|
||||
source: SelectionSource
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Called when a key down happens in the filter text input. Users have a
|
||||
* chance to respond or cancel the default behavior by calling
|
||||
* `preventDefault()`.
|
||||
*/
|
||||
readonly onFilterKeyDown?: (
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
) => void
|
||||
|
||||
/** Called when the Enter key is pressed in field of type search */
|
||||
readonly onEnterPressedWithoutFilteredItems?: (text: string) => void
|
||||
|
||||
/** Aria label for a specific group */
|
||||
readonly getGroupAriaLabel?: (group: number) => string | undefined
|
||||
|
||||
/** The current filter text to use in the form */
|
||||
readonly filterText?: string
|
||||
|
||||
/** Called when the filter text is changed by the user */
|
||||
readonly onFilterTextChanged?: (text: string) => void
|
||||
|
||||
/**
|
||||
* Whether or not the filter list should allow selection
|
||||
* and filtering. Defaults to false.
|
||||
*/
|
||||
readonly disabled?: boolean
|
||||
|
||||
/** Any props which should cause a re-render if they change. */
|
||||
readonly invalidationProps: any
|
||||
|
||||
/** Called to render content after the filter. */
|
||||
readonly renderPostFilter?: () => JSX.Element | null
|
||||
|
||||
/** Called when there are no items to render. */
|
||||
readonly renderNoItems?: () => JSX.Element | null
|
||||
|
||||
/**
|
||||
* A reference to a TextBox that will be used to control this component.
|
||||
*
|
||||
* See https://github.com/desktop/desktop/issues/4317 for refactoring work to
|
||||
* make this more composable which should make this unnecessary.
|
||||
*/
|
||||
readonly filterTextBox?: TextBox
|
||||
|
||||
/**
|
||||
* Callback to fire when the items in the filter list are updated
|
||||
*/
|
||||
readonly onFilterListResultsChanged?: (resultCount: number) => void
|
||||
|
||||
/** Placeholder text for text box. Default is "Filter". */
|
||||
readonly placeholderText?: string
|
||||
|
||||
/** 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<ReadonlyArray<IFilterListRow<T>>>
|
||||
readonly selectedRow: RowIndexPath
|
||||
readonly filterValue: string
|
||||
// Indices of groups in the filtered list
|
||||
readonly groups: ReadonlyArray<number>
|
||||
}
|
||||
|
||||
/** A List which includes the ability to filter based on its contents. */
|
||||
export class SectionFilterList<
|
||||
T extends IFilterListItem
|
||||
> extends React.Component<ISectionFilterListProps<T>, IFilterListState<T>> {
|
||||
private list: SectionList | null = null
|
||||
private filterTextBox: TextBox | null = null
|
||||
|
||||
public constructor(props: ISectionFilterListProps<T>) {
|
||||
super(props)
|
||||
|
||||
this.state = createStateUpdate(props)
|
||||
}
|
||||
|
||||
public componentWillMount() {
|
||||
if (this.props.filterTextBox !== undefined) {
|
||||
this.filterTextBox = this.props.filterTextBox
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillReceiveProps(nextProps: ISectionFilterListProps<T>) {
|
||||
this.setState(createStateUpdate(nextProps))
|
||||
}
|
||||
|
||||
public componentDidUpdate(
|
||||
prevProps: ISectionFilterListProps<T>,
|
||||
prevState: IFilterListState<T>
|
||||
) {
|
||||
if (this.props.onSelectionChanged) {
|
||||
const oldSelectedItemId = getItemIdFromRowIndex(
|
||||
prevState.rows,
|
||||
prevState.selectedRow
|
||||
)
|
||||
const newSelectedItemId = getItemIdFromRowIndex(
|
||||
this.state.rows,
|
||||
this.state.selectedRow
|
||||
)
|
||||
|
||||
if (oldSelectedItemId !== newSelectedItemId) {
|
||||
const propSelectionId = this.props.selectedItem
|
||||
? this.props.selectedItem.id
|
||||
: null
|
||||
|
||||
if (propSelectionId !== newSelectedItemId) {
|
||||
const newSelectedItem = getItemFromRowIndex(
|
||||
this.state.rows,
|
||||
this.state.selectedRow
|
||||
)
|
||||
this.props.onSelectionChanged(newSelectedItem, {
|
||||
kind: 'filter',
|
||||
filterText: this.props.filterText || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.onFilterListResultsChanged !== undefined) {
|
||||
const itemCount = this.state.rows
|
||||
.flat()
|
||||
.filter(row => row.kind === 'item').length
|
||||
|
||||
this.props.onFilterListResultsChanged(itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
if (this.filterTextBox !== null) {
|
||||
this.filterTextBox.selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
public renderTextBox() {
|
||||
return (
|
||||
<TextBox
|
||||
ref={this.onTextBoxRef}
|
||||
type="search"
|
||||
autoFocus={true}
|
||||
placeholder={this.props.placeholderText || 'Filter'}
|
||||
className="filter-list-filter-field"
|
||||
onValueChanged={this.onFilterValueChanged}
|
||||
onEnterPressed={this.onEnterPressed}
|
||||
onKeyDown={this.onKeyDown}
|
||||
value={this.props.filterText}
|
||||
disabled={this.props.disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
public renderFilterRow() {
|
||||
if (this.props.hideFilterRow === true) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="filter-field-row">
|
||||
{this.props.filterTextBox === undefined ? this.renderTextBox() : null}
|
||||
{this.props.renderPostFilter ? this.props.renderPostFilter() : null}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const itemRows = this.state.rows.flat().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()}
|
||||
|
||||
<div className="filter-list-container">{this.renderContent()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public selectNextItem(
|
||||
focus: boolean = false,
|
||||
inDirection: SelectionDirection = 'down'
|
||||
) {
|
||||
if (this.list === null) {
|
||||
return
|
||||
}
|
||||
let next: RowIndexPath | null = null
|
||||
|
||||
const rowCount = this.state.rows.map(r => r.length)
|
||||
if (
|
||||
this.state.selectedRow.row === -1 ||
|
||||
this.state.selectedRow.row === this.state.rows.length
|
||||
) {
|
||||
next = findNextSelectableRow(
|
||||
rowCount,
|
||||
{
|
||||
direction: inDirection,
|
||||
row: InvalidRowIndexPath,
|
||||
},
|
||||
this.canSelectRow
|
||||
)
|
||||
} else {
|
||||
next = findNextSelectableRow(
|
||||
rowCount,
|
||||
{
|
||||
direction: inDirection,
|
||||
row: this.state.selectedRow,
|
||||
},
|
||||
this.canSelectRow
|
||||
)
|
||||
}
|
||||
|
||||
if (next !== null) {
|
||||
this.setState({ selectedRow: next }, () => {
|
||||
if (focus && this.list !== null) {
|
||||
this.list.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private renderContent() {
|
||||
if (this.state.rows.length === 0 && this.props.renderNoItems) {
|
||||
return this.props.renderNoItems()
|
||||
} else {
|
||||
return (
|
||||
<SectionList
|
||||
ref={this.onListRef}
|
||||
rowCount={this.state.rows.map(r => r.length)}
|
||||
rowRenderer={this.renderRow}
|
||||
sectionHasHeader={this.sectionHasHeader}
|
||||
getSectionAriaLabel={this.getGroupAriaLabel}
|
||||
rowHeight={this.props.rowHeight}
|
||||
selectedRows={
|
||||
rowIndexPathEquals(this.state.selectedRow, InvalidRowIndexPath)
|
||||
? []
|
||||
: [this.state.selectedRow]
|
||||
}
|
||||
onSelectedRowChanged={this.onSelectedRowChanged}
|
||||
onRowClick={this.onRowClick}
|
||||
onRowKeyDown={this.onRowKeyDown}
|
||||
onRowContextMenu={this.onRowContextMenu}
|
||||
canSelectRow={this.canSelectRow}
|
||||
invalidationProps={{
|
||||
...this.props,
|
||||
...this.props.invalidationProps,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private sectionHasHeader = (section: number) => {
|
||||
const rows = this.state.rows[section]
|
||||
return rows.length > 0 && rows[0].kind === 'group'
|
||||
}
|
||||
|
||||
private getGroupAriaLabel = (group: number) => {
|
||||
return this.props.getGroupAriaLabel?.(this.state.groups[group])
|
||||
}
|
||||
|
||||
private renderRow = (index: RowIndexPath) => {
|
||||
const row = this.state.rows[index.section][index.row]
|
||||
if (row.kind === 'item') {
|
||||
return this.props.renderItem(row.item, row.matches)
|
||||
} else if (this.props.renderGroupHeader) {
|
||||
return this.props.renderGroupHeader(row.identifier)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private onTextBoxRef = (component: TextBox | null) => {
|
||||
this.filterTextBox = component
|
||||
}
|
||||
|
||||
private onListRef = (instance: SectionList | null) => {
|
||||
this.list = instance
|
||||
}
|
||||
|
||||
private onFilterValueChanged = (text: string) => {
|
||||
if (this.props.onFilterTextChanged) {
|
||||
this.props.onFilterTextChanged(text)
|
||||
}
|
||||
}
|
||||
|
||||
private onEnterPressed = (text: string) => {
|
||||
const rows = this.state.rows.length
|
||||
if (
|
||||
rows === 0 &&
|
||||
text.trim().length > 0 &&
|
||||
this.props.onEnterPressedWithoutFilteredItems !== undefined
|
||||
) {
|
||||
this.props.onEnterPressedWithoutFilteredItems(text)
|
||||
}
|
||||
}
|
||||
|
||||
private onSelectedRowChanged = (
|
||||
index: RowIndexPath,
|
||||
source: SelectionSource
|
||||
) => {
|
||||
this.setState({ selectedRow: index })
|
||||
|
||||
if (this.props.onSelectionChanged) {
|
||||
const row = this.state.rows[index.section][index.row]
|
||||
if (row.kind === 'item') {
|
||||
this.props.onSelectionChanged(row.item, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private canSelectRow = (index: RowIndexPath) => {
|
||||
if (this.props.disabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
const row = this.state.rows[index.section][index.row]
|
||||
return row.kind === 'item'
|
||||
}
|
||||
|
||||
private onRowClick = (index: RowIndexPath, source: ClickSource) => {
|
||||
if (this.props.onItemClick) {
|
||||
const row = this.state.rows[index.section][index.row]
|
||||
|
||||
if (row.kind === 'item') {
|
||||
this.props.onItemClick(row.item, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onRowContextMenu = (
|
||||
index: RowIndexPath,
|
||||
source: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (!this.props.onItemContextMenu) {
|
||||
return
|
||||
}
|
||||
|
||||
const row = this.state.rows[index.section][index.row]
|
||||
|
||||
if (row.kind !== 'item') {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.onItemContextMenu(row.item, source)
|
||||
}
|
||||
|
||||
private onRowKeyDown = (
|
||||
indexPath: RowIndexPath,
|
||||
event: React.KeyboardEvent<any>
|
||||
) => {
|
||||
const list = this.list
|
||||
if (!list) {
|
||||
return
|
||||
}
|
||||
|
||||
const rowCount = this.state.rows.map(r => r.length)
|
||||
|
||||
const firstSelectableRow = findNextSelectableRow(
|
||||
rowCount,
|
||||
{ direction: 'down', row: InvalidRowIndexPath },
|
||||
this.canSelectRow
|
||||
)
|
||||
const lastSelectableRow = findNextSelectableRow(
|
||||
rowCount,
|
||||
{
|
||||
direction: 'up',
|
||||
row: {
|
||||
section: 0,
|
||||
row: 0,
|
||||
},
|
||||
},
|
||||
this.canSelectRow
|
||||
)
|
||||
|
||||
let shouldFocus = false
|
||||
|
||||
if (
|
||||
event.key === 'ArrowUp' &&
|
||||
firstSelectableRow &&
|
||||
rowIndexPathEquals(indexPath, firstSelectableRow)
|
||||
) {
|
||||
shouldFocus = true
|
||||
} else if (
|
||||
event.key === 'ArrowDown' &&
|
||||
lastSelectableRow &&
|
||||
rowIndexPathEquals(indexPath, lastSelectableRow)
|
||||
) {
|
||||
shouldFocus = true
|
||||
}
|
||||
|
||||
if (shouldFocus) {
|
||||
const textBox = this.filterTextBox
|
||||
|
||||
if (textBox) {
|
||||
event.preventDefault()
|
||||
textBox.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const list = this.list
|
||||
const key = event.key
|
||||
|
||||
if (!list) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.props.onFilterKeyDown) {
|
||||
this.props.onFilterKeyDown(event)
|
||||
}
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
const rowCount = this.state.rows.map(r => r.length)
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
if (rowCount.length > 0) {
|
||||
const selectedRow = findNextSelectableRow(
|
||||
rowCount,
|
||||
{ direction: 'down', row: InvalidRowIndexPath },
|
||||
this.canSelectRow
|
||||
)
|
||||
if (selectedRow != null) {
|
||||
this.setState({ selectedRow }, () => {
|
||||
list.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
} else if (key === 'ArrowUp') {
|
||||
if (rowCount.length > 0) {
|
||||
const selectedRow = findNextSelectableRow(
|
||||
rowCount,
|
||||
{
|
||||
direction: 'up',
|
||||
row: {
|
||||
section: 0,
|
||||
row: 0,
|
||||
},
|
||||
},
|
||||
this.canSelectRow
|
||||
)
|
||||
if (selectedRow != null) {
|
||||
this.setState({ selectedRow }, () => {
|
||||
list.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
} else if (key === 'Enter') {
|
||||
// no repositories currently displayed, bail out
|
||||
if (rowCount.length === 0) {
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
const filterText = this.props.filterText
|
||||
|
||||
if (filterText !== undefined && !/\S/.test(filterText)) {
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
const row = findNextSelectableRow(
|
||||
rowCount,
|
||||
{ direction: 'down', row: InvalidRowIndexPath },
|
||||
this.canSelectRow
|
||||
)
|
||||
|
||||
if (row != null) {
|
||||
this.onRowClick(row, { kind: 'keyboard', event })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getText<T extends IFilterListItem>(
|
||||
item: T
|
||||
): ReadonlyArray<string> {
|
||||
return item['text']
|
||||
}
|
||||
|
||||
function getFirstVisibleRow<T extends IFilterListItem>(
|
||||
rows: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>
|
||||
): RowIndexPath {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const groupRows = rows[i]
|
||||
for (let j = 0; j < groupRows.length; j++) {
|
||||
const row = groupRows[j]
|
||||
if (row.kind === 'item') {
|
||||
return { section: i, row: j }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return InvalidRowIndexPath
|
||||
}
|
||||
|
||||
function createStateUpdate<T extends IFilterListItem>(
|
||||
props: ISectionFilterListProps<T>
|
||||
) {
|
||||
const rows = new Array<Array<IFilterListRow<T>>>()
|
||||
const filter = (props.filterText || '').toLowerCase()
|
||||
let selectedRow = InvalidRowIndexPath
|
||||
let section = 0
|
||||
const selectedItem = props.selectedItem
|
||||
const groupIndices = []
|
||||
|
||||
for (const [idx, group] of props.groups.entries()) {
|
||||
const groupRows = new Array<IFilterListRow<T>>()
|
||||
const items: ReadonlyArray<IMatch<T>> = filter
|
||||
? match(filter, group.items, getText)
|
||||
: group.items.map(item => ({
|
||||
score: 1,
|
||||
matches: { title: [], subtitle: [] },
|
||||
item,
|
||||
}))
|
||||
|
||||
if (!items.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
groupIndices.push(idx)
|
||||
|
||||
if (props.renderGroupHeader) {
|
||||
groupRows.push({ kind: 'group', identifier: group.identifier })
|
||||
}
|
||||
|
||||
for (const { item, matches } of items) {
|
||||
if (selectedItem && item.id === selectedItem.id) {
|
||||
selectedRow = {
|
||||
section,
|
||||
row: groupRows.length,
|
||||
}
|
||||
}
|
||||
|
||||
groupRows.push({ kind: 'item', item, matches })
|
||||
}
|
||||
|
||||
rows.push(groupRows)
|
||||
section++
|
||||
}
|
||||
|
||||
if (selectedRow.row < 0 && filter.length) {
|
||||
// If the selected item isn't in the list (e.g., filtered out), then
|
||||
// select the first visible item.
|
||||
selectedRow = getFirstVisibleRow(rows)
|
||||
}
|
||||
|
||||
return { rows: rows, selectedRow, filterValue: filter, groups: groupIndices }
|
||||
}
|
||||
|
||||
function getItemFromRowIndex<T extends IFilterListItem>(
|
||||
items: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>,
|
||||
index: RowIndexPath
|
||||
): T | null {
|
||||
if (index.section < 0 || index.section >= items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const group = items[index.section]
|
||||
if (index.row < 0 || index.row >= group.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = group[index.row]
|
||||
|
||||
if (row.kind === 'item') {
|
||||
return row.item
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getItemIdFromRowIndex<T extends IFilterListItem>(
|
||||
items: ReadonlyArray<ReadonlyArray<IFilterListRow<T>>>,
|
||||
index: RowIndexPath
|
||||
): string | null {
|
||||
const item = getItemFromRowIndex(items, index)
|
||||
return item ? item.id : null
|
||||
}
|
|
@ -24,6 +24,8 @@ 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'
|
||||
import { SectionFilterList } from '../lib/section-filter-list'
|
||||
import { enableSectionList } from '../../lib/feature-flag'
|
||||
|
||||
const BlankSlateImage = encodePathAsUrl(__dirname, 'static/empty-no-repo.svg')
|
||||
|
||||
|
@ -241,25 +243,31 @@ export class RepositoriesList extends React.Component<
|
|||
]
|
||||
: baseGroups
|
||||
|
||||
const getGroupAriaLabel = (group: number) => groups[group].identifier
|
||||
|
||||
const ListComponent = enableSectionList() ? SectionFilterList : FilterList
|
||||
const filterListProps: typeof ListComponent['prototype']['props'] = {
|
||||
rowHeight: RowHeight,
|
||||
selectedItem: selectedItem,
|
||||
filterText: this.props.filterText,
|
||||
onFilterTextChanged: this.props.onFilterTextChanged,
|
||||
renderItem: this.renderItem,
|
||||
renderGroupHeader: this.renderGroupHeader,
|
||||
onItemClick: this.onItemClick,
|
||||
renderPostFilter: this.renderPostFilter,
|
||||
renderNoItems: this.renderNoItems,
|
||||
groups: groups,
|
||||
invalidationProps: {
|
||||
repositories: this.props.repositories,
|
||||
filterText: this.props.filterText,
|
||||
},
|
||||
onItemContextMenu: this.onItemContextMenu,
|
||||
getGroupAriaLabel,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="repository-list">
|
||||
<FilterList<IRepositoryListItem>
|
||||
rowHeight={RowHeight}
|
||||
selectedItem={selectedItem}
|
||||
filterText={this.props.filterText}
|
||||
onFilterTextChanged={this.props.onFilterTextChanged}
|
||||
renderItem={this.renderItem}
|
||||
renderGroupHeader={this.renderGroupHeader}
|
||||
onItemClick={this.onItemClick}
|
||||
renderPostFilter={this.renderPostFilter}
|
||||
renderNoItems={this.renderNoItems}
|
||||
groups={groups}
|
||||
invalidationProps={{
|
||||
repositories: this.props.repositories,
|
||||
filterText: this.props.filterText,
|
||||
}}
|
||||
onItemContextMenu={this.onItemContextMenu}
|
||||
/>
|
||||
<ListComponent {...filterListProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -18,7 +18,8 @@ import {
|
|||
import { DialogHeader } from '../dialog/header'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import { Button } from '../lib/button'
|
||||
import { List } from '../lib/list'
|
||||
import { RowIndexPath } from '../lib/list/list-row-index-path'
|
||||
import { SectionList } from '../lib/list/section-list'
|
||||
import { Loading } from '../lib/loading'
|
||||
import { getPullRequestReviewStateIcon } from '../notifications/pull-request-review-helpers'
|
||||
import { Octicon } from '../octicons'
|
||||
|
@ -397,9 +398,9 @@ export class TestNotifications extends React.Component<
|
|||
return (
|
||||
<div>
|
||||
Pull requests:
|
||||
<List
|
||||
<SectionList
|
||||
rowHeight={40}
|
||||
rowCount={pullRequests.length}
|
||||
rowCount={[pullRequests.length]}
|
||||
rowRenderer={this.renderPullRequestRow}
|
||||
selectedRows={[]}
|
||||
onRowClick={this.onPullRequestRowClick}
|
||||
|
@ -408,8 +409,8 @@ export class TestNotifications extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private onPullRequestRowClick = (row: number) => {
|
||||
const pullRequest = this.state.pullRequests[row]
|
||||
private onPullRequestRowClick = (indexPath: RowIndexPath) => {
|
||||
const pullRequest = this.state.pullRequests[indexPath.row]
|
||||
const stepResults = this.state.stepResults
|
||||
stepResults.set(TestNotificationStepKind.SelectPullRequest, {
|
||||
kind: TestNotificationStepKind.SelectPullRequest,
|
||||
|
@ -440,9 +441,9 @@ export class TestNotifications extends React.Component<
|
|||
return (
|
||||
<div>
|
||||
Reviews:
|
||||
<List
|
||||
<SectionList
|
||||
rowHeight={40}
|
||||
rowCount={reviews.length}
|
||||
rowCount={[reviews.length]}
|
||||
rowRenderer={this.renderPullRequestReviewRow}
|
||||
selectedRows={[]}
|
||||
onRowClick={this.onPullRequestReviewRowClick}
|
||||
|
@ -451,8 +452,8 @@ export class TestNotifications extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private onPullRequestReviewRowClick = (row: number) => {
|
||||
const review = this.state.reviews[row]
|
||||
private onPullRequestReviewRowClick = (indexPath: RowIndexPath) => {
|
||||
const review = this.state.reviews[indexPath.row]
|
||||
const stepResults = this.state.stepResults
|
||||
stepResults.set(TestNotificationStepKind.SelectPullRequestReview, {
|
||||
kind: TestNotificationStepKind.SelectPullRequestReview,
|
||||
|
@ -483,9 +484,9 @@ export class TestNotifications extends React.Component<
|
|||
return (
|
||||
<div>
|
||||
Comments:
|
||||
<List
|
||||
<SectionList
|
||||
rowHeight={40}
|
||||
rowCount={comments.length}
|
||||
rowCount={[comments.length]}
|
||||
rowRenderer={this.renderPullRequestCommentRow}
|
||||
selectedRows={[]}
|
||||
onRowClick={this.onPullRequestCommentRowClick}
|
||||
|
@ -494,8 +495,8 @@ export class TestNotifications extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private onPullRequestCommentRowClick = (row: number) => {
|
||||
const comment = this.state.comments[row]
|
||||
private onPullRequestCommentRowClick = (indexPath: RowIndexPath) => {
|
||||
const comment = this.state.comments[indexPath.row]
|
||||
const stepResults = this.state.stepResults
|
||||
stepResults.set(TestNotificationStepKind.SelectPullRequestComment, {
|
||||
kind: TestNotificationStepKind.SelectPullRequestComment,
|
||||
|
@ -513,8 +514,8 @@ export class TestNotifications extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private renderPullRequestCommentRow = (row: number) => {
|
||||
const comment = this.state.comments[row]
|
||||
private renderPullRequestCommentRow = (indexPath: RowIndexPath) => {
|
||||
const comment = this.state.comments[indexPath.row]
|
||||
return (
|
||||
<TestNotificationItemRowContent
|
||||
dispatcher={this.props.dispatcher}
|
||||
|
@ -528,8 +529,8 @@ export class TestNotifications extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private renderPullRequestReviewRow = (row: number) => {
|
||||
const review = this.state.reviews[row]
|
||||
private renderPullRequestReviewRow = (indexPath: RowIndexPath) => {
|
||||
const review = this.state.reviews[indexPath.row]
|
||||
|
||||
return (
|
||||
<TestNotificationItemRowContent
|
||||
|
@ -555,8 +556,8 @@ export class TestNotifications extends React.Component<
|
|||
)
|
||||
}
|
||||
|
||||
private renderPullRequestRow = (row: number) => {
|
||||
const pullRequest = this.state.pullRequests[row]
|
||||
private renderPullRequestRow = (indexPath: RowIndexPath) => {
|
||||
const pullRequest = this.state.pullRequests[indexPath.row]
|
||||
const repository = this.props.repository.gitHubRepository
|
||||
const endpointHtmlUrl = getHTMLURL(repository.endpoint)
|
||||
const htmlURL = `${endpointHtmlUrl}/${repository.owner.login}/${repository.name}/pull/${pullRequest.pullRequestNumber}`
|
||||
|
|
88
app/test/unit/section-list-selection-test.ts
Normal file
88
app/test/unit/section-list-selection-test.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
InvalidRowIndexPath,
|
||||
rowIndexPathEquals,
|
||||
} from '../../src/ui/lib/list/list-row-index-path'
|
||||
import { findNextSelectableRow } from '../../src/ui/lib/list/section-list-selection'
|
||||
|
||||
describe('section-list-selection', () => {
|
||||
describe('findNextSelectableRow', () => {
|
||||
const rowCount = [5, 3, 8]
|
||||
|
||||
it('returns first row when selecting down outside list (filter text)', () => {
|
||||
const selectedRow = findNextSelectableRow(rowCount, {
|
||||
direction: 'down',
|
||||
row: InvalidRowIndexPath,
|
||||
})
|
||||
expect(selectedRow?.row).toBe(0)
|
||||
})
|
||||
|
||||
it('returns first selectable row when header is first', () => {
|
||||
const selectedRow = findNextSelectableRow(
|
||||
rowCount,
|
||||
{
|
||||
direction: 'down',
|
||||
row: InvalidRowIndexPath,
|
||||
},
|
||||
row => {
|
||||
if (row.section === 0 && row.row === 0) {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(selectedRow?.row).toBe(1)
|
||||
})
|
||||
|
||||
it('returns first row when selecting down from last row', () => {
|
||||
const lastRow = rowCount[0] - 1
|
||||
const selectedRow = findNextSelectableRow(rowCount, {
|
||||
direction: 'down',
|
||||
row: {
|
||||
section: 0,
|
||||
row: lastRow,
|
||||
},
|
||||
})
|
||||
expect(selectedRow?.row).toBe(0)
|
||||
})
|
||||
|
||||
it('returns last row when selecting up from top row', () => {
|
||||
const selectedRow = findNextSelectableRow(rowCount, {
|
||||
direction: 'up',
|
||||
row: {
|
||||
section: 0,
|
||||
row: 0,
|
||||
},
|
||||
})
|
||||
expect(
|
||||
rowIndexPathEquals(selectedRow!, { section: 2, row: 7 })
|
||||
).toBeTrue()
|
||||
})
|
||||
|
||||
it('returns first row of next section when selecting down from last row of a section', () => {
|
||||
const selectedRow = findNextSelectableRow(rowCount, {
|
||||
direction: 'down',
|
||||
row: {
|
||||
section: 0,
|
||||
row: 4,
|
||||
},
|
||||
})
|
||||
expect(
|
||||
rowIndexPathEquals(selectedRow!, { section: 1, row: 0 })
|
||||
).toBeTrue()
|
||||
})
|
||||
|
||||
it('returns last row of previous section when selecting up from first row of a section', () => {
|
||||
const selectedRow = findNextSelectableRow(rowCount, {
|
||||
direction: 'up',
|
||||
row: {
|
||||
section: 2,
|
||||
row: 0,
|
||||
},
|
||||
})
|
||||
expect(
|
||||
rowIndexPathEquals(selectedRow!, { section: 1, row: 2 })
|
||||
).toBeTrue()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -43,23 +43,3 @@ $ yarn run package
|
|||
If you think you've found a solution, please submit a pull request to [`shiftkey/desktop`](https://github.com/shiftkey/desktop) explaining the change and what it fixes. If you're not quite sure, open an issue on the [`shiftkey/desktop`](https://github.com/shiftkey/desktop) fork explaining what you've found and where you think the problem lies. Maybe someone else has insight into the issue.
|
||||
|
||||
[**@shiftkey**](https://github.com/shiftkey) will co-ordinate upstreaming merged pull requests to the main repository.
|
||||
|
||||
## Technical Details
|
||||
|
||||
We use `electron-packager` to generate the artifacts and `electron-builder` to generate the installer.
|
||||
|
||||
`electron-packager` details:
|
||||
|
||||
* [API options](https://github.com/electron-userland/electron-packager/blob/development/docs/api.md#options)
|
||||
* [`dist-info.js` config file](https://github.com/desktop/desktop/blob/development/script/dist-info.js)
|
||||
* [Usage in Desktop](https://github.com/desktop/desktop/blob/development/script/build.ts#L98-L151)
|
||||
|
||||
`dist-info.js` contains the various metadata we provide to Desktop as part of packaging. This seems fairly stable, but we might need to tweak some things in here for Linux-specific changes.
|
||||
|
||||
`electron-builder` details:
|
||||
|
||||
* [API options](https://www.electron.build/configuration/linux)
|
||||
* [`electron-builder-linux.yml` config file](https://github.com/desktop/desktop/blob/development/script/electron-builder-linux.yml)
|
||||
* [Usage in Desktop](https://github.com/desktop/desktop/blob/development/script/package.ts#L124-L145)
|
||||
|
||||
We use `electron-builder-linux.yml` to configure the installers, so please investigate the documentation if you find a problem with an installer to see if something has been overlooked and can be fixed fairly easily.
|
||||
|
|
|
@ -10,7 +10,6 @@ In the interest of stability and caution we tend to stay a version (or more) beh
|
|||
| Dependency | Versions Behind Latest |
|
||||
| --- | --- |
|
||||
| electron | >= 1 major |
|
||||
| electron-builder | >= 1 minor |
|
||||
| electron-packager | >= 1 major |
|
||||
| electron-winstaller | >= 1 minor |
|
||||
| typescript | >= 1 minor |
|
||||
|
@ -76,7 +75,6 @@ These are the most important dependencies to the app, and include:
|
|||
- `package.json`
|
||||
- `@types/node`
|
||||
- `electron`
|
||||
- `electron-builder`
|
||||
- `electron-packager`
|
||||
- `electron-winstaller`
|
||||
- `typescript`
|
||||
|
|
|
@ -107,16 +107,8 @@ Other things to note about the Windows packaging process:
|
|||
|
||||
### Linux
|
||||
|
||||
Desktop uses `electron-builder` to generate these three packages:
|
||||
|
||||
- `.deb` package for Debian-based distributions
|
||||
- `.rpm` package for RPM-based various distributions
|
||||
- `.AppImage` package for various distributions (no elevated permissions
|
||||
required)
|
||||
- `.snap` package for various distributions
|
||||
|
||||
The `script/electron-builder-linux.yml` configuration file contains the details
|
||||
applied to each package (if applicable).
|
||||
Refer to the [`shiftkey/desktop`](https://github.com/shiftkey/desktop) fork
|
||||
for packaging details about Linux.
|
||||
|
||||
## `script/publish.ts`
|
||||
|
||||
|
|
|
@ -155,7 +155,6 @@
|
|||
"@types/webpack-merge": "^5.0.0",
|
||||
"@types/xml2js": "^0.4.11",
|
||||
"electron": "24.4.0",
|
||||
"electron-builder": "^23.6.0",
|
||||
"electron-packager": "^17.1.1",
|
||||
"electron-winstaller": "^5.0.0",
|
||||
"eslint-plugin-github": "^4.3.7",
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
productName: 'GitHubDesktop'
|
||||
artifactName: '${productName}-${os}-${arch}-${version}.${ext}'
|
||||
linux:
|
||||
category: 'GNOME;GTK;Development'
|
||||
packageCategory: 'GNOME;GTK;Development'
|
||||
icon: 'app/static/logos'
|
||||
target:
|
||||
- deb
|
||||
- rpm
|
||||
- AppImage
|
||||
maintainer: 'GitHub, Inc <opensource+desktop@github.com>'
|
||||
deb:
|
||||
afterInstall: './script/linux-after-install.sh'
|
||||
afterRemove: './script/linux-after-remove.sh'
|
||||
depends:
|
||||
# default Electron dependencies
|
||||
- gconf2
|
||||
- gconf-service
|
||||
- libnotify4
|
||||
- libappindicator1
|
||||
- libxtst6
|
||||
- libnss3
|
||||
# dugite-native dependencies
|
||||
- libcurl3 | libcurl4
|
||||
# keytar dependencies
|
||||
- libsecret-1-0
|
||||
rpm:
|
||||
depends:
|
||||
# default Electron dependencies
|
||||
- libXScrnSaver
|
||||
- libappindicator
|
||||
- libnotify
|
||||
# dugite-native dependencies
|
||||
- libcurl
|
||||
# keytar dependencies
|
||||
- libsecret
|
|
@ -1,25 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
PROFILE_D_FILE="/etc/profile.d/github-desktop.sh"
|
||||
INSTALL_DIR="/opt/${productFilename}"
|
||||
SCRIPT=$"#!/bin/sh
|
||||
export PATH=\"$INSTALL_DIR:\$PATH\""
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
echo "$SCRIPT" > "${PROFILE_D_FILE}";
|
||||
. "${PROFILE_D_FILE}";
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postinst called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
|
@ -1,19 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
PROFILE_D_FILE="/etc/profile.d/github-desktop.sh"
|
||||
|
||||
case "$1" in
|
||||
purge|remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||
echo "#!/bin/sh" > "${PROFILE_D_FILE}";
|
||||
. "${PROFILE_D_FILE}";
|
||||
rm "${PROFILE_D_FILE}";
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postrm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
|
@ -32,10 +32,8 @@ if (process.platform === 'darwin') {
|
|||
packageOSX()
|
||||
} else if (process.platform === 'win32') {
|
||||
packageWindows()
|
||||
} else if (process.platform === 'linux') {
|
||||
packageLinux()
|
||||
} else {
|
||||
console.error(`I dunno how to package for ${process.platform} :(`)
|
||||
console.error(`I don't know how to package for ${process.platform} :(`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
@ -138,27 +136,3 @@ function packageWindows() {
|
|||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
function packageLinux() {
|
||||
const electronBuilder = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'node_modules',
|
||||
'.bin',
|
||||
'electron-builder'
|
||||
)
|
||||
|
||||
const configPath = path.resolve(__dirname, 'electron-builder-linux.yml')
|
||||
|
||||
const args = [
|
||||
'build',
|
||||
'--prepackaged',
|
||||
distPath,
|
||||
'--x64',
|
||||
'--config',
|
||||
configPath,
|
||||
]
|
||||
|
||||
console.log('Packaging for Linux…')
|
||||
cp.spawnSync(electronBuilder, args, { stdio: 'inherit' })
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue