/**
* @provides phabricator-diff-changeset-list
* @requires javelin-install
* phuix-button-view
* phabricator-diff-tree-view
* @javelin
*/
JX.install('DiffChangesetList', {
construct: function() {
this._changesets = [];
var onload = JX.bind(this, this._ifawake, this._onload);
JX.Stratcom.listen('click', 'differential-load', onload);
var onmore = JX.bind(this, this._ifawake, this._onmore);
JX.Stratcom.listen('click', 'show-more', onmore);
var onmenu = JX.bind(this, this._ifawake, this._onmenu);
JX.Stratcom.listen('click', 'differential-view-options', onmenu);
var onexpand = JX.bind(this, this._ifawake, this._oncollapse, false);
JX.Stratcom.listen('click', 'reveal-inline', onexpand);
var onresize = JX.bind(this, this._ifawake, this._onresize);
JX.Stratcom.listen('resize', null, onresize);
var onscroll = JX.bind(this, this._ifawake, this._onscroll);
JX.Stratcom.listen('scroll', null, onscroll);
var onselect = JX.bind(this, this._ifawake, this._onselect);
JX.Stratcom.listen(
'mousedown',
['differential-inline-comment', 'differential-inline-header'],
onselect);
var onhover = JX.bind(this, this._ifawake, this._onhover);
JX.Stratcom.listen(
['mouseover', 'mouseout'],
'differential-inline-comment',
onhover);
var onrangedown = JX.bind(this, this._ifawake, this._onrangedown);
JX.Stratcom.listen(
'mousedown',
['differential-changeset', 'tag:td'],
onrangedown);
var onrangemove = JX.bind(this, this._ifawake, this._onrangemove);
JX.Stratcom.listen(
['mouseover', 'mouseout'],
['differential-changeset', 'tag:td'],
onrangemove);
var onrangeup = JX.bind(this, this._ifawake, this._onrangeup);
JX.Stratcom.listen(
'mouseup',
null,
onrangeup);
this._setupInlineCommentListeners();
},
properties: {
translations: null,
inlineURI: null,
inlineListURI: null,
isStandalone: false,
formationView: null
},
members: {
_initialized: false,
_asleep: true,
_changesets: null,
_cursorItem: null,
_focusNode: null,
_focusStart: null,
_focusEnd: null,
_hoverNode: null,
_hoverInline: null,
_hoverOrigin: null,
_hoverTarget: null,
_rangeActive: false,
_rangeOrigin: null,
_rangeTarget: null,
_bannerNode: null,
_unsavedButton: null,
_unsubmittedButton: null,
_doneButton: null,
_doneMode: null,
_dropdownMenu: null,
_menuButton: null,
_menuItems: null,
_selectedChangeset: null,
sleep: function() {
this._asleep = true;
this._redrawFocus();
this._redrawSelection();
this.resetHover();
this._bannerChangeset = null;
this._redrawBanner();
},
wake: function() {
this._asleep = false;
this._redrawFocus();
this._redrawSelection();
this._bannerChangeset = null;
this._redrawBanner();
this._redrawFiletree();
if (this._initialized) {
return;
}
this._initialized = true;
var pht = this.getTranslations();
// We may be viewing the normal "/D123" view (with all the changesets)
// or the standalone view (with just one changeset). In the standalone
// view, some options (like jumping to next or previous file) do not
// make sense and do not function.
var standalone = this.getIsStandalone();
var label;
if (!standalone) {
label = pht('Jump to the table of contents.');
this._installKey('t', 'diff-nav', label, this._ontoc);
label = pht('Jump to the comment area.');
this._installKey('x', 'diff-nav', label, this._oncomments);
}
label = pht('Jump to next change.');
this._installJumpKey('j', label, 1);
label = pht('Jump to previous change.');
this._installJumpKey('k', label, -1);
if (!standalone) {
label = pht('Jump to next file.');
this._installJumpKey('J', label, 1, 'file');
label = pht('Jump to previous file.');
this._installJumpKey('K', label, -1, 'file');
}
label = pht('Jump to next inline comment.');
this._installJumpKey('n', label, 1, 'comment');
label = pht('Jump to previous inline comment.');
this._installJumpKey('p', label, -1, 'comment');
label = pht('Jump to next inline comment, including collapsed comments.');
this._installJumpKey('N', label, 1, 'comment', true);
label = pht(
'Jump to previous inline comment, including collapsed comments.');
this._installJumpKey('P', label, -1, 'comment', true);
var formation = this.getFormationView();
if (formation) {
var filetree = formation.getColumn(0);
var toggletree = JX.bind(filetree, filetree.toggleVisibility);
label = pht('Hide or show the paths panel.');
this._installKey('f', 'diff-vis', label, toggletree);
}
if (!standalone) {
label = pht('Hide or show the current changeset.');
this._installKey('h', 'diff-vis', label, this._onkeytogglefile);
}
label = pht('Reply to selected inline comment or change.');
this._installKey('r', 'inline', label,
JX.bind(this, this._onkeyreply, false));
label = pht('Reply and quote selected inline comment.');
this._installKey('R', 'inline', label,
JX.bind(this, this._onkeyreply, true));
label = pht('Edit selected inline comment.');
this._installKey('e', 'inline', label, this._onkeyedit);
label = pht('Mark or unmark selected inline comment as done.');
this._installKey('w', 'inline', label, this._onkeydone);
label = pht('Collapse or expand inline comment.');
this._installKey('q', 'diff-vis', label, this._onkeycollapse);
label = pht('Hide or show all inline comments.');
this._installKey('A', 'diff-vis', label, this._onkeyhideall);
label = pht('Show path in repository.');
this._installKey('d', 'diff-nav', label, this._onkeyshowpath);
label = pht('Show directory in repository.');
this._installKey('D', 'diff-nav', label, this._onkeyshowdirectory);
label = pht('Open file in external editor.');
this._installKey('\\', 'diff-nav', label, this._onkeyopeneditor);
},
isAsleep: function() {
return this._asleep;
},
newChangesetForNode: function(node) {
var changeset = JX.DiffChangeset.getForNode(node);
this._changesets.push(changeset);
changeset.setChangesetList(this);
return changeset;
},
getChangesetForNode: function(node) {
return JX.DiffChangeset.getForNode(node);
},
getInlineByID: function(id) {
var inline = null;
for (var ii = 0; ii < this._changesets.length; ii++) {
inline = this._changesets[ii].getInlineByID(id);
if (inline) {
break;
}
}
return inline;
},
_ifawake: function(f) {
// This function takes another function and only calls it if the
// changeset list is awake, so we basically just ignore events when we
// are asleep. This may move up the stack at some point as we do more
// with Quicksand/Sheets.
if (this.isAsleep()) {
return;
}
return f.apply(this, [].slice.call(arguments, 1));
},
_onload: function(e) {
var data = e.getNodeData('differential-load');
// NOTE: We can trigger a load from either an explicit "Load" link on
// the changeset, or by clicking a link in the table of contents. If
// the event was a table of contents link, we let the anchor behavior
// run normally.
if (data.kill) {
e.kill();
}
var node = JX.$(data.id);
var changeset = this.getChangesetForNode(node);
changeset.load();
// TODO: Move this into Changeset.
var routable = changeset.getRoutable();
if (routable) {
routable.setPriority(2000);
}
},
_installKey: function(key, group, label, handler) {
handler = JX.bind(this, this._ifawake, handler);
return new JX.KeyboardShortcut(key, label)
.setHandler(handler)
.setGroup(group)
.register();
},
_installJumpKey: function(key, label, delta, filter, show_collapsed) {
filter = filter || null;
var options = {
filter: filter,
collapsed: show_collapsed
};
var handler = JX.bind(this, this._onjumpkey, delta, options);
return this._installKey(key, 'diff-nav', label, handler);
},
_ontoc: function(manager) {
var toc = JX.$('toc');
manager.scrollTo(toc);
},
_oncomments: function(manager) {
var reply = JX.$('reply');
manager.scrollTo(reply);
},
getSelectedInline: function() {
var cursor = this._cursorItem;
if (cursor) {
if (cursor.type == 'comment') {
return cursor.target;
}
}
return null;
},
_onkeyreply: function(is_quote) {
var cursor = this._cursorItem;
if (cursor) {
if (cursor.type == 'comment') {
var inline = cursor.target;
if (inline.canReply()) {
this.setFocus(null);
inline.reply(true);
return;
}
}
// If the keyboard cursor is selecting a range of lines, we may have
// a mixture of old and new changes on the selected rows. It is not
// entirely unambiguous what the user means when they say they want
// to reply to this, but we use this logic: reply on the new file if
// there are any new lines. Otherwise (if there are only removed
// lines) reply on the old file.
if (cursor.type == 'change') {
var origin = cursor.nodes.begin;
var target = cursor.nodes.end;
// The "origin" and "target" are entire rows, but we need to find
// a range of "
| " nodes to actually create an inline, so go
// fishing.
var old_list = [];
var new_list = [];
var row = origin;
while (row) {
var header = row.firstChild;
while (header) {
if (this.getLineNumberFromHeader(header)) {
if (header.className.indexOf('old') !== -1) {
old_list.push(header);
} else if (header.className.indexOf('new') !== -1) {
new_list.push(header);
}
}
header = header.nextSibling;
}
if (row == target) {
break;
}
row = row.nextSibling;
}
var use_list;
if (new_list.length) {
use_list = new_list;
} else {
use_list = old_list;
}
var src = use_list[0];
var dst = use_list[use_list.length - 1];
cursor.changeset.newInlineForRange(src, dst);
this.setFocus(null);
return;
}
}
var pht = this.getTranslations();
this._warnUser(pht('You must select a comment or change to reply to.'));
},
_onkeyedit: function() {
var cursor = this._cursorItem;
if (cursor) {
if (cursor.type == 'comment') {
var inline = cursor.target;
if (inline.canEdit()) {
this.setFocus(null);
inline.edit();
return;
}
}
}
var pht = this.getTranslations();
this._warnUser(pht('You must select a comment to edit.'));
},
_onkeydone: function() {
var cursor = this._cursorItem;
if (cursor) {
if (cursor.type == 'comment') {
var inline = cursor.target;
if (inline.canDone()) {
this.setFocus(null);
inline.toggleDone();
return;
}
}
}
var pht = this.getTranslations();
this._warnUser(pht('You must select a comment to mark done.'));
},
_onkeytogglefile: function() {
var pht = this.getTranslations();
var changeset = this._getChangesetForKeyCommand();
if (!changeset) {
this._warnUser(pht('You must select a file to hide or show.'));
return;
}
changeset.toggleVisibility();
},
_getChangesetForKeyCommand: function() {
var cursor = this._cursorItem;
var changeset;
if (cursor) {
changeset = cursor.changeset;
}
if (!changeset) {
changeset = this._getVisibleChangeset();
}
return changeset;
},
_onkeyopeneditor: function() {
var pht = this.getTranslations();
var changeset = this._getChangesetForKeyCommand();
if (!changeset) {
this._warnUser(pht('You must select a file to edit.'));
return;
}
var editor_uri = changeset.getEditorURI();
if (editor_uri === null) {
this._warnUser(pht('No external editor is configured.'));
return;
}
JX.$U(editor_uri).go();
},
_onkeyshowpath: function() {
this._onrepositorykey(false);
},
_onkeyshowdirectory: function() {
this._onrepositorykey(true);
},
_onrepositorykey: function(is_directory) {
var pht = this.getTranslations();
var changeset = this._getChangesetForKeyCommand();
if (!changeset) {
this._warnUser(pht('You must select a file to open.'));
return;
}
var show_uri;
if (is_directory) {
show_uri = changeset.getShowDirectoryURI();
} else {
show_uri = changeset.getShowPathURI();
}
if (show_uri === null) {
return;
}
window.open(show_uri);
},
_onkeycollapse: function() {
var cursor = this._cursorItem;
if (cursor) {
if (cursor.type == 'comment') {
var inline = cursor.target;
if (inline.canCollapse()) {
this.setFocus(null);
inline.setCollapsed(!inline.isCollapsed());
return;
}
}
}
var pht = this.getTranslations();
this._warnUser(pht('You must select a comment to hide.'));
},
_onkeyhideall: function() {
var inlines = this._getInlinesByType();
if (inlines.visible.length) {
this._toggleInlines('all');
} else {
this._toggleInlines('show');
}
},
_warnUser: function(message) {
new JX.Notification()
.setContent(message)
.alterClassName('jx-notification-alert', true)
.setDuration(3000)
.show();
},
_onjumpkey: function(delta, options) {
var state = this._getSelectionState();
var filter = options.filter || null;
var collapsed = options.collapsed || false;
var wrap = options.wrap || false;
var attribute = options.attribute || null;
var show = options.show || false;
var cursor = state.cursor;
var items = state.items;
// If there's currently no selection and the user tries to go back,
// don't do anything.
if ((cursor === null) && (delta < 0)) {
return;
}
var did_wrap = false;
while (true) {
if (cursor === null) {
cursor = 0;
} else {
cursor = cursor + delta;
}
// If we've gone backward past the first change, bail out.
if (cursor < 0) {
return;
}
// If we've gone forward off the end of the list, figure out where we
// should end up.
if (cursor >= items.length) {
if (!wrap) {
// If we aren't wrapping around, we're done.
return;
}
if (did_wrap) {
// If we're already wrapped around, we're done.
return;
}
// Otherwise, wrap the cursor back to the top.
cursor = 0;
did_wrap = true;
}
// If we're selecting things of a particular type (like only files)
// and the next item isn't of that type, move past it.
if (filter !== null) {
if (items[cursor].type !== filter) {
continue;
}
}
// If the item is collapsed, don't select it when iterating with jump
// keys. It can still potentially be selected in other ways.
if (!collapsed) {
if (items[cursor].collapsed) {
continue;
}
}
// If the item has been deleted, don't select it when iterating. The
// cursor may remain on it until it is removed.
if (items[cursor].deleted) {
continue;
}
// If we're selecting things with a particular attribute, like
// "unsaved", skip items without the attribute.
if (attribute !== null) {
if (!(items[cursor].attributes || {})[attribute]) {
continue;
}
}
// If this item is a hidden inline but we're clicking a button which
// selects inlines of a particular type, make it visible again.
if (items[cursor].hidden) {
if (!show) {
continue;
}
items[cursor].target.setHidden(false);
}
// Otherwise, we've found a valid item to select.
break;
}
this._setSelectionState(items[cursor], true);
},
_getSelectionState: function() {
var items = this._getSelectableItems();
var cursor = null;
if (this._cursorItem !== null) {
for (var ii = 0; ii < items.length; ii++) {
var item = items[ii];
if (this._cursorItem.target === item.target) {
cursor = ii;
break;
}
}
}
return {
cursor: cursor,
items: items
};
},
selectChangeset: function(changeset, scroll) {
var items = this._getSelectableItems();
var cursor = null;
for (var ii = 0; ii < items.length; ii++) {
var item = items[ii];
if (changeset === item.target) {
cursor = ii;
break;
}
}
if (cursor !== null) {
this._setSelectionState(items[cursor], scroll);
} else {
this._setSelectionState(null, false);
}
return this;
},
_setSelectionState: function(item, scroll) {
this._cursorItem = item;
this._redrawSelection(scroll);
return this;
},
_redrawSelection: function(scroll) {
var cursor = this._cursorItem;
if (!cursor) {
this.setFocus(null);
return;
}
// If this item has been removed from the document (for example: create
// a new empty comment, then use the "Unsaved" button to select it, then
// cancel it), we can still keep the cursor here but do not want to show
// a selection reticle over an invisible node.
if (cursor.deleted) {
this.setFocus(null);
return;
}
var changeset = cursor.changeset;
var tree = this._getTreeView();
if (changeset) {
tree.setSelectedPath(cursor.changeset.getPathView());
} else {
tree.setSelectedPath(null);
}
this._selectChangeset(changeset);
this.setFocus(cursor.nodes.begin, cursor.nodes.end);
if (scroll) {
var pos = JX.$V(cursor.nodes.begin);
JX.DOM.scrollToPosition(0, pos.y - 60);
}
return this;
},
redrawCursor: function() {
// NOTE: This is setting the cursor to the current cursor. Usually, this
// would have no effect.
// However, if the old cursor pointed at an inline and the inline has
// been edited so the rows have changed, this updates the cursor to point
// at the new inline with the proper rows for the current state, and
// redraws the reticle correctly.
var state = this._getSelectionState();
if (state.cursor !== null) {
this._setSelectionState(state.items[state.cursor], false);
}
},
_getSelectableItems: function() {
var result = [];
for (var ii = 0; ii < this._changesets.length; ii++) {
var items = this._changesets[ii].getSelectableItems();
for (var jj = 0; jj < items.length; jj++) {
result.push(items[jj]);
}
}
return result;
},
_onhover: function(e) {
if (e.getIsTouchEvent()) {
return;
}
var inline;
if (e.getType() == 'mouseout') {
inline = null;
} else {
inline = this._getInlineForEvent(e);
}
this._setHoverInline(inline);
},
_onmore: function(e) {
e.kill();
var node = e.getNode('differential-changeset');
var changeset = this.getChangesetForNode(node);
var data = e.getNodeData('show-more');
var target = e.getNode('context-target');
changeset.loadContext(data.range, target);
},
_onmenu: function(e) {
var button = e.getNode('differential-view-options');
var data = JX.Stratcom.getData(button);
if (data.menu) {
// We've already built this menu, so we can let the menu itself handle
// the event.
return;
}
e.prevent();
var pht = this.getTranslations();
var node = JX.DOM.findAbove(
button,
'div',
'differential-changeset');
var changeset_list = this;
var changeset = this.getChangesetForNode(node);
var menu = new JX.PHUIXDropdownMenu(button)
.setWidth(240);
var list = new JX.PHUIXActionListView();
var add_link = function(icon, name, href, local) {
var link = new JX.PHUIXActionView()
.setIcon(icon)
.setName(name)
.setHandler(function(e) {
if (local) {
window.location.assign(href);
} else {
window.open(href);
}
menu.close();
e.prevent();
});
if (href) {
link.setHref(href);
} else {
link
.setDisabled(true)
.setUnresponsive(true);
}
list.addItem(link);
return link;
};
var visible_item = new JX.PHUIXActionView()
.setKeyCommand('h')
.setHandler(function(e) {
e.prevent();
menu.close();
changeset.select(false);
changeset.toggleVisibility();
});
list.addItem(visible_item);
var reveal_item = new JX.PHUIXActionView()
.setIcon('fa-eye');
list.addItem(reveal_item);
list.addItem(
new JX.PHUIXActionView()
.setDivider(true));
var up_item = new JX.PHUIXActionView()
.setHandler(function(e) {
if (changeset.isLoaded()) {
// Don't let the user swap display modes if a comment is being
// edited, since they might lose their work. See PHI180.
var inlines = changeset.getInlines();
for (var ii = 0; ii < inlines.length; ii++) {
if (inlines[ii].isEditing()) {
changeset_list._warnUser(
pht(
'Finish editing inline comments before changing display ' +
'modes.'));
e.prevent();
menu.close();
return;
}
}
var renderer = changeset.getRendererKey();
if (renderer == '1up') {
renderer = '2up';
} else {
renderer = '1up';
}
changeset.reload({renderer: renderer});
} else {
changeset.reload();
}
e.prevent();
menu.close();
});
list.addItem(up_item);
var encoding_item = new JX.PHUIXActionView()
.setIcon('fa-font')
.setName(pht('Change Text Encoding...'))
.setHandler(function(e) {
var params = {
encoding: changeset.getCharacterEncoding()
};
new JX.Workflow('/services/encoding/', params)
.setHandler(function(r) {
changeset.reload({encoding: r.encoding});
})
.start();
e.prevent();
menu.close();
});
list.addItem(encoding_item);
var highlight_item = new JX.PHUIXActionView()
.setIcon('fa-sun-o')
.setName(pht('Highlight As...'))
.setHandler(function(e) {
var params = {
highlight: changeset.getHighlight()
};
new JX.Workflow('/services/highlight/', params)
.setHandler(function(r) {
changeset.reload({highlight: r.highlight});
})
.start();
e.prevent();
menu.close();
});
list.addItem(highlight_item);
var engine_item = new JX.PHUIXActionView()
.setIcon('fa-file-image-o')
.setName(pht('View As Document Type...'))
.setHandler(function(e) {
var options = changeset.getAvailableDocumentEngineKeys() || [];
options = options.join(',');
var params = {
engine: changeset.getResponseDocumentEngineKey(),
options: options
};
new JX.Workflow('/services/viewas/', params)
.setHandler(function(r) {
changeset.reload({engine: r.engine});
})
.start();
e.prevent();
menu.close();
});
list.addItem(engine_item);
list.addItem(
new JX.PHUIXActionView()
.setDivider(true));
add_link('fa-external-link', pht('View Standalone'), data.standaloneURI);
add_link('fa-arrow-left', pht('Show Raw File (Left)'), data.leftURI);
add_link('fa-arrow-right', pht('Show Raw File (Right)'), data.rightURI);
add_link(
'fa-folder-open-o',
pht('Show Directory in Repository'),
changeset.getShowDirectoryURI())
.setKeyCommand('D');
add_link(
'fa-file-text-o',
pht('Show Path in Repository'),
changeset.getShowPathURI())
.setKeyCommand('d');
var editor_uri = changeset.getEditorURI();
if (editor_uri !== null) {
add_link('fa-i-cursor', pht('Open in Editor'), editor_uri, true)
.setKeyCommand('\\');
} else {
var configure_uri = changeset.getEditorConfigureURI();
if (configure_uri !== null) {
add_link('fa-wrench', pht('Configure Editor'), configure_uri);
}
}
menu.setContent(list.getNode());
menu.listen('open', function() {
// When the user opens the menu, check if there are any "Show More"
// links in the changeset body. If there aren't, disable the "Show
// Entire File" menu item since it won't change anything.
var nodes = JX.DOM.scry(JX.$(data.containerID), 'a', 'show-more');
if (nodes.length) {
reveal_item
.setDisabled(false)
.setName(pht('Show All Context'))
.setIcon('fa-arrows-v')
.setHandler(function(e) {
changeset.loadAllContext();
e.prevent();
menu.close();
});
} else {
reveal_item
.setDisabled(true)
.setUnresponsive(true)
.setIcon('fa-file')
.setName(pht('All Context Shown'))
.setHref(null);
}
encoding_item.setDisabled(!changeset.isLoaded());
highlight_item.setDisabled(!changeset.isLoaded());
engine_item.setDisabled(!changeset.isLoaded());
if (changeset.isLoaded()) {
if (changeset.getRendererKey() == '2up') {
up_item
.setIcon('fa-list-alt')
.setName(pht('View Unified Diff'));
} else {
up_item
.setIcon('fa-columns')
.setName(pht('View Side-by-Side Diff'));
}
} else {
up_item
.setIcon('fa-refresh')
.setName(pht('Load Changes'));
}
visible_item
.setDisabled(true)
.setIcon('fa-eye-slash')
.setName(pht('Hide Changeset'));
var diffs = JX.DOM.scry(
JX.$(data.containerID),
'table',
'differential-diff');
if (diffs.length > 1) {
JX.$E(
'More than one node with sigil "differential-diff" was found in "'+
data.containerID+'."');
} else if (diffs.length == 1) {
visible_item.setDisabled(false);
} else {
// Do nothing when there is no diff shown in the table. For example,
// the file is binary.
}
});
data.menu = menu;
changeset.setViewMenu(menu);
menu.open();
},
_oncollapse: function(is_collapse, e) {
e.kill();
var inline = this._getInlineForEvent(e);
inline.setCollapsed(is_collapse);
},
_onresize: function() {
this._redrawFocus();
this._redrawSelection();
this._redrawHover();
// Force a banner redraw after a resize event. Particularly, this makes
// sure the inline state updates immediately after an inline edit
// operation, even if the changeset itself has not changed.
this._bannerChangeset = null;
this._redrawBanner();
var changesets = this._changesets;
for (var ii = 0; ii < changesets.length; ii++) {
changesets[ii].redrawFileTree();
}
},
_onscroll: function() {
this._redrawBanner();
},
_onselect: function(e) {
// If the user clicked some element inside the header, like an action
// icon, ignore the event. They have to click the header element itself.
if (e.getTarget() !== e.getNode('differential-inline-header')) {
return;
}
var inline = this._getInlineForEvent(e);
if (!inline) {
return;
}
// The user definitely clicked an inline, so we're going to handle the
// event.
e.kill();
this.selectInline(inline);
},
selectInline: function(inline, force, scroll) {
var selection = this._getSelectionState();
var item;
if (!force) {
// If the comment the user clicked is currently selected, deselect it.
// This makes it easy to undo things if you clicked by mistake.
if (selection.cursor !== null) {
item = selection.items[selection.cursor];
if (item.target === inline) {
this._setSelectionState(null, false);
return;
}
}
}
// Otherwise, select the item that the user clicked. This makes it
// easier to resume keyboard operations after using the mouse to do
// something else.
var items = selection.items;
for (var ii = 0; ii < items.length; ii++) {
item = items[ii];
if (item.target === inline) {
this._setSelectionState(item, scroll);
}
}
},
redrawPreview: function() {
// TODO: This isn't the cleanest way to find the preview form, but
// rendering no longer has direct access to it.
var forms = JX.DOM.scry(document.body, 'form', 'transaction-append');
if (forms.length) {
JX.DOM.invoke(forms[0], 'shouldRefresh');
}
// Clear the mouse hover reticle after a substantive edit: we don't get
// a "mouseout" event if the row vanished because of row being removed
// after an edit.
this.resetHover();
},
setFocus: function(node, extended_node) {
if (!node) {
var tree = this._getTreeView();
tree.setSelectedPath(null);
this._selectChangeset(null);
}
this._focusStart = node;
this._focusEnd = extended_node;
this._redrawFocus();
},
_selectChangeset: function(changeset) {
if (this._selectedChangeset === changeset) {
return;
}
if (this._selectedChangeset !== null) {
this._selectedChangeset.setIsSelected(false);
this._selectedChangeset = null;
}
this._selectedChangeset = changeset;
if (this._selectedChangeset !== null) {
this._selectedChangeset.setIsSelected(true);
}
},
_redrawFocus: function() {
var node = this._focusStart;
var extended_node = this._focusEnd || node;
var reticle = this._getFocusNode();
if (!node || this.isAsleep()) {
JX.DOM.remove(reticle);
return;
}
// Outset the reticle some pixels away from the element, so there's some
// space between the focused element and the outline.
var p = JX.Vector.getPos(node);
var s = JX.Vector.getAggregateScrollForNode(node);
var d = JX.Vector.getDim(node);
p.add(s).add(d.x + 1, 4).setPos(reticle);
// Compute the size we need to extend to the full extent of the focused
// nodes.
JX.Vector.getPos(extended_node)
.add(-p.x, -p.y)
.add(0, JX.Vector.getDim(extended_node).y)
.add(10, -4)
.setDim(reticle);
JX.DOM.getContentFrame().appendChild(reticle);
},
_getFocusNode: function() {
if (!this._focusNode) {
var node = JX.$N('div', {className : 'keyboard-focus-focus-reticle'});
this._focusNode = node;
}
return this._focusNode;
},
_setHoverInline: function(inline) {
this._hoverInline = inline;
if (inline) {
var changeset = inline.getChangeset();
var changeset_id;
var side = inline.getDisplaySide();
if (side == 'right') {
changeset_id = changeset.getRightChangesetID();
} else {
changeset_id = changeset.getLeftChangesetID();
}
var new_part;
if (inline.isNewFile()) {
new_part = 'N';
} else {
new_part = 'O';
}
var prefix = 'C' + changeset_id + new_part + 'L';
var number = inline.getLineNumber();
var length = inline.getLineLength();
try {
var origin = JX.$(prefix + number);
var target = JX.$(prefix + (number + length));
this._hoverOrigin = origin;
this._hoverTarget = target;
} catch (error) {
// There may not be any nodes present in the document. A case where
// this occurs is when you reply to a ghost inline which was made
// on lines near the bottom of "long.txt" in an earlier diff, and
// the file was later shortened so those lines no longer exist. For
// more details, see T11662.
this._hoverOrigin = null;
this._hoverTarget = null;
}
} else {
this._hoverOrigin = null;
this._hoverTarget = null;
}
this._redrawHover();
},
_setHoverRange: function(origin, target) {
this._hoverOrigin = origin;
this._hoverTarget = target;
this._redrawHover();
},
resetHover: function() {
this._setHoverInline(null);
this._hoverOrigin = null;
this._hoverTarget = null;
},
_redrawHover: function() {
var reticle = this._getHoverNode();
if (!this._hoverOrigin || this.isAsleep()) {
JX.DOM.remove(reticle);
return;
}
JX.DOM.getContentFrame().appendChild(reticle);
var top = this._hoverOrigin;
var bot = this._hoverTarget;
if (JX.$V(top).y > JX.$V(bot).y) {
var tmp = top;
top = bot;
bot = tmp;
}
// Find the leftmost cell that we're going to highlight. This is the
// next sibling with a "data-copy-mode" attribute, which is a marker
// for the cell with actual content in it.
var content_cell = top;
while (content_cell && !content_cell.getAttribute('data-copy-mode')) {
content_cell = content_cell.nextSibling;
}
// If we didn't find a cell to highlight, don't highlight anything.
if (!content_cell) {
return;
}
var pos = JX.$V(content_cell)
.add(JX.Vector.getAggregateScrollForNode(content_cell));
var dim = JX.$V(content_cell)
.add(JX.Vector.getAggregateScrollForNode(content_cell))
.add(-pos.x, -pos.y)
.add(JX.Vector.getDim(content_cell));
var bpos = JX.$V(bot)
.add(JX.Vector.getAggregateScrollForNode(bot));
dim.y = (bpos.y - pos.y) + JX.Vector.getDim(bot).y;
pos.setPos(reticle);
dim.setDim(reticle);
JX.DOM.show(reticle);
},
_getHoverNode: function() {
if (!this._hoverNode) {
var attributes = {
className: 'differential-reticle'
};
this._hoverNode = JX.$N('div', attributes);
}
return this._hoverNode;
},
_deleteInlineByID: function(id) {
var uri = this.getInlineURI();
var data = {
op: 'refdelete',
id: id
};
var handler = JX.bind(this, this.redrawPreview);
new JX.Workflow(uri, data)
.setHandler(handler)
.start();
},
_getInlineForEvent: function(e) {
var node = e.getNode('differential-changeset');
if (!node) {
return null;
}
var changeset = this.getChangesetForNode(node);
var inline_row = e.getNode('inline-row');
return changeset.getInlineForRow(inline_row);
},
getLineNumberFromHeader: function(node) {
var n = parseInt(node.getAttribute('data-n'));
if (!n) {
return null;
}
// If this is a line number that's part of a row showing more context,
// we don't want to let users leave inlines here.
try {
JX.DOM.findAbove(node, 'tr', 'context-target');
return null;
} catch (ex) {
// Ignore.
}
return n;
},
getDisplaySideFromHeader: function(th) {
return (th.parentNode.firstChild != th) ? 'right' : 'left';
},
_onrangedown: function(e) {
// NOTE: We're allowing "mousedown" from a touch event through so users
// can leave inlines on a single line.
// See PHI985. We want to exclude both right-mouse and middle-mouse
// clicks from continuing.
if (!e.isLeftButton()) {
return;
}
if (this._rangeActive) {
return;
}
var target = e.getTarget();
var number = this.getLineNumberFromHeader(target);
if (!number) {
return;
}
e.kill();
this._rangeActive = true;
this._rangeOrigin = target;
this._rangeTarget = target;
this._setHoverRange(this._rangeOrigin, this._rangeTarget);
},
_onrangemove: function(e) {
if (e.getIsTouchEvent()) {
return;
}
var is_out = (e.getType() == 'mouseout');
var target = e.getTarget();
this._updateRange(target, is_out);
},
_updateRange: function(target, is_out) {
// Don't update the range if this target doesn't correspond to a line
// number. For instance, this may be a dead line number, like the empty
// line numbers on the left hand side of a newly added file.
var number = this.getLineNumberFromHeader(target);
if (!number) {
return;
}
if (this._rangeActive) {
var origin = this._hoverOrigin;
// Don't update the reticle if we're selecting a line range and the
// " | " under the cursor is on the wrong side of the file. You can
// only leave inline comments on the left or right side of a file, not
// across lines on both sides.
var origin_side = this.getDisplaySideFromHeader(origin);
var target_side = this.getDisplaySideFromHeader(target);
if (origin_side != target_side) {
return;
}
// Don't update the reticle if we're selecting a line range and the
// " | " under the cursor corresponds to a different file. You can
// only leave inline comments on lines in a single file, not across
// multiple files.
var origin_table = JX.DOM.findAbove(origin, 'table');
var target_table = JX.DOM.findAbove(target, 'table');
if (origin_table != target_table) {
return;
}
}
if (is_out) {
if (this._rangeActive) {
// If we're dragging a range, just leave the state as it is. This
// allows you to drag over something invalid while selecting a
// range without the range flickering or getting lost.
} else {
// Otherwise, clear the current range.
this.resetHover();
}
return;
}
if (this._rangeActive) {
this._rangeTarget = target;
} else {
this._rangeOrigin = target;
this._rangeTarget = target;
}
this._setHoverRange(this._rangeOrigin, this._rangeTarget);
},
_onrangeup: function(e) {
if (!this._rangeActive) {
return;
}
e.kill();
var origin = this._rangeOrigin;
var target = this._rangeTarget;
// If the user dragged a range from the bottom to the top, swap the node
// order around.
if (JX.$V(origin).y > JX.$V(target).y) {
var tmp = target;
target = origin;
origin = tmp;
}
var node = JX.DOM.findAbove(origin, null, 'differential-changeset');
var changeset = this.getChangesetForNode(node);
changeset.newInlineForRange(origin, target);
this._rangeActive = false;
this._rangeOrigin = null;
this._rangeTarget = null;
this.resetHover();
},
_redrawBanner: function() {
// If the inline comment menu is open and we've done a redraw, close it.
// In particular, this makes it close when you scroll the document:
// otherwise, it stays open but the banner moves underneath it.
if (this._dropdownMenu) {
this._dropdownMenu.close();
}
var node = this._getBannerNode();
var changeset = this._getVisibleChangeset();
var tree = this._getTreeView();
var formation = this.getFormationView();
if (!changeset) {
this._bannerChangeset = null;
JX.DOM.remove(node);
tree.setFocusedPath(null);
if (formation) {
formation.repaint();
}
return;
}
// Don't do anything if nothing has changed. This seems to avoid some
// flickering issues in Safari, at least.
if (this._bannerChangeset === changeset) {
return;
}
this._bannerChangeset = changeset;
var paths = tree.getPaths();
for (var ii = 0; ii < paths.length; ii++) {
var path = paths[ii];
if (path.getChangeset() === changeset) {
tree.setFocusedPath(path);
}
}
var inlines = this._getInlinesByType();
var unsaved = inlines.unsaved;
var unsubmitted = inlines.unsubmitted;
var undone = inlines.undone;
var done = inlines.done;
var draft_done = inlines.draftDone;
JX.DOM.alterClass(
node,
'diff-banner-has-unsaved',
!!unsaved.length);
JX.DOM.alterClass(
node,
'diff-banner-has-unsubmitted',
!!unsubmitted.length);
JX.DOM.alterClass(
node,
'diff-banner-has-draft-done',
!!draft_done.length);
var pht = this.getTranslations();
var unsaved_button = this._getUnsavedButton();
var unsubmitted_button = this._getUnsubmittedButton();
var done_button = this._getDoneButton();
var menu_button = this._getMenuButton();
if (unsaved.length) {
unsaved_button.setText(unsaved.length + ' ' + pht('Unsaved'));
JX.DOM.show(unsaved_button.getNode());
} else {
JX.DOM.hide(unsaved_button.getNode());
}
if (unsubmitted.length || draft_done.length) {
var any_draft_count = unsubmitted.length + draft_done.length;
unsubmitted_button.setText(any_draft_count + ' ' + pht('Unsubmitted'));
JX.DOM.show(unsubmitted_button.getNode());
} else {
JX.DOM.hide(unsubmitted_button.getNode());
}
if (done.length || undone.length) {
// If you haven't marked any comments as "Done", we just show text
// like "3 Comments". If you've marked at least one done, we show
// "1 / 3 Comments".
var done_text;
if (done.length) {
done_text = [
done.length,
' / ',
(done.length + undone.length),
' ',
pht('Comments')
];
} else {
done_text = [
undone.length,
' ',
pht('Comments')
];
}
done_button.setText(done_text);
JX.DOM.show(done_button.getNode());
// If any comments are not marked "Done", this cycles through the
// missing comments. Otherwise, it cycles through all the saved
// comments.
if (undone.length) {
this._doneMode = 'undone';
} else {
this._doneMode = 'done';
}
} else {
JX.DOM.hide(done_button.getNode());
}
var path_view = [icon, ' ', changeset.getDisplayPath()];
var buttons_attrs = {
className: 'diff-banner-buttons'
};
var buttons_list = [
unsaved_button.getNode(),
unsubmitted_button.getNode(),
done_button.getNode(),
menu_button.getNode()
];
var buttons_view = JX.$N('div', buttons_attrs, buttons_list);
var icon = new JX.PHUIXIconView()
.setIcon(changeset.getIcon())
.getNode();
JX.DOM.setContent(node, [buttons_view, path_view]);
document.body.appendChild(node);
if (formation) {
formation.repaint();
}
},
_getInlinesByType: function() {
var changesets = this._changesets;
var unsaved = [];
var unsubmitted = [];
var undone = [];
var done = [];
var draft_done = [];
var visible_done = [];
var visible_collapsed = [];
var visible_ghosts = [];
var visible = [];
var hidden = [];
for (var ii = 0; ii < changesets.length; ii++) {
var inlines = changesets[ii].getInlines();
var inline;
var jj;
for (jj = 0; jj < inlines.length; jj++) {
inline = inlines[jj];
if (inline.isDeleted()) {
continue;
}
if (inline.isSynthetic()) {
continue;
}
if (inline.isEditing()) {
unsaved.push(inline);
} else if (!inline.getID()) {
// These are new comments which have been cancelled, and do not
// count as anything.
continue;
} else if (inline.isDraft()) {
unsubmitted.push(inline);
} else {
// NOTE: Unlike other states, an inline may be marked with a
// draft checkmark and still be a "done" or "undone" comment.
if (inline.isDraftDone()) {
draft_done.push(inline);
}
if (!inline.isDone()) {
undone.push(inline);
} else {
done.push(inline);
}
}
}
for (jj = 0; jj < inlines.length; jj++) {
inline = inlines[jj];
if (inline.isDeleted()) {
continue;
}
if (inline.isEditing()) {
continue;
}
if (inline.isHidden()) {
hidden.push(inline);
continue;
}
visible.push(inline);
if (inline.isDone()) {
visible_done.push(inline);
}
if (inline.isCollapsed()) {
visible_collapsed.push(inline);
}
if (inline.isGhost()) {
visible_ghosts.push(inline);
}
}
}
return {
unsaved: unsaved,
unsubmitted: unsubmitted,
undone: undone,
done: done,
draftDone: draft_done,
visibleDone: visible_done,
visibleGhosts: visible_ghosts,
visibleCollapsed: visible_collapsed,
visible: visible,
hidden: hidden
};
},
_getUnsavedButton: function() {
if (!this._unsavedButton) {
var button = new JX.PHUIXButtonView()
.setIcon('fa-commenting-o')
.setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE);
var node = button.getNode();
var onunsaved = JX.bind(this, this._onunsavedclick);
JX.DOM.listen(node, 'click', null, onunsaved);
this._unsavedButton = button;
}
return this._unsavedButton;
},
_getUnsubmittedButton: function() {
if (!this._unsubmittedButton) {
var button = new JX.PHUIXButtonView()
.setIcon('fa-comment-o')
.setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE);
var node = button.getNode();
var onunsubmitted = JX.bind(this, this._onunsubmittedclick);
JX.DOM.listen(node, 'click', null, onunsubmitted);
this._unsubmittedButton = button;
}
return this._unsubmittedButton;
},
_getDoneButton: function() {
if (!this._doneButton) {
var button = new JX.PHUIXButtonView()
.setIcon('fa-comment')
.setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE);
var node = button.getNode();
var ondone = JX.bind(this, this._ondoneclick);
JX.DOM.listen(node, 'click', null, ondone);
this._doneButton = button;
}
return this._doneButton;
},
_getMenuButton: function() {
if (!this._menuButton) {
var pht = this.getTranslations();
var button = new JX.PHUIXButtonView()
.setIcon('fa-bars')
.setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE)
.setAuralLabel(pht('Display Options'));
var dropdown = new JX.PHUIXDropdownMenu(button.getNode());
this._menuItems = {};
var list = new JX.PHUIXActionListView();
dropdown.setContent(list.getNode());
var map = {
hideDone: {
type: 'done'
},
hideCollapsed: {
type: 'collapsed'
},
hideGhosts: {
type: 'ghosts'
},
hideAll: {
type: 'all'
},
showAll: {
type: 'show'
}
};
for (var k in map) {
var spec = map[k];
var handler = JX.bind(this, this._onhideinlines, spec.type);
var item = new JX.PHUIXActionView()
.setHandler(handler);
list.addItem(item);
this._menuItems[k] = item;
}
dropdown.listen('open', JX.bind(this, this._ondropdown));
if (this.getInlineListURI()) {
list.addItem(
new JX.PHUIXActionView()
.setDivider(true));
list.addItem(
new JX.PHUIXActionView()
.setIcon('fa-external-link')
.setName(pht('List Inline Comments'))
.setHref(this.getInlineListURI()));
}
this._menuButton = button;
this._dropdownMenu = dropdown;
}
return this._menuButton;
},
_ondropdown: function() {
var inlines = this._getInlinesByType();
var items = this._menuItems;
var pht = this.getTranslations();
items.hideDone
.setName(pht('Hide "Done" Inlines'))
.setDisabled(!inlines.visibleDone.length);
items.hideCollapsed
.setName(pht('Hide Collapsed Inlines'))
.setDisabled(!inlines.visibleCollapsed.length);
items.hideGhosts
.setName(pht('Hide Older Inlines'))
.setDisabled(!inlines.visibleGhosts.length);
items.hideAll
.setName(pht('Hide All Inlines'))
.setDisabled(!inlines.visible.length);
items.showAll
.setName(pht('Show All Inlines'))
.setDisabled(!inlines.hidden.length);
},
_onhideinlines: function(type, e) {
this._dropdownMenu.close();
e.prevent();
this._toggleInlines(type);
},
_toggleInlines: function(type) {
var inlines = this._getInlinesByType();
// Clear the selection state since we end up in a weird place if the
// user hides the selected inline.
this._setSelectionState(null);
var targets;
var mode = true;
switch (type) {
case 'done':
targets = inlines.visibleDone;
break;
case 'collapsed':
targets = inlines.visibleCollapsed;
break;
case 'ghosts':
targets = inlines.visibleGhosts;
break;
case 'all':
targets = inlines.visible;
break;
case 'show':
targets = inlines.hidden;
mode = false;
break;
}
for (var ii = 0; ii < targets.length; ii++) {
targets[ii].setHidden(mode);
}
},
_onunsavedclick: function(e) {
e.kill();
var options = {
filter: 'comment',
wrap: true,
show: true,
attribute: 'unsaved'
};
this._onjumpkey(1, options);
},
_onunsubmittedclick: function(e) {
e.kill();
var options = {
filter: 'comment',
wrap: true,
show: true,
attribute: 'anyDraft'
};
this._onjumpkey(1, options);
},
_ondoneclick: function(e) {
e.kill();
var options = {
filter: 'comment',
wrap: true,
show: true,
attribute: this._doneMode
};
this._onjumpkey(1, options);
},
_getBannerNode: function() {
if (!this._bannerNode) {
var attributes = {
className: 'diff-banner',
id: 'diff-banner'
};
this._bannerNode = JX.$N('div', attributes);
}
return this._bannerNode;
},
_getVisibleChangeset: function() {
if (this.isAsleep()) {
return null;
}
if (JX.Device.getDevice() != 'desktop') {
return null;
}
// Never show the banner if we're very near the top of the page.
var margin = 480;
var s = JX.Vector.getScroll();
if (s.y < margin) {
return null;
}
// We're going to find the changeset which spans an invisible line a
// little underneath the bottom of the banner. This makes the header
// tick over from "A.txt" to "B.txt" just as "A.txt" scrolls completely
// offscreen.
var detect_height = 64;
for (var ii = 0; ii < this._changesets.length; ii++) {
var changeset = this._changesets[ii];
var c = changeset.getVectors();
// If the changeset starts above the line...
if (c.pos.y <= (s.y + detect_height)) {
// ...and ends below the line, this is the current visible changeset.
if ((c.pos.y + c.dim.y) >= (s.y + detect_height)) {
return changeset;
}
}
}
return null;
},
_getTreeView: function() {
if (!this._treeView) {
var tree = new JX.DiffTreeView();
for (var ii = 0; ii < this._changesets.length; ii++) {
var changeset = this._changesets[ii];
tree.addPath(changeset.getPathView());
}
this._treeView = tree;
}
return this._treeView;
},
_redrawFiletree : function() {
var formation = this.getFormationView();
if (!formation) {
return;
}
var filetree = formation.getColumn(0);
var flank = filetree.getFlank();
var flank_body = flank.getBodyNode();
var tree = this._getTreeView();
JX.DOM.setContent(flank_body, tree.getNode());
},
_setupInlineCommentListeners: function() {
var onsave = JX.bind(this, this._onInlineEvent, 'save');
JX.Stratcom.listen(
['submit', 'didSyntheticSubmit'],
'inline-edit-form',
onsave);
var oncancel = JX.bind(this, this._onInlineEvent, 'cancel');
JX.Stratcom.listen(
'click',
'inline-edit-cancel',
oncancel);
var onundo = JX.bind(this, this._onInlineEvent, 'undo');
JX.Stratcom.listen(
'click',
'differential-inline-comment-undo',
onundo);
var ondone = JX.bind(this, this._onInlineEvent, 'done');
JX.Stratcom.listen(
'click',
['differential-inline-comment', 'differential-inline-done'],
ondone);
var ondelete = JX.bind(this, this._onInlineEvent, 'delete');
JX.Stratcom.listen(
'click',
['differential-inline-comment', 'differential-inline-delete'],
ondelete);
var onmenu = JX.bind(this, this._onInlineEvent, 'menu');
JX.Stratcom.listen(
'click',
['differential-inline-comment', 'inline-action-dropdown'],
onmenu);
var ondraft = JX.bind(this, this._onInlineEvent, 'draft');
JX.Stratcom.listen(
'keydown',
['differential-inline-comment', 'tag:textarea'],
ondraft);
var on_preview_view = JX.bind(this, this._onPreviewEvent, 'view');
JX.Stratcom.listen(
'click',
'differential-inline-preview-jump',
on_preview_view);
},
_onPreviewEvent: function(action, e) {
if (this.isAsleep()) {
return;
}
var data = e.getNodeData('differential-inline-preview-jump');
var inline = this.getInlineByID(data.inlineCommentID);
if (!inline) {
return;
}
e.kill();
switch (action) {
case 'view':
this.selectInline(inline, true, true);
break;
}
},
_onInlineEvent: function(action, e) {
if (this.isAsleep()) {
return;
}
if (action !== 'draft' && action !== 'menu') {
e.kill();
}
var inline = this._getInlineForEvent(e);
var is_ref = false;
// If we don't have a natural inline object, the user may have clicked
// an action (like "Delete") inside a preview element at the bottom of
// the page.
// If they did, try to find an associated normal inline to act on, and
// pretend they clicked that instead. This makes the overall state of
// the page more consistent.
// However, there may be no normal inline (for example, because it is
// on a version of the diff which is not visible). In this case, we
// act by reference.
if (inline === null) {
var data = e.getNodeData('differential-inline-comment');
inline = this.getInlineByID(data.id);
if (inline) {
is_ref = true;
} else {
switch (action) {
case 'delete':
this._deleteInlineByID(data.id);
return;
}
}
}
// TODO: For normal operations, highlight the inline range here.
switch (action) {
case 'save':
inline.save(e.getTarget());
break;
case 'cancel':
inline.cancel();
break;
case 'undo':
inline.undo();
break;
case 'done':
inline.toggleDone();
break;
case 'delete':
inline.delete(is_ref);
break;
case 'draft':
inline.triggerDraft();
break;
case 'menu':
var node = e.getNode('inline-action-dropdown');
inline.activateMenu(node, e);
break;
}
}
}
});