From ee124ed907456355828aae06dbfc91e5d37fe66d Mon Sep 17 00:00:00 2001 From: Matt Seddon <37993418+mattseddon@users.noreply.github.com> Date: Tue, 11 Jul 2023 08:51:14 +1000 Subject: [PATCH] Display DAG in Markdown Preview editor (#4244) * wire up prototype for displaying DAG * recommend installing mermaid support extension * update package.json * add unit test for extension recommendation * add integration test for showing the DAG * stub call to dag in extension test * handle dag command throwing error --- README.md | 23 +++---- extension/package.json | 14 +++++ extension/src/cli/dvc/constants.ts | 4 ++ extension/src/cli/dvc/reader.test.ts | 48 +++++++++++++- extension/src/cli/dvc/reader.ts | 9 +++ extension/src/commands/external.ts | 2 + extension/src/data/index.ts | 7 ++- extension/src/extension.ts | 11 +++- extension/src/pipeline/data.ts | 27 ++++++++ extension/src/pipeline/index.ts | 34 ++++++++++ extension/src/pipeline/register.ts | 13 ++++ extension/src/pipeline/workspace.ts | 63 +++++++++++++++++++ extension/src/telemetry/constants.ts | 2 + extension/src/test/suite/extension.test.ts | 1 + .../src/test/suite/pipeline/workspace.test.ts | 36 +++++++++++ extension/src/vscode/config.ts | 1 + extension/src/vscode/recommend.test.ts | 63 ++++++++++++++++++- extension/src/vscode/recommend.ts | 23 +++++++ 18 files changed, 362 insertions(+), 19 deletions(-) create mode 100644 extension/src/pipeline/data.ts create mode 100644 extension/src/pipeline/index.ts create mode 100644 extension/src/pipeline/register.ts create mode 100644 extension/src/pipeline/workspace.ts create mode 100644 extension/src/test/suite/pipeline/workspace.test.ts diff --git a/README.md b/README.md index 64b14e89da..06d7d91b84 100644 --- a/README.md +++ b/README.md @@ -143,17 +143,18 @@ These are the VS Code [settings] available for the Extension: [settings]: https://code.visualstudio.com/docs/getstarted/settings -| **Option** | **Description** | -| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dvc.dvcPath` | Path or shell command to the DVC binary. Required unless Microsoft's [Python extension] is installed and the `dvc` package found in its environment. | -| `dvc.pythonPath` | Path to the desired Python interpreter to use with DVC. Should only be utilized when using a virtual environment without Microsoft's [Python extension]. | -| `dvc.experimentsTableHeadMaxHeight` | Maximum height of experiment table head rows. | -| `dvc.focusedProjects` | A subset of paths to the workspace's available DVC projects. Using this option will override project auto-discovery. | -| `dvc.doNotInformMaxExperimentsPlotted` | Do not inform when plotting more experiments is blocked (maximum number selected). | -| `dvc.doNotShowSetupAfterInstall` | Do not prompt to show the setup page after installing. Useful for pre-configured development environments. | -| `dvc.doNotRecommendAddStudioToken` | Do not prompt to add a [studio.token] to the global DVC config, which enables automatic sharing of experiments to [Studio]. | -| `dvc.doNotRecommendRedHatExtension` | Do not prompt to install the Red Hat YAML extension, which helps with DVC YAML schema validation (`dvc.yaml` and `.dvc` files). | -| `dvc.doNotShowCliUnavailable` | Do not warn when the workspace contains a DVC project but the DVC binary is unavailable. | +| **Option** | **Description** | +| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dvc.dvcPath` | Path or shell command to the DVC binary. Required unless Microsoft's [Python extension] is installed and the `dvc` package found in its environment. | +| `dvc.pythonPath` | Path to the desired Python interpreter to use with DVC. Should only be utilized when using a virtual environment without Microsoft's [Python extension]. | +| `dvc.experimentsTableHeadMaxHeight` | Maximum height of experiment table head rows. | +| `dvc.focusedProjects` | A subset of paths to the workspace's available DVC projects. Using this option will override project auto-discovery. | +| `dvc.doNotInformMaxExperimentsPlotted` | Do not inform when plotting more experiments is blocked (maximum number selected). | +| `dvc.doNotShowSetupAfterInstall` | Do not prompt to show the setup page after installing. Useful for pre-configured development environments. | +| `dvc.doNotRecommendAddStudioToken` | Do not prompt to add a [studio.token] to the global DVC config, which enables automatic sharing of experiments to [Studio]. | +| `dvc.doNotRecommendRedHatExtension` | Do not prompt to install the Red Hat YAML extension, which helps with DVC YAML schema validation (`dvc.yaml` and `.dvc` files). | +| `dvc.doNotRecommendMermaidSupportExtension` | Do not prompt to install the Markdown Preview Mermaid Support extension, which helps to visualize DVC pipeline DAGs. | +| `dvc.doNotShowCliUnavailable` | Do not warn when the workspace contains a DVC project but the DVC binary is unavailable. | > **Note** that the `Setup The Workspace` command helps you set up the basic > ones at the [Workspace level] (saved to `.vscode/setting.json`). diff --git a/extension/package.json b/extension/package.json index a0587eaf12..fe06c0f8d4 100644 --- a/extension/package.json +++ b/extension/package.json @@ -417,6 +417,11 @@ "command": "dvc.showOutput", "category": "DVC" }, + { + "title": "Show Pipeline DAG", + "command": "dvc.showPipelineDAG", + "category": "DVC" + }, { "title": "Show Plots", "command": "dvc.showPlots", @@ -606,6 +611,11 @@ "type": "boolean", "default": null }, + "dvc.doNotRecommendMermaidSupportExtension": { + "description": "Do not prompt to install the Markdown Preview Mermaid Support extension, which helps to visualize DVC pipeline DAGs", + "type": "boolean", + "default": null + }, "dvc.doNotRecommendAddStudioToken": { "description": "Do not prompt to add a studio.token to the global DVC config, which enables automatic sharing of experiments to Studio.", "type": "boolean", @@ -876,6 +886,10 @@ "command": "dvc.showCommands", "when": "false" }, + { + "command": "dvc.showPipelineDAG", + "when": "dvc.commands.available && dvc.project.available" + }, { "command": "dvc.showExperiments", "when": "dvc.commands.available && dvc.project.available" diff --git a/extension/src/cli/dvc/constants.ts b/extension/src/cli/dvc/constants.ts index 4c2ce223a1..40a194c162 100644 --- a/extension/src/cli/dvc/constants.ts +++ b/extension/src/cli/dvc/constants.ts @@ -3,6 +3,8 @@ import { join } from 'path' export const UNEXPECTED_ERROR_CODE = 255 export const DOT_DVC = '.dvc' +export const TEMP_DAG_FILE = join(DOT_DVC, 'tmp', 'dag.md') + export const TEMP_PLOTS_DIR = join(DOT_DVC, 'tmp', 'plots') const TEMP_EXP_DIR = join(DOT_DVC, 'tmp', 'exps') @@ -22,6 +24,7 @@ export enum Command { COMMIT = 'commit', CONFIG = 'config', DATA = 'data', + DAG = 'dag', EXPERIMENT = 'exp', INITIALIZE = 'init', MOVE = 'move', @@ -59,6 +62,7 @@ export enum Flag { JSON = '--json', KILL = '--kill', LOCAL = '--local', + MD = '--md', PROJECT = '--project', NUM_COMMIT = '-n', OUTPUT_PATH = '-o', diff --git a/extension/src/cli/dvc/reader.test.ts b/extension/src/cli/dvc/reader.test.ts index 00a0868c74..4685a5a991 100644 --- a/extension/src/cli/dvc/reader.test.ts +++ b/extension/src/cli/dvc/reader.test.ts @@ -42,7 +42,7 @@ beforeEach(() => { mockedGetProcessEnv.mockReturnValueOnce(mockedEnv) }) -describe('CliReader', () => { +describe('DvcReader', () => { mockedDisposable.fn.mockReturnValueOnce({ track: function (disposable: T): T { return disposable @@ -70,6 +70,52 @@ describe('CliReader', () => { } ) + describe('dag', () => { + it('should match the expected output', async () => { + const cwd = __dirname + const dag = `\`\`\`mermaid + flowchart TD + node1["nested1/data/data.xml.dvc"] + node2["nested1/dvc.yaml:evaluate"] + node3["nested1/dvc.yaml:featurize"] + node4["nested1/dvc.yaml:prepare"] + node5["nested1/dvc.yaml:train"] + node1-->node4 + node3-->node2 + node3-->node5 + node4-->node3 + node5-->node2 + node6["nested2/data/data.xml.dvc"] + \`\`\`` + mockedCreateProcess.mockReturnValueOnce(getMockedProcess(dag)) + + const cliOutput = await dvcReader.dag(cwd) + expect(cliOutput).toStrictEqual(dag) + expect(mockedCreateProcess).toHaveBeenCalledWith({ + args: ['dag', '--md'], + cwd, + env: mockedEnv, + executable: 'dvc' + }) + }) + + it('should return the error if the cli returns any type of error', async () => { + const cwd = __dirname + const error = new Error('unexpected error - something something') + const unexpectedStderr = 'This is very unexpected' + ;(error as MaybeConsoleError).exitCode = UNEXPECTED_ERROR_CODE + ;(error as MaybeConsoleError).stderr = unexpectedStderr + mockedCreateProcess.mockImplementationOnce(() => { + throw error + }) + + const cliOutput = await dvcReader.dag(cwd) + expect(cliOutput).toStrictEqual( + 'Error: unexpected error - something something' + ) + }) + }) + describe('expShow', () => { it('should match the expected output', async () => { const cwd = __dirname diff --git a/extension/src/cli/dvc/reader.ts b/extension/src/cli/dvc/reader.ts index 0e2f70895e..54523f80f2 100644 --- a/extension/src/cli/dvc/reader.ts +++ b/extension/src/cli/dvc/reader.ts @@ -32,6 +32,7 @@ export const isDvcError = < !!(Object.keys(dataOrError).length === 1 && (dataOrError as DvcError).error) export const autoRegisteredCommands = { + DAG: 'dag', DATA_STATUS: 'dataStatus', EXP_SHOW: 'expShow', GLOBAL_VERSION: 'globalVersion', @@ -47,6 +48,14 @@ export class DvcReader extends DvcCli { this ) + public async dag(cwd: string) { + try { + return await this.executeDvcProcess(cwd, Command.DAG, Flag.MD) + } catch (error: unknown) { + return (error as Error).toString() + } + } + public dataStatus( cwd: string, ...args: Args diff --git a/extension/src/commands/external.ts b/extension/src/commands/external.ts index 38cc4b8cdb..82355dc0b6 100644 --- a/extension/src/commands/external.ts +++ b/extension/src/commands/external.ts @@ -69,6 +69,8 @@ export enum RegisteredCommands { EXPERIMENT_VIEW_STOP = 'dvc.views.experiments.stopExperiment', STOP_EXPERIMENTS = 'dvc.stopAllRunningExperiments', + PIPELINE_SHOW_DAG = 'dvc.showPipelineDAG', + PLOTS_PATH_TOGGLE = 'dvc.views.plotsPathsTree.toggleStatus', PLOTS_SHOW = 'dvc.showPlots', PLOTS_SELECT = 'dvc.views.plotsPathsTree.selectPlots', diff --git a/extension/src/data/index.ts b/extension/src/data/index.ts index b5f3156ca4..0ba68dfac9 100644 --- a/extension/src/data/index.ts +++ b/extension/src/data/index.ts @@ -17,7 +17,10 @@ export type ExperimentsOutput = { } export abstract class BaseData< - T extends { data: PlotsOutputOrError; revs: string[] } | ExperimentsOutput + T extends + | { data: PlotsOutputOrError; revs: string[] } + | ExperimentsOutput + | string > extends DeferredDisposable { public readonly onDidUpdate: Event public readonly onDidChangeDvcYaml: Event @@ -102,6 +105,4 @@ export abstract class BaseData< } abstract managedUpdate(path?: string): Promise - - protected abstract collectFiles(data: T): void } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index af979b36e2..d3d4a55f82 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -50,6 +50,8 @@ import { registerSetupCommands } from './setup/commands/register' import { Status } from './status' import { registerPersistenceCommands } from './persistence/register' import { showSetupOrExecuteCommand } from './commands/util' +import { WorkspacePipeline } from './pipeline/workspace' +import { registerPipelineCommands } from './pipeline/register' class Extension extends Disposable { protected readonly internalCommands: InternalCommands @@ -57,6 +59,7 @@ class Extension extends Disposable { private readonly resourceLocator: ResourceLocator private readonly repositories: WorkspaceRepositories private readonly experiments: WorkspaceExperiments + private readonly pipelines: WorkspacePipeline private readonly plots: WorkspacePlots private readonly setup: Setup private readonly repositoriesTree: RepositoriesTree @@ -117,6 +120,10 @@ class Extension extends Disposable { new WorkspaceExperiments(this.internalCommands, context.workspaceState) ) + this.pipelines = this.dispose.track( + new WorkspacePipeline(this.internalCommands) + ) + this.plots = this.dispose.track( new WorkspacePlots(this.internalCommands, context.workspaceState) ) @@ -184,6 +191,7 @@ class Extension extends Disposable { this.internalCommands, this.setup ) + registerPipelineCommands(this.pipelines, this.internalCommands) registerPlotsCommands(this.plots, this.internalCommands, this.setup) registerSetupCommands(this.setup, this.internalCommands) this.internalCommands.registerExternalCommand( @@ -272,7 +280,8 @@ class Extension extends Disposable { await Promise.all([ this.repositories.create(this.getRoots()), this.repositoriesTree.initialize(this.getRoots()), - this.experiments.create(this.getRoots(), this.resourceLocator) + this.experiments.create(this.getRoots(), this.resourceLocator), + this.pipelines.create(this.getRoots()) ]) this.plots.create(this.getRoots(), this.resourceLocator, this.experiments) diff --git a/extension/src/pipeline/data.ts b/extension/src/pipeline/data.ts new file mode 100644 index 0000000000..23265f0fd3 --- /dev/null +++ b/extension/src/pipeline/data.ts @@ -0,0 +1,27 @@ +import { AvailableCommands, InternalCommands } from '../commands/internal' +import { BaseData } from '../data' + +export class PipelineData extends BaseData { + constructor(dvcRoot: string, internalCommands: InternalCommands) { + super( + dvcRoot, + internalCommands, + [{ name: 'update', process: () => this.update() }], + ['dvc.yaml'] + ) + + void this.managedUpdate() + } + + public managedUpdate() { + return this.processManager.run('update') + } + + public async update(): Promise { + const dag = await this.internalCommands.executeCommand( + AvailableCommands.DAG, + this.dvcRoot + ) + return this.notifyChanged(dag) + } +} diff --git a/extension/src/pipeline/index.ts b/extension/src/pipeline/index.ts new file mode 100644 index 0000000000..134e0299a1 --- /dev/null +++ b/extension/src/pipeline/index.ts @@ -0,0 +1,34 @@ +import { join } from 'path' +import { appendFileSync, writeFileSync } from 'fs-extra' +import { PipelineData } from './data' +import { DeferredDisposable } from '../class/deferred' +import { InternalCommands } from '../commands/internal' +import { TEMP_DAG_FILE } from '../cli/dvc/constants' + +export class Pipeline extends DeferredDisposable { + private readonly dvcRoot: string + private readonly data: PipelineData + + constructor(dvcRoot: string, internalCommands: InternalCommands) { + super() + this.dvcRoot = dvcRoot + this.data = this.dispose.track(new PipelineData(dvcRoot, internalCommands)) + + void this.initialize() + } + + public forceRerender() { + return appendFileSync(join(this.dvcRoot, TEMP_DAG_FILE), '\n') + } + + private async initialize() { + this.dispose.track( + this.data.onDidUpdate(data => + writeFileSync(join(this.dvcRoot, TEMP_DAG_FILE), data) + ) + ) + + await this.data.isReady() + return this.deferred.resolve() + } +} diff --git a/extension/src/pipeline/register.ts b/extension/src/pipeline/register.ts new file mode 100644 index 0000000000..75b9580e28 --- /dev/null +++ b/extension/src/pipeline/register.ts @@ -0,0 +1,13 @@ +import { WorkspacePipeline } from './workspace' +import { RegisteredCommands } from '../commands/external' +import { InternalCommands } from '../commands/internal' + +export const registerPipelineCommands = ( + pipelines: WorkspacePipeline, + internalCommands: InternalCommands +): void => { + internalCommands.registerExternalCommand( + RegisteredCommands.PIPELINE_SHOW_DAG, + () => pipelines.showDag() + ) +} diff --git a/extension/src/pipeline/workspace.ts b/extension/src/pipeline/workspace.ts new file mode 100644 index 0000000000..0ee8ac1cde --- /dev/null +++ b/extension/src/pipeline/workspace.ts @@ -0,0 +1,63 @@ +import { join } from 'path' +import { commands, Uri } from 'vscode' +import { Pipeline } from '.' +import { TEMP_DAG_FILE } from '../cli/dvc/constants' +import { BaseWorkspace } from '../workspace' +import { + MARKDOWN_MERMAID_EXTENSION_ID, + recommendMermaidSupportExtension +} from '../vscode/recommend' +import { InternalCommands } from '../commands/internal' +import { getOnDidChangeExtensions, isInstalled } from '../vscode/extensions' + +export class WorkspacePipeline extends BaseWorkspace { + private isMermaidSupportInstalled = isInstalled(MARKDOWN_MERMAID_EXTENSION_ID) + + constructor(internalCommands: InternalCommands) { + super(internalCommands) + + const onDidChangeExtensions = getOnDidChangeExtensions() + this.dispose.track( + onDidChangeExtensions(() => { + const wasMermaidInstalled = this.isMermaidSupportInstalled + this.isMermaidSupportInstalled = isInstalled( + MARKDOWN_MERMAID_EXTENSION_ID + ) + if (!wasMermaidInstalled && this.isMermaidSupportInstalled) { + this.renderDagAsMermaid() + } + }) + ) + } + + public createRepository(dvcRoot: string) { + const pipeline = this.dispose.track( + new Pipeline(dvcRoot, this.internalCommands) + ) + + this.setRepository(dvcRoot, pipeline) + + return pipeline + } + + public async showDag() { + const cwd = await this.getOnlyOrPickProject() + + if (!cwd) { + return + } + + void recommendMermaidSupportExtension() + + return commands.executeCommand( + 'markdown.showPreview', + Uri.file(join(cwd, TEMP_DAG_FILE)) + ) + } + + private renderDagAsMermaid() { + for (const dvcRoot of this.getDvcRoots()) { + void this.getRepository(dvcRoot).forceRerender() + } + } +} diff --git a/extension/src/telemetry/constants.ts b/extension/src/telemetry/constants.ts index 447dd60b85..14b59dbda3 100644 --- a/extension/src/telemetry/constants.ts +++ b/extension/src/telemetry/constants.ts @@ -171,6 +171,8 @@ export interface IEventNamePropertyMapping { [EventName.MODIFY_WORKSPACE_PARAMS_RESET_AND_RUN]: undefined [EventName.STOP_EXPERIMENTS]: { stopped: boolean; wasRunning: boolean } + [EventName.PIPELINE_SHOW_DAG]: undefined + [EventName.PLOTS_PATH_TOGGLE]: undefined [EventName.PLOTS_SHOW]: undefined [EventName.PLOTS_SELECT]: undefined diff --git a/extension/src/test/suite/extension.test.ts b/extension/src/test/suite/extension.test.ts index 87e567e72c..c32de396d6 100644 --- a/extension/src/test/suite/extension.test.ts +++ b/extension/src/test/suite/extension.test.ts @@ -99,6 +99,7 @@ suite('Extension Test Suite', () => { stub(DvcConfig.prototype, 'remote').resolves('') stub(DvcReader.prototype, 'root').resolves('.') + stub(DvcReader.prototype, 'dag').resolves('') const dataStatusCalled = new Promise(resolve => { mockDataStatus.callsFake(() => { diff --git a/extension/src/test/suite/pipeline/workspace.test.ts b/extension/src/test/suite/pipeline/workspace.test.ts new file mode 100644 index 0000000000..0a83cebe6b --- /dev/null +++ b/extension/src/test/suite/pipeline/workspace.test.ts @@ -0,0 +1,36 @@ +import { join } from 'path' +import { afterEach, beforeEach, describe, it, suite } from 'mocha' +import { expect } from 'chai' +import { restore, spy, stub } from 'sinon' +import { commands } from 'vscode' +import { closeAllEditors } from '../util' +import { dvcDemoPath } from '../../util' +import { WEBVIEW_TEST_TIMEOUT } from '../timeouts' +import { RegisteredCommands } from '../../../commands/external' +import { WorkspacePipeline } from '../../../pipeline/workspace' +import { standardizePath } from '../../../fileSystem/path' + +suite('Workspace Pipeline Test Suite', () => { + beforeEach(() => { + restore() + }) + + afterEach(() => { + return closeAllEditors() + }) + + describe('WorkspacePipeline', () => { + it("should be able to show the demo project's DAG", async () => { + const executeCommandSpy = spy(commands, 'executeCommand') + + stub(WorkspacePipeline.prototype, 'getDvcRoots').returns([dvcDemoPath]) + + await commands.executeCommand(RegisteredCommands.PIPELINE_SHOW_DAG) + + expect(executeCommandSpy).to.be.calledWith('markdown.showPreview') + expect(executeCommandSpy.lastCall.args[1].fsPath).to.equal( + standardizePath(join(dvcDemoPath, '.dvc', 'tmp', 'dag.md')) + ) + }).timeout(WEBVIEW_TEST_TIMEOUT) + }) +}) diff --git a/extension/src/vscode/config.ts b/extension/src/vscode/config.ts index 9dbb6ce96e..5f3c2533b2 100644 --- a/extension/src/vscode/config.ts +++ b/extension/src/vscode/config.ts @@ -4,6 +4,7 @@ export enum ConfigKey { DO_NOT_INFORM_MAX_PLOTTED = 'dvc.doNotInformMaxExperimentsPlotted', DO_NOT_RECOMMEND_ADD_STUDIO_TOKEN = 'dvc.doNotRecommendAddStudioToken', DO_NOT_RECOMMEND_RED_HAT = 'dvc.doNotRecommendRedHatExtension', + DO_NOT_RECOMMEND_MERMAID_SUPPORT = 'dvc.doNotRecommendMermaidSupportExtension', DO_NOT_SHOW_CLI_UNAVAILABLE = 'dvc.doNotShowCliUnavailable', DO_NOT_SHOW_SETUP_AFTER_INSTALL = 'dvc.doNotShowSetupAfterInstall', DVC_PATH = 'dvc.dvcPath', diff --git a/extension/src/vscode/recommend.test.ts b/extension/src/vscode/recommend.test.ts index d94868797c..68307becee 100644 --- a/extension/src/vscode/recommend.test.ts +++ b/extension/src/vscode/recommend.test.ts @@ -1,6 +1,9 @@ -import { commands, window } from 'vscode' +import { Extension, commands, extensions, window } from 'vscode' import { ConfigKey, setUserConfigValue } from './config' -import { recommendRedHatExtension } from './recommend' +import { + recommendMermaidSupportExtension, + recommendRedHatExtension +} from './recommend' import { Response } from './response' const mockedShowInformationMessage = jest.fn() @@ -9,7 +12,7 @@ mockedWindow.showInformationMessage = mockedShowInformationMessage const mockedExecuteCommand = jest.fn() const mockedCommands = jest.mocked(commands) mockedCommands.executeCommand = mockedExecuteCommand - +const mockedExtensions = jest.mocked(extensions) const mockedSetUserConfigValue = jest.mocked(setUserConfigValue) jest.mock('vscode') @@ -58,3 +61,57 @@ describe('recommendRedHatExtension', () => { expect(mockedSetUserConfigValue).not.toHaveBeenCalled() }) }) + +describe('recommendMermaidSupportExtension', () => { + it('should return early if the extension is installed', async () => { + mockedExtensions.all = [ + { id: 'bierner.markdown-mermaid' } + ] as unknown as readonly Extension[] & { + [x: number]: Extension + } & { [x: number]: jest.MockedObjectDeep> } + await recommendMermaidSupportExtension() + expect(mockedShowInformationMessage).not.toHaveBeenCalled() + }) + + it('should set a user config option if the user responds with do not show again', async () => { + mockedExtensions.all = [] + mockedShowInformationMessage.mockResolvedValueOnce(Response.NEVER) + await recommendMermaidSupportExtension() + + expect(mockedSetUserConfigValue).toHaveBeenCalledTimes(1) + expect(mockedSetUserConfigValue).toHaveBeenCalledWith( + ConfigKey.DO_NOT_RECOMMEND_MERMAID_SUPPORT, + true + ) + }) + + it('should open the extensions view and search for the extension if the user responds with show', async () => { + mockedExtensions.all = [] + mockedShowInformationMessage.mockResolvedValueOnce(Response.SHOW) + mockedExecuteCommand.mockResolvedValueOnce(undefined) + + await recommendMermaidSupportExtension() + + expect(mockedExecuteCommand).toHaveBeenCalledTimes(1) + expect(mockedExecuteCommand).toHaveBeenCalledWith( + 'workbench.extensions.search', + '@id:bierner.markdown-mermaid' + ) + }) + + it('should not set any options if the user responds with no', async () => { + mockedExtensions.all = [] + mockedShowInformationMessage.mockResolvedValueOnce(Response.NO) + await recommendMermaidSupportExtension() + + expect(mockedSetUserConfigValue).not.toHaveBeenCalled() + }) + + it('should not set any options if the user cancels the dialog', async () => { + mockedExtensions.all = [] + mockedShowInformationMessage.mockResolvedValueOnce(undefined) + await recommendMermaidSupportExtension() + + expect(mockedSetUserConfigValue).not.toHaveBeenCalled() + }) +}) diff --git a/extension/src/vscode/recommend.ts b/extension/src/vscode/recommend.ts index 298f434610..4783b5a445 100644 --- a/extension/src/vscode/recommend.ts +++ b/extension/src/vscode/recommend.ts @@ -7,6 +7,7 @@ import { isInstalled, showExtension } from './extensions' import { isAnyDvcYaml } from '../fileSystem' const RED_HAT_EXTENSION_ID = 'redhat.vscode-yaml' +export const MARKDOWN_MERMAID_EXTENSION_ID = 'bierner.markdown-mermaid' export const recommendRedHatExtension = async () => { const response = await Toast.askShowOrCloseOrNever( @@ -42,3 +43,25 @@ export const recommendRedHatExtensionOnce = (): Disposable => { ) return singleUseListener } + +export const recommendMermaidSupportExtension = async () => { + if ( + isInstalled(MARKDOWN_MERMAID_EXTENSION_ID) || + getConfigValue(ConfigKey.DO_NOT_RECOMMEND_MERMAID_SUPPORT) + ) { + return + } + + const response = await Toast.askShowOrCloseOrNever( + 'To ensure the proper display of generated DAGs, it is recommended ' + + 'to install the [Markdown Preview Mermaid Support](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-mermaid) extension.' + ) + + if (response === Response.SHOW) { + return showExtension(MARKDOWN_MERMAID_EXTENSION_ID) + } + + if (response === Response.NEVER) { + return setUserConfigValue(ConfigKey.DO_NOT_RECOMMEND_MERMAID_SUPPORT, true) + } +}