Repository: stateful/vscode-runme
Pull request #101 (2022-12-15 21:20:30)

Add Walkthrough
A little walkthrough addition to help get people started on Runme. This could be a potential reason we might want to move our docs into this repo.

Demo:

![wt](https://user-images.githubusercontent.com/731337/207969436-e64e0442-c98b-4c8b-9fa4-8b4d07f7af85.gif)



Patch:
diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts
index e4d14b53..6ad122d7 100644
--- a/__mocks__/vscode.ts
+++ b/__mocks__/vscode.ts
@@ -17,6 +17,7 @@ export const Uri = {
 }
 
 export const workspace = {
+  openNotebookDocument: vi.fn().mockReturnValue({ uri: 'new notebook uri' }),
   openTextDocument: vi.fn(),
   registerNotebookSerializer: vi.fn(),
   fs: {
@@ -38,7 +39,8 @@ export const window = {
 }
 
 export const commands = {
-  registerCommand: vi.fn()
+  registerCommand: vi.fn(),
+  executeCommand: vi.fn()
 }
 
 export enum ViewColumn  {
@@ -51,3 +53,10 @@ export const env = {
   },
   openExternal: vi.fn()
 }
+
+export const NotebookData = vi.fn()
+export const NotebookCellData = vi.fn()
+export enum NotebookCellKind {
+  Code = 1,
+  Markup = 2
+}
diff --git a/package.json b/package.json
index 0727ba13..bcc8ef08 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,10 @@
   "main": "./out/extension.js",
   "contributes": {
     "commands": [
+      {
+        "command": "runme.new",
+        "title": "Create: New Runme Notebook"
+      },
       {
         "command": "runme.openTerminal",
         "title": "Open Terminal for Background Task"
@@ -123,6 +127,12 @@
           "command": "runme.openSplitViewAsMarkdownText",
           "group": "navigation"
         }
+      ],
+      "file/newFile": [
+        {
+          "command": "runme.new",
+          "group": "notebook"
+        }
       ]
     },
     "notebooks": [
@@ -166,16 +176,47 @@
             "type": "boolean",
             "scope": "machine",
             "default": true,
-            "markdownDescription": "If set to `true` all shell scripts are run as interactive VS Code tasks, if set to `false` they are executed within the kernel and output is set in resulting cell."
+            "markdownDescription": "If enabled all shell scripts are run as interactive VS Code tasks, if set to `false` they are executed within the kernel and output is set in resulting cell."
           },
           "runme.shell.closeTerminalOnSuccess": {
             "type": "boolean",
             "scope": "machine",
             "default": true,
-            "markdownDescription": "If set to `true`, the terminal closes if task succeeds running."
+            "markdownDescription": "If enabled, the terminal closes if task succeeds running."
+          },
+          "runme.flags.disableSaveRestriction": {
+            "type": "boolean",
+            "scope": "machine",
+            "default": false,
+            "markdownDescription": "If enabled, Runme's save restriction on non checked-in files will be lifted."
           }
         }
       }
+    ],
+    "walkthroughs": [
+      {
+        "id": "runme.welcome",
+        "title": "Get Started with creating Runme Notebooks",
+        "description": "Runme allows to create interactive runbooks for VS Code.",
+        "steps": [
+          {
+            "id": "runme.welcome.why",
+            "title": "Why Runme?",
+            "description": "Runme is a human-centric approach to providing and consuming code repository documentation. It's designed to progressively bridge the widening developer experience gaps in a cloud-native age.",
+            "media": {
+              "markdown": "walkthroughs/0-intro.md"
+            }
+          },
+          {
+            "id": "runme.welcome.annotations",
+            "title": "Code Block Annotations",
+            "description": "Runme supports a variety of code block annotations that allows to modify the behavior of the cell and how it is being executed.",
+            "media": {
+              "markdown": "walkthroughs/1-annotations.md"
+            }
+          }
+        ]
+      }
     ]
   },
   "scripts": {
diff --git a/src/extension/commands/index.ts b/src/extension/commands/index.ts
index d4d802c7..f690a0a1 100644
--- a/src/extension/commands/index.ts
+++ b/src/extension/commands/index.ts
@@ -1,7 +1,11 @@
 import path from 'node:path'
 
-import { NotebookCell, Uri, window, env, NotebookDocument, TextDocument, ViewColumn } from 'vscode'
+import {
+  NotebookCell, Uri, window, env, NotebookDocument, TextDocument, ViewColumn,
+  workspace, NotebookData, commands, NotebookCellData, NotebookCellKind
+} from 'vscode'
 
+import { Kernel } from '../kernel'
 import { CliProvider } from '../provider/cli'
 import { getMetadata, getTerminalByCell } from '../utils'
 
@@ -57,3 +61,24 @@ export function openSplitViewAsMarkdownText (doc: TextDocument) {
     viewColumn: ViewColumn.Beside
   })
 }
