Merge branch 'development' into reuse-workflow

This commit is contained in:
Markus Olsson 2023-07-04 09:43:47 +02:00
commit 9a6d45122f
24 changed files with 3024 additions and 895 deletions

View file

@ -96,3 +96,7 @@ export function enablePullRequestQuickView(): boolean {
export function enableMoveStash(): boolean {
return enableBetaFeatures()
}
export function enableSectionList(): boolean {
return enableDevelopmentFeatures()
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

File diff suppressed because it is too large Load diff

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

658
yarn.lock

File diff suppressed because it is too large Load diff