diff --git a/docs/react-component-classification.md b/docs/react-component-classification.md index 75c3f97fd12..05a5929ec37 100644 --- a/docs/react-component-classification.md +++ b/docs/react-component-classification.md @@ -6,7 +6,7 @@ This is a high-level summary of the organization and implementation of our React **Items** are intended to be used as top-level components within subtrees that are rendered into some [Portal](https://reactjs.org/docs/portals.html) and passed to the Atom API, like pane items, dock items, or tooltips. They are mostly responsible for implementing the [Atom "item" contract](https://github.com/atom/atom/blob/a3631f0dafac146185289ac5e37eaff17b8b0209/src/workspace.js#L29-L174). -These live within [`lib/items/`](/lib/items), are tested within [`test/items/`](/test/items), and are named with an `Item` suffix. Examples: `PullRequestDetailItem`, `FilePatchItem`. +These live within [`lib/items/`](/lib/items), are tested within [`test/items/`](/test/items), and are named with an `Item` suffix. Examples: `PullRequestDetailItem`, `ChangedFileItem`. ## Containers diff --git a/keymaps/git.cson b/keymaps/git.cson index 97029fd5611..2075d945599 100644 --- a/keymaps/git.cson +++ b/keymaps/git.cson @@ -26,6 +26,11 @@ 'shift-tab': 'core:focus-previous' 'o': 'github:open-file' 'left': 'core:move-left' + 'cmd-left': 'core:move-left' + +'.github-CommitView button': + 'tab': 'core:focus-next' + 'shift-tab': 'core:focus-previous' '.github-StagingView.unstaged-changes-focused': 'cmd-backspace': 'github:discard-changes-in-selected-files' diff --git a/lib/atom/uri-pattern.js b/lib/atom/uri-pattern.js index 0a213f50e97..558a54f95d7 100644 --- a/lib/atom/uri-pattern.js +++ b/lib/atom/uri-pattern.js @@ -246,7 +246,7 @@ function dashEscape(raw) { * Reverse the escaping performed by `dashEscape` by un-doubling `-` characters. */ function dashUnescape(escaped) { - return escaped.replace('--', '-'); + return escaped.replace(/--/g, '-'); } /** diff --git a/lib/containers/file-patch-container.js b/lib/containers/changed-file-container.js similarity index 78% rename from lib/containers/file-patch-container.js rename to lib/containers/changed-file-container.js index 3b3865f08ce..6ee00e7df33 100644 --- a/lib/containers/file-patch-container.js +++ b/lib/containers/changed-file-container.js @@ -5,9 +5,9 @@ import yubikiri from 'yubikiri'; import {autobind} from '../helpers'; import ObserveModel from '../views/observe-model'; import LoadingView from '../views/loading-view'; -import FilePatchController from '../controllers/file-patch-controller'; +import MultiFilePatchController from '../controllers/multi-file-patch-controller'; -export default class FilePatchContainer extends React.Component { +export default class ChangedFileContainer extends React.Component { static propTypes = { repository: PropTypes.object.isRequired, stagingStatus: PropTypes.oneOf(['staged', 'unstaged']), @@ -30,8 +30,10 @@ export default class FilePatchContainer extends React.Component { } fetchData(repository) { + const staged = this.props.stagingStatus === 'staged'; + return yubikiri({ - filePatch: repository.getFilePatchForPath(this.props.relPath, {staged: this.props.stagingStatus === 'staged'}), + multiFilePatch: repository.getChangedFilePatch(this.props.relPath, {staged}), isPartiallyStaged: repository.isPartiallyStaged(this.props.relPath), hasUndoHistory: repository.hasDiscardHistory(this.props.relPath), }); @@ -51,10 +53,11 @@ export default class FilePatchContainer extends React.Component { } return ( - ); diff --git a/lib/containers/commit-preview-container.js b/lib/containers/commit-preview-container.js new file mode 100644 index 00000000000..877fa76cfbe --- /dev/null +++ b/lib/containers/commit-preview-container.js @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import yubikiri from 'yubikiri'; + +import ObserveModel from '../views/observe-model'; +import LoadingView from '../views/loading-view'; +import MultiFilePatchController from '../controllers/multi-file-patch-controller'; + +export default class CommitPreviewContainer extends React.Component { + static propTypes = { + repository: PropTypes.object.isRequired, + } + + fetchData = repository => { + return yubikiri({ + multiFilePatch: repository.getStagedChangesPatch(), + }); + } + + render() { + return ( + + {this.renderResult} + + ); + } + + renderResult = data => { + if (this.props.repository.isLoading() || data === null) { + return ; + } + + return ( + + ); + } +} diff --git a/lib/controllers/commit-controller.js b/lib/controllers/commit-controller.js index c4d637c56b9..059a7282bb0 100644 --- a/lib/controllers/commit-controller.js +++ b/lib/controllers/commit-controller.js @@ -8,7 +8,9 @@ import fs from 'fs-extra'; import CommitView from '../views/commit-view'; import RefHolder from '../models/ref-holder'; +import CommitPreviewItem from '../items/commit-preview-item'; import {AuthorPropType, UserStorePropType} from '../prop-types'; +import {watchWorkspaceItem} from '../watch-workspace-item'; import {autobind} from '../helpers'; import {addEvent} from '../reporter-proxy'; @@ -43,7 +45,8 @@ export default class CommitController extends React.Component { constructor(props, context) { super(props, context); - autobind(this, 'commit', 'handleMessageChange', 'toggleExpandedCommitMessageEditor', 'grammarAdded'); + autobind(this, 'commit', 'handleMessageChange', 'toggleExpandedCommitMessageEditor', 'grammarAdded', + 'toggleCommitPreview'); this.subscriptions = new CompositeDisposable(); this.refCommitView = new RefHolder(); @@ -52,6 +55,14 @@ export default class CommitController extends React.Component { this.subscriptions.add( this.commitMessageBuffer.onDidChange(this.handleMessageChange), ); + + this.previewWatcher = watchWorkspaceItem( + this.props.workspace, + CommitPreviewItem.buildURI(this.props.repository.getWorkingDirectoryPath()), + this, + 'commitPreviewOpen', + ); + this.subscriptions.add(this.previewWatcher); } componentDidMount() { @@ -110,6 +121,8 @@ export default class CommitController extends React.Component { userStore={this.props.userStore} selectedCoAuthors={this.props.selectedCoAuthors} updateSelectedCoAuthors={this.props.updateSelectedCoAuthors} + toggleCommitPreview={this.toggleCommitPreview} + commitPreviewOpen={this.state.commitPreviewOpen} /> ); } @@ -120,6 +133,12 @@ export default class CommitController extends React.Component { } else { this.commitMessageBuffer.setTextViaDiff(this.getCommitMessage()); } + + if (prevProps.repository !== this.props.repository) { + this.previewWatcher.setPattern( + CommitPreviewItem.buildURI(this.props.repository.getWorkingDirectoryPath()), + ); + } } componentWillUnmount() { @@ -249,12 +268,22 @@ export default class CommitController extends React.Component { return this.refCommitView.map(view => view.setFocus(focus)).getOr(false); } - hasFocus() { - return this.refCommitView.map(view => view.hasFocus()).getOr(false); + advanceFocus(...args) { + return this.refCommitView.map(view => view.advanceFocus(...args)).getOr(false); } - hasFocusEditor() { - return this.refCommitView.map(view => view.hasFocusEditor()).getOr(false); + retreatFocus(...args) { + return this.refCommitView.map(view => view.retreatFocus(...args)).getOr(false); + } + + hasFocusAtBeginning() { + return this.refCommitView.map(view => view.hasFocusAtBeginning()).getOr(false); + } + + toggleCommitPreview() { + return this.props.workspace.toggle( + CommitPreviewItem.buildURI(this.props.repository.getWorkingDirectoryPath()), + ); } } diff --git a/lib/controllers/file-patch-controller.js b/lib/controllers/file-patch-controller.js index 14dc10db171..ebda46ca87c 100644 --- a/lib/controllers/file-patch-controller.js +++ b/lib/controllers/file-patch-controller.js @@ -4,7 +4,7 @@ import path from 'path'; import {autobind, equalSets} from '../helpers'; import {addEvent} from '../reporter-proxy'; -import FilePatchItem from '../items/file-patch-item'; +import ChangedFileItem from '../items/changed-file-item'; import FilePatchView from '../views/file-patch-view'; export default class FilePatchController extends React.Component { @@ -13,7 +13,7 @@ export default class FilePatchController extends React.Component { stagingStatus: PropTypes.oneOf(['staged', 'unstaged']), relPath: PropTypes.string.isRequired, filePatch: PropTypes.object.isRequired, - hasUndoHistory: PropTypes.bool.isRequired, + hasUndoHistory: PropTypes.bool, workspace: PropTypes.object.isRequired, commands: PropTypes.object.isRequired, @@ -22,9 +22,11 @@ export default class FilePatchController extends React.Component { config: PropTypes.object.isRequired, destroy: PropTypes.func.isRequired, - discardLines: PropTypes.func.isRequired, - undoLastDiscard: PropTypes.func.isRequired, - surfaceFileAtPath: PropTypes.func.isRequired, + discardLines: PropTypes.func, + undoLastDiscard: PropTypes.func, + surfaceFileAtPath: PropTypes.func, + handleClick: PropTypes.func, + isActive: PropTypes.bool, } constructor(props) { @@ -96,7 +98,7 @@ export default class FilePatchController extends React.Component { diveIntoMirrorPatch() { const mirrorStatus = this.withStagingStatus({staged: 'unstaged', unstaged: 'staged'}); const workingDirectory = this.props.repository.getWorkingDirectoryPath(); - const uri = FilePatchItem.buildURI(this.props.relPath, workingDirectory, mirrorStatus); + const uri = ChangedFileItem.buildURI(this.props.relPath, workingDirectory, mirrorStatus); this.props.destroy(); return this.props.workspace.open(uri); diff --git a/lib/controllers/github-tab-controller.js b/lib/controllers/github-tab-controller.js index f8eeb45b51b..153b723d280 100644 --- a/lib/controllers/github-tab-controller.js +++ b/lib/controllers/github-tab-controller.js @@ -19,7 +19,7 @@ export default class GitHubTabController extends React.Component { allRemotes: RemoteSetPropType.isRequired, branches: BranchSetPropType.isRequired, selectedRemoteName: PropTypes.string, - aheadCount: PropTypes.number.isRequired, + aheadCount: PropTypes.number, pushInProgress: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, } diff --git a/lib/controllers/multi-file-patch-controller.js b/lib/controllers/multi-file-patch-controller.js new file mode 100644 index 00000000000..0b8f3ecede7 --- /dev/null +++ b/lib/controllers/multi-file-patch-controller.js @@ -0,0 +1,41 @@ +import React from 'react'; + +import {MultiFilePatchPropType} from '../prop-types'; +import FilePatchController from '../controllers/file-patch-controller'; +import {autobind} from '../helpers'; + +export default class MultiFilePatchController extends React.Component { + static propTypes = { + multiFilePatch: MultiFilePatchPropType.isRequired, + } + + constructor(props) { + super(props); + autobind(this, 'handleMouseDown'); + const firstFilePatch = this.props.multiFilePatch.getFilePatches()[0]; + + this.state = {activeFilePatch: firstFilePatch ? firstFilePatch.getPath() : null}; + } + + handleMouseDown(relPath) { + this.setState({activeFilePatch: relPath}); + } + + render() { + return this.props.multiFilePatch.getFilePatches().map(filePatch => { + const relPath = filePatch.getPath(); + const isActive = this.state.activeFilePatch === relPath; + return ( + + ); + }); + } +} diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 3b4a6ad9f0f..5622941a053 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -15,8 +15,9 @@ import InitDialog from '../views/init-dialog'; import CredentialDialog from '../views/credential-dialog'; import Commands, {Command} from '../atom/commands'; import GitTimingsView from '../views/git-timings-view'; -import FilePatchItem from '../items/file-patch-item'; +import ChangedFileItem from '../items/changed-file-item'; import IssueishDetailItem from '../items/issueish-detail-item'; +import CommitPreviewItem from '../items/commit-preview-item'; import GitTabItem from '../items/git-tab-item'; import GitHubTabItem from '../items/github-tab-item'; import StatusBarTileController from './status-bar-tile-controller'; @@ -128,6 +129,15 @@ export default class RootController extends React.Component { return ( {devMode && } + {devMode && ( + { + const workdir = this.props.repository.getWorkingDirectoryPath(); + this.props.workspace.toggle(CommitPreviewItem.buildURI(workdir)); + }} + /> + )} @@ -316,9 +326,9 @@ export default class RootController extends React.Component { + uriPattern={ChangedFileItem.uriPattern}> {({itemHolder, params}) => ( - )} + + {({itemHolder, params}) => ( + + )} + {({itemHolder, params}) => ( { + /* istanbul ignore else */ + if (!this.isDestroyed) { + this.emitter.emit('did-destroy'); + this.isDestroyed = true; + } + } + + onDidDestroy(callback) { + return this.emitter.on('did-destroy', callback); + } + + render() { + const repository = this.props.workdirContextPool.getContext(this.props.workingDirectory).getRepository(); + + return ( + + ); + } + + getTitle() { + return 'Commit preview'; + } + + getIconName() { + return 'git-commit'; + } + + getWorkingDirectory() { + return this.props.workingDirectory; + } + + serialize() { + return { + deserializer: 'CommitPreviewStub', + uri: CommitPreviewItem.buildURI(this.props.workingDirectory), + }; + } +} diff --git a/lib/models/patch/builder.js b/lib/models/patch/builder.js index a773e3d9768..c2bef5fe72e 100644 --- a/lib/models/patch/builder.js +++ b/lib/models/patch/builder.js @@ -5,8 +5,9 @@ import File, {nullFile} from './file'; import Patch from './patch'; import {Unchanged, Addition, Deletion, NoNewline} from './region'; import FilePatch from './file-patch'; +import MultiFilePatch from './multi-file-patch'; -export default function buildFilePatch(diffs) { +export function buildFilePatch(diffs) { if (diffs.length === 0) { return emptyDiffFilePatch(); } else if (diffs.length === 1) { @@ -18,6 +19,42 @@ export default function buildFilePatch(diffs) { } } +export function buildMultiFilePatch(diffs) { + const byPath = new Map(); + const filePatches = []; + + let index = 0; + for (const diff of diffs) { + const thePath = diff.oldPath || diff.newPath; + + if (diff.status === 'added' || diff.status === 'deleted') { + // Potential paired diff. Either a symlink deletion + content addition or a symlink addition + + // content deletion. + const otherHalf = byPath.get(thePath); + if (otherHalf) { + // The second half. Complete the paired diff, or fail if they have unexpected statuses or modes. + const [otherDiff, otherIndex] = otherHalf; + filePatches[otherIndex] = dualDiffFilePatch(diff, otherDiff); + byPath.delete(thePath); + } else { + // The first half we've seen. + byPath.set(thePath, [diff, index]); + index++; + } + } else { + filePatches[index] = singleDiffFilePatch(diff); + index++; + } + } + + // Populate unpaired diffs that looked like they could be part of a pair, but weren't. + for (const [unpairedDiff, originalIndex] of byPath.values()) { + filePatches[originalIndex] = singleDiffFilePatch(unpairedDiff); + } + + return new MultiFilePatch(filePatches); +} + function emptyDiffFilePatch() { return FilePatch.createNull(); } diff --git a/lib/models/patch/index.js b/lib/models/patch/index.js index 596fcfde501..525043dbc41 100644 --- a/lib/models/patch/index.js +++ b/lib/models/patch/index.js @@ -1 +1 @@ -export {default as buildFilePatch} from './builder'; +export {buildFilePatch, buildMultiFilePatch} from './builder'; diff --git a/lib/models/patch/multi-file-patch.js b/lib/models/patch/multi-file-patch.js new file mode 100644 index 00000000000..b47b90f5450 --- /dev/null +++ b/lib/models/patch/multi-file-patch.js @@ -0,0 +1,9 @@ +export default class MultiFilePatch { + constructor(filePatches) { + this.filePatches = filePatches; + } + + getFilePatches() { + return this.filePatches; + } +} diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index ed25c56e497..863c59a1e3d 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -6,7 +6,7 @@ import State from './state'; import {LargeRepoError} from '../../git-shell-out-strategy'; import {FOCUS} from '../workspace-change-observer'; -import {buildFilePatch} from '../patch'; +import {buildFilePatch, buildMultiFilePatch} from '../patch'; import DiscardHistory from '../discard-history'; import Branch, {nullBranch} from '../branch'; import Author from '../author'; @@ -14,6 +14,7 @@ import BranchSet from '../branch-set'; import Remote from '../remote'; import RemoteSet from '../remote-set'; import Commit from '../commit'; +import MultiFilePatch from '../patch/multi-file-patch'; import OperationStates from '../operation-states'; import {addEvent} from '../../reporter-proxy'; @@ -92,6 +93,7 @@ export default class Present extends State { const includes = (...segments) => fullPath.includes(path.join(...segments)); if (endsWith('.git', 'index')) { + keys.add(Keys.stagedChanges); keys.add(Keys.stagedChangesSinceParentCommit); keys.add(Keys.filePatch.all); keys.add(Keys.index.all); @@ -231,6 +233,7 @@ export default class Present extends State { ...Keys.filePatch.eachWithOpts({staged: true}), Keys.headDescription, Keys.branches, + Keys.stagedChanges, ], // eslint-disable-next-line no-shadow () => this.executePipelineAction('COMMIT', async (message, options = {}) => { @@ -276,6 +279,7 @@ export default class Present extends State { return this.invalidate( () => [ Keys.statusBundle, + Keys.stagedChanges, Keys.stagedChangesSinceParentCommit, Keys.filePatch.all, Keys.index.all, @@ -297,6 +301,7 @@ export default class Present extends State { () => [ Keys.statusBundle, Keys.stagedChangesSinceParentCommit, + Keys.stagedChanges, ...Keys.filePatch.eachWithFileOpts([filePath], [{staged: false}, {staged: true}]), Keys.index.oneWith(filePath), ], @@ -309,6 +314,7 @@ export default class Present extends State { checkout(revision, options = {}) { return this.invalidate( () => [ + Keys.stagedChanges, Keys.stagedChangesSinceParentCommit, Keys.lastCommit, Keys.recentCommits, @@ -330,6 +336,7 @@ export default class Present extends State { return this.invalidate( () => [ Keys.statusBundle, + Keys.stagedChanges, Keys.stagedChangesSinceParentCommit, ...paths.map(fileName => Keys.index.oneWith(fileName)), ...Keys.filePatch.eachWithFileOpts(paths, [{staged: true}]), @@ -619,6 +626,11 @@ export default class Present extends State { return {stagedFiles, unstagedFiles, mergeConflictFiles}; } + // hack hack hack + async getChangedFilePatch(...args) { + return new MultiFilePatch([await this.getFilePatchForPath(...args)]); + } + getFilePatchForPath(filePath, {staged} = {staged: false}) { return this.cache.getOrSet(Keys.filePatch.oneWith(filePath, {staged}), async () => { const diffs = await this.git().getDiffsForFilePath(filePath, {staged}); @@ -626,6 +638,12 @@ export default class Present extends State { }); } + getStagedChangesPatch() { + return this.cache.getOrSet(Keys.stagedChanges, () => { + return this.git().getStagedChangesPatch().then(buildMultiFilePatch); + }); + } + readFileFromIndex(filePath) { return this.cache.getOrSet(Keys.index.oneWith(filePath), () => { return this.git().readFileFromIndex(filePath); @@ -950,6 +968,8 @@ class GroupKey { const Keys = { statusBundle: new CacheKey('status-bundle'), + stagedChanges: new CacheKey('staged-changes'), + stagedChangesSinceParentCommit: new CacheKey('staged-changes-since-parent-commit'), filePatch: { @@ -1029,6 +1049,7 @@ const Keys = { ...Keys.workdirOperationKeys(fileNames), ...Keys.filePatch.eachWithFileOpts(fileNames, [{staged: true}]), ...fileNames.map(Keys.index.oneWith), + Keys.stagedChanges, Keys.stagedChangesSinceParentCommit, ], diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index dc3b8e9bb4f..791b52174d9 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -3,6 +3,7 @@ import BranchSet from '../branch-set'; import RemoteSet from '../remote-set'; import {nullOperationStates} from '../operation-states'; import FilePatch from '../patch/file-patch'; +import MultiFilePatch from '../patch/multi-file-patch'; /** * Map of registered subclasses to allow states to transition to one another without circular dependencies. @@ -278,6 +279,14 @@ export default class State { return Promise.resolve(FilePatch.createNull()); } + getChangedFilePatch() { + return Promise.resolve(new MultiFilePatch([])); + } + + getStagedChangesPatch() { + return Promise.resolve(new MultiFilePatch([])); + } + readFileFromIndex(filePath) { return Promise.reject(new Error(`fatal: Path ${filePath} does not exist (neither on disk nor in the index).`)); } diff --git a/lib/models/repository.js b/lib/models/repository.js index ab50db5f527..08970a981d6 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -326,7 +326,9 @@ const delegates = [ 'getStatusBundle', 'getStatusesForChangedFiles', 'getFilePatchForPath', + 'getStagedChangesPatch', 'readFileFromIndex', + 'getChangedFilePatch', 'getLastCommit', 'getRecentCommits', diff --git a/lib/prop-types.js b/lib/prop-types.js index 13d0b4d394c..f00c709f8f3 100644 --- a/lib/prop-types.js +++ b/lib/prop-types.js @@ -130,6 +130,10 @@ export const FilePatchItemPropType = PropTypes.shape({ status: PropTypes.string.isRequired, }); +export const MultiFilePatchPropType = PropTypes.shape({ + getFilePatches: PropTypes.func.isRequired, +}); + const statusNames = [ 'added', 'deleted', diff --git a/lib/views/commit-view.js b/lib/views/commit-view.js index 7690da29d95..69017069154 100644 --- a/lib/views/commit-view.js +++ b/lib/views/commit-view.js @@ -23,6 +23,7 @@ let FakeKeyDownEvent; export default class CommitView extends React.Component { static focus = { + COMMIT_PREVIEW_BUTTON: Symbol('commit-preview-button'), EDITOR: Symbol('commit-editor'), COAUTHOR_INPUT: Symbol('coauthor-input'), ABORT_MERGE_BUTTON: Symbol('commit-abort-merge-button'), @@ -41,6 +42,7 @@ export default class CommitView extends React.Component { mergeConflictsExist: PropTypes.bool.isRequired, stagedChangesExist: PropTypes.bool.isRequired, isCommitting: PropTypes.bool.isRequired, + commitPreviewOpen: PropTypes.bool.isRequired, deactivateCommitBox: PropTypes.bool.isRequired, maximumCharacterLimit: PropTypes.number.isRequired, messageBuffer: PropTypes.object.isRequired, // FIXME more specific proptype @@ -51,6 +53,7 @@ export default class CommitView extends React.Component { abortMerge: PropTypes.func.isRequired, prepareToCommit: PropTypes.func.isRequired, toggleExpandedCommitMessageEditor: PropTypes.func.isRequired, + toggleCommitPreview: PropTypes.func.isRequired, }; constructor(props, context) { @@ -73,6 +76,7 @@ export default class CommitView extends React.Component { this.subscriptions = new CompositeDisposable(); this.refRoot = new RefHolder(); + this.refCommitPreviewButton = new RefHolder(); this.refExpandButton = new RefHolder(); this.refCommitButton = new RefHolder(); this.refHardWrapButton = new RefHolder(); @@ -157,6 +161,15 @@ export default class CommitView extends React.Component { +
+ +
} @@ -551,26 +564,38 @@ export default class CommitView extends React.Component { return this.refEditorComponent.map(editor => editor.contains(document.activeElement)).getOr(false); } - rememberFocus(event) { - if (this.refEditorComponent.map(editor => editor.contains(event.target)).getOr(false)) { + hasFocusAtBeginning() { + return this.refCommitPreviewButton.map(button => button.contains(document.activeElement)).getOr(false); + } + + getFocus(element = document.activeElement) { + if (this.refCommitPreviewButton.map(button => button.contains(element)).getOr(false)) { + return CommitView.focus.COMMIT_PREVIEW_BUTTON; + } + + if (this.refEditorComponent.map(editor => editor.contains(element)).getOr(false)) { return CommitView.focus.EDITOR; } - if (this.refAbortMergeButton.map(e => e.contains(event.target)).getOr(false)) { + if (this.refAbortMergeButton.map(e => e.contains(element)).getOr(false)) { return CommitView.focus.ABORT_MERGE_BUTTON; } - if (this.refCommitButton.map(e => e.contains(event.target)).getOr(false)) { + if (this.refCommitButton.map(e => e.contains(element)).getOr(false)) { return CommitView.focus.COMMIT_BUTTON; } - if (this.refCoAuthorSelect.map(c => c.wrapper && c.wrapper.contains(event.target)).getOr(false)) { + if (this.refCoAuthorSelect.map(c => c.wrapper && c.wrapper.contains(element)).getOr(false)) { return CommitView.focus.COAUTHOR_INPUT; } return null; } + rememberFocus(event) { + return this.getFocus(event.target); + } + setFocus(focus) { let fallback = false; const focusElement = element => { @@ -578,6 +603,12 @@ export default class CommitView extends React.Component { return true; }; + if (focus === CommitView.focus.COMMIT_PREVIEW_BUTTON) { + if (this.refCommitPreviewButton.map(focusElement).getOr(false)) { + return true; + } + } + if (focus === CommitView.focus.EDITOR) { if (this.refEditorComponent.map(focusElement).getOr(false)) { return true; @@ -611,4 +642,78 @@ export default class CommitView extends React.Component { return false; } + + advanceFocus(event) { + const f = this.constructor.focus; + const current = this.getFocus(); + if (current === f.EDITOR) { + // Let the editor handle it + return true; + } + + let next = null; + switch (current) { + case f.COMMIT_PREVIEW_BUTTON: + next = f.EDITOR; + break; + case f.COAUTHOR_INPUT: + next = this.props.isMerging ? f.ABORT_MERGE_BUTTON : f.COMMIT_BUTTON; + break; + case f.ABORT_MERGE_BUTTON: + next = f.COMMIT_BUTTON; + break; + case f.COMMIT_BUTTON: + // End of tab navigation. Prevent cycling. + event.stopPropagation(); + return true; + } + + if (next !== null) { + this.setFocus(next); + event.stopPropagation(); + + return true; + } else { + return false; + } + } + + retreatFocus(event) { + const f = this.constructor.focus; + const current = this.getFocus(); + + let next = null; + switch (current) { + case f.COMMIT_BUTTON: + if (this.props.isMerging) { + next = f.ABORT_MERGE_BUTTON; + } else if (this.state.showCoAuthorInput) { + next = f.COAUTHOR_INPUT; + } else { + next = f.EDITOR; + } + break; + case f.ABORT_MERGE_BUTTON: + next = this.state.showCoAuthorInput ? f.COAUTHOR_INPUT : f.EDITOR; + break; + case f.COAUTHOR_INPUT: + next = f.EDITOR; + break; + case f.EDITOR: + next = f.COMMIT_PREVIEW_BUTTON; + break; + case f.COMMIT_PREVIEW_BUTTON: + // Allow the GitTabView to retreat focus back to the last StagingView list. + return false; + } + + if (next !== null) { + this.setFocus(next); + event.stopPropagation(); + + return true; + } else { + return false; + } + } } diff --git a/lib/views/file-patch-view.js b/lib/views/file-patch-view.js index 8736496e217..4cbf23538a3 100644 --- a/lib/views/file-patch-view.js +++ b/lib/views/file-patch-view.js @@ -35,6 +35,8 @@ export default class FilePatchView extends React.Component { selectedRows: PropTypes.object.isRequired, repository: PropTypes.object.isRequired, hasUndoHistory: PropTypes.bool.isRequired, + useEditorAutoHeight: PropTypes.bool, + isActive: PropTypes.bool.isRequired, workspace: PropTypes.object.isRequired, commands: PropTypes.object.isRequired, @@ -53,6 +55,11 @@ export default class FilePatchView extends React.Component { toggleSymlinkChange: PropTypes.func.isRequired, undoLastDiscard: PropTypes.func.isRequired, discardRows: PropTypes.func.isRequired, + handleMouseDown: PropTypes.func, + } + + static defaultProps = { + useEditorAutoHeight: false, } constructor(props) { @@ -62,7 +69,7 @@ export default class FilePatchView extends React.Component { 'didMouseDownOnHeader', 'didMouseDownOnLineNumber', 'didMouseMoveOnLineNumber', 'didMouseUp', 'didConfirm', 'didToggleSelectionMode', 'selectNextHunk', 'selectPreviousHunk', 'didOpenFile', 'didAddSelection', 'didChangeSelectionRange', 'didDestroySelection', - 'oldLineNumberLabel', 'newLineNumberLabel', + 'oldLineNumberLabel', 'newLineNumberLabel', 'handleMouseDown', ); this.mouseSelectionInProgress = false; @@ -147,16 +154,22 @@ export default class FilePatchView extends React.Component { this.subs.dispose(); } + handleMouseDown() { + this.props.handleMouseDown(this.props.relPath); + } + render() { const rootClass = cx( 'github-FilePatchView', `github-FilePatchView--${this.props.stagingStatus}`, {'github-FilePatchView--blank': !this.props.filePatch.isPresent()}, {'github-FilePatchView--hunkMode': this.props.selectionMode === 'hunk'}, + {'github-FilePatchView--active': this.props.isActive}, + {'github-FilePatchView--inactive': !this.props.isActive}, ); return ( -
+
{this.renderCommands()} @@ -228,7 +241,7 @@ export default class FilePatchView extends React.Component { buffer={this.props.filePatch.getBuffer()} lineNumberGutterVisible={false} autoWidth={false} - autoHeight={false} + autoHeight={this.props.useEditorAutoHeight} readOnly={true} softWrapped={true} @@ -429,7 +442,7 @@ export default class FilePatchView extends React.Component { {this.props.filePatch.getHunks().map((hunk, index) => { const containsSelection = this.props.selectionMode === 'line' && selectedHunks.has(hunk); - const isSelected = this.props.selectionMode === 'hunk' && selectedHunks.has(hunk); + const isSelected = this.props.isActive && (this.props.selectionMode === 'hunk') && selectedHunks.has(hunk); let buttonSuffix = ''; if (containsSelection) { @@ -482,6 +495,9 @@ export default class FilePatchView extends React.Component { if (ranges.length === 0) { return null; } + if (!this.props.isActive) { + return null; + } const holder = refHolder || new RefHolder(); return ( diff --git a/lib/views/git-tab-view.js b/lib/views/git-tab-view.js index d748e3bf71b..d89237e76d1 100644 --- a/lib/views/git-tab-view.js +++ b/lib/views/git-tab-view.js @@ -248,30 +248,38 @@ export default class GitTabView extends React.Component { } async advanceFocus(evt) { - // The commit controller manages its own focus - if (this.refCommitController.map(c => c.hasFocus()).getOr(false)) { + // Advance focus within the CommitView if it's there + if (this.refCommitController.map(c => c.advanceFocus(evt)).getOr(false)) { return; } + // Advance focus to the next staging view list, if it's there if (await this.props.refStagingView.map(view => view.activateNextList()).getOr(false)) { evt.stopPropagation(); - } else { - if (this.refCommitController.map(c => c.setFocus(GitTabView.focus.EDITOR)).getOr(false)) { - evt.stopPropagation(); - } + return; + } + + // Advance focus from the staging view lists to the CommitView + if (this.refCommitController.map(c => c.setFocus(GitTabView.focus.COMMIT_PREVIEW_BUTTON)).getOr(false)) { + evt.stopPropagation(); } } async retreatFocus(evt) { - if (this.refCommitController.map(c => c.hasFocus()).getOr(false)) { - // if the commit editor is focused, focus the last staging view list - if (this.refCommitController.map(c => c.hasFocusEditor()).getOr(false) && - await this.props.refStagingView.map(view => view.activateLastList()).getOr(null) - ) { + // Retreat focus within the CommitView if it's there + if (this.refCommitController.map(c => c.retreatFocus(evt)).getOr(false)) { + return; + } + + if (this.refCommitController.map(c => c.hasFocusAtBeginning()).getOr(false)) { + // Retreat focus from the beginning of the CommitView to the end of the StagingView + if (await this.props.refStagingView.map(view => view.activateLastList()).getOr(null)) { this.setFocus(GitTabView.focus.STAGING); evt.stopPropagation(); } } else if (await this.props.refStagingView.map(c => c.activatePreviousList()).getOr(null)) { + // Retreat focus within the StagingView + this.setFocus(GitTabView.focus.STAGING); evt.stopPropagation(); } } diff --git a/lib/views/github-tab-view.js b/lib/views/github-tab-view.js index cac4e26b2ca..4c8bc73a4be 100644 --- a/lib/views/github-tab-view.js +++ b/lib/views/github-tab-view.js @@ -22,7 +22,7 @@ export default class GitHubTabView extends React.Component { remotes: RemoteSetPropType.isRequired, currentRemote: RemotePropType.isRequired, manyRemotesAvailable: PropTypes.bool.isRequired, - aheadCount: PropTypes.number.isRequired, + aheadCount: PropTypes.number, pushInProgress: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js index fae58d5cf04..23f35e96cc4 100644 --- a/lib/views/staging-view.js +++ b/lib/views/staging-view.js @@ -13,7 +13,7 @@ import MergeConflictListItemView from './merge-conflict-list-item-view'; import CompositeListSelection from '../models/composite-list-selection'; import ResolutionProgress from '../models/conflicts/resolution-progress'; import RefHolder from '../models/ref-holder'; -import FilePatchItem from '../items/file-patch-item'; +import ChangedFileItem from '../items/changed-file-item'; import Commands, {Command} from '../atom/commands'; import {autobind} from '../helpers'; import {addEvent} from '../reporter-proxy'; @@ -743,7 +743,7 @@ export default class StagingView extends React.Component { const activePane = this.props.workspace.getCenter().getActivePane(); const activePendingItem = activePane.getPendingItem(); const activePaneHasPendingFilePatchItem = activePendingItem && activePendingItem.getRealItem && - activePendingItem.getRealItem() instanceof FilePatchItem; + activePendingItem.getRealItem() instanceof ChangedFileItem; if (activePaneHasPendingFilePatchItem) { await this.showFilePatchItem(selectedItem.filePath, this.state.selection.getActiveListKey(), { activate: false, @@ -762,7 +762,7 @@ export default class StagingView extends React.Component { const pendingItem = pane.getPendingItem(); if (!pendingItem || !pendingItem.getRealItem) { return false; } const realItem = pendingItem.getRealItem(); - const isDiffViewItem = realItem instanceof FilePatchItem; + const isDiffViewItem = realItem instanceof ChangedFileItem; // We only want to update pending diff views for currently active repo const isInActiveRepo = realItem.getWorkingDirectory() === this.props.workingDirectoryPath; const isStale = !this.changedFileExists(realItem.getFilePath(), realItem.getStagingStatus()); @@ -777,19 +777,19 @@ export default class StagingView extends React.Component { } async showFilePatchItem(filePath, stagingStatus, {activate, pane} = {activate: false}) { - const uri = FilePatchItem.buildURI(filePath, this.props.workingDirectoryPath, stagingStatus); - const filePatchItem = await this.props.workspace.open( + const uri = ChangedFileItem.buildURI(filePath, this.props.workingDirectoryPath, stagingStatus); + const changedFileItem = await this.props.workspace.open( uri, {pending: true, activatePane: activate, activateItem: activate, pane}, ); if (activate) { - const itemRoot = filePatchItem.getElement(); + const itemRoot = changedFileItem.getElement(); const focusRoot = itemRoot.querySelector('[tabIndex]'); if (focusRoot) { focusRoot.focus(); } } else { // simply make item visible - this.props.workspace.paneForItem(filePatchItem).activateItem(filePatchItem); + this.props.workspace.paneForItem(changedFileItem).activateItem(changedFileItem); } } diff --git a/lib/watch-workspace-item.js b/lib/watch-workspace-item.js new file mode 100644 index 00000000000..e55fdae9ba1 --- /dev/null +++ b/lib/watch-workspace-item.js @@ -0,0 +1,88 @@ +import {CompositeDisposable} from 'atom'; + +import URIPattern from './atom/uri-pattern'; + +class ItemWatcher { + constructor(workspace, pattern, component, stateKey) { + this.workspace = workspace; + this.pattern = pattern instanceof URIPattern ? pattern : new URIPattern(pattern); + this.component = component; + this.stateKey = stateKey; + + this.itemCount = this.getItemCount(); + this.subs = new CompositeDisposable(); + } + + setInitialState() { + if (!this.component.state) { + this.component.state = {}; + } + this.component.state[this.stateKey] = this.itemCount > 0; + return this; + } + + subscribeToWorkspace() { + this.subs.dispose(); + this.subs = new CompositeDisposable( + this.workspace.onDidAddPaneItem(this.itemAdded), + this.workspace.onDidDestroyPaneItem(this.itemDestroyed), + ); + return this; + } + + setPattern(pattern) { + const wasTrue = this.itemCount > 0; + + this.pattern = pattern instanceof URIPattern ? pattern : new URIPattern(pattern); + + // Update the item count to match the new pattern + this.itemCount = this.getItemCount(); + + // Update the component's state if it's changed as a result + if (wasTrue && this.itemCount <= 0) { + return new Promise(resolve => this.component.setState({[this.stateKey]: false}, resolve)); + } else if (!wasTrue && this.itemCount > 0) { + return new Promise(resolve => this.component.setState({[this.stateKey]: true}, resolve)); + } else { + return Promise.resolve(); + } + } + + itemMatches = item => item.getURI && this.pattern.matches(item.getURI()).ok() + + getItemCount() { + return this.workspace.getPaneItems().filter(this.itemMatches).length; + } + + itemAdded = ({item}) => { + const hadOpen = this.itemCount > 0; + if (this.itemMatches(item)) { + this.itemCount++; + + if (this.itemCount > 0 && !hadOpen) { + this.component.setState({[this.stateKey]: true}); + } + } + } + + itemDestroyed = ({item}) => { + const hadOpen = this.itemCount > 0; + if (this.itemMatches(item)) { + this.itemCount--; + + if (this.itemCount <= 0 && hadOpen) { + this.component.setState({[this.stateKey]: false}); + } + } + } + + dispose() { + this.subs.dispose(); + } +} + +export function watchWorkspaceItem(workspace, pattern, component, stateKey) { + return new ItemWatcher(workspace, pattern, component, stateKey) + .setInitialState() + .subscribeToWorkspace(); +} diff --git a/package.json b/package.json index 3504ad6e5f3..3617fd9f9d6 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,7 @@ "IssueishPaneItem": "createIssueishPaneItemStub", "GitDockItem": "createDockItemStub", "GithubDockItem": "createDockItemStub", - "FilePatchControllerStub": "createFilePatchControllerStub" + "FilePatchControllerStub": "createFilePatchControllerStub", + "CommitPreviewStub": "createCommitPreviewStub" } } diff --git a/styles/commit-preview-view.less b/styles/commit-preview-view.less new file mode 100644 index 00000000000..a0fa33e21b7 --- /dev/null +++ b/styles/commit-preview-view.less @@ -0,0 +1,21 @@ +@import "variables"; + +.github-CommitPreview-root { + overflow: auto; + z-index: 1; // Fixes scrollbar on macOS + + .github-FilePatchView { + height: auto; + border-bottom: 1px solid @base-border-color; + + &:last-child { + border-bottom: none; + } + + & + .github-FilePatchView { + margin-top: @component-padding; + border-top: 1px solid @base-border-color; + } + } + +} diff --git a/styles/commit-view.less b/styles/commit-view.less index b97f49b6f7f..5151292b49c 100644 --- a/styles/commit-view.less +++ b/styles/commit-view.less @@ -79,6 +79,12 @@ } } + &-buttonWrapper { + align-items: center; + display: flex; + margin-bottom: 10px; + } + &-coAuthorEditor { position: relative; margin-top: @component-padding / 2; diff --git a/styles/file-patch-view.less b/styles/file-patch-view.less index c04a3445b02..8fe131c6c03 100644 --- a/styles/file-patch-view.less +++ b/styles/file-patch-view.less @@ -163,7 +163,7 @@ .hunk-line-mixin(@bg;) { background-color: fade(@bg, 18%); - &.line.cursor-line { + .github-FilePatchView--active &.line.cursor-line { background-color: fade(@bg, 28%); } } @@ -225,4 +225,10 @@ } } } + + // Inactive + + &--inactive .highlights .highlight.selection { + display: none; + } } diff --git a/test/atom/uri-pattern.test.js b/test/atom/uri-pattern.test.js index 4cb77a52ade..a36f332401c 100644 --- a/test/atom/uri-pattern.test.js +++ b/test/atom/uri-pattern.test.js @@ -38,6 +38,14 @@ describe('URIPattern', function() { assert.isTrue(pattern.matches('proto://host/foo#exact').ok()); assert.isFalse(pattern.matches('proto://host/foo#nope').ok()); }); + + it('escapes and unescapes dashes', function() { + assert.isTrue( + new URIPattern('atom-github://with-many-dashes') + .matches('atom-github://with-many-dashes') + .ok(), + ); + }); }); describe('parameter placeholders', function() { diff --git a/test/containers/file-patch-container.test.js b/test/containers/changed-file-container.test.js similarity index 94% rename from test/containers/file-patch-container.test.js rename to test/containers/changed-file-container.test.js index a4e12a65f58..40d4c09b216 100644 --- a/test/containers/file-patch-container.test.js +++ b/test/containers/changed-file-container.test.js @@ -3,10 +3,10 @@ import fs from 'fs-extra'; import React from 'react'; import {mount} from 'enzyme'; -import FilePatchContainer from '../../lib/containers/file-patch-container'; +import ChangedFileContainer from '../../lib/containers/changed-file-container'; import {cloneRepository, buildRepository} from '../helpers'; -describe('FilePatchContainer', function() { +describe('ChangedFileContainer', function() { let atomEnv, repository; beforeEach(async function() { @@ -46,7 +46,7 @@ describe('FilePatchContainer', function() { ...overrideProps, }; - return ; + return ; } it('renders a loading spinner before file patch data arrives', function() { diff --git a/test/containers/commit-preview-container.test.js b/test/containers/commit-preview-container.test.js new file mode 100644 index 00000000000..b33b11f169d --- /dev/null +++ b/test/containers/commit-preview-container.test.js @@ -0,0 +1,59 @@ +import React from 'react'; +import {mount} from 'enzyme'; + +import CommitPreviewContainer from '../../lib/containers/commit-preview-container'; +import {cloneRepository, buildRepository} from '../helpers'; + +describe('CommitPreviewContainer', function() { + let atomEnv, repository; + + beforeEach(async function() { + atomEnv = global.buildAtomEnvironment(); + + const workdir = await cloneRepository(); + repository = await buildRepository(workdir); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(override = {}) { + const props = { + repository, + ...override, + }; + + return ; + } + + it('renders a loading spinner while the repository is loading', function() { + const wrapper = mount(buildApp()); + assert.isTrue(wrapper.find('LoadingView').exists()); + }); + + it('renders a loading spinner while the file patch is being loaded', async function() { + await repository.getLoadPromise(); + const patchPromise = repository.getStagedChangesPatch(); + let resolveDelayedPromise = () => {}; + const delayedPromise = new Promise(resolve => { + resolveDelayedPromise = resolve; + }); + sinon.stub(repository, 'getStagedChangesPatch').returns(delayedPromise); + + const wrapper = mount(buildApp()); + + assert.isTrue(wrapper.find('LoadingView').exists()); + resolveDelayedPromise(patchPromise); + await assert.async.isFalse(wrapper.update().find('LoadingView').exists()); + }); + + it('renders a MultiFilePatchController once the file patch is loaded', async function() { + await repository.getLoadPromise(); + const patch = await repository.getStagedChangesPatch(); + + const wrapper = mount(buildApp()); + await assert.async.isTrue(wrapper.update().find('MultiFilePatchController').exists()); + assert.strictEqual(wrapper.find('MultiFilePatchController').prop('multiFilePatch'), patch); + }); +}); diff --git a/test/controllers/commit-controller.test.js b/test/controllers/commit-controller.test.js index 3e716ea10da..8e450e80dc3 100644 --- a/test/controllers/commit-controller.test.js +++ b/test/controllers/commit-controller.test.js @@ -6,8 +6,10 @@ import {shallow, mount} from 'enzyme'; import Commit from '../../lib/models/commit'; import {nullBranch} from '../../lib/models/branch'; import UserStore from '../../lib/models/user-store'; +import URIPattern from '../../lib/atom/uri-pattern'; import CommitController, {COMMIT_GRAMMAR_SCOPE} from '../../lib/controllers/commit-controller'; +import CommitPreviewItem from '../../lib/items/commit-preview-item'; import {cloneRepository, buildRepository, buildRepositoryWithPipeline} from '../helpers'; import * as reporterProxy from '../../lib/reporter-proxy'; @@ -28,6 +30,16 @@ describe('CommitController', function() { const noop = () => {}; const store = new UserStore({config}); + // Ensure the Workspace doesn't mangle atom-github://... URIs + const pattern = new URIPattern(CommitPreviewItem.uriPattern); + workspace.addOpener(uri => { + if (pattern.matches(uri).ok()) { + return {getURI() { return uri; }}; + } else { + return undefined; + } + }); + app = ( item.getURI() === CommitPreviewItem.buildURI(workdir0))); + assert.isFalse(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir1))); + assert.isTrue(wrapper.find('CommitView').prop('commitPreviewOpen')); + + wrapper.setProps({repository: repository1}); + assert.isTrue(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir0))); + assert.isFalse(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir1))); + assert.isFalse(wrapper.find('CommitView').prop('commitPreviewOpen')); + + await wrapper.find('CommitView').prop('toggleCommitPreview')(); + assert.isTrue(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir0))); + assert.isTrue(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir1))); + assert.isTrue(wrapper.find('CommitView').prop('commitPreviewOpen')); + + await wrapper.find('CommitView').prop('toggleCommitPreview')(); + assert.isTrue(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir0))); + assert.isFalse(workspace.getPaneItems().some(item => item.getURI() === CommitPreviewItem.buildURI(workdir1))); + assert.isFalse(wrapper.find('CommitView').prop('commitPreviewOpen')); }); }); }); diff --git a/test/controllers/git-tab-controller.test.js b/test/controllers/git-tab-controller.test.js index 27838dd4274..3b2e059f87a 100644 --- a/test/controllers/git-tab-controller.test.js +++ b/test/controllers/git-tab-controller.test.js @@ -3,7 +3,6 @@ import path from 'path'; import React from 'react'; import {mount} from 'enzyme'; import dedent from 'dedent-js'; -import until from 'test-until'; import GitTabController from '../../lib/controllers/git-tab-controller'; import {gitTabControllerProps} from '../fixtures/props/git-tab-props'; @@ -277,165 +276,6 @@ describe('GitTabController', function() { }); }); - describe('keyboard navigation commands', function() { - let wrapper, rootElement, gitTab, stagingView, commitView, commitController, focusElement; - const focuses = GitTabController.focus; - - const extractReferences = () => { - rootElement = wrapper.instance().refRoot.get(); - gitTab = wrapper.instance().refView.get(); - stagingView = wrapper.instance().refStagingView.get(); - commitController = gitTab.refCommitController.get(); - commitView = commitController.refCommitView.get(); - focusElement = stagingView.element; - - const commitViewElements = []; - commitView.refEditorComponent.map(e => commitViewElements.push(e)); - commitView.refAbortMergeButton.map(e => commitViewElements.push(e)); - commitView.refCommitButton.map(e => commitViewElements.push(e)); - - const stubFocus = element => { - sinon.stub(element, 'focus').callsFake(() => { - focusElement = element; - }); - }; - stubFocus(stagingView.refRoot.get()); - for (const e of commitViewElements) { - stubFocus(e); - } - - sinon.stub(commitController, 'hasFocus').callsFake(() => { - return commitViewElements.includes(focusElement); - }); - }; - - const assertSelected = paths => { - const selectionPaths = Array.from(stagingView.state.selection.getSelectedItems()).map(item => item.filePath); - assert.deepEqual(selectionPaths, paths); - }; - - const assertAsyncSelected = paths => { - return assert.async.deepEqual( - Array.from(stagingView.state.selection.getSelectedItems()).map(item => item.filePath), - paths, - ); - }; - - describe('with conflicts and staged files', function() { - beforeEach(async function() { - const workdirPath = await cloneRepository('each-staging-group'); - const repository = await buildRepository(workdirPath); - - // Merge with conflicts - assert.isRejected(repository.git.merge('origin/branch')); - - fs.writeFileSync(path.join(workdirPath, 'unstaged-1.txt'), 'This is an unstaged file.'); - fs.writeFileSync(path.join(workdirPath, 'unstaged-2.txt'), 'This is an unstaged file.'); - fs.writeFileSync(path.join(workdirPath, 'unstaged-3.txt'), 'This is an unstaged file.'); - - // Three staged files - fs.writeFileSync(path.join(workdirPath, 'staged-1.txt'), 'This is a file with some changes staged for commit.'); - fs.writeFileSync(path.join(workdirPath, 'staged-2.txt'), 'This is another file staged for commit.'); - fs.writeFileSync(path.join(workdirPath, 'staged-3.txt'), 'This is a third file staged for commit.'); - await repository.stageFiles(['staged-1.txt', 'staged-2.txt', 'staged-3.txt']); - repository.refresh(); - - wrapper = mount(await buildApp(repository)); - await assert.async.lengthOf(wrapper.update().find('GitTabView').prop('unstagedChanges'), 3); - - extractReferences(); - }); - - it('blurs on tool-panel:unfocus', function() { - sinon.spy(workspace.getActivePane(), 'activate'); - - commandRegistry.dispatch(wrapper.find('.github-Git').getDOMNode(), 'tool-panel:unfocus'); - - assert.isTrue(workspace.getActivePane().activate.called); - }); - - it('advances focus through StagingView groups and CommitView, but does not cycle', async function() { - assertSelected(['unstaged-1.txt']); - - commandRegistry.dispatch(rootElement, 'core:focus-next'); - assertSelected(['conflict-1.txt']); - - commandRegistry.dispatch(rootElement, 'core:focus-next'); - assertSelected(['staged-1.txt']); - - commandRegistry.dispatch(rootElement, 'core:focus-next'); - assertSelected(['staged-1.txt']); - await assert.async.strictEqual(focusElement, wrapper.find('AtomTextEditor').instance()); - - // This should be a no-op. (Actually, it'll insert a tab in the CommitView editor.) - commandRegistry.dispatch(rootElement, 'core:focus-next'); - assertSelected(['staged-1.txt']); - assert.strictEqual(focusElement, wrapper.find('AtomTextEditor').instance()); - }); - - it('retreats focus from the CommitView through StagingView groups, but does not cycle', async function() { - gitTab.setFocus(focuses.EDITOR); - sinon.stub(commitView, 'hasFocusEditor').returns(true); - - commandRegistry.dispatch(rootElement, 'core:focus-previous'); - await assert.async.strictEqual(focusElement, stagingView.refRoot.get()); - assertSelected(['staged-1.txt']); - - commandRegistry.dispatch(rootElement, 'core:focus-previous'); - await assertAsyncSelected(['conflict-1.txt']); - - commandRegistry.dispatch(rootElement, 'core:focus-previous'); - await assertAsyncSelected(['unstaged-1.txt']); - - // This should be a no-op. - commandRegistry.dispatch(rootElement, 'core:focus-previous'); - await assertAsyncSelected(['unstaged-1.txt']); - }); - }); - - describe('with staged changes', function() { - let repository; - - beforeEach(async function() { - const workdirPath = await cloneRepository('each-staging-group'); - repository = await buildRepository(workdirPath); - - // A staged file - fs.writeFileSync(path.join(workdirPath, 'staged-1.txt'), 'This is a file with some changes staged for commit.'); - await repository.stageFiles(['staged-1.txt']); - repository.refresh(); - - const prepareToCommit = () => Promise.resolve(true); - const ensureGitTab = () => Promise.resolve(false); - - wrapper = mount(await buildApp(repository, {ensureGitTab, prepareToCommit})); - - extractReferences(); - await assert.async.isTrue(commitView.props.stagedChangesExist); - }); - - it('focuses the CommitView on github:commit with an empty commit message', async function() { - commitView.refEditorModel.map(e => e.setText('')); - sinon.spy(wrapper.instance(), 'commit'); - wrapper.update(); - - commandRegistry.dispatch(workspaceElement, 'github:commit'); - - await assert.async.strictEqual(focusElement, wrapper.find('AtomTextEditor').instance()); - assert.isFalse(wrapper.instance().commit.called); - }); - - it('creates a commit on github:commit with a nonempty commit message', async function() { - commitView.refEditorModel.map(e => e.setText('I fixed the things')); - sinon.spy(repository, 'commit'); - - commandRegistry.dispatch(workspaceElement, 'github:commit'); - - await until('Commit method called', () => repository.commit.calledWith('I fixed the things')); - }); - }); - }); - describe('integration tests', function() { it('can stage and unstage files and commit', async function() { const workdirPath = await cloneRepository('three-files'); diff --git a/test/controllers/multi-file-patch-controller.test.js b/test/controllers/multi-file-patch-controller.test.js new file mode 100644 index 00000000000..e1996c4c250 --- /dev/null +++ b/test/controllers/multi-file-patch-controller.test.js @@ -0,0 +1,76 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import MultiFilePatchController from '../../lib/controllers/multi-file-patch-controller'; +import {buildMultiFilePatch} from '../../lib/models/patch'; + +describe('MultiFilePatchController', function() { + let multiFilePatch; + + beforeEach(function() { + multiFilePatch = buildMultiFilePatch([ + { + oldPath: 'first', oldMode: '100644', newPath: 'first', newMode: '100755', status: 'modified', + hunks: [ + { + oldStartLine: 1, oldLineCount: 2, newStartLine: 1, newLineCount: 4, + lines: [' line-0', '+line-1', '+line-2', ' line-3'], + }, + ], + }, + { + oldPath: 'second', oldMode: '100644', newPath: 'second', newMode: '100644', status: 'modified', + hunks: [ + { + oldStartLine: 5, oldLineCount: 3, newStartLine: 5, newLineCount: 3, + lines: [' line-5', '+line-6', '-line-7', ' line-8'], + }, + ], + }, + { + oldPath: 'third', oldMode: '100755', newPath: 'third', newMode: '100755', status: 'added', + hunks: [ + { + oldStartLine: 1, oldLineCount: 0, newStartLine: 1, newLineCount: 3, + lines: ['+line-0', '+line-1', '+line-2'], + }, + ], + }, + ]); + }); + + function buildApp(override = {}) { + const props = { + multiFilePatch, + ...override, + }; + + return ; + } + + it('renders a FilePatchController for each file patch', function() { + const wrapper = shallow(buildApp()); + + assert.lengthOf(wrapper.find('FilePatchController'), 3); + + // O(n^2) doesn't matter when n is small :stars: + assert.isTrue( + multiFilePatch.getFilePatches().every(fp => { + return wrapper + .find('FilePatchController') + .someWhere(w => w.prop('filePatch') === fp); + }), + ); + }); + + it('passes additional props to each controller', function() { + const extra = Symbol('hooray'); + const wrapper = shallow(buildApp({extra})); + + assert.isTrue( + wrapper + .find('FilePatchController') + .everyWhere(w => w.prop('extra') === extra), + ); + }); +}); diff --git a/test/controllers/root-controller.test.js b/test/controllers/root-controller.test.js index 4d94f594ea8..5cd8fe293de 100644 --- a/test/controllers/root-controller.test.js +++ b/test/controllers/root-controller.test.js @@ -15,6 +15,7 @@ import GitTabItem from '../../lib/items/git-tab-item'; import GitHubTabItem from '../../lib/items/github-tab-item'; import ResolutionProgress from '../../lib/models/conflicts/resolution-progress'; import IssueishDetailItem from '../../lib/items/issueish-detail-item'; +import CommitPreviewItem from '../../lib/items/commit-preview-item'; import * as reporterProxy from '../../lib/reporter-proxy'; import RootController from '../../lib/controllers/root-controller'; @@ -989,13 +990,13 @@ describe('RootController', function() { editor.setCursorBufferPosition([7, 0]); // TODO: too implementation-detail-y - const filePatchItem = { + const changedFileItem = { goToDiffLine: sinon.spy(), focus: sinon.spy(), getRealItemPromise: () => Promise.resolve(), getFilePatchLoadedPromise: () => Promise.resolve(), }; - sinon.stub(workspace, 'open').returns(filePatchItem); + sinon.stub(workspace, 'open').returns(changedFileItem); await wrapper.instance().viewUnstagedChangesForCurrentFile(); await assert.async.equal(workspace.open.callCount, 1); @@ -1003,9 +1004,9 @@ describe('RootController', function() { `atom-github://file-patch/a.txt?workdir=${encodeURIComponent(workdirPath)}&stagingStatus=unstaged`, {pending: true, activatePane: true, activateItem: true}, ]); - await assert.async.equal(filePatchItem.goToDiffLine.callCount, 1); - assert.deepEqual(filePatchItem.goToDiffLine.args[0], [8]); - assert.equal(filePatchItem.focus.callCount, 1); + await assert.async.equal(changedFileItem.goToDiffLine.callCount, 1); + assert.deepEqual(changedFileItem.goToDiffLine.args[0], [8]); + assert.equal(changedFileItem.focus.callCount, 1); }); it('does nothing on an untitled buffer', async function() { @@ -1034,13 +1035,13 @@ describe('RootController', function() { editor.setCursorBufferPosition([7, 0]); // TODO: too implementation-detail-y - const filePatchItem = { + const changedFileItem = { goToDiffLine: sinon.spy(), focus: sinon.spy(), getRealItemPromise: () => Promise.resolve(), getFilePatchLoadedPromise: () => Promise.resolve(), }; - sinon.stub(workspace, 'open').returns(filePatchItem); + sinon.stub(workspace, 'open').returns(changedFileItem); await wrapper.instance().viewStagedChangesForCurrentFile(); await assert.async.equal(workspace.open.callCount, 1); @@ -1048,9 +1049,9 @@ describe('RootController', function() { `atom-github://file-patch/a.txt?workdir=${encodeURIComponent(workdirPath)}&stagingStatus=staged`, {pending: true, activatePane: true, activateItem: true}, ]); - await assert.async.equal(filePatchItem.goToDiffLine.callCount, 1); - assert.deepEqual(filePatchItem.goToDiffLine.args[0], [8]); - assert.equal(filePatchItem.focus.callCount, 1); + await assert.async.equal(changedFileItem.goToDiffLine.callCount, 1); + assert.deepEqual(changedFileItem.goToDiffLine.args[0], [8]); + assert.equal(changedFileItem.focus.callCount, 1); }); it('does nothing on an untitled buffer', async function() { @@ -1088,6 +1089,20 @@ describe('RootController', function() { }); }); + describe('opening a CommitPreviewItem', function() { + it('registers an opener for CommitPreviewItems', async function() { + const workdir = await cloneRepository('three-files'); + const repository = await buildRepository(workdir); + const wrapper = mount(React.cloneElement(app, {repository})); + + const uri = CommitPreviewItem.buildURI(workdir); + const item = await atomEnv.workspace.open(uri); + + assert.strictEqual(item.getTitle(), 'Commit preview'); + assert.lengthOf(wrapper.update().find('CommitPreviewItem'), 1); + }); + }); + describe('context commands trigger event reporting', function() { let wrapper; diff --git a/test/git-strategies.test.js b/test/git-strategies.test.js index 635b09dafd8..2c3bdcb7cc0 100644 --- a/test/git-strategies.test.js +++ b/test/git-strategies.test.js @@ -627,6 +627,32 @@ import * as reporterProxy from '../lib/reporter-proxy'; }); }); + describe('getStagedChangesPatch', function() { + it('returns an empty patch if there are no staged files', async function() { + const workdir = await cloneRepository('three-files'); + const git = createTestStrategy(workdir); + const mp = await git.getStagedChangesPatch(); + assert.lengthOf(mp, 0); + }); + + it('returns a combined diff of all staged files', async function() { + const workdir = await cloneRepository('each-staging-group'); + const git = createTestStrategy(workdir); + + await assert.isRejected(git.merge('origin/branch')); + await fs.writeFile(path.join(workdir, 'unstaged-1.txt'), 'Unstaged file'); + await fs.writeFile(path.join(workdir, 'unstaged-2.txt'), 'Unstaged file'); + + await fs.writeFile(path.join(workdir, 'staged-1.txt'), 'Staged file'); + await fs.writeFile(path.join(workdir, 'staged-2.txt'), 'Staged file'); + await fs.writeFile(path.join(workdir, 'staged-3.txt'), 'Staged file'); + await git.stageFiles(['staged-1.txt', 'staged-2.txt', 'staged-3.txt']); + + const diffs = await git.getStagedChangesPatch(); + assert.deepEqual(diffs.map(diff => diff.newPath), ['staged-1.txt', 'staged-2.txt', 'staged-3.txt']); + }); + }); + describe('isMerging', function() { it('returns true if `.git/MERGE_HEAD` exists', async function() { const workingDirPath = await cloneRepository('merge-conflict'); diff --git a/test/integration/file-patch.test.js b/test/integration/file-patch.test.js index ca2acba9a86..55bc811d629 100644 --- a/test/integration/file-patch.test.js +++ b/test/integration/file-patch.test.js @@ -75,15 +75,15 @@ describe('integration: file patches', function() { listItem.simulate('mousedown', {button: 0, persist() {}}); window.dispatchEvent(new MouseEvent('mouseup')); - const itemSelector = `FilePatchItem[relPath="${relativePath}"][stagingStatus="${stagingStatus}"]`; + const itemSelector = `ChangedFileItem[relPath="${relativePath}"][stagingStatus="${stagingStatus}"]`; await until( () => wrapper.update().find(itemSelector).find('.github-FilePatchView').exists(), - `the FilePatchItem for ${relativePath} arrives and loads`, + `the ChangedFileItem for ${relativePath} arrives and loads`, ); } function getPatchItem(stagingStatus, relativePath) { - return wrapper.update().find(`FilePatchItem[relPath="${relativePath}"][stagingStatus="${stagingStatus}"]`); + return wrapper.update().find(`ChangedFileItem[relPath="${relativePath}"][stagingStatus="${stagingStatus}"]`); } function getPatchEditor(stagingStatus, relativePath) { diff --git a/test/items/file-patch-item.test.js b/test/items/changed-file-item.test.js similarity index 87% rename from test/items/file-patch-item.test.js rename to test/items/changed-file-item.test.js index 00467f1d2fc..59f8d131011 100644 --- a/test/items/file-patch-item.test.js +++ b/test/items/changed-file-item.test.js @@ -3,11 +3,11 @@ import React from 'react'; import {mount} from 'enzyme'; import PaneItem from '../../lib/atom/pane-item'; -import FilePatchItem from '../../lib/items/file-patch-item'; +import ChangedFileItem from '../../lib/items/changed-file-item'; import WorkdirContextPool from '../../lib/models/workdir-context-pool'; import {cloneRepository} from '../helpers'; -describe('FilePatchItem', function() { +describe('ChangedFileItem', function() { let atomEnv, repository, pool; beforeEach(async function() { @@ -41,10 +41,10 @@ describe('FilePatchItem', function() { }; return ( - + {({itemHolder, params}) => { return ( - + {({itemHolder, params}) => { + return ( + + ); + }} + + ); + } + + function open(wrapper, options = {}) { + const opts = { + workingDirectory: repository.getWorkingDirectoryPath(), + ...options, + }; + const uri = CommitPreviewItem.buildURI(opts.workingDirectory); + return atomEnv.workspace.open(uri); + } + + it('constructs and opens the correct URI', async function() { + const wrapper = mount(buildPaneApp()); + await open(wrapper); + + assert.isTrue(wrapper.update().find('CommitPreviewItem').exists()); + }); + + it('passes extra props to its container', async function() { + const extra = Symbol('extra'); + const wrapper = mount(buildPaneApp({extra})); + await open(wrapper); + + assert.strictEqual(wrapper.update().find('CommitPreviewContainer').prop('extra'), extra); + }); + + it('locates the repository from the context pool', async function() { + const wrapper = mount(buildPaneApp()); + await open(wrapper); + + assert.strictEqual(wrapper.update().find('CommitPreviewContainer').prop('repository'), repository); + }); + + it('passes an absent repository if the working directory is unrecognized', async function() { + const wrapper = mount(buildPaneApp()); + await open(wrapper, {workingDirectory: '/nah'}); + + assert.isTrue(wrapper.update().find('CommitPreviewContainer').prop('repository').isAbsent()); + }); + + it('returns a fixed title and icon', async function() { + const wrapper = mount(buildPaneApp()); + const item = await open(wrapper); + + assert.strictEqual(item.getTitle(), 'Commit preview'); + assert.strictEqual(item.getIconName(), 'git-commit'); + }); + + it('terminates pending state', async function() { + const wrapper = mount(buildPaneApp()); + + const item = await open(wrapper); + const callback = sinon.spy(); + const sub = item.onDidTerminatePendingState(callback); + + assert.strictEqual(callback.callCount, 0); + item.terminatePendingState(); + assert.strictEqual(callback.callCount, 1); + item.terminatePendingState(); + assert.strictEqual(callback.callCount, 1); + + sub.dispose(); + }); + + it('may be destroyed once', async function() { + const wrapper = mount(buildPaneApp()); + + const item = await open(wrapper); + const callback = sinon.spy(); + const sub = item.onDidDestroy(callback); + + assert.strictEqual(callback.callCount, 0); + item.destroy(); + assert.strictEqual(callback.callCount, 1); + + sub.dispose(); + }); + + it('serializes itself as a CommitPreviewStub', async function() { + const wrapper = mount(buildPaneApp()); + const item0 = await open(wrapper, {workingDirectory: '/dir0'}); + assert.deepEqual(item0.serialize(), { + deserializer: 'CommitPreviewStub', + uri: 'atom-github://commit-preview?workdir=%2Fdir0', + }); + + const item1 = await open(wrapper, {workingDirectory: '/dir1'}); + assert.deepEqual(item1.serialize(), { + deserializer: 'CommitPreviewStub', + uri: 'atom-github://commit-preview?workdir=%2Fdir1', + }); + }); + + it('has an item-level accessor for the current working directory', async function() { + const wrapper = mount(buildPaneApp()); + const item = await open(wrapper, {workingDirectory: '/dir7'}); + assert.strictEqual(item.getWorkingDirectory(), '/dir7'); + }); +}); diff --git a/test/models/patch/builder.test.js b/test/models/patch/builder.test.js index 6b2ce62401e..987194ef824 100644 --- a/test/models/patch/builder.test.js +++ b/test/models/patch/builder.test.js @@ -1,5 +1,5 @@ -import {buildFilePatch} from '../../../lib/models/patch'; -import {assertInPatch} from '../../helpers'; +import {buildFilePatch, buildMultiFilePatch} from '../../../lib/models/patch'; +import {assertInPatch, assertInFilePatch} from '../../helpers'; describe('buildFilePatch', function() { it('returns a null patch for an empty diff list', function() { @@ -501,6 +501,200 @@ describe('buildFilePatch', function() { }); }); + describe('with multiple diffs', function() { + it('creates a MultiFilePatch containing each', function() { + const mp = buildMultiFilePatch([ + { + oldPath: 'first', oldMode: '100644', newPath: 'first', newMode: '100755', status: 'modified', + hunks: [ + { + oldStartLine: 1, oldLineCount: 2, newStartLine: 1, newLineCount: 4, + lines: [ + ' line-0', + '+line-1', + '+line-2', + ' line-3', + ], + }, + { + oldStartLine: 10, oldLineCount: 3, newStartLine: 12, newLineCount: 2, + lines: [ + ' line-4', + '-line-5', + ' line-6', + ], + }, + ], + }, + { + oldPath: 'second', oldMode: '100644', newPath: 'second', newMode: '100644', status: 'modified', + hunks: [ + { + oldStartLine: 5, oldLineCount: 3, newStartLine: 5, newLineCount: 3, + lines: [ + ' line-5', + '+line-6', + '-line-7', + ' line-8', + ], + }, + ], + }, + { + oldPath: 'third', oldMode: '100755', newPath: 'third', newMode: '100755', status: 'added', + hunks: [ + { + oldStartLine: 1, oldLineCount: 0, newStartLine: 1, newLineCount: 3, + lines: [ + '+line-0', + '+line-1', + '+line-2', + ], + }, + ], + }, + ]); + + assert.lengthOf(mp.getFilePatches(), 3); + assert.strictEqual(mp.getFilePatches()[0].getOldPath(), 'first'); + assertInFilePatch(mp.getFilePatches()[0]).hunks( + { + startRow: 0, endRow: 3, header: '@@ -1,2 +1,4 @@', regions: [ + {kind: 'unchanged', string: ' line-0\n', range: [[0, 0], [0, 6]]}, + {kind: 'addition', string: '+line-1\n+line-2\n', range: [[1, 0], [2, 6]]}, + {kind: 'unchanged', string: ' line-3\n', range: [[3, 0], [3, 6]]}, + ], + }, + { + startRow: 4, endRow: 6, header: '@@ -10,3 +12,2 @@', regions: [ + {kind: 'unchanged', string: ' line-4\n', range: [[4, 0], [4, 6]]}, + {kind: 'deletion', string: '-line-5\n', range: [[5, 0], [5, 6]]}, + {kind: 'unchanged', string: ' line-6\n', range: [[6, 0], [6, 6]]}, + ], + }, + ); + assert.strictEqual(mp.getFilePatches()[1].getOldPath(), 'second'); + assertInFilePatch(mp.getFilePatches()[1]).hunks( + { + startRow: 0, endRow: 3, header: '@@ -5,3 +5,3 @@', regions: [ + {kind: 'unchanged', string: ' line-5\n', range: [[0, 0], [0, 6]]}, + {kind: 'addition', string: '+line-6\n', range: [[1, 0], [1, 6]]}, + {kind: 'deletion', string: '-line-7\n', range: [[2, 0], [2, 6]]}, + {kind: 'unchanged', string: ' line-8\n', range: [[3, 0], [3, 6]]}, + ], + }, + ); + assert.strictEqual(mp.getFilePatches()[2].getOldPath(), 'third'); + assertInFilePatch(mp.getFilePatches()[2]).hunks( + { + startRow: 0, endRow: 2, header: '@@ -1,0 +1,3 @@', regions: [ + {kind: 'addition', string: '+line-0\n+line-1\n+line-2\n', range: [[0, 0], [2, 6]]}, + ], + }, + ); + }); + + it('identifies mode and content change pairs within the patch list', function() { + const mp = buildMultiFilePatch([ + { + oldPath: 'first', oldMode: '100644', newPath: 'first', newMode: '100755', status: 'modified', + hunks: [ + { + oldStartLine: 1, oldLineCount: 2, newStartLine: 1, newLineCount: 3, + lines: [ + ' line-0', + '+line-1', + ' line-2', + ], + }, + ], + }, + { + oldPath: 'was-non-symlink', oldMode: '100644', newPath: 'was-non-symlink', newMode: '000000', status: 'deleted', + hunks: [ + { + oldStartLine: 1, oldLineCount: 2, newStartLine: 1, newLineCount: 0, + lines: ['-line-0', '-line-1'], + }, + ], + }, + { + oldPath: 'was-symlink', oldMode: '000000', newPath: 'was-symlink', newMode: '100755', status: 'added', + hunks: [ + { + oldStartLine: 1, oldLineCount: 0, newStartLine: 1, newLineCount: 2, + lines: ['+line-0', '+line-1'], + }, + ], + }, + { + oldMode: '100644', newPath: 'third', newMode: '100644', status: 'deleted', + hunks: [ + { + oldStartLine: 1, oldLineCount: 3, newStartLine: 1, newLineCount: 0, + lines: ['-line-0', '-line-1', '-line-2'], + }, + ], + }, + { + oldPath: 'was-symlink', oldMode: '120000', newPath: 'was-non-symlink', newMode: '000000', status: 'deleted', + hunks: [ + { + oldStartLine: 1, oldLineCount: 0, newStartLine: 0, newLineCount: 0, + lines: ['-was-symlink-destination'], + }, + ], + }, + { + oldPath: 'was-non-symlink', oldMode: '000000', newPath: 'was-non-symlink', newMode: '120000', status: 'added', + hunks: [ + { + oldStartLine: 1, oldLineCount: 0, newStartLine: 1, newLineCount: 1, + lines: ['+was-non-symlink-destination'], + }, + ], + }, + ]); + + assert.lengthOf(mp.getFilePatches(), 4); + const [fp0, fp1, fp2, fp3] = mp.getFilePatches(); + + assert.strictEqual(fp0.getOldPath(), 'first'); + assertInFilePatch(fp0).hunks({ + startRow: 0, endRow: 2, header: '@@ -1,2 +1,3 @@', regions: [ + {kind: 'unchanged', string: ' line-0\n', range: [[0, 0], [0, 6]]}, + {kind: 'addition', string: '+line-1\n', range: [[1, 0], [1, 6]]}, + {kind: 'unchanged', string: ' line-2\n', range: [[2, 0], [2, 6]]}, + ], + }); + + assert.strictEqual(fp1.getOldPath(), 'was-non-symlink'); + assert.isTrue(fp1.hasTypechange()); + assert.strictEqual(fp1.getNewSymlink(), 'was-non-symlink-destination'); + assertInFilePatch(fp1).hunks({ + startRow: 0, endRow: 1, header: '@@ -1,2 +1,0 @@', regions: [ + {kind: 'deletion', string: '-line-0\n-line-1\n', range: [[0, 0], [1, 6]]}, + ], + }); + + assert.strictEqual(fp2.getOldPath(), 'was-symlink'); + assert.isTrue(fp2.hasTypechange()); + assert.strictEqual(fp2.getOldSymlink(), 'was-symlink-destination'); + assertInFilePatch(fp2).hunks({ + startRow: 0, endRow: 1, header: '@@ -1,0 +1,2 @@', regions: [ + {kind: 'addition', string: '+line-0\n+line-1\n', range: [[0, 0], [1, 6]]}, + ], + }); + + assert.strictEqual(fp3.getNewPath(), 'third'); + assertInFilePatch(fp3).hunks({ + startRow: 0, endRow: 2, header: '@@ -1,3 +1,0 @@', regions: [ + {kind: 'deletion', string: '-line-0\n-line-1\n-line-2\n', range: [[0, 0], [2, 6]]}, + ], + }); + }); + }); + it('throws an error with an unexpected number of diffs', function() { assert.throws(() => buildFilePatch([1, 2, 3]), /Unexpected number of diffs: 3/); }); diff --git a/test/models/patch/multi-file-patch.test.js b/test/models/patch/multi-file-patch.test.js new file mode 100644 index 00000000000..3c0a38f5257 --- /dev/null +++ b/test/models/patch/multi-file-patch.test.js @@ -0,0 +1,65 @@ +import {TextBuffer} from 'atom'; + +import MultiFilePatch from '../../../lib/models/patch/multi-file-patch'; +import FilePatch from '../../../lib/models/patch/file-patch'; +import File from '../../../lib/models/patch/file'; +import Patch from '../../../lib/models/patch/patch'; +import Hunk from '../../../lib/models/patch/hunk'; +import {Unchanged, Addition, Deletion} from '../../../lib/models/patch/region'; + +describe('MultiFilePatch', function() { + it('has an accessor for its file patches', function() { + const filePatches = [buildFilePatchFixture(0), buildFilePatchFixture(1)]; + const mp = new MultiFilePatch(filePatches); + assert.strictEqual(mp.getFilePatches(), filePatches); + }); +}); + +function buildFilePatchFixture(index) { + const buffer = new TextBuffer(); + for (let i = 0; i < 8; i++) { + buffer.append(`file-${index} line-${i}\n`); + } + + const layers = { + hunk: buffer.addMarkerLayer(), + unchanged: buffer.addMarkerLayer(), + addition: buffer.addMarkerLayer(), + deletion: buffer.addMarkerLayer(), + noNewline: buffer.addMarkerLayer(), + }; + + const mark = (layer, start, end = start) => layer.markRange([[start, 0], [end, Infinity]]); + + const hunks = [ + new Hunk({ + oldStartRow: 0, newStartRow: 0, oldRowCount: 3, newRowCount: 3, + sectionHeading: `file-${index} hunk-0`, + marker: mark(layers.hunk, 0, 3), + regions: [ + new Unchanged(mark(layers.unchanged, 0)), + new Addition(mark(layers.addition, 1)), + new Deletion(mark(layers.deletion, 2)), + new Unchanged(mark(layers.unchanged, 3)), + ], + }), + new Hunk({ + oldStartRow: 10, newStartRow: 10, oldRowCount: 3, newRowCount: 3, + sectionHeading: `file-${index} hunk-1`, + marker: mark(layers.hunk, 4, 7), + regions: [ + new Unchanged(mark(layers.unchanged, 4)), + new Addition(mark(layers.addition, 5)), + new Deletion(mark(layers.deletion, 6)), + new Unchanged(mark(layers.unchanged, 7)), + ], + }), + ]; + + const patch = new Patch({status: 'modified', hunks, buffer, layers}); + + const oldFile = new File({path: `file-${index}.txt`, mode: '100644'}); + const newFile = new File({path: `file-${index}.txt`, mode: '100644'}); + + return new FilePatch(oldFile, newFile, patch); +} diff --git a/test/models/repository.test.js b/test/models/repository.test.js index d1ad3a84c4f..f0fb42c4e06 100644 --- a/test/models/repository.test.js +++ b/test/models/repository.test.js @@ -440,6 +440,25 @@ describe('Repository', function() { }); }); + describe('getStagedChangesPatch', function() { + it('computes a multi-file patch of the staged changes', async function() { + const workdir = await cloneRepository('each-staging-group'); + const repo = new Repository(workdir); + await repo.getLoadPromise(); + + await fs.writeFile(path.join(workdir, 'unstaged-1.txt'), 'Unstaged file'); + + await fs.writeFile(path.join(workdir, 'staged-1.txt'), 'Staged file'); + await fs.writeFile(path.join(workdir, 'staged-2.txt'), 'Staged file'); + await repo.stageFiles(['staged-1.txt', 'staged-2.txt']); + + const mp = await repo.getStagedChangesPatch(); + + assert.lengthOf(mp.getFilePatches(), 2); + assert.deepEqual(mp.getFilePatches().map(fp => fp.getPath()), ['staged-1.txt', 'staged-2.txt']); + }); + }); + describe('isPartiallyStaged(filePath)', function() { it('returns true if specified file path is partially staged', async function() { const workingDirPath = await cloneRepository('three-files'); @@ -1473,6 +1492,10 @@ describe('Repository', function() { 'getRemotes', () => repository.getRemotes(), ); + calls.set( + 'getStagedChangesPatch', + () => repository.getStagedChangesPatch(), + ); const withFile = fileName => { calls.set( diff --git a/test/views/commit-view.test.js b/test/views/commit-view.test.js index 7666fa22dc4..fd182e4e501 100644 --- a/test/views/commit-view.test.js +++ b/test/views/commit-view.test.js @@ -43,6 +43,7 @@ describe('CommitView', function() { stagedChangesExist={false} mergeConflictsExist={false} isCommitting={false} + commitPreviewOpen={false} deactivateCommitBox={false} maximumCharacterLimit={72} messageBuffer={messageBuffer} @@ -359,6 +360,160 @@ describe('CommitView', function() { assert.isFalse(wrapper.instance().hasFocusEditor()); }); + describe('advancing focus', function() { + let wrapper, instance, event; + + beforeEach(function() { + wrapper = mount(app); + instance = wrapper.instance(); + event = {stopPropagation: sinon.spy()}; + + sinon.spy(instance, 'setFocus'); + }); + + it('does nothing and returns false if the focus is not in the commit view', function() { + sinon.stub(instance, 'getFocus').returns(null); + assert.isFalse(instance.advanceFocus(event)); + assert.isFalse(instance.setFocus.called); + assert.isFalse(event.stopPropagation.called); + }); + + it('moves focus to the commit editor if the commit preview button is focused', function() { + sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_PREVIEW_BUTTON); + + assert.isTrue(instance.advanceFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.EDITOR)); + assert.isTrue(event.stopPropagation.called); + }); + + it('inserts a tab if the commit editor is focused', function() { + sinon.stub(instance, 'getFocus').returns(CommitView.focus.EDITOR); + + assert.isTrue(instance.advanceFocus(event)); + assert.isFalse(event.stopPropagation.called); + }); + + it('moves focus to the commit button if the coauthor form is focused and no merge is in progress', function() { + sinon.stub(instance, 'getFocus').returns(CommitView.focus.COAUTHOR_INPUT); + + assert.isTrue(instance.advanceFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.COMMIT_BUTTON)); + assert.isTrue(event.stopPropagation.called); + }); + + it('moves focus to the abort merge button if the coauthor form is focused and a merge is in progress', function() { + wrapper.setProps({isMerging: true}); + sinon.stub(instance, 'getFocus').returns(CommitView.focus.COAUTHOR_INPUT); + + assert.isTrue(instance.advanceFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.ABORT_MERGE_BUTTON)); + assert.isTrue(event.stopPropagation.called); + }); + + it('moves focus to the commit button if the abort merge button is focused', function() { + sinon.stub(instance, 'getFocus').returns(CommitView.focus.ABORT_MERGE_BUTTON); + + assert.isTrue(instance.advanceFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.COMMIT_BUTTON)); + assert.isTrue(event.stopPropagation.called); + }); + + it('does nothing and returns true if the commit button is focused', function() { + sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_BUTTON); + + assert.isTrue(instance.advanceFocus(event)); + assert.isFalse(instance.setFocus.called); + assert.isTrue(event.stopPropagation.called); + }); + }); + + describe('retreating focus', function() { + let wrapper, instance, event; + + beforeEach(function() { + wrapper = mount(app); + instance = wrapper.instance(); + event = {stopPropagation: sinon.spy()}; + + sinon.spy(instance, 'setFocus'); + }); + + it('does nothing and returns false if the focus is not in the commit view', function() { + sinon.stub(instance, 'getFocus').returns(null); + + assert.isFalse(instance.retreatFocus(event)); + assert.isFalse(instance.setFocus.called); + assert.isFalse(event.stopPropagation.called); + }); + + it('moves focus to the abort merge button if the commit button is focused and a merge is in progress', function() { + wrapper.setProps({isMerging: true}); + sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_BUTTON); + + assert.isTrue(instance.retreatFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.ABORT_MERGE_BUTTON)); + assert.isTrue(event.stopPropagation.called); + }); + + it('moves focus to the editor if the commit button is focused and no merge is underway', function() { + sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_BUTTON); + + assert.isTrue(instance.retreatFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.EDITOR)); + assert.isTrue(event.stopPropagation.called); + }); + + it('moves focus to the co-author form if it is visible, the commit button is focused, and no merge', function() { + wrapper.setState({showCoAuthorInput: true}); + sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_BUTTON); + + assert.isTrue(instance.retreatFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.COAUTHOR_INPUT)); + assert.isTrue(event.stopPropagation.called); + }); + + it('moves focus to the co-author form if it is visible and the abort merge button is in focus', function() { + wrapper.setState({showCoAuthorInput: true}); + sinon.stub(instance, 'getFocus').returns(CommitView.focus.ABORT_MERGE_BUTTON); + + assert.isTrue(instance.retreatFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.COAUTHOR_INPUT)); + assert.isTrue(event.stopPropagation.called); + }); + + it('moves focus to the commit editor if the abort merge button is in focus', function() { + sinon.stub(instance, 'getFocus').returns(CommitView.focus.ABORT_MERGE_BUTTON); + + assert.isTrue(instance.retreatFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.EDITOR)); + assert.isTrue(event.stopPropagation.called); + }); + + it('moves focus to the commit editor if the co-author form is focused', function() { + sinon.stub(instance, 'getFocus').returns(CommitView.focus.COAUTHOR_INPUT); + + assert.isTrue(instance.retreatFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.EDITOR)); + assert.isTrue(event.stopPropagation.called); + }); + + it('moves focus to the commit preview button if the commit editor is focused', function() { + sinon.stub(instance, 'getFocus').returns(CommitView.focus.EDITOR); + + assert.isTrue(instance.retreatFocus(event)); + assert.isTrue(instance.setFocus.calledWith(CommitView.focus.COMMIT_PREVIEW_BUTTON)); + assert.isTrue(event.stopPropagation.called); + }); + + it('does nothing and returns false if the commit preview button is focused', function() { + sinon.stub(instance, 'getFocus').returns(CommitView.focus.COMMIT_PREVIEW_BUTTON); + + assert.isFalse(instance.retreatFocus(event)); + assert.isFalse(instance.setFocus.called); + assert.isFalse(event.stopPropagation.called); + }); + }); + it('remembers the current focus', function() { const wrapper = mount(React.cloneElement(app, {isMerging: true})); wrapper.instance().toggleCoAuthorInput(); @@ -369,6 +524,7 @@ describe('CommitView', function() { ['.github-CommitView-abortMerge', CommitView.focus.ABORT_MERGE_BUTTON], ['.github-CommitView-commit', CommitView.focus.COMMIT_BUTTON], ['.github-CommitView-coAuthorEditor input', CommitView.focus.COAUTHOR_INPUT], + ['.github-CommitView-commitPreview', CommitView.focus.COMMIT_PREVIEW_BUTTON], ]; for (const [selector, focus, subselector] of foci) { let target = wrapper.find(selector).getDOMNode(); @@ -382,6 +538,7 @@ describe('CommitView', function() { const holders = [ 'refEditorComponent', 'refEditorModel', 'refAbortMergeButton', 'refCommitButton', 'refCoAuthorSelect', + 'refCommitPreviewButton', ].map(ivar => wrapper.instance()[ivar]); for (const holder of holders) { holder.setter(null); @@ -390,6 +547,15 @@ describe('CommitView', function() { }); describe('restoring focus', function() { + it('to the commit preview button', function() { + const wrapper = mount(app); + const element = wrapper.find('.github-CommitView-commitPreview').getDOMNode(); + sinon.spy(element, 'focus'); + + assert.isTrue(wrapper.instance().setFocus(CommitView.focus.COMMIT_PREVIEW_BUTTON)); + assert.isTrue(element.focus.called); + }); + it('to the editor', function() { const wrapper = mount(app); const element = wrapper.find('AtomTextEditor').getDOMNode().querySelector('atom-text-editor'); @@ -448,6 +614,7 @@ describe('CommitView', function() { // Simulate an unmounted component by clearing out RefHolders manually. const holders = [ 'refEditorComponent', 'refEditorModel', 'refAbortMergeButton', 'refCommitButton', 'refCoAuthorSelect', + 'refCommitPreviewButton', ].map(ivar => wrapper.instance()[ivar]); for (const holder of holders) { holder.setter(null); @@ -458,4 +625,44 @@ describe('CommitView', function() { } }); }); + + describe('commit preview button', function() { + it('is enabled when there is staged changes', function() { + const wrapper = shallow(React.cloneElement(app, { + stagedChangesExist: true, + })); + assert.isFalse(wrapper.find('.github-CommitView-commitPreview').prop('disabled')); + }); + + it('is disabled when there\'s no staged changes', function() { + const wrapper = shallow(React.cloneElement(app, { + stagedChangesExist: false, + })); + assert.isTrue(wrapper.find('.github-CommitView-commitPreview').prop('disabled')); + }); + + it('calls a callback when the button is clicked', function() { + const toggleCommitPreview = sinon.spy(); + + const wrapper = shallow(React.cloneElement(app, { + toggleCommitPreview, + stagedChangesExist: true, + })); + + wrapper.find('.github-CommitView-commitPreview').simulate('click'); + assert.isTrue(toggleCommitPreview.called); + }); + + it('displays correct button text depending on prop value', function() { + const wrapper = shallow(app); + + assert.strictEqual(wrapper.find('.github-CommitView-commitPreview').text(), 'Preview Commit'); + + wrapper.setProps({commitPreviewOpen: true}); + assert.strictEqual(wrapper.find('.github-CommitView-commitPreview').text(), 'Close Commit Preview'); + + wrapper.setProps({commitPreviewOpen: false}); + assert.strictEqual(wrapper.find('.github-CommitView-commitPreview').text(), 'Preview Commit'); + }); + }); }); diff --git a/test/views/file-patch-view.test.js b/test/views/file-patch-view.test.js index f0c70afd89d..05a919011aa 100644 --- a/test/views/file-patch-view.test.js +++ b/test/views/file-patch-view.test.js @@ -53,6 +53,7 @@ describe('FilePatchView', function() { selectionMode: 'line', selectedRows: new Set(), repository, + isActive: true, workspace, config: atomEnv.config, @@ -99,6 +100,15 @@ describe('FilePatchView', function() { assert.strictEqual(editor.instance().getModel().getText(), filePatch.getBuffer().getText()); }); + it('enables autoHeight on the editor when requested', function() { + const wrapper = mount(buildApp({useEditorAutoHeight: true})); + + assert.isTrue(wrapper.find('AtomTextEditor').prop('autoHeight')); + + wrapper.setProps({useEditorAutoHeight: false}); + assert.isFalse(wrapper.find('AtomTextEditor').prop('autoHeight')); + }); + it('sets the root class when in hunk selection mode', function() { const wrapper = shallow(buildApp({selectionMode: 'line'})); assert.isFalse(wrapper.find('.github-FilePatchView--hunkMode').exists()); @@ -106,6 +116,15 @@ describe('FilePatchView', function() { assert.isTrue(wrapper.find('.github-FilePatchView--hunkMode').exists()); }); + it('sets the root class when active or inactive', function() { + const wrapper = shallow(buildApp({isActive: true})); + assert.isTrue(wrapper.find('.github-FilePatchView--active').exists()); + assert.isFalse(wrapper.find('.github-FilePatchView--inactive').exists()); + wrapper.setProps({isActive: false}); + assert.isFalse(wrapper.find('.github-FilePatchView--active').exists()); + assert.isTrue(wrapper.find('.github-FilePatchView--inactive').exists()); + }); + it('preserves the selection index when a new file patch arrives in line selection mode', function() { const selectedRowsChanged = sinon.spy(); const wrapper = mount(buildApp({ diff --git a/test/views/git-tab-view.test.js b/test/views/git-tab-view.test.js index ba684fc246c..efb57f37bec 100644 --- a/test/views/git-tab-view.test.js +++ b/test/views/git-tab-view.test.js @@ -83,17 +83,8 @@ describe('GitTabView', function() { event = {stopPropagation: sinon.spy()}; }); - it('does nothing if the commit controller has focus', async function() { - sinon.stub(commitController, 'hasFocus').returns(true); - sinon.spy(stagingView, 'activateNextList'); - - await wrapper.instance().advanceFocus(event); - - assert.isFalse(event.stopPropagation.called); - assert.isFalse(stagingView.activateNextList.called); - }); - it('activates the next staging view list and stops', async function() { + sinon.stub(commitController, 'advanceFocus').returns(false); sinon.stub(stagingView, 'activateNextList').resolves(true); sinon.spy(commitController, 'setFocus'); @@ -104,16 +95,27 @@ describe('GitTabView', function() { assert.isFalse(commitController.setFocus.called); }); - it('moves focus to the commit message editor from the end of the staging view', async function() { + it('moves focus to the commit preview button from the end of the staging view', async function() { + sinon.stub(commitController, 'advanceFocus').returns(false); sinon.stub(stagingView, 'activateNextList').resolves(false); sinon.stub(commitController, 'setFocus').returns(true); await wrapper.instance().advanceFocus(event); - assert.isTrue(commitController.setFocus.calledWith(GitTabView.focus.EDITOR)); + assert.isTrue(commitController.setFocus.calledWith(GitTabView.focus.COMMIT_PREVIEW_BUTTON)); assert.isTrue(event.stopPropagation.called); }); + it('advances focus within the commit view', async function() { + sinon.stub(commitController, 'advanceFocus').returns(true); + sinon.spy(stagingView, 'activateNextList'); + + await wrapper.instance().advanceFocus(event); + + assert.isTrue(commitController.advanceFocus.called); + assert.isFalse(stagingView.activateNextList.called); + }); + it('does nothing if refs are unavailable', async function() { wrapper.instance().refCommitController.setter(null); @@ -135,20 +137,8 @@ describe('GitTabView', function() { event = {stopPropagation: sinon.spy()}; }); - it('focuses the last staging list if the commit editor has focus', async function() { - sinon.stub(commitController, 'hasFocus').returns(true); - sinon.stub(commitController, 'hasFocusEditor').returns(true); - sinon.stub(stagingView, 'activateLastList').resolves(true); - - await wrapper.instance().retreatFocus(event); - - assert.isTrue(stagingView.activateLastList.called); - assert.isTrue(event.stopPropagation.called); - }); - - it('does nothing if the commit controller has focus but not in its editor', async function() { - sinon.stub(commitController, 'hasFocus').returns(true); - sinon.stub(commitController, 'hasFocusEditor').returns(false); + it('does nothing if the commit controller has focus but not in the preview button', async function() { + sinon.stub(commitController, 'retreatFocus').returns(true); sinon.spy(stagingView, 'activateLastList'); sinon.spy(stagingView, 'activatePreviousList'); @@ -159,8 +149,19 @@ describe('GitTabView', function() { assert.isFalse(event.stopPropagation.called); }); + it('focuses the last staging list if the commit preview button has focus', async function() { + sinon.stub(commitController, 'retreatFocus').returns(false); + sinon.stub(commitController, 'hasFocusAtBeginning').returns(true); + sinon.stub(stagingView, 'activateLastList').resolves(true); + + await wrapper.instance().retreatFocus(event); + + assert.isTrue(stagingView.activateLastList.called); + assert.isTrue(event.stopPropagation.called); + }); + it('activates the previous staging list and stops', async function() { - sinon.stub(commitController, 'hasFocus').returns(false); + sinon.stub(commitController, 'retreatFocus').returns(false); sinon.stub(stagingView, 'activatePreviousList').resolves(true); await wrapper.instance().retreatFocus(event); diff --git a/test/views/staging-view.test.js b/test/views/staging-view.test.js index 8c9fb955031..3fb74bea3df 100644 --- a/test/views/staging-view.test.js +++ b/test/views/staging-view.test.js @@ -224,12 +224,12 @@ describe('StagingView', function() { it('passes activation options and focuses the returned item if activate is true', async function() { const wrapper = mount(app); - const filePatchItem = { - getElement: () => filePatchItem, - querySelector: () => filePatchItem, + const changedFileItem = { + getElement: () => changedFileItem, + querySelector: () => changedFileItem, focus: sinon.spy(), }; - workspace.open.returns(filePatchItem); + workspace.open.returns(changedFileItem); await wrapper.instance().showFilePatchItem('file.txt', 'staged', {activate: true}); @@ -238,15 +238,15 @@ describe('StagingView', function() { `atom-github://file-patch/file.txt?workdir=${encodeURIComponent(workingDirectoryPath)}&stagingStatus=staged`, {pending: true, activatePane: true, pane: undefined, activateItem: true}, ]); - assert.isTrue(filePatchItem.focus.called); + assert.isTrue(changedFileItem.focus.called); }); it('makes the item visible if activate is false', async function() { const wrapper = mount(app); const focus = sinon.spy(); - const filePatchItem = {focus}; - workspace.open.returns(filePatchItem); + const changedFileItem = {focus}; + workspace.open.returns(changedFileItem); const activateItem = sinon.spy(); workspace.paneForItem.returns({activateItem}); @@ -259,7 +259,7 @@ describe('StagingView', function() { ]); assert.isFalse(focus.called); assert.equal(activateItem.callCount, 1); - assert.equal(activateItem.args[0][0], filePatchItem); + assert.equal(activateItem.args[0][0], changedFileItem); }); }); }); diff --git a/test/watch-workspace-item.test.js b/test/watch-workspace-item.test.js new file mode 100644 index 00000000000..f5926855466 --- /dev/null +++ b/test/watch-workspace-item.test.js @@ -0,0 +1,159 @@ +import {watchWorkspaceItem} from '../lib/watch-workspace-item'; +import URIPattern from '../lib/atom/uri-pattern'; + +describe('watchWorkspaceItem', function() { + let sub, atomEnv, workspace, component; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + workspace = atomEnv.workspace; + + component = { + state: {}, + setState: sinon.stub().callsFake((updater, cb) => cb && cb()), + }; + + workspace.addOpener(uri => { + if (uri.startsWith('atom-github://')) { + return { + getURI() { return uri; }, + }; + } else { + return undefined; + } + }); + }); + + afterEach(function() { + sub && sub.dispose(); + atomEnv.destroy(); + }); + + describe('initial state', function() { + it('creates component state if none is present', function() { + component.state = undefined; + + sub = watchWorkspaceItem(workspace, 'atom-github://item', component, 'aKey'); + assert.deepEqual(component.state, {aKey: false}); + }); + + it('is false when the pane is not open', async function() { + await workspace.open('atom-github://nonmatching'); + + sub = watchWorkspaceItem(workspace, 'atom-github://item', component, 'someKey'); + assert.isFalse(component.state.someKey); + }); + + it('is true when the pane is already open', async function() { + await workspace.open('atom-github://item/one'); + await workspace.open('atom-github://item/two'); + + sub = watchWorkspaceItem(workspace, 'atom-github://item/one', component, 'theKey'); + + assert.isTrue(component.state.theKey); + }); + + it('is true when multiple panes matching the URI pattern are open', async function() { + await workspace.open('atom-github://item/one'); + await workspace.open('atom-github://item/two'); + await workspace.open('atom-github://nonmatch'); + + sub = watchWorkspaceItem(workspace, 'atom-github://item/{pattern}', component, 'theKey'); + + assert.isTrue(component.state.theKey); + }); + + it('accepts a preconstructed URIPattern', async function() { + await workspace.open('atom-github://item/one'); + const u = new URIPattern('atom-github://item/{pattern}'); + + sub = watchWorkspaceItem(workspace, u, component, 'theKey'); + assert.isTrue(component.state.theKey); + }); + }); + + describe('workspace events', function() { + it('becomes true when the pane is opened', async function() { + sub = watchWorkspaceItem(workspace, 'atom-github://item/{pattern}', component, 'theKey'); + + assert.isFalse(component.state.theKey); + + await workspace.open('atom-github://item/match'); + + assert.isTrue(component.setState.calledWith({theKey: true})); + }); + + it('remains true if another matching pane is opened', async function() { + await workspace.open('atom-github://item/match0'); + sub = watchWorkspaceItem(workspace, 'atom-github://item/{pattern}', component, 'theKey'); + + assert.isTrue(component.state.theKey); + + await workspace.open('atom-github://item/match1'); + + assert.isFalse(component.setState.called); + }); + + it('remains true if a matching pane is closed but another remains open', async function() { + await workspace.open('atom-github://item/match0'); + await workspace.open('atom-github://item/match1'); + + sub = watchWorkspaceItem(workspace, 'atom-github://item/{pattern}', component, 'theKey'); + assert.isTrue(component.state.theKey); + + assert.isTrue(workspace.hide('atom-github://item/match1')); + + assert.isFalse(component.setState.called); + }); + + it('becomes false if the last matching pane is closed', async function() { + await workspace.open('atom-github://item/match0'); + await workspace.open('atom-github://item/match1'); + + sub = watchWorkspaceItem(workspace, 'atom-github://item/{pattern}', component, 'theKey'); + assert.isTrue(component.state.theKey); + + assert.isTrue(workspace.hide('atom-github://item/match1')); + assert.isTrue(workspace.hide('atom-github://item/match0')); + + assert.isTrue(component.setState.calledWith({theKey: false})); + }); + }); + + it('stops updating when disposed', async function() { + sub = watchWorkspaceItem(workspace, 'atom-github://item', component, 'theKey'); + assert.isFalse(component.state.theKey); + + sub.dispose(); + await workspace.open('atom-github://item'); + assert.isFalse(component.setState.called); + + await workspace.hide('atom-github://item'); + assert.isFalse(component.setState.called); + }); + + describe('setPattern', function() { + it('immediately updates the state based on the new pattern', async function() { + sub = watchWorkspaceItem(workspace, 'atom-github://item0/{pattern}', component, 'theKey'); + assert.isFalse(component.state.theKey); + + await workspace.open('atom-github://item1/match'); + assert.isFalse(component.setState.called); + + await sub.setPattern('atom-github://item1/{pattern}'); + assert.isFalse(component.state.theKey); + assert.isTrue(component.setState.calledWith({theKey: true})); + }); + + it('uses the new pattern to keep state up to date', async function() { + sub = watchWorkspaceItem(workspace, 'atom-github://item0/{pattern}', component, 'theKey'); + await sub.setPattern('atom-github://item1/{pattern}'); + + await workspace.open('atom-github://item0/match'); + assert.isFalse(component.setState.called); + + await workspace.open('atom-github://item1/match'); + assert.isTrue(component.setState.calledWith({theKey: true})); + }); + }); +});