From 64054cb4a62df9502ff458d81883d17ab903c5bf Mon Sep 17 00:00:00 2001 From: mustard Date: Thu, 21 Jul 2022 09:09:12 +0000 Subject: [PATCH] Add in product changelog --- extensions/gitpod-shared/package.json | 4 +- extensions/gitpod-shared/scripts/inflate.js | 11 + extensions/gitpod-shared/src/common/cache.ts | 52 +++ extensions/gitpod-shared/src/extension.ts | 1 + extensions/gitpod-shared/src/releaseNote.ts | 347 +++++++++++++++++++ extensions/gitpod-web/package.json | 19 + extensions/gitpod-web/package.nls.json | 1 + extensions/gitpod-web/src/extension.ts | 3 +- extensions/yarn.lock | 7 +- 9 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 extensions/gitpod-shared/src/common/cache.ts create mode 100644 extensions/gitpod-shared/src/releaseNote.ts diff --git a/extensions/gitpod-shared/package.json b/extensions/gitpod-shared/package.json index a3d799c9d2f2c..45a44daa71b18 100644 --- a/extensions/gitpod-shared/package.json +++ b/extensions/gitpod-shared/package.json @@ -12,6 +12,7 @@ "prepare": "node scripts/inflate.js" }, "devDependencies": { + "@types/js-yaml": "^4.0.5", "@types/node": "16.x", "@types/uuid": "^8.3.1", "@types/ws": "^7.2.6" @@ -20,8 +21,9 @@ "@gitpod/gitpod-protocol": "main", "@gitpod/supervisor-api-grpc": "main", "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2", + "js-yaml": "^4.1.0", "reconnecting-websocket": "^4.4.0", + "utf-8-validate": "^5.0.2", "uuid": "^8.3.1", "vscode-nls": "^5.0.0", "ws": "^7.4.6", diff --git a/extensions/gitpod-shared/scripts/inflate.js b/extensions/gitpod-shared/scripts/inflate.js index 8e9d1704c80e3..134376ca8a9ed 100644 --- a/extensions/gitpod-shared/scripts/inflate.js +++ b/extensions/gitpod-shared/scripts/inflate.js @@ -12,6 +12,7 @@ const nls = { 'openSettings': 'Gitpod: Open Settings', 'openContext': 'Gitpod: Open Context', 'openDocumentation': 'Gitpod: Documentation', + 'showReleaseNotes': 'Gitpod: Show Release Notes', 'openDiscord': 'Gitpod: Open Community Chat', 'openTwitter': 'Gitpod: Follow us on Twitter', 'reportIssue': 'Gitpod: Report Issue', @@ -72,6 +73,11 @@ const commands = [ 'title': '%reportIssue%', 'enablement': 'gitpod.inWorkspace == true' }, + { + 'command': 'gitpod.showReleaseNotes', + 'title': '%showReleaseNotes%', + 'enablement': 'gitpod.inWorkspace == true' + }, { 'command': 'gitpod.upgradeSubscription', 'title': '%upgradeSubscription%', @@ -160,6 +166,11 @@ const remoteMenus = [ 'group': 'remote_00_gitpod_navigation@90', 'when': 'gitpod.inWorkspace == true' }, + { + 'command': 'gitpod.showReleaseNotes', + 'group': 'remote_00_gitpod_navigation@70', + 'when': 'gitpod.inWorkspace == true' + }, { 'command': 'gitpod.upgradeSubscription', 'group': 'remote_00_gitpod_navigation@100', diff --git a/extensions/gitpod-shared/src/common/cache.ts b/extensions/gitpod-shared/src/common/cache.ts new file mode 100644 index 0000000000000..38daf63d77689 --- /dev/null +++ b/extensions/gitpod-shared/src/common/cache.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +const CACHE_KEY = 'gitpod.cache'; + +interface CacheObject { + value: any; + expiration?: number; +} + +interface CacheMap { [key: string]: CacheObject } + +export class CacheHelper { + constructor(private readonly context: vscode.ExtensionContext) { } + + set(key: string, value: any, expiration?: number) { + let obj = this.context.globalState.get(CACHE_KEY); + if (!obj) { + obj = {}; + } + const exp = expiration ? (Date.now() / 1000 + expiration) : undefined; + obj[key] = { value, expiration: exp }; + return this.context.globalState.update(CACHE_KEY, obj); + } + + get(key: string): T | undefined { + const value = this.context.globalState.get(CACHE_KEY); + if (!value || !value[key]) { + return undefined; + } + const data = value[key]; + if (!data.expiration) { + return data.value; + } + const now = Date.now() / 1000; + return now > data.expiration ? undefined : data.value; + } + + async getOrRefresh(key: string, refreshCallback: () => Thenable<{ value: T; ttl?: number }>): Promise { + let value = this.get(key); + if (value === undefined) { + const result = await refreshCallback(); + await this.set(key, result.value, result.ttl); + value = result.value; + } + return value; + } +} diff --git a/extensions/gitpod-shared/src/extension.ts b/extensions/gitpod-shared/src/extension.ts index 27b3064d24cfd..1d2654cdf718c 100644 --- a/extensions/gitpod-shared/src/extension.ts +++ b/extensions/gitpod-shared/src/extension.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { registerActiveLanguageAnalytics, registerUsageAnalytics } from './analytics'; import { createGitpodExtensionContext, GitpodExtensionContext, registerDefaultLayout, registerNotifications, registerWorkspaceCommands, registerWorkspaceSharing, registerWorkspaceTimeout } from './features'; +export { registerReleaseNotesView } from './releaseNote'; export { GitpodExtensionContext, registerTasks, SupervisorConnection, registerIpcHookCli } from './features'; export * from './gitpod-plugin-model'; diff --git a/extensions/gitpod-shared/src/releaseNote.ts b/extensions/gitpod-shared/src/releaseNote.ts new file mode 100644 index 0000000000000..4511f9aebfd97 --- /dev/null +++ b/extensions/gitpod-shared/src/releaseNote.ts @@ -0,0 +1,347 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import fetch, { Response } from 'node-fetch'; +import * as vscode from 'vscode'; +import { load } from 'js-yaml'; +import { CacheHelper } from './common/cache'; + +const LAST_READ_RELEASE_NOTES_ID = 'gitpod.lastReadReleaseNotesId'; + +export function registerReleaseNotesView(context: vscode.ExtensionContext) { + const cacheHelper = new CacheHelper(context); + + async function shouldShowReleaseNotes(lastReadId: string | undefined) { + const releaseId = await getLastPublish(cacheHelper); + console.log(`gitpod release notes lastReadId: ${lastReadId}, latestReleaseId: ${releaseId}`); + return releaseId !== lastReadId; + } + + context.subscriptions.push( + vscode.commands.registerCommand('gitpod.showReleaseNotes', () => { + ReleaseNotesPanel.createOrShow(context, cacheHelper); + }) + ); + + // sync between machines + context.globalState.setKeysForSync([LAST_READ_RELEASE_NOTES_ID]); + + const lastReadId = context.globalState.get(LAST_READ_RELEASE_NOTES_ID); + shouldShowReleaseNotes(lastReadId).then(shouldShow => { + if (shouldShow) { + ReleaseNotesPanel.createOrShow(context, cacheHelper); + } + }); +} + +function getResponseCacheTime(resp: Response) { + const v = resp.headers.get('Cache-Control'); + if (!v) { + return undefined; + } + const t = /max-age=(\d+)/.exec(v); + if (!t) { + return undefined; + } + return Number(t[1]); +} + +async function getLastPublish(cacheHelper: CacheHelper) { + const url = `${websiteHost}/changelog/latest`; + return cacheHelper.getOrRefresh(url, async () => { + const resp = await fetch(url); + if (!resp.ok) { + throw new Error(`Getting latest releaseId failed: ${resp.statusText}`); + } + const { releaseId } = JSON.parse(await resp.text()); + return { + value: releaseId as string, + ttl: getResponseCacheTime(resp), + }; + }); + +} + +const websiteHost = 'https://www.gitpod.io'; + +class ReleaseNotesPanel { + public static currentPanel: ReleaseNotesPanel | undefined; + public static readonly viewType = 'gitpodReleaseNotes'; + private readonly panel: vscode.WebviewPanel; + private lastReadId: string | undefined; + private _disposables: vscode.Disposable[] = []; + + private async loadChangelog(releaseId: string) { + const url = `${websiteHost}/changelog/raw-markdown?releaseId=${releaseId}`; + const md = await this.cacheHelper.getOrRefresh(url, async () => { + const resp = await fetch(url); + if (!resp.ok) { + throw new Error(`Getting raw markdown content failed: ${resp.statusText}`); + } + const md = await resp.text(); + return { + value: md, + ttl: getResponseCacheTime(resp), + }; + }); + + const parseInfo = (md: string) => { + if (!md.startsWith('---')) { + return; + } + const lines = md.split('\n'); + const end = lines.indexOf('---', 1); + const content = lines.slice(1, end).join('\n'); + return load(content) as { title: string; date: string; image: string; alt: string; excerpt: string }; + }; + const info = parseInfo(md); + + const content = md + .replace(/---.*?---/gms, '') + .replace(/