diff --git a/src/infrastructure/diff/PhabricatorInlineCommentController.php b/src/infrastructure/diff/PhabricatorInlineCommentController.php index e1b8dc7264..693f66066f 100644 --- a/src/infrastructure/diff/PhabricatorInlineCommentController.php +++ b/src/infrastructure/diff/PhabricatorInlineCommentController.php @@ -1,591 +1,596 @@ containerObject === null) { $object = $this->newContainerObject(); if (!$object) { throw new Exception( pht( 'Failed to load container object for inline comment.')); } $this->containerObject = $object; } return $this->containerObject; } protected function hideComments(array $ids) { throw new PhutilMethodNotImplementedException(); } protected function showComments(array $ids) { throw new PhutilMethodNotImplementedException(); } private $changesetID; private $isNewFile; private $isOnRight; private $lineNumber; private $lineLength; private $operation; private $commentID; private $renderer; private $replyToCommentPHID; public function getCommentID() { return $this->commentID; } public function getOperation() { return $this->operation; } public function getLineLength() { return $this->lineLength; } public function getLineNumber() { return $this->lineNumber; } public function getIsOnRight() { return $this->isOnRight; } public function getChangesetID() { return $this->changesetID; } public function getIsNewFile() { return $this->isNewFile; } public function setRenderer($renderer) { $this->renderer = $renderer; return $this; } public function getRenderer() { return $this->renderer; } public function setReplyToCommentPHID($phid) { $this->replyToCommentPHID = $phid; return $this; } public function getReplyToCommentPHID() { return $this->replyToCommentPHID; } public function processRequest() { $request = $this->getRequest(); $viewer = $this->getViewer(); if (!$request->validateCSRF()) { return new Aphront404Response(); } $this->readRequestParameters(); $op = $this->getOperation(); switch ($op) { case 'hide': case 'show': $ids = $request->getStrList('ids'); if ($ids) { if ($op == 'hide') { $this->hideComments($ids); } else { $this->showComments($ids); } } return id(new AphrontAjaxResponse())->setContent(array()); case 'done': $inline = $this->loadCommentForDone($this->getCommentID()); $is_draft_state = false; $is_checked = false; switch ($inline->getFixedState()) { case PhabricatorInlineComment::STATE_DRAFT: $next_state = PhabricatorInlineComment::STATE_UNDONE; break; case PhabricatorInlineComment::STATE_UNDRAFT: $next_state = PhabricatorInlineComment::STATE_DONE; $is_checked = true; break; case PhabricatorInlineComment::STATE_DONE: $next_state = PhabricatorInlineComment::STATE_UNDRAFT; $is_draft_state = true; break; default: case PhabricatorInlineComment::STATE_UNDONE: $next_state = PhabricatorInlineComment::STATE_DRAFT; $is_draft_state = true; $is_checked = true; break; } $inline->setFixedState($next_state)->save(); return id(new AphrontAjaxResponse()) ->setContent( array( 'isChecked' => $is_checked, 'draftState' => $is_draft_state, )); case 'delete': case 'undelete': case 'refdelete': // NOTE: For normal deletes, we just process the delete immediately // and show an "Undo" action. For deletes by reference from the // preview ("refdelete"), we prompt first (because the "Undo" may // not draw, or may not be easy to locate). if ($op == 'refdelete') { if (!$request->isFormPost()) { return $this->newDialog() ->setTitle(pht('Really delete comment?')) ->addHiddenInput('id', $this->getCommentID()) ->addHiddenInput('op', $op) ->appendParagraph(pht('Delete this inline comment?')) ->addCancelButton('#') ->addSubmitButton(pht('Delete')); } } $is_delete = ($op == 'delete' || $op == 'refdelete'); $inline = $this->loadCommentByIDForEdit($this->getCommentID()); if ($is_delete) { $inline->setIsDeleted(1); } else { $inline->setIsDeleted(0); } $this->saveComment($inline); return $this->buildEmptyResponse(); case 'edit': case 'save': $inline = $this->loadCommentByIDForEdit($this->getCommentID()); if ($op === 'save') { $this->updateCommentContentState($inline); $inline->setIsEditing(false); if (!$inline->isVoidComment($viewer)) { + $inline->setIsDeleted(0); + $this->saveComment($inline); return $this->buildRenderedCommentResponse( $inline, $this->getIsOnRight()); } else { $inline->setIsDeleted(1); $this->saveComment($inline); return $this->buildEmptyResponse(); } } else { // NOTE: At time of writing, the "editing" state of inlines is // preserved by simulating a click on "Edit" when the inline loads. // In this case, we don't want to "saveComment()", because it // recalculates object drafts and purges versioned drafts. // The recalculation is merely unnecessary (state doesn't change) // but purging drafts means that loading a page and then closing it // discards your drafts. // To avoid the purge, only invoke "saveComment()" if we actually // have changes to apply. $is_dirty = false; if (!$inline->getIsEditing()) { - $inline->setIsEditing(true); + $inline + ->setIsDeleted(0) + ->setIsEditing(true); + $is_dirty = true; } if ($this->hasContentState()) { $this->updateCommentContentState($inline); $is_dirty = true; } else { PhabricatorInlineComment::loadAndAttachVersionedDrafts( $viewer, array($inline)); } if ($is_dirty) { $this->saveComment($inline); } } $edit_dialog = $this->buildEditDialog($inline) ->setTitle(pht('Edit Inline Comment')); $view = $this->buildScaffoldForView($edit_dialog); return $this->newInlineResponse($inline, $view, true); case 'cancel': $inline = $this->loadCommentByIDForEdit($this->getCommentID()); $inline->setIsEditing(false); // If the user uses "Undo" to get into an edited state ("AB"), then // clicks cancel to return to the previous state ("A"), we also want // to set the stored state back to "A". $this->updateCommentContentState($inline); if ($inline->isVoidComment($viewer)) { $inline->setIsDeleted(1); } $this->saveComment($inline); return $this->buildEmptyResponse(); case 'draft': $inline = $this->loadCommentByIDForEdit($this->getCommentID()); $versioned_draft = PhabricatorVersionedDraft::loadOrCreateDraft( $inline->getPHID(), $viewer->getPHID(), $inline->getID()); $map = $this->newRequestContentState($inline)->newStorageMap(); $versioned_draft->setProperty('inline.state', $map); $versioned_draft->save(); // We have to synchronize the draft engine after saving a versioned // draft, because taking an inline comment from "no text, no draft" // to "no text, text in a draft" marks the container object as having // a draft. $draft_engine = $this->newDraftEngine(); if ($draft_engine) { $draft_engine->synchronize(); } return $this->buildEmptyResponse(); case 'new': case 'reply': default: // NOTE: We read the values from the client (the display values), not // the values from the database (the original values) when replying. // In particular, when replying to a ghost comment which was moved // across diffs and then moved backward to the most recent visible // line, we want to reply on the display line (which exists), not on // the comment's original line (which may not exist in this changeset). $is_new = $this->getIsNewFile(); $number = $this->getLineNumber(); $length = $this->getLineLength(); $inline = $this->createComment() ->setChangesetID($this->getChangesetID()) ->setAuthorPHID($viewer->getPHID()) ->setIsNewFile($is_new) ->setLineNumber($number) ->setLineLength($length) ->setReplyToCommentPHID($this->getReplyToCommentPHID()) ->setIsEditing(true) ->setStartOffset($request->getInt('startOffset')) ->setEndOffset($request->getInt('endOffset')) ->setContent(''); $document_engine_key = $request->getStr('documentEngineKey'); if ($document_engine_key !== null) { $inline->setDocumentEngineKey($document_engine_key); } // If you own this object, mark your own inlines as "Done" by default. $owner_phid = $this->loadObjectOwnerPHID($inline); if ($owner_phid) { if ($viewer->getPHID() == $owner_phid) { $fixed_state = PhabricatorInlineComment::STATE_DRAFT; $inline->setFixedState($fixed_state); } } if ($this->hasContentState()) { $this->updateCommentContentState($inline); } $this->saveComment($inline); // Reload the inline to attach context. $inline = $this->loadCommentByIDForEdit($inline->getID()); $edit_dialog = $this->buildEditDialog($inline); if ($this->getOperation() == 'reply') { $edit_dialog->setTitle(pht('Reply to Inline Comment')); } else { $edit_dialog->setTitle(pht('New Inline Comment')); } $view = $this->buildScaffoldForView($edit_dialog); return $this->newInlineResponse($inline, $view, true); } } private function readRequestParameters() { $request = $this->getRequest(); // NOTE: This isn't necessarily a DifferentialChangeset ID, just an // application identifier for the changeset. In Diffusion, it's a Path ID. $this->changesetID = $request->getInt('changesetID'); $this->isNewFile = (int)$request->getBool('is_new'); $this->isOnRight = $request->getBool('on_right'); $this->lineNumber = $request->getInt('number'); $this->lineLength = $request->getInt('length'); $this->commentID = $request->getInt('id'); $this->operation = $request->getStr('op'); $this->renderer = $request->getStr('renderer'); $this->replyToCommentPHID = $request->getStr('replyToCommentPHID'); if ($this->getReplyToCommentPHID()) { $reply_phid = $this->getReplyToCommentPHID(); $reply_comment = $this->loadCommentByPHID($reply_phid); if (!$reply_comment) { throw new Exception( pht('Failed to load comment "%s".', $reply_phid)); } // When replying, force the new comment into the same location as the // old comment. If we don't do this, replying to a ghost comment from // diff A while viewing diff B can end up placing the two comments in // different places while viewing diff C, because the porting algorithm // makes a different decision. Forcing the comments to bind to the same // place makes sure they stick together no matter which diff is being // viewed. See T10562 for discussion. $this->changesetID = $reply_comment->getChangesetID(); $this->isNewFile = $reply_comment->getIsNewFile(); $this->lineNumber = $reply_comment->getLineNumber(); $this->lineLength = $reply_comment->getLineLength(); } } private function buildEditDialog(PhabricatorInlineComment $inline) { $request = $this->getRequest(); $viewer = $this->getViewer(); $edit_dialog = id(new PHUIDiffInlineCommentEditView()) ->setViewer($viewer) ->setInlineComment($inline) ->setIsOnRight($this->getIsOnRight()) ->setRenderer($this->getRenderer()); return $edit_dialog; } private function buildEmptyResponse() { return id(new AphrontAjaxResponse()) ->setContent( array( 'inline' => array(), 'view' => null, )); } private function buildRenderedCommentResponse( PhabricatorInlineComment $inline, $on_right) { $request = $this->getRequest(); $viewer = $this->getViewer(); $engine = new PhabricatorMarkupEngine(); $engine->setViewer($viewer); $engine->addObject( $inline, PhabricatorInlineComment::MARKUP_FIELD_BODY); $engine->process(); $phids = array($viewer->getPHID()); $handles = $this->loadViewerHandles($phids); $object_owner_phid = $this->loadObjectOwnerPHID($inline); $view = id(new PHUIDiffInlineCommentDetailView()) ->setUser($viewer) ->setInlineComment($inline) ->setIsOnRight($on_right) ->setMarkupEngine($engine) ->setHandles($handles) ->setEditable(true) ->setCanMarkDone(false) ->setObjectOwnerPHID($object_owner_phid); $view = $this->buildScaffoldForView($view); return $this->newInlineResponse($inline, $view, false); } private function buildScaffoldForView(PHUIDiffInlineCommentView $view) { $renderer = DifferentialChangesetHTMLRenderer::getHTMLRendererByKey( $this->getRenderer()); $view = $renderer->getRowScaffoldForInline($view); return id(new PHUIDiffInlineCommentTableScaffold()) ->addRowScaffold($view); } private function newInlineResponse( PhabricatorInlineComment $inline, $view, $is_edit) { if ($inline->getReplyToCommentPHID()) { $can_suggest = false; } else { $can_suggest = (bool)$inline->getInlineContext(); } if ($is_edit) { $viewer = $this->getViewer(); $content_state = $inline->getContentStateForEdit($viewer); } else { $content_state = $inline->getContentState(); } $state_map = $content_state->newStorageMap(); $response = array( 'inline' => array( 'id' => $inline->getID(), 'contentState' => $state_map, 'canSuggestEdit' => $can_suggest, ), 'view' => hsprintf('%s', $view), ); return id(new AphrontAjaxResponse()) ->setContent($response); } final protected function loadCommentByID($id) { $query = $this->newInlineCommentQuery() ->withIDs(array($id)); return $this->loadCommentByQuery($query); } final protected function loadCommentByPHID($phid) { $query = $this->newInlineCommentQuery() ->withPHIDs(array($phid)); return $this->loadCommentByQuery($query); } final protected function loadCommentByIDForEdit($id) { $viewer = $this->getViewer(); $query = $this->newInlineCommentQuery() ->withIDs(array($id)) ->needInlineContext(true); $inline = $this->loadCommentByQuery($query); if (!$inline) { throw new Exception( pht( 'Unable to load inline "%s".', $id)); } if (!$this->canEditInlineComment($viewer, $inline)) { throw new Exception( pht( 'Inline comment "%s" is not editable.', $id)); } return $inline; } private function loadCommentByQuery( PhabricatorDiffInlineCommentQuery $query) { $viewer = $this->getViewer(); $inline = $query ->setViewer($viewer) ->executeOne(); if ($inline) { $inline = $inline->newInlineCommentObject(); } return $inline; } private function hasContentState() { $request = $this->getRequest(); return (bool)$request->getBool('hasContentState'); } private function newRequestContentState($inline) { $request = $this->getRequest(); return $inline->newContentStateFromRequest($request); } private function updateCommentContentState(PhabricatorInlineComment $inline) { if (!$this->hasContentState()) { throw new Exception( pht( 'Attempting to update comment content state, but request has no '. 'content state.')); } $state = $this->newRequestContentState($inline); $inline->setContentState($state); } private function saveComment(PhabricatorInlineComment $inline) { $viewer = $this->getViewer(); $draft_engine = $this->newDraftEngine(); $inline->openTransaction(); $inline->save(); PhabricatorVersionedDraft::purgeDrafts( $inline->getPHID(), $viewer->getPHID()); if ($draft_engine) { $draft_engine->synchronize(); } $inline->saveTransaction(); } private function newDraftEngine() { $viewer = $this->getViewer(); $object = $this->getContainerObject(); if (!($object instanceof PhabricatorDraftInterface)) { return null; } return $object->newDraftEngine() ->setObject($object) ->setViewer($viewer); } }