From ec9307f7e64b30040da6e00a0554e8d14b17bae7 Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Tue, 28 Dec 2021 15:40:43 +0100 Subject: [PATCH] Provide a way to add a timestamp or detail to a comment WIP for #139524 --- package.json | 1 + src/vs/editor/common/modes.ts | 10 ++ .../workbench/api/common/extHostComments.ts | 19 +++- .../contrib/comments/browser/commentNode.ts | 9 +- .../contrib/comments/browser/media/review.css | 6 + .../contrib/comments/browser/timestamp.ts | 106 ++++++++++++++++++ .../common/extensionsApiProposals.ts | 1 + .../vscode.proposed.commentTimestamp.d.ts | 14 +++ yarn.lock | 5 + 9 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 src/vs/workbench/contrib/comments/browser/timestamp.ts create mode 100644 src/vscode-dts/vscode.proposed.commentTimestamp.d.ts diff --git a/package.json b/package.json index 1aed68be832ea..6cdaf4b50f1b3 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@vscode/sudo-prompt": "9.3.1", "@vscode/vscode-languagedetection": "1.0.21", "applicationinsights": "1.4.2", + "dayjs": "^1.10.7", "graceful-fs": "4.2.8", "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^2.2.3", diff --git a/src/vs/editor/common/modes.ts b/src/vs/editor/common/modes.ts index 0eb734a5eba28..a6c2527597cbb 100644 --- a/src/vs/editor/common/modes.ts +++ b/src/vs/editor/common/modes.ts @@ -1663,6 +1663,15 @@ export enum CommentMode { Preview = 1 } +/** + * @internal + */ +export interface Timestamp { + date: Date; + useRelativeTime?: boolean; + label?: string; +} + /** * @internal */ @@ -1675,6 +1684,7 @@ export interface Comment { readonly commentReactions?: CommentReaction[]; readonly label?: string; readonly mode?: CommentMode; + readonly timestamp?: Timestamp; } /** diff --git a/src/vs/workbench/api/common/extHostComments.ts b/src/vs/workbench/api/common/extHostComments.ts index ab15113e65507..4deeea6dc85fd 100644 --- a/src/vs/workbench/api/common/extHostComments.ts +++ b/src/vs/workbench/api/common/extHostComments.ts @@ -16,6 +16,7 @@ import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensio import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments'; import * as extHostTypeConverter from 'vs/workbench/api/common/extHostTypeConverters'; import * as types from 'vs/workbench/api/common/extHostTypes'; +import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import type * as vscode from 'vscode'; import { ExtHostCommentsShape, IMainContext, MainContext, CommentThreadChanges } from './extHost.protocol'; import { ExtHostCommands } from './extHostCommands'; @@ -346,7 +347,7 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo private _uri: vscode.Uri, private _range: vscode.Range, private _comments: vscode.Comment[], - extensionId: ExtensionIdentifier + public readonly extensionDescription: IExtensionDescription ) { this._acceptInputDisposables.value = new DisposableStore(); @@ -360,7 +361,7 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo this._id, this._uri, extHostTypeConverter.Range.from(this._range), - extensionId + extensionDescription.identifier ); this._localDisposables = []; @@ -561,18 +562,18 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo createCommentThread(resource: vscode.Uri, range: vscode.Range, comments: vscode.Comment[]): ExtHostCommentThread; createCommentThread(arg0: vscode.Uri | string, arg1: vscode.Uri | vscode.Range, arg2: vscode.Range | vscode.Comment[], arg3?: vscode.Comment[]): vscode.CommentThread { if (typeof arg0 === 'string') { - const commentThread = new ExtHostCommentThread(this.id, this.handle, arg0, arg1 as vscode.Uri, arg2 as vscode.Range, arg3 as vscode.Comment[], this._extension.identifier); + const commentThread = new ExtHostCommentThread(this.id, this.handle, arg0, arg1 as vscode.Uri, arg2 as vscode.Range, arg3 as vscode.Comment[], this._extension); this._threads.set(commentThread.handle, commentThread); return commentThread; } else { - const commentThread = new ExtHostCommentThread(this.id, this.handle, undefined, arg0 as vscode.Uri, arg1 as vscode.Range, arg2 as vscode.Comment[], this._extension.identifier); + const commentThread = new ExtHostCommentThread(this.id, this.handle, undefined, arg0 as vscode.Uri, arg1 as vscode.Range, arg2 as vscode.Comment[], this._extension); this._threads.set(commentThread.handle, commentThread); return commentThread; } } $createCommentThreadTemplate(uriComponents: UriComponents, range: IRange): ExtHostCommentThread { - const commentThread = new ExtHostCommentThread(this.id, this.handle, undefined, URI.revive(uriComponents), extHostTypeConverter.Range.to(range), [], this._extension.identifier); + const commentThread = new ExtHostCommentThread(this.id, this.handle, undefined, URI.revive(uriComponents), extHostTypeConverter.Range.to(range), [], this._extension); commentThread.collapsibleState = modes.CommentThreadCollapsibleState.Expanded; this._threads.set(commentThread.handle, commentThread); return commentThread; @@ -617,6 +618,10 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo const iconPath = vscodeComment.author && vscodeComment.author.iconPath ? vscodeComment.author.iconPath.toString() : undefined; + if (vscodeComment.timestamp) { + checkProposedApiEnabled(thread.extensionDescription, 'commentTimestamp'); + } + return { mode: vscodeComment.mode, contextValue: vscodeComment.contextValue, @@ -625,7 +630,8 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo userName: vscodeComment.author.name, userIconPath: iconPath, label: vscodeComment.label, - commentReactions: vscodeComment.reactions ? vscodeComment.reactions.map(reaction => convertToReaction(reaction)) : undefined + commentReactions: vscodeComment.reactions ? vscodeComment.reactions.map(reaction => convertToReaction(reaction)) : undefined, + timestamp: vscodeComment.timestamp }; } @@ -661,3 +667,4 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo return new ExtHostCommentsImpl(); } + diff --git a/src/vs/workbench/contrib/comments/browser/commentNode.ts b/src/vs/workbench/contrib/comments/browser/commentNode.ts index 163fd7af48666..022a5a608d186 100644 --- a/src/vs/workbench/contrib/comments/browser/commentNode.ts +++ b/src/vs/workbench/contrib/comments/browser/commentNode.ts @@ -36,6 +36,7 @@ import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { DropdownMenuActionViewItem } from 'vs/base/browser/ui/dropdown/dropdownActionViewItem'; import { Codicon } from 'vs/base/common/codicons'; import { MarshalledId } from 'vs/base/common/marshalling'; +import { TimestampWidget } from 'vs/workbench/contrib/comments/browser/timestamp'; export class CommentNode extends Disposable { private _domNode: HTMLElement; @@ -53,6 +54,7 @@ export class CommentNode extends Disposable { private _commentEditorDisposables: IDisposable[] = []; private _commentEditorModel: ITextModel | null = null; private _isPendingLabel!: HTMLElement; + private _timestamp: TimestampWidget | undefined; private _contextKeyService: IContextKeyService; private _commentContextValue: IContextKey; @@ -125,7 +127,8 @@ export class CommentNode extends Disposable { const header = dom.append(commentDetailsContainer, dom.$(`div.comment-title.${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME}`)); const author = dom.append(header, dom.$('strong.author')); author.innerText = this.comment.userName; - + this._timestamp = new TimestampWidget(header, this.comment.timestamp); + this._register(this._timestamp); this._isPendingLabel = dom.append(header, dom.$('span.isPending')); if (this.comment.label) { @@ -516,6 +519,10 @@ export class CommentNode extends Disposable { } else { this._commentContextValue.reset(); } + + if (this.comment.timestamp) { + this._timestamp?.setTimestamp(this.comment.timestamp); + } } focus() { diff --git a/src/vs/workbench/contrib/comments/browser/media/review.css b/src/vs/workbench/contrib/comments/browser/media/review.css index 0791ae5b94d75..8bf070482b73a 100644 --- a/src/vs/workbench/contrib/comments/browser/media/review.css +++ b/src/vs/workbench/contrib/comments/browser/media/review.css @@ -102,6 +102,12 @@ font-style: italic; } +.monaco-editor .review-widget .body .review-comment .review-comment-contents .timestamp { + line-height: 22px; + margin: 0 5px 0 5px; + padding: 0 2px 0 2px; +} + .monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-body { padding-top: 4px; } diff --git a/src/vs/workbench/contrib/comments/browser/timestamp.ts b/src/vs/workbench/contrib/comments/browser/timestamp.ts new file mode 100644 index 0000000000000..ac155df88af37 --- /dev/null +++ b/src/vs/workbench/contrib/comments/browser/timestamp.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { Timestamp } from 'vs/editor/common/modes'; +import * as dayjs from 'dayjs'; +import * as relativeTime from 'dayjs/plugin/relativeTime'; +import * as updateLocale from 'dayjs/plugin/updateLocale'; +import * as localizedFormat from 'dayjs/plugin/localizedFormat' + +dayjs.extend(relativeTime, { + thresholds: [ + { l: 's', r: 44, d: 'second' }, + { l: 'm', r: 89 }, + { l: 'mm', r: 44, d: 'minute' }, + { l: 'h', r: 89 }, + { l: 'hh', r: 21, d: 'hour' }, + { l: 'd', r: 35 }, + { l: 'dd', r: 6, d: 'day' }, + { l: 'w', r: 7 }, + { l: 'ww', r: 3, d: 'week' }, + { l: 'M', r: 4 }, + { l: 'MM', r: 10, d: 'month' }, + { l: 'y', r: 17 }, + { l: 'yy', d: 'year' }, + ], +}); + +dayjs.extend(updateLocale); +dayjs.updateLocale('en', { + relativeTime: { + past: '%s ago', + s: 'seconds', + m: 'a minute', + mm: '%d minutes', + h: 'an hour', + hh: '%d hours', + d: 'a day', + dd: '%d days', + w: 'a week', + ww: '%d weeks', + M: 'a month', + MM: '%d months', + y: 'a year', + yy: '%d years', + }, +}); +dayjs.extend(localizedFormat); + +export class TimestampWidget extends Disposable { + private _date: HTMLElement; + private _timestamp: Timestamp | undefined; + + constructor(container: HTMLElement, timeStamp?: Timestamp) { + super(); + this._date = dom.append(container, dom.$('span.timestamp')); + this.setTimestamp(timeStamp); + } + + public async setTimestamp(timestamp: Timestamp | undefined) { + if ((timestamp?.date !== this._timestamp?.date) || (timestamp?.useRelativeTime !== this._timestamp?.useRelativeTime)) { + this.updateDate(timestamp); + } + this._timestamp = timestamp; + } + + private updateDate(timestamp?: Timestamp) { + if (!timestamp) { + this._date.textContent = ''; + } else if ((timestamp.date !== this._timestamp?.date) + || (timestamp.useRelativeTime !== this._timestamp.useRelativeTime)) { + + let textContent: string; + let tooltip: string | undefined; + if (timestamp.useRelativeTime) { + textContent = this.getRelative(timestamp.date); + tooltip = timestamp.label ?? this.getDateString(timestamp.date); + } else { + textContent = timestamp.label ?? this.getDateString(timestamp.date); + } + + this._date.textContent = textContent; + if (tooltip) { + this._date.title = tooltip; + } + } + } + + private getRelative(date: Date): string { + const djs = dayjs(date); + const now = Date.now(); + const diff = djs.diff(now, 'month'); + if ((diff < 1) && (diff > -11)) { + return djs.fromNow(); + } + return this.getDateString(date); + } + + private getDateString(date: Date): string { + const djs = dayjs(date); + return djs.format('lll'); + } +} diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 0a1c1f9dcfb91..54045e660051e 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -7,6 +7,7 @@ export const allApiProposals = Object.freeze({ authSession: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authSession.d.ts', + commentTimestamp: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.commentTimestamp.d.ts', contribIconFonts: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribIconFonts.d.ts', contribIcons: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribIcons.d.ts', contribLabelFormatterWorkspaceTooltip: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribLabelFormatterWorkspaceTooltip.d.ts', diff --git a/src/vscode-dts/vscode.proposed.commentTimestamp.d.ts b/src/vscode-dts/vscode.proposed.commentTimestamp.d.ts new file mode 100644 index 0000000000000..25f86da097e5f --- /dev/null +++ b/src/vscode-dts/vscode.proposed.commentTimestamp.d.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + export interface Comment { + timestamp?: { + date: Date, + label?: string, + useRelativeTime?: boolean + } + } +} diff --git a/yarn.lock b/yarn.lock index 6c5cb2e2a1b2a..397c7e109dc97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3015,6 +3015,11 @@ data-uri-to-buffer@3: resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz#594b8973938c5bc2c33046535785341abc4f3636" integrity sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og== +dayjs@^1.10.7: + version "1.10.7" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468" + integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig== + debounce@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.1.0.tgz#6a1a4ee2a9dc4b7c24bb012558dbcdb05b37f408"