// ***************************************************************************** // Copyright (C) 2020 Red Hat, Inc. and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { MonacoEditorZoneWidget } from '@theia/monaco/lib/browser/monaco-editor-zone-widget'; import { Comment, CommentMode, CommentThread, CommentThreadState, CommentThreadCollapsibleState } from '../../../common/plugin-api-rpc-model'; import { CommentGlyphWidget } from './comment-glyph-widget'; import { BaseWidget, DISABLED_CLASS } from '@theia/core/lib/browser'; import * as React from '@theia/core/shared/react'; import { MouseTargetType } from '@theia/editor/lib/browser'; import { CommentsService } from './comments-service'; import { CommandMenu, CommandRegistry, CompoundMenuNode, isObject, DisposableCollection, MenuModelRegistry, MenuPath } from '@theia/core/lib/common'; import { CommentsContext } from './comments-context'; import { RefObject } from '@theia/core/shared/react'; import * as monaco from '@theia/monaco-editor-core'; import { createRoot, Root } from '@theia/core/shared/react-dom/client'; import { CommentAuthorInformation } from '@theia/plugin'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ // some code copied and modified from https://github.com/microsoft/vscode/blob/1.49.3/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts export const COMMENT_THREAD_CONTEXT: MenuPath = ['comment_thread-context-menu']; export const COMMENT_CONTEXT: MenuPath = ['comment-context-menu']; export const COMMENT_TITLE: MenuPath = ['comment-title-menu']; export class CommentThreadWidget extends BaseWidget { protected readonly zoneWidget: MonacoEditorZoneWidget; protected readonly containerNodeRoot: Root; protected readonly commentGlyphWidget: CommentGlyphWidget; protected readonly commentFormRef: RefObject = React.createRef(); protected isExpanded?: boolean; constructor( editor: monaco.editor.IStandaloneCodeEditor, private _owner: string, private _commentThread: CommentThread, private commentService: CommentsService, protected readonly menus: MenuModelRegistry, protected readonly commentsContext: CommentsContext, protected readonly contextKeyService: ContextKeyService, protected readonly commands: CommandRegistry ) { super(); this.toDispose.push(this.zoneWidget = new MonacoEditorZoneWidget(editor)); this.containerNodeRoot = createRoot(this.zoneWidget.containerNode); this.toDispose.push(this.commentGlyphWidget = new CommentGlyphWidget(editor)); this.toDispose.push(this._commentThread.onDidChangeCollapsibleState(state => { if (state === CommentThreadCollapsibleState.Expanded && !this.isExpanded) { const lineNumber = this._commentThread.range?.startLineNumber ?? 0; this.display({ afterLineNumber: lineNumber, afterColumn: 1, heightInLines: 2 }); return; } if (state === CommentThreadCollapsibleState.Collapsed && this.isExpanded) { this.hide(); return; } })); this.commentsContext.commentIsEmpty.set(true); this.toDispose.push(this.zoneWidget.editor.onMouseDown(e => this.onEditorMouseDown(e))); this.toDispose.push(this._commentThread.onDidChangeCanReply(_canReply => { const commentForm = this.commentFormRef.current; if (commentForm) { commentForm.update(); } })); this.toDispose.push(this._commentThread.onDidChangeState(_state => { this.update(); })); const contextMenu = this.menus.getMenu(COMMENT_THREAD_CONTEXT); contextMenu?.children.forEach(node => { if (node.onDidChange) { this.toDispose.push(node.onDidChange(() => { const commentForm = this.commentFormRef.current; if (commentForm) { commentForm.update(); } })); } }); } public getGlyphPosition(): number { return this.commentGlyphWidget.getPosition(); } public collapse(): void { this._commentThread.collapsibleState = CommentThreadCollapsibleState.Collapsed; if (this._commentThread.comments && this._commentThread.comments.length === 0) { this.deleteCommentThread(); } this.hide(); } private deleteCommentThread(): void { this.dispose(); this.commentService.disposeCommentThread(this.owner, this._commentThread.threadId); } override dispose(): void { super.dispose(); if (this.commentGlyphWidget) { this.commentGlyphWidget.dispose(); } } toggleExpand(lineNumber: number): void { if (this.isExpanded) { this._commentThread.collapsibleState = CommentThreadCollapsibleState.Collapsed; this.hide(); if (!this._commentThread.comments || !this._commentThread.comments.length) { this.deleteCommentThread(); } } else { this._commentThread.collapsibleState = CommentThreadCollapsibleState.Expanded; this.display({ afterLineNumber: lineNumber, afterColumn: 1, heightInLines: 2 }); } } override hide(): void { this.zoneWidget.hide(); this.isExpanded = false; super.hide(); } display(options: MonacoEditorZoneWidget.Options): void { this.isExpanded = true; if (this._commentThread.collapsibleState && this._commentThread.collapsibleState !== CommentThreadCollapsibleState.Expanded) { return; } this.commentGlyphWidget.setLineNumber(options.afterLineNumber); this._commentThread.collapsibleState = CommentThreadCollapsibleState.Expanded; this.zoneWidget.show(options); this.update(); } private onEditorMouseDown(e: monaco.editor.IEditorMouseEvent): void { const range = e.target.range; if (!range) { return; } if (!e.event.leftButton) { return; } if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) { return; } const data = e.target.detail; const gutterOffsetX = data.offsetX - data.glyphMarginWidth - data.lineNumbersWidth - data.glyphMarginLeft; // don't collide with folding and git decorations if (gutterOffsetX > 14) { return; } const mouseDownInfo = { lineNumber: range.startLineNumber }; const { lineNumber } = mouseDownInfo; if (!range || range.startLineNumber !== lineNumber) { return; } if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) { return; } if (!e.target.element) { return; } if (this.commentGlyphWidget && this.commentGlyphWidget.getPosition() !== lineNumber) { return; } if (e.target.element.className.indexOf('comment-thread') >= 0) { this.toggleExpand(lineNumber); return; } if (this._commentThread.collapsibleState === CommentThreadCollapsibleState.Collapsed) { this.display({ afterLineNumber: mouseDownInfo.lineNumber, heightInLines: 2 }); } else { this.hide(); } } public get owner(): string { return this._owner; } public get commentThread(): CommentThread { return this._commentThread; } private getThreadLabel(): string { let label: string | undefined; label = this._commentThread.label; if (label === undefined) { if (this._commentThread.comments && this._commentThread.comments.length) { const onlyUnique = (value: Comment, index: number, self: Comment[]) => self.indexOf(value) === index; const participantsList = this._commentThread.comments.filter(onlyUnique).map(comment => `@${comment.userName}`).join(', '); const resolutionState = this._commentThread.state === CommentThreadState.Resolved ? '(Resolved)' : '(Unresolved)'; label = `Participants: ${participantsList} ${resolutionState}`; } else { label = 'Start discussion'; } } return label; } override update(): void { if (!this.isExpanded) { return; } this.render(); const headHeight = Math.ceil(this.zoneWidget.editor.getOption(monaco.editor.EditorOption.lineHeight) * 1.2); const lineHeight = this.zoneWidget.editor.getOption(monaco.editor.EditorOption.lineHeight); const arrowHeight = Math.round(lineHeight / 3); const frameThickness = Math.round(lineHeight / 9) * 2; const body = this.zoneWidget.containerNode.getElementsByClassName('body')[0]; const computedLinesNumber = Math.ceil((headHeight + (body?.clientHeight ?? 0) + arrowHeight + frameThickness + 8 /** margin bottom to avoid margin collapse */) / lineHeight); this.zoneWidget.show({ afterLineNumber: this._commentThread.range?.startLineNumber ?? 0, heightInLines: computedLinesNumber }); } protected render(): void { const headHeight = Math.ceil(this.zoneWidget.editor.getOption(monaco.editor.EditorOption.lineHeight) * 1.2); this.containerNodeRoot.render(
{this.getThreadLabel()}
{this._commentThread.comments?.map((comment, index) => )}
); } } namespace CommentForm { export interface Props { menus: MenuModelRegistry, commentThread: CommentThread; commands: CommandRegistry; contextKeyService: ContextKeyService; commentsContext: CommentsContext; widget: CommentThreadWidget; } export interface State { expanded: boolean } } export class CommentForm

