Repository: stateful/vscode-runme
Pull request #569 (2023-06-01 21:58:47)

Add Runme to recommended extensions
Prompt the user to add the extension to recommended extensions

Note: It does not cover Multi-root workspace for now.
- It prompts the user on Extension Activation (unless disabled by the user)
- It supports don't ask again feature
- Added as a command in order to bring it back again

🎥 Demo 

https://github.com/stateful/vscode-runme/assets/4001529/f8085ef4-1a51-4c9b-b832-9b05bda15181

Fixes https://github.com/stateful/vscode-runme/issues/520



degrammer: @sourishkrout  in case you want to take a look
sourishkrout: Below's error pops up when I open a workspace (not just a folder). Since the user didn't request "anything", I don't think we should show an error notif in this case. Logging is sufficient. **Not** supporting workspaces is fine, since it's ambiguous what `extension.json` to add Runme to with multiple repos involved.

<img width="821" alt="image" src="https://github.com/stateful/vscode-runme/assets/250527/2e0e9766-329a-4d74-90f0-1ae1e13e7b07">

degrammer: > Can the JSON be written back to the file pretty-print? Making it compact will likely make git diffs unreadable.
> 
> Also, before merging, let's be sure to disable the auto pop-up. We need to determine what conditions should be met for it to pop-up which we can work on in a separate PR.

@sourishkrout  the popup is not displayed in the following conditions:
- The Runme extension is already added to recommendations.json
- The user opt-out via Don't ask me again

Am I missing any other scenario?
sourishkrout: > > Can the JSON be written back to the file pretty-print? Making it compact will likely make git diffs unreadable.
> > Also, before merging, let's be sure to disable the auto pop-up. We need to determine what conditions should be met for it to pop-up which we can work on in a separate PR.
> 
> @sourishkrout the popup is not displayed in the following conditions:
> 
> * The Runme extension is already added to recommendations.json
> * The user opt-out via Don't ask me again
> 
> Am I missing any other scenario?

That's a great start. What I mean is that I don't want to display the message to the entire install base. The idea would be only to display it if you have run a cell recently or something like that—some activation events. Not suggesting the current implementation "is wrong".
degrammer: > 

Fixed, now stores the JSON adequately formatted.
degrammer: > > > Can the JSON be written back to the file pretty-print? Making it compact will likely make git diffs unreadable.
> > > Also, before merging, let's be sure to disable the auto pop-up. We need to determine what conditions should be met for it to pop-up which we can work on in a separate PR.
> > 
> > 
> > @sourishkrout the popup is not displayed in the following conditions:
> > 
> > * The Runme extension is already added to recommendations.json
> > * The user opt-out via Don't ask me again
> > 
> > Am I missing any other scenario?
> 
> That's a great start. What I mean is that I don't want to display the message to the entire install base. The idea would be only to display it if you have run a cell recently or something like that—some activation events. Not suggesting the current implementation "is wrong".

Good point, let's discuss that then

Patch:
diff --git a/README.md b/README.md
index 1c2dc19c..9af5fb3e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # [Runme](http://runme.dev) for [Visual Studio Code](https://code.visualstudio.com/) [![Tests](https://github.com/stateful/vscode-runme/actions/workflows/test.yml/badge.svg)](https://github.com/stateful/vscode-runme/actions/workflows/test.yml)
 
-This VS Code extension transparently opens markdown files (i.e. README.md) as runnable VS Code notebooks (with the click of a `play` button). Annotating markdown code blocks repo maintainers can enhance the interactive runbook-like experience of their markdown notebooks while they will continue to render properly in markdown viewers.
+This VS Code extension transparently opens markdown files (i.e. README.md) as runnable VS Code notebooks (with the click of a `play` button). Annotating markdown code blocks repo maintainers can enhance the interactive runbook-like experience of their markdown notebooks while they will continue to render properly in markdown viewers. Runme is open source and licensed under APL2.0.
 
 > 💡 If you don't want the runme notebook, you can always right click on the .md file, click `Open With...` and select the text editor. To make your choice permanent pick "Configure default editor for...".
 