+
+export async function createNewRunmeNotebook () {
+  const newNotebook = await workspace.openNotebookDocument(Kernel.type, new NotebookData([
+    new NotebookCellData(
+      NotebookCellKind.Markup,
+      '# Runme Notebook\n\nStart writing here...',
+      'markdown'
+    ),
+    new NotebookCellData(
+      NotebookCellKind.Code,
+      'echo "Hello World"',
+      'bash'
+    ),
+    new NotebookCellData(
+      NotebookCellKind.Markup,
+      '_Find more documentation on writing Runme markdown on [runme.dev](https://www.runme.dev/docs/intro)!_',
+      'markdown'
+    )
+  ]))
+  await commands.executeCommand('vscode.openWith', newNotebook.uri, Kernel.type)
+}
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index 691a23ef..b0c113dc 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -12,7 +12,8 @@ import {
   copyCellToClipboard,
   openAsRunmeNotebook,
   openSplitViewAsMarkdownText ,
-  stopBackgroundTask
+  stopBackgroundTask,
+  createNewRunmeNotebook
 } from './commands'
 import { Serializer } from './serializer'
 
@@ -42,6 +43,7 @@ export class RunmeExtension {
       commands.registerCommand('runme.stopBackgroundTask', stopBackgroundTask),
       commands.registerCommand('runme.openSplitViewAsMarkdownText', openSplitViewAsMarkdownText),
       commands.registerCommand('runme.openAsRunmeNotebook', openAsRunmeNotebook),
+      commands.registerCommand('runme.new', createNewRunmeNotebook),
     )
   }
 }
diff --git a/src/extension/kernel.ts b/src/extension/kernel.ts
index dc28c4e1..7e012ccf 100644
--- a/src/extension/kernel.ts
+++ b/src/extension/kernel.ts
@@ -11,11 +11,13 @@ import { resetEnv, getKey } from './utils'
 import './wasm/wasm_exec.js'
 
 export class Kernel implements Disposable {
+  static readonly type = 'runme' as const
+
   #disposables: Disposable[] = []
   #controller = notebooks.createNotebookController(
-    'runme',
-    'runme',
-    'RUNME'
+    Kernel.type,
+    Kernel.type,
+    Kernel.type.toUpperCase()
   )
   protected messaging = notebooks.createRendererMessaging('runme-renderer')
 
