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..8b17a8b8e8eec --- /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<CacheMap>(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<CacheMap>(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<T>(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/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..51504c54c3d17 --- /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<string>(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(/<script>.*?<\/script>/gms, '') + .replace(/<Badge.*?text="(.*?)".*?\/>/gim, '`$1`') + .replace(/<Contributors usernames="(.*?)" \/>/gim, (_, p1) => { + const users = p1 + .split(',') + .map((e: string) => `[${e}](https://github.com/${e})`); + return `Contributors: ${users.join(', ')}`; + }) + .replace(/<p>(.*?)<\/p>/gm, '$1') + .replace(/^[\n]+/m, ''); + if (!info) { + return content; + } + + return [ + `# ${info.title}`, + `> Published at ${releaseId}, see also https://gitpod.io/changelog`, + `![${info.alt ?? 'image'}](https://www.gitpod.io/images/changelog/${info.image})`, + content, + ].join('\n\n'); + } + + public async updateHtml(releaseId?: string) { + if (!releaseId) { + releaseId = await getLastPublish(this.cacheHelper); + } + const mdContent = await this.loadChangelog(releaseId); + const html = await vscode.commands.executeCommand('markdown.api.render', mdContent) as string; + this.panel.webview.html = `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Gitpod Release Notes</title> + <style> + ${DEFAULT_MARKDOWN_STYLES} + </style> +</head> + <body> + ${html} + </body> +</html>`; + if (!this.lastReadId || releaseId > this.lastReadId) { + await this.context.globalState.update(LAST_READ_RELEASE_NOTES_ID, releaseId); + this.lastReadId = releaseId; + } + } + + public static createOrShow(context: vscode.ExtensionContext, cacheHelper: CacheHelper) { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + if (ReleaseNotesPanel.currentPanel) { + ReleaseNotesPanel.currentPanel.panel.reveal(column); + return; + } + + const panel = vscode.window.createWebviewPanel( + ReleaseNotesPanel.viewType, + 'Gitpod Release Notes', + column || vscode.ViewColumn.One, + { enableScripts: true }, + ); + + ReleaseNotesPanel.currentPanel = new ReleaseNotesPanel(context, cacheHelper, panel); + } + + public static revive(context: vscode.ExtensionContext, cacheHelper: CacheHelper, panel: vscode.WebviewPanel) { + ReleaseNotesPanel.currentPanel = new ReleaseNotesPanel(context, cacheHelper, panel); + } + + private constructor( + private readonly context: vscode.ExtensionContext, + private readonly cacheHelper: CacheHelper, + panel: vscode.WebviewPanel + ) { + this.lastReadId = this.context.globalState.get<string>(LAST_READ_RELEASE_NOTES_ID); + this.panel = panel; + + this.updateHtml(); + + this.panel.onDidDispose(() => this.dispose(), null, this._disposables); + this.panel.onDidChangeViewState( + () => { + if (this.panel.visible) { + this.updateHtml(); + } + }, + null, + this._disposables + ); + } + + public dispose() { + ReleaseNotesPanel.currentPanel = undefined; + this.panel.dispose(); + while (this._disposables.length) { + const x = this._disposables.pop(); + if (x) { + x.dispose(); + } + } + } +} + +// Align with https://github.com/gitpod-io/openvscode-server/blob/494f7eba3615344ee634e6bec0b20a1903e5881d/src/vs/workbench/contrib/markdown/browser/markdownDocumentRenderer.ts#L14 +export const DEFAULT_MARKDOWN_STYLES = ` +body { + padding: 10px 20px; + line-height: 22px; + max-width: 882px; + margin: 0 auto; +} + +body *:last-child { + margin-bottom: 0; +} + +img { + max-width: 100%; + max-height: 100%; +} + +a { + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +a:focus, +input:focus, +select:focus, +textarea:focus { + outline: 1px solid -webkit-focus-ring-color; + outline-offset: -1px; +} + +hr { + border: 0; + height: 2px; + border-bottom: 2px solid; +} + +h1 { + padding-bottom: 0.3em; + line-height: 1.2; + border-bottom-width: 1px; + border-bottom-style: solid; +} + +h1, h2, h3 { + font-weight: normal; +} + +table { + border-collapse: collapse; +} + +table > thead > tr > th { + text-align: left; + border-bottom: 1px solid; +} + +table > thead > tr > th, +table > thead > tr > td, +table > tbody > tr > th, +table > tbody > tr > td { + padding: 5px 10px; +} + +table > tbody > tr + tr > td { + border-top-width: 1px; + border-top-style: solid; +} + +blockquote { + margin: 0 7px 0 5px; + padding: 0 16px 0 10px; + border-left-width: 5px; + border-left-style: solid; +} + +code { + font-family: "SF Mono", Monaco, Menlo, Consolas, "Ubuntu Mono", "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace; +} + +pre code { + font-family: var(--vscode-editor-font-family); + font-weight: var(--vscode-editor-font-weight); + font-size: var(--vscode-editor-font-size); + line-height: 1.5; +} + +code > div { + padding: 16px; + border-radius: 3px; + overflow: auto; +} + +.monaco-tokenized-source { + white-space: pre; +} + +/** Theming */ + +.vscode-light code > div { + background-color: rgba(220, 220, 220, 0.4); +} + +.vscode-dark code > div { + background-color: rgba(10, 10, 10, 0.4); +} + +.vscode-high-contrast code > div { + background-color: var(--vscode-textCodeBlock-background); +} + +.vscode-high-contrast h1 { + border-color: rgb(0, 0, 0); +} + +.vscode-light table > thead > tr > th { + border-color: rgba(0, 0, 0, 0.69); +} + +.vscode-dark table > thead > tr > th { + border-color: rgba(255, 255, 255, 0.69); +} + +.vscode-light h1, +.vscode-light hr, +.vscode-light table > tbody > tr + tr > td { + border-color: rgba(0, 0, 0, 0.18); +} + +.vscode-dark h1, +.vscode-dark hr, +.vscode-dark table > tbody > tr + tr > td { + border-color: rgba(255, 255, 255, 0.18); +} + +`; diff --git a/extensions/gitpod-web/package.json b/extensions/gitpod-web/package.json index db5f9c7846d57..367d23ff9eba8 100644 --- a/extensions/gitpod-web/package.json +++ b/extensions/gitpod-web/package.json @@ -41,6 +41,11 @@ "title": "%stopWorkspace%", "enablement": "gitpod.inWorkspace == true && gitpod.workspaceOwned == true" }, + { + "command": "gitpod.showReleaseNotes", + "title": "%showReleaseNotes%", + "enablement": "gitpod.inWorkspace == true && gitpod.workspaceOwned == true" + }, { "command": "gitpod.open.settings", "title": "%openSettings%", @@ -81,6 +86,11 @@ "title": "%reportIssue%", "enablement": "gitpod.inWorkspace == true" }, + { + "command": "gitpod.showReleaseNotes", + "title": "%showReleaseNotes%", + "enablement": "gitpod.inWorkspace == true" + }, { "command": "gitpod.upgradeSubscription", "title": "%upgradeSubscription%", @@ -244,6 +254,10 @@ { "command": "gitpod.reportIssue", "group": "z_about2@40" + }, + { + "command": "gitpod.showReleaseNotes", + "group": "z_about2@50" } ], "extension/context": [ @@ -394,6 +408,11 @@ "group": "remote_00_gitpod_navigation@130", "when": "gitpod.inWorkspace == true && gitpod.workspaceOwned == true && gitpod.workspaceShared == true" }, + { + "command": "gitpod.showReleaseNotes", + "group": "remote_00_gitpod_navigation@140", + "when": "gitpod.inWorkspace == true" + }, { "command": "gitpod.openInStable", "group": "remote_00_gitpod_navigation@900", diff --git a/extensions/gitpod-web/package.nls.json b/extensions/gitpod-web/package.nls.json index 572ce136ce55b..412a9fe82625f 100644 --- a/extensions/gitpod-web/package.nls.json +++ b/extensions/gitpod-web/package.nls.json @@ -20,6 +20,7 @@ "openTwitter": "Gitpod: Follow us on Twitter", "reportIssue": "Gitpod: Report Issue", "stopWorkspace": "Gitpod: Stop Workspace", + "showReleaseNotes": "Gitpod: Show Release Notes", "upgradeSubscription": "Gitpod: Upgrade Subscription", "extendTimeout": "Gitpod: Extend Workspace Timeout", "takeSnapshot": "Gitpod: Share Workspace Snapshot", diff --git a/extensions/gitpod-web/src/extension.ts b/extensions/gitpod-web/src/extension.ts index 1f3a68f2dd99c..d6ea24731c3a2 100644 --- a/extensions/gitpod-web/src/extension.ts +++ b/extensions/gitpod-web/src/extension.ts @@ -9,7 +9,7 @@ import * as grpc from '@grpc/grpc-js'; import * as fs from 'fs'; import * as os from 'os'; import * as uuid from 'uuid'; -import { GitpodPluginModel, GitpodExtensionContext, setupGitpodContext, registerTasks, registerIpcHookCli } from 'gitpod-shared'; +import { GitpodPluginModel, GitpodExtensionContext, setupGitpodContext, registerTasks, registerIpcHookCli, registerReleaseNotesView } from 'gitpod-shared'; import { GetTokenRequest } from '@gitpod/supervisor-api-grpc/lib/token_pb'; import { PortsStatus, ExposedPortInfo, PortsStatusRequest, PortsStatusResponse, PortVisibility, OnPortExposedAction } from '@gitpod/supervisor-api-grpc/lib/status_pb'; import { TunnelVisiblity, TunnelPortRequest, RetryAutoExposeRequest, CloseTunnelRequest } from '@gitpod/supervisor-api-grpc/lib/port_pb'; @@ -49,6 +49,7 @@ export async function activate(context: vscode.ExtensionContext) { registerIpcHookCli(gitpodContext); registerExtensionManagement(gitpodContext); + registerReleaseNotesView(gitpodContext); await gitpodContext.active; } diff --git a/extensions/yarn.lock b/extensions/yarn.lock index 9e707900b0a07..9dd805b657ab1 100644 --- a/extensions/yarn.lock +++ b/extensions/yarn.lock @@ -125,6 +125,11 @@ component-type "^1.2.1" join-component "^1.1.0" +"@types/js-yaml@^4.0.5": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138" + integrity sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA== + "@types/long@^4.0.1": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" @@ -1019,7 +1024,7 @@ join-component@^1.1.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@4.1.0: +js-yaml@4.1.0, js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==