From 13d7e000abeb0d0f69bd2fc5dfe53abe1db8118e Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 26 Apr 2018 22:11:27 -0400 Subject: [PATCH 01/25] Port the FilePatch and MergeConflict list item views to React --- lib/prop-types.js | 14 ++++++++ lib/views/file-patch-list-item-view.js | 40 ++++++++++++++------- lib/views/merge-conflict-list-item-view.js | 41 +++++++++++++++------- 3 files changed, 69 insertions(+), 26 deletions(-) diff --git a/lib/prop-types.js b/lib/prop-types.js index 786dfd4042..9690805416 100644 --- a/lib/prop-types.js +++ b/lib/prop-types.js @@ -64,3 +64,17 @@ export const RefHolderPropType = PropTypes.shape({ setter: PropTypes.func.isRequired, observe: PropTypes.func.isRequired, }); + +export const FilePatchItemPropType = PropTypes.shape({ + filePath: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, +}); + +export const MergeConflictItemPropType = PropTypes.shape({ + filePath: PropTypes.string.isRequired, + status: PropTypes.shape({ + file: PropTypes.oneOf([]).isRequired, + ours: PropTypes.oneOf([]).isRequired, + theirs: PropTypes.oneOf([]).isRequired, + }).isRequired, +}); diff --git a/lib/views/file-patch-list-item-view.js b/lib/views/file-patch-list-item-view.js index 1ef5758074..a65f5392e7 100644 --- a/lib/views/file-patch-list-item-view.js +++ b/lib/views/file-patch-list-item-view.js @@ -1,32 +1,46 @@ -/** @jsx etch.dom */ -/* eslint react/no-unknown-property: "off" */ +import React from 'react'; +import PropTypes from 'prop-types'; +import {CompositeDisposable} from 'event-kit'; -import etch from 'etch'; +import {FilePatchItemPropType} from '../prop-types'; import {classNameForStatus} from '../helpers'; +import RefHolder from '../models/ref-holder'; -export default class FilePatchListItemView { - constructor(props) { - this.props = props; - etch.initialize(this); - this.props.registerItemElement(this.props.filePatch, this.element); +export default class FilePatchListItemView extends React.Component { + static propTypes = { + filePatch: FilePatchItemPropType.isRequired, + selected: PropTypes.bool.isRequired, + registerItemElement: PropTypes.func, + } + + static defaultProps = { + registerItemElement: () => {}, } - update(props) { - this.props = props; - this.props.registerItemElement(this.props.filePatch, this.element); - return etch.update(this); + constructor(props) { + super(props); + + this.refItem = new RefHolder(); + this.subs = new CompositeDisposable( + this.refItem.observe(item => this.props.registerItemElement(this.props.filePatch, item)), + ); } render() { const {filePatch, selected, ...others} = this.props; + delete others.registerItemElement; const status = classNameForStatus[filePatch.status]; const className = selected ? 'is-selected' : ''; return ( -
+
{filePatch.filePath}
); } + + componentWillUnmount() { + this.subs.dispose(); + } } diff --git a/lib/views/merge-conflict-list-item-view.js b/lib/views/merge-conflict-list-item-view.js index d353ecddc4..e84770b446 100644 --- a/lib/views/merge-conflict-list-item-view.js +++ b/lib/views/merge-conflict-list-item-view.js @@ -1,31 +1,42 @@ -/** @jsx etch.dom */ -/* eslint react/no-unknown-property: "off" */ +import React from 'react'; +import PropTypes from 'prop-types'; +import {CompositeDisposable} from 'event-kit'; -import etch from 'etch'; import {classNameForStatus} from '../helpers'; +import {MergeConflictItemPropType} from '../prop-types'; +import RefHolder from '../models/ref-holder'; + +export default class MergeConflictListItemView extends React.Component { + static propTypes = { + mergeConflict: MergeConflictItemPropType.isRequired, + selected: PropTypes.bool.isRequired, + remainingConflicts: PropTypes.number.isRequired, + registerItemElement: PropTypes.func.isRequired, + }; -export default class FilePatchListItemView { constructor(props) { - this.props = props; - etch.initialize(this); - this.props.registerItemElement(this.props.mergeConflict, this.element); - } + super(props); - update(props) { - this.props = props; - this.props.registerItemElement(this.props.mergeConflict, this.element); - return etch.update(this); + this.refItem = new RefHolder(); + this.subs = new CompositeDisposable( + this.refItem.observe(item => this.props.registerItemElement(this.props.mergeConflict, item)), + ); } render() { const {mergeConflict, selected, ...others} = this.props; + delete others.remainingConflicts; + delete others.registerItemElement; const fileStatus = classNameForStatus[mergeConflict.status.file]; const oursStatus = classNameForStatus[mergeConflict.status.ours]; const theirsStatus = classNameForStatus[mergeConflict.status.theirs]; const className = selected ? 'is-selected' : ''; return ( -
+
{mergeConflict.filePath} @@ -62,4 +73,8 @@ export default class FilePatchListItemView { ); } } + + componentWillUnmount() { + this.subs.dispose(); + } } From f58250958a6533f484646dfced817b6aea5d824c Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Thu, 26 Apr 2018 22:11:40 -0400 Subject: [PATCH 02/25] StagingView :rocket: React --- lib/views/staging-view.js | 889 +++++++++++++++++--------------- test/views/staging-view.test.js | 589 ++++++++++----------- 2 files changed, 760 insertions(+), 718 deletions(-) diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js index da7effb588..1fbe40f76c 100644 --- a/lib/views/staging-view.js +++ b/lib/views/staging-view.js @@ -1,22 +1,21 @@ -/** @jsx etch.dom */ -/* eslint react/no-unknown-property: "off" */ - import {Disposable, CompositeDisposable} from 'event-kit'; import {remote} from 'electron'; const {Menu, MenuItem} = remote; import {File} from 'atom'; +import React, {Fragment} from 'react'; +import PropTypes from 'prop-types'; import path from 'path'; -import etch from 'etch'; import {autobind} from 'core-decorators'; -import isEqual from 'lodash.isequal'; +import {FilePatchItemPropType} from '../prop-types'; import FilePatchListItemView from './file-patch-list-item-view'; +import ObserveModel from './observe-model'; import MergeConflictListItemView from './merge-conflict-list-item-view'; import CompositeListSelection from '../models/composite-list-selection'; import ResolutionProgress from '../models/conflicts/resolution-progress'; -import ModelObserver from '../models/model-observer'; import FilePatchController from '../controllers/file-patch-controller'; +import Commands, {Command} from './commands'; const debounce = (fn, wait) => { let timeout; @@ -30,300 +29,526 @@ const debounce = (fn, wait) => { }; }; +function calculateTruncatedLists(lists) { + return Object.keys(lists).reduce((acc, key) => { + const list = lists[key]; + acc.source[key] = list; + if (list.length <= MAXIMUM_LISTED_ENTRIES) { + acc[key] = list; + } else { + acc[key] = list.slice(0, MAXIMUM_LISTED_ENTRIES); + } + return acc; + }, {source: {}}); +} + +const noop = () => {}; + const MAXIMUM_LISTED_ENTRIES = 1000; -export default class StagingView { +export default class StagingView extends React.Component { + static propTypes = { + unstagedChanges: PropTypes.arrayOf(FilePatchItemPropType).isRequired, + stagedChanges: PropTypes.arrayOf(FilePatchItemPropType).isRequired, + mergeConflicts: PropTypes.arrayOf(FilePatchItemPropType), + workingDirectoryPath: PropTypes.string.isRequired, + resolutionProgress: PropTypes.object, + hasUndoHistory: PropTypes.bool.isRequired, + commandRegistry: PropTypes.object.isRequired, + notificationManager: PropTypes.object.isRequired, + workspace: PropTypes.object.isRequired, + openFiles: PropTypes.func.isRequired, + attemptFileStageOperation: PropTypes.func.isRequired, + discardWorkDirChangesForPaths: PropTypes.func.isRequired, + undoLastDiscard: PropTypes.func.isRequired, + attemptStageAllOperation: PropTypes.func.isRequired, + resolveAsOurs: PropTypes.func.isRequired, + resolveAsTheirs: PropTypes.func.isRequired, + } + + static defaultProps = { + mergeConflicts: [], + resolutionProgress: new ResolutionProgress(), + } + static focus = { STAGING: Symbol('staging'), }; constructor(props) { - this.props = props; - this.truncatedLists = this.calculateTruncatedLists({ - unstagedChanges: this.props.unstagedChanges, - stagedChanges: this.props.stagedChanges, - mergeConflicts: this.props.mergeConflicts || [], - }); - atom.config.observe('github.keyboardNavigationDelay', value => { - if (value === 0) { - this.debouncedDidChangeSelectedItem = this.didChangeSelectedItems; - } else { - this.debouncedDidChangeSelectedItem = debounce(this.didChangeSelectedItems, value); - } - }); + super(props); + + this.subs = new CompositeDisposable( + atom.config.observe('github.keyboardNavigationDelay', value => { + if (value === 0) { + this.debouncedDidChangeSelectedItem = this.didChangeSelectedItems; + } else { + this.debouncedDidChangeSelectedItem = debounce(this.didChangeSelectedItems, value); + } + }), + ); + + this.state = { + ...calculateTruncatedLists({ + unstagedChanges: this.props.unstagedChanges, + stagedChanges: this.props.stagedChanges, + mergeConflicts: this.props.mergeConflicts, + }), + selection: new CompositeListSelection({ + listsByKey: [ + ['unstaged', this.props.unstagedChanges], + ['conflicts', this.props.mergeConflicts], + ['staged', this.props.stagedChanges], + ], + idForItem: item => item.filePath, + }), + }; + this.mouseSelectionInProgress = false; this.listElementsByItem = new WeakMap(); + } - this.selection = new CompositeListSelection({ - listsByKey: [ - ['unstaged', this.props.unstagedChanges], - ['conflicts', this.props.mergeConflicts || []], - ['staged', this.props.stagedChanges], - ], - idForItem: item => item.filePath, - }); + static getDerivedStateFromProps(nextProps, prevState) { + let nextState = {}; - this.resolutionProgressObserver = new ModelObserver({ - didUpdate: () => { - if (this.element) { etch.update(this); } - }, - }); - this.resolutionProgressObserver.setActiveModel(this.props.resolutionProgress); - - etch.initialize(this); - - this.subscriptions = new CompositeDisposable(); - this.subscriptions.add(this.props.commandRegistry.add(this.element, { - 'core:move-up': () => this.selectPrevious(), - 'core:move-down': () => this.selectNext(), - 'core:move-left': () => this.diveIntoSelection(), - 'github:show-diff-view': () => this.showDiffView(), - 'core:select-up': () => this.selectPrevious(true), - 'core:select-down': () => this.selectNext(true), - 'core:select-all': () => this.selectAll(), - 'core:move-to-top': () => this.selectFirst(), - 'core:move-to-bottom': () => this.selectLast(), - 'core:select-to-top': () => this.selectFirst(true), - 'core:select-to-bottom': () => this.selectLast(true), - 'core:confirm': () => this.confirmSelectedItems(), - 'github:activate-next-list': () => this.activateNextList(), - 'github:activate-previous-list': () => this.activatePreviousList(), - 'github:open-file': () => this.openFile(), - 'github:resolve-file-as-ours': () => this.resolveCurrentAsOurs(), - 'github:resolve-file-as-theirs': () => this.resolveCurrentAsTheirs(), - 'github:discard-changes-in-selected-files': () => this.discardChanges(), - 'core:undo': () => this.props.hasUndoHistory && this.undoLastDiscard(), - })); - this.subscriptions.add(this.props.commandRegistry.add('atom-workspace', { - 'github:stage-all-changes': () => this.stageAll(), - 'github:unstage-all-changes': () => this.unstageAll(), - 'github:discard-all-changes': () => this.discardAll(), - 'github:undo-last-discard-in-git-tab': () => this.props.hasUndoHistory && this.undoLastDiscard(), - })); + if ( + ['unstagedChanges', 'stagedChanges', 'mergeConflicts'].some(key => prevState.source[key] !== nextProps[key]) + ) { + const nextLists = calculateTruncatedLists({ + unstagedChanges: nextProps.unstagedChanges, + stagedChanges: nextProps.stagedChanges, + mergeConflicts: nextProps.mergeConflicts, + }); + + nextState = { + ...nextLists, + selection: prevState.updateLists(nextLists), + }; + } + + return nextState; + } + + componentDidMount() { window.addEventListener('mouseup', this.mouseup); - this.subscriptions.add( + this.subs.add( new Disposable(() => window.removeEventListener('mouseup', this.mouseup)), this.props.workspace.onDidChangeActivePaneItem(item => { - if (item) { - const isFilePatchController = item.getRealItem && item.getRealItem() instanceof FilePatchController; - const isMatch = item.getWorkingDirectory && item.getWorkingDirectory() === this.props.workingDirectoryPath; - if (isFilePatchController && isMatch) { - this.quietlySelectItem(item.getFilePath(), item.getStagingStatus()); - } - this.activeFilePatch = isFilePatchController ? item : null; - } else { - this.activeFilePatch = null; + if (!item) { + return; + } + + const realItem = item.getRealItem && item.getRealItem(); + if (!realItem) { + return; + } + + const isFilePatchController = realItem instanceof FilePatchController; + const isMatch = realItem.getWorkingDirectory && item.getWorkingDirectory() === this.props.workingDirectoryPath; + + if (isFilePatchController && isMatch) { + this.quietlySelectItem(realItem.getFilePath(), realItem.getStagingStatus()); } }), ); } - getSelectedConflictPaths() { - if (this.selection.getActiveListKey() !== 'conflicts') { - return []; - } - return Array.from(this.selection.getSelectedItems(), item => item.filePath); + render() { + return ( + + {this.renderBody} + + ); } - async update(props) { - const oldProps = this.props; - this.props = {...this.props, ...props}; - this.truncatedLists = this.calculateTruncatedLists({ - unstagedChanges: this.props.unstagedChanges, - stagedChanges: this.props.stagedChanges, - mergeConflicts: this.props.mergeConflicts || [], - }); - const previouslySelectedItems = this.selection.getSelectedItems(); + renderBody = () => { + const selectedItems = this.state.selection.getSelectedItems(); + + return ( +
+ {this.renderCommands()} +
+
+ + Unstaged Changes + {this.props.unstagedChanges.length ? this.renderStageAllButton() : null} +
+ {this.props.hasUndoHistory ? this.renderUndoButton() : null} +
+ { + this.state.unstagedChanges.map(filePatch => ( + this.dblclickOnItem(event, filePatch)} + onContextMenu={event => this.contextMenuOnItem(event, filePatch)} + onMouseDown={event => this.mousedownOnItem(event, filePatch)} + onMouseMove={event => this.mousemoveOnItem(event, filePatch)} + selected={selectedItems.has(filePatch)} + /> + )) + } +
+ {this.renderTruncatedMessage(this.props.unstagedChanges)} +
+ { this.renderMergeConflicts() } +
+
+ + + Staged Changes + + { this.props.stagedChanges.length ? this.renderUnstageAllButton() : null } +
+
+ { + this.state.stagedChanges.map(filePatch => ( + this.dblclickOnItem(event, filePatch)} + onContextMenu={event => this.contextMenuOnItem(event, filePatch)} + onMouseDown={event => this.mousedownOnItem(event, filePatch)} + onMouseMove={event => this.mousemoveOnItem(event, filePatch)} + selected={selectedItems.has(filePatch)} + /> + )) + } +
+ {this.renderTruncatedMessage(this.props.stagedChanges)} +
+
+ ); + } + + renderCommands() { + return ( + + + + + + + this.selectPrevious(true)} /> + this.selectNext(true)} /> + + + + this.selectFirst(true)} /> + this.selectLast(true)} /> + + + + + + + + + + + + + + + + + ); + } + + renderStageAllButton() { + return ( + + ); + } + + renderUnstageAllButton() { + return ( + + ); + } - this.selection = this.selection.updateLists([ - ['unstaged', this.props.unstagedChanges], - ['conflicts', this.props.mergeConflicts || []], - ['staged', this.props.stagedChanges], - ]); - const currentlySelectedItems = this.selection.getSelectedItems(); + renderUndoButton() { + return ( + + ); + } - if (this.props.resolutionProgress !== oldProps.resolutionProgress) { - await this.resolutionProgressObserver.setActiveModel(this.props.resolutionProgress); + renderTruncatedMessage(list) { + if (list.length > MAXIMUM_LISTED_ENTRIES) { + return ( +
+ List truncated to the first {MAXIMUM_LISTED_ENTRIES} items +
+ ); + } else { + return null; } + } + + renderMergeConflicts() { + const mergeConflicts = this.state.mergeConflicts; + + if (mergeConflicts && mergeConflicts.length > 0) { + const selectedItems = this.state.selection.getSelectedItems(); + const resolutionProgress = this.props.resolutionProgress; + const anyUnresolved = mergeConflicts + .map(conflict => path.join(this.props.workingDirectoryPath, conflict.filePath)) + .some(conflictPath => resolutionProgress.getRemaining(conflictPath) !== 0); - if (this.activeFilePatch) { - this.quietlySelectItem(this.activeFilePatch.getFilePath(), this.activeFilePatch.getStagingStatus()); + const bulkResolveDropdown = anyUnresolved ? ( + + ) : null; + + return ( +
+
+ + Merge Conflicts + {bulkResolveDropdown} + +
+
+ { + mergeConflicts.map(mergeConflict => { + const fullPath = path.join(this.props.workingDirectoryPath, mergeConflict.filePath); + + return ( + this.dblclickOnItem(event, mergeConflict)} + onContextMenu={event => this.contextMenuOnItem(event, mergeConflict)} + onMouseDown={event => this.mousedownOnItem(event, mergeConflict)} + onMouseMove={event => this.mousemoveOnItem(event, mergeConflict)} + selected={selectedItems.has(mergeConflict)} + /> + ); + }) + } +
+ {this.renderTruncatedMessage(mergeConflicts)} +
+ ); } else { - const isRepoSame = oldProps.workingDirectoryPath === this.props.workingDirectoryPath; - const selectionsPresent = previouslySelectedItems.size > 0 && currentlySelectedItems.size > 0; - // ignore when data has not yet been fetched and no selection is present - const selectionChanged = selectionsPresent && !isEqual(previouslySelectedItems, currentlySelectedItems); - if (isRepoSame && selectionChanged) { - this.debouncedDidChangeSelectedItem(); - } + return