Skip to content
This repository was archived by the owner on Dec 15, 2022. It is now read-only.
41 changes: 41 additions & 0 deletions lib/context-menu-interceptor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';

export default class ContextMenuInterceptor extends React.Component {
static propTypes = {
onWillShowContextMenu: PropTypes.func.isRequired,
children: PropTypes.element.isRequired,
}

static registration = new Map()

static handle(event) {
for (const [element, callback] of ContextMenuInterceptor.registration) {
if (element.contains(event.target)) {
callback(event);
}
}
}

static dispose() {
document.removeEventListener('contextmenu', contextMenuHandler, {capture: true});
}

componentDidMount() {
// Helpfully, addEventListener dedupes listeners for us.
document.addEventListener('contextmenu', contextMenuHandler, {capture: true});
ContextMenuInterceptor.registration.set(this.element, this.props.onWillShowContextMenu);
}

render() {
return <div ref={e => { this.element = e; }}>{this.props.children}</div>;
}

componentWillUnmount() {
ContextMenuInterceptor.registration.delete(this.element);
}
}

function contextMenuHandler(event) {
ContextMenuInterceptor.handle(event);
}
2 changes: 2 additions & 0 deletions lib/github-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import StubItem from './atom-items/stub-item';
import Switchboard from './switchboard';
import yardstick from './yardstick';
import GitTimingsView from './views/git-timings-view';
import ContextMenuInterceptor from './context-menu-interceptor';
import AsyncQueue from './async-queue';
import WorkerManager from './worker-manager';

Expand Down Expand Up @@ -71,6 +72,7 @@ export default class GithubPackage {
this.setActiveContext(WorkdirContext.absent());
}
}),
ContextMenuInterceptor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sure I understand why this is necessary, if registrations only happen on mount and are removed on unmount.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ContextMenuInterceptor uses a single native event listener attached to the document that's shared among all instances. Each instance registers and deregisters an element in a Map, but the event listener itself isn't removed until the static ContextMenuInterceptor.dispose() method is called. This bit ☝️ ensures that we don't leak listeners on package deactivation.

... Although, this might be overkill. I could just register a new document listener for each instance and check element.includes(event.target)? I was intending to minimize the number of listeners we register but maybe a single listener that does an O(n) operation on a Map isn't any better.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gave the simplification here a shot and our test suite leaked a ton of event listeners, because Enzyme doesn't trigger componentWillUnmount. I think I'm just going to leave it as -is with the static Map.

);

