blob: 68ab132ed58721eae2c0add4ae84bc5b61354d1f [file] [log] [blame]
Dave Borowitz8cdc76b2018-03-26 10:04:27 -04001/**
2 * @license
Ben Rohlfs94fcbbc2022-05-27 10:45:03 +02003 * Copyright 2015 Google LLC
4 * SPDX-License-Identifier: Apache-2.0
Dave Borowitz8cdc76b2018-03-26 10:04:27 -04005 */
Milutin Kristoficafae0052020-09-17 10:38:08 +02006import '../../../styles/shared-styles';
7import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
8import '../../plugins/gr-endpoint-param/gr-endpoint-param';
9import '../gr-button/gr-button';
10import '../gr-dialog/gr-dialog';
Milutin Kristoficafae0052020-09-17 10:38:08 +020011import '../gr-formatted-text/gr-formatted-text';
Chris Poucet1c713862022-07-25 13:12:24 +020012import '../gr-icon/gr-icon';
Ben Rohlfs62561172024-04-18 09:20:38 +020013import '../gr-suggestion-textarea/gr-suggestion-textarea';
Milutin Kristoficafae0052020-09-17 10:38:08 +020014import '../gr-tooltip-content/gr-tooltip-content';
15import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
16import '../gr-account-label/gr-account-label';
Milutin Kristofic490fa952023-09-12 20:17:36 +020017import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
Milutin Kristofic03ec98d2024-02-20 19:56:39 +010018import '../gr-fix-suggestions/gr-fix-suggestions';
Chris Poucetc6e880b2021-11-15 19:57:06 +010019import {getAppContext} from '../../../services/app-context';
Milutin Kristofic1d219672022-06-21 14:57:25 +020020import {css, html, LitElement, nothing, PropertyValues} from 'lit';
Frank Borden42c1a452022-08-11 16:27:20 +020021import {customElement, property, query, state} from 'lit/decorators.js';
Milutin Kristofice9dbbe92023-05-17 21:21:28 +020022import {provide, resolve} from '../../../models/dependency';
Ben Rohlfs62561172024-04-18 09:20:38 +020023import {GrSuggestionTextarea} from '../gr-suggestion-textarea/gr-suggestion-textarea';
Milutin Kristoficafae0052020-09-17 10:38:08 +020024import {
Milutin Kristoficafae0052020-09-17 10:38:08 +020025 AccountDetailInfo,
Dhruv Srivastava65edec82023-02-28 19:37:59 +010026 DraftInfo,
Ben Rohlfs4401b232021-10-21 13:51:59 +020027 NumericChangeId,
Dhruv Srivastava0287bf92020-09-11 16:56:38 +020028 RepoName,
Ben Rohlfs05750b92021-10-29 08:23:08 +020029 RobotCommentInfo,
Dhruv Srivastava4e27dc42023-03-01 10:49:49 +010030 Comment,
Dhruv Srivastava4e27dc42023-03-01 10:49:49 +010031 isRobot,
Ben Rohlfs1efb1a72023-04-12 23:25:31 +020032 isSaving,
33 isError,
Ben Rohlfs1efb1a72023-04-12 23:25:31 +020034 isDraft,
Ben Rohlfs610bb4f2023-04-17 12:34:35 +020035 isNew,
Chris Poucet77226982023-08-10 18:10:04 +020036 CommentInput,
Milutin Kristoficafae0052020-09-17 10:38:08 +020037} from '../../../types/common';
Milutin Kristoficafae0052020-09-17 10:38:08 +020038import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
Ben Rohlfs1d487062020-09-26 11:26:03 +020039import {
Chris Poucet77226982023-08-10 18:10:04 +020040 convertToCommentInput,
Ben Rohlfs23843882022-08-04 18:06:27 +020041 createUserFixSuggestion,
Milutin Kristofic1d219672022-06-21 14:57:25 +020042 getContentInCommentRange,
Ben Rohlfs23843882022-08-04 18:06:27 +020043 getUserSuggestion,
Milutin Kristofic1d219672022-06-21 14:57:25 +020044 hasUserSuggestion,
Ben Rohlfsba440822023-04-11 18:08:03 +020045 id,
Ben Rohlfs23843882022-08-04 18:06:27 +020046 NEWLINE_PATTERN,
Milutin Kristofic1d219672022-06-21 14:57:25 +020047 USER_SUGGESTION_START_PATTERN,
Ben Rohlfs31825d82020-10-02 18:08:04 +020048} from '../../../utils/comment-util';
Ben Rohlfs05750b92021-10-29 08:23:08 +020049import {
50 OpenFixPreviewEventDetail,
Ben Rohlfs23843882022-08-04 18:06:27 +020051 ReplyToCommentEventDetail,
Ben Rohlfs05750b92021-10-29 08:23:08 +020052 ValueChangedEvent,
53} from '../../../types/events';
Ben Rohlfs44f01042023-02-18 13:27:57 +010054import {fire} from '../../../utils/event-util';
Milutin Kristofic96ac52e2023-09-18 10:28:58 +020055import {assertIsDefined, assert, uuid} from '../../../utils/common-util';
Dhruv Srivastavaa49dffd2022-10-20 14:04:37 +020056import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
Chris Poucetdae98bf2022-01-05 15:23:45 +010057import {commentsModelToken} from '../../../models/comments/comments-model';
Ben Rohlfs05750b92021-10-29 08:23:08 +020058import {sharedStyles} from '../../../styles/shared-styles';
59import {subscribe} from '../../lit/subscription-controller';
60import {ShortcutController} from '../../lit/shortcut-controller';
Frank Borden42c1a452022-08-11 16:27:20 +020061import {classMap} from 'lit/directives/class-map.js';
Ben Rohlfsb9956102023-05-12 17:07:06 +020062import {FILE, LineNumber} from '../../../api/diff';
Milutin Kristofic1d219672022-06-21 14:57:25 +020063import {CommentSide, SpecialFilePath} from '../../../constants/constants';
Ben Rohlfs2e237552021-11-24 10:34:28 +010064import {Subject} from 'rxjs';
65import {debounceTime} from 'rxjs/operators';
Chris Poucetbf65b8f2022-01-18 21:18:12 +000066import {changeModelToken} from '../../../models/change/change-model';
Milutin Kristofic1cfc0212024-02-01 20:16:06 +010067import {
68 ChangeInfo,
Milutin Kristoficb0bc74a2024-02-13 09:55:52 +010069 FixSuggestionInfo,
Milutin Kristofic1cfc0212024-02-01 20:16:06 +010070 isBase64FileContent,
71} from '../../../api/rest-api';
Ben Rohlfsb91a6a42023-01-13 09:29:31 +010072import {createDiffUrl} from '../../../models/views/change';
Chris Poucetbb0cf832022-10-24 12:32:10 +020073import {userModelToken} from '../../../models/user/user-model';
Dhruv Srivastava4063d262022-11-09 18:46:29 +053074import {modalStyles} from '../../../styles/gr-modal-styles';
Milutin Kristofic734df552023-08-07 10:38:50 +020075import {KnownExperimentId} from '../../../services/flags/flags';
76import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
Milutin Kristofice9dbbe92023-05-17 21:21:28 +020077import {
78 CommentModel,
79 commentModelToken,
80} from '../gr-comment-model/gr-comment-model';
Milutin Kristoficaa1c08b2023-09-06 10:34:16 +020081import {formStyles} from '../../../styles/form-styles';
Ben Rohlfsdd88cd92024-05-08 13:29:39 +020082import {Interaction, Timing} from '../../../constants/reporting';
83import {
84 AutocompleteCommentResponse,
85 Suggestion,
86 SuggestionsProvider,
87} from '../../../api/suggestions';
Milutin Kristofic74caea4b2023-11-14 20:10:56 +010088import {when} from 'lit/directives/when.js';
Milutin Kristofic5942c9d2023-11-15 20:34:42 +010089import {getDocUrl} from '../../../utils/url-util';
90import {configModelToken} from '../../../models/config/config-model';
Milutin Kristofica31d8942023-11-17 13:32:16 +010091import {getFileExtension} from '../../../utils/file-util';
Milutin Kristofic4d963732024-01-04 12:10:03 +010092import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
Milutin Kristoficd9a9c832024-02-20 19:03:30 +010093import {deepEqual} from '../../../utils/deep-util';
Milutin Kristofic847c46d2024-04-15 13:43:06 +020094import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
95import {waitUntil} from '../../../utils/async-util';
Ben Rohlfsdd88cd92024-05-08 13:29:39 +020096import {
97 AutocompleteCache,
98 AutocompletionContext,
99} from '../../../utils/autocomplete-cache';
100import {HintAppliedEventDetail, HintShownEventDetail} from '../../../api/embed';
101import {levenshteinDistance} from '../../../utils/string-util';
Wyatt Allen846ac2f2018-05-14 12:59:23 -0700102
Ben Rohlfs2e237552021-11-24 10:34:28 +0100103// visible for testing
104export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
Milutin Kristofic61bec782024-04-15 11:40:24 +0000105export const GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS = 500;
Ben Rohlfs2ad97562024-04-26 18:27:16 +0200106export const AUTOCOMPLETE_DEBOUNCE_DELAY_MS = 200;
Milutin Kristofic4d963732024-01-04 12:10:03 +0100107export const ENABLE_GENERATE_SUGGESTION_STORAGE_KEY =
Milutin Kristofic409e4a02024-03-12 13:32:36 +0100108 'enableGenerateSuggestionStorageKeyForCommentWithId-';
Ben Rohlfs2e237552021-11-24 10:34:28 +0100109
Ben Rohlfs05750b92021-10-29 08:23:08 +0200110declare global {
111 interface HTMLElementEventMap {
Dhruv Srivastavaee018e92022-08-31 11:37:46 +0200112 'comment-editing-changed': CustomEvent<CommentEditingChangedDetail>;
Dhruv Srivastava463bb332022-08-31 13:00:49 +0200113 'comment-unresolved-changed': ValueChangedEvent<boolean>;
114 'comment-text-changed': ValueChangedEvent<string>;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200115 'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
Milutin Kristofica0d350b2024-01-10 19:42:33 +0100116 'apply-user-suggestion': CustomEvent;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200117 }
Milutin Kristoficafae0052020-09-17 10:38:08 +0200118}
119
Ben Rohlfs05750b92021-10-29 08:23:08 +0200120export interface CommentAnchorTapEventDetail {
121 number: LineNumber;
122 side?: CommentSide;
Milutin Kristoficafae0052020-09-17 10:38:08 +0200123}
Dmitrii Filippov3f3c2052020-09-22 16:51:18 +0200124
Dhruv Srivastavaee018e92022-08-31 11:37:46 +0200125export interface CommentEditingChangedDetail {
126 editing: boolean;
127 path: string;
128}
129
Milutin Kristoficafae0052020-09-17 10:38:08 +0200130@customElement('gr-comment')
Ben Rohlfs05750b92021-10-29 08:23:08 +0200131export class GrComment extends LitElement {
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100132 /**
Ben Rohlfs23843882022-08-04 18:06:27 +0200133 * Fired when the parent thread component should create a reply.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100134 *
Ben Rohlfs23843882022-08-04 18:06:27 +0200135 * @event reply-to-comment
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100136 */
Kasper Nilssond43d2a72018-10-19 14:26:41 -0700137
Dmitrii Filippov3fd2b102019-11-15 16:16:46 +0100138 /**
Ben Rohlfs23843882022-08-04 18:06:27 +0200139 * Fired when the open fix preview action is triggered.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100140 *
141 * @event open-fix-preview
Tao Zhou500437d2020-02-14 16:57:27 +0100142 */
Tao Zhou500437d2020-02-14 16:57:27 +0100143
144 /**
Tao Zhou31f3f102020-04-27 16:15:29 +0200145 * Fired when editing status changed.
146 *
147 * @event comment-editing-changed
148 */
149
150 /**
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100151 * Fired when the comment's timestamp is tapped.
152 *
153 * @event comment-anchor-tap
154 */
Andrew Bonventre28165262016-05-19 17:24:45 -0700155
Ben Rohlfs05750b92021-10-29 08:23:08 +0200156 @query('#editTextarea')
Ben Rohlfs62561172024-04-18 09:20:38 +0200157 textarea?: GrSuggestionTextarea;
Viktar Donich7ad28922016-05-23 15:24:05 -0700158
Ben Rohlfs05750b92021-10-29 08:23:08 +0200159 @query('#container')
160 container?: HTMLElement;
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200161
Ben Rohlfs05750b92021-10-29 08:23:08 +0200162 @query('#resolvedCheckbox')
163 resolvedCheckbox?: HTMLInputElement;
Kasper Nilssond43d2a72018-10-19 14:26:41 -0700164
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530165 @query('#confirmDeleteModal')
166 confirmDeleteModal?: HTMLDialogElement;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200167
Kamil Musinc7d3f282022-12-29 13:27:55 +0100168 @query('#confirmDeleteCommentDialog')
169 confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
170
Milutin Kristofic847c46d2024-04-15 13:43:06 +0200171 @query('#suggestionDiffPreview')
172 suggestionDiffPreview?: GrSuggestionDiffPreview;
173
Ben Rohlfs05750b92021-10-29 08:23:08 +0200174 @property({type: Object})
175 comment?: Comment;
176
177 // TODO: Move this out of gr-comment. gr-comment should not have a comments
178 // property. This is only used for hasHumanReply at the moment.
Milutin Kristoficafae0052020-09-17 10:38:08 +0200179 @property({type: Array})
Ben Rohlfs05750b92021-10-29 08:23:08 +0200180 comments?: Comment[];
Milutin Kristoficafae0052020-09-17 10:38:08 +0200181
182 /**
Ben Rohlfs05750b92021-10-29 08:23:08 +0200183 * Initial collapsed state of the comment.
Milutin Kristoficafae0052020-09-17 10:38:08 +0200184 */
Ben Rohlfs05750b92021-10-29 08:23:08 +0200185 @property({type: Boolean, attribute: 'initially-collapsed'})
186 initiallyCollapsed?: boolean;
187
188 /**
Dhruv Srivastavae75f6f72022-08-25 10:14:37 +0200189 * Hide the header for patchset level comments used in GrReplyDialog.
190 */
191 @property({type: Boolean, attribute: 'hide-header'})
192 hideHeader = false;
193
194 /**
Ben Rohlfs05750b92021-10-29 08:23:08 +0200195 * This is the *current* (internal) collapsed state of the comment. Do not set
196 * from the outside. Use `initiallyCollapsed` instead. This is just a
197 * reflected property such that css rules can be based on it.
198 */
199 @property({type: Boolean, reflect: true})
200 collapsed?: boolean;
201
202 @property({type: Boolean, attribute: 'robot-button-disabled'})
203 robotButtonDisabled = false;
204
Dhruv Srivastavaf007abc2022-09-06 11:18:57 +0200205 @property({type: String})
206 messagePlaceholder?: string;
207
Dhruv Srivastava4e5fd112022-08-25 12:01:22 +0200208 // GrReplyDialog requires the patchset level comment to always remain
209 // editable.
210 @property({type: Boolean, attribute: 'permanent-editing-mode'})
211 permanentEditingMode = false;
212
Chris Poucet77226982023-08-10 18:10:04 +0200213 // Whether to disable autosaving
214 @property({type: Boolean})
215 disableAutoSaving = false;
216
Ben Rohlfs2e237552021-11-24 10:34:28 +0100217 @state()
Ben Rohlfs607126f2021-12-07 08:21:52 +0100218 autoSaving?: Promise<DraftInfo>;
Ben Rohlfs2e237552021-11-24 10:34:28 +0100219
Ben Rohlfs05750b92021-10-29 08:23:08 +0200220 @state()
221 changeNum?: NumericChangeId;
222
223 @state()
224 editing = false;
225
226 @state()
Ben Rohlfs05750b92021-10-29 08:23:08 +0200227 repoName?: RepoName;
228
229 /* The 'dirty' state of the comment.message, which will be saved on demand. */
230 @state()
231 messageText = '';
232
Ben Rohlfs2ad97562024-04-26 18:27:16 +0200233 /**
234 * An hint for autocompleting the comment message from plugin suggestion
235 * providers.
236 */
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200237 @state() autocompleteHint?: AutocompletionContext;
238
239 private autocompleteAcceptedHints: string[] = [];
Ben Rohlfs2ad97562024-04-26 18:27:16 +0200240
Ben Rohlfsb3873692024-05-15 13:40:33 +0200241 /** Based on user preferences. */
242 @state() autocompleteEnabled = true;
243
Ben Rohlfs4deb8df52024-05-10 12:53:19 +0200244 readonly autocompleteCache = new AutocompleteCache();
245
Ben Rohlfs05750b92021-10-29 08:23:08 +0200246 /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
247 @state()
248 unresolved = true;
Milutin Kristoficafae0052020-09-17 10:38:08 +0200249
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +0200250 @state()
251 generateSuggestion = true;
252
Milutin Kristofic96ac52e2023-09-18 10:28:58 +0200253 @state()
Milutin Kristofic8f07c382023-10-02 13:13:21 +0200254 generatedSuggestion?: Suggestion;
Milutin Kristofic96ac52e2023-09-18 10:28:58 +0200255
256 @state()
Milutin Kristoficd9a9c832024-02-20 19:03:30 +0100257 generatedFixSuggestion: FixSuggestionInfo | undefined =
258 this.comment?.fix_suggestions?.[0];
Milutin Kristofic1cfc0212024-02-01 20:16:06 +0100259
260 @state()
Milutin Kristofic06dfda42023-11-15 09:51:13 +0100261 generatedSuggestionId?: string;
Milutin Kristofic96ac52e2023-09-18 10:28:58 +0200262
Milutin Kristofic74caea4b2023-11-14 20:10:56 +0100263 @state()
Milutin Kristoficc0582fc2024-04-02 20:30:51 +0200264 addedGeneratedSuggestion?: string;
265
266 @state()
Milutin Kristofica31d8942023-11-17 13:32:16 +0100267 suggestionsProvider?: SuggestionsProvider;
268
269 @state()
Milutin Kristofic74caea4b2023-11-14 20:10:56 +0100270 suggestionLoading = false;
271
Ben Rohlfs05750b92021-10-29 08:23:08 +0200272 @property({type: Boolean, attribute: 'show-patchset'})
273 showPatchset = false;
Tao Zhou500437d2020-02-14 16:57:27 +0100274
Ben Rohlfs05750b92021-10-29 08:23:08 +0200275 @property({type: Boolean, attribute: 'show-ported-comment'})
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200276 showPortedComment = false;
277
Ben Rohlfs05750b92021-10-29 08:23:08 +0200278 @state()
279 account?: AccountDetailInfo;
280
281 @state()
282 isAdmin = false;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100283
Milutin Kristofic15bfb3e2022-08-16 20:01:33 +0200284 @state()
285 isOwner = false;
286
Milutin Kristofic78cdec92023-08-29 14:37:31 +0200287 @state()
288 commentedText?: string;
289
Milutin Kristofic5942c9d2023-11-15 20:34:42 +0100290 @state() private docsBaseUrl = '';
291
Chris Poucetc6e880b2021-11-15 19:57:06 +0100292 private readonly restApiService = getAppContext().restApiService;
Ben Rohlfs43935a42020-12-01 19:14:09 +0100293
Chris Poucetc6e880b2021-11-15 19:57:06 +0100294 private readonly reporting = getAppContext().reportingService;
Milutin Kristoficafae0052020-09-17 10:38:08 +0200295
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000296 private readonly getChangeModel = resolve(this, changeModelToken);
Chris Poucet01422482021-11-30 19:43:28 +0100297
Chris Poucetbb0cf832022-10-24 12:32:10 +0200298 private readonly getCommentsModel = resolve(this, commentsModelToken);
Dhruv Srivastavadb2ab602021-06-24 15:20:29 +0200299
Chris Poucetbb0cf832022-10-24 12:32:10 +0200300 private readonly getUserModel = resolve(this, userModelToken);
Ben Rohlfsf92d3b52021-03-10 23:13:03 +0100301
Milutin Kristofic734df552023-08-07 10:38:50 +0200302 private readonly getPluginLoader = resolve(this, pluginLoaderToken);
303
Milutin Kristofic5942c9d2023-11-15 20:34:42 +0100304 private readonly getConfigModel = resolve(this, configModelToken);
305
Milutin Kristofic4d963732024-01-04 12:10:03 +0100306 private readonly getStorage = resolve(this, storageServiceToken);
307
Milutin Kristofic734df552023-08-07 10:38:50 +0200308 private readonly flagsService = getAppContext().flagsService;
309
Ben Rohlfs05750b92021-10-29 08:23:08 +0200310 private readonly shortcuts = new ShortcutController(this);
Ben Rohlfsf92d3b52021-03-10 23:13:03 +0100311
Milutin Kristofic78cdec92023-08-29 14:37:31 +0200312 private commentModel = new CommentModel(this.restApiService);
Milutin Kristofice9dbbe92023-05-17 21:21:28 +0200313
Ben Rohlfs2e237552021-11-24 10:34:28 +0100314 /**
315 * This is triggered when the user types into the editing textarea. We then
316 * debounce it and call autoSave().
317 */
Frank Borden3801d7d2023-03-27 09:00:58 +0000318 private autoSaveTrigger$ = new Subject();
Ben Rohlfs2e237552021-11-24 10:34:28 +0100319
320 /**
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +0200321 * This is triggered when the user types into the editing textarea. We then
322 * debounce it and call generateSuggestEdit().
323 */
324 private generateSuggestionTrigger$ = new Subject();
325
326 /**
Ben Rohlfs2ad97562024-04-26 18:27:16 +0200327 * This is triggered when the user types into the editing textarea. We then
328 * debounce it and call autocompleteComment().
329 */
330 private autocompleteTrigger$ = new Subject();
331
332 /**
Ben Rohlfs2e237552021-11-24 10:34:28 +0100333 * Set to the content of DraftInfo when entering editing mode.
334 * Only used for "Cancel".
335 */
336 private originalMessage = '';
337
338 /**
339 * Set to the content of DraftInfo when entering editing mode.
340 * Only used for "Cancel".
341 */
342 private originalUnresolved = false;
343
Ben Rohlfs05750b92021-10-29 08:23:08 +0200344 constructor() {
345 super();
Milutin Kristofice9dbbe92023-05-17 21:21:28 +0200346 provide(this, commentModelToken, () => this.commentModel);
Dhruv Srivastavae110a372022-09-08 12:18:33 +0200347 // Allow the shortcuts to bubble up so that GrReplyDialog can respond to
348 // them as well.
349 this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc(), {
350 preventDefault: false,
351 });
Dhruv Srivastavaf43eee72022-09-14 11:03:01 +0200352 for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
353 this.shortcuts.addLocal(
354 {key: Key.ENTER, modifiers: [modifier]},
Milutin Kristoficed07cf12024-03-14 18:02:16 +0100355 e => {
Dhruv Srivastavaf43eee72022-09-14 11:03:01 +0200356 this.save();
Milutin Kristoficed07cf12024-03-14 18:02:16 +0100357 // We don't stop propagation for patchset comment
358 // (this.permanentEditingMode = true), but we stop it for normal
359 // comments. This prevents accidentally sending a reply when
360 // editing/saving them in the reply dialog.
361 if (!this.permanentEditingMode) {
362 e.preventDefault();
363 e.stopPropagation();
364 }
Dhruv Srivastavaf43eee72022-09-14 11:03:01 +0200365 },
366 {preventDefault: false}
367 );
368 }
369 // For Ctrl+s add shorctut with preventDefault so that it does
370 // not bubble up to the browser
371 for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
372 this.shortcuts.addLocal({key: 's', modifiers: [modifier]}, () => {
373 this.save();
374 });
Ben Rohlfsaadbdd12021-10-19 11:49:01 +0200375 }
Milutin Kristofica0d350b2024-01-10 19:42:33 +0100376 this.addEventListener('apply-user-suggestion', () => {
377 this.handleAppliedFix();
378 });
Milutin Kristofic1ebae372022-11-22 20:35:38 +0100379 this.addEventListener('open-user-suggest-preview', e => {
380 this.handleShowFix(e.detail.code);
381 });
Milutin Kristofic490fa952023-09-12 20:17:36 +0200382 this.addEventListener('add-generated-suggestion', e => {
383 this.handleAddGeneratedSuggestion(e.detail.code);
384 });
Ben Rohlfsb7082e12023-01-23 11:43:48 +0100385 this.messagePlaceholder = 'Mention others with @';
Chris Poucet0b961412022-01-05 16:24:50 +0100386 subscribe(
387 this,
Chris Poucetbb0cf832022-10-24 12:32:10 +0200388 () => this.getUserModel().account$,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200389 x => (this.account = x)
390 );
391 subscribe(
392 this,
Chris Poucetbb0cf832022-10-24 12:32:10 +0200393 () => this.getUserModel().isAdmin$,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200394 x => (this.isAdmin = x)
395 );
396
397 subscribe(
398 this,
399 () => this.getChangeModel().repo$,
400 x => (this.repoName = x)
401 );
402 subscribe(
403 this,
404 () => this.getChangeModel().changeNum$,
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000405 x => (this.changeNum = x)
406 );
407 subscribe(
408 this,
Milutin Kristofic15bfb3e2022-08-16 20:01:33 +0200409 () => this.getChangeModel().isOwner$,
410 x => (this.isOwner = x)
411 );
412 subscribe(
413 this,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200414 () =>
415 this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000416 () => {
417 this.autoSave();
418 }
419 );
Milutin Kristofic5942c9d2023-11-15 20:34:42 +0100420 subscribe(
421 this,
422 () => this.getConfigModel().docsBaseUrl$,
423 docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
424 );
Ben Rohlfs2ad97562024-04-26 18:27:16 +0200425 subscribe(
426 this,
427 () => this.getPluginLoader().pluginsModel.suggestionsPlugins$,
428 // We currently support results from only 1 provider.
429 suggestionsPlugins =>
430 (this.suggestionsProvider = suggestionsPlugins?.[0]?.provider)
431 );
432 subscribe(
433 this,
434 () =>
435 this.autocompleteTrigger$.pipe(
436 debounceTime(AUTOCOMPLETE_DEBOUNCE_DELAY_MS)
437 ),
438 () => {
439 this.autocompleteComment();
440 }
441 );
Milutin Kristofic1cfc0212024-02-01 20:16:06 +0100442 if (
443 this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
444 this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
445 ) {
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +0200446 subscribe(
447 this,
448 () =>
449 this.generateSuggestionTrigger$.pipe(
450 debounceTime(GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS)
451 ),
452 () => {
Milutin Kristofic4d963732024-01-04 12:10:03 +0100453 this.generateSuggestEdit();
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +0200454 }
455 );
Milutin Kristoficba95beb2024-01-12 10:20:25 +0100456 subscribe(
457 this,
458 () => this.getUserModel().preferences$,
459 prefs => {
Ben Rohlfsb3873692024-05-15 13:40:33 +0200460 this.autocompleteEnabled = !!prefs.allow_autocompleting_comments;
Milutin Kristoficba95beb2024-01-12 10:20:25 +0100461 if (
462 this.generateSuggestion !==
463 !!prefs.allow_suggest_code_while_commenting
464 ) {
465 this.generateSuggestion =
466 !!prefs.allow_suggest_code_while_commenting;
467 }
468 }
469 );
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +0200470 }
Chris Poucet0b961412022-01-05 16:24:50 +0100471 }
472
Milutin Kristofica31d8942023-11-17 13:32:16 +0100473 override connectedCallback() {
474 super.connectedCallback();
Milutin Kristofic409e4a02024-03-12 13:32:36 +0100475 if (this.comment?.id) {
476 const generateSuggestionStoredContent =
477 this.getStorage().getEditableContentItem(
478 ENABLE_GENERATE_SUGGESTION_STORAGE_KEY + this.comment.id
479 );
480 if (generateSuggestionStoredContent?.message === 'false') {
481 this.generateSuggestion = false;
482 }
Milutin Kristofic4d963732024-01-04 12:10:03 +0100483 }
Milutin Kristofica31d8942023-11-17 13:32:16 +0100484 }
485
Gerrit Code Review86b969c2021-08-19 14:33:41 +0000486 override disconnectedCallback() {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200487 // Clean up emoji dropdown.
488 if (this.textarea) this.textarea.closeDropdown();
Ben Rohlfs5f520da2021-03-10 14:58:43 +0100489 super.disconnectedCallback();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100490 }
Andrew Bonventre78792e82016-03-04 17:48:22 -0500491
Ben Rohlfs05750b92021-10-29 08:23:08 +0200492 static override get styles() {
493 return [
Milutin Kristoficaa1c08b2023-09-06 10:34:16 +0200494 formStyles,
Ben Rohlfs05750b92021-10-29 08:23:08 +0200495 sharedStyles,
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530496 modalStyles,
Ben Rohlfs05750b92021-10-29 08:23:08 +0200497 css`
498 :host {
499 display: block;
500 font-family: var(--font-family);
501 padding: var(--spacing-m);
502 }
503 :host([collapsed]) {
504 padding: var(--spacing-s) var(--spacing-m);
505 }
Ben Rohlfs1efb1a72023-04-12 23:25:31 +0200506 :host([error]) {
507 background-color: var(--error-background);
508 border-radius: var(--border-radius);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200509 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200510 .header {
511 align-items: center;
512 cursor: pointer;
513 display: flex;
Dhruv Srivastavad8f61e72022-09-16 07:34:34 +0000514 padding-bottom: var(--spacing-m);
515 }
516 :host([collapsed]) .header {
517 padding-bottom: 0px;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200518 }
519 .headerLeft > span {
520 font-weight: var(--font-weight-bold);
521 }
522 .headerMiddle {
523 color: var(--deemphasized-text-color);
524 flex: 1;
525 overflow: hidden;
526 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200527 .draftTooltip {
Ben Rohlfsba361a42022-09-01 12:12:45 +0200528 font-weight: var(--font-weight-bold);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200529 display: inline;
530 }
Ben Rohlfsba361a42022-09-01 12:12:45 +0200531 .draftTooltip gr-icon {
532 color: var(--info-foreground);
533 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200534 .date {
535 justify-content: flex-end;
536 text-align: right;
537 white-space: nowrap;
538 }
539 span.date {
540 color: var(--deemphasized-text-color);
541 }
542 span.date:hover {
543 text-decoration: underline;
544 }
545 .actions,
546 .robotActions {
547 display: flex;
Milutin Kristoficb7929e72023-09-05 13:13:07 +0200548 justify-content: space-between;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200549 padding-top: 0;
550 }
551 .robotActions {
552 /* Better than the negative margin would be to remove the gr-button
553 * padding, but then we would also need to fix the buttons that are
554 * inserted by plugins. :-/ */
555 margin: 4px 0 -4px;
556 }
557 .action {
558 margin-left: var(--spacing-l);
559 }
Milutin Kristoficb7929e72023-09-05 13:13:07 +0200560 .leftActions,
Ben Rohlfs05750b92021-10-29 08:23:08 +0200561 .rightActions {
562 display: flex;
563 justify-content: flex-end;
564 }
Milutin Kristoficb7929e72023-09-05 13:13:07 +0200565 .leftActions gr-button,
Ben Rohlfs05750b92021-10-29 08:23:08 +0200566 .rightActions gr-button {
567 --gr-button-padding: 0 var(--spacing-s);
568 }
569 .editMessage {
570 display: block;
Dhruv Srivastava694e9372022-09-13 10:29:08 +0200571 margin-bottom: var(--spacing-m);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200572 width: 100%;
573 }
574 .show-hide {
575 margin-left: var(--spacing-s);
576 }
577 .robotId {
578 color: var(--deemphasized-text-color);
579 margin-bottom: var(--spacing-m);
580 }
581 .robotRun {
582 margin-left: var(--spacing-m);
583 }
584 .robotRunLink {
585 margin-left: var(--spacing-m);
586 }
587 /* just for a11y */
588 input.show-hide {
589 display: none;
590 }
591 label.show-hide {
592 cursor: pointer;
593 display: block;
594 }
Chris Poucet1c713862022-07-25 13:12:24 +0200595 label.show-hide gr-icon {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200596 vertical-align: top;
597 }
598 :host([collapsed]) #container .body {
599 padding-top: 0;
600 }
601 #container .collapsedContent {
602 display: block;
603 overflow: hidden;
604 padding-left: var(--spacing-m);
605 text-overflow: ellipsis;
606 white-space: nowrap;
607 }
608 .resolve,
609 .unresolved {
610 align-items: center;
611 display: flex;
612 flex: 1;
613 margin: 0;
614 }
615 .resolve label {
616 color: var(--comment-text-color);
617 }
618 gr-dialog .main {
619 display: flex;
620 flex-direction: column;
621 width: 100%;
622 }
623 #deleteBtn {
624 --gr-button-text-color: var(--deemphasized-text-color);
625 --gr-button-padding: 0;
626 }
627
628 /** Disable select for the caret and actions */
629 .actions,
630 .show-hide {
631 -webkit-user-select: none;
632 -moz-user-select: none;
633 -ms-user-select: none;
634 user-select: none;
635 }
636
Ben Rohlfs05750b92021-10-29 08:23:08 +0200637 .pointer {
638 cursor: pointer;
639 }
640 .patchset-text {
641 color: var(--deemphasized-text-color);
642 margin-left: var(--spacing-s);
643 }
644 .headerLeft gr-account-label {
645 --account-max-length: 130px;
646 width: 150px;
647 }
648 .headerLeft gr-account-label::part(gr-account-label-text) {
649 font-weight: var(--font-weight-bold);
650 }
651 .draft gr-account-label {
652 width: unset;
653 }
Frank Borden0c078842022-09-19 15:47:26 +0200654 .draft gr-formatted-text.message {
Frank Borden3b3a4c92022-09-28 14:14:00 +0200655 display: block;
Frank Borden0c078842022-09-19 15:47:26 +0200656 margin-bottom: var(--spacing-m);
657 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200658 .portedMessage {
659 margin: 0 var(--spacing-m);
660 }
661 .link-icon {
Chris Poucetc4142042022-06-28 17:51:50 +0200662 margin-left: var(--spacing-m);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200663 cursor: pointer;
664 }
Milutin Kristofic80c3c7e2023-03-16 20:22:28 +0100665 .suggestEdit {
666 /** same height as header */
667 --margin: calc(0px - var(--spacing-s));
668 margin-right: var(--spacing-s);
669 }
670 .suggestEdit gr-icon {
671 color: inherit;
672 margin-right: var(--spacing-s);
673 }
Milutin Kristofic8f07c382023-10-02 13:13:21 +0200674 .info {
675 background-color: var(--info-background);
676 padding: var(--spacing-l) var(--spacing-xl);
677 }
678 .info gr-icon {
679 color: var(--selected-foreground);
680 margin-right: var(--spacing-xl);
681 }
Milutin Kristofic74caea4b2023-11-14 20:10:56 +0100682 /* The basics of .loadingSpin are defined in shared styles. */
683 .loadingSpin {
684 width: calc(var(--line-height-normal) - 2px);
685 height: calc(var(--line-height-normal) - 2px);
686 display: inline-block;
687 vertical-align: top;
688 position: relative;
689 /* Making up for the 2px reduced height above. */
690 top: 1px;
691 }
Milutin Kristofic647c8a22024-05-20 14:37:57 +0200692 gr-suggestion-diff-preview,
693 gr-fix-suggestions {
694 margin-top: var(--spacing-s);
695 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200696 `,
697 ];
Dhruv Srivastavacf70e792020-07-24 15:35:39 +0200698 }
699
Ben Rohlfs05750b92021-10-29 08:23:08 +0200700 override render() {
Ben Rohlfs1efb1a72023-04-12 23:25:31 +0200701 if (!this.comment) return;
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200702 this.toggleAttribute('saving', isSaving(this.comment));
703 this.toggleAttribute('error', isError(this.comment));
Ben Rohlfs1efb1a72023-04-12 23:25:31 +0200704 const classes = {
705 container: true,
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200706 draft: isDraft(this.comment),
Ben Rohlfs1efb1a72023-04-12 23:25:31 +0200707 };
Ben Rohlfs05750b92021-10-29 08:23:08 +0200708 return html`
Ben Rohlfs7a167842022-09-29 21:55:50 +0200709 <gr-endpoint-decorator name="comment">
710 <gr-endpoint-param name="comment" .value=${this.comment}>
711 </gr-endpoint-param>
712 <gr-endpoint-param name="editing" .value=${this.editing}>
713 </gr-endpoint-param>
Ben Rohlfs57c2c592022-10-25 12:49:11 +0200714 <gr-endpoint-param name="message" .value=${this.messageText}>
715 </gr-endpoint-param>
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200716 <gr-endpoint-param name="isDraft" .value=${isDraft(this.comment)}>
Ben Rohlfs57c2c592022-10-25 12:49:11 +0200717 </gr-endpoint-param>
Ben Rohlfs7a167842022-09-29 21:55:50 +0200718 <div id="container" class=${classMap(classes)}>
719 ${this.renderHeader()}
720 <div class="body">
721 ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
722 ${this.renderCommentMessage()}
723 <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
724 ${this.renderHumanActions()} ${this.renderRobotActions()}
Ben Rohlfs7a167842022-09-29 21:55:50 +0200725 </div>
Milutin Kristofic03ec98d2024-02-20 19:56:39 +0100726 ${/* if this.editing */ this.renderGeneratedSuggestionPreview()}
727 ${/* if !this.editing */ this.renderFixSuggestionPreview()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200728 </div>
Ben Rohlfs7a167842022-09-29 21:55:50 +0200729 </gr-endpoint-decorator>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200730 ${this.renderConfirmDialog()}
731 `;
732 }
733
Dhruv Srivastavae75f6f72022-08-25 10:14:37 +0200734 private renderHeader() {
735 if (this.hideHeader) return nothing;
736 return html`
737 <div
738 class="header"
739 id="header"
740 @click=${() => (this.collapsed = !this.collapsed)}
741 >
742 <div class="headerLeft">
743 ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
744 ${this.renderDraftLabel()}
745 </div>
746 <div class="headerMiddle">${this.renderCollapsedContent()}</div>
Milutin Kristofic8238de52023-01-12 19:33:45 +0100747 ${this.renderSuggestEditButton()} ${this.renderRunDetails()}
748 ${this.renderDeleteButton()} ${this.renderPatchset()}
Ben Rohlfs0ed33592023-04-12 11:33:27 +0200749 ${this.renderSeparator()} ${this.renderDate()} ${this.renderToggle()}
Dhruv Srivastavae75f6f72022-08-25 10:14:37 +0200750 </div>
751 `;
752 }
753
Ben Rohlfs05750b92021-10-29 08:23:08 +0200754 private renderAuthor() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200755 if (isDraft(this.comment)) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200756 if (isRobot(this.comment)) {
757 const id = this.comment.robot_id;
758 return html`<span class="robotName">${id}</span>`;
759 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200760 return html`
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200761 <gr-account-label .account=${this.comment?.author ?? this.account}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200762 </gr-account-label>
763 `;
764 }
765
766 private renderPortedCommentMessage() {
767 if (!this.showPortedComment) return;
768 if (!this.comment?.patch_set) return;
769 return html`
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200770 <a href=${this.getUrlForComment()}>
771 <span class="portedMessage" @click=${this.handlePortedMessageClick}>
Ben Rohlfs95796222021-12-01 16:39:42 +0100772 From patchset ${this.comment?.patch_set}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200773 </span>
774 </a>
775 `;
776 }
777
778 private renderDraftLabel() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200779 if (!isDraft(this.comment)) return;
Ben Rohlfsba361a42022-09-01 12:12:45 +0200780 let label = 'Draft';
Ben Rohlfs05750b92021-10-29 08:23:08 +0200781 let tooltip =
782 'This draft is only visible to you. ' +
783 "To publish drafts, click the 'Reply' or 'Start review' button " +
784 "at the top of the change or press the 'a' key.";
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200785 if (isError(this.comment)) {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200786 label += ' (Failed to save)';
787 tooltip = 'Unable to save draft. Please try to save again.';
788 }
789 return html`
790 <gr-tooltip-content
791 class="draftTooltip"
792 has-tooltip
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200793 title=${tooltip}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200794 max-width="20em"
Ben Rohlfs05750b92021-10-29 08:23:08 +0200795 >
Ben Rohlfsba361a42022-09-01 12:12:45 +0200796 <gr-icon filled icon="rate_review"></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200797 <span class="draftLabel">${label}</span>
798 </gr-tooltip-content>
799 `;
800 }
801
802 private renderCollapsedContent() {
803 if (!this.collapsed) return;
804 return html`
805 <span class="collapsedContent">${this.comment?.message}</span>
806 `;
807 }
808
809 private renderRunDetails() {
810 if (!isRobot(this.comment)) return;
811 if (!this.comment?.url || this.collapsed) return;
812 return html`
813 <div class="runIdMessage message">
814 <div class="runIdInformation">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200815 <a class="robotRunLink" href=${this.comment.url}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200816 <span class="robotRun link">Run Details</span>
817 </a>
818 </div>
819 </div>
820 `;
821 }
822
823 /**
824 * Deleting a comment is an admin feature. It means more than just discarding
825 * a draft. It is an action applied to published comments.
826 */
827 private renderDeleteButton() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200828 if (!this.isAdmin || isDraft(this.comment) || isRobot(this.comment)) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200829 if (this.collapsed) return;
830 return html`
831 <gr-button
832 id="deleteBtn"
833 title="Delete Comment"
834 link
835 class="action delete"
Kamil Musin462428b2022-12-29 11:12:08 +0100836 @click=${(e: MouseEvent) => {
837 e.stopPropagation();
838 this.openDeleteCommentModal();
839 }}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200840 >
Chris Poucet1c713862022-07-25 13:12:24 +0200841 <gr-icon id="icon" icon="delete" filled></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200842 </gr-button>
843 `;
844 }
845
846 private renderPatchset() {
847 if (!this.showPatchset) return;
848 assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
849 return html`
850 <span class="patchset-text"> Patchset ${this.comment.patch_set}</span>
851 `;
852 }
853
Ben Rohlfs0ed33592023-04-12 11:33:27 +0200854 private renderSeparator() {
855 // This should match the condition of `renderPatchset()`.
856 if (!this.showPatchset) return;
857 // This should match the condition of `renderDate()`.
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200858 if (this.collapsed) return;
Ben Rohlfs0ed33592023-04-12 11:33:27 +0200859 // Render separator, if both are present: patchset AND date.
860 return html`<span class="separator"></span>`;
861 }
862
Ben Rohlfs05750b92021-10-29 08:23:08 +0200863 private renderDate() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200864 if (this.collapsed) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200865 return html`
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200866 <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200867 ${this.renderDateInner()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200868 </span>
869 `;
870 }
871
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200872 private renderDateInner() {
873 if (isError(this.comment)) return 'Error';
Ben Rohlfsd98a04c2023-05-04 11:31:15 +0200874 if (isSaving(this.comment) && !this.autoSaving) return 'Saving';
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200875 if (isNew(this.comment)) return 'New';
876 return html`
877 <gr-date-formatter
878 withTooltip
879 .dateStr=${this.comment!.updated}
880 ></gr-date-formatter>
881 `;
882 }
883
Ben Rohlfs05750b92021-10-29 08:23:08 +0200884 private renderToggle() {
Chris Poucetb8c06392022-07-08 16:35:43 +0200885 const icon = this.collapsed ? 'expand_more' : 'expand_less';
Ben Rohlfs05750b92021-10-29 08:23:08 +0200886 const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
887 return html`
888 <div class="show-hide" tabindex="0">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200889 <label class="show-hide" aria-label=${ariaLabel}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200890 <input
891 type="checkbox"
892 class="show-hide"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200893 ?checked=${this.collapsed}
894 @change=${() => (this.collapsed = !this.collapsed)}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200895 />
Chris Poucet1c713862022-07-25 13:12:24 +0200896 <gr-icon icon=${icon} id="icon"></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200897 </label>
898 </div>
899 `;
900 }
901
902 private renderRobotAuthor() {
903 if (!isRobot(this.comment) || this.collapsed) return;
904 return html`<div class="robotId">${this.comment.author?.name}</div>`;
905 }
906
907 private renderEditingTextarea() {
908 if (!this.editing || this.collapsed) return;
909 return html`
Ben Rohlfs62561172024-04-18 09:20:38 +0200910 <gr-suggestion-textarea
Ben Rohlfs05750b92021-10-29 08:23:08 +0200911 id="editTextarea"
912 class="editMessage"
913 autocomplete="on"
914 code=""
Ben Rohlfs05750b92021-10-29 08:23:08 +0200915 rows="4"
Dhruv Srivastavaf007abc2022-09-06 11:18:57 +0200916 .placeholder=${this.messagePlaceholder}
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200917 text=${this.messageText}
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200918 autocompleteHint=${this.autocompleteHint?.commentCompletion ?? ''}
Ben Rohlfs93e11b72024-04-30 17:33:51 +0200919 @text-changed=${this.handleTextChanged}
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200920 @hintShown=${this.handleHintShown}
921 @hintApplied=${this.handleHintApplied}
Ben Rohlfs62561172024-04-18 09:20:38 +0200922 ></gr-suggestion-textarea>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200923 `;
924 }
925
Ben Rohlfsdd88cd92024-05-08 13:29:39 +0200926 private handleHintShown(e: CustomEvent<HintShownEventDetail>) {
927 const context = this.autocompleteCache.get(e.detail.oldValue);
928 if (context?.commentCompletion !== e.detail.hint) return;
929
930 this.reportHintInteraction(
931 Interaction.COMMENT_COMPLETION_SUGGESTION_SHOWN,
932 context
933 );
934 }
935
936 private handleHintApplied(e: CustomEvent<HintAppliedEventDetail>) {
937 const context = this.autocompleteCache.get(e.detail.oldValue);
938 if (context?.commentCompletion !== e.detail.hint) return;
939
940 this.autocompleteAcceptedHints.push(e.detail.hint);
941 this.reportHintInteraction(
942 Interaction.COMMENT_COMPLETION_SUGGESTION_ACCEPTED,
943 context
944 );
945 }
946
947 private reportHintInteractionSaved() {
948 const content = this.messageText.trimEnd();
949 const acceptedHintsConcatenated = this.autocompleteAcceptedHints.join('');
950 const numExtraCharacters =
951 content.length - acceptedHintsConcatenated.length;
952 let distance = levenshteinDistance(acceptedHintsConcatenated, content);
953 if (numExtraCharacters > 0) {
954 distance -= numExtraCharacters;
955 }
956 const context = {
957 ...this.createAutocompletionBaseContext(),
958 similarCharacters: acceptedHintsConcatenated.length - distance,
959 maxSimilarCharacters: acceptedHintsConcatenated.length,
960 acceptedSuggestionsCount: this.autocompleteAcceptedHints.length,
961 totalAcceptedCharacters: acceptedHintsConcatenated.length,
962 savedDraftLength: content.length,
963 };
964 this.reportHintInteraction(
965 Interaction.COMMENT_COMPLETION_SAVE_DRAFT,
966 context
967 );
968 }
969
970 private reportHintInteraction(
971 interaction: Interaction,
972 context: Partial<AutocompletionContext>
973 ) {
974 context = {
975 ...context,
976 draftContent: '[REDACTED]',
977 commentCompletion: '[REDACTED]',
978 };
979 this.reporting.reportInteraction(interaction, context);
980 }
981
Ben Rohlfs93e11b72024-04-30 17:33:51 +0200982 private handleTextChanged(e: ValueChangedEvent) {
983 const oldValue = this.messageText;
984 const newValue = e.detail.value;
985 if (oldValue === newValue) return;
986 // TODO: This is causing a re-render of <gr-comment> on every key
987 // press. Try to avoid always setting `this.messageText` or at least
988 // debounce it. Most of the code can just inspect the current value
989 // of the textare instead of needing a dedicated property.
990 this.messageText = newValue;
991
Ben Rohlfs4deb8df52024-05-10 12:53:19 +0200992 this.handleTextChangedForAutocomplete();
Ben Rohlfs93e11b72024-04-30 17:33:51 +0200993 this.autoSaveTrigger$.next();
994 this.generateSuggestionTrigger$.next();
995 }
996
997 // visible for testing
Ben Rohlfs4deb8df52024-05-10 12:53:19 +0200998 handleTextChangedForAutocomplete() {
999 const cachedHint = this.autocompleteCache.get(this.messageText);
1000 if (cachedHint) {
1001 this.autocompleteHint = cachedHint;
1002 } else {
Ben Rohlfsdd88cd92024-05-08 13:29:39 +02001003 this.autocompleteHint = undefined;
Ben Rohlfs4deb8df52024-05-10 12:53:19 +02001004 this.autocompleteTrigger$.next();
Ben Rohlfs93e11b72024-04-30 17:33:51 +02001005 }
Ben Rohlfs93e11b72024-04-30 17:33:51 +02001006 }
1007
Ben Rohlfs05750b92021-10-29 08:23:08 +02001008 private renderCommentMessage() {
1009 if (this.collapsed || this.editing) return;
Frank Bordenf9a29992022-08-24 20:19:23 +02001010
Ben Rohlfs05750b92021-10-29 08:23:08 +02001011 return html`
1012 <!--The "message" class is needed to ensure selectability from
1013 gr-diff-selection.-->
1014 <gr-formatted-text
1015 class="message"
Frank Bordenabdd1872022-09-26 12:55:59 +02001016 .markdown=${true}
1017 .content=${this.comment?.message ?? ''}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001018 ></gr-formatted-text>
1019 `;
1020 }
1021
1022 private renderCopyLinkIcon() {
1023 // Only show the icon when the thread contains a published comment.
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001024 if (!this.comment?.in_reply_to && isDraft(this.comment)) return;
Ben Rohlfs38703a42024-04-30 16:40:02 +02001025 if (this.editing) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001026 return html`
Chris Poucet1c713862022-07-25 13:12:24 +02001027 <gr-icon
1028 icon="link"
1029 class="copy link-icon"
Ben Rohlfs8003bd32022-04-05 18:24:42 +02001030 @click=${this.handleCopyLink}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001031 title="Copy link to this comment"
Ben Rohlfs05750b92021-10-29 08:23:08 +02001032 role="button"
1033 tabindex="0"
Chris Poucet1c713862022-07-25 13:12:24 +02001034 ></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +02001035 `;
1036 }
1037
1038 private renderHumanActions() {
1039 if (!this.account || isRobot(this.comment)) return;
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001040 if (this.collapsed || !isDraft(this.comment)) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001041 return html`
1042 <div class="actions">
Milutin Kristoficb7929e72023-09-05 13:13:07 +02001043 <div class="leftActions">
1044 <div class="action resolve">
1045 <label>
1046 <input
1047 type="checkbox"
1048 id="resolvedCheckbox"
Frank Bordene00592af2024-02-19 12:12:29 +01001049 .checked=${!this.unresolved}
Milutin Kristoficb7929e72023-09-05 13:13:07 +02001050 @change=${this.handleToggleResolved}
1051 />
1052 Resolved
1053 </label>
1054 </div>
1055 ${this.renderGenerateSuggestEditButton()}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001056 </div>
1057 ${this.renderDraftActions()}
1058 </div>
1059 `;
1060 }
1061
1062 private renderDraftActions() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001063 if (!isDraft(this.comment)) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001064 return html`
1065 <div class="rightActions">
Milutin Kristofic1ebae372022-11-22 20:35:38 +01001066 ${this.renderDiscardButton()} ${this.renderEditButton()}
Milutin Kristoficb7929e72023-09-05 13:13:07 +02001067 ${this.renderCancelButton()} ${this.renderSaveButton()}
1068 ${this.renderCopyLinkIcon()}
Ben Rohlfs38703a42024-04-30 16:40:02 +02001069 <gr-endpoint-slot name="draft-actions-end"></gr-endpoint-slot>
Ben Rohlfs05750b92021-10-29 08:23:08 +02001070 </div>
1071 `;
1072 }
1073
Milutin Kristofic1d219672022-06-21 14:57:25 +02001074 private renderSuggestEditButton() {
Dhruv Srivastava66a15632022-09-06 11:57:34 +02001075 if (
Milutin Kristofic8238de52023-01-12 19:33:45 +01001076 !this.editing ||
Dhruv Srivastava66a15632022-09-06 11:57:34 +02001077 this.permanentEditingMode ||
1078 this.comment?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
1079 ) {
1080 return nothing;
1081 }
Milutin Kristofic1d219672022-06-21 14:57:25 +02001082 assertIsDefined(this.comment, 'comment');
1083 if (hasUserSuggestion(this.comment)) return nothing;
1084 // TODO(milutin): remove this check once suggesting on commit message is
1085 // fixed. Currently diff line doesn't match commit message line, because
1086 // of metadata in diff, which aren't in content api request.
1087 if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing;
Milutin Kristofic50394b12023-02-01 11:28:52 +00001088 if (this.isOwner) return nothing;
Milutin Kristofic7dec89b2022-09-13 12:11:35 +02001089 return html`<gr-button
1090 link
1091 class="action suggestEdit"
Milutin Kristofic80c3c7e2023-03-16 20:22:28 +01001092 title="This button copies the text to make a suggestion"
Milutin Kristofic7dec89b2022-09-13 12:11:35 +02001093 @click=${this.createSuggestEdit}
Milutin Kristofic80c3c7e2023-03-16 20:22:28 +01001094 ><gr-icon icon="edit" id="icon" filled></gr-icon> Suggest edit</gr-button
Milutin Kristofic1d219672022-06-21 14:57:25 +02001095 >`;
1096 }
1097
Ben Rohlfs05750b92021-10-29 08:23:08 +02001098 private renderDiscardButton() {
Dhruv Srivastava705eac92022-09-01 11:33:27 +02001099 if (this.editing || this.permanentEditingMode) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001100 return html`<gr-button
1101 link
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001102 ?disabled=${isSaving(this.comment) && !this.autoSaving}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001103 class="action discard"
Ben Rohlfs8003bd32022-04-05 18:24:42 +02001104 @click=${this.discard}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001105 >Discard</gr-button
1106 >`;
1107 }
1108
1109 private renderEditButton() {
1110 if (this.editing) return;
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001111 return html`<gr-button link class="action edit" @click=${this.edit}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001112 >Edit</gr-button
1113 >`;
1114 }
1115
1116 private renderCancelButton() {
Dhruv Srivastava705eac92022-09-01 11:33:27 +02001117 if (!this.editing || this.permanentEditingMode) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001118 return html`
1119 <gr-button
1120 link
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001121 ?disabled=${isSaving(this.comment) && !this.autoSaving}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001122 class="action cancel"
Ben Rohlfs8003bd32022-04-05 18:24:42 +02001123 @click=${this.cancel}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001124 >Cancel</gr-button
1125 >
1126 `;
1127 }
1128
1129 private renderSaveButton() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001130 if (!this.editing) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001131 return html`
1132 <gr-button
1133 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +02001134 ?disabled=${this.isSaveDisabled()}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001135 class="action save"
Dhruv Srivastava705eac92022-09-01 11:33:27 +02001136 @click=${this.handleSaveButtonClicked}
Dhruv Srivastava00831e72022-09-05 08:20:20 +02001137 >${this.permanentEditingMode ? 'Preview' : 'Save'}</gr-button
Ben Rohlfs05750b92021-10-29 08:23:08 +02001138 >
1139 `;
1140 }
1141
Milutin Kristoficbf61a4e2024-02-12 20:50:16 +01001142 private renderFixSuggestionPreview() {
Milutin Kristofice28cde52024-03-20 13:07:29 +01001143 if (
1144 !this.comment?.fix_suggestions ||
1145 this.editing ||
1146 isRobot(this.comment) ||
1147 this.collapsed
1148 )
Milutin Kristofic03ec98d2024-02-20 19:56:39 +01001149 return nothing;
1150 return html`<gr-fix-suggestions
1151 .comment=${this.comment}
1152 ></gr-fix-suggestions>`;
Milutin Kristoficbf61a4e2024-02-12 20:50:16 +01001153 }
1154
Milutin Kristoficd9e99752024-02-20 14:35:25 +01001155 // private but used in test
1156 showGeneratedSuggestion() {
Milutin Kristofic490fa952023-09-12 20:17:36 +02001157 return (
Milutin Kristofic1cfc0212024-02-01 20:16:06 +01001158 (this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
1159 this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)) &&
Milutin Kristofica31d8942023-11-17 13:32:16 +01001160 this.suggestionsProvider &&
Milutin Kristofic490fa952023-09-12 20:17:36 +02001161 this.editing &&
1162 !this.permanentEditingMode &&
Milutin Kristofic490fa952023-09-12 20:17:36 +02001163 this.comment &&
Milutin Kristofica31d8942023-11-17 13:32:16 +01001164 this.comment.path &&
Milutin Kristofic8afa8e42023-09-22 20:33:07 +02001165 this.comment.path !== SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
1166 this.comment.path !== SpecialFilePath.COMMIT_MESSAGE &&
Milutin Kristofica31d8942023-11-17 13:32:16 +01001167 (!this.suggestionsProvider.supportedFileExtensions ||
1168 this.suggestionsProvider.supportedFileExtensions.includes(
1169 getFileExtension(this.comment.path)
1170 )) &&
Milutin Kristofic8afa8e42023-09-22 20:33:07 +02001171 this.comment === this.comments?.[0] && // Is first comment
1172 (this.comment.range || this.comment.line) && // Disabled for File comments
Milutin Kristofic35c48c32023-12-11 19:52:41 +01001173 !hasUserSuggestion(this.comment) &&
1174 this.getChangeModel().getChange()?.is_private !== true
Milutin Kristofic490fa952023-09-12 20:17:36 +02001175 );
1176 }
1177
Milutin Kristofic8f07c382023-10-02 13:13:21 +02001178 private renderGeneratedSuggestionPreview() {
Milutin Kristofic03ec98d2024-02-20 19:56:39 +01001179 if (
1180 !this.editing ||
1181 !this.showGeneratedSuggestion() ||
1182 !this.generateSuggestion
1183 )
Milutin Kristofic8f07c382023-10-02 13:13:21 +02001184 return nothing;
Milutin Kristoficd4274a12024-02-19 20:30:02 +01001185 if (!isDraft(this.comment)) return nothing;
Milutin Kristofic97683a82023-11-20 20:06:22 +01001186
Milutin Kristofic1cfc0212024-02-01 20:16:06 +01001187 if (this.generatedFixSuggestion) {
1188 return html`<gr-suggestion-diff-preview
Milutin Kristofic847c46d2024-04-15 13:43:06 +02001189 id="suggestionDiffPreview"
Milutin Kristofic03ec98d2024-02-20 19:56:39 +01001190 .fixSuggestionInfo=${this.generatedFixSuggestion}
Milutin Kristofic1cfc0212024-02-01 20:16:06 +01001191 ></gr-suggestion-diff-preview>`;
1192 } else if (this.generatedSuggestion) {
1193 return html`<gr-suggestion-diff-preview
1194 .showAddSuggestionButton=${true}
1195 .suggestion=${this.generatedSuggestion?.replacement}
1196 .uuid=${this.generatedSuggestionId}
1197 ></gr-suggestion-diff-preview>`;
1198 } else {
1199 return nothing;
1200 }
Milutin Kristofic8f07c382023-10-02 13:13:21 +02001201 }
1202
Milutin Kristofic734df552023-08-07 10:38:50 +02001203 private renderGenerateSuggestEditButton() {
Milutin Kristofic490fa952023-09-12 20:17:36 +02001204 if (!this.showGeneratedSuggestion()) {
Milutin Kristoficb7929e72023-09-05 13:13:07 +02001205 return nothing;
1206 }
Milutin Kristoficdca27ed2023-11-21 21:46:51 +01001207 const tooltip =
1208 'Select to show a generated suggestion based on your comment for commented text. This suggestion can be inserted as a code block in your comment.';
Milutin Kristofic734df552023-08-07 10:38:50 +02001209 return html`
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +02001210 <div class="action">
Milutin Kristoficdca27ed2023-11-21 21:46:51 +01001211 <label title=${tooltip}>
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +02001212 <input
1213 type="checkbox"
1214 id="generateSuggestCheckbox"
1215 ?checked=${this.generateSuggestion}
1216 @change=${() => {
1217 this.generateSuggestion = !this.generateSuggestion;
Milutin Kristofic409e4a02024-03-12 13:32:36 +01001218 if (this.comment?.id) {
1219 this.getStorage().setEditableContentItem(
1220 ENABLE_GENERATE_SUGGESTION_STORAGE_KEY + this.comment.id,
1221 this.generateSuggestion.toString()
1222 );
1223 }
Milutin Kristofic4d963732024-01-04 12:10:03 +01001224 if (this.generateSuggestion) {
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +02001225 this.generateSuggestionTrigger$.next();
Milutin Kristoficd4274a12024-02-19 20:30:02 +01001226 } else {
1227 if (
1228 this.flagsService.isEnabled(
1229 KnownExperimentId.ML_SUGGESTED_EDIT_V2
1230 )
1231 ) {
1232 this.generatedFixSuggestion = undefined;
1233 this.autoSaveTrigger$.next();
1234 }
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +02001235 }
Milutin Kristofic96ac52e2023-09-18 10:28:58 +02001236 this.reporting.reportInteraction(
1237 this.generateSuggestion
1238 ? Interaction.GENERATE_SUGGESTION_ENABLED
1239 : Interaction.GENERATE_SUGGESTION_DISABLED
1240 );
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +02001241 }}
1242 />
Milutin Kristofice28cde52024-03-20 13:07:29 +01001243 ${this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
Milutin Kristoficbe553232024-04-16 14:38:21 +02001244 ? 'Attach AI-suggested fix'
Milutin Kristofice28cde52024-03-20 13:07:29 +01001245 : 'Generate Suggestion'}
Milutin Kristofic74caea4b2023-11-14 20:10:56 +01001246 ${when(
1247 this.suggestionLoading,
1248 () => html`<span class="loadingSpin"></span>`,
Milutin Kristofic97683a82023-11-20 20:06:22 +01001249 () => html`${this.getNumberOfSuggestions()}`
Milutin Kristofic74caea4b2023-11-14 20:10:56 +01001250 )}
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +02001251 </label>
Milutin Kristofic5942c9d2023-11-15 20:34:42 +01001252 <a
Milutin Kristofice28cde52024-03-20 13:07:29 +01001253 href=${this.suggestionsProvider?.getDocumentationLink?.() ||
1254 getDocUrl(
Milutin Kristofic5942c9d2023-11-15 20:34:42 +01001255 this.docsBaseUrl,
Milutin Kristofice28cde52024-03-20 13:07:29 +01001256 'user-suggest-edits.html$_generate_suggestion'
Milutin Kristofic5942c9d2023-11-15 20:34:42 +01001257 )}
1258 target="_blank"
1259 rel="noopener noreferrer"
1260 >
1261 <gr-icon
1262 icon="help"
1263 title="About Generated Suggested Edits"
1264 ></gr-icon>
1265 </a>
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +02001266 </div>
Milutin Kristofic734df552023-08-07 10:38:50 +02001267 `;
1268 }
1269
Milutin Kristofic97683a82023-11-20 20:06:22 +01001270 private getNumberOfSuggestions() {
Chris Poucet03bc8a32024-02-01 18:33:17 +01001271 if (!this.generateSuggestion) {
1272 return '';
1273 }
Milutin Kristoficc3dfc022024-02-19 19:41:02 +01001274 if (this.generatedSuggestion || this.generatedFixSuggestion) {
Milutin Kristofic97683a82023-11-20 20:06:22 +01001275 return '(1)';
1276 } else {
1277 return '(0)';
1278 }
1279 }
1280
Milutin Kristofic490fa952023-09-12 20:17:36 +02001281 private handleAddGeneratedSuggestion(code: string) {
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +02001282 const addNewLine = this.messageText.length !== 0;
Milutin Kristoficc0582fc2024-04-02 20:30:51 +02001283 this.addedGeneratedSuggestion = `${
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +02001284 addNewLine ? '\n' : ''
Milutin Kristofic490fa952023-09-12 20:17:36 +02001285 }${USER_SUGGESTION_START_PATTERN}${code}${'\n```'}`;
Milutin Kristoficc0582fc2024-04-02 20:30:51 +02001286 this.messageText += this.addedGeneratedSuggestion;
Milutin Kristoficfc5ccca2023-09-11 13:43:45 +02001287 }
1288
Milutin Kristofic1cfc0212024-02-01 20:16:06 +01001289 private generateSuggestEdit() {
1290 if (this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)) {
1291 this.generateSuggestEdit_v2();
1292 } else if (
1293 this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT)
1294 ) {
1295 this.generateSuggestEdit_v1();
1296 }
1297 }
1298
1299 private async generateSuggestEdit_v1() {
Milutin Kristofica31d8942023-11-17 13:32:16 +01001300 const suggestionsProvider = this.suggestionsProvider;
Kamil Musin48ff12a2023-12-05 14:25:27 +01001301 const changeInfo = this.getChangeModel().getChange();
Milutin Kristofic8afa8e42023-09-22 20:33:07 +02001302 if (
Milutin Kristofic604ac712024-01-25 13:46:18 +01001303 !suggestionsProvider?.suggestCode ||
Milutin Kristofic8afa8e42023-09-22 20:33:07 +02001304 !this.showGeneratedSuggestion() ||
Chris Poucet4ca9f2f2024-02-01 17:28:50 +01001305 !this.generateSuggestion ||
Kamil Musin48ff12a2023-12-05 14:25:27 +01001306 !changeInfo ||
Milutin Kristofic8afa8e42023-09-22 20:33:07 +02001307 !this.comment ||
1308 !this.comment.patch_set ||
1309 !this.comment.path ||
1310 this.messageText.length === 0
1311 )
Milutin Kristofic734df552023-08-07 10:38:50 +02001312 return;
Milutin Kristofic06dfda42023-11-15 09:51:13 +01001313 this.generatedSuggestionId = uuid();
Milutin Kristofic96ac52e2023-09-18 10:28:58 +02001314 this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_REQUEST, {
Milutin Kristofic06dfda42023-11-15 09:51:13 +01001315 uuid: this.generatedSuggestionId,
Milutin Kristoficaa120cc2024-02-01 13:14:43 +01001316 type: 'suggest-code',
1317 commentId: this.comment.id,
Milutin Kristofic96ac52e2023-09-18 10:28:58 +02001318 });
Milutin Kristofic74caea4b2023-11-14 20:10:56 +01001319 this.suggestionLoading = true;
Milutin Kristoficd682dc72023-12-13 09:35:47 +01001320 let suggestionResponse;
1321 try {
1322 suggestionResponse = await suggestionsProvider.suggestCode({
1323 prompt: this.messageText,
1324 changeInfo: changeInfo as ChangeInfo,
1325 patchsetNumber: this.comment?.patch_set,
1326 filePath: this.comment.path,
1327 range: this.comment.range,
1328 lineNumber: this.comment.line,
1329 });
1330 } finally {
1331 this.suggestionLoading = false;
1332 }
1333
1334 if (!suggestionResponse) return;
Milutin Kristofic8f07c382023-10-02 13:13:21 +02001335 // TODO(milutin): The suggestionResponse can contain multiple suggestion
1336 // options. We pick the first one for now. In future we shouldn't ignore
1337 // other suggestions.
Milutin Kristofic96ac52e2023-09-18 10:28:58 +02001338 this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
Milutin Kristofic06dfda42023-11-15 09:51:13 +01001339 uuid: this.generatedSuggestionId,
Milutin Kristofic1cfc0212024-02-01 20:16:06 +01001340 type: 'suggest-code',
Milutin Kristoficfa158252024-04-24 11:58:32 +02001341 commentId: this.comment.id,
Milutin Kristofic8f07c382023-10-02 13:13:21 +02001342 response: suggestionResponse.responseCode,
1343 numSuggestions: suggestionResponse.suggestions.length,
1344 hasNewRange: suggestionResponse.suggestions?.[0]?.newRange !== undefined,
Milutin Kristofic96ac52e2023-09-18 10:28:58 +02001345 });
Milutin Kristofic8f07c382023-10-02 13:13:21 +02001346 const suggestion = suggestionResponse.suggestions?.[0];
Milutin Kristoficead5c55b2024-03-05 18:13:52 +01001347 if (!suggestion?.replacement) return;
Milutin Kristofic8f07c382023-10-02 13:13:21 +02001348 this.generatedSuggestion = suggestion;
Milutin Kristofic734df552023-08-07 10:38:50 +02001349 }
1350
Milutin Kristofic1cfc0212024-02-01 20:16:06 +01001351 private async generateSuggestEdit_v2() {
1352 const suggestionsProvider = this.suggestionsProvider;
1353 const changeInfo = this.getChangeModel().getChange();
1354 if (
1355 !suggestionsProvider?.suggestFix ||
1356 !this.showGeneratedSuggestion() ||
1357 !this.generateSuggestion ||
1358 !changeInfo ||
1359 !this.comment ||
1360 !this.comment.patch_set ||
1361 !this.comment.path ||
1362 this.messageText.length === 0
1363 )
1364 return;
1365 this.generatedSuggestionId = uuid();
1366 this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_REQUEST, {
1367 uuid: this.generatedSuggestionId,
1368 type: 'suggest-fix',
1369 commentId: this.comment.id,
1370 });
1371 this.suggestionLoading = true;
1372 let suggestionResponse;
1373 try {
1374 suggestionResponse = await suggestionsProvider.suggestFix({
1375 prompt: this.messageText,
1376 changeInfo: changeInfo as ChangeInfo,
1377 patchsetNumber: this.comment?.patch_set,
1378 filePath: this.comment.path,
1379 range: this.comment.range,
1380 lineNumber: this.comment.line,
1381 });
1382 } finally {
1383 this.suggestionLoading = false;
1384 }
1385
1386 if (!suggestionResponse) return;
1387 // TODO(milutin): The suggestionResponse can contain multiple suggestion
1388 // options. We pick the first one for now. In future we shouldn't ignore
1389 // other suggestions.
1390 this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
1391 uuid: this.generatedSuggestionId,
1392 type: 'suggest-fix',
Milutin Kristoficfa158252024-04-24 11:58:32 +02001393 commentId: this.comment.id,
Milutin Kristofic1cfc0212024-02-01 20:16:06 +01001394 response: suggestionResponse.responseCode,
1395 numSuggestions: suggestionResponse.fix_suggestions.length,
1396 });
1397 const suggestion = suggestionResponse.fix_suggestions?.[0];
Milutin Kristoficead5c55b2024-03-05 18:13:52 +01001398 if (!suggestion?.replacements || suggestion.replacements.length === 0) {
1399 return;
1400 }
Milutin Kristofic1cfc0212024-02-01 20:16:06 +01001401 this.generatedFixSuggestion = suggestion;
Milutin Kristofic847c46d2024-04-15 13:43:06 +02001402 try {
Ben Rohlfs6f46d242024-04-19 09:12:22 +02001403 await waitUntil(() => this.getFixSuggestions() !== undefined);
Milutin Kristofic847c46d2024-04-15 13:43:06 +02001404 this.autoSaveTrigger$.next();
1405 } catch (error) {
1406 // Error is ok in some cases like quick save by user.
1407 console.warn(error);
1408 }
Milutin Kristofic1cfc0212024-02-01 20:16:06 +01001409 }
1410
Ben Rohlfs2ad97562024-04-26 18:27:16 +02001411 private async autocompleteComment() {
1412 const enabled = this.flagsService.isEnabled(
1413 KnownExperimentId.COMMENT_AUTOCOMPLETION
1414 );
1415 const suggestionsProvider = this.suggestionsProvider;
1416 const change = this.getChangeModel().getChange();
1417 if (
1418 !enabled ||
Ben Rohlfsb3873692024-05-15 13:40:33 +02001419 !this.autocompleteEnabled ||
Ben Rohlfs2ad97562024-04-26 18:27:16 +02001420 !suggestionsProvider?.autocompleteComment ||
1421 !change ||
1422 !this.comment?.patch_set ||
1423 !this.comment.path ||
1424 this.messageText.length === 0
1425 ) {
1426 return;
1427 }
1428 const commentText = this.messageText;
Ben Rohlfsdd88cd92024-05-08 13:29:39 +02001429 this.reporting.time(Timing.COMMENT_COMPLETION);
Ben Rohlfs2ad97562024-04-26 18:27:16 +02001430 const response = await suggestionsProvider.autocompleteComment({
1431 id: id(this.comment),
1432 commentText,
1433 changeInfo: change as ChangeInfo,
1434 patchsetNumber: this.comment?.patch_set,
1435 filePath: this.comment.path,
1436 range: this.comment.range,
1437 lineNumber: this.comment.line,
1438 });
Ben Rohlfsdd88cd92024-05-08 13:29:39 +02001439 const elapsed = this.reporting.timeEnd(Timing.COMMENT_COMPLETION);
1440 const context = this.createAutocompletionContext(
1441 commentText,
1442 response,
1443 elapsed
1444 );
1445 this.reportHintInteraction(
1446 Interaction.COMMENT_COMPLETION_SUGGESTION_FETCHED,
1447 context
1448 );
Ben Rohlfs2ad97562024-04-26 18:27:16 +02001449 if (!response?.completion) return;
Ben Rohlfsdd88cd92024-05-08 13:29:39 +02001450 // Note that we are setting the cache value for `commentText` and getting the value
1451 // for `this.messageText`.
1452 this.autocompleteCache.set(context);
1453 this.autocompleteHint = this.autocompleteCache.get(this.messageText);
1454 }
1455
1456 private createAutocompletionBaseContext(): Partial<AutocompletionContext> {
1457 return {
1458 commentId: id(this.comment!),
1459 commentNumber: this.comments?.length ?? 0,
1460 filePath: this.comment!.path,
1461 fileExtension: getFileExtension(this.comment!.path ?? ''),
1462 };
1463 }
1464
1465 private createAutocompletionContext(
1466 draftContent: string,
1467 response: AutocompleteCommentResponse,
1468 requestDurationMs: number
1469 ): AutocompletionContext {
1470 const commentCompletion = response.completion ?? '';
1471 return {
1472 ...this.createAutocompletionBaseContext(),
1473
1474 draftContent,
1475 draftContentLength: draftContent.length,
1476 commentCompletion,
1477 commentCompletionLength: commentCompletion.length,
1478
1479 isFullCommentPrediction: draftContent.length === 0,
1480 draftInSyncWithSuggestionLength: 0,
1481 modelVersion: response.modelVersion ?? '',
1482 requestDurationMs,
1483 };
Ben Rohlfs2ad97562024-04-26 18:27:16 +02001484 }
1485
Ben Rohlfs05750b92021-10-29 08:23:08 +02001486 private renderRobotActions() {
1487 if (!this.account || !isRobot(this.comment)) return;
1488 const endpoint = html`
1489 <gr-endpoint-decorator name="robot-comment-controls">
Ben Rohlfs8003bd32022-04-05 18:24:42 +02001490 <gr-endpoint-param name="comment" .value=${this.comment}>
Ben Rohlfs05750b92021-10-29 08:23:08 +02001491 </gr-endpoint-param>
1492 </gr-endpoint-decorator>
1493 `;
1494 return html`
1495 <div class="robotActions">
1496 ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()}
1497 ${this.renderPleaseFixButton()}
1498 </div>
1499 `;
1500 }
1501
1502 private renderShowFixButton() {
Kamil Musind4418632023-03-07 10:20:49 +01001503 const fix_suggestions = (this.comment as RobotCommentInfo)?.fix_suggestions;
1504 if (!fix_suggestions || fix_suggestions.length === 0) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001505 return html`
1506 <gr-button
1507 link
1508 secondary
1509 class="action show-fix"
Kamil Musind4418632023-03-07 10:20:49 +01001510 @click=${() => this.handleShowFix()}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001511 >
1512 Show Fix
1513 </gr-button>
1514 `;
1515 }
1516
1517 private renderPleaseFixButton() {
1518 if (this.hasHumanReply()) return;
1519 return html`
1520 <gr-button
1521 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +02001522 ?disabled=${this.robotButtonDisabled}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001523 class="action fix"
Ben Rohlfs23843882022-08-04 18:06:27 +02001524 @click=${this.handlePleaseFix}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001525 >
1526 Please Fix
1527 </gr-button>
1528 `;
1529 }
1530
1531 private renderConfirmDialog() {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001532 return html`
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301533 <dialog id="confirmDeleteModal" tabindex="-1">
Ben Rohlfs05750b92021-10-29 08:23:08 +02001534 <gr-confirm-delete-comment-dialog
Kamil Musinc7d3f282022-12-29 13:27:55 +01001535 id="confirmDeleteCommentDialog"
Ben Rohlfs8003bd32022-04-05 18:24:42 +02001536 @confirm=${this.handleConfirmDeleteComment}
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301537 @cancel=${this.closeDeleteCommentModal}
Ben Rohlfs05750b92021-10-29 08:23:08 +02001538 >
1539 </gr-confirm-delete-comment-dialog>
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301540 </dialog>
Ben Rohlfs05750b92021-10-29 08:23:08 +02001541 `;
1542 }
1543
1544 private getUrlForComment() {
Ben Rohlfsba440822023-04-11 18:08:03 +02001545 if (!this.changeNum || !this.repoName || !this.comment?.id) return '';
Ben Rohlfs731738b2022-09-15 15:55:33 +02001546 return createDiffUrl({
1547 changeNum: this.changeNum,
Ben Rohlfsbfc688b2022-10-21 12:38:37 +02001548 repo: this.repoName,
Ben Rohlfsba440822023-04-11 18:08:03 +02001549 commentId: this.comment.id,
Ben Rohlfs731738b2022-09-15 15:55:33 +02001550 });
Dhruv Srivastava0287bf92020-09-11 16:56:38 +02001551 }
1552
Ben Rohlfs05750b92021-10-29 08:23:08 +02001553 private firstWillUpdateDone = false;
1554
1555 firstWillUpdate() {
1556 if (this.firstWillUpdateDone) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001557 assertIsDefined(this.comment, 'comment');
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001558 this.firstWillUpdateDone = true;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001559 this.unresolved = this.comment.unresolved ?? true;
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001560 if (this.permanentEditingMode) {
1561 this.edit();
1562 }
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001563 if (isDraft(this.comment)) {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001564 this.collapsed = false;
1565 } else {
1566 this.collapsed = !!this.initiallyCollapsed;
1567 }
1568 }
1569
Dhruv Srivastavaa49dffd2022-10-20 14:04:37 +02001570 override updated(changed: PropertyValues) {
1571 if (changed.has('editing')) {
Dhruv Srivastavae8b86392022-10-20 17:17:21 +02001572 if (this.editing && !this.permanentEditingMode) {
Ben Rohlfsba9329c2023-05-16 10:05:12 +02001573 // Note that this is a bit fragile, because we are relying on the
1574 // comment to become visible soonish. If that does not happen, then we
1575 // will be waiting indefinitely and grab focus at some point in the
1576 // distant future.
Dhruv Srivastavaa49dffd2022-10-20 14:04:37 +02001577 whenVisible(this, () => this.textarea?.putCursorAtEnd());
1578 }
1579 }
Milutin Kristofic490fa952023-09-12 20:17:36 +02001580 if (
1581 changed.has('changeNum') ||
1582 changed.has('comment') ||
Milutin Kristofic06dfda42023-11-15 09:51:13 +01001583 changed.has('generatedSuggestion')
Milutin Kristofic490fa952023-09-12 20:17:36 +02001584 ) {
Milutin Kristofice9dbbe92023-05-17 21:21:28 +02001585 if (
Milutin Kristofice9dbbe92023-05-17 21:21:28 +02001586 !this.changeNum ||
Milutin Kristofic78cdec92023-08-29 14:37:31 +02001587 !this.comment ||
Milutin Kristofic8f07c382023-10-02 13:13:21 +02001588 (!hasUserSuggestion(this.comment) && !this.generatedSuggestion)
Milutin Kristofice9dbbe92023-05-17 21:21:28 +02001589 )
1590 return;
1591 (async () => {
Milutin Kristofic78cdec92023-08-29 14:37:31 +02001592 this.commentedText = await this.commentModel.getCommentedCode(
1593 this.comment,
1594 this.changeNum
1595 );
Milutin Kristofice9dbbe92023-05-17 21:21:28 +02001596 })();
1597 }
Dhruv Srivastavaa49dffd2022-10-20 14:04:37 +02001598 }
1599
Ben Rohlfs05750b92021-10-29 08:23:08 +02001600 override willUpdate(changed: PropertyValues) {
1601 this.firstWillUpdate();
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001602 if (changed.has('comment')) {
1603 if (isDraft(this.comment) && isError(this.comment)) {
1604 this.edit();
1605 }
Milutin Kristofice9dbbe92023-05-17 21:21:28 +02001606 if (this.comment) {
1607 this.commentModel.updateState({
1608 comment: this.comment,
1609 });
1610 }
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001611 }
Ben Rohlfs05750b92021-10-29 08:23:08 +02001612 if (changed.has('editing')) {
Chris Poucetafd0f7c2022-10-04 10:04:43 +00001613 this.onEditingChanged();
Ben Rohlfs05750b92021-10-29 08:23:08 +02001614 }
1615 if (changed.has('unresolved')) {
1616 // The <gr-comment-thread> component wants to change its color based on
1617 // the (dirty) unresolved state, so let's notify it about changes.
Dhruv Srivastava463bb332022-08-31 13:00:49 +02001618 fire(this, 'comment-unresolved-changed', {value: this.unresolved});
1619 }
1620 if (changed.has('messageText')) {
1621 // GrReplyDialog updates it's state when text inside patchset level
1622 // comment changes.
1623 fire(this, 'comment-text-changed', {value: this.messageText});
Ben Rohlfs05750b92021-10-29 08:23:08 +02001624 }
1625 }
1626
1627 private handlePortedMessageClick() {
Ben Rohlfsc1c6afd2021-02-18 13:13:22 +01001628 assertIsDefined(this.comment, 'comment');
Dhruv Srivastavac8df7602021-01-15 10:59:00 +01001629 this.reporting.reportInteraction('navigate-to-original-comment', {
1630 line: this.comment.line,
1631 range: this.comment.range,
1632 });
1633 }
1634
Ben Rohlfs05750b92021-10-29 08:23:08 +02001635 private handleCopyLink() {
Ben Rohlfs44f01042023-02-18 13:27:57 +01001636 fire(this, 'copy-comment-link', {});
Ben Rohlfs05750b92021-10-29 08:23:08 +02001637 }
1638
1639 /** Enter editing mode. */
Ben Rohlfsba9329c2023-05-16 10:05:12 +02001640 edit() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001641 assert(isDraft(this.comment), 'only drafts are editable');
Ben Rohlfs05750b92021-10-29 08:23:08 +02001642 if (this.editing) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001643 this.editing = true;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001644 }
1645
1646 // TODO: Move this out of gr-comment. gr-comment should not have a comments
1647 // property.
1648 private hasHumanReply() {
1649 if (!this.comment || !this.comments) return false;
1650 return this.comments.some(
1651 c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c)
Milutin Kristoficafae0052020-09-17 10:38:08 +02001652 );
Ben Rohlfs05750b92021-10-29 08:23:08 +02001653 }
1654
1655 // private, but visible for testing
Milutin Kristofic1ebae372022-11-22 20:35:38 +01001656 async createFixPreview(
1657 replacement?: string
1658 ): Promise<OpenFixPreviewEventDetail> {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001659 assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
Ben Rohlfs23843882022-08-04 18:06:27 +02001660 assertIsDefined(this.comment?.path, 'comment.path');
1661
Milutin Kristofic1ebae372022-11-22 20:35:38 +01001662 if (hasUserSuggestion(this.comment) || replacement) {
1663 replacement = replacement ?? getUserSuggestion(this.comment);
Ben Rohlfs23843882022-08-04 18:06:27 +02001664 assert(!!replacement, 'malformed user suggestion');
Milutin Kristofic78cdec92023-08-29 14:37:31 +02001665 let commentedCode = this.commentedText;
1666 if (!commentedCode) {
1667 commentedCode = await this.getCommentedCode();
1668 }
Ben Rohlfs23843882022-08-04 18:06:27 +02001669
1670 return {
1671 fixSuggestions: createUserFixSuggestion(
1672 this.comment,
Milutin Kristofic78cdec92023-08-29 14:37:31 +02001673 commentedCode,
Ben Rohlfs23843882022-08-04 18:06:27 +02001674 replacement
1675 ),
1676 patchNum: this.comment.patch_set,
Milutin Kristoficeb8f6552023-02-09 22:17:35 +01001677 onCloseFixPreviewCallbacks: [
1678 fixApplied => {
1679 if (fixApplied) this.handleAppliedFix();
1680 },
1681 ],
Ben Rohlfs23843882022-08-04 18:06:27 +02001682 };
1683 }
Milutin Kristoficbf61a4e2024-02-12 20:50:16 +01001684 if (
1685 isRobot(this.comment) &&
1686 this.comment.fix_suggestions &&
1687 this.comment.fix_suggestions.length > 0
1688 ) {
Ben Rohlfs23843882022-08-04 18:06:27 +02001689 const id = this.comment.robot_id;
1690 return {
1691 fixSuggestions: this.comment.fix_suggestions.map(s => {
1692 return {
1693 ...s,
1694 description: `${id ?? ''} - ${s.description ?? ''}`,
1695 };
1696 }),
1697 patchNum: this.comment.patch_set,
Milutin Kristoficeb8f6552023-02-09 22:17:35 +01001698 onCloseFixPreviewCallbacks: [],
Ben Rohlfs23843882022-08-04 18:06:27 +02001699 };
1700 }
1701 throw new Error('unable to create preview fix event');
Ben Rohlfs05750b92021-10-29 08:23:08 +02001702 }
1703
Chris Poucetafd0f7c2022-10-04 10:04:43 +00001704 private onEditingChanged() {
1705 if (this.editing) {
1706 this.collapsed = false;
1707 this.messageText = this.comment?.message ?? '';
1708 this.unresolved = this.comment?.unresolved ?? true;
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001709 if (!isError(this.comment) && !isSaving(this.comment)) {
1710 this.originalMessage = this.messageText;
1711 this.originalUnresolved = this.unresolved;
1712 }
Chris Poucetafd0f7c2022-10-04 10:04:43 +00001713 }
1714
1715 // Parent components such as the reply dialog might be interested in whether
1716 // come of their child components are in editing mode.
1717 fire(this, 'comment-editing-changed', {
1718 editing: this.editing,
1719 path: this.comment?.path ?? '',
1720 });
Ben Rohlfs05750b92021-10-29 08:23:08 +02001721 }
1722
Ben Rohlfs05750b92021-10-29 08:23:08 +02001723 // private, but visible for testing
1724 isSaveDisabled() {
1725 assertIsDefined(this.comment, 'comment');
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001726 if (isSaving(this.comment) && !this.autoSaving) return true;
Ben Rohlfs2e237552021-11-24 10:34:28 +01001727 return !this.messageText?.trimEnd();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001728 }
1729
Dhruv Srivastavae8b86392022-10-20 17:17:21 +02001730 override focus() {
Ben Rohlfsba9329c2023-05-16 10:05:12 +02001731 // Note that this may not work as intended, because the textarea is not
1732 // rendered yet.
Dhruv Srivastavae8b86392022-10-20 17:17:21 +02001733 this.textarea?.focus();
1734 }
1735
Ben Rohlfs05750b92021-10-29 08:23:08 +02001736 private handleEsc() {
1737 // vim users don't like ESC to cancel/discard, so only do this when the
1738 // comment text is empty.
Ben Rohlfs2e237552021-11-24 10:34:28 +01001739 if (!this.messageText?.trimEnd()) this.cancel();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001740 }
1741
Ben Rohlfs05750b92021-10-29 08:23:08 +02001742 private handleAnchorClick() {
1743 assertIsDefined(this.comment, 'comment');
1744 fire(this, 'comment-anchor-tap', {
1745 number: this.comment.line || FILE,
1746 side: this.comment?.side,
Milutin Kristoficafae0052020-09-17 10:38:08 +02001747 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001748 }
1749
Dhruv Srivastava15950b452022-09-12 10:56:53 +02001750 private async handleSaveButtonClicked() {
1751 await this.save();
1752 if (this.permanentEditingMode) {
1753 this.editing = !this.editing;
1754 }
Dhruv Srivastava705eac92022-09-01 11:33:27 +02001755 }
1756
Ben Rohlfs23843882022-08-04 18:06:27 +02001757 private handlePleaseFix() {
1758 const message = this.comment?.message;
1759 assert(!!message, 'empty message');
1760 const quoted = message.replace(NEWLINE_PATTERN, '\n> ');
1761 const eventDetail: ReplyToCommentEventDetail = {
1762 content: `> ${quoted}\n\nPlease fix.`,
1763 userWantsToEdit: false,
1764 unresolved: true,
1765 };
Ben Rohlfs05750b92021-10-29 08:23:08 +02001766 // Handled by <gr-comment-thread>.
Ben Rohlfs23843882022-08-04 18:06:27 +02001767 fire(this, 'reply-to-comment', eventDetail);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001768 }
1769
Milutin Kristoficeb8f6552023-02-09 22:17:35 +01001770 private handleAppliedFix() {
1771 const message = this.comment?.message;
1772 assert(!!message, 'empty message');
1773 const eventDetail: ReplyToCommentEventDetail = {
1774 content: 'Fix applied.',
1775 userWantsToEdit: false,
1776 unresolved: false,
1777 };
1778 // Handled by <gr-comment-thread>.
1779 fire(this, 'reply-to-comment', eventDetail);
1780 }
1781
Milutin Kristofic1ebae372022-11-22 20:35:38 +01001782 private async handleShowFix(replacement?: string) {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001783 // Handled top-level in the diff and change view components.
Milutin Kristofic1ebae372022-11-22 20:35:38 +01001784 fire(this, 'open-fix-preview', await this.createFixPreview(replacement));
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001785 }
1786
Milutin Kristofic8238de52023-01-12 19:33:45 +01001787 async createSuggestEdit(e: MouseEvent) {
1788 e.stopPropagation();
Ben Rohlfs23843882022-08-04 18:06:27 +02001789 const line = await this.getCommentedCode();
Milutin Kristofic7d557472023-08-21 11:23:36 +02001790 const addNewLine = this.messageText.length !== 0;
1791 this.messageText += `${
1792 addNewLine ? '\n' : ''
1793 }${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
Ben Rohlfs23843882022-08-04 18:06:27 +02001794 }
1795
Milutin Kristofic78cdec92023-08-29 14:37:31 +02001796 // TODO(milutin): Remove once feature flag is rollout and use only model
Ben Rohlfs23843882022-08-04 18:06:27 +02001797 async getCommentedCode() {
Milutin Kristofic1d219672022-06-21 14:57:25 +02001798 assertIsDefined(this.comment, 'comment');
1799 assertIsDefined(this.changeNum, 'changeNum');
Milutin Kristofic1d219672022-06-21 14:57:25 +02001800 const file = await this.restApiService.getFileContent(
1801 this.changeNum,
1802 this.comment.path!,
1803 this.comment.patch_set!
1804 );
Ben Rohlfs23843882022-08-04 18:06:27 +02001805 assert(
1806 !!file && isBase64FileContent(file) && !!file.content,
1807 'file content for comment not found'
1808 );
Milutin Kristofic1d219672022-06-21 14:57:25 +02001809 const line = getContentInCommentRange(file.content, this.comment);
Ben Rohlfs23843882022-08-04 18:06:27 +02001810 assert(!!line, 'file content for comment not found');
1811 return line;
Milutin Kristofic1d219672022-06-21 14:57:25 +02001812 }
1813
Ben Rohlfs05750b92021-10-29 08:23:08 +02001814 // private, but visible for testing
1815 cancel() {
1816 assertIsDefined(this.comment, 'comment');
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001817 assert(isDraft(this.comment), 'only drafts are editable');
Ben Rohlfs2e237552021-11-24 10:34:28 +01001818 this.messageText = this.originalMessage;
1819 this.unresolved = this.originalUnresolved;
1820 this.save();
Ben Rohlfs05750b92021-10-29 08:23:08 +02001821 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001822
Ben Rohlfs2e237552021-11-24 10:34:28 +01001823 async autoSave() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001824 if (isSaving(this.comment) || this.autoSaving) return;
Ben Rohlfs2e237552021-11-24 10:34:28 +01001825 if (!this.editing || !this.comment) return;
Chris Poucet77226982023-08-10 18:10:04 +02001826 if (this.disableAutoSaving) return;
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001827 assert(isDraft(this.comment), 'only drafts are editable');
Ben Rohlfs2e237552021-11-24 10:34:28 +01001828 const messageToSave = this.messageText.trimEnd();
1829 if (messageToSave === '') return;
Milutin Kristoficd4274a12024-02-19 20:30:02 +01001830 if (!this.somethingToSave()) return;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001831
Ben Rohlfs2e237552021-11-24 10:34:28 +01001832 try {
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001833 this.autoSaving = this.rawSave({showToast: false});
Ben Rohlfs2e237552021-11-24 10:34:28 +01001834 await this.autoSaving;
1835 } finally {
1836 this.autoSaving = undefined;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001837 }
Ben Rohlfs2e237552021-11-24 10:34:28 +01001838 }
1839
1840 async discard() {
1841 this.messageText = '';
1842 await this.save();
1843 }
1844
Chris Poucet063fa502023-11-21 10:06:35 +01001845 async convertToCommentInputAndOrDiscard(): Promise<CommentInput | undefined> {
Chris Poucet77226982023-08-10 18:10:04 +02001846 if (!this.somethingToSave() || !this.comment) return;
Chris Poucet063fa502023-11-21 10:06:35 +01001847 const messageToSave = this.messageText.trimEnd();
1848 if (messageToSave === '') {
1849 await this.getCommentsModel().discardDraft(id(this.comment));
1850 return undefined;
1851 } else {
1852 return convertToCommentInput({
1853 ...this.comment,
1854 message: this.messageText.trimEnd(),
1855 unresolved: this.unresolved,
1856 });
1857 }
Chris Poucet77226982023-08-10 18:10:04 +02001858 }
1859
Ben Rohlfs2e237552021-11-24 10:34:28 +01001860 async save() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001861 assert(isDraft(this.comment), 'only drafts are editable');
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001862 // There is a minimal chance of `isSaving()` being false between iterations
1863 // of the below while loop. But this will be extremely rare and just lead
1864 // to a harmless assertion error. So let's not bother.
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001865 if (isSaving(this.comment) && !this.autoSaving) return;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001866
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001867 if (!this.permanentEditingMode) {
1868 this.editing = false;
1869 }
1870 if (this.autoSaving) {
1871 this.comment = await this.autoSaving;
1872 }
1873 // Depending on whether `messageToSave` is empty we treat this either as
1874 // a discard or a save action.
1875 const messageToSave = this.messageText.trimEnd();
1876 if (messageToSave === '') {
Ben Rohlfs0d9d0c32023-04-20 18:12:06 +02001877 if (!this.permanentEditingMode || this.somethingToSave()) {
1878 await this.getCommentsModel().discardDraft(id(this.comment));
1879 }
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001880 } else {
1881 // No need to make a backend call when nothing has changed.
1882 while (this.somethingToSave()) {
Milutin Kristoficc0582fc2024-04-02 20:30:51 +02001883 this.trackGeneratedSuggestionEdit();
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001884 this.comment = await this.rawSave({showToast: true});
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001885 if (isError(this.comment)) return;
Ben Rohlfs607126f2021-12-07 08:21:52 +01001886 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001887 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001888 }
1889
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001890 private somethingToSave() {
1891 if (!this.comment) return false;
1892 return (
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001893 isError(this.comment) ||
Milutin Kristoficd0fc9e82024-04-17 14:29:13 +02001894 this.messageText.trimEnd() !== this.comment.message ||
Milutin Kristoficd4274a12024-02-19 20:30:02 +01001895 this.unresolved !== this.comment.unresolved ||
Milutin Kristofic847c46d2024-04-15 13:43:06 +02001896 this.isFixSuggestionChanged()
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001897 );
1898 }
1899
Ben Rohlfs2e237552021-11-24 10:34:28 +01001900 /** For sharing between save() and autoSave(). */
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001901 private rawSave(options: {showToast: boolean}) {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001902 assert(isDraft(this.comment), 'only drafts are editable');
1903 assert(!isSaving(this.comment), 'saving already in progress');
Milutin Kristofic847c46d2024-04-15 13:43:06 +02001904 const draft: DraftInfo = {
1905 ...this.comment,
1906 message: this.messageText.trimEnd(),
1907 unresolved: this.unresolved,
1908 };
1909 if (this.isFixSuggestionChanged()) {
1910 draft.fix_suggestions = this.getFixSuggestions();
1911 }
Ben Rohlfsdd88cd92024-05-08 13:29:39 +02001912 this.reportHintInteractionSaved();
Milutin Kristofic847c46d2024-04-15 13:43:06 +02001913 return this.getCommentsModel().saveDraft(draft, options.showToast);
1914 }
1915
1916 isFixSuggestionChanged(): boolean {
1917 // Check to not change fix suggestion when draft is not being edited only
1918 // when user quickly disable generating suggestions and click save
1919 if (!this.editing && this.generateSuggestion) return false;
1920 return !deepEqual(this.comment?.fix_suggestions, this.getFixSuggestions());
Ben Rohlfs2e237552021-11-24 10:34:28 +01001921 }
1922
Milutin Kristoficd4274a12024-02-19 20:30:02 +01001923 getFixSuggestions(): FixSuggestionInfo[] | undefined {
1924 if (!this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2))
1925 return undefined;
1926 if (!this.generateSuggestion) return undefined;
1927 if (!this.generatedFixSuggestion) return undefined;
Milutin Kristoficd0fc9e82024-04-17 14:29:13 +02001928 // Disable fix suggestions when the comment already has a user suggestion
1929 if (this.comment && hasUserSuggestion(this.comment)) return undefined;
Milutin Kristofic847c46d2024-04-15 13:43:06 +02001930 // we ignore fixSuggestions until they are previewed.
1931 if (
1932 this.suggestionDiffPreview &&
1933 !this.suggestionDiffPreview?.previewed &&
1934 !this.suggestionLoading
1935 )
1936 return undefined;
Milutin Kristoficd4274a12024-02-19 20:30:02 +01001937 return [this.generatedFixSuggestion];
1938 }
1939
Ben Rohlfs05750b92021-10-29 08:23:08 +02001940 private handleToggleResolved() {
1941 this.unresolved = !this.unresolved;
Dhruv Srivastava73f9edc2021-12-02 11:23:27 +01001942 if (!this.editing) {
1943 // messageText is only assigned a value if the comment reaches editing
1944 // state, however it is possible that the user toggles the resolved state
1945 // without editing the comment in which case we assign the correct value
1946 // to messageText here
1947 this.messageText = this.comment?.message ?? '';
1948 this.save();
1949 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001950 }
1951
Kamil Musin9c8833a2022-12-29 12:05:08 +01001952 private openDeleteCommentModal() {
1953 this.confirmDeleteModal?.showModal();
Kamil Musinc7d3f282022-12-29 13:27:55 +01001954 whenVisible(this.confirmDeleteDialog!, () => {
1955 this.confirmDeleteDialog!.resetFocus();
1956 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001957 }
1958
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301959 private closeDeleteCommentModal() {
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301960 this.confirmDeleteModal?.close();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001961 }
1962
Ben Rohlfs05750b92021-10-29 08:23:08 +02001963 /**
1964 * Deleting a *published* comment is an admin feature. It means more than just
1965 * discarding a draft.
Ben Rohlfs05750b92021-10-29 08:23:08 +02001966 */
1967 // private, but visible for testing
Kamil Musind88622f2023-01-02 11:52:57 +01001968 async handleConfirmDeleteComment() {
Kamil Musinc7d3f282022-12-29 13:27:55 +01001969 if (!this.confirmDeleteDialog || !this.confirmDeleteDialog.message) {
Milutin Kristoficafae0052020-09-17 10:38:08 +02001970 throw new Error('missing confirm delete dialog');
1971 }
Ben Rohlfs05750b92021-10-29 08:23:08 +02001972 assertIsDefined(this.changeNum, 'changeNum');
1973 assertIsDefined(this.comment, 'comment');
Kamil Musind88622f2023-01-02 11:52:57 +01001974
1975 await this.getCommentsModel().deleteComment(
1976 this.changeNum,
1977 this.comment,
Kamil Musinc7d3f282022-12-29 13:27:55 +01001978 this.confirmDeleteDialog.message
Kamil Musind88622f2023-01-02 11:52:57 +01001979 );
1980 this.closeDeleteCommentModal();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001981 }
Milutin Kristoficc0582fc2024-04-02 20:30:51 +02001982
1983 private trackGeneratedSuggestionEdit() {
Milutin Kristofic23f923a2024-04-04 14:05:32 +02001984 const hasUserSuggestion = this.messageText.includes(
1985 USER_SUGGESTION_START_PATTERN
1986 );
Milutin Kristoficc0582fc2024-04-02 20:30:51 +02001987 const wasGeneratedSuggestionEdited =
1988 this.addedGeneratedSuggestion &&
Milutin Kristofic23f923a2024-04-04 14:05:32 +02001989 hasUserSuggestion &&
Milutin Kristoficc0582fc2024-04-02 20:30:51 +02001990 !this.messageText.includes(this.addedGeneratedSuggestion);
1991 if (wasGeneratedSuggestionEdited) {
1992 this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_EDITED, {
1993 uuid: this.generatedSuggestionId,
1994 commentId: this.comment?.id ?? '',
1995 });
1996 this.addedGeneratedSuggestion = undefined;
1997 }
1998 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001999}
2000
Milutin Kristoficafae0052020-09-17 10:38:08 +02002001declare global {
2002 interface HTMLElementTagNameMap {
2003 'gr-comment': GrComment;
2004 }
Ben Rohlfs5b3c6552023-02-18 13:02:46 +01002005 interface HTMLElementEventMap {
2006 'copy-comment-link': CustomEvent<{}>;
2007 }
Milutin Kristoficafae0052020-09-17 10:38:08 +02002008}