diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index d8e9f66869bb5..470e2c2265fbb 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -1865,6 +1865,14 @@ export class CommonFrontendContribution implements FrontendApplicationContributi dark: Color.transparent('button.background', 0.5), light: Color.transparent('button.background', 0.5) }, description: 'Background color of secondary buttons.' + }, + { + id: 'editorGutter.commentRangeForeground', + defaults: { + dark: '#c5c5c5', + light: '#c5c5c5', + hc: '#c5c5c5' + }, description: 'Editor gutter decoration color for commenting ranges.' } ); } diff --git a/packages/core/src/browser/style/index.css b/packages/core/src/browser/style/index.css index 8ad1cfafc9da1..1b23814de8c0f 100644 --- a/packages/core/src/browser/style/index.css +++ b/packages/core/src/browser/style/index.css @@ -114,8 +114,8 @@ blockquote { margin-left: 8px; } -.theia-mod-disabled { - opacity: var(--theia-mod-disabled-opacity); +.theia-mod-disabled, .theia-mod-disabled:focus { + opacity: var(--theia-mod-disabled-opacity) !important; } .theia-header { @@ -161,7 +161,7 @@ blockquote { button.theia-button, .theia-button { border: none; - color: var(--theia-button-foreground); + color: var(--theia-button-foreground) !important; background-color: var(--theia-button-background); min-width: 65px; outline: none; diff --git a/packages/monaco/src/browser/monaco-editor-zone-widget.ts b/packages/monaco/src/browser/monaco-editor-zone-widget.ts index ead035eb44427..6e5768e5cb5e9 100644 --- a/packages/monaco/src/browser/monaco-editor-zone-widget.ts +++ b/packages/monaco/src/browser/monaco-editor-zone-widget.ts @@ -19,6 +19,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableCollection, Event, Emitter } from '@theia/core'; +import { TrackedRangeStickiness } from '@theia/editor/lib/browser'; export interface MonacoEditorViewZone extends monaco.editor.IViewZone { id: string; @@ -26,6 +27,8 @@ export interface MonacoEditorViewZone extends monaco.editor.IViewZone { export class MonacoEditorZoneWidget implements Disposable { + private arrow: Arrow | undefined; + readonly zoneNode = document.createElement('div'); readonly containerNode = document.createElement('div'); @@ -42,7 +45,7 @@ export class MonacoEditorZoneWidget implements Disposable { ); constructor( - readonly editor: monaco.editor.IStandaloneCodeEditor + readonly editor: monaco.editor.IStandaloneCodeEditor, readonly showArrow: boolean = true ) { this.zoneNode.classList.add('zone-widget'); this.containerNode.classList.add('zone-widget-container'); @@ -53,6 +56,7 @@ export class MonacoEditorZoneWidget implements Disposable { dispose(): void { this.toDispose.dispose(); + this.hide(); } protected _options: MonacoEditorZoneWidget.Options | undefined; @@ -67,10 +71,10 @@ export class MonacoEditorZoneWidget implements Disposable { show(options: MonacoEditorZoneWidget.Options): void { let { afterLineNumber, afterColumn, heightInLines } = this._options = { showFrame: true, ...options }; const lineHeight = this.editor.getOption(monaco.editor.EditorOption.lineHeight); - const maxHeightInLines = (this.editor.getLayoutInfo().height / lineHeight) * .8; - if (heightInLines >= maxHeightInLines) { - heightInLines = maxHeightInLines; - } + // adjust heightInLines to viewport + const maxHeightInLines = Math.max(12, (this.editor.getLayoutInfo().height / lineHeight) * 0.8); + heightInLines = Math.min(heightInLines, maxHeightInLines); + let arrowHeight = 0; this.toHide.dispose(); this.editor.changeViewZones(accessor => { this.zoneNode.style.top = '-1000px'; @@ -92,6 +96,14 @@ export class MonacoEditorZoneWidget implements Disposable { this.editor.changeViewZones(a => a.removeZone(id)); this.viewZone = undefined; })); + if (this.showArrow) { + this.arrow = new Arrow(this.editor); + arrowHeight = Math.round(lineHeight / 3); + this.arrow.height = arrowHeight; + this.arrow.show({ lineNumber: options.afterLineNumber, column: 0 }); + + this.toHide.push(this.arrow); + } const widget: monaco.editor.IOverlayWidget = { getId: () => 'editor-zone-widget-' + id, getDomNode: () => this.zoneNode, @@ -102,7 +114,6 @@ export class MonacoEditorZoneWidget implements Disposable { this.toHide.push(Disposable.create(() => this.editor.removeOverlayWidget(widget))); }); - this.containerNode.style.top = 0 + 'px'; this.containerNode.style.overflow = 'hidden'; this.updateContainerHeight(heightInLines * lineHeight); @@ -122,7 +133,7 @@ export class MonacoEditorZoneWidget implements Disposable { } protected updateTop(top: number): void { - this.zoneNode.style.top = top + 'px'; + this.zoneNode.style.top = top + (this.showArrow ? 6 : 0) + 'px'; } protected updateHeight(zoneHeight: number): void { this.zoneNode.style.height = zoneHeight + 'px'; @@ -166,6 +177,64 @@ export class MonacoEditorZoneWidget implements Disposable { } } + +class IdGenerator { + private lastId: number; + constructor(private prefix: string) { + this.lastId = 0; + } + + nextId(): string { + return this.prefix + (++this.lastId); + } +} + +class Arrow implements Disposable { + + private readonly idGenerator = new IdGenerator('.arrow-decoration-'); + + private readonly ruleName = this.idGenerator.nextId(); + private decorations: string[] = []; + private _height: number = -1; + + constructor( + private readonly _editor: monaco.editor.ICodeEditor + ) {} + + dispose(): void { + this.hide(); + } + + set height(value: number) { + if (this._height !== value) { + this._height = value; + this._updateStyle(); + } + } + + private _updateStyle(): void { + const style = document.createElement('style'); + style.type = 'text/css'; + style.media = 'screen'; + document.getElementsByTagName('head')[0].appendChild(style); + const selector = `.monaco-editor ${this.ruleName}`; + const cssText = `border-style: solid; border-color: transparent transparent var(--theia-peekView-border); border-width: + ${this._height}px; bottom: -${this._height}px; margin-left: -${this._height}px; `; + (style.sheet).insertRule(selector + '{' + cssText + '}', 0); + } + + show(where: monaco.IPosition): void { + this.decorations = this._editor.deltaDecorations( + this.decorations, + [{ range: monaco.Range.fromPositions(where), options: { className: this.ruleName, stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }] + ); + } + + hide(): void { + this._editor.deltaDecorations(this.decorations, []); + } +} + export namespace MonacoEditorZoneWidget { export interface Options { afterLineNumber: number, diff --git a/packages/monaco/src/browser/style/index.css b/packages/monaco/src/browser/style/index.css index 8cb239480ca3e..9d95d8e526a77 100644 --- a/packages/monaco/src/browser/style/index.css +++ b/packages/monaco/src/browser/style/index.css @@ -40,6 +40,7 @@ .monaco-editor .zone-widget { position: absolute; z-index: 10; + background-color: var(--theia-editorWidget-background); } .monaco-editor .zone-widget .zone-widget-container { diff --git a/packages/plugin-ext/src/common/plugin-api-rpc-model.ts b/packages/plugin-ext/src/common/plugin-api-rpc-model.ts index 64aa35a6c38f5..46d8042257693 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc-model.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc-model.ts @@ -17,6 +17,8 @@ import * as theia from '@theia/plugin'; import { UriComponents } from './uri-components'; import { CompletionItemTag } from '../plugin/types-impl'; +import { Event as TheiaEvent } from '@theia/core/lib/common/event'; +import { URI } from 'vscode-uri'; // Should contains internal Plugin API types @@ -562,3 +564,98 @@ export interface AuthenticationProviderInformation { id: string; label: string; } + +export interface CommentOptions { + /** + * An optional string to show on the comment input box when it's collapsed. + */ + prompt?: string; + + /** + * An optional string to show as placeholder in the comment input box when it's focused. + */ + placeHolder?: string; +} + +export enum CommentMode { + Editing = 0, + Preview = 1 +} + +export interface Comment { + readonly uniqueIdInThread: number; + readonly body: MarkdownString; + readonly userName: string; + readonly userIconPath?: string; + readonly contextValue?: string; + readonly label?: string; + readonly mode?: CommentMode; +} + +export enum CommentThreadCollapsibleState { + /** + * Determines an item is collapsed + */ + Collapsed = 0, + /** + * Determines an item is expanded + */ + Expanded = 1 +} + +export interface CommentInput { + value: string; + uri: URI; +} + +export interface CommentThread { + commentThreadHandle: number; + controllerHandle: number; + extensionId?: string; + threadId: string; + resource: string | null; + range: Range; + label: string | undefined; + contextValue: string | undefined; + comments: Comment[] | undefined; + onDidChangeComments: TheiaEvent; + collapsibleState?: CommentThreadCollapsibleState; + input?: CommentInput; + onDidChangeInput: TheiaEvent; + onDidChangeRange: TheiaEvent; + onDidChangeLabel: TheiaEvent; + onDidChangeCollapsibleState: TheiaEvent; + isDisposed: boolean; +} + +export interface CommentThreadChangedEventMain extends CommentThreadChangedEvent { + owner: string; +} + +export interface CommentThreadChangedEvent { + /** + * Added comment threads. + */ + readonly added: CommentThread[]; + + /** + * Removed comment threads. + */ + readonly removed: CommentThread[]; + + /** + * Changed comment threads. + */ + readonly changed: CommentThread[]; +} + +export interface CommentingRanges { + readonly resource: URI; + ranges: Range[]; +} + +export interface CommentInfo { + extensionId?: string; + threads: CommentThread[]; + commentingRanges: CommentingRanges; +} diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 68a86ab5dfa70..dec1f306ef768 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -67,7 +67,12 @@ import { SearchInWorkspaceResult, AuthenticationSession, AuthenticationSessionsChangeEvent, - AuthenticationProviderInformation + AuthenticationProviderInformation, + Comment, + CommentOptions, + CommentThreadCollapsibleState, + CommentThread, + CommentThreadChangedEvent, } from './plugin-api-rpc-model'; import { ExtPluginApi } from './plugin-ext-api-contribution'; import { KeysToAnyValues, KeysToKeysToAnyValue } from './types'; @@ -700,6 +705,40 @@ export interface TimelineCommandArg { uri: string; } +export namespace CommentsCommandArg { + export function is(arg: Object | undefined): arg is CommentsCommandArg { + return !!arg && typeof arg === 'object' && 'commentControlHandle' in arg && 'commentThreadHandle' in arg && 'text' in arg && !('commentUniqueId' in arg); + } +} +export interface CommentsCommandArg { + commentControlHandle: number; + commentThreadHandle: number; + text: string +} + +export namespace CommentsContextCommandArg { + export function is(arg: Object | undefined): arg is CommentsContextCommandArg { + return !!arg && typeof arg === 'object' && 'commentControlHandle' in arg && 'commentThreadHandle' in arg && 'commentUniqueId' in arg && !('text' in arg); + } +} +export interface CommentsContextCommandArg { + commentControlHandle: number; + commentThreadHandle: number; + commentUniqueId: number +} + +export namespace CommentsEditCommandArg { + export function is(arg: Object | undefined): arg is CommentsEditCommandArg { + return !!arg && typeof arg === 'object' && 'commentControlHandle' in arg && 'commentThreadHandle' in arg && 'commentUniqueId' in arg && 'text' in arg; + } +} +export interface CommentsEditCommandArg { + commentControlHandle: number; + commentThreadHandle: number; + commentUniqueId: number + text: string +} + export interface DecorationsExt { registerDecorationProvider(provider: theia.DecorationProvider): theia.Disposable $provideDecoration(id: number, uri: string): Promise @@ -1475,6 +1514,35 @@ export interface ClipboardMain { $writeText(value: string): Promise; } +export interface CommentsExt { + $createCommentThreadTemplate(commentControllerHandle: number, uriComponents: UriComponents, range: Range): void; + $updateCommentThreadTemplate(commentControllerHandle: number, threadHandle: number, range: Range): Promise; + $deleteCommentThread(commentControllerHandle: number, commentThreadHandle: number): Promise; + $provideCommentingRanges(commentControllerHandle: number, uriComponents: UriComponents, token: CancellationToken): Promise; +} + +export interface CommentProviderFeatures { + options?: CommentOptions; +} + +export type CommentThreadChanges = Partial<{ + range: Range, + label: string, + contextValue: string, + comments: Comment[], + collapseState: CommentThreadCollapsibleState; +}>; + +export interface CommentsMain { + $registerCommentController(handle: number, id: string, label: string): void; + $unregisterCommentController(handle: number): void; + $updateCommentControllerFeatures(handle: number, features: CommentProviderFeatures): void; + $createCommentThread(handle: number, commentThreadHandle: number, threadId: string, resource: UriComponents, range: Range, extensionId: string): CommentThread | undefined; + $updateCommentThread(handle: number, commentThreadHandle: number, threadId: string, resource: UriComponents, changes: CommentThreadChanges): void; + $deleteCommentThread(handle: number, commentThreadHandle: number): void; + $onDidCommentThreadsChange(handle: number, event: CommentThreadChangedEvent): void; +} + export const PLUGIN_RPC_CONTEXT = { AUTHENTICATION_MAIN: >createProxyIdentifier('AuthenticationMain'), COMMAND_REGISTRY_MAIN: >createProxyIdentifier('CommandRegistryMain'), @@ -1504,7 +1572,8 @@ export const PLUGIN_RPC_CONTEXT = { CLIPBOARD_MAIN: >createProxyIdentifier('ClipboardMain'), LABEL_SERVICE_MAIN: >createProxyIdentifier('LabelServiceMain'), TIMELINE_MAIN: >createProxyIdentifier('TimelineMain'), - THEMING_MAIN: >createProxyIdentifier('ThemingMain') + THEMING_MAIN: >createProxyIdentifier('ThemingMain'), + COMMENTS_MAIN: >createProxyIdentifier('CommentsMain') }; export const MAIN_RPC_CONTEXT = { @@ -1534,8 +1603,8 @@ export const MAIN_RPC_CONTEXT = { DECORATIONS_EXT: createProxyIdentifier('DecorationsExt'), LABEL_SERVICE_EXT: createProxyIdentifier('LabelServiceExt'), TIMELINE_EXT: createProxyIdentifier('TimeLineExt'), - THEMING_EXT: createProxyIdentifier('ThemingExt') -}; + THEMING_EXT: createProxyIdentifier('ThemingExt'), + COMMENTS_EXT: createProxyIdentifier('CommentsExt')}; export interface TasksExt { $provideTasks(handle: number, token?: CancellationToken): Promise; diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 00379e6ca3cdf..7acbb69a25710 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -112,6 +112,7 @@ export interface PluginPackageCommand { title: string; category?: string; icon?: string | { light: string; dark: string; }; + enablement?: string; } export interface PluginPackageMenu { @@ -637,6 +638,7 @@ export interface PluginCommand { category?: string; iconUrl?: IconUrl; themeIcon?: string; + enablement?: string; } export type IconUrl = string | { light: string; dark: string; }; @@ -663,6 +665,18 @@ export interface Keybinding { win?: string; } +/** + * Keybinding contribution + */ +export interface Keybinding { + keybinding?: string; + command: string; + when?: string; + mac?: string; + linux?: string; + win?: string; +} + /** * This interface describes a plugin lifecycle object. */ diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index 99e5efcea7097..d450055d97fbd 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -317,7 +317,7 @@ export class TheiaPluginScanner implements PluginScanner { return contributions; } - protected readCommand({ command, title, category, icon }: PluginPackageCommand, pck: PluginPackage): PluginCommand { + protected readCommand({ command, title, category, icon, enablement }: PluginPackageCommand, pck: PluginPackage): PluginCommand { let themeIcon: string | undefined; let iconUrl: IconUrl | undefined; if (icon) { @@ -334,7 +334,7 @@ export class TheiaPluginScanner implements PluginScanner { }; } } - return { command, title, category, iconUrl, themeIcon }; + return { command, title, category, iconUrl, themeIcon, enablement }; } protected toPluginUrl(pck: PluginPackage, relativePath: string): string { diff --git a/packages/plugin-ext/src/main/browser/comments/comment-glyph-widget.ts b/packages/plugin-ext/src/main/browser/comments/comment-glyph-widget.ts new file mode 100644 index 0000000000000..746a39c618e03 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/comments/comment-glyph-widget.ts @@ -0,0 +1,65 @@ +/******************************************************************************** + * 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 WITH Classpath-exception-2.0 + ********************************************************************************/ +import { Disposable } from '@theia/core/lib/common'; + +/*--------------------------------------------------------------------------------------------- + * 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/commentGlyphWidget.ts + +export class CommentGlyphWidget implements Disposable { + + private lineNumber!: number; + private editor: monaco.editor.ICodeEditor; + private commentsDecorations: string[] = []; + readonly commentsOptions: monaco.editor.IModelDecorationOptions; + constructor(editor: monaco.editor.ICodeEditor) { + this.commentsOptions = { + isWholeLine: true, + linesDecorationsClassName: 'comment-range-glyph comment-thread' + }; + this.editor = editor; + } + + getPosition(): number { + const model = this.editor.getModel(); + const range = model && this.commentsDecorations && this.commentsDecorations.length + ? model.getDecorationRange(this.commentsDecorations[0]) + : null; + + return range ? range.startLineNumber : this.lineNumber; + } + + setLineNumber(lineNumber: number): void { + this.lineNumber = lineNumber; + const commentsDecorations = [{ + range: { + startLineNumber: lineNumber, startColumn: 1, + endLineNumber: lineNumber, endColumn: 1 + }, + options: this.commentsOptions + }]; + + this.commentsDecorations = this.editor.deltaDecorations(this.commentsDecorations, commentsDecorations); + } + + dispose(): void { + if (this.commentsDecorations) { + this.editor.deltaDecorations(this.commentsDecorations, []); + } + } +} diff --git a/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx b/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx new file mode 100644 index 0000000000000..e95ffb1e162b5 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/comments/comment-thread-widget.tsx @@ -0,0 +1,669 @@ +/******************************************************************************** + * 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 WITH Classpath-exception-2.0 + ********************************************************************************/ +import { MonacoEditorZoneWidget } from '@theia/monaco/lib/browser/monaco-editor-zone-widget'; +import { + Comment, + CommentMode, + CommentThread, + CommentThreadCollapsibleState +} from '../../../common/plugin-api-rpc-model'; +import { CommentGlyphWidget } from './comment-glyph-widget'; +import { BaseWidget, DISABLED_CLASS } from '@theia/core/lib/browser'; +import * as ReactDOM from 'react-dom'; +import * as React from 'react'; +import { MouseTargetType } from '@theia/editor/lib/browser'; +import { CommentsService } from './comments-service'; +import { + ActionMenuNode, + CommandRegistry, + CompositeMenuNode, + MenuModelRegistry, + MenuPath +} from '@theia/core/lib/common'; +import { CommentsContextKeyService } from './comments-context-key-service'; +import { RefObject } from 'react'; + +/*--------------------------------------------------------------------------------------------- + * 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 commentGlyphWidget: CommentGlyphWidget; + protected readonly contextMenu: CompositeMenuNode; + 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 contextKeyService: CommentsContextKeyService, + protected readonly commands: CommandRegistry + ) { + super(); + this.toDispose.push(this.zoneWidget = new MonacoEditorZoneWidget(editor)); + 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; + + this.display({ afterLineNumber: lineNumber, afterColumn: 1, heightInLines: 2}); + return; + } + + if (state === CommentThreadCollapsibleState.Collapsed && this.isExpanded) { + this.hide(); + return; + } + })); + this.contextKeyService.commentIsEmpty.set(true); + this.toDispose.push(this.zoneWidget.editor.onMouseDown(e => this.onEditorMouseDown(e))); + this.toDispose.push(this.contextKeyService.onDidChange(() => { + const commentForm = this.commentFormRef.current; + if (commentForm) { + commentForm.update(); + } + })); + this.contextMenu = this.menus.getMenu(COMMENT_THREAD_CONTEXT); + this.contextMenu.children.map(node => node instanceof ActionMenuNode && node.action.when).forEach(exp => { + if (typeof exp === 'string') { + this.contextKeyService.setExpression(exp); + } + }); + } + + 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); + } + + 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 }); + } + } + + 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(', '); + label = `Participants: ${participantsList}`; + } else { + label = 'Start discussion'; + } + } + + return label; + } + + 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 + arrowHeight + frameThickness + 8 /** margin bottom to avoid margin collapse */) / lineHeight); + this.zoneWidget.show({ afterLineNumber: this._commentThread.range.startLineNumber, heightInLines: computedLinesNumber }); + } + + protected render(): void { + const headHeight = Math.ceil(this.zoneWidget.editor.getOption(monaco.editor.EditorOption.lineHeight) * 1.2); + ReactDOM.render(, this.zoneWidget.containerNode); + } +} + +namespace CommentForm { + export interface Props { + menus: MenuModelRegistry, + commentThread: CommentThread; + commands: CommandRegistry; + contextKeyService: CommentsContextKeyService; + widget: CommentThreadWidget; + } + + export interface State { + expanded: boolean + } +} + +export class CommentForm

extends React.Component { + private readonly menu: CompositeMenuNode; + private inputRef: RefObject = React.createRef(); + private inputValue: string = ''; + private readonly getInput = () => this.inputValue; + private readonly clearInput: () => void = () => { + const input = this.inputRef.current; + if (input) { + this.inputValue = ''; + input.value = this.inputValue; + this.props.contextKeyService.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); + }; + + componentDidMount(): void { + // Wait for the widget to be rendered. + setTimeout(() => { + this.inputRef.current?.focus(); + }, 100); + } + + 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.contextKeyService.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); + }; + + this.menu = this.props.menus.getMenu(COMMENT_THREAD_CONTEXT); + this.menu.children.map(node => node instanceof ActionMenuNode && node.action.when).forEach(exp => { + if (typeof exp === 'string') { + this.props.contextKeyService.setExpression(exp); + } + }); + } + + render(): React.ReactNode { + const { commands, commentThread, contextKeyService } = this.props; + const hasExistingComments = commentThread.comments && commentThread.comments.length > 0; + return

+
+ +
+ + +
; + } +} + +namespace ReviewComment { + export interface Props { + menus: MenuModelRegistry, + comment: Comment; + commentThread: CommentThread; + contextKeyService: CommentsContextKeyService; + 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 }); + + render(): React.ReactNode { + const { comment, commentForm, contextKeyService, menus, commands, commentThread } = this.props; + const commentUniqueId = comment.uniqueIdInThread; + const { hover } = this.state; + contextKeyService.comment.set(comment.contextValue); + return

+
+ +
+
+
+ {comment.userName} + {comment.label} +
+
+ {hover && menus.getMenu(COMMENT_TITLE).children.map((node, index) => node instanceof ActionMenuNode && + )} +
+
+
+ + +
+
; + } +} + +namespace CommentBody { + export interface Props { + value: string + isVisible: boolean + } +} + +export class CommentBody extends React.Component { + render(): React.ReactNode { + const { value, isVisible } = this.props; + if (!isVisible) { + return false; + } + return
+
+

{value}

+
+
; + } +} + +namespace CommentEditContainer { + export interface Props { + contextKeyService: CommentsContextKeyService + 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; + + 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; + } + + render(): React.ReactNode { + const { menus, comment, commands, commentThread, contextKeyService } = this.props; + if (!(comment.mode === CommentMode.Editing)) { + return false; + } + return
+
+
+