diff --git a/package.json b/package.json index d5eeace7..a7d9726d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "onCommand:gitpod.syncProvider.remove", "onCommand:gitpod.exportLogs", "onCommand:gitpod.api.autoTunnel", + "onCommand:gitpod.showReleaseNotes", "onAuthenticationRequest:gitpod", "onUri" ], @@ -92,6 +93,11 @@ "command": "gitpod.exportLogs", "category": "Gitpod", "title": "Export all logs" + }, + { + "command": "gitpod.showReleaseNotes", + "category": "Gitpod", + "title": "Show Release Notes" } ] }, @@ -109,6 +115,7 @@ "@types/analytics-node": "^3.1.9", "@types/crypto-js": "4.1.1", "@types/google-protobuf": "^3.7.4", + "@types/js-yaml": "^4.0.5", "@types/node": "16.x", "@types/node-fetch": "^2.5.12", "@types/semver": "^7.3.10", @@ -135,6 +142,7 @@ "@gitpod/local-app-api-grpcweb": "main", "@improbable-eng/grpc-web-node-http-transport": "^0.14.0", "analytics-node": "^6.0.0", + "js-yaml": "^4.1.0", "node-fetch": "2.6.7", "pkce-challenge": "^3.0.0", "semver": "^7.3.7", diff --git a/src/common/cache.ts b/src/common/cache.ts new file mode 100644 index 00000000..8b17a8b8 --- /dev/null +++ b/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 ? ((+ new Date()) / 1000 + expiration) : undefined; + obj[key] = { value, expiration: exp }; + return this.context.globalState.update(CACHE_KEY, obj); + } + + get(key: string) { + 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 = (+ new Date()) / 1000; + return now > data.expiration ? undefined : data.value; + } + + async handy(key: string, cb: () => Thenable<{ value: T; ttl?: number }>) { + let d = this.get(key); + if (d === undefined) { + const tmp = await cb(); + await this.set(key, tmp.value, tmp.ttl); + d = tmp.value; + } + return d as T; + } +} diff --git a/src/extension.ts b/src/extension.ts index 02f8055f..cd74b81b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,6 +12,7 @@ import { enableSettingsSync, updateSyncContext } from './settingsSync'; import { GitpodServer } from './gitpodServer'; import TelemetryReporter from './telemetryReporter'; import { exportLogs } from './exportLogs'; +import { registerReleaseNotesView } from './releaseNotes'; const EXTENSION_ID = 'gitpod.gitpod-desktop'; const FIRST_INSTALL_KEY = 'gitpod-desktop.firstInstall'; @@ -89,6 +90,8 @@ export async function activate(context: vscode.ExtensionContext) { await context.globalState.update(FIRST_INSTALL_KEY, true); telemetry.sendTelemetryEvent('gitpod_desktop_installation', { kind: 'install' }); } + + registerReleaseNotesView(context); } export async function deactivate() { diff --git a/src/releaseNotes.ts b/src/releaseNotes.ts new file mode 100644 index 00000000..51504c54 --- /dev/null +++ b/src/releaseNotes.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.handy(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.handy(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(/