@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

Save drafts for inline comments currently being edited

Summary:
Ref T13513. As users type text into inline comments, save the comment state as a draft on the server.

This has some rough edges, particularly around previews, but mostly works. See T13513 for notes.

Test Plan: Started an inline, typed some text, waited a second, reloaded the page, saw an editing inline with the saved text.

Maniphest Tasks: T13513

Differential Revision: https://secure.phabricator.com/D21216

+283 -51
+21 -21
resources/celerity/map.php
··· 13 13 'core.pkg.js' => '632fb8f5', 14 14 'dark-console.pkg.js' => '187792c2', 15 15 'differential.pkg.css' => '2d70b7b9', 16 - 'differential.pkg.js' => '4287e51f', 16 + 'differential.pkg.js' => '4d375e61', 17 17 'diffusion.pkg.css' => '42c75c37', 18 18 'diffusion.pkg.js' => 'a98c0bf7', 19 19 'maniphest.pkg.css' => '35995d6d', ··· 380 380 'rsrc/js/application/dashboard/behavior-dashboard-query-panel-select.js' => '1e413dc9', 381 381 'rsrc/js/application/dashboard/behavior-dashboard-tab-panel.js' => '0116d3e8', 382 382 'rsrc/js/application/diff/DiffChangeset.js' => 'a49dc31e', 383 - 'rsrc/js/application/diff/DiffChangesetList.js' => '10726e6a', 384 - 'rsrc/js/application/diff/DiffInline.js' => '7f804f2b', 383 + 'rsrc/js/application/diff/DiffChangesetList.js' => '6992b85c', 384 + 'rsrc/js/application/diff/DiffInline.js' => 'a39fd98e', 385 385 'rsrc/js/application/diff/DiffPathView.js' => '8207abf9', 386 386 'rsrc/js/application/diff/DiffTreeView.js' => '5d83623b', 387 387 'rsrc/js/application/diff/behavior-preview-link.js' => 'f51e9c17', ··· 462 462 'rsrc/js/core/MultirowRowManager.js' => '5b54c823', 463 463 'rsrc/js/core/Notification.js' => 'a9b91e3f', 464 464 'rsrc/js/core/Prefab.js' => '5793d835', 465 - 'rsrc/js/core/ShapedRequest.js' => 'abf88db8', 465 + 'rsrc/js/core/ShapedRequest.js' => '995f5102', 466 466 'rsrc/js/core/TextAreaUtils.js' => 'f340a484', 467 467 'rsrc/js/core/Title.js' => '43bc9360', 468 468 'rsrc/js/core/ToolTip.js' => '83754533', ··· 777 777 'phabricator-darkmessage' => '26cd4b73', 778 778 'phabricator-dashboard-css' => '5a205b9d', 779 779 'phabricator-diff-changeset' => 'a49dc31e', 780 - 'phabricator-diff-changeset-list' => '10726e6a', 781 - 'phabricator-diff-inline' => '7f804f2b', 780 + 'phabricator-diff-changeset-list' => '6992b85c', 781 + 'phabricator-diff-inline' => 'a39fd98e', 782 782 'phabricator-diff-path-view' => '8207abf9', 783 783 'phabricator-diff-tree-view' => '5d83623b', 784 784 'phabricator-drag-and-drop-file-upload' => '4370900d', ··· 800 800 'phabricator-prefab' => '5793d835', 801 801 'phabricator-remarkup-css' => 'c286eaef', 802 802 'phabricator-search-results-css' => '9ea70ace', 803 - 'phabricator-shaped-request' => 'abf88db8', 803 + 'phabricator-shaped-request' => '995f5102', 804 804 'phabricator-slowvote-css' => '1694baed', 805 805 'phabricator-source-code-view-css' => '03d7ac28', 806 806 'phabricator-standard-page-view' => 'a374f94c', ··· 1021 1021 'phuix-action-view', 1022 1022 'javelin-workflow', 1023 1023 'phuix-icon-view', 1024 - ), 1025 - '10726e6a' => array( 1026 - 'javelin-install', 1027 - 'phuix-button-view', 1028 - 'phabricator-diff-tree-view', 1029 1024 ), 1030 1025 '111bfd2d' => array( 1031 1026 'javelin-install', ··· 1519 1514 'javelin-install', 1520 1515 'javelin-dom', 1521 1516 ), 1517 + '6992b85c' => array( 1518 + 'javelin-install', 1519 + 'phuix-button-view', 1520 + 'phabricator-diff-tree-view', 1521 + ), 1522 1522 '6a1583a8' => array( 1523 1523 'javelin-behavior', 1524 1524 'javelin-history', ··· 1624 1624 ), 1625 1625 '7c4d8998' => array( 1626 1626 'javelin-install', 1627 - 'javelin-dom', 1628 - ), 1629 - '7f804f2b' => array( 1630 1627 'javelin-dom', 1631 1628 ), 1632 1629 '80bff3af' => array( ··· 1797 1794 'javelin-request', 1798 1795 'javelin-util', 1799 1796 ), 1797 + '995f5102' => array( 1798 + 'javelin-install', 1799 + 'javelin-util', 1800 + 'javelin-request', 1801 + 'javelin-router', 1802 + ), 1800 1803 '9aae2b66' => array( 1801 1804 'javelin-install', 1802 1805 'javelin-util', ··· 1837 1840 'javelin-stratcom', 1838 1841 'javelin-workflow', 1839 1842 'phabricator-draggable-list', 1843 + ), 1844 + 'a39fd98e' => array( 1845 + 'javelin-dom', 1840 1846 ), 1841 1847 'a4356cde' => array( 1842 1848 'javelin-install', ··· 1915 1921 'javelin-install', 1916 1922 'javelin-dom', 1917 1923 'phabricator-notification', 1918 - ), 1919 - 'abf88db8' => array( 1920 - 'javelin-install', 1921 - 'javelin-util', 1922 - 'javelin-request', 1923 - 'javelin-router', 1924 1924 ), 1925 1925 'ad258e28' => array( 1926 1926 'javelin-behavior',
+7 -1
src/applications/audit/editor/PhabricatorAuditEditor.php
··· 105 105 106 106 switch ($xaction->getTransactionType()) { 107 107 case PhabricatorAuditActionConstants::INLINE: 108 - $xaction->getComment()->setAttribute('editing', false); 108 + $comment = $xaction->getComment(); 109 + 110 + $comment->setAttribute('editing', false); 111 + 112 + PhabricatorVersionedDraft::purgeDrafts( 113 + $comment->getPHID(), 114 + $this->getActingAsPHID()); 109 115 return; 110 116 case PhabricatorAuditTransaction::TYPE_COMMIT: 111 117 return;
-7
src/applications/audit/storage/PhabricatorAuditInlineComment.php
··· 111 111 $viewer->getPHID()); 112 112 } 113 113 114 - foreach ($inlines as $key => $inline) { 115 - $is_draft = !$inline->getTransactionPHID(); 116 - if ($is_draft && $inline->isEmptyInlineComment()) { 117 - unset($inlines[$key]); 118 - } 119 - } 120 - 121 114 return self::buildProxies($inlines); 122 115 } 123 116
-1
src/applications/differential/controller/DifferentialChangesetViewController.php
··· 197 197 $query = id(new DifferentialInlineCommentQuery()) 198 198 ->setViewer($viewer) 199 199 ->needHidden(true) 200 - ->withEmptyInlineComments(false) 201 200 ->withRevisionPHIDs(array($revision->getPHID())); 202 201 $inlines = $query->execute(); 203 202 $inlines = $query->adjustInlinesForChangesets(
+7 -1
src/applications/differential/editor/DifferentialTransactionEditor.php
··· 112 112 113 113 switch ($xaction->getTransactionType()) { 114 114 case DifferentialTransaction::TYPE_INLINE: 115 - $xaction->getComment()->setAttribute('editing', false); 115 + $comment = $xaction->getComment(); 116 + 117 + $comment->setAttribute('editing', false); 118 + 119 + PhabricatorVersionedDraft::purgeDrafts( 120 + $comment->getPHID(), 121 + $this->getActingAsPHID()); 116 122 return; 117 123 } 118 124
+13
src/applications/differential/parser/DifferentialChangesetParser.php
··· 752 752 $range_len = null, 753 753 $mask_force = array()) { 754 754 755 + $viewer = $this->getViewer(); 756 + 755 757 $renderer = $this->getRenderer(); 756 758 if (!$renderer) { 757 759 $renderer = $this->newRenderer(); ··· 852 854 } 853 855 854 856 $has_document_engine = ($engine_blocks !== null); 857 + 858 + // Remove empty comments that don't have any unsaved draft data. 859 + PhabricatorInlineComment::loadAndAttachVersionedDrafts( 860 + $viewer, 861 + $this->comments); 862 + foreach ($this->comments as $key => $comment) { 863 + if ($comment->isVoidComment($viewer)) { 864 + unset($this->comments[$key]); 865 + } 866 + } 855 867 856 868 // See T13515. Sometimes, we collapse file content by default: for 857 869 // example, if the file is marked as containing generated code. ··· 1050 1062 } 1051 1063 } 1052 1064 } 1065 + 1053 1066 $renderer 1054 1067 ->setOldComments($old_comments) 1055 1068 ->setNewComments($new_comments);
-15
src/applications/differential/query/DifferentialInlineCommentQuery.php
··· 16 16 private $revisionPHIDs; 17 17 private $deletedDrafts; 18 18 private $needHidden; 19 - private $withEmpty; 20 19 21 20 public function setViewer(PhabricatorUser $viewer) { 22 21 $this->viewer = $viewer; ··· 62 61 return $this; 63 62 } 64 63 65 - public function withEmptyInlineComments($empty) { 66 - $this->withEmpty = $empty; 67 - return $this; 68 - } 69 - 70 64 public function execute() { 71 65 $table = new DifferentialTransactionComment(); 72 66 $conn_r = $table->establishConnection('r'); ··· 79 73 $this->buildLimitClause($conn_r)); 80 74 81 75 $comments = $table->loadAllFromArray($data); 82 - 83 - if ($this->withEmpty !== null) { 84 - $want_empty = (bool)$this->withEmpty; 85 - foreach ($comments as $key => $value) { 86 - if ($value->isEmptyInlineComment() !== $want_empty) { 87 - unset($comments[$key]); 88 - } 89 - } 90 - } 91 76 92 77 if ($this->needHidden) { 93 78 $viewer_phid = $this->getViewer()->getPHID();
+13 -2
src/applications/differential/query/DifferentialTransactionQuery.php
··· 20 20 ->needReplyToComments(true) 21 21 ->execute(); 22 22 23 - // Don't count empty inlines when considering draft state. 23 + foreach ($inlines as $key => $inline) { 24 + $inlines[$key] = DifferentialInlineComment::newFromModernComment( 25 + $inline); 26 + } 27 + 28 + PhabricatorInlineComment::loadAndAttachVersionedDrafts( 29 + $viewer, 30 + $inlines); 31 + 32 + // Don't count void inlines when considering draft state. 24 33 foreach ($inlines as $key => $inline) { 25 - if ($inline->isEmptyInlineComment()) { 34 + if ($inline->isVoidComment($viewer)) { 26 35 unset($inlines[$key]); 27 36 } 28 37 } 38 + 39 + $inlines = mpull($inlines, 'getStorageObject'); 29 40 30 41 return $inlines; 31 42 }
+17
src/applications/draft/storage/PhabricatorVersionedDraft.php
··· 35 35 return idx($this->properties, $key, $default); 36 36 } 37 37 38 + public static function loadDrafts( 39 + array $object_phids, 40 + $viewer_phid) { 41 + 42 + $rows = id(new self())->loadAllWhere( 43 + 'objectPHID IN (%Ls) AND authorPHID = %s ORDER BY version ASC', 44 + $object_phids, 45 + $viewer_phid); 46 + 47 + $map = array(); 48 + foreach ($rows as $row) { 49 + $map[$row->getObjectPHID()] = $row; 50 + } 51 + 52 + return $map; 53 + } 54 + 38 55 public static function loadDraft( 39 56 $object_phid, 40 57 $viewer_phid) {
+30
src/infrastructure/diff/PhabricatorInlineCommentController.php
··· 192 192 ->setIsEditing(false); 193 193 194 194 $this->saveComment($inline); 195 + $this->purgeVersionedDrafts($inline); 196 + 195 197 return $this->buildRenderedCommentResponse( 196 198 $inline, 197 199 $this->getIsOnRight()); 198 200 } else { 199 201 $this->deleteComment($inline); 202 + $this->purgeVersionedDrafts($inline); 203 + 200 204 return $this->buildEmptyResponse(); 201 205 } 202 206 } else { ··· 234 238 } else { 235 239 $this->saveComment($inline); 236 240 } 241 + 242 + $this->purgeVersionedDrafts($inline); 243 + 244 + return $this->buildEmptyResponse(); 245 + case 'draft': 246 + $inline = $this->loadCommentForEdit($this->getCommentID()); 247 + 248 + $versioned_draft = PhabricatorVersionedDraft::loadOrCreateDraft( 249 + $inline->getPHID(), 250 + $viewer->getPHID(), 251 + $inline->getID()); 252 + 253 + $text = $this->getCommentText(); 254 + 255 + $versioned_draft 256 + ->setProperty('inline.text', $text) 257 + ->save(); 237 258 238 259 return $this->buildEmptyResponse(); 239 260 case 'new': ··· 404 425 return id(new AphrontAjaxResponse()) 405 426 ->setContent($response); 406 427 } 428 + 429 + private function purgeVersionedDrafts( 430 + PhabricatorInlineComment $inline) { 431 + $viewer = $this->getViewer(); 432 + PhabricatorVersionedDraft::purgeDrafts( 433 + $inline->getPHID(), 434 + $viewer->getPHID()); 435 + } 436 + 407 437 408 438 }
+91
src/infrastructure/diff/interface/PhabricatorInlineComment.php
··· 15 15 private $storageObject; 16 16 private $syntheticAuthor; 17 17 private $isGhost; 18 + private $versionedDrafts = array(); 18 19 19 20 public function __clone() { 20 21 $this->storageObject = clone $this->storageObject; 22 + } 23 + 24 + final public static function loadAndAttachVersionedDrafts( 25 + PhabricatorUser $viewer, 26 + array $inlines) { 27 + 28 + $viewer_phid = $viewer->getPHID(); 29 + if (!$viewer_phid) { 30 + return; 31 + } 32 + 33 + $inlines = mpull($inlines, null, 'getPHID'); 34 + 35 + $load = array(); 36 + foreach ($inlines as $key => $inline) { 37 + if (!$inline->getIsEditing()) { 38 + continue; 39 + } 40 + 41 + if ($inline->getAuthorPHID() !== $viewer_phid) { 42 + continue; 43 + } 44 + 45 + $load[$key] = $inline; 46 + } 47 + 48 + if (!$load) { 49 + return; 50 + } 51 + 52 + $drafts = PhabricatorVersionedDraft::loadDrafts( 53 + array_keys($load), 54 + $viewer_phid); 55 + 56 + $drafts = mpull($drafts, null, 'getObjectPHID'); 57 + foreach ($inlines as $inline) { 58 + $draft = idx($drafts, $inline->getPHID()); 59 + $inline->attachVersionedDraftForViewer($viewer, $draft); 60 + } 21 61 } 22 62 23 63 public function setSyntheticAuthor($synthetic_author) { ··· 202 242 public function makeEphemeral() { 203 243 $this->getStorageObject()->makeEphemeral(); 204 244 return $this; 245 + } 246 + 247 + public function attachVersionedDraftForViewer( 248 + PhabricatorUser $viewer, 249 + PhabricatorVersionedDraft $draft = null) { 250 + 251 + $key = $viewer->getCacheFragment(); 252 + $this->versionedDrafts[$key] = $draft; 253 + 254 + return $this; 255 + } 256 + 257 + public function hasVersionedDraftForViewer(PhabricatorUser $viewer) { 258 + $key = $viewer->getCacheFragment(); 259 + return array_key_exists($key, $this->versionedDrafts); 260 + } 261 + 262 + public function getVersionedDraftForViewer(PhabricatorUser $viewer) { 263 + $key = $viewer->getCacheFragment(); 264 + if (!array_key_exists($key, $this->versionedDrafts)) { 265 + throw new Exception( 266 + pht( 267 + 'Versioned draft is not attached for user with fragment "%s".', 268 + $key)); 269 + } 270 + 271 + return $this->versionedDrafts[$key]; 272 + } 273 + 274 + public function isVoidComment(PhabricatorUser $viewer) { 275 + return !strlen($this->getContentForEdit($viewer)); 276 + } 277 + 278 + public function getContentForEdit(PhabricatorUser $viewer) { 279 + $content = $this->getContent(); 280 + 281 + if (!$this->hasVersionedDraftForViewer($viewer)) { 282 + return $content; 283 + } 284 + 285 + $versioned_draft = $this->getVersionedDraftForViewer($viewer); 286 + if (!$versioned_draft) { 287 + return $content; 288 + } 289 + 290 + $draft_text = $versioned_draft->getProperty('inline.text'); 291 + if ($draft_text === null) { 292 + return $content; 293 + } 294 + 295 + return $draft_text; 205 296 } 206 297 207 298
+4 -1
src/infrastructure/diff/view/PHUIDiffInlineCommentView.php
··· 54 54 } 55 55 56 56 protected function getInlineCommentMetadata() { 57 + $viewer = $this->getViewer(); 57 58 $inline = $this->getInlineComment(); 58 59 59 60 $is_synthetic = (bool)$inline->getSyntheticAuthor(); ··· 74 75 break; 75 76 } 76 77 78 + $original_text = $inline->getContentForEdit($viewer); 79 + 77 80 return array( 78 81 'id' => $inline->getID(), 79 82 'phid' => $inline->getPHID(), ··· 81 84 'number' => $inline->getLineNumber(), 82 85 'length' => $inline->getLineLength(), 83 86 'isNewFile' => (bool)$inline->getIsNewFile(), 84 - 'original' => $inline->getContent(), 87 + 'original' => $original_text, 85 88 'replyToCommentPHID' => $inline->getReplyToCommentPHID(), 86 89 'isDraft' => $inline->isDraft(), 87 90 'isFixed' => $is_fixed,
+13 -1
webroot/rsrc/js/application/diff/DiffChangesetList.js
··· 2110 2110 'click', 2111 2111 ['differential-inline-comment', 'differential-inline-reply'], 2112 2112 onreply); 2113 + 2114 + var ondraft = JX.bind(this, this._onInlineEvent, 'draft'); 2115 + JX.Stratcom.listen( 2116 + 'keydown', 2117 + ['differential-inline-comment', 'tag:textarea'], 2118 + ondraft); 2119 + 2113 2120 }, 2114 2121 2115 2122 _onInlineEvent: function(action, e) { ··· 2117 2124 return; 2118 2125 } 2119 2126 2120 - e.kill(); 2127 + if (action !== 'draft') { 2128 + e.kill(); 2129 + } 2121 2130 2122 2131 var inline = this._getInlineForEvent(e); 2123 2132 var is_ref = false; ··· 2171 2180 break; 2172 2181 case 'reply': 2173 2182 inline.reply(); 2183 + break; 2184 + case 'draft': 2185 + inline.triggerDraft(); 2174 2186 break; 2175 2187 } 2176 2188 }
+63 -1
webroot/rsrc/js/application/diff/DiffInline.js
··· 42 42 _undoType: null, 43 43 _undoText: null, 44 44 45 + _draftRequest: null, 46 + 45 47 bindToRow: function(row) { 46 48 this._row = row; 47 49 ··· 89 91 this._isEditing = data.isEditing; 90 92 91 93 if (this._isEditing) { 92 - this.edit(); 94 + // NOTE: The "original" shipped down in the DOM may reflect a draft 95 + // which we're currently editing. This flow is a little clumsy, but 96 + // reasonable until some future change moves away from "send down 97 + // the inline, then immediately click edit". 98 + this.edit(this._originalText); 93 99 } else { 94 100 this.setInvisible(false); 95 101 } 96 102 103 + this._startDrafts(); 104 + 97 105 return this; 98 106 }, 99 107 ··· 153 161 parent_row.parentNode.insertBefore(row, target_row); 154 162 155 163 this.setInvisible(true); 164 + this._startDrafts(); 156 165 157 166 return this; 158 167 }, ··· 213 222 parent_row.parentNode.insertBefore(row, target_row); 214 223 215 224 this.setInvisible(true); 225 + this._startDrafts(); 216 226 217 227 return this; 218 228 }, ··· 795 805 var changeset = this.getChangeset(); 796 806 var list = changeset.getChangesetList(); 797 807 return list.getInlineURI(); 808 + }, 809 + 810 + _startDrafts: function() { 811 + if (this._draftRequest) { 812 + return; 813 + } 814 + 815 + var onresponse = JX.bind(this, this._onDraftResponse); 816 + var draft = JX.bind(this, this._getDraftState); 817 + 818 + var uri = this._getInlineURI(); 819 + var request = new JX.PhabricatorShapedRequest(uri, onresponse, draft); 820 + 821 + // The main transaction code uses a 500ms delay on desktop and a 822 + // 10s delay on mobile. Perhaps this should be standardized. 823 + request.setRateLimit(2000); 824 + 825 + this._draftRequest = request; 826 + 827 + request.start(); 828 + }, 829 + 830 + _onDraftResponse: function() { 831 + // For now, do nothing. 832 + }, 833 + 834 + _getDraftState: function() { 835 + if (this.isDeleted()) { 836 + return null; 837 + } 838 + 839 + if (!this.isEditing()) { 840 + return null; 841 + } 842 + 843 + var text = this._readText(this._editRow); 844 + if (text === null) { 845 + return null; 846 + } 847 + 848 + return { 849 + op: 'draft', 850 + id: this.getID(), 851 + text: text 852 + }; 853 + }, 854 + 855 + triggerDraft: function() { 856 + if (this._draftRequest) { 857 + this._draftRequest.trigger(); 858 + } 798 859 } 860 + 799 861 } 800 862 801 863 });
+4
webroot/rsrc/js/core/ShapedRequest.js
··· 81 81 }, 82 82 83 83 shouldSendRequest : function(last, data) { 84 + if (data === null) { 85 + return false; 86 + } 87 + 84 88 if (last === null) { 85 89 return true; 86 90 }