diff --git a/.eslintrc.js b/.eslintrc.js index 2dffd1f23..cbd5bb85f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,7 +17,10 @@ module.exports = { { ignoreRestArgs: true }, ], "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error"], + "@typescript-eslint/no-unused-vars": [ + "error", + { varsIgnorePattern: "_" }, + ], "@typescript-eslint/no-non-null-assertion": "error", "guard-for-in": "error", "no-var": "error", diff --git a/media/styles.css b/media/styles.css new file mode 100644 index 000000000..fd1ac6829 --- /dev/null +++ b/media/styles.css @@ -0,0 +1,8 @@ +h2, +h3, +h4, +h5, +h6 { + margin-top: 2em; + margin-bottom: 0em; +} diff --git a/package.json b/package.json index bab490a38..4fedbac88 100644 --- a/package.json +++ b/package.json @@ -415,6 +415,11 @@ "category": "Metals", "title": "Run doctor" }, + { + "command": "metals.show-release-notes", + "category": "Metals", + "title": "Show release notes" + }, { "command": "metals.scalafix-run", "category": "Metals", @@ -660,6 +665,10 @@ "command": "metals.doctor-run", "when": "metals:enabled" }, + { + "command": "metals.show-release-notes", + "when": "metals:enabled" + }, { "command": "metals.scalafix-run", "when": "metals:enabled" @@ -899,10 +908,11 @@ "scripts": { "vscode:prepublish": "yarn compile", "compile": "tsc -p ./", + "clean": "rimraf out/", "watch": "tsc -watch -p ./", "test": "ts-mocha src/test/unit/*.test.ts", "test-extension": "rimraf out/ && tsc -p ./ && node out/test/extension/runTest.js", - "build": "vsce package --yarn", + "build": "yarn clean && vsce package --yarn", "vscode:publish": "vsce publish --yarn", "ovsx:publish": "ovsx publish", "lint": "eslint . --ext .ts --fix && yarn format", @@ -913,6 +923,8 @@ "@types/glob": "^7.2.0", "@types/mocha": "^9.1.1", "@types/node": "17.0.27", + "@types/remarkable": "^2.0.3", + "@types/semver": "^7.3.9", "@types/vscode": "1.59.0", "@typescript-eslint/eslint-plugin": "^5.27.1", "@typescript-eslint/parser": "^5.27.1", @@ -934,6 +946,8 @@ "ansicolor": "^1.1.100", "metals-languageclient": "0.5.15", "promisify-child-process": "4.1.1", + "semver": "^7.3.7", + "remarkable": "^2.0.1", "vscode-languageclient": "7.0.0" }, "extensionPack": [ diff --git a/src/extension.ts b/src/extension.ts index e5eb3b889..22fac7a47 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -90,6 +90,7 @@ import * as workbenchCommands from "./workbenchCommands"; import { getServerVersion } from "./getServerVersion"; import { getCoursierMirrorPath } from "./mirrors"; import { DoctorProvider } from "./doctor"; +import { showReleaseNotes } from "./releaseNotesProvider"; const outputChannel = window.createOutputChannel("Metals"); const openSettingsAction = "Open settings"; @@ -130,7 +131,13 @@ export async function activate(context: ExtensionContext): Promise { commands.executeCommand("setContext", "metals:enabled", true); try { const javaHome = await getJavaHome(getJavaHomeFromConfig()); - return fetchAndLaunchMetals(context, javaHome, serverVersion); + await fetchAndLaunchMetals(context, javaHome, serverVersion); + await showReleaseNotes( + "onExtensionStart", + context, + serverVersion, + outputChannel + ); } catch (err) { outputChannel.appendLine(`${err}`); showMissingJavaMessage(); @@ -262,7 +269,8 @@ function fetchAndLaunchMetals( context, classpath, serverProperties, - javaConfig + javaConfig, + serverVersion ); }, (reason) => { @@ -320,7 +328,8 @@ function launchMetals( context: ExtensionContext, metalsClasspath: string, serverProperties: string[], - javaConfig: JavaConfig + javaConfig: JavaConfig, + serverVersion: string ) { // Make editing Scala docstrings slightly nicer. enableScaladocIndentation(); @@ -507,7 +516,16 @@ function launchMetals( ) ); - context.subscriptions.push(client.start()); + registerCommand( + "metals.show-release-notes", + async () => + await showReleaseNotes( + "onUserDemand", + context, + serverVersion, + outputChannel + ) + ); return client.onReady().then( () => { diff --git a/src/releaseNotesProvider.ts b/src/releaseNotesProvider.ts new file mode 100644 index 000000000..f24875e31 --- /dev/null +++ b/src/releaseNotesProvider.ts @@ -0,0 +1,254 @@ +import { env, ExtensionContext } from "vscode"; +import * as vscode from "vscode"; +import * as semver from "semver"; +import { Remarkable } from "remarkable"; +import { fetchFrom } from "./util"; +import { Either, makeLeft, makeRight } from "./types"; + +const versionKey = "metals-server-version"; +type CalledOn = "onExtensionStart" | "onUserDemand"; + +/** + * Show release notes if possible, swallow errors since its not a crucial feature. + * Treats snapshot versions like 0.11.6+67-926ec9a3-SNAPSHOT as a 0.11.6. + * + * @param calledOn determines when this function was called. + * For 'onExtensionStart' case show release notes only once (first time). + * For 'onUserDemand' show extension notes no matter if it's another time. + */ +export async function showReleaseNotes( + calledOn: CalledOn, + context: ExtensionContext, + serverVersion: string, + outputChannel: vscode.OutputChannel +) { + try { + const result = await showReleaseNotesImpl(calledOn, context, serverVersion); + if (result.kind === "left") { + const msg = `Release notes was not shown: ${result.value}`; + outputChannel.appendLine(msg); + } + } catch (error) { + outputChannel.appendLine( + `Error, couldn't show release notes for Metals ${serverVersion}` + ); + outputChannel.appendLine(`${error}`); + } +} + +async function showReleaseNotesImpl( + calledOn: CalledOn, + context: ExtensionContext, + currentVersion: string +): Promise> { + const state = context.globalState; + + const remote = isRemote(); + if (remote.kind === "left") { + return remote; + } + + const version = getVersion(calledOn); + if (version.kind === "left") { + return version; + } + + const releaseNotesUrl = await getMarkdownLink(version.value); + if (releaseNotesUrl.kind === "left") { + return releaseNotesUrl; + } + + // actual logic starts here + await showPanel(version.value, releaseNotesUrl.value); + return makeRight(undefined); + + // below are helper functions + + async function showPanel(version: string, releaseNotesUrl: string) { + const panel = vscode.window.createWebviewPanel( + `scalameta.metals.whatsNew`, + `Metals ${version} release notes`, + vscode.ViewColumn.One + ); + + const releaseNotes = await getReleaseNotesMarkdown( + releaseNotesUrl, + context, + (uri) => panel.webview.asWebviewUri(uri) + ); + + panel.webview.html = releaseNotes; + panel.reveal(); + + // Update current device's latest server version when there's no value or it was a older one. + // Then sync this value across other devices. + state.update(versionKey, version); + state.setKeysForSync([versionKey]); + + context.subscriptions.push(panel); + } + + /** + * Don't show panel for remote environment because it installs extension on every time. + * TODO: what about wsl? + */ + function isRemote(): Either { + return env.remoteName == null || env.remoteName === "wsl" + ? makeRight(undefined) + : makeLeft(`is a remote environment ${env.remoteName}`); + } + + /** + * Return version for which release notes should be displayed + */ + function getVersion(calledOn: CalledOn): Either { + const previousVersion: string | undefined = state.get(versionKey); + // strip version to + // in theory semver.clean can return null, but we're almost sure that currentVersion is well defined + const cleanVersion = semver.clean(currentVersion); + + if (cleanVersion == null) { + const msg = `can't transform ${currentVersion} to 'major.minor.patch'`; + return makeLeft(msg); + } + + // if there was no previous version or user explicitly wants to read release notes + // show release notes for current cleaned version + if (!previousVersion || calledOn === "onUserDemand") { + return makeRight(currentVersion); + } + + const compare = semver.compare(cleanVersion, previousVersion); + const diff = semver.diff(cleanVersion, previousVersion); + + // take into account only major, minor and patch, ignore snapshot releases + const isNewerVersion = + compare === 1 && + (diff === "major" || diff === "minor" || diff === "patch"); + + return isNewerVersion + ? makeRight(cleanVersion) + : makeLeft("do not show release notes for an older version"); + } +} + +/** + * Translate server version to link to the markdown file with release notes. + * @param version clean version in major.minor.patch form + * If version has release notes return link to them, if not return nothing. + * Sample link to which we're doing request https://api.github.com/repos/scalameta/metals/releases/tags/v0.11.6. + * From such JSON obtain body property which contains link to the blogpost, but what's more important, + * contains can be converted to name of markdown file with release notes. + */ +async function getMarkdownLink( + version: string +): Promise> { + const releaseInfoUrl = `https://api.github.com/repos/scalameta/metals/releases/tags/v${version}`; + const options = { + headers: { + "User-Agent": "metals", + }, + }; + const stringifiedContent = await fetchFrom(releaseInfoUrl, options); + const body = JSON.parse(stringifiedContent)["body"] as string; + + if (!body) { + const msg = `can't obtain content of ${releaseInfoUrl}`; + return makeLeft(msg); + } + + // matches (2022)/(06)/(03)/(aluminium) via capture groups + const matchResult = body.match( + new RegExp("(\\d\\d\\d\\d)/(\\d\\d)/(\\d\\d)/(\\w+)") + ); + // whole expression + 4 capture groups = 5 entries + if (matchResult?.length === 5) { + // omit first entry + const [_, ...tail] = matchResult; + const name = tail.join("-"); + const url = `https://raw.githubusercontent.com/scalameta/metals/main/website/blog/${name}.md`; + return makeRight(url); + } else { + const msg = `can't obtain markdown file name for ${version} from ${body}`; + return makeLeft(msg); + } +} + +/** + * + * @param releaseNotesUrl Url which server markdown with release notes + * @param context Extension context + * @param asWebviewUri + * Webviews cannot directly load resources from the workspace or local + * file system using file: uris. The asWebviewUri function takes a local + * file: uri and converts it into a uri that can be used inside of a webview + * to load the same resource. + * proxy to webview.asWebviewUri + */ +async function getReleaseNotesMarkdown( + releaseNotesUrl: string, + context: ExtensionContext, + asWebviewUri: (_: vscode.Uri) => vscode.Uri +): Promise { + const text = await fetchFrom(releaseNotesUrl); + // every release notes starts with that + const beginning = "We're happy to announce the release of"; + const notesStartIdx = text.indexOf(beginning); + const releaseNotes = text.substring(notesStartIdx); + + // cut metadata yaml from release notes, it start with --- and ends with --- + const metadata = text + .substring(0, notesStartIdx - 1) + .replace("---", "") + .replace("---", "") + .trim() + .split("\n"); + const author = metadata[0].slice("author: ".length); + const title = metadata[1].slice("title: ".length); + const authorUrl = metadata[2].slice("authorURL: ".length); + + const md = new Remarkable({ html: true }); + const renderedNotes = md.render(releaseNotes); + + // Uri with additional styles for webview + const stylesPathMainPath = vscode.Uri.joinPath( + context.extensionUri, + "media", + "styles.css" + ); + // need to transform Uri + const stylesUri = asWebviewUri(stylesPathMainPath); + + return ` + + + + + + + + +

${title}

+
+

+ Showing Metals' release notes embedded in vscode is an experimental feature, in case of any issues report them at + https://github.com/scalameta/metals-vscode. +
+
+ Original blogpost can be viewed at + . +

+
+

+ +

+
+ ${renderedNotes} + + +`; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..ff4a72b76 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,11 @@ +export type Either = + | { kind: "left"; value: Left } + | { kind: "right"; value: Right }; + +export function makeLeft(t: T): Either { + return { kind: "left", value: t }; +} + +export function makeRight(t: T): Either { + return { kind: "right", value: t }; +} diff --git a/src/util.ts b/src/util.ts index 8358d73d9..0a7bc2e93 100644 --- a/src/util.ts +++ b/src/util.ts @@ -11,6 +11,7 @@ import { TextDocumentPositionParams, } from "vscode-languageclient"; import { LanguageClient } from "vscode-languageclient/node"; +import http from "https"; declare const sym: unique symbol; /** @@ -103,3 +104,18 @@ export function getJavaHomeFromConfig(): string | undefined { return javaHomePath; } } + +export async function fetchFrom( + url: string, + options?: http.RequestOptions +): Promise { + const promise = new Promise((resolve, reject) => { + http.get(url, options || {}, (resp) => { + let body = ""; + resp.on("data", (chunk) => (body += chunk)); + resp.on("end", () => resolve(body)); + resp.on("error", (e) => reject(e)); + }); + }); + return await promise; +} diff --git a/yarn.lock b/yarn.lock index 9e312d86a..877f9615e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -65,6 +65,11 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/highlight.js@^9.7.0": + version "9.12.4" + resolved "https://registry.yarnpkg.com/@types/highlight.js/-/highlight.js-9.12.4.tgz#8c3496bd1b50cc04aeefd691140aa571d4dbfa34" + integrity sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww== + "@types/json-schema@^7.0.9": version "7.0.11" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" @@ -90,6 +95,19 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.27.tgz#f4df3981ae8268c066e8f49995639f855469081e" integrity sha512-4/Ke7bbWOasuT3kceBZFGakP1dYN2XFd8v2l9bqF2LNWrmeU07JLpp56aEeG6+Q3olqO5TvXpW0yaiYnZJ5CXg== +"@types/remarkable@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/remarkable/-/remarkable-2.0.3.tgz#ba2e5edada8f0fe64c658beb2127e06ac0b9c1c8" + integrity sha512-QQUBeYApuHCNl9Br6ZoI3PlKmwZ69JHrlJktJXnjxobia9liZgsI70fm8PnCqVFAcefYK+9PGzR5L/hzCslNYQ== + dependencies: + "@types/highlight.js" "^9.7.0" + highlight.js "^9.7.0" + +"@types/semver@^7.3.9": + version "7.3.9" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.9.tgz#152c6c20a7688c30b967ec1841d31ace569863fc" + integrity sha512-L/TMpyURfBkf+o/526Zb6kd/tchUP3iBDEPjqjb+U2MAJhVRxxrmr2fwpe08E7QsV7YLcpq0tUaQ9O9x97ZIxQ== + "@types/vscode@1.59.0": version "1.59.0" resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.59.0.tgz#11c93f5016926126bf30b47b9ece3bd617eeef31" @@ -280,6 +298,13 @@ are-we-there-yet@~1.1.2: delegates "^1.0.0" readable-stream "^2.0.6" +argparse@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -300,6 +325,13 @@ async@^3.2.2: resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== +autolinker@^3.11.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/autolinker/-/autolinker-3.15.0.tgz#03956088648f236642a5783612f9ca16adbbed38" + integrity sha512-N/5Dk5AZnqL9k6kkHdFIGLm/0/rRuSnJwqYYhLCJjU7ZtiaJwCBzNTvjzy1zzJADngv/wvtHYcrPHytPnASeFA== + dependencies: + tslib "^2.3.0" + azure-devops-node-api@^11.0.1: version "11.1.0" resolved "https://registry.yarnpkg.com/azure-devops-node-api/-/azure-devops-node-api-11.1.0.tgz#ea3ca49de8583b0366d000f3c3f8a75b8104055f" @@ -1102,6 +1134,11 @@ he@1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +highlight.js@^9.7.0: + version "9.18.5" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.5.tgz#d18a359867f378c138d6819edfc2a8acd5f29825" + integrity sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA== + hosted-git-info@^4.0.2: version "4.1.0" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224" @@ -1758,6 +1795,14 @@ regexpp@^3.2.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== +remarkable@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-2.0.1.tgz#280ae6627384dfb13d98ee3995627ca550a12f31" + integrity sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA== + dependencies: + argparse "^1.0.10" + autolinker "^3.11.0" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -1901,6 +1946,11 @@ source-map@^0.6.0: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + string-width@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" @@ -2071,6 +2121,11 @@ tslib@^2.2.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== +tslib@^2.3.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"