@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 608 lines 18 kB view raw
1<?php 2 3abstract class PhabricatorInlineCommentController 4 extends PhabricatorController { 5 6 private $containerObject; 7 8 abstract protected function createComment(); 9 abstract protected function newInlineCommentQuery(); 10 abstract protected function loadCommentForDone($id); 11 abstract protected function loadObjectOwnerPHID( 12 PhabricatorInlineComment $inline); 13 abstract protected function newContainerObject(); 14 15 final protected function getContainerObject() { 16 if ($this->containerObject === null) { 17 $object = $this->newContainerObject(); 18 if (!$object) { 19 throw new Exception( 20 pht( 21 'Failed to load container object for inline comment.')); 22 } 23 $this->containerObject = $object; 24 } 25 26 return $this->containerObject; 27 } 28 29 protected function hideComments(array $ids) { 30 throw new PhutilMethodNotImplementedException(); 31 } 32 33 protected function showComments(array $ids) { 34 throw new PhutilMethodNotImplementedException(); 35 } 36 37 private $changesetID; 38 private $isNewFile; 39 private $isOnRight; 40 private $lineNumber; 41 private $lineLength; 42 private $operation; 43 private $commentID; 44 private $renderer; 45 private $replyToCommentPHID; 46 47 public function getCommentID() { 48 return $this->commentID; 49 } 50 51 public function getOperation() { 52 return $this->operation; 53 } 54 55 public function getLineLength() { 56 return $this->lineLength; 57 } 58 59 public function getLineNumber() { 60 return $this->lineNumber; 61 } 62 63 public function getIsOnRight() { 64 return $this->isOnRight; 65 } 66 67 public function getChangesetID() { 68 return $this->changesetID; 69 } 70 71 public function getIsNewFile() { 72 return $this->isNewFile; 73 } 74 75 public function setRenderer($renderer) { 76 $this->renderer = $renderer; 77 return $this; 78 } 79 80 public function getRenderer() { 81 return $this->renderer; 82 } 83 84 public function setReplyToCommentPHID($phid) { 85 $this->replyToCommentPHID = $phid; 86 return $this; 87 } 88 89 public function getReplyToCommentPHID() { 90 return $this->replyToCommentPHID; 91 } 92 93 public function processRequest() { 94 $request = $this->getRequest(); 95 $viewer = $this->getViewer(); 96 97 if (!$request->validateCSRF()) { 98 return new Aphront404Response(); 99 } 100 101 $this->readRequestParameters(); 102 103 $op = $this->getOperation(); 104 switch ($op) { 105 case 'hide': 106 case 'show': 107 $ids = $request->getStrList('ids'); 108 if ($ids) { 109 if ($op == 'hide') { 110 $this->hideComments($ids); 111 } else { 112 $this->showComments($ids); 113 } 114 } 115 116 return id(new AphrontAjaxResponse())->setContent(array()); 117 case 'done': 118 $inline = $this->loadCommentForDone($this->getCommentID()); 119 120 $is_draft_state = false; 121 $is_checked = false; 122 switch ($inline->getFixedState()) { 123 case PhabricatorInlineComment::STATE_DRAFT: 124 $next_state = PhabricatorInlineComment::STATE_UNDONE; 125 break; 126 case PhabricatorInlineComment::STATE_UNDRAFT: 127 $next_state = PhabricatorInlineComment::STATE_DONE; 128 $is_checked = true; 129 break; 130 case PhabricatorInlineComment::STATE_DONE: 131 $next_state = PhabricatorInlineComment::STATE_UNDRAFT; 132 $is_draft_state = true; 133 break; 134 default: 135 case PhabricatorInlineComment::STATE_UNDONE: 136 $next_state = PhabricatorInlineComment::STATE_DRAFT; 137 $is_draft_state = true; 138 $is_checked = true; 139 break; 140 } 141 142 $inline->setFixedState($next_state)->save(); 143 144 return id(new AphrontAjaxResponse()) 145 ->setContent( 146 array( 147 'isChecked' => $is_checked, 148 'draftState' => $is_draft_state, 149 )); 150 case 'delete': 151 case 'undelete': 152 case 'refdelete': 153 // NOTE: For normal deletes, we just process the delete immediately 154 // and show an "Undo" action. For deletes by reference from the 155 // preview ("refdelete"), we prompt first (because the "Undo" may 156 // not draw, or may not be easy to locate). 157 158 if ($op == 'refdelete') { 159 if (!$request->isFormPost()) { 160 return $this->newDialog() 161 ->setTitle(pht('Really delete comment?')) 162 ->addHiddenInput('id', $this->getCommentID()) 163 ->addHiddenInput('op', $op) 164 ->appendParagraph(pht('Delete this inline comment?')) 165 ->addCancelButton('#') 166 ->addSubmitButton(pht('Delete')); 167 } 168 } 169 170 $is_delete = ($op == 'delete' || $op == 'refdelete'); 171 172 $inline = $this->loadCommentByIDForEdit($this->getCommentID()); 173 174 if ($is_delete) { 175 $inline 176 ->setIsEditing(false) 177 ->setIsDeleted(1); 178 } else { 179 $inline->setIsDeleted(0); 180 } 181 182 $this->saveComment($inline); 183 184 return $this->buildEmptyResponse(); 185 case 'save': 186 $inline = $this->loadCommentByIDForEdit($this->getCommentID()); 187 188 $this->updateCommentContentState($inline); 189 190 $inline 191 ->setIsEditing(false) 192 ->setIsDeleted(0); 193 194 // Since we're saving the comment, update the committed state. 195 $active_state = $inline->getContentState(); 196 $inline->setCommittedContentState($active_state); 197 198 $this->saveComment($inline); 199 200 return $this->buildRenderedCommentResponse( 201 $inline, 202 $this->getIsOnRight()); 203 case 'edit': 204 $inline = $this->loadCommentByIDForEdit($this->getCommentID()); 205 206 // NOTE: At time of writing, the "editing" state of inlines is 207 // preserved by simulating a click on "Edit" when the inline loads. 208 209 // In this case, we don't want to "saveComment()", because it 210 // recalculates object drafts and purges versioned drafts. 211 212 // The recalculation is merely unnecessary (state doesn't change) 213 // but purging drafts means that loading a page and then closing it 214 // discards your drafts. 215 216 // To avoid the purge, only invoke "saveComment()" if we actually 217 // have changes to apply. 218 219 $is_dirty = false; 220 if (!$inline->getIsEditing()) { 221 $inline 222 ->setIsDeleted(0) 223 ->setIsEditing(true); 224 225 $is_dirty = true; 226 } 227 228 if ($this->hasContentState()) { 229 $this->updateCommentContentState($inline); 230 $is_dirty = true; 231 } else { 232 PhabricatorInlineComment::loadAndAttachVersionedDrafts( 233 $viewer, 234 array($inline)); 235 } 236 237 if ($is_dirty) { 238 $this->saveComment($inline); 239 } 240 241 $edit_dialog = $this->buildEditDialog($inline) 242 ->setTitle(pht('Edit Inline Comment')); 243 244 $view = $this->buildScaffoldForView($edit_dialog); 245 246 return $this->newInlineResponse($inline, $view, true); 247 case 'cancel': 248 $inline = $this->loadCommentByIDForEdit($this->getCommentID()); 249 250 $inline->setIsEditing(false); 251 252 // If the user uses "Undo" to get into an edited state ("AB"), then 253 // clicks cancel to return to the previous state ("A"), we also want 254 // to set the stored state back to "A". 255 $this->updateCommentContentState($inline); 256 257 $this->saveComment($inline); 258 259 return $this->buildEmptyResponse(); 260 case 'draft': 261 $inline = $this->loadCommentByIDForEdit($this->getCommentID()); 262 263 $versioned_draft = PhabricatorVersionedDraft::loadOrCreateDraft( 264 $inline->getPHID(), 265 $viewer->getPHID(), 266 $inline->getID()); 267 268 $map = $this->newRequestContentState($inline)->newStorageMap(); 269 $versioned_draft->setProperty('inline.state', $map); 270 $versioned_draft->save(); 271 272 // We have to synchronize the draft engine after saving a versioned 273 // draft, because taking an inline comment from "no text, no draft" 274 // to "no text, text in a draft" marks the container object as having 275 // a draft. 276 $draft_engine = $this->newDraftEngine(); 277 if ($draft_engine) { 278 $draft_engine->synchronize(); 279 } 280 281 return $this->buildEmptyResponse(); 282 case 'new': 283 case 'reply': 284 default: 285 // NOTE: We read the values from the client (the display values), not 286 // the values from the database (the original values) when replying. 287 // In particular, when replying to a ghost comment which was moved 288 // across diffs and then moved backward to the most recent visible 289 // line, we want to reply on the display line (which exists), not on 290 // the comment's original line (which may not exist in this changeset). 291 $is_new = $this->getIsNewFile(); 292 $number = $this->getLineNumber(); 293 $length = $this->getLineLength(); 294 295 $inline = $this->createComment() 296 ->setChangesetID($this->getChangesetID()) 297 ->setAuthorPHID($viewer->getPHID()) 298 ->setIsNewFile($is_new) 299 ->setLineNumber($number) 300 ->setLineLength($length) 301 ->setReplyToCommentPHID($this->getReplyToCommentPHID()) 302 ->setIsEditing(true) 303 ->setStartOffset($request->getInt('startOffset')) 304 ->setEndOffset($request->getInt('endOffset')) 305 ->setContent(''); 306 307 $document_engine_key = $request->getStr('documentEngineKey'); 308 if ($document_engine_key !== null) { 309 $inline->setDocumentEngineKey($document_engine_key); 310 } 311 312 // If you own this object, mark your own inlines as "Done" by default. 313 $owner_phid = $this->loadObjectOwnerPHID($inline); 314 if ($owner_phid) { 315 if ($viewer->getPHID() == $owner_phid) { 316 $fixed_state = PhabricatorInlineComment::STATE_DRAFT; 317 $inline->setFixedState($fixed_state); 318 } 319 } 320 321 if ($this->hasContentState()) { 322 $this->updateCommentContentState($inline); 323 } 324 325 // NOTE: We're writing the comment as "deleted", then reloading to 326 // pick up context and undeleting it. This is silly -- we just want 327 // to load and attach context -- but just loading context is currently 328 // complicated (for example, context relies on cache keys that expect 329 // the inline to have an ID). 330 331 $inline->setIsDeleted(1); 332 333 $this->saveComment($inline); 334 335 // Reload the inline to attach context. 336 $inline = $this->loadCommentByIDForEdit($inline->getID()); 337 338 // Now, we can read the source file and set the initial state. 339 $state = $inline->getContentState(); 340 $default_suggestion = $inline->getDefaultSuggestionText(); 341 $state->setContentSuggestionText($default_suggestion); 342 343 $inline->setInitialContentState($state); 344 $inline->setContentState($state); 345 346 $inline->setIsDeleted(0); 347 348 $this->saveComment($inline); 349 350 $edit_dialog = $this->buildEditDialog($inline); 351 352 if ($this->getOperation() == 'reply') { 353 $edit_dialog->setTitle(pht('Reply to Inline Comment')); 354 } else { 355 $edit_dialog->setTitle(pht('New Inline Comment')); 356 } 357 358 $view = $this->buildScaffoldForView($edit_dialog); 359 360 return $this->newInlineResponse($inline, $view, true); 361 } 362 } 363 364 private function readRequestParameters() { 365 $request = $this->getRequest(); 366 367 // NOTE: This isn't necessarily a DifferentialChangeset ID, just an 368 // application identifier for the changeset. In Diffusion, it's a Path ID. 369 $this->changesetID = $request->getInt('changesetID'); 370 371 $this->isNewFile = (int)$request->getBool('is_new'); 372 $this->isOnRight = $request->getBool('on_right'); 373 $this->lineNumber = $request->getInt('number'); 374 $this->lineLength = $request->getInt('length'); 375 $this->commentID = $request->getInt('id'); 376 $this->operation = $request->getStr('op'); 377 $this->renderer = $request->getStr('renderer'); 378 $this->replyToCommentPHID = $request->getStr('replyToCommentPHID'); 379 380 if ($this->getReplyToCommentPHID()) { 381 $reply_phid = $this->getReplyToCommentPHID(); 382 $reply_comment = $this->loadCommentByPHID($reply_phid); 383 if (!$reply_comment) { 384 throw new Exception( 385 pht('Failed to load comment "%s".', $reply_phid)); 386 } 387 388 // When replying, force the new comment into the same location as the 389 // old comment. If we don't do this, replying to a ghost comment from 390 // diff A while viewing diff B can end up placing the two comments in 391 // different places while viewing diff C, because the porting algorithm 392 // makes a different decision. Forcing the comments to bind to the same 393 // place makes sure they stick together no matter which diff is being 394 // viewed. See T10562 for discussion. 395 396 $this->changesetID = $reply_comment->getChangesetID(); 397 $this->isNewFile = $reply_comment->getIsNewFile(); 398 $this->lineNumber = $reply_comment->getLineNumber(); 399 $this->lineLength = $reply_comment->getLineLength(); 400 } 401 } 402 403 private function buildEditDialog(PhabricatorInlineComment $inline) { 404 $request = $this->getRequest(); 405 $viewer = $this->getViewer(); 406 407 $edit_dialog = id(new PHUIDiffInlineCommentEditView()) 408 ->setViewer($viewer) 409 ->setInlineComment($inline) 410 ->setIsOnRight($this->getIsOnRight()) 411 ->setRenderer($this->getRenderer()); 412 413 return $edit_dialog; 414 } 415 416 private function buildEmptyResponse() { 417 return id(new AphrontAjaxResponse()) 418 ->setContent( 419 array( 420 'inline' => array(), 421 'view' => null, 422 )); 423 } 424 425 private function buildRenderedCommentResponse( 426 PhabricatorInlineComment $inline, 427 $on_right) { 428 429 $request = $this->getRequest(); 430 $viewer = $this->getViewer(); 431 432 $engine = new PhabricatorMarkupEngine(); 433 $engine->setViewer($viewer); 434 $engine->addObject( 435 $inline, 436 PhabricatorInlineComment::MARKUP_FIELD_BODY); 437 $engine->process(); 438 439 $phids = array($viewer->getPHID()); 440 441 $handles = $this->loadViewerHandles($phids); 442 $object_owner_phid = $this->loadObjectOwnerPHID($inline); 443 444 $view = id(new PHUIDiffInlineCommentDetailView()) 445 ->setUser($viewer) 446 ->setInlineComment($inline) 447 ->setIsOnRight($on_right) 448 ->setMarkupEngine($engine) 449 ->setHandles($handles) 450 ->setEditable(true) 451 ->setCanMarkDone(false) 452 ->setObjectOwnerPHID($object_owner_phid); 453 454 $view = $this->buildScaffoldForView($view); 455 456 return $this->newInlineResponse($inline, $view, false); 457 } 458 459 private function buildScaffoldForView(PHUIDiffInlineCommentView $view) { 460 $renderer = DifferentialChangesetHTMLRenderer::getHTMLRendererByKey( 461 $this->getRenderer()); 462 463 $view = $renderer->getRowScaffoldForInline($view); 464 465 return id(new PHUIDiffInlineCommentTableScaffold()) 466 ->addRowScaffold($view); 467 } 468 469 private function newInlineResponse( 470 PhabricatorInlineComment $inline, 471 $view, 472 $is_edit) { 473 $viewer = $this->getViewer(); 474 475 if ($inline->getReplyToCommentPHID()) { 476 $can_suggest = false; 477 } else { 478 $can_suggest = (bool)$inline->getInlineContext(); 479 } 480 481 if ($is_edit) { 482 $state = $inline->getContentStateMapForEdit($viewer); 483 } else { 484 $state = $inline->getContentStateMap(); 485 } 486 487 $response = array( 488 'inline' => array( 489 'id' => $inline->getID(), 490 'state' => $state, 491 'canSuggestEdit' => $can_suggest, 492 ), 493 'view' => hsprintf('%s', $view), 494 ); 495 496 return id(new AphrontAjaxResponse()) 497 ->setContent($response); 498 } 499 500 final protected function loadCommentByID($id) { 501 $query = $this->newInlineCommentQuery() 502 ->withIDs(array($id)); 503 504 return $this->loadCommentByQuery($query); 505 } 506 507 final protected function loadCommentByPHID($phid) { 508 $query = $this->newInlineCommentQuery() 509 ->withPHIDs(array($phid)); 510 511 return $this->loadCommentByQuery($query); 512 } 513 514 final protected function loadCommentByIDForEdit($id) { 515 $viewer = $this->getViewer(); 516 517 $query = $this->newInlineCommentQuery() 518 ->withIDs(array($id)) 519 ->needInlineContext(true); 520 521 $inline = $this->loadCommentByQuery($query); 522 523 if (!$inline) { 524 throw new Exception( 525 pht( 526 'Unable to load inline "%s".', 527 $id)); 528 } 529 530 if (!$this->canEditInlineComment($viewer, $inline)) { 531 throw new Exception( 532 pht( 533 'Inline comment "%s" is not editable.', 534 $id)); 535 } 536 537 return $inline; 538 } 539 540 private function loadCommentByQuery( 541 PhabricatorDiffInlineCommentQuery $query) { 542 $viewer = $this->getViewer(); 543 544 $inline = $query 545 ->setViewer($viewer) 546 ->executeOne(); 547 548 if ($inline) { 549 $inline = $inline->newInlineCommentObject(); 550 } 551 552 return $inline; 553 } 554 555 private function hasContentState() { 556 $request = $this->getRequest(); 557 return (bool)$request->getBool('hasContentState'); 558 } 559 560 private function newRequestContentState($inline) { 561 $request = $this->getRequest(); 562 return $inline->newContentStateFromRequest($request); 563 } 564 565 private function updateCommentContentState(PhabricatorInlineComment $inline) { 566 if (!$this->hasContentState()) { 567 throw new Exception( 568 pht( 569 'Attempting to update comment content state, but request has no '. 570 'content state.')); 571 } 572 573 $state = $this->newRequestContentState($inline); 574 $inline->setContentState($state); 575 } 576 577 private function saveComment(PhabricatorInlineComment $inline) { 578 $viewer = $this->getViewer(); 579 $draft_engine = $this->newDraftEngine(); 580 581 $inline->openTransaction(); 582 $inline->save(); 583 584 PhabricatorVersionedDraft::purgeDrafts( 585 $inline->getPHID(), 586 $viewer->getPHID()); 587 588 if ($draft_engine) { 589 $draft_engine->synchronize(); 590 } 591 592 $inline->saveTransaction(); 593 } 594 595 private function newDraftEngine() { 596 $viewer = $this->getViewer(); 597 $object = $this->getContainerObject(); 598 599 if (!($object instanceof PhabricatorDraftInterface)) { 600 return null; 601 } 602 603 return $object->newDraftEngine() 604 ->setObject($object) 605 ->setViewer($viewer); 606 } 607 608}