@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
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}