Repository: stateful/vscode-runme
Pull request #102 (2022-12-15 22:36:27)

Allow user to lift save restriction
Demo:
![lift](https://user-images.githubusercontent.com/731337/207981737-381ffdfc-1989-46e2-9e9e-8d0fffcd4644.gif)



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/serializer.ts b/src/extension/serializer.ts
index 32590be8..5d22e2c9 100644
--- a/src/extension/serializer.ts
+++ b/src/extension/serializer.ts
@@ -2,6 +2,7 @@
 import {
   workspace,
   window,
+  commands,
   NotebookSerializer,
   ExtensionContext,
   Uri,
@@ -9,15 +10,13 @@ import {
   NotebookCellData,
   NotebookCellKind,
   CancellationToken,
-  NotebookDocument
 } from 'vscode'
 
 import { WasmLib } from '../types'
 
 import Languages from './languages'
-import { Kernel } from './kernel'
 import { PLATFORM_OS } from './constants'
-import { verifyCheckedInFile } from './utils'
+import { canEditFile } from './utils'
 
 const DEFAULT_LANG_ID = 'text'
 
@@ -59,40 +58,35 @@ export class Serializer implements NotebookSerializer {
       throw new Error('Could\'t save notebook as it is not active!')
     }
 
-    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)
+    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 ' +
+        '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.')
+    }
 
-      return encoded
-    } catch (err: any) {
-      console.error(err)
+    const err = await this.wasmReady
+    if (err) {
       throw err
     }
-  }
 
-  private async checkTracked(notebook: NotebookDocument) {
-    const currentDocumentPath = notebook.uri.fsPath
-    const isNewFile = notebook.isUntitled && notebook.notebookType === Kernel.type
+    const { Runme } = globalThis as WasmLib.Serializer
 
-    if (!isNewFile && currentDocumentPath && !(await verifyCheckedInFile(currentDocumentPath))) {
-      throw new Error(
-        '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.'
-      )
-    }
+    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)
+  })
+})