import React from 'react'; import PropTypes from 'prop-types'; import path from 'path'; import {autobind, equalSets} from '../helpers'; import {addEvent} from '../reporter-proxy'; import {MultiFilePatchPropType} from '../prop-types'; import ChangedFileItem from '../items/changed-file-item'; import MultiFilePatchView from '../views/multi-file-patch-view'; export default class MultiFilePatchController extends React.Component { static propTypes = { repository: PropTypes.object.isRequired, stagingStatus: PropTypes.oneOf(['staged', 'unstaged']), multiFilePatch: MultiFilePatchPropType.isRequired, hasUndoHistory: PropTypes.bool, reviewCommentsLoading: PropTypes.bool, reviewCommentThreads: PropTypes.arrayOf(PropTypes.shape({ thread: PropTypes.object.isRequired, comments: PropTypes.arrayOf(PropTypes.object).isRequired, })), workspace: PropTypes.object.isRequired, commands: PropTypes.object.isRequired, keymaps: PropTypes.object.isRequired, tooltips: PropTypes.object.isRequired, config: PropTypes.object.isRequired, destroy: PropTypes.func.isRequired, discardLines: PropTypes.func, undoLastDiscard: PropTypes.func, surface: PropTypes.func, switchToIssueish: PropTypes.func, } constructor(props) { super(props); autobind( this, 'selectedRowsChanged', 'undoLastDiscard', 'diveIntoMirrorPatch', 'openFile', 'toggleFile', 'toggleRows', 'toggleModeChange', 'toggleSymlinkChange', 'discardRows', ); this.state = { selectionMode: 'hunk', selectedRows: new Set(), hasMultipleFileSelections: false, }; this.mouseSelectionInProgress = false; this.stagingOperationInProgress = false; this.lastPatchString = null; this.patchChangePromise = new Promise(resolve => { this.resolvePatchChangePromise = resolve; }); } componentDidUpdate(prevProps) { if ( this.lastPatchString !== null && this.lastPatchString !== this.props.multiFilePatch.toString() ) { this.resolvePatchChangePromise(); this.patchChangePromise = new Promise(resolve => { this.resolvePatchChangePromise = resolve; }); } } render() { return ( <MultiFilePatchView {...this.props} selectedRows={this.state.selectedRows} selectionMode={this.state.selectionMode} hasMultipleFileSelections={this.state.hasMultipleFileSelections} selectedRowsChanged={this.selectedRowsChanged} diveIntoMirrorPatch={this.diveIntoMirrorPatch} openFile={this.openFile} toggleFile={this.toggleFile} toggleRows={this.toggleRows} toggleModeChange={this.toggleModeChange} toggleSymlinkChange={this.toggleSymlinkChange} undoLastDiscard={this.undoLastDiscard} discardRows={this.discardRows} selectNextHunk={this.selectNextHunk} selectPreviousHunk={this.selectPreviousHunk} switchToIssueish={this.props.switchToIssueish} /> ); } undoLastDiscard(filePatch, {eventSource} = {}) { addEvent('undo-last-discard', { package: 'github', component: this.constructor.name, eventSource, }); return this.props.undoLastDiscard(filePatch.getPath(), this.props.repository); } diveIntoMirrorPatch(filePatch) { const mirrorStatus = this.withStagingStatus({staged: 'unstaged', unstaged: 'staged'}); const workingDirectory = this.props.repository.getWorkingDirectoryPath(); const uri = ChangedFileItem.buildURI(filePatch.getPath(), workingDirectory, mirrorStatus); this.props.destroy(); return this.props.workspace.open(uri); } async openFile(filePatch, positions, pending) { const absolutePath = path.join(this.props.repository.getWorkingDirectoryPath(), filePatch.getPath()); const editor = await this.props.workspace.open(absolutePath, {pending}); if (positions.length > 0) { editor.setCursorBufferPosition(positions[0], {autoscroll: false}); for (const position of positions.slice(1)) { editor.addCursorAtBufferPosition(position); } editor.scrollToBufferPosition(positions[positions.length - 1], {center: true}); } return editor; } toggleFile(filePatch) { return this.stagingOperation(() => { const methodName = this.withStagingStatus({staged: 'unstageFiles', unstaged: 'stageFiles'}); return this.props.repository[methodName]([filePatch.getPath()]); }); } async toggleRows(rowSet, nextSelectionMode) { let chosenRows = rowSet; if (chosenRows) { const nextMultipleFileSelections = this.props.multiFilePatch.spansMultipleFiles(chosenRows); await this.selectedRowsChanged(chosenRows, nextSelectionMode, nextMultipleFileSelections); } else { chosenRows = this.state.selectedRows; } if (chosenRows.size === 0) { return Promise.resolve(); } return this.stagingOperation(() => { const patch = this.withStagingStatus({ staged: () => this.props.multiFilePatch.getUnstagePatchForLines(chosenRows), unstaged: () => this.props.multiFilePatch.getStagePatchForLines(chosenRows), }); return this.props.repository.applyPatchToIndex(patch); }); } toggleModeChange(filePatch) { return this.stagingOperation(() => { const targetMode = this.withStagingStatus({ unstaged: filePatch.getNewMode(), staged: filePatch.getOldMode(), }); return this.props.repository.stageFileModeChange(filePatch.getPath(), targetMode); }); } toggleSymlinkChange(filePatch) { return this.stagingOperation(() => { const relPath = filePatch.getPath(); const repository = this.props.repository; return this.withStagingStatus({ unstaged: () => { if (filePatch.hasTypechange() && filePatch.getStatus() === 'added') { return repository.stageFileSymlinkChange(relPath); } return repository.stageFiles([relPath]); }, staged: () => { if (filePatch.hasTypechange() && filePatch.getStatus() === 'deleted') { return repository.stageFileSymlinkChange(relPath); } return repository.unstageFiles([relPath]); }, }); }); } async discardRows(rowSet, nextSelectionMode, {eventSource} = {}) { // (kuychaco) For now we only support discarding rows for MultiFilePatches that contain a single file patch // The only way to access this method from the UI is to be in a ChangedFileItem, which only has a single file patch // This check is duplicated in RootController#discardLines. We also want it here to prevent us from sending metrics // unnecessarily if (this.props.multiFilePatch.getFilePatches().length !== 1) { return Promise.resolve(null); } let chosenRows = rowSet; if (chosenRows) { const nextMultipleFileSelections = this.props.multiFilePatch.spansMultipleFiles(chosenRows); await this.selectedRowsChanged(chosenRows, nextSelectionMode, nextMultipleFileSelections); } else { chosenRows = this.state.selectedRows; } addEvent('discard-unstaged-changes', { package: 'github', component: this.constructor.name, lineCount: chosenRows.size, eventSource, }); return this.props.discardLines(this.props.multiFilePatch, chosenRows, this.props.repository); } selectedRowsChanged(rows, nextSelectionMode, nextMultipleFileSelections) { if ( equalSets(this.state.selectedRows, rows) && this.state.selectionMode === nextSelectionMode && this.state.hasMultipleFileSelections === nextMultipleFileSelections ) { return Promise.resolve(); } return new Promise(resolve => { this.setState({ selectedRows: rows, selectionMode: nextSelectionMode, hasMultipleFileSelections: nextMultipleFileSelections, }, resolve); }); } withStagingStatus(callbacks) { const callback = callbacks[this.props.stagingStatus]; /* istanbul ignore if */ if (!callback) { throw new Error(`Unknown staging status: ${this.props.stagingStatus}`); } return callback instanceof Function ? callback() : callback; } stagingOperation(fn) { if (this.stagingOperationInProgress) { return null; } this.stagingOperationInProgress = true; this.lastPatchString = this.props.multiFilePatch.toString(); const operationPromise = fn(); operationPromise .then(() => this.patchChangePromise) .then(() => { this.stagingOperationInProgress = false; this.lastPatchString = null; }); return operationPromise; } }