diff --git a/src/extension/serializer.ts b/src/extension/serializer.ts
index 39a20d97..5d22e2c9 100644
--- a/src/extension/serializer.ts
+++ b/src/extension/serializer.ts
@@ -2,6 +2,7 @@
 import {
   workspace,
   window,
+  commands,
   NotebookSerializer,
   ExtensionContext,
   Uri,
@@ -9,14 +10,13 @@ import {
   NotebookCellData,
   NotebookCellKind,
   CancellationToken,
-  NotebookDocument
 } from 'vscode'
 
 import { WasmLib } from '../types'
 
 import Languages from './languages'
 import { PLATFORM_OS } from './constants'
-import { verifyCheckedInFile } from './utils'
+import { canEditFile } from './utils'
 
 const DEFAULT_LANG_ID = 'text'
 
@@ -54,39 +54,39 @@ export class Serializer implements NotebookSerializer {
     data: NotebookData,
     token: CancellationToken
   ): Promise<Uint8Array> {
-    try {
-      await this.checkTracked(window.activeNotebookEditor?.notebook)
-
-      const err = await this.wasmReady
-      if (err) {
-        throw err
-      }
-
-      const { Runme } = globalThis as WasmLib.Serializer
-
-      const notebook = JSON.stringify(data)
-      const markdown = await Runme.serialize(notebook)
-
-      const encoder = new TextEncoder()
-      const encoded = encoder.encode(markdown)
-
-      return encoded
-    } catch (err: any) {
-      console.error(err)
-      throw err
+    if (!window.activeNotebookEditor) {
+      throw new Error('Could\'t save notebook as it is not active!')
     }
-  }
 
-  private async checkTracked(notebook?: NotebookDocument) {
-    const currentDocumentPath = notebook?.uri.fsPath
-
-    if (currentDocumentPath && !(await verifyCheckedInFile(currentDocumentPath))) {
-      throw new Error(
+    if (!await canEditFile(window.activeNotebookEditor.notebook)) {
+      const errorMessage = (
         'You are writing to a file that is not version controlled! ' +
         'Runme\'s authoring features are in early stages and require hardening. ' +
-        'We wouldn\'t want you to loose important data. Please version track your file first.'
+        'We wouldn\'t want you to loose important data. Please version track your file first ' +
+        'or disable this restriction in the VS Code settings.'
       )
+      window.showErrorMessage(errorMessage, 'Open Runme Settings').then((openSettings) => {
+        if (openSettings) {
+          return commands.executeCommand('workbench.action.openSettings', 'runme.flags.disableSaveRestriction')
+        }
+      })
+      throw new Error('saving non version controlled notebooks is disabled during beta phase.')
     }
+
+    const err = await this.wasmReady
+    if (err) {
+      throw err
+    }
+
+    const { Runme } = globalThis as WasmLib.Serializer
+
+    const notebook = JSON.stringify(data)
+    const markdown = await Runme.serialize(notebook)
+
+    const encoder = new TextEncoder()
+    const encoded = encoder.encode(markdown)
+
+    return encoded
   }
 
   public async deserializeNotebook(
diff --git a/src/extension/utils.ts b/src/extension/utils.ts
index 4b2eef42..e6e430ad 100644
--- a/src/extension/utils.ts
+++ b/src/extension/utils.ts
@@ -2,12 +2,13 @@ import path from 'node:path'
 import util from 'node:util'
 import cp from 'node:child_process'
 
-import vscode, { FileType } from 'vscode'
+import vscode, { FileType, NotebookDocument } from 'vscode'
 
 import { CONFIGURATION_SHELL_DEFAULTS } from '../constants'
 import { WasmLib } from '../types'
 
 import executor from './executors'
+import { Kernel } from './kernel'
 import { ENV_STORE, DEFAULT_ENV } from './constants'
 
 const HASH_PREFIX_REGEXP = /^\s*\#\s*/g
@@ -121,3 +122,36 @@ export async function verifyCheckedInFile (filePath: string) {
   ).then(() => true, () => false)
   return isCheckedIn
 }
+
+export async function canEditFile (
+  notebook: NotebookDocument,
+  // for testing purposes only
+  verifyCheckedInFileFn = verifyCheckedInFile
+): Promise<boolean> {
+  const config = vscode.workspace.getConfiguration('runme.flags')
+  const disableSaveRestriction = config.get<boolean>('disableSaveRestriction')
+  const currentDocumentPath = notebook.uri.fsPath
+  const isNewFile = notebook.isUntitled && notebook.notebookType === Kernel.type
+
+  /**
+   * allow serializing files if:
+   */
+  if (
+    /**
+     * the user has disabled this restriction
+     */
+    disableSaveRestriction ||
+    /**
+     * the user just created a new file
+     */
+    isNewFile ||
+    /**
+     * the user works on a checked in file
+     */
+    !currentDocumentPath || (await verifyCheckedInFileFn(currentDocumentPath))
+  ) {
+    return true
+  }
+
+  return false
+}
diff --git a/walkthroughs/0-intro.md b/walkthroughs/0-intro.md
new file mode 100644
index 00000000..ed5ca690
--- /dev/null
+++ b/walkthroughs/0-intro.md
@@ -0,0 +1,26 @@
+# Why Runme?
+
+Thank you for interest in Runme 💟!
+
+Runme is a human-centric approach to providing and consuming code repository documentation. It's designed to progressively bridge the widening developer experience gaps in a cloud-native age. Gaps such as low integration between building blocks employed to deliver apps and services, error-prone copy&paste-management of dev environments, docs that have fallen behind undetect, and the vault lines of commonplace tools which were designed for machines not humans.
+
+![Readme as Notebook and Markdown side-by-side](https://www.runme.dev/assets/images/README_side_by_side-e67bbc4db8e183d9193f1fcccd9b302b.png)
+
+## 🌈 Runme will let you
+
+- Easily onramp via your existing README.md (any markdown docs) with zero changes
+- Intuitively run your commands (shell) in a notebook UX inside your IDE
+- Alternatively the `runme` CLI is available for power users
+- Curate developer experiences using Runme's subtle command block annotations
+- Use commonplace git workflows to continuously improve your docs, just like code
+- Continue to display your docs in Markdown-compatible viewers
+
+## 🛣 What's on the roadmap
+
+- Coming soon 🔥! Authoring/editing inside of notebook UX
+- Just like code, test your documentation in Continiuous Integration
+- Switch back and forth between notebook and terminal UX seamlessly
+- Notebook UX tweaks and integrations for both authors and users
+- And more - tell us about your feature request [on Discord](https://discord.gg/stateful)
+
+<br /><br />
diff --git a/walkthroughs/1-annotations.md b/walkthroughs/1-annotations.md
new file mode 100644
index 00000000..db89bc19
--- /dev/null
+++ b/walkthroughs/1-annotations.md
@@ -0,0 +1,37 @@
+
+# Code Block Annotations
+
+Runme supports a variety of code block annotations that allows to modify the behavior of the cell and how it is being executed.
+
+## `background`
+
+Some scripts are suppose to run within the background to not disturb you from development. With the `background` annotation you can tell Runme to have this command run as background task and don't have it pop-up a terminal.
+
+__Default:__ `false`<br />
+__Example:__
+
+    ```sh { background=true }
+    npm run watch
+    ```
+
+## `interactive`
+
+With the interactive flag you can decide whether the stdout of the process should be printed directly within the result cell or as part of a VS Code terminal. __Note:__ printing stdout within the result cell doesn't allow you to interact with the process, e.g. through `stdin` input. However it allows you to copy out the process output which is useful in many situations to continue with your dev-ops process.
+
+__Default:__ `true`<br />
+__Example:__
+
+    ```sh { interactive=false }
+    openssl rand -base64 32
+    ```
+
+## `closeTerminalOnSuccess`
+
+If your execute a command within a VS Code terminal window you can have it stick around even after successful execution using the `closeTerminalOnSuccess` flag.
+
+__Default:__ `true`<br />
+__Example:__
+
+    ```sh { closeTerminalOnSuccess=false }
+    docker ps | grep runme/demo:latest
+    ```


Test patch:
diff --git a/tests/extension/commands/index.test.ts b/tests/extension/commands/index.test.ts
index d36324a7..a20b1ee6 100644
--- a/tests/extension/commands/index.test.ts
+++ b/tests/extension/commands/index.test.ts
@@ -1,11 +1,17 @@
+import path from 'node:path'
+
 import { beforeEach, expect, test, vi } from 'vitest'
 import {
-  window, env,
+  window,
+  env,
+  workspace,
+  commands,
   // @ts-expect-error mock feature
   terminal,
   NotebookDocument,
   TextDocument,
   ViewColumn,
+  NotebookCellData
 } from 'vscode'
 
 import {
@@ -14,11 +20,13 @@ import {
   runCLICommand,
   openAsRunmeNotebook,
   openSplitViewAsMarkdownText,
-  stopBackgroundTask } from '../../../src/extension/commands'
+  stopBackgroundTask,
+  createNewRunmeNotebook
+} from '../../../src/extension/commands'
 import { getTerminalByCell, getMetadata } from '../../../src/extension/utils'
 import { CliProvider } from '../../../src/extension/provider/cli'
 
-vi.mock('vscode')
+vi.mock('vscode', () => import(path.join(process.cwd(), '__mocks__', 'vscode')))
 vi.mock('../../../src/extension/utils', () => ({
   getMetadata: vi.fn(),
   getTerminalByCell: vi.fn()
@@ -94,3 +102,10 @@ test('stopBackgroundTask if terminal does not exist', () => {
   stopBackgroundTask({} as any)
   expect(window.showWarningMessage).toBeCalledTimes(1)
 })
+
+test('createNewRunmeNotebook', async () => {
+  await createNewRunmeNotebook()
+  expect(workspace.openNotebookDocument).toBeCalledWith('runme', expect.any(Object))
+  expect(NotebookCellData).toBeCalledTimes(3)
+  expect(commands.executeCommand).toBeCalledWith('vscode.openWith', expect.any(String), 'runme')
+})
diff --git a/tests/extension/extension.test.ts b/tests/extension/extension.test.ts
index 576e55ec..2b6b0d7a 100644
--- a/tests/extension/extension.test.ts
+++ b/tests/extension/extension.test.ts
@@ -12,5 +12,5 @@ test('initializes all providers', async () => {
   await ext.initialize(context)
   expect(notebooks.registerNotebookCellStatusBarItemProvider).toBeCalledTimes(5)
   expect(workspace.registerNotebookSerializer).toBeCalledTimes(1)
-  expect(commands.registerCommand).toBeCalledTimes(7)
+  expect(commands.registerCommand).toBeCalledTimes(8)
 })
diff --git a/tests/extension/serializer.test.ts b/tests/extension/serializer.test.ts
new file mode 100644
index 00000000..3e9cac19
--- /dev/null
+++ b/tests/extension/serializer.test.ts
@@ -0,0 +1,67 @@
+import { window } from 'vscode'
+import { expect, vi, it, describe } from 'vitest'
+
+import { Serializer } from '../../src/extension/serializer'
+import { canEditFile } from '../../src/extension/utils'
+
+globalThis.Go = vi.fn()
+globalThis.Runme = { serialize: vi.fn().mockResolvedValue('Hello World!') }
+
+vi.mock('vscode', () => ({
+    window: {
+        activeNotebookEditor: undefined,
+        showErrorMessage: vi.fn().mockResolvedValue({})
+    },
+    Uri: { joinPath: vi.fn().mockReturnValue('/foo/bar') },
+    workspace: {
+        fs: { readFile: vi.fn().mockReturnValue(new Promise(() => {})) }
+    },
+    commands: { executeCommand: vi.fn() }
+}))
+
+vi.mock('../../src/extension/utils', () => ({
+    canEditFile: vi.fn().mockResolvedValue(false)
+}))
+
+describe('Serializer', () => {
+    const context: any = {
+        extensionUri: { fsPath: '/foo/bar' }
+    }
+
+    describe('serializeNotebook', () => {
+        it('fails when notebook is not active', async () => {
+            const s = new Serializer(context)
+            await expect(() => s.serializeNotebook({} as any, {} as any))
+                .rejects.toThrow(/not active/)
+        })
+
+        it('prevents saving if canEditFile returns false', async () => {
+            // @ts-ignore readonly
+            window.activeNotebookEditor = {} as any
+            const s = new Serializer(context)
+            await expect(() => s.serializeNotebook({} as any, {} as any))
+                .rejects.toThrow(/disabled during beta phase/)
+        })
+
+        it('throws if wasm fails to laod', async () => {
+            // @ts-ignore readonly
+            window.activeNotebookEditor = {} as any
+            vi.mocked(canEditFile).mockResolvedValue(true)
+            const s = new Serializer(context)
+            // @ts-ignore readonly
+            s['wasmReady'] = Promise.reject('ups')
+            await expect(() => s.serializeNotebook({} as any, {} as any)).rejects.toThrow(/ups/)
+        })
+
+        it('uses Runme wasm to save the file', async () => {
+            // @ts-ignore readonly
+            window.activeNotebookEditor = {} as any
+            vi.mocked(canEditFile).mockResolvedValue(true)
+            const s = new Serializer(context)
+            // @ts-ignore readonly
+            s['wasmReady'] = Promise.resolve()
+            expect(Buffer.from(await s.serializeNotebook({} as any, {} as any)))
+                .toEqual(Buffer.from('Hello World!'))
+        })
+    })
+})
diff --git a/tests/extension/utilts.test.ts b/tests/extension/utilts.test.ts
index 48440c3f..9f6f9d44 100644
--- a/tests/extension/utilts.test.ts
+++ b/tests/extension/utilts.test.ts
@@ -1,5 +1,5 @@
 import vscode from 'vscode'
-import { expect, vi, test, beforeAll, afterAll, suite } from 'vitest'
+import { expect, vi, test, beforeEach, beforeAll, afterAll, suite } from 'vitest'
 
 import {
   getExecutionProperty,
@@ -8,6 +8,7 @@ import {
   getKey,
   getCmdShellSeq,
   normalizeLanguage,
+  canEditFile,
 } from '../../src/extension/utils'
 import { ENV_STORE, DEFAULT_ENV } from '../../src/extension/constants'
 
@@ -138,3 +139,42 @@ suite('normalizeLanguage', () => {
     expect(lang).toBe('sh')
   })
 })
+
+suite('canEditFile', () => {
+  const verifyCheckedInFile = vi.fn().mockResolvedValue(false)
+  const notebook: any = {
+    isUntitled: false,
+    notebookType: 'runme',
+    uri: { fsPath: '/foo/bar' }
+  }
+
+  beforeEach(() => {
+    vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
+      get: vi.fn().mockReturnValue(false)
+    } as any)
+  })
+
+  test('can not edit by default', async () => {
+    expect(await canEditFile(notebook, verifyCheckedInFile)).toBe(false)
+  })
+
+  test('can edit if ignore flag is enabled', async () => {
+    const notebookMock: any = JSON.parse(JSON.stringify(notebook))
+    vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
+      get: vi.fn().mockReturnValue(true)
+    } as any)
+    expect(await canEditFile(notebookMock, verifyCheckedInFile)).toBe(true)
+  })
+
+  test('can edit file if new', async () => {
+    const notebookMock: any = JSON.parse(JSON.stringify(notebook))
+    notebookMock.isUntitled = true
+    expect(await canEditFile(notebookMock, verifyCheckedInFile)).toBe(true)
+  })
+
+  test('can edit file if checked in', async () => {
+    const notebookMock: any = JSON.parse(JSON.stringify(notebook))
+    verifyCheckedInFile.mockResolvedValue(true)
+    expect(await canEditFile(notebookMock, verifyCheckedInFile)).toBe(true)
+  })
+})
diff --git a/vitest.conf.ts b/vitest.conf.ts
index 080eecc3..959eebbe 100644
--- a/vitest.conf.ts
+++ b/vitest.conf.ts
@@ -4,12 +4,9 @@ import { defineConfig } from 'vitest/config'
 export default defineConfig({
   test: {
     include: ['tests/**/*.test.ts'],
-    /**
-     * not to ESM ported packages
-     */
     exclude: [
       'dist', '.idea', '.git', '.cache',
-      '**/node_modules/**', 'examples'
+      '**/node_modules/**', 'examples/**'
     ],
     coverage: {
       enabled: true,