Repository: stateful/vscode-runme
Pull request #100 (2022-12-15 19:43:48)

Allow users to store a new Runme notebook file
This update allows to save new files created within the `runme` notebook, e.g.:

![createnew](https://user-images.githubusercontent.com/731337/207952243-961f0c5c-fb76-4597-b761-ec1966ebc1af.gif)

As you can see, a second save is not possible due to our restriction. My suggestion here is to allow disable this through a configuration.


Patch:
diff --git a/package.json b/package.json
index 641a5992..d964b283 100644
--- a/package.json
+++ b/package.json
@@ -176,13 +176,19 @@
             "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."
           }
         }
       }
diff --git a/src/extension/commands/index.ts b/src/extension/commands/index.ts
index 4e987923..f690a0a1 100644
--- a/src/extension/commands/index.ts
+++ b/src/extension/commands/index.ts
@@ -5,6 +5,7 @@ import {
   workspace, NotebookData, commands, NotebookCellData, NotebookCellKind
 } from 'vscode'
 
+import { Kernel } from '../kernel'
 import { CliProvider } from '../provider/cli'
 import { getMetadata, getTerminalByCell } from '../utils'
 
@@ -62,7 +63,7 @@ export function openSplitViewAsMarkdownText (doc: TextDocument) {
 }
 
 export async function createNewRunmeNotebook () {
-  const newNotebook = await workspace.openNotebookDocument('runme', new NotebookData([
+  const newNotebook = await workspace.openNotebookDocument(Kernel.type, new NotebookData([
     new NotebookCellData(
       NotebookCellKind.Markup,
       '# Runme Notebook\n\nStart writing here...',
@@ -79,5 +80,5 @@ export async function createNewRunmeNotebook () {
       'markdown'
     )
   ]))
-  await commands.executeCommand('vscode.openWith', newNotebook.uri, 'runme')
+  await commands.executeCommand('vscode.openWith', newNotebook.uri, Kernel.type)
 }
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
+}


Test patch:
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,