@@ -12,14 +12,8 @@ Please visit Runme's documentation to learn about its purpose, all its features,
 
 - [https://docs.runme.dev/](https://docs.runme.dev/)
 
-This project is open source licensed under APL2.0 and its code can be found in the following repos:
-
-- [stateful/vscode-runme](https://github.com/stateful/vscode-runme)
-- [stateful/runme](https://github.com/stateful/runme)
-- [stateful/docs.runme.dev](https://github.com/stateful/docs.runme.dev)
-
 Feel free to submit any issues you may have via the
-[issue tracker](https://github.com/stateful/vscode-runme/issues) or [tell us about it on Discord](https://discord.gg/stateful).
+[issue tracker](https://github.com/stateful/vscode-runme/issues) or [tell us about it on Discord](https://discord.gg/runme).
 
 # Installation
 
@@ -51,7 +45,7 @@ Check the complete list of configuration options at https://docs.runme.dev/confi
 
 Runme is in rapid development. Please be aware of following known limitations:
 
-- Only shell is currently supported on macOS, Linux, and Windows ([WSL]([url](https://learn.microsoft.com/en-us/windows/wsl/))). PowerShell is not supported yet
+- Only shell is currently supported on macOS, Linux, and Windows ([WSL](<[url](https://learn.microsoft.com/en-us/windows/wsl/)>)). PowerShell is not supported yet
 - Be aware of edge cases. Let us know when you hit any snags. We appreciate it!
 
 We would love to hear feedback, appreciate your patience, as Runme continutes to harden. Get in touch please!
diff --git a/package.json b/package.json
index 736cf7c4..a5ff2ba3 100644
--- a/package.json
+++ b/package.json
@@ -166,7 +166,12 @@
         "category": "Runme",
         "title": "Run cells with the same Category",
         "icon": "$(group-by-ref-type)"
-      }
+      },
+      {
+        "command": "runme.addToRecommendedExtensions",
+        "title": "Add Runme to recommended extensions",
+        "category": "Runme"
+      } 
     ],
     "menus": {
       "commandPalette": [
diff --git a/src/constants.ts b/src/constants.ts
index d7d44c34..ca37daf6 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -112,7 +112,7 @@ export const LANGUAGES = new Map([
  * For example, the vscode language id for all bash-like shells is
  * "shellscript," so this object maps "shellscript" -> "sh"
  */
-export const VSCODE_LANGUAGEID_MAP: Record<string, string|undefined> = {
+export const VSCODE_LANGUAGEID_MAP: Record<string, string | undefined> = {
   'shellscript': 'sh',
   'javascriptreact': 'jsx',
   'typescriptreact': 'tsx',
@@ -1042,3 +1042,8 @@ export const SUPPORTED_FILE_EXTENSIONS = [
   'sh', 'bash', 'ksh', 'zsh', 'fish', 'bat', 'cmd', 'pwsh',
   ...Object.keys(LANGUAGE_PREFIXES),
 ]
+
+export const EXTENSION_NAME = 'stateful.runme'
+export enum TELEMETRY_EVENTS {
+  RecommendExtension = 'runme.recommendExtension'
+}
\ No newline at end of file
diff --git a/src/extension/commands/index.ts b/src/extension/commands/index.ts
index d35af58f..ad568348 100644
--- a/src/extension/commands/index.ts
+++ b/src/extension/commands/index.ts
@@ -31,6 +31,7 @@ import RunmeServer from '../server/runmeServer'
 import { GrpcRunnerEnvironment } from '../runner'
 import { NotebookToolbarCommand } from '../../types'
 import getLogger from '../logger'
+import { RecommendExtensionMessage } from '../messaging'
 
 const log = getLogger('Commands')
 
@@ -260,3 +261,9 @@ export async function authenticateWithGitHub() {
     window.showErrorMessage('Failed to authenticate with GitHub')
   }
 }
+
+export async function addToRecommendedExtensions(context: ExtensionContext) {
+  return new RecommendExtensionMessage(context, {
+    'runme.recommendExtension': true
+  }).display()
+}
\ No newline at end of file
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index c97169d3..72a6c917 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -26,7 +26,8 @@ import {
   openIntegratedTerminal,
   authenticateWithGitHub,
   displayCategoriesSelector,
-  runCellsByCategory
+  runCellsByCategory,
+  addToRecommendedExtensions
 } from './commands'
 import { WasmSerializer, GrpcSerializer } from './serializer'
 import { RunmeLauncherProvider } from './provider/launcher'
@@ -153,7 +154,12 @@ export class RunmeExtension {
       /**
        * Uri handler
        */
-      window.registerUriHandler(uriHandler)
+      window.registerUriHandler(uriHandler),
+
+      /**
+       * Runme Message Display commands
+       */
+      RunmeExtension.registerCommand('runme.addToRecommendedExtensions', () => addToRecommendedExtensions(context))
     )
 
     await bootFile()
diff --git a/src/extension/messaging.ts b/src/extension/messaging.ts
new file mode 100644
index 00000000..c3099383
--- /dev/null
+++ b/src/extension/messaging.ts
@@ -0,0 +1,120 @@
+import { join } from 'node:path'
+
+import { Disposable, ExtensionContext, Uri, window, workspace } from 'vscode'
+import { TelemetryReporter } from 'vscode-telemetry'
+
+import { EXTENSION_NAME, TELEMETRY_EVENTS } from '../constants'
+
+import { fileOrDirectoryExists, getDefaultWorkspace, isMultiRootWorkspace } from './utils'
+
+export abstract class DisplayableMessage {
+    abstract dispose(): void
+    abstract display(): void
+    constructor(protected readonly context: ExtensionContext, readonly stateSettings?: Record<TELEMETRY_EVENTS, any>) {
+        this.context = context
+        this.stateSettings = stateSettings
+    }
+}
+
+export class MessagingBuilder implements Disposable {
+    /**
+     * Represents a collection of DisplayableMessages, useful to display window messages.
+     * @param messages Specify the displayable messages to show
+     */
+    constructor(private messages: DisplayableMessage[]) {
+        this.messages = messages
+    }
+
+    dispose() {
+        this.messages.forEach(d => d.dispose())
+    }
+
+    /**
+     * Display registered DisplayableMessages
+     */
+    activate() {
+        for (const message of this.messages) {
+            message.display()
+        }
+    }
+}
+
+
+export class RecommendExtensionMessage extends DisplayableMessage implements Disposable {
+    async display(): Promise<void> {
+        try {
+            const skipPrompSettings = this.stateSettings && this.stateSettings[TELEMETRY_EVENTS.RecommendExtension]
+            let promptUser = skipPrompSettings ||
+                this.context.globalState.get(TELEMETRY_EVENTS.RecommendExtension, true)
+            // Multi-root workspace not supported atm
+            const isMultiRoot = isMultiRootWorkspace()
+            if (isMultiRoot && skipPrompSettings) {
+                window.showInformationMessage('Multi-root workspace are not supported')
+                return
+            }
+            const workspaceRoot = getDefaultWorkspace()
+            if (!workspaceRoot || isMultiRoot) {
+                return
+            }
+            const extensionsFile = Uri.parse(join(workspaceRoot, '.vscode/extensions.json'))
+            const extensionfileOrDirectoryExists = await fileOrDirectoryExists(extensionsFile)
+            let extensionRecommendations: string[] = []
+            if (extensionfileOrDirectoryExists) {
+                const extensionDocument = await workspace.openTextDocument(extensionsFile)
+                const extensionContents = JSON.parse(extensionDocument.getText())
+                const { recommendations }: Record<string, string[]> = extensionContents
+                extensionRecommendations = recommendations
+                if (recommendations.includes(EXTENSION_NAME)) {
+                    promptUser = false
+                }
+            }
+
+            if (!promptUser) {
+                return
+            }
+
+            // eslint-disable-next-line max-len
+            const answer = await window.showInformationMessage(
+                'Would you like to add Runme to the recommended extensions?',
+                'Yes',
+                'No',
+                'Don\'t ask again'
+            )
+            if (answer !== 'Yes') {
+                TelemetryReporter.sendTelemetryEvent(TELEMETRY_EVENTS.RecommendExtension,
+                    {
+                        added: 'false',
+                        error: 'false'
+                    }
+                )
+                if (answer === 'Don\'t ask again') {
+                    await this.context.globalState.update(TELEMETRY_EVENTS.RecommendExtension, false)
+                }
+                return
+            }
+
+            const folderUri = Uri.parse(join(workspaceRoot, '.vscode'))
+            const dirExists = await fileOrDirectoryExists(folderUri)
+            if (!dirExists) {
+                await workspace.fs.createDirectory(folderUri)
+            }
+
+            const recommendations = extensionfileOrDirectoryExists
+                ? extensionRecommendations.push(EXTENSION_NAME) && extensionRecommendations
+                : [EXTENSION_NAME]
+            await workspace.fs.writeFile(extensionsFile, Buffer.from(JSON.stringify({
+                recommendations
+            }, null, 2)))
+
+            window.showInformationMessage('Runme added successfully to the recommended extensions')
+            TelemetryReporter.sendTelemetryEvent(TELEMETRY_EVENTS.RecommendExtension, { added: 'true', error: 'false' })
+
+        } catch (error) {
+            window.showErrorMessage('Failed to add Runme to the recommended extensions')
+            TelemetryReporter.sendTelemetryEvent(TELEMETRY_EVENTS.RecommendExtension, { added: 'false', error: 'true' })
+        }
+
+    }
+
+    dispose() { }
+}
\ No newline at end of file
diff --git a/src/extension/utils.ts b/src/extension/utils.ts
index 04020410..a88539a3 100644
--- a/src/extension/utils.ts
+++ b/src/extension/utils.ts
@@ -534,3 +534,16 @@ export async function bootFile() {
   log.info(`Open file defined in "runme.flag.startFile" setting: ${startupFileUriConfig.fsPath}`)
   return commands.executeCommand('vscode.openWith', startupFileUriConfig, Kernel.type)
 }
+
+export async function fileOrDirectoryExists(path: Uri): Promise<boolean> {
+  return workspace.fs.stat(path).then(
+    async (file) => {
+      return file.type === FileType.File || file.type === FileType.Directory
+    },
+    () => false
+  )
+}
+
+export function isMultiRootWorkspace(): boolean {
+  return (workspace.workspaceFolders && workspace.workspaceFolders.length > 1) || false
+}


Test patch:
diff --git a/tests/extension/commands/index.test.ts b/tests/extension/commands/index.test.ts
index 32f66380..14cc5438 100644
--- a/tests/extension/commands/index.test.ts
+++ b/tests/extension/commands/index.test.ts
@@ -13,6 +13,7 @@ import {
   ViewColumn,
   NotebookCellData,
   Uri,
+  ExtensionContext,
 } from 'vscode'
 
 import {
@@ -25,7 +26,8 @@ import {
   createNewRunmeNotebook,
   welcome,
   tryIt,
-  openFileInRunme
+  openFileInRunme,
+  addToRecommendedExtensions
 } from '../../../src/extension/commands'
 import { getTerminalByCell, getAnnotations, openFileAsRunmeNotebook } from '../../../src/extension/utils'
 import {
@@ -33,6 +35,7 @@ import {
   getCLIUseIntegratedRunme,
   isNotebookTerminalEnabledForCell
 } from '../../../src/utils/configuration'
+import { RecommendExtensionMessage } from '../../../src/extension/messaging'
 
 vi.mock('vscode', () => import(path.join(process.cwd(), '__mocks__', 'vscode')))
 vi.mock('vscode-telemetry')
@@ -70,7 +73,7 @@ beforeEach(() => {
 })
 
 test('openTerminal without notebook terminal', () => {
-  const func = toggleTerminal({ } as any, false)
+  const func = toggleTerminal({} as any, false)
 
   vi.mocked(isNotebookTerminalEnabledForCell).mockReturnValueOnce(false)
   vi.mocked(getAnnotations).mockReturnValueOnce(({ interactive: true } as any))
@@ -123,7 +126,7 @@ suite('runCliCommand', () => {
   test('runs command when not dirty, using index', async () => {
     const cell: any = {
       metadata: { name: 'foobar' },
-      document: { uri: { fsPath: '/foo/bar/README.md' }},
+      document: { uri: { fsPath: '/foo/bar/README.md' } },
       kind: 1
     }
 
@@ -148,7 +151,7 @@ suite('runCliCommand', () => {
   test('uses integrated runme if config set', async () => {
     const cell: any = {
       metadata: { name: 'foobar' },
-      document: { uri: { fsPath: '/foo/bar/README.md' }},
+      document: { uri: { fsPath: '/foo/bar/README.md' } },
       kind: 1
     }
 
@@ -169,7 +172,7 @@ suite('runCliCommand', () => {
   test('prompts user when dirty and fails if cancelled or closed', async () => {
     const cell: any = {
       metadata: { name: 'foobar' },
-      document: { uri: { fsPath: '/foo/bar/README.md' }},
+      document: { uri: { fsPath: '/foo/bar/README.md' } },
       kind: 1
     }
 
@@ -205,12 +208,12 @@ suite('runCliCommand', () => {
 
 test('open markdown as Runme notebook', (file: NotebookDocument) => {
   openAsRunmeNotebook(file)
-  expect(window.showNotebookDocument).toBeCalledWith(file, { viewColumn: undefined})
+  expect(window.showNotebookDocument).toBeCalledWith(file, { viewColumn: undefined })
 })
 
 test('open Runme notebook in text editor', (file: TextDocument) => {
   openSplitViewAsMarkdownText(file)
-  expect(window.showTextDocument).toBeCalledWith(file, {viewColumn: ViewColumn.Beside})
+  expect(window.showTextDocument).toBeCalledWith(file, { viewColumn: ViewColumn.Beside })
 })
 
 test('stopBackgroundTask if terminal exists', () => {
@@ -264,7 +267,7 @@ beforeEach(() => {
 test('openFileInRunme command', async () => {
   vi.mocked(openFileAsRunmeNotebook).mockClear()
 
-  const uri = { } as any
+  const uri = {} as any
   await openFileInRunme(uri)
 
   expect(openFileAsRunmeNotebook).toHaveBeenCalledOnce()
@@ -274,11 +277,23 @@ test('openFileInRunme command', async () => {
 test('openFileInRunme command multiselect', async () => {
   vi.mocked(openFileAsRunmeNotebook).mockClear()
 
-  const uri = { } as any
-  const uri2 = { } as any
-  await openFileInRunme(uri, [ uri, uri2 ])
+  const uri = {} as any
+  const uri2 = {} as any
+  await openFileInRunme(uri, [uri, uri2])
 
   expect(openFileAsRunmeNotebook).toBeCalledTimes(2)
   expect(openFileAsRunmeNotebook).toBeCalledWith(uri, 0, [uri, uri2])
   expect(openFileAsRunmeNotebook).toBeCalledWith(uri2, 1, [uri, uri2])
 })
+
+test('addToRecommendedExtensions command', async () => {
+  const recommendExtensionSpy = vi.spyOn(RecommendExtensionMessage.prototype, 'display')
+  const contextMock: ExtensionContext = {
+    globalState: {
+      get: vi.fn().mockReturnValue(undefined),
+      update: vi.fn().mockResolvedValue({}),
+    }
+  } as any
+  await addToRecommendedExtensions(contextMock)
+  expect(recommendExtensionSpy).toHaveBeenCalledOnce()
+})
diff --git a/tests/extension/extension.test.ts b/tests/extension/extension.test.ts
index 49129270..596e5183 100644
--- a/tests/extension/extension.test.ts
+++ b/tests/extension/extension.test.ts
@@ -73,7 +73,7 @@ test('initializes all providers', async () => {
   await ext.initialize(context)
   expect(notebooks.registerNotebookCellStatusBarItemProvider).toBeCalledTimes(6)
   expect(workspace.registerNotebookSerializer).toBeCalledTimes(1)
-  expect(commands.registerCommand).toBeCalledTimes(23)
+  expect(commands.registerCommand).toBeCalledTimes(24)
   expect(window.registerTreeDataProvider).toBeCalledTimes(1)
   expect(window.registerUriHandler).toBeCalledTimes(1)
   expect(bootFile).toBeCalledTimes(1)
diff --git a/tests/extension/messaging.test.ts b/tests/extension/messaging.test.ts
new file mode 100644
index 00000000..3730080c
--- /dev/null
+++ b/tests/extension/messaging.test.ts
@@ -0,0 +1,208 @@
+import { expect, vi, test, suite, beforeEach } from 'vitest'
+import { Disposable, FileType, workspace, window, Uri, ExtensionContext } from 'vscode'
+import { TelemetryReporter } from 'vscode-telemetry'
+
+import { DisplayableMessage, MessagingBuilder, RecommendExtensionMessage } from '../../src/extension/messaging'
+
+vi.mock('vscode', async () => {
+    const vscode = await import('../../__mocks__/vscode')
+    return {
+        default: vscode,
+        ...vscode,
+        workspace: {
+            ...vscode.workspace,
+            openTextDocument: vi.fn().mockReturnValue({
+                getText: vi.fn().mockReturnValue(JSON.stringify({
+                    recommendations: ['stateful.runme']
+                }))
+            })
+        }
+    }
+})
+
+vi.mock('vscode')
+
+vi.mock('vscode-telemetry')
+
+vi.mock('../../src/extension/grpc/client', () => ({}))
+vi.mock('../../src/extension/grpc/runnerTypes', () => ({}))
+
+class MockMessage extends DisplayableMessage implements Disposable {
+    dispose(): void { }
+    display(): void { }
+}
+
+const contextMock: ExtensionContext = {
+    globalState: {
+        get: vi.fn().mockReturnValue(true),
+        update: vi.fn().mockResolvedValue({}),
+    }
+} as any
+
+suite('MessageBuilder', () => {
+
+    test('On Activate should display all displayable messages', () => {
+        const mockMessage = new MockMessage(contextMock)
+        const messageBuilder = new MessagingBuilder([mockMessage, mockMessage])
+        const mockMessageSpy = vi.spyOn(mockMessage, 'display')
+        messageBuilder.activate()
+        expect(mockMessageSpy).toHaveBeenCalledTimes(2)
+    })
+
+    test('On Dispose should dispose all displayable messages', () => {
+        const mockMessage = new MockMessage(contextMock)
+        const messageBuilder = new MessagingBuilder([mockMessage, mockMessage])
+        const mockMessageSpy = vi.spyOn(mockMessage, 'dispose')
+        messageBuilder.dispose()
+        expect(mockMessageSpy).toHaveBeenCalledTimes(2)
+    })
+})
+
+suite('RecommendExtensionMessage', () => {
+
+    beforeEach(() => {
+        vi.mocked(window.showInformationMessage).mockClear()
+        vi.mocked(workspace.fs.writeFile).mockClear()
+    })
+
+    test('It should not prompt the user to install the extension when already added', async () => {
+        const recommendExtension = new RecommendExtensionMessage(contextMock)
+        vi.mocked(workspace.fs.stat).mockResolvedValue({ type: FileType.File } as any)
+        await recommendExtension.display()
+        expect(window.showInformationMessage).toHaveBeenCalledTimes(0)
+        expect(workspace.fs.writeFile).toHaveBeenCalledTimes(0)
+    })
+
+    test('It should add the extension when selecting "Yes" option', async () => {
+        const recommendExtension = new RecommendExtensionMessage(contextMock)
+        vi.mocked(workspace.fs.stat).mockResolvedValue({ type: FileType.File } as any)
+        vi.mocked(workspace.openTextDocument as any).mockResolvedValue({
+            getText: vi.fn().mockReturnValue(JSON.stringify({
+                recommendations: ['microsoft.docker']
+            }))
+        })
+        vi.mocked(window.showInformationMessage).mockResolvedValueOnce('Yes' as any)
+        await recommendExtension.display()
+        expect(workspace.fs.writeFile).toBeCalledTimes(1)
+        expect(TelemetryReporter.sendTelemetryEvent)
+            .toBeCalledWith('runme.recommendExtension', { added: 'true', error: 'false' })
+        expect(window.showInformationMessage)
+            .toHaveBeenCalledWith('Runme added successfully to the recommended extensions')
+    })
+
+
+    test('It should create a .vscode folder when needed', async () => {
+        const recommendExtension = new RecommendExtensionMessage(contextMock)
+        vi.mocked(workspace.fs.stat).mockResolvedValue(false as any)
+        vi.mocked(workspace.openTextDocument as any).mockResolvedValue({
+            getText: vi.fn().mockReturnValue(JSON.stringify({
+                recommendations: ['microsoft.docker']
+            }))
+        })
+        vi.mocked(window.showInformationMessage).mockResolvedValueOnce('Yes' as any)
+        await recommendExtension.display()
+        expect(workspace.fs.createDirectory).toBeCalledTimes(1)
+        expect(workspace.fs.createDirectory).toHaveBeenCalledWith(Uri.parse('/runme/workspace/.vscode'))
+        expect(workspace.fs.writeFile).toBeCalledTimes(1)
+        expect(window.showInformationMessage)
+            .toHaveBeenCalledWith('Runme added successfully to the recommended extensions')
+        expect(TelemetryReporter.sendTelemetryEvent)
+            .toBeCalledWith('runme.recommendExtension', { added: 'true', error: 'false' })
+    })
+
+    test('It should create a .vscode/extensions.json file when needed', async () => {
+        const recommendExtension = new RecommendExtensionMessage(contextMock)
+        vi.mocked(workspace.fs.stat).mockImplementation(async (param: Uri) => {
+            return param.path === '/runme/workspace/.vscode/extensions.json'
+                ? { type: FileType.Unknown } :
+                { type: FileType.Directory } as any
+        })
+        vi.mocked(workspace.openTextDocument as any).mockResolvedValue({
+            getText: vi.fn().mockReturnValue(JSON.stringify({
+                recommendations: ['microsoft.docker']
+            }, null, 2))
+        })
+        vi.mocked(window.showInformationMessage).mockResolvedValueOnce('Yes' as any)
+        await recommendExtension.display()
+        const writeFileCalls = vi.mocked(workspace.fs.writeFile).mock.calls[0]
+        expect(workspace.fs.writeFile).toHaveBeenCalledOnce()
+        expect((writeFileCalls[0] as Uri).path).toStrictEqual('/runme/workspace/.vscode/extensions.json')
+        expect((writeFileCalls[1] as Buffer).toString('utf-8')).toStrictEqual(JSON.stringify({
+            recommendations: ['stateful.runme']
+        }, null, 2))
+        expect(window.showInformationMessage)
+            .toHaveBeenCalledWith('Runme added successfully to the recommended extensions')
+        expect(TelemetryReporter.sendTelemetryEvent)
+            .toBeCalledWith('runme.recommendExtension', { added: 'true', error: 'false' })
+    })
+
+    test('It should not add the extension when selecting "No" option', async () => {
+        const recommendExtension = new RecommendExtensionMessage(contextMock)
+        vi.mocked(workspace.fs.stat).mockResolvedValue({ type: FileType.File } as any)
+        vi.mocked(workspace.openTextDocument as any).mockResolvedValue({
+            getText: vi.fn().mockReturnValue(JSON.stringify({
+                recommendations: ['microsoft.docker']
+            }, null, 2))
+        })
+        vi.mocked(window.showInformationMessage).mockResolvedValueOnce('No' as any)
+        await recommendExtension.display()
+        expect(workspace.fs.writeFile).toBeCalledTimes(0)
+        expect(window.showInformationMessage).toBeCalledTimes(1)
+        expect(TelemetryReporter.sendTelemetryEvent)
+            .toBeCalledWith('runme.recommendExtension', { added: 'false', error: 'false' })
+    })
+
+    test('It should report on failure and display a message', async () => {
+        const recommendExtension = new RecommendExtensionMessage(contextMock)
+        vi.mocked(workspace.openTextDocument as any).mockResolvedValue({
+            getText: vi.fn().mockImplementation(new Error('Failure') as any)
+        })
+        await recommendExtension.display()
+        expect(window.showErrorMessage)
+            .toHaveBeenCalledWith('Failed to add Runme to the recommended extensions')
+        expect(TelemetryReporter.sendTelemetryEvent)
+            .toBeCalledWith('runme.recommendExtension', { added: 'false', error: 'true' })
+    })
+
+    test('It should not prompt when selecting don\'t ask again', async () => {
+        const recommendExtension = new RecommendExtensionMessage(contextMock)
+        vi.mocked(workspace.fs.stat).mockResolvedValue({ type: FileType.File } as any)
+        vi.mocked(workspace.openTextDocument as any).mockResolvedValue({
+            getText: vi.fn().mockReturnValue(JSON.stringify({
+                recommendations: ['microsoft.docker']
+            }, null, 2))
+        })
+        vi.mocked(window.showInformationMessage).mockResolvedValueOnce('Don\'t ask again' as any)
+        const spy = vi.spyOn(contextMock.globalState, 'update')
+        await recommendExtension.display()
+        expect(workspace.fs.writeFile).toBeCalledTimes(0)
+        expect(TelemetryReporter.sendTelemetryEvent)
+            .toBeCalledWith('runme.recommendExtension', { added: 'false', error: 'false' })
+        expect(spy).toHaveBeenCalledWith('runme.recommendExtension', false)
+    })
+
+    test('Should not prompt for multi-root workspaces when using the command palette', async () => {
+        const recommendExtension = new RecommendExtensionMessage(contextMock, {
+            'runme.recommendExtension': true
+        })
+        // @ts-expect-error
+        workspace.workspaceFolders = [
+            { uri: Uri.file('/Users/user/Projects/project1') },
+            { uri: Uri.file('/Users/user/Projects/project2') }
+        ]
+        await recommendExtension.display()
+        expect(window.showInformationMessage).toHaveBeenCalledWith('Multi-root workspace are not supported')
+    })
+
+    test('Should not prompt for multi-root workspaces', async () => {
+        const recommendExtension = new RecommendExtensionMessage(contextMock)
+        // @ts-expect-error
+        workspace.workspaceFolders = [
+            { uri: Uri.file('/Users/user/Projects/project1') },
+            { uri: Uri.file('/Users/user/Projects/project2') }
+        ]
+        await recommendExtension.display()
+        expect(window.showInformationMessage).toHaveBeenCalledTimes(0)
+        expect(workspace.fs.writeFile).toHaveBeenCalledTimes(0)
+    })
+})
\ No newline at end of file
diff --git a/tests/extension/utils.test.ts b/tests/extension/utils.test.ts
index 9d624132..c606913f 100644
--- a/tests/extension/utils.test.ts
+++ b/tests/extension/utils.test.ts
@@ -25,7 +25,9 @@ import {
   getNotebookCategories,
   getNamespacedMid,
   bootFile,
-  isShellLanguage
+  isShellLanguage,
+  fileOrDirectoryExists,
+  isMultiRootWorkspace
 } from '../../src/extension/utils'
 import { ENV_STORE, DEFAULT_ENV } from '../../src/extension/constants'
 import { CellAnnotations } from '../../src/types'
@@ -98,16 +100,16 @@ test('getTerminalByCell', () => {
   } as any))
     .toBeTruthy()
 
-    expect(getTerminalByCell({
-      metadata: { 'runme.dev/uuid': v4() },
-      kind: 2,
-    } as any))
-      .toBeUndefined()
+  expect(getTerminalByCell({
+    metadata: { 'runme.dev/uuid': v4() },
+    kind: 2,
+  } as any))
+    .toBeUndefined()
 
-    expect(getTerminalByCell({
-      kind: 1,
-    } as any))
-      .toBeUndefined()
+  expect(getTerminalByCell({
+    kind: 1,
+  } as any))
+    .toBeUndefined()
 })
 
 test('resetEnv', () => {
@@ -661,3 +663,51 @@ suite('bootFile', () => {
     }), 'runme')
   })
 })
+
+suite('fileOrDirectoryExists', () => {
+  test('should return false when file does not exists', async () => {
+    vi.mocked(workspace.fs.stat).mockResolvedValue({ type: FileType.Unknown } as any)
+    const path = Uri.parse('.vscode-mango')
+    const exists = await fileOrDirectoryExists(path)
+    expect(exists).toBe(false)
+  })
+
+  test('should return true when file does exists', async () => {
+    vi.mocked(workspace.fs.stat).mockResolvedValue({ type: FileType.File } as any)
+    const path = Uri.parse('.vscode-mango')
+    const exists = await fileOrDirectoryExists(path)
+    expect(exists).toBe(true)
+  })
+
+  test('should return true when directory does exists', async () => {
+    vi.mocked(workspace.fs.stat).mockResolvedValue({ type: FileType.Directory } as any)
+    const path = Uri.parse('.vscode-mango')
+    const exists = await fileOrDirectoryExists(path)
+    expect(exists).toBe(true)
+  })
+})
+
+suite('isMultiRootWorkspace', () => {
+  test('should return true when there are multiple workspace folders', () => {
+    // @ts-expect-error
+    workspace.workspaceFolders = [
+      { uri: Uri.file('/Users/user/Projects/project1') },
+      { uri: Uri.file('/Users/user/Projects/project2') }
+    ]
+    expect(isMultiRootWorkspace()).toStrictEqual(true)
+  })
+
+  test('should return false when there is one workspace folder', () => {
+    // @ts-expect-error
+    workspace.workspaceFolders = [
+      { uri: Uri.file('/Users/user/Projects/project1') }
+    ]
+    expect(isMultiRootWorkspace()).toStrictEqual(false)
+  })
+
+  test('should return false when workspaceFolders are not defined', () => {
+    // @ts-expect-error
+    workspace.workspaceFolders = undefined
+    expect(isMultiRootWorkspace()).toStrictEqual(false)
+  })
+})