extends React.Component { private inputRef: RefObject = React.createRef(); private inputValue: string = ''; private readonly getInput = () => this.inputValue; private toDisposeOnUnmount = new DisposableCollection(); private readonly clearInput: () => void = () => { const input = this.inputRef.current; if (input) { this.inputValue = ''; input.value = this.inputValue; this.props.commentsContext.commentIsEmpty.set(true); } }; update(): void { this.setState(this.state); } protected expand = () => { this.setState({ expanded: true }); // Wait for the widget to be rendered. setTimeout(() => { // Update the widget's height. this.props.widget.update(); this.inputRef.current?.focus(); }, 100); }; protected collapse = () => { this.setState({ expanded: false }); // Wait for the widget to be rendered. setTimeout(() => { // Update the widget's height. this.props.widget.update(); }, 100); }; override componentDidMount(): void { // Wait for the widget to be rendered. setTimeout(() => { this.inputRef.current?.focus(); }, 100); } override componentWillUnmount(): void { this.toDisposeOnUnmount.dispose(); } private readonly onInput: (event: React.FormEvent) => void = (event: React.FormEvent) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const value = (event.target as any).value; if (this.inputValue.length === 0 || value.length === 0) { this.props.commentsContext.commentIsEmpty.set(value.length === 0); } this.inputValue = value; }; constructor(props: P) { super(props); this.state = { expanded: false }; const setState = this.setState.bind(this); this.setState = newState => { setState(newState); }; } /** * Renders the comment form with textarea, actions, and reply button. * * @returns The rendered comment form */ protected renderCommentForm(): React.ReactNode { const { commentThread, commentsContext, contextKeyService, menus } = this.props; const hasExistingComments = commentThread.comments && commentThread.comments.length > 0; // Determine when to show the expanded form: // - When state.expanded is true (user clicked the reply button) // - When there are no existing comments (new thread) const shouldShowExpanded = this.state.expanded || (commentThread.comments && commentThread.comments.length === 0); return commentThread.canReply ? (

) : null; } /** * Renders the author information section. * * @param authorInfo The author information to display * @returns The rendered author information section */ protected renderAuthorInfo(authorInfo: CommentAuthorInformation): React.ReactNode { return (
{authorInfo.iconPath && ( )}
); } override render(): React.ReactNode { const { commentThread } = this.props; if (!commentThread.canReply) { return null; } // If there's author info, wrap in a container with author info on the left if (isCommentAuthorInformation(commentThread.canReply)) { return (
{this.renderAuthorInfo(commentThread.canReply)}
{commentThread.canReply.name}
{this.renderCommentForm()}
); } // Otherwise, just return the comment form return (
{this.renderCommentForm()}
); } } function isCommentAuthorInformation(item: unknown): item is CommentAuthorInformation { return isObject(item) && 'name' in item; } namespace ReviewComment { export interface Props { menus: MenuModelRegistry, comment: Comment; commentThread: CommentThread; contextKeyService: ContextKeyService; commentsContext: CommentsContext; commands: CommandRegistry; commentForm: RefObject; } export interface State { hover: boolean } } export class ReviewComment

