diff --git a/package-lock.json b/package-lock.json index c9156d9c..8031c3c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,18 @@ "@types/node": "*" } }, + "@types/highlight.js": { + "version": "9.12.3", + "resolved": "https://registry.npmjs.org/@types/highlight.js/-/highlight.js-9.12.3.tgz", + "integrity": "sha512-pGF/zvYOACZ/gLGWdQH8zSwteQS1epp68yRcVLJMgUck/MjEn/FBYmPub9pXT8C1e4a8YZfHo1CKyV8q1vKUnQ==", + "dev": true + }, + "@types/linkify-it": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-2.0.4.tgz", + "integrity": "sha512-9o5piu3tP6DwqT+Cyf7S3BitsTc6Cl0pCPKUhIE5hzQbtueiBXdtBipTLLvaGfT11/8XHRmsagu4YfBesTaiCA==", + "dev": true + }, "@types/lodash": { "version": "4.14.121", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.121.tgz", @@ -28,6 +40,15 @@ "@types/lodash": "*" } }, + "@types/markdown-it": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-0.0.7.tgz", + "integrity": "sha512-WyL6pa76ollQFQNEaLVa41ZUUvDvPY+qAUmlsphnrpL6I9p1m868b26FyeoOmo7X3/Ta/S9WKXcEYXUSHnxoVQ==", + "dev": true, + "requires": { + "@types/linkify-it": "*" + } + }, "@types/mocha": { "version": "2.2.48", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-2.2.48.tgz", @@ -127,7 +148,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "requires": { "sprintf-js": "~1.0.2" } @@ -1268,6 +1288,11 @@ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" }, + "highlight.js": { + "version": "9.15.6", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.6.tgz", + "integrity": "sha512-zozTAWM1D6sozHo8kqhfYgsac+B+q0PmsjXeyDrYIHHcBN0zTVT66+s2GW1GZv7DbyaROdLXKdabwS/WqPyIdQ==" + }, "htmlparser2": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", @@ -1610,6 +1635,14 @@ "type-check": "~0.3.2" } }, + "linkify-it": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.1.0.tgz", + "integrity": "sha512-4REs8/062kV2DSHxNfq5183zrqXMl7WP0WzABH9IeJI+NLm429FgE1PDecltYfnOoFDFlZGh2T8PfZn0r+GTRg==", + "requires": { + "uc.micro": "^1.0.1" + } + }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -1651,6 +1684,23 @@ "integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=", "dev": true }, + "markdown-it": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.2.tgz", + "integrity": "sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ==", + "requires": { + "argparse": "^1.0.7", + "entities": "~1.1.1", + "linkify-it": "^2.0.0", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + } + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=" + }, "mem": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mem/-/mem-4.1.0.tgz", @@ -2442,8 +2492,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, "sshpk": { "version": "1.16.1", @@ -2742,6 +2791,11 @@ "integrity": "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==", "dev": true }, + "uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, "unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", diff --git a/package.json b/package.json index f86cfa7b..f5fe00ea 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,11 @@ "dark": "resources/dark/search.svg" } }, + { + "command": "leetcode.showSolution", + "title": "Show Top Voted Solution", + "category": "LeetCode" + }, { "command": "leetcode.testSolution", "title": "Test in LeetCode", @@ -171,6 +176,12 @@ "when": "view == leetCodeExplorer && viewItem == problem", "group": "leetcode@1" }, + { + + "command": "leetcode.showSolution", + "when": "view == leetCodeExplorer && viewItem == problem", + "group": "leetcode@1" + }, { "command": "leetcode.previewProblem", "when": "view == leetCodeExplorer && viewItem == problem", @@ -182,6 +193,11 @@ "command": "leetcode.showProblem", "when": "never" }, + { + + "command": "leetcode.showSolution", + "when": "never" + }, { "command": "leetcode.previewProblem", "when": "never" @@ -288,7 +304,9 @@ }, "devDependencies": { "@types/fs-extra": "5.0.0", + "@types/highlight.js": "^9.12.3", "@types/lodash.kebabcase": "^4.1.5", + "@types/markdown-it": "0.0.7", "@types/mocha": "^2.2.42", "@types/node": "^7.0.43", "@types/require-from-string": "^1.2.0", @@ -298,8 +316,10 @@ }, "dependencies": { "fs-extra": "^6.0.1", - "vsc-leetcode-cli": "2.6.2", + "highlight.js": "^9.15.6", "lodash.kebabcase": "^4.1.1", - "require-from-string": "^2.0.2" + "markdown-it": "^8.4.2", + "require-from-string": "^2.0.2", + "vsc-leetcode-cli": "2.6.2" } } diff --git a/src/commands/show.ts b/src/commands/show.ts index d1ce1cf4..b535ea95 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -8,6 +8,7 @@ import { LeetCodeNode } from "../explorer/LeetCodeNode"; import { leetCodeChannel } from "../leetCodeChannel"; import { leetCodeExecutor } from "../leetCodeExecutor"; import { leetCodeManager } from "../leetCodeManager"; +import { leetCodeSolutionProvider } from "../leetCodeSolutionProvider"; import { IProblem, IQuickItemEx, languages, ProblemState } from "../shared"; import { DialogOptions, DialogType, promptForOpenOutputChannel, promptForSignIn } from "../utils/uiUtils"; import { selectWorkspaceFolder } from "../utils/workspaceUtils"; @@ -39,18 +40,60 @@ export async function searchProblem(): Promise { await showProblemInternal(choice.value); } -async function showProblemInternal(node: IProblem): Promise { +export async function showSolution(node?: LeetCodeNode): Promise { + if (!node) { + return; + } + const language: string | undefined = await fetchProblemLanguage(); + if (!language) { + return; + } try { - const leetCodeConfig: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration("leetcode"); - let defaultLanguage: string | undefined = leetCodeConfig.get("defaultLanguage"); - if (defaultLanguage && languages.indexOf(defaultLanguage) < 0) { - defaultLanguage = undefined; + let solution: string = await leetCodeExecutor.showSolution(node, language); + // remove backslash in espaced \'...\'(generated by leetcode's database) + solution = solution.replace(/\\'/g, "'"); + await leetCodeSolutionProvider.show(solution, node); + } catch (error) { + await promptForOpenOutputChannel("Failed to fetch the top voted solution. Please open the output channel for details.", DialogType.error); + } +} + +// SUGGESTION: group config retriving into one file +async function fetchProblemLanguage(): Promise { + const leetCodeConfig: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration("leetcode"); + let defaultLanguage: string | undefined = leetCodeConfig.get("defaultLanguage"); + if (defaultLanguage && languages.indexOf(defaultLanguage) < 0) { + defaultLanguage = undefined; + } + const language: string | undefined = defaultLanguage || await vscode.window.showQuickPick(languages, { placeHolder: "Select the language you want to use" }); + // fire-and-forget default language query + (async (): Promise => { + if (!defaultLanguage && leetCodeConfig.get("showSetDefaultLanguageHint")) { + const choice: vscode.MessageItem | undefined = await vscode.window.showInformationMessage( + `Would you like to set '${language}' as your default language?`, + DialogOptions.yes, + DialogOptions.no, + DialogOptions.never, + ); + if (choice === DialogOptions.yes) { + leetCodeConfig.update("defaultLanguage", language, true /* UserSetting */); + } else if (choice === DialogOptions.never) { + leetCodeConfig.update("showSetDefaultLanguageHint", false, true /* UserSetting */); + } } - const language: string | undefined = defaultLanguage || await vscode.window.showQuickPick(languages, { placeHolder: "Select the language you want to use" }); + })(); + return language; +} + +async function showProblemInternal(node: IProblem): Promise { + try { + const language: string | undefined = await fetchProblemLanguage(); if (!language) { return; } + // SUGGESTION: group config retriving into one file + const leetCodeConfig: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration("leetcode"); let outDir: string = await selectWorkspaceFolder(); let relativePath: string = (leetCodeConfig.get("outputFolder") || "").trim(); const matchResult: RegExpMatchArray | null = relativePath.match(/\$\{(.*?)\}/); @@ -69,20 +112,6 @@ async function showProblemInternal(node: IProblem): Promise { const originFilePath: string = await leetCodeExecutor.showProblem(node, language, outDir); const filePath: string = wsl.useWsl() ? await wsl.toWinPath(originFilePath) : originFilePath; await vscode.window.showTextDocument(vscode.Uri.file(filePath), { preview: false }); - - if (!defaultLanguage && leetCodeConfig.get("showSetDefaultLanguageHint")) { - const choice: vscode.MessageItem | undefined = await vscode.window.showInformationMessage( - `Would you like to set '${language}' as your default language?`, - DialogOptions.yes, - DialogOptions.no, - DialogOptions.never, - ); - if (choice === DialogOptions.yes) { - leetCodeConfig.update("defaultLanguage", language, true /* UserSetting */); - } else if (choice === DialogOptions.never) { - leetCodeConfig.update("showSetDefaultLanguageHint", false, true /* UserSetting */); - } - } } catch (error) { await promptForOpenOutputChannel("Failed to show the problem. Please open the output channel for details.", DialogType.error); } diff --git a/src/extension.ts b/src/extension.ts index 32f02f2c..92e5a2fa 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,6 +17,7 @@ import { leetCodeExecutor } from "./leetCodeExecutor"; import { leetCodeManager } from "./leetCodeManager"; import { leetCodePreviewProvider } from "./leetCodePreviewProvider"; import { leetCodeResultProvider } from "./leetCodeResultProvider"; +import { leetCodeSolutionProvider } from "./leetCodeSolutionProvider"; import { leetCodeStatusBarItem } from "./leetCodeStatusBarItem"; export async function activate(context: vscode.ExtensionContext): Promise { @@ -32,12 +33,14 @@ export async function activate(context: vscode.ExtensionContext): Promise const leetCodeTreeDataProvider: LeetCodeTreeDataProvider = new LeetCodeTreeDataProvider(context); leetCodePreviewProvider.initialize(context); leetCodeResultProvider.initialize(context); + leetCodeSolutionProvider.initialize(context); context.subscriptions.push( leetCodeStatusBarItem, leetCodeChannel, leetCodePreviewProvider, leetCodeResultProvider, + leetCodeSolutionProvider, vscode.window.createTreeView("leetCodeExplorer", { treeDataProvider: leetCodeTreeDataProvider, showCollapseAll: true }), vscode.languages.registerCodeLensProvider({ scheme: "file" }, codeLensProvider), vscode.commands.registerCommand("leetcode.deleteCache", () => cache.deleteCache()), @@ -49,6 +52,7 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.registerCommand("leetcode.previewProblem", (node: LeetCodeNode) => leetCodePreviewProvider.preview(node)), vscode.commands.registerCommand("leetcode.showProblem", (node: LeetCodeNode) => show.showProblem(node)), vscode.commands.registerCommand("leetcode.searchProblem", () => show.searchProblem()), + vscode.commands.registerCommand("leetcode.showSolution", (node: LeetCodeNode) => show.showSolution(node)), vscode.commands.registerCommand("leetcode.refreshExplorer", () => leetCodeTreeDataProvider.refresh()), vscode.commands.registerCommand("leetcode.testSolution", (uri?: vscode.Uri) => test.testSolution(uri)), vscode.commands.registerCommand("leetcode.submitSolution", (uri?: vscode.Uri) => submit.submitSolution(uri)), diff --git a/src/leetCodeExecutor.ts b/src/leetCodeExecutor.ts index f4080a95..5d76fdd1 100644 --- a/src/leetCodeExecutor.ts +++ b/src/leetCodeExecutor.ts @@ -48,10 +48,12 @@ class LeetCodeExecutor { } return false; } - try { // Check company plugin - await this.executeCommandEx("node", [await this.getLeetCodeBinaryPath(), "plugin", "-e", "company"]); - } catch (error) { // Download company plugin and activate - await this.executeCommandEx("node", [await this.getLeetCodeBinaryPath(), "plugin", "-i", "company"]); + for (const plugin of ["company", "solution.discuss"]) { + try { // Check plugin + await this.executeCommandEx("node", [await this.getLeetCodeBinaryPath(), "plugin", "-e", plugin]); + } catch (error) { // Download plugin and activate + await this.executeCommandEx("node", [await this.getLeetCodeBinaryPath(), "plugin", "-i", plugin]); + } } return true; } @@ -87,6 +89,11 @@ class LeetCodeExecutor { return filePath; } + public async showSolution(problemNode: IProblem, language: string): Promise { + const solution: string = await this.executeCommandWithProgressEx("Fetching top voted solution from discussions...", "node", [await this.getLeetCodeBinaryPath(), "show", problemNode.id, "--solution", "-l", language]); + return solution; + } + public async getDescription(problemNode: IProblem): Promise { return await this.executeCommandWithProgressEx("Fetching problem description...", "node", [await this.getLeetCodeBinaryPath(), "show", problemNode.id, "-x"]); } diff --git a/src/leetCodeSolutionProvider.ts b/src/leetCodeSolutionProvider.ts new file mode 100644 index 00000000..afbf88d6 --- /dev/null +++ b/src/leetCodeSolutionProvider.ts @@ -0,0 +1,140 @@ +// Copyright (c) jdneo. All rights reserved. +// Licensed under the MIT license. + +import * as hljs from "highlight.js"; +import * as MarkdownIt from "markdown-it"; +import * as path from "path"; +import * as vscode from "vscode"; +import { Disposable, ExtensionContext, ViewColumn, WebviewPanel, window } from "vscode"; +import { leetCodeChannel } from "./leetCodeChannel"; +import { IProblem } from "./shared"; + +class LeetCodeSolutionProvider implements Disposable { + + private context: ExtensionContext; + private panel: WebviewPanel | undefined; + private markdown: MarkdownIt; + private markdownPath: string; // path of vscode built-in markdown extension + private solution: Solution; + + public initialize(context: ExtensionContext): void { + this.context = context; + this.markdown = new MarkdownIt({ + linkify: true, + typographer: true, + highlight: this.codeHighlighter.bind(this), + }); + this.markdownPath = path.join(vscode.env.appRoot, "extensions", "markdown-language-features"); + + // Override code_block rule for highlighting in solution language + // tslint:disable-next-line:typedef + this.markdown.renderer.rules["code_block"] = (tokens, idx, options, _, self) => { + const highlight: string = options.highlight(tokens[idx].content, undefined); + return [ + `
`,
+                highlight || this.markdown.utils.escapeHtml(tokens[idx].content),
+                "
", + ].join("\n"); + }; + } + + public async show(solutionString: string, problem: IProblem): Promise { + if (!this.panel) { + this.panel = window.createWebviewPanel("leetCode", "Top voted solution", ViewColumn.Active, { + retainContextWhenHidden: true, + enableFindWidget: true, + localResourceRoots: [vscode.Uri.file(path.join(this.markdownPath, "media"))], + }); + + this.panel.onDidDispose(() => { + this.panel = undefined; + }, null, this.context.subscriptions); + } + + this.solution = this.parseSolution(solutionString); + this.panel.title = problem.name; + this.panel.webview.html = this.getWebViewContent(this.solution); + this.panel.reveal(ViewColumn.Active); + } + + public dispose(): void { + if (this.panel) { + this.panel.dispose(); + } + } + + private parseSolution(raw: string): Solution { + const solution: Solution = new Solution(); + // [^] matches everything including \n, yet can be replaced by . in ES2018's `m` flag + raw = raw.slice(1); // skip first empty line + [solution.title, raw] = raw.split(/\n\n([^]+)/); // parse title and skip one line + [solution.url, raw] = raw.split(/\n\n([^]+)/); // parse url and skip one line + [solution.lang, raw] = raw.match(/\* Lang:\s+(.+)\n([^]+)/)!.slice(1); + [solution.author, raw] = raw.match(/\* Author:\s+(.+)\n([^]+)/)!.slice(1); + [solution.votes, raw] = raw.match(/\* Votes:\s+(\d+)\n\n([^]+)/)!.slice(1); + solution.body = raw; + return solution; + } + + private codeHighlighter(code: string, lang: string | undefined): string { + if (!lang) { + lang = this.solution.lang; + } + if (hljs.getLanguage(lang)) { + try { + return hljs.highlight(lang, code, true).value; + } catch (error) { /* do not highlight */ } + } + return ""; // use external default escaping + } + + private getMarkdownStyles(): vscode.Uri[] { + try { + const stylePaths: string[] = require(path.join(this.markdownPath, "package.json"))["contributes"]["markdown.previewStyles"]; + return stylePaths.map((p: string) => vscode.Uri.file(path.join(this.markdownPath, p)).with({ scheme: "vscode-resource" })); + } catch (error) { + leetCodeChannel.appendLine("[Error] Fail to load built-in markdown style file."); + return []; + } + } + + private getWebViewContent(solution: Solution): string { + const styles: string = this.getMarkdownStyles() + .map((style: vscode.Uri) => ``) + .join("\n"); + const { title, url, lang, author, votes } = solution; + const head: string = this.markdown.render(`# [${title}](${url})`); + const auth: string = `[${author}](https://leetcode.com/${author}/)`; + const info: string = this.markdown.render([ + `| Language | Author | Votes |`, + `| :------: | :------: | :------: |`, + `| ${lang} | ${auth} | ${votes} |`, + ].join("\n")); + const body: string = this.markdown.render(solution.body); + return ` + + + + ${styles} + + + ${head} + ${info} + ${body} + + + `; + } +} + +// tslint:disable-next-line:max-classes-per-file +class Solution { + public title: string = ""; + public url: string = ""; + public lang: string = ""; + public author: string = ""; + public votes: string = ""; + public body: string = ""; // Markdown supported +} + +export const leetCodeSolutionProvider: LeetCodeSolutionProvider = new LeetCodeSolutionProvider();