| /** |
| * @license |
| * Copyright 2015 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea'; |
| import '../../../styles/shared-styles'; |
| import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator'; |
| import '../../plugins/gr-endpoint-param/gr-endpoint-param'; |
| import '../gr-button/gr-button'; |
| import '../gr-dialog/gr-dialog'; |
| import '../gr-formatted-text/gr-formatted-text'; |
| import '../gr-icon/gr-icon'; |
| import '../gr-overlay/gr-overlay'; |
| import '../gr-textarea/gr-textarea'; |
| import '../gr-tooltip-content/gr-tooltip-content'; |
| import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog'; |
| import '../gr-account-label/gr-account-label'; |
| import {getAppContext} from '../../../services/app-context'; |
| import {css, html, LitElement, nothing, PropertyValues} from 'lit'; |
| import {customElement, property, query, state} from 'lit/decorators.js'; |
| import {resolve} from '../../../models/dependency'; |
| import {GrTextarea} from '../gr-textarea/gr-textarea'; |
| import {GrOverlay} from '../gr-overlay/gr-overlay'; |
| import { |
| AccountDetailInfo, |
| CommentLinks, |
| NumericChangeId, |
| RepoName, |
| RobotCommentInfo, |
| } from '../../../types/common'; |
| import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog'; |
| import { |
| Comment, |
| createUserFixSuggestion, |
| DraftInfo, |
| getContentInCommentRange, |
| getUserSuggestion, |
| hasUserSuggestion, |
| isDraftOrUnsaved, |
| isRobot, |
| isUnsaved, |
| NEWLINE_PATTERN, |
| USER_SUGGESTION_START_PATTERN, |
| } from '../../../utils/comment-util'; |
| import { |
| OpenFixPreviewEventDetail, |
| ReplyToCommentEventDetail, |
| ValueChangedEvent, |
| } from '../../../types/events'; |
| import {fire, fireEvent} from '../../../utils/event-util'; |
| import {assertIsDefined, assert} from '../../../utils/common-util'; |
| import {Key, Modifier} from '../../../utils/dom-util'; |
| import {commentsModelToken} from '../../../models/comments/comments-model'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {subscribe} from '../../lit/subscription-controller'; |
| import {ShortcutController} from '../../lit/shortcut-controller'; |
| import {classMap} from 'lit/directives/class-map.js'; |
| import {LineNumber} from '../../../api/diff'; |
| import {CommentSide, SpecialFilePath} from '../../../constants/constants'; |
| import {Subject} from 'rxjs'; |
| import {debounceTime} from 'rxjs/operators'; |
| import {configModelToken} from '../../../models/config/config-model'; |
| import {changeModelToken} from '../../../models/change/change-model'; |
| import {Interaction} from '../../../constants/reporting'; |
| import {KnownExperimentId} from '../../../services/flags/flags'; |
| import {isBase64FileContent} from '../../../api/rest-api'; |
| import {createDiffUrl} from '../../../models/views/diff'; |
| |
| const UNSAVED_MESSAGE = 'Unable to save draft'; |
| |
| const FILE = 'FILE'; |
| |
| // visible for testing |
| export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000; |
| |
| export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE; |
| |
| declare global { |
| interface HTMLElementEventMap { |
| 'comment-editing-changed': CustomEvent<CommentEditingChangedDetail>; |
| 'comment-unresolved-changed': ValueChangedEvent<boolean>; |
| 'comment-text-changed': ValueChangedEvent<string>; |
| 'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>; |
| } |
| } |
| |
| export interface CommentAnchorTapEventDetail { |
| number: LineNumber; |
| side?: CommentSide; |
| } |
| |
| export interface CommentEditingChangedDetail { |
| editing: boolean; |
| path: string; |
| } |
| |
| @customElement('gr-comment') |
| export class GrComment extends LitElement { |
| /** |
| * Fired when the parent thread component should create a reply. |
| * |
| * @event reply-to-comment |
| */ |
| |
| /** |
| * Fired when the open fix preview action is triggered. |
| * |
| * @event open-fix-preview |
| */ |
| |
| /** |
| * Fired when editing status changed. |
| * |
| * @event comment-editing-changed |
| */ |
| |
| /** |
| * Fired when the comment's timestamp is tapped. |
| * |
| * @event comment-anchor-tap |
| */ |
| |
| @query('#editTextarea') |
| textarea?: GrTextarea; |
| |
| @query('#container') |
| container?: HTMLElement; |
| |
| @query('#resolvedCheckbox') |
| resolvedCheckbox?: HTMLInputElement; |
| |
| @query('#confirmDeleteOverlay') |
| confirmDeleteOverlay?: GrOverlay; |
| |
| @property({type: Object}) |
| comment?: Comment; |
| |
| // TODO: Move this out of gr-comment. gr-comment should not have a comments |
| // property. This is only used for hasHumanReply at the moment. |
| @property({type: Array}) |
| comments?: Comment[]; |
| |
| /** |
| * Initial collapsed state of the comment. |
| */ |
| @property({type: Boolean, attribute: 'initially-collapsed'}) |
| initiallyCollapsed?: boolean; |
| |
| /** |
| * Hide the header for patchset level comments used in GrReplyDialog. |
| */ |
| @property({type: Boolean, attribute: 'hide-header'}) |
| hideHeader = false; |
| |
| /** |
| * This is the *current* (internal) collapsed state of the comment. Do not set |
| * from the outside. Use `initiallyCollapsed` instead. This is just a |
| * reflected property such that css rules can be based on it. |
| */ |
| @property({type: Boolean, reflect: true}) |
| collapsed?: boolean; |
| |
| @property({type: Boolean, attribute: 'robot-button-disabled'}) |
| robotButtonDisabled = false; |
| |
| @property({type: String}) |
| messagePlaceholder?: string; |
| |
| /* private, but used in css rules */ |
| @property({type: Boolean, reflect: true}) |
| saving = false; |
| |
| // GrReplyDialog requires the patchset level comment to always remain |
| // editable. |
| @property({type: Boolean, attribute: 'permanent-editing-mode'}) |
| permanentEditingMode = false; |
| |
| /** |
| * `saving` and `autoSaving` are separate and cannot be set at the same time. |
| * `saving` affects the UI state (disabled buttons, etc.) and eventually |
| * leaves editing mode, but `autoSaving` just happens in the background |
| * without the user noticing. |
| */ |
| @state() |
| autoSaving?: Promise<DraftInfo>; |
| |
| @state() |
| changeNum?: NumericChangeId; |
| |
| @state() |
| editing = false; |
| |
| @state() |
| commentLinks: CommentLinks = {}; |
| |
| @state() |
| repoName?: RepoName; |
| |
| /* The 'dirty' state of the comment.message, which will be saved on demand. */ |
| @state() |
| messageText = ''; |
| |
| /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */ |
| @state() |
| unresolved = true; |
| |
| @property({type: Boolean}) |
| showConfirmDeleteOverlay = false; |
| |
| @property({type: Boolean}) |
| unableToSave = false; |
| |
| @property({type: Boolean, attribute: 'show-patchset'}) |
| showPatchset = false; |
| |
| @property({type: Boolean, attribute: 'show-ported-comment'}) |
| showPortedComment = false; |
| |
| @state() |
| account?: AccountDetailInfo; |
| |
| @state() |
| isAdmin = false; |
| |
| @state() |
| isOwner = false; |
| |
| private readonly restApiService = getAppContext().restApiService; |
| |
| private readonly reporting = getAppContext().reportingService; |
| |
| private readonly flagsService = getAppContext().flagsService; |
| |
| private readonly getChangeModel = resolve(this, changeModelToken); |
| |
| // Private but used in tests. |
| readonly getCommentsModel = resolve(this, commentsModelToken); |
| |
| private readonly userModel = getAppContext().userModel; |
| |
| private readonly configModel = resolve(this, configModelToken); |
| |
| private readonly shortcuts = new ShortcutController(this); |
| |
| /** |
| * This is triggered when the user types into the editing textarea. We then |
| * debounce it and call autoSave(). |
| */ |
| private autoSaveTrigger$ = new Subject(); |
| |
| /** |
| * Set to the content of DraftInfo when entering editing mode. |
| * Only used for "Cancel". |
| */ |
| private originalMessage = ''; |
| |
| /** |
| * Set to the content of DraftInfo when entering editing mode. |
| * Only used for "Cancel". |
| */ |
| private originalUnresolved = false; |
| |
| constructor() { |
| super(); |
| // Allow the shortcuts to bubble up so that GrReplyDialog can respond to |
| // them as well. |
| this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc(), { |
| preventDefault: false, |
| }); |
| for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) { |
| this.shortcuts.addLocal( |
| {key: Key.ENTER, modifiers: [modifier]}, |
| () => { |
| this.save(); |
| }, |
| {preventDefault: false} |
| ); |
| } |
| // For Ctrl+s add shorctut with preventDefault so that it does |
| // not bubble up to the browser |
| for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) { |
| this.shortcuts.addLocal({key: 's', modifiers: [modifier]}, () => { |
| this.save(); |
| }); |
| } |
| if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) { |
| this.messagePlaceholder = 'Mention others with @'; |
| } |
| subscribe( |
| this, |
| () => this.configModel().repoCommentLinks$, |
| x => (this.commentLinks = x) |
| ); |
| subscribe( |
| this, |
| () => this.userModel.account$, |
| x => (this.account = x) |
| ); |
| subscribe( |
| this, |
| () => this.userModel.isAdmin$, |
| x => (this.isAdmin = x) |
| ); |
| |
| subscribe( |
| this, |
| () => this.getChangeModel().repo$, |
| x => (this.repoName = x) |
| ); |
| subscribe( |
| this, |
| () => this.getChangeModel().changeNum$, |
| x => (this.changeNum = x) |
| ); |
| subscribe( |
| this, |
| () => this.getChangeModel().isOwner$, |
| x => (this.isOwner = x) |
| ); |
| subscribe( |
| this, |
| () => |
| this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)), |
| () => { |
| this.autoSave(); |
| } |
| ); |
| } |
| |
| override disconnectedCallback() { |
| // Clean up emoji dropdown. |
| if (this.textarea) this.textarea.closeDropdown(); |
| if (this.editing) { |
| this.reporting.reportInteraction( |
| Interaction.COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED |
| ); |
| } |
| super.disconnectedCallback(); |
| } |
| |
| static override get styles() { |
| return [ |
| sharedStyles, |
| css` |
| :host { |
| display: block; |
| font-family: var(--font-family); |
| padding: var(--spacing-m); |
| } |
| :host([collapsed]) { |
| padding: var(--spacing-s) var(--spacing-m); |
| } |
| :host([saving]) { |
| pointer-events: none; |
| } |
| :host([saving]) .actions, |
| :host([saving]) .robotActions, |
| :host([saving]) .date { |
| opacity: 0.5; |
| } |
| .header { |
| align-items: center; |
| cursor: pointer; |
| display: flex; |
| padding-bottom: var(--spacing-m); |
| } |
| :host([collapsed]) .header { |
| padding-bottom: 0px; |
| } |
| .headerLeft > span { |
| font-weight: var(--font-weight-bold); |
| } |
| .headerMiddle { |
| color: var(--deemphasized-text-color); |
| flex: 1; |
| overflow: hidden; |
| } |
| .draftTooltip { |
| font-weight: var(--font-weight-bold); |
| display: inline; |
| } |
| .draftTooltip gr-icon { |
| color: var(--info-foreground); |
| } |
| .date { |
| justify-content: flex-end; |
| text-align: right; |
| white-space: nowrap; |
| } |
| span.date { |
| color: var(--deemphasized-text-color); |
| } |
| span.date:hover { |
| text-decoration: underline; |
| } |
| .actions, |
| .robotActions { |
| display: flex; |
| justify-content: flex-end; |
| padding-top: 0; |
| } |
| .robotActions { |
| /* Better than the negative margin would be to remove the gr-button |
| * padding, but then we would also need to fix the buttons that are |
| * inserted by plugins. :-/ */ |
| margin: 4px 0 -4px; |
| } |
| .action { |
| margin-left: var(--spacing-l); |
| } |
| .rightActions { |
| display: flex; |
| justify-content: flex-end; |
| } |
| .rightActions gr-button { |
| --gr-button-padding: 0 var(--spacing-s); |
| } |
| .editMessage { |
| display: block; |
| margin-bottom: var(--spacing-m); |
| width: 100%; |
| } |
| .show-hide { |
| margin-left: var(--spacing-s); |
| } |
| .robotId { |
| color: var(--deemphasized-text-color); |
| margin-bottom: var(--spacing-m); |
| } |
| .robotRun { |
| margin-left: var(--spacing-m); |
| } |
| .robotRunLink { |
| margin-left: var(--spacing-m); |
| } |
| /* just for a11y */ |
| input.show-hide { |
| display: none; |
| } |
| label.show-hide { |
| cursor: pointer; |
| display: block; |
| } |
| label.show-hide gr-icon { |
| vertical-align: top; |
| } |
| :host([collapsed]) #container .body { |
| padding-top: 0; |
| } |
| #container .collapsedContent { |
| display: block; |
| overflow: hidden; |
| padding-left: var(--spacing-m); |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| } |
| .resolve, |
| .unresolved { |
| align-items: center; |
| display: flex; |
| flex: 1; |
| margin: 0; |
| } |
| .resolve label { |
| color: var(--comment-text-color); |
| } |
| gr-dialog .main { |
| display: flex; |
| flex-direction: column; |
| width: 100%; |
| } |
| #deleteBtn { |
| --gr-button-text-color: var(--deemphasized-text-color); |
| --gr-button-padding: 0; |
| } |
| |
| /** Disable select for the caret and actions */ |
| .actions, |
| .show-hide { |
| -webkit-user-select: none; |
| -moz-user-select: none; |
| -ms-user-select: none; |
| user-select: none; |
| } |
| |
| .pointer { |
| cursor: pointer; |
| } |
| .patchset-text { |
| color: var(--deemphasized-text-color); |
| margin-left: var(--spacing-s); |
| } |
| .headerLeft gr-account-label { |
| --account-max-length: 130px; |
| width: 150px; |
| } |
| .headerLeft gr-account-label::part(gr-account-label-text) { |
| font-weight: var(--font-weight-bold); |
| } |
| .draft gr-account-label { |
| width: unset; |
| } |
| .draft gr-formatted-text.message { |
| margin-bottom: var(--spacing-m); |
| } |
| .portedMessage { |
| margin: 0 var(--spacing-m); |
| } |
| .link-icon { |
| margin-left: var(--spacing-m); |
| cursor: pointer; |
| } |
| `, |
| ]; |
| } |
| |
| override render() { |
| if (isUnsaved(this.comment) && !this.editing) return; |
| const classes = {container: true, draft: isDraftOrUnsaved(this.comment)}; |
| return html` |
| <div id="container" class=${classMap(classes)}> |
| ${this.renderHeader()} |
| <div class="body"> |
| ${this.renderRobotAuthor()} ${this.renderEditingTextarea()} |
| ${this.renderCommentMessage()} ${this.renderHumanActions()} |
| ${this.renderRobotActions()} ${this.renderSuggestEditActions()} |
| </div> |
| </div> |
| ${this.renderConfirmDialog()} |
| `; |
| } |
| |
| private renderHeader() { |
| if (this.hideHeader) return nothing; |
| return html` |
| <div |
| class="header" |
| id="header" |
| @click=${() => (this.collapsed = !this.collapsed)} |
| > |
| <div class="headerLeft"> |
| ${this.renderAuthor()} ${this.renderPortedCommentMessage()} |
| ${this.renderDraftLabel()} |
| </div> |
| <div class="headerMiddle">${this.renderCollapsedContent()}</div> |
| ${this.renderRunDetails()} ${this.renderDeleteButton()} |
| ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()} |
| </div> |
| `; |
| } |
| |
| private renderAuthor() { |
| if (isDraftOrUnsaved(this.comment)) return; |
| if (isRobot(this.comment)) { |
| const id = this.comment.robot_id; |
| return html`<span class="robotName">${id}</span>`; |
| } |
| const classes = {draft: isDraftOrUnsaved(this.comment)}; |
| return html` |
| <gr-account-label |
| .account=${this.comment?.author ?? this.account} |
| class=${classMap(classes)} |
| > |
| </gr-account-label> |
| `; |
| } |
| |
| private renderPortedCommentMessage() { |
| if (!this.showPortedComment) return; |
| if (!this.comment?.patch_set) return; |
| return html` |
| <a href=${this.getUrlForComment()}> |
| <span class="portedMessage" @click=${this.handlePortedMessageClick}> |
| From patchset ${this.comment?.patch_set} |
| </span> |
| </a> |
| `; |
| } |
| |
| private renderDraftLabel() { |
| if (!isDraftOrUnsaved(this.comment)) return; |
| let label = 'Draft'; |
| let tooltip = |
| 'This draft is only visible to you. ' + |
| "To publish drafts, click the 'Reply' or 'Start review' button " + |
| "at the top of the change or press the 'a' key."; |
| if (this.unableToSave) { |
| label += ' (Failed to save)'; |
| tooltip = 'Unable to save draft. Please try to save again.'; |
| } |
| return html` |
| <gr-tooltip-content |
| class="draftTooltip" |
| has-tooltip |
| title=${tooltip} |
| max-width="20em" |
| > |
| <gr-icon filled icon="rate_review"></gr-icon> |
| <span class="draftLabel">${label}</span> |
| </gr-tooltip-content> |
| `; |
| } |
| |
| private renderCollapsedContent() { |
| if (!this.collapsed) return; |
| return html` |
| <span class="collapsedContent">${this.comment?.message}</span> |
| `; |
| } |
| |
| private renderRunDetails() { |
| if (!isRobot(this.comment)) return; |
| if (!this.comment?.url || this.collapsed) return; |
| return html` |
| <div class="runIdMessage message"> |
| <div class="runIdInformation"> |
| <a class="robotRunLink" href=${this.comment.url}> |
| <span class="robotRun link">Run Details</span> |
| </a> |
| </div> |
| </div> |
| `; |
| } |
| |
| /** |
| * Deleting a comment is an admin feature. It means more than just discarding |
| * a draft. It is an action applied to published comments. |
| */ |
| private renderDeleteButton() { |
| if ( |
| !this.isAdmin || |
| isDraftOrUnsaved(this.comment) || |
| isRobot(this.comment) |
| ) |
| return; |
| if (this.collapsed) return; |
| return html` |
| <gr-button |
| id="deleteBtn" |
| title="Delete Comment" |
| link |
| class="action delete" |
| @click=${this.openDeleteCommentOverlay} |
| > |
| <gr-icon id="icon" icon="delete" filled></gr-icon> |
| </gr-button> |
| `; |
| } |
| |
| private renderPatchset() { |
| if (!this.showPatchset) return; |
| assertIsDefined(this.comment?.patch_set, 'comment.patch_set'); |
| return html` |
| <span class="patchset-text"> Patchset ${this.comment.patch_set}</span> |
| `; |
| } |
| |
| private renderDate() { |
| if (!this.comment?.updated || this.collapsed) return; |
| return html` |
| <span class="separator"></span> |
| <span class="date" tabindex="0" @click=${this.handleAnchorClick}> |
| <gr-date-formatter |
| withTooltip |
| .dateStr=${this.comment.updated} |
| ></gr-date-formatter> |
| </span> |
| `; |
| } |
| |
| private renderToggle() { |
| const icon = this.collapsed ? 'expand_more' : 'expand_less'; |
| const ariaLabel = this.collapsed ? 'Expand' : 'Collapse'; |
| return html` |
| <div class="show-hide" tabindex="0"> |
| <label class="show-hide" aria-label=${ariaLabel}> |
| <input |
| type="checkbox" |
| class="show-hide" |
| ?checked=${this.collapsed} |
| @change=${() => (this.collapsed = !this.collapsed)} |
| /> |
| <gr-icon icon=${icon} id="icon"></gr-icon> |
| </label> |
| </div> |
| `; |
| } |
| |
| private renderRobotAuthor() { |
| if (!isRobot(this.comment) || this.collapsed) return; |
| return html`<div class="robotId">${this.comment.author?.name}</div>`; |
| } |
| |
| private renderEditingTextarea() { |
| if (!this.editing || this.collapsed) return; |
| return html` |
| <gr-textarea |
| id="editTextarea" |
| class="editMessage" |
| autocomplete="on" |
| code="" |
| ?disabled=${this.saving} |
| rows="4" |
| .placeholder=${this.messagePlaceholder} |
| text=${this.messageText} |
| @text-changed=${(e: ValueChangedEvent) => { |
| // TODO: This is causing a re-render of <gr-comment> on every key |
| // press. Try to avoid always setting `this.messageText` or at least |
| // debounce it. Most of the code can just inspect the current value |
| // of the textare instead of needing a dedicated property. |
| this.messageText = e.detail.value; |
| this.autoSaveTrigger$.next(); |
| }} |
| ></gr-textarea> |
| `; |
| } |
| |
| private renderCommentMessage() { |
| if (this.collapsed || this.editing) return; |
| |
| return html` |
| <!--The "message" class is needed to ensure selectability from |
| gr-diff-selection.--> |
| <gr-formatted-text |
| class="message" |
| .content=${this.comment?.message} |
| .config=${this.commentLinks} |
| ></gr-formatted-text> |
| `; |
| } |
| |
| private renderCopyLinkIcon() { |
| // Only show the icon when the thread contains a published comment. |
| if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return; |
| return html` |
| <gr-icon |
| icon="link" |
| class="copy link-icon" |
| @click=${this.handleCopyLink} |
| title="Copy link to this comment" |
| role="button" |
| tabindex="0" |
| ></gr-icon> |
| `; |
| } |
| |
| private renderHumanActions() { |
| if (!this.account || isRobot(this.comment)) return; |
| if (this.collapsed || !isDraftOrUnsaved(this.comment)) return; |
| return html` |
| <div class="actions"> |
| <div class="action resolve"> |
| <label> |
| <input |
| type="checkbox" |
| id="resolvedCheckbox" |
| ?checked=${!this.unresolved} |
| @change=${this.handleToggleResolved} |
| /> |
| Resolved |
| </label> |
| </div> |
| ${this.renderDraftActions()} |
| </div> |
| `; |
| } |
| |
| private renderDraftActions() { |
| if (!isDraftOrUnsaved(this.comment)) return; |
| return html` |
| <div class="rightActions"> |
| ${this.autoSaving ? html`. ` : ''} |
| ${this.renderDiscardButton()} ${this.renderSuggestEditButton()} |
| ${this.renderPreviewSuggestEditButton()} ${this.renderEditButton()} |
| ${this.renderCancelButton()} ${this.renderSaveButton()} |
| ${this.renderCopyLinkIcon()} |
| </div> |
| `; |
| } |
| |
| private renderPreviewSuggestEditButton() { |
| if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) { |
| return nothing; |
| } |
| assertIsDefined(this.comment, 'comment'); |
| if (!hasUserSuggestion(this.comment)) return nothing; |
| return html` |
| <gr-button |
| link |
| secondary |
| class="action show-fix" |
| ?disabled=${this.saving} |
| @click=${this.handleShowFix} |
| > |
| Preview Fix |
| </gr-button> |
| `; |
| } |
| |
| private renderSuggestEditButton() { |
| if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) { |
| return nothing; |
| } |
| if ( |
| this.permanentEditingMode || |
| this.comment?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS |
| ) { |
| return nothing; |
| } |
| assertIsDefined(this.comment, 'comment'); |
| if (hasUserSuggestion(this.comment)) return nothing; |
| // TODO(milutin): remove this check once suggesting on commit message is |
| // fixed. Currently diff line doesn't match commit message line, because |
| // of metadata in diff, which aren't in content api request. |
| if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing; |
| if (this.isOwner) return nothing; |
| return html`<gr-button |
| link |
| class="action suggestEdit" |
| @click=${this.createSuggestEdit} |
| >Suggest Fix</gr-button |
| >`; |
| } |
| |
| private renderDiscardButton() { |
| if (this.editing || this.permanentEditingMode) return; |
| return html`<gr-button |
| link |
| ?disabled=${this.saving} |
| class="action discard" |
| @click=${this.discard} |
| >Discard</gr-button |
| >`; |
| } |
| |
| private renderEditButton() { |
| if (this.editing) return; |
| return html`<gr-button |
| link |
| ?disabled=${this.saving} |
| class="action edit" |
| @click=${this.edit} |
| >Edit</gr-button |
| >`; |
| } |
| |
| private renderCancelButton() { |
| if (!this.editing || this.permanentEditingMode) return; |
| return html` |
| <gr-button |
| link |
| ?disabled=${this.saving} |
| class="action cancel" |
| @click=${this.cancel} |
| >Cancel</gr-button |
| > |
| `; |
| } |
| |
| private renderSaveButton() { |
| if (!this.editing && !this.unableToSave) return; |
| return html` |
| <gr-button |
| link |
| ?disabled=${this.isSaveDisabled()} |
| class="action save" |
| @click=${this.handleSaveButtonClicked} |
| >${this.permanentEditingMode ? 'Preview' : 'Save'}</gr-button |
| > |
| `; |
| } |
| |
| private renderRobotActions() { |
| if (!this.account || !isRobot(this.comment)) return; |
| const endpoint = html` |
| <gr-endpoint-decorator name="robot-comment-controls"> |
| <gr-endpoint-param name="comment" .value=${this.comment}> |
| </gr-endpoint-param> |
| </gr-endpoint-decorator> |
| `; |
| return html` |
| <div class="robotActions"> |
| ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()} |
| ${this.renderPleaseFixButton()} |
| </div> |
| `; |
| } |
| |
| private renderSuggestEditActions() { |
| if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) { |
| return nothing; |
| } |
| if ( |
| !this.account || |
| isRobot(this.comment) || |
| isDraftOrUnsaved(this.comment) |
| ) { |
| return nothing; |
| } |
| return html` |
| <div class="robotActions">${this.renderPreviewSuggestEditButton()}</div> |
| `; |
| } |
| |
| private renderShowFixButton() { |
| if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return; |
| return html` |
| <gr-button |
| link |
| secondary |
| class="action show-fix" |
| ?disabled=${this.saving} |
| @click=${this.handleShowFix} |
| > |
| Show Fix |
| </gr-button> |
| `; |
| } |
| |
| private renderPleaseFixButton() { |
| if (this.hasHumanReply()) return; |
| return html` |
| <gr-button |
| link |
| ?disabled=${this.robotButtonDisabled} |
| class="action fix" |
| @click=${this.handlePleaseFix} |
| > |
| Please Fix |
| </gr-button> |
| `; |
| } |
| |
| private renderConfirmDialog() { |
| if (!this.showConfirmDeleteOverlay) return; |
| return html` |
| <gr-overlay id="confirmDeleteOverlay" with-backdrop> |
| <gr-confirm-delete-comment-dialog |
| id="confirmDeleteComment" |
| @confirm=${this.handleConfirmDeleteComment} |
| @cancel=${this.closeDeleteCommentOverlay} |
| > |
| </gr-confirm-delete-comment-dialog> |
| </gr-overlay> |
| `; |
| } |
| |
| private getUrlForComment() { |
| const comment = this.comment; |
| if (!comment || !this.changeNum || !this.repoName) return ''; |
| if (!comment.id) throw new Error('comment must have an id'); |
| return createDiffUrl({ |
| changeNum: this.changeNum, |
| project: this.repoName, |
| commentId: comment.id, |
| }); |
| } |
| |
| private firstWillUpdateDone = false; |
| |
| firstWillUpdate() { |
| if (this.firstWillUpdateDone) return; |
| this.firstWillUpdateDone = true; |
| if (this.permanentEditingMode) this.editing = true; |
| assertIsDefined(this.comment, 'comment'); |
| this.unresolved = this.comment.unresolved ?? true; |
| if (isUnsaved(this.comment)) this.editing = true; |
| if (isDraftOrUnsaved(this.comment)) { |
| this.reporting.reportInteraction( |
| Interaction.COMMENTS_AUTOCLOSE_FIRST_UPDATE, |
| {editing: this.editing, unsaved: isUnsaved(this.comment)} |
| ); |
| this.collapsed = false; |
| } else { |
| this.collapsed = !!this.initiallyCollapsed; |
| } |
| } |
| |
| override willUpdate(changed: PropertyValues) { |
| this.firstWillUpdate(); |
| if (changed.has('editing')) { |
| this.onEditingChanged(); |
| } |
| if (changed.has('unresolved')) { |
| // The <gr-comment-thread> component wants to change its color based on |
| // the (dirty) unresolved state, so let's notify it about changes. |
| fire(this, 'comment-unresolved-changed', {value: this.unresolved}); |
| } |
| if (changed.has('messageText')) { |
| // GrReplyDialog updates it's state when text inside patchset level |
| // comment changes. |
| fire(this, 'comment-text-changed', {value: this.messageText}); |
| } |
| } |
| |
| private handlePortedMessageClick() { |
| assertIsDefined(this.comment, 'comment'); |
| this.reporting.reportInteraction('navigate-to-original-comment', { |
| line: this.comment.line, |
| range: this.comment.range, |
| }); |
| } |
| |
| private handleCopyLink() { |
| fireEvent(this, 'copy-comment-link'); |
| } |
| |
| /** Enter editing mode. */ |
| private edit() { |
| if (!isDraftOrUnsaved(this.comment)) { |
| throw new Error('Cannot edit published comment.'); |
| } |
| if (this.editing) return; |
| this.editing = true; |
| } |
| |
| // TODO: Move this out of gr-comment. gr-comment should not have a comments |
| // property. |
| private hasHumanReply() { |
| if (!this.comment || !this.comments) return false; |
| return this.comments.some( |
| c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c) |
| ); |
| } |
| |
| // private, but visible for testing |
| async createFixPreview(): Promise<OpenFixPreviewEventDetail> { |
| assertIsDefined(this.comment?.patch_set, 'comment.patch_set'); |
| assertIsDefined(this.comment?.path, 'comment.path'); |
| |
| if (hasUserSuggestion(this.comment)) { |
| const replacement = getUserSuggestion(this.comment); |
| assert(!!replacement, 'malformed user suggestion'); |
| const line = await this.getCommentedCode(); |
| |
| return { |
| fixSuggestions: createUserFixSuggestion( |
| this.comment, |
| line, |
| replacement |
| ), |
| patchNum: this.comment.patch_set, |
| }; |
| } |
| if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) { |
| const id = this.comment.robot_id; |
| return { |
| fixSuggestions: this.comment.fix_suggestions.map(s => { |
| return { |
| ...s, |
| description: `${id ?? ''} - ${s.description ?? ''}`, |
| }; |
| }), |
| patchNum: this.comment.patch_set, |
| }; |
| } |
| throw new Error('unable to create preview fix event'); |
| } |
| |
| private onEditingChanged() { |
| if (this.editing) { |
| this.collapsed = false; |
| this.messageText = this.comment?.message ?? ''; |
| this.unresolved = this.comment?.unresolved ?? true; |
| this.originalMessage = this.messageText; |
| this.originalUnresolved = this.unresolved; |
| setTimeout(() => this.textarea?.putCursorAtEnd(), 1); |
| } |
| |
| // Parent components such as the reply dialog might be interested in whether |
| // come of their child components are in editing mode. |
| fire(this, 'comment-editing-changed', { |
| editing: this.editing, |
| path: this.comment?.path ?? '', |
| }); |
| } |
| |
| // private, but visible for testing |
| isSaveDisabled() { |
| assertIsDefined(this.comment, 'comment'); |
| if (this.saving) return true; |
| if (this.comment.unresolved !== this.unresolved) return false; |
| return !this.messageText?.trimEnd(); |
| } |
| |
| private handleEsc() { |
| // vim users don't like ESC to cancel/discard, so only do this when the |
| // comment text is empty. |
| if (!this.messageText?.trimEnd()) this.cancel(); |
| } |
| |
| private handleAnchorClick() { |
| assertIsDefined(this.comment, 'comment'); |
| fire(this, 'comment-anchor-tap', { |
| number: this.comment.line || FILE, |
| side: this.comment?.side, |
| }); |
| } |
| |
| private async handleSaveButtonClicked() { |
| await this.save(); |
| if (this.permanentEditingMode) { |
| this.editing = !this.editing; |
| } |
| } |
| |
| private handlePleaseFix() { |
| const message = this.comment?.message; |
| assert(!!message, 'empty message'); |
| const quoted = message.replace(NEWLINE_PATTERN, '\n> '); |
| const eventDetail: ReplyToCommentEventDetail = { |
| content: `> ${quoted}\n\nPlease fix.`, |
| userWantsToEdit: false, |
| unresolved: true, |
| }; |
| // Handled by <gr-comment-thread>. |
| fire(this, 'reply-to-comment', eventDetail); |
| } |
| |
| private async handleShowFix() { |
| // Handled top-level in the diff and change view components. |
| fire(this, 'open-fix-preview', await this.createFixPreview()); |
| } |
| |
| async createSuggestEdit() { |
| const line = await this.getCommentedCode(); |
| this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`; |
| } |
| |
| async getCommentedCode() { |
| assertIsDefined(this.comment, 'comment'); |
| assertIsDefined(this.changeNum, 'changeNum'); |
| // TODO(milutin): Show a toast while the file is being loaded. |
| // TODO(milutin): This should be moved into a service/model. |
| const file = await this.restApiService.getFileContent( |
| this.changeNum, |
| this.comment.path!, |
| this.comment.patch_set! |
| ); |
| assert( |
| !!file && isBase64FileContent(file) && !!file.content, |
| 'file content for comment not found' |
| ); |
| const line = getContentInCommentRange(file.content, this.comment); |
| assert(!!line, 'file content for comment not found'); |
| return line; |
| } |
| |
| // private, but visible for testing |
| cancel() { |
| assertIsDefined(this.comment, 'comment'); |
| if (!isDraftOrUnsaved(this.comment)) { |
| throw new Error('only unsaved and draft comments are editable'); |
| } |
| this.messageText = this.originalMessage; |
| this.unresolved = this.originalUnresolved; |
| this.save(); |
| } |
| |
| async autoSave() { |
| if (this.saving || this.autoSaving) return; |
| if (!this.editing || !this.comment) return; |
| if (!isDraftOrUnsaved(this.comment)) return; |
| const messageToSave = this.messageText.trimEnd(); |
| if (messageToSave === '') return; |
| if (messageToSave === this.comment.message) return; |
| |
| try { |
| this.autoSaving = this.rawSave(messageToSave, {showToast: false}); |
| await this.autoSaving; |
| } finally { |
| this.autoSaving = undefined; |
| } |
| } |
| |
| async discard() { |
| this.messageText = ''; |
| await this.save(); |
| } |
| |
| async save() { |
| if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft'); |
| |
| try { |
| this.saving = true; |
| this.unableToSave = false; |
| if (this.autoSaving) { |
| this.comment = await this.autoSaving; |
| } |
| // Depending on whether `messageToSave` is empty we treat this either as |
| // a discard or a save action. |
| const messageToSave = this.messageText.trimEnd(); |
| if (messageToSave === '') { |
| // Don't try to discard UnsavedInfo. Nothing to do then. |
| if (this.comment.id) { |
| await this.getCommentsModel().discardDraft(this.comment.id); |
| } |
| } else { |
| // No need to make a backend call when nothing has changed. |
| if ( |
| messageToSave !== this.comment?.message || |
| this.unresolved !== this.comment.unresolved |
| ) { |
| await this.rawSave(messageToSave, {showToast: true}); |
| } |
| } |
| this.reporting.reportInteraction( |
| Interaction.COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE |
| ); |
| if (!this.permanentEditingMode) { |
| this.editing = false; |
| } |
| } catch (e) { |
| this.unableToSave = true; |
| throw e; |
| } finally { |
| this.saving = false; |
| } |
| } |
| |
| /** For sharing between save() and autoSave(). */ |
| private rawSave(message: string, options: {showToast: boolean}) { |
| if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft'); |
| return this.getCommentsModel().saveDraft( |
| { |
| ...this.comment, |
| message, |
| unresolved: this.unresolved, |
| }, |
| options.showToast |
| ); |
| } |
| |
| private handleToggleResolved() { |
| this.unresolved = !this.unresolved; |
| if (!this.editing) { |
| // messageText is only assigned a value if the comment reaches editing |
| // state, however it is possible that the user toggles the resolved state |
| // without editing the comment in which case we assign the correct value |
| // to messageText here |
| this.messageText = this.comment?.message ?? ''; |
| this.save(); |
| } |
| } |
| |
| private async openDeleteCommentOverlay() { |
| this.showConfirmDeleteOverlay = true; |
| await this.updateComplete; |
| await this.confirmDeleteOverlay?.open(); |
| } |
| |
| private closeDeleteCommentOverlay() { |
| this.showConfirmDeleteOverlay = false; |
| this.confirmDeleteOverlay?.remove(); |
| this.confirmDeleteOverlay?.close(); |
| } |
| |
| /** |
| * Deleting a *published* comment is an admin feature. It means more than just |
| * discarding a draft. |
| * |
| * TODO: Also move this into the comments-service. |
| * TODO: Figure out a good reloading strategy when deleting was successful. |
| * `this.comment = newComment` does not seem sufficient. |
| */ |
| // private, but visible for testing |
| handleConfirmDeleteComment() { |
| const dialog = this.confirmDeleteOverlay?.querySelector( |
| '#confirmDeleteComment' |
| ) as GrConfirmDeleteCommentDialog | null; |
| if (!dialog || !dialog.message) { |
| throw new Error('missing confirm delete dialog'); |
| } |
| assertIsDefined(this.changeNum, 'changeNum'); |
| assertIsDefined(this.comment, 'comment'); |
| assertIsDefined(this.comment.patch_set, 'comment.patch_set'); |
| if (isDraftOrUnsaved(this.comment)) { |
| throw new Error('Admin deletion is only for published comments.'); |
| } |
| this.restApiService |
| .deleteComment( |
| this.changeNum, |
| this.comment.patch_set, |
| this.comment.id, |
| dialog.message |
| ) |
| .then(newComment => { |
| this.closeDeleteCommentOverlay(); |
| this.comment = newComment; |
| }); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-comment': GrComment; |
| } |
| } |