From adb8450420fee8b22d04c2f808dd4e2caad62b0f Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Thu, 13 Jan 2022 11:33:02 +0100 Subject: [PATCH] Add timestamps to comments proposal (#139849) Part of #139524 --- src/vs/base/common/date.ts | 146 +++++++++++++----- src/vs/base/common/marshalling.ts | 2 + src/vs/editor/common/languages.ts | 1 + .../api/browser/mainThreadComments.ts | 22 ++- .../workbench/api/common/extHost.protocol.ts | 18 ++- .../workbench/api/common/extHostComments.ts | 35 +++-- .../contrib/comments/browser/commentNode.ts | 40 ++++- .../comments/browser/comments.contribution.ts | 6 + .../contrib/comments/browser/media/review.css | 6 + .../contrib/comments/browser/timestamp.ts | 66 ++++++++ .../common/extensionsApiProposals.ts | 1 + .../vscode.proposed.commentTimestamp.d.ts | 15 ++ 12 files changed, 305 insertions(+), 53 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/src/vs/base/common/date.ts b/src/vs/base/common/date.ts index a948b8ddb1a..a188f1341ab 100644 --- a/src/vs/base/common/date.ts +++ b/src/vs/base/common/date.ts @@ -12,7 +12,7 @@ const week = day * 7; const month = day * 30; const year = day * 365; -export function fromNow(date: number | Date, appendAgoLabel?: boolean): string { +export function fromNow(date: number | Date, appendAgoLabel?: boolean, useFullTimeWords?: boolean): string { if (typeof date !== 'number') { date = date.getTime(); } @@ -31,39 +31,75 @@ export function fromNow(date: number | Date, appendAgoLabel?: boolean): string { value = seconds; if (appendAgoLabel) { - return value === 1 - ? localize('date.fromNow.seconds.singular.ago', '{0} sec ago', value) - : localize('date.fromNow.seconds.plural.ago', '{0} secs ago', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.seconds.singular.ago.fullWord', '{0} second ago', value) + : localize('date.fromNow.seconds.singular.ago', '{0} sec ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.seconds.plural.ago.fullWord', '{0} seconds ago', value) + : localize('date.fromNow.seconds.plural.ago', '{0} secs ago', value); + } } else { - return value === 1 - ? localize('date.fromNow.seconds.singular', '{0} sec', value) - : localize('date.fromNow.seconds.plural', '{0} secs', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.seconds.singular.fullWord', '{0} second', value) + : localize('date.fromNow.seconds.singular', '{0} sec', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.seconds.plural.fullWord', '{0} seconds', value) + : localize('date.fromNow.seconds.plural', '{0} secs', value); + } } } if (seconds < hour) { value = Math.floor(seconds / minute); if (appendAgoLabel) { - return value === 1 - ? localize('date.fromNow.minutes.singular.ago', '{0} min ago', value) - : localize('date.fromNow.minutes.plural.ago', '{0} mins ago', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.minutes.singular.ago.fullWord', '{0} minute ago', value) + : localize('date.fromNow.minutes.singular.ago', '{0} min ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.minutes.plural.ago.fullWord', '{0} minutes ago', value) + : localize('date.fromNow.minutes.plural.ago', '{0} mins ago', value); + } } else { - return value === 1 - ? localize('date.fromNow.minutes.singular', '{0} min', value) - : localize('date.fromNow.minutes.plural', '{0} mins', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.minutes.singular.fullWord', '{0} minute', value) + : localize('date.fromNow.minutes.singular', '{0} min', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.minutes.plural.fullWord', '{0} minutes', value) + : localize('date.fromNow.minutes.plural', '{0} mins', value); + } } } if (seconds < day) { value = Math.floor(seconds / hour); if (appendAgoLabel) { - return value === 1 - ? localize('date.fromNow.hours.singular.ago', '{0} hr ago', value) - : localize('date.fromNow.hours.plural.ago', '{0} hrs ago', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.hours.singular.ago.fullWord', '{0} hour ago', value) + : localize('date.fromNow.hours.singular.ago', '{0} hr ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.hours.plural.ago.fullWord', '{0} hours ago', value) + : localize('date.fromNow.hours.plural.ago', '{0} hrs ago', value); + } } else { - return value === 1 - ? localize('date.fromNow.hours.singular', '{0} hr', value) - : localize('date.fromNow.hours.plural', '{0} hrs', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.hours.singular.fullWord', '{0} hour', value) + : localize('date.fromNow.hours.singular', '{0} hr', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.hours.plural.fullWord', '{0} hours', value) + : localize('date.fromNow.hours.plural', '{0} hrs', value); + } } } @@ -83,38 +119,74 @@ export function fromNow(date: number | Date, appendAgoLabel?: boolean): string { if (seconds < month) { value = Math.floor(seconds / week); if (appendAgoLabel) { - return value === 1 - ? localize('date.fromNow.weeks.singular.ago', '{0} wk ago', value) - : localize('date.fromNow.weeks.plural.ago', '{0} wks ago', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.weeks.singular.ago.fullWord', '{0} week ago', value) + : localize('date.fromNow.weeks.singular.ago', '{0} wk ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.weeks.plural.ago.fullWord', '{0} weeks ago', value) + : localize('date.fromNow.weeks.plural.ago', '{0} wks ago', value); + } } else { - return value === 1 - ? localize('date.fromNow.weeks.singular', '{0} wk', value) - : localize('date.fromNow.weeks.plural', '{0} wks', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.weeks.singular.fullWord', '{0} week', value) + : localize('date.fromNow.weeks.singular', '{0} wk', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.weeks.plural.fullWord', '{0} weeks', value) + : localize('date.fromNow.weeks.plural', '{0} wks', value); + } } } if (seconds < year) { value = Math.floor(seconds / month); if (appendAgoLabel) { - return value === 1 - ? localize('date.fromNow.months.singular.ago', '{0} mo ago', value) - : localize('date.fromNow.months.plural.ago', '{0} mos ago', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.months.singular.ago.fullWord', '{0} month ago', value) + : localize('date.fromNow.months.singular.ago', '{0} mo ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.months.plural.ago.fullWord', '{0} months ago', value) + : localize('date.fromNow.months.plural.ago', '{0} mos ago', value); + } } else { - return value === 1 - ? localize('date.fromNow.months.singular', '{0} mo', value) - : localize('date.fromNow.months.plural', '{0} mos', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.months.singular.fullWord', '{0} month', value) + : localize('date.fromNow.months.singular', '{0} mo', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.months.plural.fullWord', '{0} months', value) + : localize('date.fromNow.months.plural', '{0} mos', value); + } } } value = Math.floor(seconds / year); if (appendAgoLabel) { - return value === 1 - ? localize('date.fromNow.years.singular.ago', '{0} yr ago', value) - : localize('date.fromNow.years.plural.ago', '{0} yrs ago', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.years.singular.ago.fullWord', '{0} year ago', value) + : localize('date.fromNow.years.singular.ago', '{0} yr ago', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.years.plural.ago.fullWord', '{0} years ago', value) + : localize('date.fromNow.years.plural.ago', '{0} yrs ago', value); + } } else { - return value === 1 - ? localize('date.fromNow.years.singular', '{0} yr', value) - : localize('date.fromNow.years.plural', '{0} yrs', value); + if (value === 1) { + return useFullTimeWords + ? localize('date.fromNow.years.singular.fullWord', '{0} year', value) + : localize('date.fromNow.years.singular', '{0} yr', value); + } else { + return useFullTimeWords + ? localize('date.fromNow.years.plural.fullWord', '{0} years', value) + : localize('date.fromNow.years.plural', '{0} yrs', value); + } } } diff --git a/src/vs/base/common/marshalling.ts b/src/vs/base/common/marshalling.ts index 789cdf08a16..e8edabcea5d 100644 --- a/src/vs/base/common/marshalling.ts +++ b/src/vs/base/common/marshalling.ts @@ -31,6 +31,7 @@ export const enum MarshalledId { TimelineActionContext, NotebookCellActionContext, TestItemContext, + Date, } export interface MarshalledObject { @@ -68,6 +69,7 @@ export function revive(obj: any, depth = 0): Revived { switch ((obj).$mid) { case MarshalledId.Uri: return URI.revive(obj); case MarshalledId.Regexp: return new RegExp(obj.source, obj.flags); + case MarshalledId.Date: return new Date(obj.source); } if ( diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index f05132706eb..52c84e3e2e7 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -1716,6 +1716,7 @@ export interface Comment { readonly commentReactions?: CommentReaction[]; readonly label?: string; readonly mode?: CommentMode; + readonly detail?: Date | string; } /** diff --git a/src/vs/workbench/api/browser/mainThreadComments.ts b/src/vs/workbench/api/browser/mainThreadComments.ts index 6c0a4b1c4c3..d85214116b9 100644 --- a/src/vs/workbench/api/browser/mainThreadComments.ts +++ b/src/vs/workbench/api/browser/mainThreadComments.ts @@ -15,7 +15,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; import { ICommentInfo, ICommentService } from 'vs/workbench/contrib/comments/browser/commentService'; import { CommentsPanel } from 'vs/workbench/contrib/comments/browser/commentsView'; -import { CommentProviderFeatures, ExtHostCommentsShape, ExtHostContext, IExtHostContext, MainContext, MainThreadCommentsShape, CommentThreadChanges } from '../common/extHost.protocol'; +import { CommentProviderFeatures, ExtHostCommentsShape, ExtHostContext, IExtHostContext, MainContext, MainThreadCommentsShape, CommentThreadChanges, CommentChanges } from '../common/extHost.protocol'; import { COMMENTS_VIEW_ID, COMMENTS_VIEW_TITLE } from 'vs/workbench/contrib/comments/browser/commentsTreeViewer'; import { ViewContainer, IViewContainersRegistry, Extensions as ViewExtensions, ViewContainerLocation, IViewsRegistry, IViewsService, IViewDescriptorService } from 'vs/workbench/common/views'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; @@ -23,7 +23,7 @@ import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneCont import { Codicon } from 'vs/base/common/codicons'; import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; import { localize } from 'vs/nls'; -import { MarshalledId } from 'vs/base/common/marshalling'; +import { MarshalledId, revive } from 'vs/base/common/marshalling'; export class MainThreadCommentThread implements modes.CommentThread { @@ -139,11 +139,27 @@ export class MainThreadCommentThread implements modes.CommentThread { if (modified('range')) { this._range = changes.range!; } if (modified('label')) { this._label = changes.label; } if (modified('contextValue')) { this._contextValue = changes.contextValue === null ? undefined : changes.contextValue; } - if (modified('comments')) { this._comments = changes.comments; } + if (modified('comments')) { this._comments = this.commentsFromCommentChanges(changes.comments); } if (modified('collapseState')) { this._collapsibleState = changes.collapseState; } if (modified('canReply')) { this.canReply = changes.canReply!; } } + private commentsFromCommentChanges(comments?: CommentChanges[]): modes.Comment[] | undefined { + return comments?.map(comment => { + return { + body: comment.body, + uniqueIdInThread: comment.uniqueIdInThread, + userName: comment.userName, + commentReactions: comment.commentReactions, + contextValue: comment.contextValue, + detail: comment.detail ? revive(comment.detail) : undefined, + label: comment.label, + mode: comment.mode, + userIconPath: comment.userIconPath + }; + }); + } + dispose() { this._isDisposed = true; this._onDidChangeCollasibleState.dispose(); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index d8a3274728f..64f5ebaf7df 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -10,7 +10,7 @@ import { SerializedError } from 'vs/base/common/errors'; import { IRelativePattern } from 'vs/base/common/glob'; import { IMarkdownString } from 'vs/base/common/htmlContent'; import { IDisposable } from 'vs/base/common/lifecycle'; -import { revive } from 'vs/base/common/marshalling'; +import { MarshalledId, revive } from 'vs/base/common/marshalling'; import * as performance from 'vs/base/common/performance'; import Severity from 'vs/base/common/severity'; import { Dto } from 'vs/base/common/types'; @@ -160,11 +160,25 @@ export interface CommentProviderFeatures { options?: modes.CommentOptions; } +export interface CommentChanges { + readonly uniqueIdInThread: number; + readonly body: IMarkdownString; + readonly userName: string; + readonly userIconPath?: string; + readonly contextValue?: string; + readonly commentReactions?: modes.CommentReaction[]; + readonly label?: string; + readonly mode?: modes.CommentMode; + readonly detail?: { + $mid: MarshalledId.Date + } | string; +} + export type CommentThreadChanges = Partial<{ range: IRange, label: string, contextValue: string | null, - comments: modes.Comment[], + comments: CommentChanges[], collapseState: modes.CommentThreadCollapsibleState; canReply: boolean; }>; diff --git a/src/vs/workbench/api/common/extHostComments.ts b/src/vs/workbench/api/common/extHostComments.ts index 190c0f44d39..915d28eb866 100644 --- a/src/vs/workbench/api/common/extHostComments.ts +++ b/src/vs/workbench/api/common/extHostComments.ts @@ -16,8 +16,9 @@ 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 { ExtHostCommentsShape, IMainContext, MainContext, CommentThreadChanges, CommentChanges } from './extHost.protocol'; import { ExtHostCommands } from './extHostCommands'; type ProviderHandle = number; @@ -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 = []; @@ -433,7 +434,7 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo } if (modified('comments')) { formattedModifications.comments = - this._comments.map(cmt => convertToModeComment(this, cmt, this._commentsMap)); + this._comments.map(cmt => convertToDTOComment(this, cmt, this._commentsMap)); } if (modified('collapsibleState')) { formattedModifications.collapseState = convertToCollapsibleState(this._collapseState); @@ -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; @@ -608,7 +609,7 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo } } - function convertToModeComment(thread: ExtHostCommentThread, vscodeComment: vscode.Comment, commentsMap: Map): modes.Comment { + function convertToDTOComment(thread: ExtHostCommentThread, vscodeComment: vscode.Comment, commentsMap: Map): CommentChanges { let commentUniqueId = commentsMap.get(vscodeComment)!; if (!commentUniqueId) { commentUniqueId = ++thread.commentHandle; @@ -617,6 +618,20 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo const iconPath = vscodeComment.author && vscodeComment.author.iconPath ? vscodeComment.author.iconPath.toString() : undefined; + if (vscodeComment.detail) { + checkProposedApiEnabled(thread.extensionDescription, 'commentTimestamp'); + } + + let detail: { $mid: MarshalledId.Date, source: any } | string | undefined; + if (vscodeComment.detail && (typeof vscodeComment.detail !== 'string')) { + detail = { + source: vscodeComment.detail, + $mid: MarshalledId.Date + }; + } else { + detail = vscodeComment.detail; + } + return { mode: vscodeComment.mode, contextValue: vscodeComment.contextValue, @@ -625,7 +640,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, + detail: detail }; } @@ -661,3 +677,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 51763c76158..bae1f3ed23f 100644 --- a/src/vs/workbench/contrib/comments/browser/commentNode.ts +++ b/src/vs/workbench/contrib/comments/browser/commentNode.ts @@ -36,6 +36,8 @@ 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'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; export class CommentNode extends Disposable { private _domNode: HTMLElement; @@ -53,6 +55,8 @@ export class CommentNode extends Disposable { private _commentEditorDisposables: IDisposable[] = []; private _commentEditorModel: ITextModel | null = null; private _isPendingLabel!: HTMLElement; + private _detail: HTMLElement | undefined; + private _timestamp: TimestampWidget | undefined; private _contextKeyService: IContextKeyService; private _commentContextValue: IContextKey; @@ -83,7 +87,8 @@ export class CommentNode extends Disposable { @ILanguageService private languageService: ILanguageService, @INotificationService private notificationService: INotificationService, @IContextMenuService private contextMenuService: IContextMenuService, - @IContextKeyService contextKeyService: IContextKeyService + @IContextKeyService contextKeyService: IContextKeyService, + @IConfigurationService private configurationService: IConfigurationService ) { super(); @@ -121,11 +126,38 @@ export class CommentNode extends Disposable { return this._onDidClick.event; } + private createDetail(container: HTMLElement) { + this._detail = dom.append(container, dom.$('span.detail')); + this.updateDetail(this.comment.detail); + } + + private updateDetail(detail?: Date | string) { + if (!this._detail) { + return; + } + + if (!detail) { + this._timestamp?.dispose(); + this._detail.innerText = ''; + } else if (typeof detail === 'string') { + this._timestamp?.dispose(); + this._detail.innerText = detail; + } else { + this._detail.innerText = ''; + if (!this._timestamp) { + this._timestamp = new TimestampWidget(this.configurationService, this._detail, detail); + this._register(this._timestamp); + } else { + this._timestamp.setTimestamp(detail); + } + } + } + private createHeader(commentDetailsContainer: HTMLElement): void { 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.createDetail(header); this._isPendingLabel = dom.append(header, dom.$('span.isPending')); if (this.comment.label) { @@ -516,6 +548,10 @@ export class CommentNode extends Disposable { } else { this._commentContextValue.reset(); } + + if (this.comment.detail) { + this.updateDetail(this.comment.detail); + } } focus() { diff --git a/src/vs/workbench/contrib/comments/browser/comments.contribution.ts b/src/vs/workbench/contrib/comments/browser/comments.contribution.ts index d01db82f2c1..f2c6b1d3b4b 100644 --- a/src/vs/workbench/contrib/comments/browser/comments.contribution.ts +++ b/src/vs/workbench/contrib/comments/browser/comments.contribution.ts @@ -25,6 +25,12 @@ Registry.as(ConfigurationExtensions.Configuration).regis default: 'openOnSessionStartWithComments', description: nls.localize('openComments', "Controls when the comments panel should open."), restricted: false + }, + 'comments.useRelativeTime': { + type: 'boolean', + default: true, + description: nls.localize('useRelativeTime', "Determines if relative time will be used in comment timestamps (ex. '1 day ago').") + } } }); diff --git a/src/vs/workbench/contrib/comments/browser/media/review.css b/src/vs/workbench/contrib/comments/browser/media/review.css index 0791ae5b94d..8bf070482b7 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 00000000000..802cf8cb698 --- /dev/null +++ b/src/vs/workbench/contrib/comments/browser/timestamp.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { fromNow } from 'vs/base/common/date'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; + +const USE_RELATIVE_TIME_CONFIGURATION = 'comments.useRelativeTime'; + +export class TimestampWidget extends Disposable { + private _date: HTMLElement; + private _timestamp: Date | undefined; + private _useRelativeTime: boolean; + + constructor(private configurationService: IConfigurationService, container: HTMLElement, timeStamp?: Date) { + super(); + this._date = dom.append(container, dom.$('span.timestamp')); + this._useRelativeTime = this.useRelativeTimeSetting; + this.setTimestamp(timeStamp); + } + + private get useRelativeTimeSetting(): boolean { + return this.configurationService.getValue(USE_RELATIVE_TIME_CONFIGURATION); + } + + public async setTimestamp(timestamp: Date | undefined) { + if ((timestamp !== this._timestamp) || (this.useRelativeTimeSetting !== this._useRelativeTime)) { + this.updateDate(timestamp); + } + this._timestamp = timestamp; + this._useRelativeTime = this.useRelativeTimeSetting; + } + + private updateDate(timestamp?: Date) { + if (!timestamp) { + this._date.textContent = ''; + } else if ((timestamp !== this._timestamp) + || (this.useRelativeTimeSetting !== this._useRelativeTime)) { + + let textContent: string; + let tooltip: string | undefined; + if (this.useRelativeTimeSetting) { + textContent = this.getRelative(timestamp); + tooltip = this.getDateString(timestamp); + } else { + textContent = this.getDateString(timestamp); + } + + this._date.textContent = textContent; + if (tooltip) { + this._date.title = tooltip; + } + } + } + + private getRelative(date: Date): string { + return fromNow(date, true, true); + } + + private getDateString(date: Date): string { + return date.toLocaleString(); + } +} diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 6e72752e6af..ca0a04329d4 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 00000000000..1039db61b98 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.commentTimestamp.d.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + /** + * An optional detail that will be displayed less prominently than the `author`. + * If a date is provided, then the date will be formatted according to the user's + * locale and settings. + */ + detail?: Date | string + } +}