Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: show Metals' release notes if server version is updated #1009

Merged
merged 13 commits into from
Jun 13, 2022
5 changes: 4 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions media/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
h2,
h3,
h4,
h5,
h6 {
margin-top: 2em;
margin-bottom: 0em;
}
16 changes: 15 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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": [
Expand Down
26 changes: 22 additions & 4 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -130,7 +131,13 @@ export async function activate(context: ExtensionContext): Promise<void> {
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();
Expand Down Expand Up @@ -262,7 +269,8 @@ function fetchAndLaunchMetals(
context,
classpath,
serverProperties,
javaConfig
javaConfig,
serverVersion
);
},
(reason) => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
() => {
Expand Down
254 changes: 254 additions & 0 deletions src/releaseNotesProvider.ts
Original file line number Diff line number Diff line change
@@ -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<Either<string, void>> {
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?
kpodsiad marked this conversation as resolved.
Show resolved Hide resolved
*/
function isRemote(): Either<string, void> {
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<string, string> {
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<Either<string, string>> {
kpodsiad marked this conversation as resolved.
Show resolved Hide resolved
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<string> {
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 `
<!DOCTYPE html>
<html lang="en" style="height: 100%; width: 100%;">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="${stylesUri}" rel="stylesheet">
</head>
<body>
<h1>${title}</h1>
<hr>
<p>
Showing Metals' release notes embedded in vscode is an experimental feature, in case of any issues report them at
<a href="https://github.com/scalameta/metals-vscode">https://github.com/scalameta/metals-vscode</a>.
<br/>
<br/>
Original blogpost can be viewed at
<a href="https://scalameta.org/metals/blog/" target="_blank" itemprop="url">
<span itemprop="name">Metals blog</span>
</a>.
</p>
<hr>
<p>
<a href="${authorUrl}" target="_blank" itemprop="url">
<span itemprop="name">${author}</span>
</a>
</p>
<hr>
${renderedNotes}
</body>
</html>
`;
}