blob: 630095e97d24b7256b9fcb8a40a94efe8a9ad813 [file] [log] [blame]
/**
* @license
* Copyright 2015 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../styles/gr-a11y-styles';
import '../../../styles/shared-styles';
import '../gr-comment/gr-comment';
import '../../../embed/diff/gr-diff/gr-diff';
import '../gr-copy-clipboard/gr-copy-clipboard';
import {css, html, nothing, LitElement, PropertyValues} from 'lit';
import {customElement, property, query, queryAll, state} from 'lit/decorators';
import {
computeDiffFromContext,
isDraft,
isRobot,
Comment,
CommentThread,
getLastComment,
UnsavedInfo,
isDraftOrUnsaved,
createUnsavedComment,
getFirstComment,
createUnsavedReply,
isUnsaved,
} from '../../../utils/comment-util';
import {ChangeMessageId} from '../../../api/rest-api';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getAppContext} from '../../../services/app-context';
import {
createDefaultDiffPrefs,
SpecialFilePath,
} from '../../../constants/constants';
import {computeDisplayPath} from '../../../utils/path-list-util';
import {
AccountDetailInfo,
CommentRange,
NumericChangeId,
RepoName,
UrlEncodedCommentId,
} from '../../../types/common';
import {GrComment} from '../gr-comment/gr-comment';
import {FILE} from '../../../embed/diff/gr-diff/gr-diff-line';
import {GrButton} from '../gr-button/gr-button';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {DiffLayer, RenderPreferences} from '../../../api/diff';
import {assertIsDefined} from '../../../utils/common-util';
import {fire, fireAlert} from '../../../utils/event-util';
import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
import {getUserName} from '../../../utils/display-name-util';
import {generateAbsoluteUrl} from '../../../utils/url-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {a11yStyles} from '../../../styles/gr-a11y-styles';
import {subscribe} from '../../lit/subscription-controller';
import {repeat} from 'lit/directives/repeat';
import {classMap} from 'lit/directives/class-map';
import {ShortcutController} from '../../lit/shortcut-controller';
import {ValueChangedEvent} from '../../../types/events';
import {notDeepEqual} from '../../../utils/deep-util';
import {resolve} from '../../../models/dependency';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {changeModelToken} from '../../../models/change/change-model';
import {whenRendered} from '../../../utils/dom-util';
import {Interaction} from '../../../constants/reporting';
const NEWLINE_PATTERN = /\n/g;
declare global {
interface HTMLElementEventMap {
'comment-thread-editing-changed': ValueChangedEvent<boolean>;
}
}
/**
* gr-comment-thread exposes the following attributes that allow a
* diff widget like gr-diff to show the thread in the right location:
*
* line-num:
* 1-based line number or 'FILE' if it refers to the entire file.
*
* diff-side:
* "left" or "right". These indicate which of the two diffed versions
* the comment relates to. In the case of unified diff, the left
* version is the one whose line number column is further to the left.
*
* range:
* The range of text that the comment refers to (start_line,
* start_character, end_line, end_character), serialized as JSON. If
* set, range's end_line will have the same value as line-num. Line
* numbers are 1-based, char numbers are 0-based. The start position
* (start_line, start_character) is inclusive, and the end position
* (end_line, end_character) is exclusive.
*/
@customElement('gr-comment-thread')
export class GrCommentThread extends LitElement {
@query('#replyBtn')
replyBtn?: GrButton;
@query('#quoteBtn')
quoteBtn?: GrButton;
@query('.comment-box')
commentBox?: HTMLElement;
@queryAll('gr-comment')
commentElements?: NodeList;
/**
* Required to be set by parent.
*
* Lit's `hasChanged` change detection defaults to just checking strict
* equality (===). Here it makes sense to install a proper `deepEqual`
* check, because of how the comments-model and ChangeComments are setup:
* Each thread object is recreated on the slightest model change. So when you
* have 100 comment threads and there is an update to one thread, then you
* want to avoid re-rendering the other 99 threads.
*/
@property({hasChanged: notDeepEqual})
thread?: CommentThread;
/**
* Id of the first comment and thus must not change. Will be derived from
* the `thread` property in the first willUpdate() cycle.
*
* The `rootId` property is also used in gr-diff for maintaining lists and
* maps of threads and their associated elements.
*
* Only stays `undefined` for new threads that only have an unsaved comment.
*/
@property({type: String})
rootId?: UrlEncodedCommentId;
// TODO: Is this attribute needed for querySelector() or css rules?
// We don't need this internally for the component.
@property({type: Boolean, reflect: true, attribute: 'has-draft'})
hasDraft?: boolean;
/** Will be inspected on firstUpdated() only. */
@property({type: Boolean, attribute: 'should-scroll-into-view'})
shouldScrollIntoView = false;
/**
* Should the file path and line number be rendered above the comment thread
* widget? Typically true in <gr-thread-list> and false in <gr-diff>.
*/
@property({type: Boolean, attribute: 'show-file-path'})
showFilePath = false;
/**
* Only relevant when `showFilePath` is set.
* If false, then only the line number is rendered.
*/
@property({type: Boolean, attribute: 'show-file-name'})
showFileName = false;
@property({type: Boolean, attribute: 'show-ported-comment'})
showPortedComment = false;
/** This is set to false by <gr-diff>. */
@property({type: Boolean, attribute: false})
showPatchset = true;
@property({type: Boolean, attribute: 'show-comment-context'})
showCommentContext = false;
/**
* Optional context information when a thread is being displayed for a
* specific change message. That influences which comments are expanded or
* collapsed by default.
*/
@property({type: String, attribute: 'message-id'})
messageId?: ChangeMessageId;
/**
* We are reflecting the editing state of the draft comment here. This is not
* an input property, but can be inspected from the parent component.
*
* Changes to this property are fired as 'comment-thread-editing-changed'
* events.
*/
@property({type: Boolean, attribute: 'false'})
editing = false;
/**
* This can either be an unsaved reply to the last comment or the unsaved
* content of a brand new comment thread (then `comments` is empty).
* If set, then `thread.comments` must not contain a draft. A thread can only
* contain *either* an unsaved comment *or* a draft, not both.
*/
@state()
unsavedComment?: UnsavedInfo;
@state()
changeNum?: NumericChangeId;
@state()
prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
@state()
renderPrefs: RenderPreferences = {
hide_left_side: true,
disable_context_control_buttons: true,
show_file_comment_button: false,
hide_line_length_indicator: true,
};
@state()
repoName?: RepoName;
@state()
account?: AccountDetailInfo;
@state()
layers: DiffLayer[] = [];
/** Computed during willUpdate(). */
@state()
diff?: DiffInfo;
/** Computed during willUpdate(). */
@state()
highlightRange?: CommentRange;
/**
* Reflects the *dirty* state of whether the thread is currently unresolved.
* We are listening on the <gr-comment> of the draft, so we even know when the
* checkbox is checked, even if not yet saved.
*/
@state()
unresolved = true;
/**
* Normally drafts are saved within the <gr-comment> child component and we
* don't care about that. But when creating 'Done.' replies we are actually
* saving from this component. True while the REST API call is inflight.
*/
@state()
saving = false;
// Private but used in tests.
readonly getCommentsModel = resolve(this, commentsModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly userModel = getAppContext().userModel;
private readonly reporting = getAppContext().reportingService;
private readonly shortcuts = new ShortcutController(this);
private readonly syntaxLayer = new GrSyntaxLayerWorker();
constructor() {
super();
this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
subscribe(
this,
() => this.getChangeModel().changeNum$,
x => (this.changeNum = x)
);
subscribe(
this,
() => this.userModel.account$,
x => (this.account = x)
);
subscribe(
this,
() => this.getChangeModel().repo$,
x => (this.repoName = x)
);
subscribe(
this,
() => this.userModel.diffPreferences$,
x => this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
);
subscribe(
this,
() => this.userModel.preferences$,
prefs => {
const layers: DiffLayer[] = [this.syntaxLayer];
if (!prefs.disable_token_highlighting) {
layers.push(new TokenHighlightLayer(this));
}
this.layers = layers;
}
);
subscribe(
this,
() => this.userModel.diffPreferences$,
prefs => {
this.prefs = {
...prefs,
// set line_wrapping to true so that the context can take all the
// remaining space after comment card has rendered
line_wrapping: true,
};
}
);
}
override disconnectedCallback() {
if (this.editing) {
this.reporting.reportInteraction(
Interaction.COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED
);
}
super.disconnectedCallback();
}
static override get styles() {
return [
a11yStyles,
sharedStyles,
css`
:host {
font-family: var(--font-family);
font-size: var(--font-size-normal);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-normal);
/* Explicitly set the background color of the diff. We
* cannot use the diff content type ab because of the skip chunk preceding
* it, diff processor assumes the chunk of type skip/ab can be collapsed
* and hides our diff behind context control buttons.
* */
--dark-add-highlight-color: var(--background-color-primary);
}
gr-button {
margin-left: var(--spacing-m);
}
gr-comment {
border-bottom: 1px solid var(--comment-separator-color);
}
#actions {
margin-left: auto;
padding: var(--spacing-s) var(--spacing-m);
}
.comment-box {
width: 80ch;
max-width: 100%;
background-color: var(--comment-background-color);
color: var(--comment-text-color);
box-shadow: var(--elevation-level-2);
border-radius: var(--border-radius);
flex-shrink: 0;
}
#container {
display: var(--gr-comment-thread-display, flex);
align-items: flex-start;
margin: 0 var(--spacing-s) var(--spacing-s);
white-space: normal;
/** This is required for firefox to continue the inheritance */
-webkit-user-select: inherit;
-moz-user-select: inherit;
-ms-user-select: inherit;
user-select: inherit;
}
.comment-box.unresolved {
background-color: var(--unresolved-comment-background-color);
}
.comment-box.robotComment {
background-color: var(--robot-comment-background-color);
}
#actionsContainer {
display: flex;
}
.comment-box.saving #actionsContainer {
opacity: 0.5;
}
#unresolvedLabel {
font-family: var(--font-family);
margin: auto 0;
padding: var(--spacing-m);
}
.pathInfo {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0 var(--spacing-s) var(--spacing-s);
}
.fileName {
padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
}
@media only screen and (max-width: 1200px) {
.diff-container {
display: none;
}
}
.diff-container {
margin-left: var(--spacing-l);
border: 1px solid var(--border-color);
flex-grow: 1;
flex-shrink: 1;
max-width: 1200px;
}
.view-diff-button {
margin: var(--spacing-s) var(--spacing-m);
}
.view-diff-container {
border-top: 1px solid var(--border-color);
background-color: var(--background-color-primary);
}
/* In saved state the "reply" and "quote" buttons are 28px height.
* top:4px positions the 20px icon vertically centered.
* Currently in draft state the "save" and "cancel" buttons are 20px
* height, so the link icon does not need a top:4px in gr-comment_html.
*/
.link-icon {
position: relative;
top: 4px;
cursor: pointer;
}
.fileName gr-copy-clipboard {
display: inline-block;
visibility: hidden;
vertical-align: top;
--gr-button-padding: 0px;
}
.fileName:focus-within gr-copy-clipboard,
.fileName:hover gr-copy-clipboard {
visibility: visible;
}
`,
];
}
override render() {
if (!this.thread) return;
const dynamicBoxClasses = {
robotComment: this.isRobotComment(),
unresolved: this.unresolved,
saving: this.saving,
};
return html`
${this.renderFilePath()}
<div id="container">
<h3 class="assistive-tech-only">${this.computeAriaHeading()}</h3>
<div class="comment-box ${classMap(dynamicBoxClasses)}" tabindex="0">
${this.renderComments()} ${this.renderActions()}
</div>
${this.renderContextualDiff()}
</div>
`;
}
renderFilePath() {
if (!this.showFilePath) return;
const href = this.getUrlForFileComment();
const line = this.computeDisplayLine();
return html`
${this.renderFileName()}
<div class="pathInfo">
${href ? html`<a href=${href}>${line}</a>` : html`<span>${line}</span>`}
</div>
`;
}
renderFileName() {
if (!this.showFileName) return;
if (this.isPatchsetLevel()) {
return html`<div class="fileName"><span>Patchset</span></div>`;
}
const href = this.getDiffUrlForPath();
const displayPath = this.getDisplayPath();
return html`
<div class="fileName">
${href
? html`<a href=${href}>${displayPath}</a>`
: html`<span>${displayPath}</span>`}
<gr-copy-clipboard hideInput .text=${displayPath}></gr-copy-clipboard>
</div>
`;
}
renderComments() {
assertIsDefined(this.thread, 'thread');
const publishedComments = repeat(
this.thread.comments.filter(c => !isDraftOrUnsaved(c)),
comment => comment.id,
comment => this.renderComment(comment)
);
// We are deliberately not including the draft in the repeat directive,
// because we ran into spurious issues with <gr-comment> being destroyed
// and re-created when an unsaved draft transitions to 'saved' state.
const draftComment = this.renderComment(this.getDraftOrUnsaved());
return html`${publishedComments}${draftComment}`;
}
private renderComment(comment?: Comment) {
if (!comment) return nothing;
const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
const initiallyCollapsed =
!isDraftOrUnsaved(comment) &&
(this.messageId
? comment.change_message_id !== this.messageId
: !this.unresolved);
return html`
<gr-comment
.comment=${comment}
.comments=${this.thread!.comments}
?initially-collapsed=${initiallyCollapsed}
?robot-button-disabled=${robotButtonDisabled}
?show-patchset=${this.showPatchset}
?show-ported-comment=${this.showPortedComment &&
comment.id === this.rootId}
@create-fix-comment=${this.handleCommentFix}
@copy-comment-link=${this.handleCopyLink}
@comment-editing-changed=${(e: CustomEvent) => {
if (isDraftOrUnsaved(comment)) this.editing = e.detail;
}}
@comment-unresolved-changed=${(e: CustomEvent) => {
if (isDraftOrUnsaved(comment)) this.unresolved = e.detail;
}}
></gr-comment>
`;
}
renderActions() {
if (!this.account || this.isDraftOrUnsaved() || this.isRobotComment())
return;
return html`
<div id="actionsContainer">
<span id="unresolvedLabel">${
this.unresolved ? 'Unresolved' : 'Resolved'
}</span>
<div id="actions">
<iron-icon
class="link-icon copy"
@click=${this.handleCopyLink}
title="Copy link to this comment"
icon="gr-icons:link"
role="button"
tabindex="0"
>
</iron-icon>
<gr-button
id="replyBtn"
link
class="action reply"
?disabled=${this.saving}
@click=${() => this.handleCommentReply(false)}
>Reply</gr-button
>
<gr-button
id="quoteBtn"
link
class="action quote"
?disabled=${this.saving}
@click=${() => this.handleCommentReply(true)}
>Quote</gr-button
>
${
this.unresolved
? html`
<gr-button
id="ackBtn"
link
class="action ack"
?disabled=${this.saving}
@click=${this.handleCommentAck}
>Ack</gr-button
>
<gr-button
id="doneBtn"
link
class="action done"
?disabled=${this.saving}
@click=${this.handleCommentDone}
>Done</gr-button
>
`
: ''
}
</div>
</div>
</div>
`;
}
renderContextualDiff() {
if (!this.changeNum || !this.showCommentContext || !this.diff) return;
if (!this.thread?.path) return;
const href = this.getUrlForFileComment();
return html`
<div class="diff-container">
<gr-diff
id="diff"
.diff=${this.diff}
.layers=${this.layers}
.path=${this.thread.path}
.prefs=${this.prefs}
.renderPrefs=${this.renderPrefs}
.highlightRange=${this.highlightRange}
>
</gr-diff>
<div class="view-diff-container">
<a href=${href}>
<gr-button link class="view-diff-button">View Diff</gr-button>
</a>
</div>
</div>
`;
}
private firstWillUpdateDone = false;
firstWillUpdate() {
if (!this.thread) return;
if (this.firstWillUpdateDone) return;
this.firstWillUpdateDone = true;
if (this.getFirstComment() === undefined) {
this.unsavedComment = createUnsavedComment(this.thread);
}
this.unresolved = this.getLastComment()?.unresolved ?? true;
this.diff = this.computeDiff();
this.highlightRange = this.computeHighlightRange();
}
override willUpdate(changed: PropertyValues) {
this.firstWillUpdate();
if (changed.has('thread')) {
if (!this.isDraftOrUnsaved()) {
// We can only do this for threads without draft, because otherwise we
// are relying on the <gr-comment> component for the draft to fire
// events about the *dirty* `unresolved` state.
this.unresolved = this.getLastComment()?.unresolved ?? true;
}
this.hasDraft = this.isDraftOrUnsaved();
this.rootId = this.getFirstComment()?.id;
if (this.isDraft()) {
this.unsavedComment = undefined;
}
}
if (changed.has('editing')) {
// changed.get('editing') contains the old value. We only want to trigger
// when changing from editing to non-editing (user has cancelled/saved).
// We do *not* want to trigger on first render (old value is `null`)
if (!this.editing && changed.get('editing') === true) {
this.unsavedComment = undefined;
if (this.thread?.comments.length === 0) {
this.remove();
}
}
fire(this, 'comment-thread-editing-changed', {value: this.editing});
}
}
override firstUpdated() {
if (this.shouldScrollIntoView) {
whenRendered(this, () => {
this.expandCollapseComments(false);
this.commentBox?.focus();
this.scrollIntoView({block: 'center'});
});
}
}
private isDraft() {
return isDraft(this.getLastComment());
}
private isDraftOrUnsaved(): boolean {
return this.isDraft() || this.isUnsaved();
}
private getDraftOrUnsaved(): Comment | undefined {
if (this.unsavedComment) return this.unsavedComment;
if (this.isDraft()) return this.getLastComment();
return undefined;
}
private isNewThread(): boolean {
return this.thread?.comments.length === 0;
}
private isUnsaved(): boolean {
return !!this.unsavedComment || this.thread?.comments.length === 0;
}
private isPatchsetLevel() {
return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
}
private computeDiff() {
if (!this.showCommentContext) return;
if (!this.thread?.path) return;
const firstComment = this.getFirstComment();
if (!firstComment?.context_lines?.length) return;
const diff = computeDiffFromContext(
firstComment.context_lines,
this.thread?.path,
firstComment.source_content_type
);
// Do we really have to re-compute (and re-render) the diff?
if (this.diff && JSON.stringify(this.diff) === JSON.stringify(diff)) {
return this.diff;
}
if (!anyLineTooLong(diff)) {
this.syntaxLayer.process(diff);
}
return diff;
}
private getDiffUrlForPath() {
if (!this.changeNum || !this.repoName || !this.thread?.path) {
return undefined;
}
if (this.isNewThread()) return undefined;
return GerritNav.getUrlForDiffById(
this.changeNum,
this.repoName,
this.thread.path,
this.thread.patchNum
);
}
private computeHighlightRange() {
const comment = this.getFirstComment();
if (!comment) return undefined;
if (comment.range) return comment.range;
if (comment.line) {
return {
start_line: comment.line,
start_character: 0,
end_line: comment.line,
end_character: 0,
};
}
return undefined;
}
// Does not work for patchset level comments
private getUrlForFileComment() {
if (!this.repoName || !this.changeNum || this.isNewThread()) {
return undefined;
}
assertIsDefined(this.rootId, 'rootId of comment thread');
return GerritNav.getUrlForComment(
this.changeNum,
this.repoName,
this.rootId
);
}
private handleCopyLink() {
const comment = this.getFirstComment();
if (!comment) return;
assertIsDefined(this.changeNum, 'changeNum');
assertIsDefined(this.repoName, 'repoName');
const url = generateAbsoluteUrl(
GerritNav.getUrlForCommentsTab(this.changeNum, this.repoName, comment.id)
);
assertIsDefined(url, 'url for comment');
navigator.clipboard.writeText(generateAbsoluteUrl(url)).then(() => {
fireAlert(this, 'Link copied to clipboard');
});
}
private getDisplayPath() {
if (this.isPatchsetLevel()) return 'Patchset';
return computeDisplayPath(this.thread?.path);
}
private computeDisplayLine() {
assertIsDefined(this.thread, 'thread');
if (this.thread.line === FILE) return this.isPatchsetLevel() ? '' : FILE;
if (this.thread.line) return `#${this.thread.line}`;
// If range is set, then lineNum equals the end line of the range.
if (this.thread.range) return `#${this.thread.range.end_line}`;
return '';
}
private isRobotComment() {
return isRobot(this.getLastComment());
}
private getFirstComment() {
assertIsDefined(this.thread);
return getFirstComment(this.thread);
}
private getLastComment() {
assertIsDefined(this.thread);
return getLastComment(this.thread);
}
private handleExpandShortcut() {
this.expandCollapseComments(false);
}
private handleCollapseShortcut() {
this.expandCollapseComments(true);
}
private expandCollapseComments(actionIsCollapse: boolean) {
for (const comment of this.commentElements ?? []) {
(comment as GrComment).collapsed = actionIsCollapse;
}
}
private async createReplyComment(
content: string,
userWantsToEdit: boolean,
unresolved: boolean
) {
const replyingTo = this.getLastComment();
assertIsDefined(this.thread, 'thread');
assertIsDefined(replyingTo, 'the comment that the user wants to reply to');
if (isDraft(replyingTo)) {
throw new Error('cannot reply to draft');
}
if (isUnsaved(replyingTo)) {
throw new Error('cannot reply to unsaved comment');
}
const unsaved = createUnsavedReply(replyingTo, content, unresolved);
if (userWantsToEdit) {
this.unsavedComment = unsaved;
} else {
try {
this.saving = true;
await this.getCommentsModel().saveDraft(unsaved);
} finally {
this.saving = false;
}
}
}
private handleCommentReply(quote: boolean) {
const comment = this.getLastComment();
if (!comment) throw new Error('Failed to find last comment.');
let content = '';
if (quote) {
const msg = comment.message;
if (!msg) throw new Error('Quoting empty comment.');
content = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
}
this.createReplyComment(content, true, comment.unresolved ?? true);
}
private handleCommentAck() {
this.createReplyComment('Ack', false, false);
}
private handleCommentDone() {
this.createReplyComment('Done', false, false);
}
private handleCommentFix(e: CustomEvent) {
const comment = e.detail.comment;
const msg = comment.message;
const quoted = msg.replace(NEWLINE_PATTERN, '\n> ') as string;
const quoteStr = '> ' + quoted + '\n\n';
const response = quoteStr + 'Please fix.';
this.createReplyComment(response, false, true);
}
private computeAriaHeading() {
const author = this.getFirstComment()?.author ?? this.account;
const user = getUserName(undefined, author);
const unresolvedStatus = this.unresolved ? 'Unresolved ' : '';
const draftStatus = this.isDraftOrUnsaved() ? 'Draft ' : '';
return `${unresolvedStatus}${draftStatus}Comment thread by ${user}`;
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-comment-thread': GrCommentThread;
}
}