this.setupYardstick();
Expand Down
10 changes: 5 additions & 5 deletions lib/views/file-patch-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,20 +193,20 @@ export default class FilePatchView extends React.Component {
@autobind
contextMenuOnItem(event, hunk, line) {
const resend = () => {
event.stopPropagation();

const newEvent = new MouseEvent(event.type, event);
requestAnimationFrame(() => {
event.target.parentNode.dispatchEvent(newEvent);
});
setImmediate(() => event.target.parentNode.dispatchEvent(newEvent));
};

const mode = this.state.selection.getMode();
if (mode === 'hunk' && !this.state.selection.getSelectedHunks().has(hunk)) {
event.stopPropagation();

this.setState(prevState => {
return {selection: prevState.selection.selectHunk(hunk, event.shiftKey)};
}, resend);
} else if (mode === 'line' && !this.state.selection.getSelectedLines().has(line)) {
event.stopPropagation();

this.setState(prevState => {
return {selection: prevState.selection.selectLine(line, event.shiftKey)};
}, resend);
Expand Down
27 changes: 15 additions & 12 deletions lib/views/hunk-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import {autobind} from 'core-decorators';

import ContextMenuInterceptor from '../context-menu-interceptor';

export default class HunkView extends React.Component {
static propTypes = {
hunk: PropTypes.object.isRequired,
Expand Down Expand Up @@ -102,19 +104,20 @@ class LineView extends React.Component {
const lineSelectedClass = this.props.isSelected ? 'is-selected' : '';

return (
<div
className={`github-HunkView-line ${lineSelectedClass} is-${line.getStatus()}`}
onMouseDown={event => this.props.mousedown(event, line)}
onMouseMove={event => this.props.mousemove(event, line)}
onContextMenu={event => this.props.contextMenuOnItem(event, line)}
ref={e => this.props.registerLineElement(line, e)}>
<div className="github-HunkView-lineNumber is-old">{oldLineNumber}</div>
<div className="github-HunkView-lineNumber is-new">{newLineNumber}</div>
<div className="github-HunkView-lineContent">
<span className="github-HunkView-plusMinus">{line.getOrigin()}</span>
<span>{line.getText()}</span>
<ContextMenuInterceptor onWillShowContextMenu={event => this.props.contextMenuOnItem(event, line)}>
<div
className={`github-HunkView-line ${lineSelectedClass} is-${line.getStatus()}`}
onMouseDown={event => this.props.mousedown(event, line)}
onMouseMove={event => this.props.mousemove(event, line)}
ref={e => this.props.registerLineElement(line, e)}>
<div className="github-HunkView-lineNumber is-old">{oldLineNumber}</div>
<div className="github-HunkView-lineNumber is-new">{newLineNumber}</div>
<div className="github-HunkView-lineContent">
<span className="github-HunkView-plusMinus">{line.getOrigin()}</span>
<span>{line.getText()}</span>
</div>
</div>
</div>
</ContextMenuInterceptor>
);
}
}
148 changes: 148 additions & 0 deletions test/context-menu-interceptor.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import React from 'react';
import {mount} from 'enzyme';

import ContextMenuInterceptor from '../lib/context-menu-interceptor';

class SampleComponent extends React.Component {
render() {
return (
<div className="parent">
<div className="child">
This element has content.
</div>
</div>
);
}
}

describe('ContextMenuInterceptor', function() {
let rootElement, rootHandler, rootHandlerCalled;

beforeEach(function() {
rootHandlerCalled = false;
rootHandler = event => {
rootHandlerCalled = true;
event.preventDefault();
return false;
};
document.addEventListener('contextmenu', rootHandler);

rootElement = document.createElement('div');
document.body.appendChild(rootElement);
});

afterEach(function() {
document.removeEventListener('contextmenu', rootHandler);
ContextMenuInterceptor.dispose();
rootElement.remove();
});

it('responds to native contextmenu events before they reach the document root', function() {
let interceptorHandlerCalled = false;
let rootHandlerCalledFirst = false;
const handler = () => {
interceptorHandlerCalled = true;
rootHandlerCalledFirst = rootHandlerCalled;
};

const wrapper = mount(
<ContextMenuInterceptor onWillShowContextMenu={handler}>
<SampleComponent />
</ContextMenuInterceptor>,
{attachTo: rootElement},
);

const targetDOMNode = wrapper.find('.child').getDOMNode();
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
targetDOMNode.dispatchEvent(event);

assert.isTrue(interceptorHandlerCalled);
assert.isFalse(rootHandlerCalledFirst);
assert.isTrue(rootHandlerCalled);
});

it('can prevent event propagation', function() {
let interceptorHandlerCalled = false;
const handler = () => {
interceptorHandlerCalled = true;
event.stopPropagation();
};

const wrapper = mount(
<ContextMenuInterceptor onWillShowContextMenu={handler}>
<SampleComponent />
</ContextMenuInterceptor>,
{attachTo: rootElement},
);

const targetDOMNode = wrapper.find('.child').getDOMNode();
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
targetDOMNode.dispatchEvent(event);

assert.isTrue(interceptorHandlerCalled);
assert.isFalse(rootHandlerCalled);
});

it('ignores contextmenu events from other children', function() {
let interceptorHandlerCalled = false;
const handler = () => {
interceptorHandlerCalled = true;
};

const wrapper = mount(
<div>
<ContextMenuInterceptor onWillShowContextMenu={handler}>
<SampleComponent />
</ContextMenuInterceptor>
<div className="unrelated">
<div className="otherNode">
This is another div.
</div>
</div>
</div>,
{attachTo: rootElement},
);

const targetDOMNode = wrapper.find('.otherNode').getDOMNode();
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
targetDOMNode.dispatchEvent(event);

assert.isFalse(interceptorHandlerCalled);
assert.isTrue(rootHandlerCalled);
});

it('cleans itself up on .dispose()', function() {
let interceptorHandlerCalled = false;
const handler = () => {
interceptorHandlerCalled = true;
};

const wrapper = mount(
<ContextMenuInterceptor onWillShowContextMenu={handler}>
<SampleComponent />
</ContextMenuInterceptor>,
{attachTo: rootElement},
);

ContextMenuInterceptor.dispose();

const targetDOMNode = wrapper.find('.child').getDOMNode();
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
targetDOMNode.dispatchEvent(event);

assert.isFalse(interceptorHandlerCalled);
assert.isTrue(rootHandlerCalled);
});
});
16 changes: 10 additions & 6 deletions test/controllers/file-patch-controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@ describe('FilePatchController', function() {

const opPromise0 = switchboard.getFinishStageOperationPromise();
const hunkView0 = wrapper.find('HunkView').at(0);
hunkView0.find('LineView').at(1).simulate('mousedown', {button: 0, detail: 1});
hunkView0.find('LineView').at(3).simulate('mousemove', {});
hunkView0.find('LineView').at(1).find('.github-HunkView-line').simulate('mousedown', {button: 0, detail: 1});
hunkView0.find('LineView').at(3).find('.github-HunkView-line').simulate('mousemove', {});
window.dispatchEvent(new MouseEvent('mouseup'));
hunkView0.find('button.github-HunkView-stageButton').simulate('click');
await opPromise0;
Expand Down Expand Up @@ -329,9 +329,11 @@ describe('FilePatchController', function() {
await updatePromise2;

const hunkView2 = wrapper.find('HunkView').at(0);
hunkView2.find('LineView').at(1).simulate('mousedown', {button: 0, detail: 1});
hunkView2.find('LineView').at(1).find('.github-HunkView-line')
.simulate('mousedown', {button: 0, detail: 1});
window.dispatchEvent(new MouseEvent('mouseup'));
hunkView2.find('LineView').at(2).simulate('mousedown', {button: 0, detail: 1, metaKey: true});
hunkView2.find('LineView').at(2).find('.github-HunkView-line')
.simulate('mousedown', {button: 0, detail: 1, metaKey: true});
window.dispatchEvent(new MouseEvent('mouseup'));

const opPromise2 = switchboard.getFinishStageOperationPromise();
Expand Down Expand Up @@ -442,7 +444,8 @@ describe('FilePatchController', function() {
}));

const hunkView0 = wrapper.find('HunkView').at(0);
hunkView0.find('LineView').at(1).simulate('mousedown', {button: 0, detail: 1});
hunkView0.find('LineView').at(1).find('.github-HunkView-line')
.simulate('mousedown', {button: 0, detail: 1});
window.dispatchEvent(new MouseEvent('mouseup'));

// stage lines in rapid succession
Expand All @@ -467,7 +470,8 @@ describe('FilePatchController', function() {
await line1PatchPromise;

const hunkView1 = wrapper.find('HunkView').at(0);
hunkView1.find('LineView').at(2).simulate('mousedown', {button: 0, detail: 1});
hunkView1.find('LineView').at(2).find('.github-HunkView-line')
.simulate('mousedown', {button: 0, detail: 1});
window.dispatchEvent(new MouseEvent('mouseup'));

const line2StagingPromise = switchboard.getFinishStageOperationPromise();
Expand Down
4 changes: 4 additions & 0 deletions test/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import sinon from 'sinon';
import Repository from '../lib/models/repository';
import GitShellOutStrategy from '../lib/git-shell-out-strategy';
import WorkerManager from '../lib/worker-manager';
import ContextMenuInterceptor from '../lib/context-menu-interceptor';

assert.autocrlfEqual = (actual, expected, ...args) => {
const newActual = actual.replace(/\r\n/g, '\n');
Expand Down Expand Up @@ -186,6 +187,9 @@ beforeEach(function() {
afterEach(function() {
activeRenderers.forEach(r => r.unmount());
activeRenderers = [];

ContextMenuInterceptor.dispose();

global.sinon.restore();
});

Expand Down