From 7a4092f1b0c3aa83ab94fb60268e087fe431cd81 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Mon, 20 Mar 2023 09:41:52 +0100 Subject: [PATCH] Package hover: Show when last published (#177634) --- extensions/npm/src/features/date.ts | 201 ++++++++++++++++++ .../src/features/packageJSONContribution.ts | 19 +- 2 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 extensions/npm/src/features/date.ts diff --git a/extensions/npm/src/features/date.ts b/extensions/npm/src/features/date.ts new file mode 100644 index 00000000000..e2f3b44f818 --- /dev/null +++ b/extensions/npm/src/features/date.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { l10n } from 'vscode'; + + +const minute = 60; +const hour = minute * 60; +const day = hour * 24; +const week = day * 7; +const month = day * 30; +const year = day * 365; + +/** + * Create a localized of the time between now and the specified date. + * @param date The date to generate the difference from. + * @param appendAgoLabel Whether to append the " ago" to the end. + * @param useFullTimeWords Whether to use full words (eg. seconds) instead of + * shortened (eg. secs). + * @param disallowNow Whether to disallow the string "now" when the difference + * is less than 30 seconds. + */ +export function fromNow(date: number | Date, appendAgoLabel?: boolean, useFullTimeWords?: boolean, disallowNow?: boolean): string { + if (typeof date !== 'number') { + date = date.getTime(); + } + + const seconds = Math.round((new Date().getTime() - date) / 1000); + if (seconds < -30) { + return l10n.t('in {0}', fromNow(new Date().getTime() + seconds * 1000, false)); + } + + if (!disallowNow && seconds < 30) { + return l10n.t('now'); + } + + let value: number; + if (seconds < minute) { + value = seconds; + + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} second ago', value) + : l10n.t('{0} sec ago', value); + } else { + return useFullTimeWords + ? l10n.t('{0} seconds ago', value) + : l10n.t('{0} secs ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} second', value) + : l10n.t('{0} sec', value); + } else { + return useFullTimeWords + ? l10n.t('{0} seconds', value) + : l10n.t('{0} secs', value); + } + } + } + + if (seconds < hour) { + value = Math.floor(seconds / minute); + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} minute ago', value) + : l10n.t('{0} min ago', value); + } else { + return useFullTimeWords + ? l10n.t('{0} minutes ago', value) + : l10n.t('{0} mins ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} minute', value) + : l10n.t('{0} min', value); + } else { + return useFullTimeWords + ? l10n.t('{0} minutes', value) + : l10n.t('{0} mins', value); + } + } + } + + if (seconds < day) { + value = Math.floor(seconds / hour); + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} hour ago', value) + : l10n.t('{0} hr ago', value); + } else { + return useFullTimeWords + ? l10n.t('{0} hours ago', value) + : l10n.t('{0} hrs ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} hour', value) + : l10n.t('{0} hr', value); + } else { + return useFullTimeWords + ? l10n.t('{0} hours', value) + : l10n.t('{0} hrs', value); + } + } + } + + if (seconds < week) { + value = Math.floor(seconds / day); + if (appendAgoLabel) { + return value === 1 + ? l10n.t('{0} day ago', value) + : l10n.t('{0} days ago', value); + } else { + return value === 1 + ? l10n.t('{0} day', value) + : l10n.t('{0} days', value); + } + } + + if (seconds < month) { + value = Math.floor(seconds / week); + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} week ago', value) + : l10n.t('{0} wk ago', value); + } else { + return useFullTimeWords + ? l10n.t('{0} weeks ago', value) + : l10n.t('{0} wks ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} week', value) + : l10n.t('{0} wk', value); + } else { + return useFullTimeWords + ? l10n.t('{0} weeks', value) + : l10n.t('{0} wks', value); + } + } + } + + if (seconds < year) { + value = Math.floor(seconds / month); + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} month ago', value) + : l10n.t('{0} mo ago', value); + } else { + return useFullTimeWords + ? l10n.t('{0} months ago', value) + : l10n.t('{0} mos ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} month', value) + : l10n.t('{0} mo', value); + } else { + return useFullTimeWords + ? l10n.t('{0} months', value) + : l10n.t('{0} mos', value); + } + } + } + + value = Math.floor(seconds / year); + if (appendAgoLabel) { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} year ago', value) + : l10n.t('{0} yr ago', value); + } else { + return useFullTimeWords + ? l10n.t('{0} years ago', value) + : l10n.t('{0} yrs ago', value); + } + } else { + if (value === 1) { + return useFullTimeWords + ? l10n.t('{0} year', value) + : l10n.t('{0} yr', value); + } else { + return useFullTimeWords + ? l10n.t('{0} years', value) + : l10n.t('{0} yrs', value); + } + } +} diff --git a/extensions/npm/src/features/packageJSONContribution.ts b/extensions/npm/src/features/packageJSONContribution.ts index 7368f8cbf86..5a9250d97de 100644 --- a/extensions/npm/src/features/packageJSONContribution.ts +++ b/extensions/npm/src/features/packageJSONContribution.ts @@ -10,6 +10,7 @@ import { Location } from 'jsonc-parser'; import * as cp from 'child_process'; import { dirname } from 'path'; +import { fromNow } from './date'; const LIMIT = 40; @@ -215,14 +216,14 @@ export class PackageJSONContribution implements IJSONContribution { return null; } - private getDocumentation(description: string | undefined, version: string | undefined, homepage: string | undefined): MarkdownString { + private getDocumentation(description: string | undefined, version: string | undefined, time: string | undefined, homepage: string | undefined): MarkdownString { const str = new MarkdownString(); if (description) { str.appendText(description); } if (version) { str.appendText('\n\n'); - str.appendText(l10n.t("Latest version: {0}", version)); + str.appendText(time ? l10n.t("Latest version: {0} published {1}", version, fromNow(Date.parse(time), true, true)) : l10n.t("Latest version: {0}", version)); } if (homepage) { str.appendText('\n\n'); @@ -241,7 +242,7 @@ export class PackageJSONContribution implements IJSONContribution { return this.fetchPackageInfo(name, resource).then(info => { if (info) { - item.documentation = this.getDocumentation(info.description, info.version, info.homepage); + item.documentation = this.getDocumentation(info.description, info.version, info.time, info.homepage); return item; } return null; @@ -283,15 +284,17 @@ export class PackageJSONContribution implements IJSONContribution { private npmView(npmCommandPath: string, pack: string, resource: Uri | undefined): Promise { return new Promise((resolve, _reject) => { - const args = ['view', '--json', pack, 'description', 'dist-tags.latest', 'homepage', 'version']; + const args = ['view', '--json', pack, 'description', 'dist-tags.latest', 'homepage', 'version', 'time']; const cwd = resource && resource.scheme === 'file' ? dirname(resource.fsPath) : undefined; cp.execFile(npmCommandPath, args, { cwd }, (error, stdout) => { if (!error) { try { const content = JSON.parse(stdout); + const version = content['dist-tags.latest'] || content['version']; resolve({ description: content['description'], - version: content['dist-tags.latest'] || content['version'], + version, + time: content.time?.[version], homepage: content['homepage'] }); return; @@ -316,6 +319,7 @@ export class PackageJSONContribution implements IJSONContribution { return { description: obj.description || '', version, + time: obj.time?.[version], homepage: obj.homepage || '' }; } @@ -334,7 +338,7 @@ export class PackageJSONContribution implements IJSONContribution { if (typeof pack === 'string') { return this.fetchPackageInfo(pack, resource).then(info => { if (info) { - return [this.getDocumentation(info.description, info.version, info.homepage)]; + return [this.getDocumentation(info.description, info.version, info.time, info.homepage)]; } return null; }); @@ -363,7 +367,7 @@ export class PackageJSONContribution implements IJSONContribution { proposal.kind = CompletionItemKind.Property; proposal.insertText = insertText; proposal.filterText = JSON.stringify(name); - proposal.documentation = this.getDocumentation(pack.description, pack.version, pack?.links?.homepage); + proposal.documentation = this.getDocumentation(pack.description, pack.version, undefined, pack?.links?.homepage); collector.add(proposal); } } @@ -379,5 +383,6 @@ interface SearchPackageInfo { interface ViewPackageInfo { description: string; version?: string; + time?: string; homepage?: string; }