blob: 65a328c26d5ae2b4484063f9710ef6cb9388a543 [file] [log] [blame]
/**
* @license
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '../../../styles/shared-styles';
import '../gr-comment/gr-comment';
import '../../diff/gr-diff/gr-diff';
import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-comment-thread_html';
import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {
computeDiffFromContext,
computeId,
isDraft,
isRobot,
sortComments,
UIComment,
UIDraft,
UIRobot,
} from '../../../utils/comment-util';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {appContext} from '../../../services/app-context';
import {
CommentSide,
createDefaultDiffPrefs,
Side,
SpecialFilePath,
} from '../../../constants/constants';
import {computeDisplayPath} from '../../../utils/path-list-util';
import {computed, customElement, observe, property} from '@polymer/decorators';
import {
AccountDetailInfo,
CommentRange,
ConfigInfo,
NumericChangeId,
PatchSetNum,
RepoName,
UrlEncodedCommentId,
} from '../../../types/common';
import {GrComment} from '../gr-comment/gr-comment';
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {CustomKeyboardEvent} from '../../../types/events';
import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
import {GrButton} from '../gr-button/gr-button';
import {KnownExperimentId} from '../../../services/flags/flags';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {RenderPreferences} from '../../../api/diff';
import {check, assertIsDefined} from '../../../utils/common-util';
import {fireAlert, waitForEventOnce} from '../../../utils/event-util';
import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
import {StorageLocation} from '../../../services/storage/gr-storage';
import {TokenHighlightLayer} from '../../diff/gr-diff-builder/token-highlight-layer';
import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
import {getUserName} from '../../../utils/display-name-util';
import {generateAbsoluteUrl} from '../../../utils/url-util';
const UNRESOLVED_EXPAND_COUNT = 5;
const NEWLINE_PATTERN = /\n/g;
export interface GrCommentThread {
$: {
replyBtn: GrButton;
quoteBtn: GrButton;
};
}
@customElement('gr-comment-thread')
export class GrCommentThread extends KeyboardShortcutMixin(PolymerElement) {
// KeyboardShortcutMixin Not used in this element rather other elements tests
static get template() {
return htmlTemplate;
}
/**
* Fired when the thread should be discarded.
*
* @event thread-discard
*/
/**
* 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.
*/
@property({type: Number})
changeNum?: NumericChangeId;
@property({type: Array})
comments: UIComment[] = [];
@property({type: Object, reflectToAttribute: true})
range?: CommentRange;
@property({type: Object})
keyEventTarget: HTMLElement = document.body;
@property({type: String, reflectToAttribute: true})
diffSide?: Side;
@property({type: String})
patchNum?: PatchSetNum;
@property({type: String})
path: string | undefined;
@property({type: String, observer: '_projectNameChanged'})
projectName?: RepoName;
@property({type: Boolean, notify: true, reflectToAttribute: true})
hasDraft?: boolean;
@property({type: Boolean})
isOnParent = false;
@property({type: Number})
parentIndex: number | null = null;
@property({
type: String,
notify: true,
computed: '_computeRootId(comments.*)',
})
rootId?: UrlEncodedCommentId;
@property({type: Boolean, observer: 'handleShouldScrollIntoViewChanged'})
shouldScrollIntoView = false;
@property({type: Boolean})
showFilePath = false;
@property({type: Object, reflectToAttribute: true})
lineNum?: LineNumber;
@property({type: Boolean, notify: true, reflectToAttribute: true})
unresolved?: boolean;
@property({type: Boolean})
_showActions?: boolean;
@property({type: Object})
_lastComment?: UIComment;
@property({type: Array})
_orderedComments: UIComment[] = [];
@property({type: Object})
_projectConfig?: ConfigInfo;
@property({type: Object})
_prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
@property({type: Object})
_renderPrefs: RenderPreferences = {
hide_left_side: true,
disable_context_control_buttons: true,
show_file_comment_button: false,
hide_line_length_indicator: true,
};
@property({type: Boolean, reflectToAttribute: true})
isRobotComment = false;
@property({type: Boolean})
showFileName = true;
@property({type: Boolean})
showPortedComment = false;
@property({type: Boolean})
showPatchset = true;
@property({type: Boolean})
showCommentContext = false;
@property({type: Object})
_selfAccount?: AccountDetailInfo;
get keyBindings() {
return {
'e shift+e': '_handleEKey',
};
}
private readonly reporting = appContext.reportingService;
private readonly flagsService = appContext.flagsService;
readonly storage = appContext.storageService;
private readonly syntaxLayer = new GrSyntaxLayer();
readonly restApiService = appContext.restApiService;
constructor() {
super();
this.addEventListener('comment-update', e =>
this._handleCommentUpdate(e as CustomEvent)
);
}
/** @override */
connectedCallback() {
super.connectedCallback();
this._getLoggedIn().then(loggedIn => {
this._showActions = loggedIn;
});
this.restApiService.getDiffPreferences().then(prefs => {
if (!prefs) return;
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,
};
this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
});
this.restApiService.getAccount().then(account => {
this._selfAccount = account;
});
this._setInitialExpandedState();
}
@computed('comments', 'path')
get _diff() {
if (this.comments === undefined || this.path === undefined) return;
if (!this.comments[0]?.context_lines?.length) return;
const diff = computeDiffFromContext(
this.comments[0].context_lines,
this.path,
this.comments[0].source_content_type
);
if (!anyLineTooLong(diff)) {
this.syntaxLayer.init(diff);
waitForEventOnce(this, 'render').then(() => {
this.syntaxLayer.process();
});
}
return diff;
}
handleShouldScrollIntoViewChanged(shouldScrollIntoView?: boolean) {
// Wait for comment to be rendered before scrolling to it
if (shouldScrollIntoView) {
const resizeObserver = new ResizeObserver(
(_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
if (this.offsetHeight > 0) {
this.scrollIntoView();
}
observer.unobserve(this);
}
);
resizeObserver.observe(this);
}
}
_shouldShowCommentContext(
changeNum?: NumericChangeId,
showCommentContext?: boolean,
diff?: DiffInfo
) {
return changeNum && showCommentContext && !!diff;
}
addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
const lastComment = this.comments[this.comments.length - 1] || {};
if (isDraft(lastComment)) {
const commentEl = this._commentElWithDraftID(
lastComment.id || lastComment.__draftID
);
if (!commentEl) throw new Error('Failed to find draft.');
commentEl.editing = true;
// If the comment was collapsed, re-open it to make it clear which
// actions are available.
commentEl.collapsed = false;
} else {
const range = rangeParam
? rangeParam
: lastComment
? lastComment.range
: undefined;
const unresolved = lastComment ? lastComment.unresolved : undefined;
this.addDraft(lineNum, range, unresolved);
}
}
addDraft(lineNum?: LineNumber, range?: CommentRange, unresolved?: boolean) {
const draft = this._newDraft(lineNum, range);
draft.__editing = true;
draft.unresolved = unresolved === false ? unresolved : true;
this.push('comments', draft);
}
fireRemoveSelf() {
this.dispatchEvent(
new CustomEvent('thread-discard', {
detail: {rootId: this.rootId},
bubbles: false,
})
);
}
_getDiffUrlForPath(
projectName?: RepoName,
changeNum?: NumericChangeId,
path?: string,
patchNum?: PatchSetNum
) {
if (!changeNum || !projectName || !path) return undefined;
if (isDraft(this.comments[0])) {
return GerritNav.getUrlForDiffById(
changeNum,
projectName,
path,
patchNum
);
}
const id = this.comments[0].id;
if (!id) throw new Error('A published comment is missing the id.');
return GerritNav.getUrlForComment(changeNum, projectName, id);
}
getHighlightRange() {
const comment = this.comments?.[0];
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;
}
_getLayers(diff?: DiffInfo) {
if (!diff) return [];
const layers = [];
if (this.flagsService.isEnabled(KnownExperimentId.TOKEN_HIGHLIGHTING)) {
layers.push(new TokenHighlightLayer());
}
layers.push(this.syntaxLayer);
return layers;
}
_getUrlForViewDiff(
comments: UIComment[],
changeNum?: NumericChangeId,
projectName?: RepoName
) {
if (!changeNum) return;
if (!projectName) return;
check(comments.length > 0, 'comment not found');
return GerritNav.getUrlForComment(changeNum, projectName, comments[0].id!);
}
_getDiffUrlForComment(
projectName?: RepoName,
changeNum?: NumericChangeId,
path?: string,
patchNum?: PatchSetNum
) {
if (!projectName || !changeNum || !path) return undefined;
if (
(this.comments.length && this.comments[0].side === 'PARENT') ||
isDraft(this.comments[0])
) {
if (this.lineNum === 'LOST') throw new Error('invalid lineNum lost');
return GerritNav.getUrlForDiffById(
changeNum,
projectName,
path,
patchNum,
undefined,
this.lineNum === FILE ? undefined : this.lineNum
);
}
const id = this.comments[0].id;
if (!id) throw new Error('A published comment is missing the id.');
return GerritNav.getUrlForComment(changeNum, projectName, id);
}
handleCopyLink() {
assertIsDefined(this.changeNum, 'changeNum');
assertIsDefined(this.projectName, 'projectName');
const url = generateAbsoluteUrl(
GerritNav.getUrlForCommentsTab(
this.changeNum,
this.projectName,
this.comments[0].id!
)
);
navigator.clipboard.writeText(url).then(() => {
fireAlert(this, 'Link copied to clipboard');
});
}
_isPatchsetLevelComment(path: string) {
return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
}
_computeShowPortedComment(comment: UIComment) {
if (this._orderedComments.length === 0) return false;
return this.showPortedComment && comment.id === this._orderedComments[0].id;
}
_computeDisplayPath(path: string) {
const displayPath = computeDisplayPath(path);
if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
return 'Patchset';
}
return displayPath;
}
_computeDisplayLine(lineNum?: LineNumber, range?: CommentRange) {
if (lineNum === FILE) {
if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
return '';
}
return FILE;
}
if (lineNum) return `#${lineNum}`;
// If range is set, then lineNum equals the end line of the range.
if (range) return `#${range.end_line}`;
return '';
}
_getLoggedIn() {
return this.restApiService.getLoggedIn();
}
_getUnresolvedLabel(unresolved?: boolean) {
return unresolved ? 'Unresolved' : 'Resolved';
}
@observe('comments.*')
_commentsChanged() {
this._orderedComments = sortComments(this.comments);
this.updateThreadProperties();
}
updateThreadProperties() {
if (this._orderedComments.length) {
this._lastComment = this._getLastComment();
this.unresolved = this._lastComment.unresolved;
this.hasDraft = isDraft(this._lastComment);
this.isRobotComment = isRobot(this._lastComment);
}
}
_shouldDisableAction(_showActions?: boolean, _lastComment?: UIComment) {
return !_showActions || !_lastComment || isDraft(_lastComment);
}
_hideActions(_showActions?: boolean, _lastComment?: UIComment) {
return (
this._shouldDisableAction(_showActions, _lastComment) ||
isRobot(_lastComment)
);
}
_getLastComment() {
return this._orderedComments[this._orderedComments.length - 1] || {};
}
_handleEKey(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e)) {
return;
}
// Don’t preventDefault in this case because it will render the event
// useless for other handlers (other gr-comment-thread elements).
if (e.detail.keyboardEvent?.shiftKey) {
this._expandCollapseComments(true);
} else {
if (this.modifierPressed(e)) {
return;
}
this._expandCollapseComments(false);
}
}
_expandCollapseComments(actionIsCollapse: boolean) {
const comments = this.root?.querySelectorAll('gr-comment');
if (!comments) return;
for (const comment of comments) {
comment.collapsed = actionIsCollapse;
}
}
/**
* Sets the initial state of the comment thread.
* Expands the thread if one of the following is true:
* - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
* thread is unresolved,
* - it's a robot comment.
* - it's a draft
*/
_setInitialExpandedState() {
if (this._orderedComments) {
for (let i = 0; i < this._orderedComments.length; i++) {
const comment = this._orderedComments[i];
if (isDraft(comment)) {
comment.collapsed = false;
continue;
}
const isRobotComment = !!(comment as UIRobot).robot_id;
// False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
const resolvedThread =
!this.unresolved ||
this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
if (comment.collapsed === undefined) {
comment.collapsed = !isRobotComment && resolvedThread;
}
}
}
}
_createReplyComment(
content?: string,
isEditing?: boolean,
unresolved?: boolean
) {
this.reporting.recordDraftInteraction();
const id = this._orderedComments[this._orderedComments.length - 1].id;
if (!id) throw new Error('Cannot reply to comment without id.');
const reply = this._newReply(id, content, unresolved);
if (isEditing) {
reply.__editing = true;
}
this.push('comments', reply);
if (!isEditing) {
// Allow the reply to render in the dom-repeat.
setTimeout(() => {
const commentEl = this._commentElWithDraftID(reply.__draftID);
if (commentEl) commentEl.save();
}, 1);
}
}
_isDraft(comment: UIComment) {
return isDraft(comment);
}
_processCommentReply(quote?: boolean) {
const comment = this._lastComment;
if (!comment) throw new Error('Failed to find last comment.');
let content = undefined;
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);
}
_handleCommentReply() {
this._processCommentReply();
}
_handleCommentQuote() {
this._processCommentReply(true);
}
_handleCommentAck() {
this._createReplyComment('Ack', false, false);
}
_handleCommentDone() {
this._createReplyComment('Done', false, false);
}
_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);
}
_commentElWithDraftID(id?: string): GrComment | null {
if (!id) return null;
const els = this.root?.querySelectorAll('gr-comment');
if (!els) return null;
for (const el of els) {
const c = el.comment;
if (isRobot(c)) continue;
if (c?.id === id || (isDraft(c) && c?.__draftID === id)) return el;
}
return null;
}
_newReply(
inReplyTo: UrlEncodedCommentId,
message?: string,
unresolved?: boolean
) {
const d = this._newDraft();
d.in_reply_to = inReplyTo;
if (message !== undefined) {
d.message = message;
}
if (unresolved !== undefined) {
d.unresolved = unresolved;
}
return d;
}
_newDraft(lineNum?: LineNumber, range?: CommentRange) {
const d: UIDraft = {
__draft: true,
__draftID: Math.random().toString(36),
__date: new Date(),
};
if (lineNum === 'LOST') throw new Error('invalid lineNum lost');
// For replies, always use same meta info as root.
if (this.comments && this.comments.length >= 1) {
const rootComment = this.comments[0];
if (rootComment.path !== undefined) d.path = rootComment.path;
if (rootComment.patch_set !== undefined)
d.patch_set = rootComment.patch_set;
if (rootComment.side !== undefined) d.side = rootComment.side;
if (rootComment.line !== undefined) d.line = rootComment.line;
if (rootComment.range !== undefined) d.range = rootComment.range;
if (rootComment.parent !== undefined) d.parent = rootComment.parent;
} else {
// Set meta info for root comment.
d.path = this.path;
d.patch_set = this.patchNum;
d.side = this._getSide(this.isOnParent);
if (lineNum && lineNum !== FILE) {
d.line = lineNum;
}
if (range) {
d.range = range;
}
if (this.parentIndex) {
d.parent = this.parentIndex;
}
}
return d;
}
_getSide(isOnParent: boolean): CommentSide {
return isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
}
_computeRootId(comments: PolymerDeepPropertyChange<UIComment[], unknown>) {
// Keep the root ID even if the comment was removed, so that notification
// to sync will know which thread to remove.
if (!comments.base.length) {
return this.rootId;
}
return computeId(comments.base[0]);
}
_handleCommentDiscard(e: Event) {
assertIsDefined(this.changeNum, 'changeNum');
assertIsDefined(this.patchNum, 'patchNum');
const diffCommentEl = (dom(e) as EventApi).rootTarget as GrComment;
const comment = diffCommentEl.comment;
const idx = this._indexOf(comment, this.comments);
if (idx === -1) {
throw new Error(
'Cannot find comment ' + JSON.stringify(diffCommentEl.comment)
);
}
this.splice('comments', idx, 1);
if (this.comments.length === 0) {
this.fireRemoveSelf();
}
// Check to see if there are any other open comments getting edited and
// set the local storage value to its message value.
for (const changeComment of this.comments) {
if (isDraft(changeComment) && changeComment.__editing) {
const commentLocation: StorageLocation = {
changeNum: this.changeNum,
patchNum: this.patchNum,
path: changeComment.path,
line: changeComment.line,
};
this.storage.setDraftComment(
commentLocation,
changeComment.message ?? ''
);
}
}
}
_handleCommentUpdate(e: CustomEvent) {
const comment = e.detail.comment;
const index = this._indexOf(comment, this.comments);
if (index === -1) {
// This should never happen: comment belongs to another thread.
this.reporting.error(
new Error(`Comment update for another comment thread: ${comment}`)
);
return;
}
this.set(['comments', index], comment);
// Because of the way we pass these comment objects around by-ref, in
// combination with the fact that Polymer does dirty checking in
// observers, the this.set() call above will not cause a thread update in
// some situations.
this.updateThreadProperties();
}
_indexOf(comment: UIComment | undefined, arr: UIComment[]) {
if (!comment) return -1;
for (let i = 0; i < arr.length; i++) {
const c = arr[i];
if (
(isDraft(c) && isDraft(comment) && c.__draftID === comment.__draftID) ||
(c.id && c.id === comment.id)
) {
return i;
}
}
return -1;
}
_computeHostClass(unresolved?: boolean) {
if (this.isRobotComment) {
return 'robotComment';
}
return unresolved ? 'unresolved' : '';
}
/**
* Load the project config when a project name has been provided.
*
* @param name The project name.
*/
_projectNameChanged(name?: RepoName) {
if (!name) {
return;
}
this.restApiService.getProjectConfig(name).then(config => {
this._projectConfig = config;
});
}
_computeAriaHeading(_orderedComments: UIComment[]) {
const firstComment = _orderedComments[0];
const author = firstComment?.author ?? this._selfAccount;
const lastComment = _orderedComments[_orderedComments.length - 1] || {};
const status = [
lastComment.unresolved ? 'Unresolved' : '',
isDraft(lastComment) ? 'Draft' : '',
].join(' ');
return `${status} Comment thread by ${getUserName(undefined, author)}`;
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-comment-thread': GrCommentThread;
}
}