diff --git a/src/infrastructure/diff/view/PHUIDiffInlineCommentDetailView.php b/src/infrastructure/diff/view/PHUIDiffInlineCommentDetailView.php
index a42efa932a..46853e185c 100644
--- a/src/infrastructure/diff/view/PHUIDiffInlineCommentDetailView.php
+++ b/src/infrastructure/diff/view/PHUIDiffInlineCommentDetailView.php
@@ -1,493 +1,494 @@
inlineComment = $comment;
return $this;
}
public function isHidden() {
return $this->inlineComment->isHidden();
}
public function setHandles(array $handles) {
assert_instances_of($handles, 'PhabricatorObjectHandle');
$this->handles = $handles;
return $this;
}
public function setMarkupEngine(PhabricatorMarkupEngine $engine) {
$this->markupEngine = $engine;
return $this;
}
public function setEditable($editable) {
$this->editable = $editable;
return $this;
}
public function setPreview($preview) {
$this->preview = $preview;
return $this;
}
public function setAllowReply($allow_reply) {
$this->allowReply = $allow_reply;
return $this;
}
public function setRenderer($renderer) {
$this->renderer = $renderer;
return $this;
}
public function getRenderer() {
return $this->renderer;
}
public function setCanMarkDone($can_mark_done) {
$this->canMarkDone = $can_mark_done;
return $this;
}
public function getCanMarkDone() {
return $this->canMarkDone;
}
public function setObjectOwnerPHID($phid) {
$this->objectOwnerPHID = $phid;
return $this;
}
public function getObjectOwnerPHID() {
return $this->objectOwnerPHID;
}
public function getAnchorName() {
$inline = $this->inlineComment;
if ($inline->getID()) {
return 'inline-'.$inline->getID();
}
return null;
}
public function getScaffoldCellID() {
$anchor = $this->getAnchorName();
if ($anchor) {
return 'anchor-'.$anchor;
}
return null;
}
public function render() {
require_celerity_resource('phui-inline-comment-view-css');
$inline = $this->inlineComment;
$classes = array(
'differential-inline-comment',
);
$is_fixed = false;
switch ($inline->getFixedState()) {
case PhabricatorInlineCommentInterface::STATE_DONE:
case PhabricatorInlineCommentInterface::STATE_DRAFT:
$is_fixed = true;
break;
}
+ $is_synthetic = false;
+ if ($inline->getSyntheticAuthor()) {
+ $is_synthetic = true;
+ }
+
$metadata = array(
'id' => $inline->getID(),
'phid' => $inline->getPHID(),
'changesetID' => $inline->getChangesetID(),
'number' => $inline->getLineNumber(),
'length' => $inline->getLineLength(),
'isNewFile' => (bool)$inline->getIsNewFile(),
'on_right' => $this->getIsOnRight(),
'original' => $inline->getContent(),
'replyToCommentPHID' => $inline->getReplyToCommentPHID(),
'isDraft' => $inline->isDraft(),
'isFixed' => $is_fixed,
'isGhost' => $inline->getIsGhost(),
+ 'isSynthetic' => $is_synthetic,
);
$sigil = 'differential-inline-comment';
if ($this->preview) {
$sigil = $sigil.' differential-inline-comment-preview';
}
$classes = array(
'differential-inline-comment',
);
$content = $inline->getContent();
$handles = $this->handles;
$links = array();
- $is_synthetic = false;
- if ($inline->getSyntheticAuthor()) {
- $is_synthetic = true;
- }
-
$draft_text = null;
if (!$is_synthetic) {
// This display is controlled by CSS
$draft_text = id(new PHUITagView())
->setType(PHUITagView::TYPE_SHADE)
->setName(pht('Unsubmitted'))
->setSlimShady(true)
->setColor(PHUITagView::COLOR_RED)
->addClass('mml inline-draft-text');
}
$ghost_tag = null;
$ghost = $inline->getIsGhost();
$ghost_id = null;
if ($ghost) {
if ($ghost['new']) {
$ghosticon = 'fa-fast-forward';
$reason = pht('View on forward revision');
} else {
$ghosticon = 'fa-fast-backward';
$reason = pht('View on previous revision');
}
$ghost_icon = id(new PHUIIconView())
->setIcon($ghosticon)
->addSigil('has-tooltip')
->setMetadata(
array(
'tip' => $reason,
'size' => 300,
));
$ghost_tag = phutil_tag(
'a',
array(
'class' => 'ghost-icon',
'href' => $ghost['href'],
'target' => '_blank',
),
$ghost_icon);
$classes[] = 'inline-comment-ghost';
}
// I think this is unused
if ($inline->getHasReplies()) {
$classes[] = 'inline-comment-has-reply';
}
if ($inline->getReplyToCommentPHID()) {
$classes[] = 'inline-comment-is-reply';
}
$viewer_phid = $this->getUser()->getPHID();
$owner_phid = $this->getObjectOwnerPHID();
if ($viewer_phid) {
if ($viewer_phid == $owner_phid) {
$classes[] = 'viewer-is-object-owner';
}
}
$anchor_name = $this->getAnchorName();
$action_buttons = array();
$can_reply =
(!$this->editable) &&
(!$this->preview) &&
($this->allowReply) &&
// NOTE: No product reason why you can't reply to synthetic comments,
// but the reply mechanism currently sends the inline comment ID to the
// server, not file/line information, and synthetic comments don't have
// an inline comment ID.
(!$is_synthetic);
if ($can_reply) {
$action_buttons[] = id(new PHUIButtonView())
->setTag('a')
->setIcon('fa-reply')
->setTooltip(pht('Reply'))
->addSigil('differential-inline-reply')
->setMustCapture(true);
}
if ($this->editable && !$this->preview) {
$action_buttons[] = id(new PHUIButtonView())
->setTag('a')
->setIcon('fa-pencil')
->setTooltip(pht('Edit'))
->addSigil('differential-inline-edit')
->setMustCapture(true);
$action_buttons[] = id(new PHUIButtonView())
->setTag('a')
->setIcon('fa-trash-o')
->setTooltip(pht('Delete'))
->addSigil('differential-inline-delete')
->setMustCapture(true);
} else if ($this->preview) {
$links[] = javelin_tag(
'a',
array(
'class' => 'inline-button-divider pml msl',
'meta' => array(
'anchor' => $anchor_name,
),
'sigil' => 'differential-inline-preview-jump',
),
pht('View'));
$action_buttons[] = id(new PHUIButtonView())
->setTag('a')
->setTooltip(pht('Delete'))
->setIcon('fa-trash-o')
->addSigil('differential-inline-delete')
->setMustCapture(true);
}
if (!$this->preview && $this->canHide()) {
$action_buttons[] = id(new PHUIButtonView())
->setTag('a')
->setTooltip(pht('Hide Comment'))
->setIcon('fa-times')
->addSigil('hide-inline')
->setMustCapture(true);
}
$done_button = null;
if (!$is_synthetic) {
$draft_state = false;
switch ($inline->getFixedState()) {
case PhabricatorInlineCommentInterface::STATE_DRAFT:
$is_done = ($this->getCanMarkDone());
$draft_state = true;
break;
case PhabricatorInlineCommentInterface::STATE_UNDRAFT:
$is_done = !($this->getCanMarkDone());
$draft_state = true;
break;
case PhabricatorInlineCommentInterface::STATE_DONE:
$is_done = true;
break;
default:
case PhabricatorInlineCommentInterface::STATE_UNDONE:
$is_done = false;
break;
}
// If you don't have permission to mark the comment as "Done", you also
// can not see the draft state.
if (!$this->getCanMarkDone()) {
$draft_state = false;
}
if ($is_done) {
$classes[] = 'inline-is-done';
}
if ($draft_state) {
$classes[] = 'inline-state-is-draft';
}
if ($this->getCanMarkDone()) {
$done_input = javelin_tag(
'input',
array(
'type' => 'checkbox',
'checked' => ($is_done ? 'checked' : null),
'disabled' => ($this->getCanMarkDone() ? null : 'disabled'),
'class' => 'differential-inline-done',
'sigil' => 'differential-inline-done',
));
$done_button = phutil_tag(
'label',
array(
'class' => 'differential-inline-done-label '.
($this->getCanMarkDone() ? null : 'done-is-disabled'),
),
array(
$done_input,
pht('Done'),
));
} else {
if ($is_done) {
$icon = id(new PHUIIconView())->setIcon('fa-check sky msr');
$label = pht('Done');
$class = 'button-done';
} else {
$icon = null;
$label = pht('Not Done');
$class = 'button-not-done';
}
$done_button = phutil_tag(
'div',
array(
'class' => 'done-label '.$class,
),
array(
$icon,
$label,
));
}
}
$content = $this->markupEngine->getOutput(
$inline,
PhabricatorInlineCommentInterface::MARKUP_FIELD_BODY);
if ($this->preview) {
$anchor = null;
} else {
$anchor = phutil_tag(
'a',
array(
'name' => $anchor_name,
'id' => $anchor_name,
'class' => 'differential-inline-comment-anchor',
),
'');
}
if ($inline->isDraft() && !$is_synthetic) {
$classes[] = 'inline-state-is-draft';
}
if ($is_synthetic) {
$classes[] = 'differential-inline-comment-synthetic';
}
$classes = implode(' ', $classes);
$author_owner = null;
if ($is_synthetic) {
$author = $inline->getSyntheticAuthor();
} else {
$author = $handles[$inline->getAuthorPHID()]->getName();
if ($inline->getAuthorPHID() == $this->objectOwnerPHID) {
$author_owner = id(new PHUITagView())
->setType(PHUITagView::TYPE_SHADE)
->setName(pht('Author'))
->setSlimShady(true)
->setColor(PHUITagView::COLOR_YELLOW)
->addClass('mml');
}
}
$actions = null;
if ($action_buttons) {
$actions = new PHUIButtonBarView();
$actions->setBorderless(true);
$actions->addClass('inline-button-divider');
foreach ($action_buttons as $button) {
$actions->addButton($button);
}
}
$group_left = phutil_tag(
'div',
array(
'class' => 'inline-head-left',
),
array(
$author,
$author_owner,
$draft_text,
$ghost_tag,
));
$group_right = phutil_tag(
'div',
array(
'class' => 'inline-head-right',
),
array(
$done_button,
$links,
$actions,
));
$snippet = id(new PhutilUTF8StringTruncator())
->setMaximumGlyphs(96)
->truncateString($inline->getContent());
$metadata['snippet'] = pht('%s: %s', $author, $snippet);
$markup = javelin_tag(
'div',
array(
'class' => $classes,
'sigil' => $sigil,
'meta' => $metadata,
),
array(
javelin_tag(
'div',
array(
'class' => 'differential-inline-comment-head grouped',
'sigil' => 'differential-inline-header',
),
array(
$group_left,
$group_right,
)),
phutil_tag_div(
'differential-inline-comment-content',
phutil_tag_div('phabricator-remarkup', $content)),
));
$summary = phutil_tag(
'div',
array(
'class' => 'differential-inline-summary',
),
array(
phutil_tag('strong', array(), pht('%s:', $author)),
' ',
$snippet,
));
return array(
$anchor,
$markup,
$summary,
);
}
private function canHide() {
$inline = $this->inlineComment;
if ($inline->isDraft()) {
return false;
}
if (!$inline->getID()) {
return false;
}
$viewer = $this->getUser();
if (!$viewer->isLoggedIn()) {
return false;
}
if (!$inline->supportsHiding()) {
return false;
}
return true;
}
}
diff --git a/webroot/rsrc/js/application/diff/DiffChangesetList.js b/webroot/rsrc/js/application/diff/DiffChangesetList.js
index 400b828a9a..b476b8c168 100644
--- a/webroot/rsrc/js/application/diff/DiffChangesetList.js
+++ b/webroot/rsrc/js/application/diff/DiffChangesetList.js
@@ -1,1581 +1,1604 @@
/**
* @provides phabricator-diff-changeset-list
* @requires javelin-install
* phuix-button-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 onhide = JX.bind(this, this._ifawake, this._onhide);
JX.Stratcom.listen('click', 'hide-inline', onhide);
var onreveal = JX.bind(this, this._ifawake, this._onreveal);
JX.Stratcom.listen('click', 'reveal-inline', onreveal);
var onedit = JX.bind(this, this._ifawake, this._onaction, 'edit');
JX.Stratcom.listen(
'click',
['differential-inline-comment', 'differential-inline-edit'],
onedit);
var ondone = JX.bind(this, this._ifawake, this._onaction, 'done');
JX.Stratcom.listen(
'click',
['differential-inline-comment', 'differential-inline-done'],
ondone);
var ondelete = JX.bind(this, this._ifawake, this._onaction, 'delete');
JX.Stratcom.listen(
'click',
['differential-inline-comment', 'differential-inline-delete'],
ondelete);
var onreply = JX.bind(this, this._ifawake, this._onaction, 'reply');
JX.Stratcom.listen(
'click',
['differential-inline-comment', 'differential-inline-reply'],
onreply);
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:th'],
onrangedown);
var onrangemove = JX.bind(this, this._ifawake, this._onrangemove);
JX.Stratcom.listen(
['mouseover', 'mouseout'],
['differential-changeset', 'tag:th'],
onrangemove);
var onrangeup = JX.bind(this, this._ifawake, this._onrangeup);
JX.Stratcom.listen(
'mouseup',
null,
onrangeup);
},
properties: {
translations: null,
inlineURI: 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,
sleep: function() {
this._asleep = true;
this._redrawFocus();
this._redrawSelection();
this.resetHover();
},
wake: function() {
this._asleep = false;
this._redrawFocus();
this._redrawSelection();
if (this._initialized) {
return;
}
this._initialized = true;
var pht = this.getTranslations();
var label;
label = pht('Jump to next change.');
this._installJumpKey('j', label, 1);
label = pht('Jump to previous change.');
this._installJumpKey('k', label, -1);
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 hidden comments.');
this._installJumpKey('N', label, 1, 'comment', true);
label = pht(
'Jump to previous inline comment, including hidden comments.');
this._installJumpKey('P', label, -1, 'comment', true);
label = pht('Hide or show the current file.');
this._installKey('h', label, this._onkeytogglefile);
label = pht('Jump to the table of contents.');
this._installKey('t', label, this._ontoc);
label = pht('Reply to selected inline comment or change.');
this._installKey('r', label, JX.bind(this, this._onkeyreply, false));
label = pht('Reply and quote selected inline comment.');
this._installKey('R', label, JX.bind(this, this._onkeyreply, true));
label = pht('Edit selected inline comment.');
this._installKey('e', label, this._onkeyedit);
label = pht('Mark or unmark selected inline comment as done.');
this._installKey('w', label, this._onkeydone);
label = pht('Hide or show inline comment.');
this._installKey('q', label, this._onkeyhide);
},
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, label, handler) {
handler = JX.bind(this, this._ifawake, handler);
return new JX.KeyboardShortcut(key, label)
.setHandler(handler)
.register();
},
_installJumpKey: function(key, label, delta, filter, show_hidden) {
filter = filter || null;
var options = {
filter: filter,
hidden: show_hidden
};
var handler = JX.bind(this, this._onjumpkey, delta, options);
return this._installKey(key, label, handler);
},
_ontoc: function(manager) {
var toc = JX.$('toc');
manager.scrollTo(toc);
},
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);
var text;
if (is_quote) {
text = inline.getRawText();
text = '> ' + text.replace(/\n/g, '\n> ') + '\n\n';
} else {
text = '';
}
inline.reply(text);
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 (JX.DOM.isType(header, 'th')) {
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 cursor = this._cursorItem;
if (cursor) {
if (cursor.type == 'file') {
cursor.changeset.toggleVisibility();
return;
}
}
var pht = this.getTranslations();
this._warnUser(pht('You must select a file to hide or show.'));
},
_onkeyhide: function() {
var cursor = this._cursorItem;
if (cursor) {
if (cursor.type == 'comment') {
var inline = cursor.target;
if (inline.canHide()) {
this.setFocus(null);
inline.setHidden(!inline.isHidden());
return;
}
}
}
var pht = this.getTranslations();
this._warnUser(pht('You must select a comment to hide.'));
},
_warnUser: function(message) {
new JX.Notification()
.setContent(message)
.alterClassName('jx-notification-alert', true)
.setDuration(1000)
.show();
},
_onjumpkey: function(delta, options) {
var state = this._getSelectionState();
var filter = options.filter || null;
var hidden = options.hidden || false;
var wrap = options.wrap || false;
var attribute = options.attribute || null;
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 hidden, don't select it when iterating with jump
// keys. It can still potentially be selected in other ways.
if (!hidden) {
if (items[cursor].hidden) {
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;
}
}
// 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
};
},
_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;
}
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 = this.getChangesetForNode(node);
var menu = new JX.PHUIXDropdownMenu(button);
var list = new JX.PHUIXActionListView();
var add_link = function(icon, name, href, local) {
if (!href) {
return;
}
var link = new JX.PHUIXActionView()
.setIcon(icon)
.setName(name)
.setHref(href)
.setHandler(function(e) {
if (local) {
window.location.assign(href);
} else {
window.open(href);
}
menu.close();
e.prevent();
});
list.addItem(link);
return link;
};
var reveal_item = new JX.PHUIXActionView()
.setIcon('fa-eye');
list.addItem(reveal_item);
var visible_item = new JX.PHUIXActionView()
.setHandler(function(e) {
e.prevent();
menu.close();
changeset.toggleVisibility();
});
list.addItem(visible_item);
add_link('fa-file-text', pht('Browse in Diffusion'), data.diffusionURI);
add_link('fa-file-o', pht('View Standalone'), data.standaloneURI);
var up_item = new JX.PHUIXActionView()
.setHandler(function(e) {
if (changeset.isLoaded()) {
var renderer = changeset.getRenderer();
if (renderer == '1up') {
renderer = '2up';
} else {
renderer = '1up';
}
changeset.setRenderer(renderer);
}
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.getEncoding()
};
new JX.Workflow('/services/encoding/', params)
.setHandler(function(r) {
changeset.setEncoding(r.encoding);
changeset.reload();
})
.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.setHighlight(r.highlight);
changeset.reload();
})
.start();
e.prevent();
menu.close();
});
list.addItem(highlight_item);
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-pencil', pht('Open in Editor'), data.editor, true);
add_link('fa-wrench', pht('Configure Editor'), data.editorConfigure);
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-file-o')
.setHandler(function(e) {
changeset.loadAllContext();
e.prevent();
menu.close();
});
} else {
reveal_item
.setDisabled(true)
.setIcon('fa-file')
.setName(pht('All Context Shown'))
.setHandler(function(e) { e.prevent(); });
}
encoding_item.setDisabled(!changeset.isLoaded());
highlight_item.setDisabled(!changeset.isLoaded());
if (changeset.isLoaded()) {
if (changeset.getRenderer() == '2up') {
up_item
.setIcon('fa-list-alt')
.setName(pht('View Unified'));
} else {
up_item
.setIcon('fa-files-o')
.setName(pht('View Side-by-Side'));
}
} else {
up_item
.setIcon('fa-refresh')
.setName(pht('Load Changes'));
}
visible_item
.setDisabled(true)
.setIcon('fa-expand')
.setName(pht('Can\'t Toggle Unloaded File'));
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) {
var diff = diffs[0];
visible_item.setDisabled(false);
if (!changeset.isVisible()) {
visible_item
.setName(pht('Expand File'))
.setIcon('fa-expand');
} else {
visible_item
.setName(pht('Collapse File'))
.setIcon('fa-compress');
}
} else {
// Do nothing when there is no diff shown in the table. For example,
// the file is binary.
}
});
data.menu = menu;
menu.open();
},
_onhide: function(e) {
this._onhidereveal(e, true);
},
_onreveal: function(e) {
this._onhidereveal(e, false);
},
_onhidereveal: function(e, is_hide) {
e.kill();
var inline = this._getInlineForEvent(e);
inline.setHidden(is_hide);
},
_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();
},
_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) {
var selection = this._getSelectionState();
var item;
// 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, false);
}
}
},
_onaction: function(action, e) {
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 'edit':
inline.edit();
break;
case 'done':
inline.toggleDone();
break;
case 'delete':
inline.delete(is_ref);
break;
case 'reply':
inline.reply();
break;
}
},
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) {
this._focusStart = node;
this._focusEnd = extended_node;
this._redrawFocus();
},
_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);
p.add(s).add(-4, -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(JX.Vector.getDim(extended_node))
.add(8, 8)
.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
// | in the row. In 2up views, it should be directly adjacent. In
// 1up views, we may have to skip over the other line number column.
var l = top;
while (JX.DOM.isType(l, 'th')) {
l = l.nextSibling;
}
// Find the rightmost cell that we're going to highlight: this is the
// farthest consecutive, adjacent | in the row. Sometimes the left
// and right nodes are the same (left side of 2up view); sometimes we're
// going to highlight several nodes (copy + code + coverage).
var r = l;
while (r.nextSibling && JX.DOM.isType(r.nextSibling, 'td')) {
r = r.nextSibling;
}
var pos = JX.$V(l)
.add(JX.Vector.getAggregateScrollForNode(l));
var dim = JX.$V(r)
.add(JX.Vector.getAggregateScrollForNode(r))
.add(-pos.x, -pos.y)
.add(JX.Vector.getDim(r));
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(th) {
try {
return parseInt(th.id.match(/^C\d+[ON]L(\d+)$/)[1], 10);
} catch (x) {
return null;
}
},
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.
if (e.isRightButton()) {
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 " | " 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() {
var node = this._getBannerNode();
var changeset = this._getVisibleChangeset();
// 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;
if (!changeset) {
JX.DOM.remove(node);
return;
}
var changesets = this._changesets;
var unsaved = [];
var unsubmitted = [];
var undone = [];
var done = [];
for (var ii = 0; ii < changesets.length; ii++) {
var inlines = changesets[ii].getInlines();
for (var jj = 0; jj < inlines.length; jj++) {
var 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 if (!inline.isDone()) {
undone.push(inline);
} else {
done.push(inline);
}
}
}
JX.DOM.alterClass(
node,
'diff-banner-has-unsaved',
!!unsaved.length);
JX.DOM.alterClass(
node,
'diff-banner-has-unsubmitted',
!!unsubmitted.length);
var pht = this.getTranslations();
var unsaved_button = this._getUnsavedButton();
var unsubmitted_button = this._getUnsubmittedButton();
var done_button = this._getDoneButton();
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) {
unsubmitted_button.setText(
unsubmitted.length + ' ' + pht('Unsubmitted'));
JX.DOM.show(unsubmitted_button.getNode());
} else {
JX.DOM.hide(unsubmitted_button.getNode());
}
if (done.length || undone.length) {
- done_button.setText([
- done.length,
- ' / ',
- (done.length + undone.length),
- ' ',
- pht('Comments')
- ]);
+ // 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()
];
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);
},
_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;
},
_onunsavedclick: function(e) {
e.kill();
var options = {
filter: 'comment',
wrap: true,
attribute: 'unsaved'
};
this._onjumpkey(1, options);
},
_onunsubmittedclick: function(e) {
e.kill();
var options = {
filter: 'comment',
wrap: true,
attribute: 'unsubmitted'
};
this._onjumpkey(1, options);
},
_ondoneclick: function(e) {
e.kill();
var options = {
filter: 'comment',
wrap: 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;
}
}
});
diff --git a/webroot/rsrc/js/application/diff/DiffInline.js b/webroot/rsrc/js/application/diff/DiffInline.js
index 6d897c27ae..baee53a7a4 100644
--- a/webroot/rsrc/js/application/diff/DiffInline.js
+++ b/webroot/rsrc/js/application/diff/DiffInline.js
@@ -1,721 +1,727 @@
/**
* @provides phabricator-diff-inline
* @requires javelin-dom
* @javelin
*/
JX.install('DiffInline', {
construct : function() {
},
members: {
_id: null,
_phid: null,
_changesetID: null,
_row: null,
_hidden: false,
_number: null,
_length: null,
_displaySide: null,
_isNewFile: null,
_undoRow: null,
_replyToCommentPHID: null,
_originalText: null,
_snippet: null,
_isDeleted: false,
_isInvisible: false,
_isLoading: false,
_changeset: null,
_isDraft: null,
_isFixed: null,
_isEditing: false,
_isNew: false,
+ _isSynthetic: false,
bindToRow: function(row) {
this._row = row;
var row_data = JX.Stratcom.getData(row);
row_data.inline = this;
this._hidden = row_data.hidden || false;
// TODO: Get smarter about this once we do more editing, this is pretty
// hacky.
var comment = JX.DOM.find(row, 'div', 'differential-inline-comment');
var data = JX.Stratcom.getData(comment);
this._id = data.id;
this._phid = data.phid;
// TODO: This is very, very, very, very, very, very, very hacky.
var td = comment.parentNode;
var th = td.previousSibling;
if (th.parentNode.firstChild != th) {
this._displaySide = 'right';
} else {
this._displaySide = 'left';
}
this._number = parseInt(data.number, 10);
this._length = parseInt(data.length, 10);
this._originalText = data.original;
this._isNewFile =
(this.getDisplaySide() == 'right') ||
(data.left != data.right);
this._replyToCommentPHID = data.replyToCommentPHID;
this._isDraft = data.isDraft;
this._isFixed = data.isFixed;
this._isGhost = data.isGhost;
+ this._isSynthetic = data.isSynthetic;
this._changesetID = data.changesetID;
this._isNew = false;
this._snippet = data.snippet;
this.setInvisible(false);
return this;
},
isDraft: function() {
return this._isDraft;
},
isDone: function() {
return this._isFixed;
},
isEditing: function() {
return this._isEditing;
},
isDeleted: function() {
return this._isDeleted;
},
+ isSynthetic: function() {
+ return this._isSynthetic;
+ },
+
bindToRange: function(data) {
this._displaySide = data.displaySide;
this._number = parseInt(data.number, 10);
this._length = parseInt(data.length, 10);
this._isNewFile = data.isNewFile;
this._changesetID = data.changesetID;
this._isNew = true;
// Insert the comment after any other comments which already appear on
// the same row.
var parent_row = JX.DOM.findAbove(data.target, 'tr');
var target_row = parent_row.nextSibling;
while (target_row && JX.Stratcom.hasSigil(target_row, 'inline-row')) {
target_row = target_row.nextSibling;
}
var row = this._newRow();
parent_row.parentNode.insertBefore(row, target_row);
this.setInvisible(true);
return this;
},
bindToReply: function(inline) {
this._displaySide = inline._displaySide;
this._number = inline._number;
this._length = inline._length;
this._isNewFile = inline._isNewFile;
this._changesetID = inline._changesetID;
this._isNew = true;
this._replyToCommentPHID = inline._phid;
var changeset = this.getChangeset();
// We're going to figure out where in the document to position the new
// inline. Normally, it goes after any existing inline rows (so if
// several inlines reply to the same line, they appear in chronological
// order).
// However: if inlines are threaded, we want to put the new inline in
// the right place in the thread. This might be somewhere in the middle,
// so we need to do a bit more work to figure it out.
// To find the right place in the thread, we're going to look for any
// inline which is at or above the level of the comment we're replying
// to. This means we've reached a new fork of the thread, and should
// put our new inline before the comment we found.
var ancestor_map = {};
var ancestor = inline;
var reply_phid;
while (ancestor) {
reply_phid = ancestor.getReplyToCommentPHID();
if (!reply_phid) {
break;
}
ancestor_map[reply_phid] = true;
ancestor = changeset.getInlineByPHID(reply_phid);
}
var parent_row = inline._row;
var target_row = parent_row.nextSibling;
while (target_row && JX.Stratcom.hasSigil(target_row, 'inline-row')) {
var target = changeset.getInlineForRow(target_row);
reply_phid = target.getReplyToCommentPHID();
// If we found an inline which is replying directly to some ancestor
// of this new comment, this is where the new rows go.
if (ancestor_map.hasOwnProperty(reply_phid)) {
break;
}
target_row = target_row.nextSibling;
}
var row = this._newRow();
parent_row.parentNode.insertBefore(row, target_row);
this.setInvisible(true);
return this;
},
setChangeset: function(changeset) {
this._changeset = changeset;
return this;
},
getChangeset: function() {
return this._changeset;
},
setEditing: function(editing) {
this._isEditing = editing;
return this;
},
canReply: function() {
if (!this._hasAction('reply')) {
return false;
}
return true;
},
canEdit: function() {
if (!this._hasAction('edit')) {
return false;
}
return true;
},
canDone: function() {
if (!JX.DOM.scry(this._row, 'input', 'differential-inline-done').length) {
return false;
}
return true;
},
canHide: function() {
if (!JX.DOM.scry(this._row, 'a', 'hide-inline').length) {
return false;
}
return true;
},
getRawText: function() {
return this._originalText;
},
_hasAction: function(action) {
var nodes = JX.DOM.scry(this._row, 'a', 'differential-inline-' + action);
return (nodes.length > 0);
},
_newRow: function() {
var attributes = {
sigil: 'inline-row'
};
var row = JX.$N('tr', attributes);
JX.Stratcom.getData(row).inline = this;
this._row = row;
this._id = null;
this._phid = null;
this._hidden = false;
this._originalText = null;
return row;
},
setHidden: function(hidden) {
this._hidden = hidden;
JX.DOM.alterClass(this._row, 'inline-hidden', this._hidden);
var op;
if (hidden) {
op = 'hide';
} else {
op = 'show';
}
var inline_uri = this._getInlineURI();
var comment_id = this._id;
new JX.Workflow(inline_uri, {op: op, ids: comment_id})
.setHandler(JX.bag)
.start();
this._didUpdate(true);
},
isHidden: function() {
return this._hidden;
},
toggleDone: function() {
var uri = this._getInlineURI();
var data = {
op: 'done',
id: this._id
};
var ondone = JX.bind(this, this._ondone);
new JX.Workflow(uri, data)
.setHandler(ondone)
.start();
},
_ondone: function(response) {
var checkbox = JX.DOM.find(
this._row,
'input',
'differential-inline-done');
checkbox.checked = (response.isChecked ? 'checked' : null);
var comment = JX.DOM.findAbove(
checkbox,
'div',
'differential-inline-comment');
JX.DOM.alterClass(comment, 'inline-is-done', response.isChecked);
// NOTE: This is marking the inline as having an unsubmitted checkmark,
// as opposed to a submitted checkmark. This is different from the
// top-level "draft" state of unsubmitted comments.
JX.DOM.alterClass(comment, 'inline-state-is-draft', response.draftState);
this._isFixed = response.isChecked;
this._didUpdate();
},
create: function(text) {
var uri = this._getInlineURI();
var handler = JX.bind(this, this._oncreateresponse);
var data = this._newRequestData('new', text);
this.setLoading(true);
new JX.Request(uri, handler)
.setData(data)
.send();
},
reply: function(text) {
var changeset = this.getChangeset();
return changeset.newInlineReply(this, text);
},
edit: function(text) {
var uri = this._getInlineURI();
var handler = JX.bind(this, this._oneditresponse);
var data = this._newRequestData('edit', text || null);
this.setLoading(true);
new JX.Request(uri, handler)
.setData(data)
.send();
},
delete: function(is_ref) {
var uri = this._getInlineURI();
var handler = JX.bind(this, this._ondeleteresponse);
// NOTE: This may be a direct delete (the user clicked on the inline
// itself) or a "refdelete" (the user clicked somewhere else, like the
// preview, but the inline is present on the page).
// For a "refdelete", we prompt the user to confirm that they want to
// delete the comment, because they can not undo deletions from the
// preview. We could jump the user to the inline instead, but this would
// be somewhat disruptive and make deleting several comments more
// difficult.
var op;
if (is_ref) {
op = 'refdelete';
} else {
op = 'delete';
}
var data = this._newRequestData(op);
this.setLoading(true);
new JX.Workflow(uri, data)
.setHandler(handler)
.start();
},
getDisplaySide: function() {
return this._displaySide;
},
getLineNumber: function() {
return this._number;
},
getLineLength: function() {
return this._length;
},
isNewFile: function() {
return this._isNewFile;
},
getID: function() {
return this._id;
},
getPHID: function() {
return this._phid;
},
getChangesetID: function() {
return this._changesetID;
},
getReplyToCommentPHID: function() {
return this._replyToCommentPHID;
},
setDeleted: function(deleted) {
this._isDeleted = deleted;
this._redraw();
return this;
},
setInvisible: function(invisible) {
this._isInvisible = invisible;
this._redraw();
return this;
},
setLoading: function(loading) {
this._isLoading = loading;
this._redraw();
return this;
},
_newRequestData: function(operation, text) {
return {
op: operation,
id: this._id,
on_right: ((this.getDisplaySide() == 'right') ? 1 : 0),
renderer: this.getChangeset().getRenderer(),
number: this.getLineNumber(),
length: this.getLineLength(),
is_new: this.isNewFile(),
changesetID: this.getChangesetID(),
replyToCommentPHID: this.getReplyToCommentPHID() || '',
text: text || ''
};
},
_oneditresponse: function(response) {
var rows = JX.$H(response).getNode();
this._drawEditRows(rows);
this.setLoading(false);
this.setInvisible(true);
},
_oncreateresponse: function(response) {
var rows = JX.$H(response).getNode();
this._drawEditRows(rows);
},
_ondeleteresponse: function() {
this._drawUndeleteRows();
this.setLoading(false);
this.setDeleted(true);
this._didUpdate();
},
_drawUndeleteRows: function() {
return this._drawUndoRows('undelete', this._row);
},
_drawUneditRows: function(text) {
return this._drawUndoRows('unedit', null, text);
},
_drawUndoRows: function(mode, cursor, text) {
var templates = this.getChangeset().getUndoTemplates();
var template;
if (this.getDisplaySide() == 'right') {
template = templates.r;
} else {
template = templates.l;
}
template = JX.$H(template).getNode();
this._undoRow = this._drawRows(template, cursor, mode, text);
},
_drawContentRows: function(rows) {
return this._drawRows(rows, null, 'content');
},
_drawEditRows: function(rows) {
this.setEditing(true);
return this._drawRows(rows, null, 'edit');
},
_drawRows: function(rows, cursor, type, text) {
var first_row = JX.DOM.scry(rows, 'tr')[0];
var first_meta;
var row = first_row;
var anchor = cursor || this._row;
cursor = cursor || this._row.nextSibling;
var next_row;
while (row) {
// Grab this first, since it's going to change once we insert the row
// into the document.
next_row = row.nextSibling;
// Bind edit and undo rows to this DiffInline object so that
// interactions like hovering work properly.
JX.Stratcom.getData(row).inline = this;
anchor.parentNode.insertBefore(row, cursor);
cursor = row;
var row_meta = {
node: row,
type: type,
text: text || null,
listeners: []
};
if (!first_meta) {
first_meta = row_meta;
}
if (type == 'edit') {
row_meta.listeners.push(
JX.DOM.listen(
row,
['submit', 'didSyntheticSubmit'],
'inline-edit-form',
JX.bind(this, this._onsubmit, row_meta)));
row_meta.listeners.push(
JX.DOM.listen(
row,
'click',
'inline-edit-cancel',
JX.bind(this, this._oncancel, row_meta)));
} else if (type == 'content') {
// No special listeners for these rows.
} else {
row_meta.listeners.push(
JX.DOM.listen(
row,
'click',
'differential-inline-comment-undo',
JX.bind(this, this._onundo, row_meta)));
}
// If the row has a textarea, focus it. This allows the user to start
// typing a comment immediately after a "new", "edit", or "reply"
// action.
var textareas = JX.DOM.scry(
row,
'textarea',
'differential-inline-comment-edit-textarea');
if (textareas.length) {
var area = textareas[0];
area.focus();
var length = area.value.length;
JX.TextAreaUtils.setSelectionRange(area, length, length);
}
row = next_row;
}
JX.Stratcom.invoke('resize');
return first_meta;
},
_onsubmit: function(row, e) {
e.kill();
var handler = JX.bind(this, this._onsubmitresponse, row);
this.setLoading(true);
JX.Workflow.newFromForm(e.getTarget())
.setHandler(handler)
.start();
},
_onundo: function(row, e) {
e.kill();
this._removeRow(row);
if (row.type == 'undelete') {
var uri = this._getInlineURI();
var data = this._newRequestData('undelete');
var handler = JX.bind(this, this._onundelete);
this.setDeleted(false);
this.setLoading(true);
new JX.Request(uri, handler)
.setData(data)
.send();
}
if (row.type == 'unedit') {
if (this.getID()) {
this.edit(row.text);
} else {
this.create(row.text);
}
}
},
_onundelete: function() {
this.setLoading(false);
this._didUpdate();
},
_oncancel: function(row, e) {
e.kill();
var text = this._readText(row.node);
if (text && text.length && (text != this._originalText)) {
this._drawUneditRows(text);
}
this._removeRow(row);
this.setEditing(false);
this.setInvisible(false);
this._didUpdate(true);
},
_readText: function(row) {
var textarea;
try {
textarea = JX.DOM.find(
row,
'textarea',
'differential-inline-comment-edit-textarea');
} catch (ex) {
return null;
}
return textarea.value;
},
_onsubmitresponse: function(row, response) {
this._removeRow(row);
this.setLoading(false);
this.setInvisible(false);
this.setEditing(false);
this._onupdate(response);
},
_onupdate: function(response) {
var new_row;
if (response.markup) {
new_row = this._drawContentRows(JX.$H(response.markup).getNode()).node;
}
// TODO: Save the old row so the action it's undo-able if it was a
// delete.
var remove_old = true;
if (remove_old) {
JX.DOM.remove(this._row);
}
this.bindToRow(new_row);
this._didUpdate();
},
_didUpdate: function(local_only) {
// After making changes to inline comments, refresh the transaction
// preview at the bottom of the page.
if (!local_only) {
this.getChangeset().getChangesetList().redrawPreview();
}
this.getChangeset().getChangesetList().redrawCursor();
this.getChangeset().getChangesetList().resetHover();
// Emit a resize event so that UI elements like the keyboard focus
// reticle can redraw properly.
JX.Stratcom.invoke('resize');
},
_redraw: function() {
var is_invisible = (this._isInvisible || this._isDeleted);
var is_loading = (this._isLoading);
var row = this._row;
JX.DOM.alterClass(row, 'differential-inline-hidden', is_invisible);
JX.DOM.alterClass(row, 'differential-inline-loading', is_loading);
},
_removeRow: function(row) {
JX.DOM.remove(row.node);
for (var ii = 0; ii < row.listeners.length; ii++) {
row.listeners[ii].remove();
}
},
_getInlineURI: function() {
var changeset = this.getChangeset();
var list = changeset.getChangesetList();
return list.getInlineURI();
}
}
});