extends React.Component { constructor(props: P) { super(props); this.state = { hover: false }; const setState = this.setState.bind(this); this.setState = newState => { setState(newState); }; } protected detectHover = (element: HTMLElement | null) => { if (element) { window.requestAnimationFrame(() => { const hover = element.matches(':hover'); this.setState({ hover }); }); } }; protected showHover = () => this.setState({ hover: true }); protected hideHover = () => this.setState({ hover: false }); override render(): React.ReactNode { const { comment, commentForm, contextKeyService, commentsContext, menus, commands, commentThread } = this.props; const commentUniqueId = comment.uniqueIdInThread; const { hover } = this.state; commentsContext.comment.set(comment.contextValue); return

{comment.userName} {this.localeDate(comment.timestamp)} {comment.label}
{hover && menus.getMenuNode(COMMENT_TITLE) && menus.getMenu(COMMENT_TITLE)?.children.map((node, index): React.ReactNode => CommandMenu.is(node) && )}
; } protected localeDate(timestamp: string | undefined): string { if (timestamp === undefined) { return ''; } const date = new Date(timestamp); if (!isNaN(date.getTime())) { return date.toLocaleString(); } return ''; } } namespace CommentBody { export interface Props { value: string isVisible: boolean } } export class CommentBody extends React.Component { override render(): React.ReactNode { const { value, isVisible } = this.props; if (!isVisible) { return false; } return

{value}

; } } namespace CommentEditContainer { export interface Props { contextKeyService: ContextKeyService; commentsContext: CommentsContext; menus: MenuModelRegistry, comment: Comment; commentThread: CommentThread; commentForm: RefObject; commands: CommandRegistry; } } export class CommentEditContainer extends React.Component { private readonly inputRef: RefObject = React.createRef(); private dirtyCommentMode: CommentMode | undefined; private dirtyCommentFormState: boolean | undefined; override componentDidUpdate(prevProps: Readonly, prevState: Readonly<{}>): void { const commentFormState = this.props.commentForm.current?.state; const mode = this.props.comment.mode; if (this.dirtyCommentMode !== mode || (this.dirtyCommentFormState !== commentFormState?.expanded && !commentFormState?.expanded)) { const currentInput = this.inputRef.current; if (currentInput) { // Wait for the widget to be rendered. setTimeout(() => { currentInput.focus(); currentInput.setSelectionRange(currentInput.value.length, currentInput.value.length); }, 50); } } this.dirtyCommentMode = mode; this.dirtyCommentFormState = commentFormState?.expanded; } override render(): React.ReactNode { const { menus, comment, commands, commentThread, contextKeyService, commentsContext } = this.props; if (!(comment.mode === CommentMode.Editing)) { return false; } return