Repository: stateful/vscode-runme
Pull request #182 (2023-02-07 08:13:06)

Support set-up by file
This is an addition to https://github.com/stateful/vscode-runme/pull/159 which allows one to set up a project with an url of a single markdown file, e.g. using a gist. The url has to be as follows:

```
vscode://stateful.runme?command=setup&fileToOpen=https://raw.githubusercontent.com/stateful/vscode-runme/main/examples/k8s/README.md
```


Patch:
diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts
index b9f73389..64acfabc 100644
--- a/__mocks__/vscode.ts
+++ b/__mocks__/vscode.ts
@@ -31,6 +31,7 @@ export const workspace = {
   onDidOpenNotebookDocument: vi.fn().mockReturnValue({ dispose: vi.fn() }),
   onDidSaveNotebookDocument: vi.fn().mockReturnValue({ dispose: vi.fn() }),
   fs: {
+    writeFile: vi.fn(),
     readFile: vi.fn().mockResolvedValue(Buffer.from('some wasm file')),
     stat: vi.fn().mockResolvedValue(1),
     createDirectory: vi.fn(),
diff --git a/src/extension/handler/uri.ts b/src/extension/handler/uri.ts
index 565246c5..33dbf41b 100644
--- a/src/extension/handler/uri.ts
+++ b/src/extension/handler/uri.ts
@@ -1,9 +1,16 @@
-import { UriHandler, window, Uri, Progress, ProgressLocation, commands } from 'vscode'
+import path from 'node:path'
 
-import { getProjectDir, getTargetDirName, waitForProjectCheckout, getSuggestedProjectName } from './utils'
+import { UriHandler, window, Uri, Progress, ProgressLocation, commands, workspace } from 'vscode'
+import got from 'got'
+import { v4 as uuidv4 } from 'uuid'
+
+import {
+    getProjectDir, getTargetDirName, waitForProjectCheckout, getSuggestedProjectName, writeBootstrapFile
+} from './utils'
 
 let NEXT_TERM_ID = 0
 const DEFAULT_START_FILE = 'README.md'
+const REGEX_WEB_RESOURCE = /^https?:\/\//
 
 export class RunmeUriHandler implements UriHandler {
     async handleUri (uri: Uri) {
@@ -18,7 +25,14 @@ export class RunmeUriHandler implements UriHandler {
 
         if (command === 'setup') {
             const fileToOpen = params.get('fileToOpen') || DEFAULT_START_FILE
-            await this._setupProject(fileToOpen, params.get('repository'))
+            const repository = params.get('repository')
+
+            if (!repository && fileToOpen.match(REGEX_WEB_RESOURCE)) {
+                await this._setupFile(fileToOpen)
+                return
+            }
+
+            await this._setupProject(fileToOpen, repository)
             return
         }
 
@@ -51,6 +65,36 @@ export class RunmeUriHandler implements UriHandler {
         }, (progress) => this._cloneProject(progress, targetDirUri, repository, fileToOpen))
     }
 
+    private async _setupFile (fileToOpen: string) {
+        const fileName = path.basename(Uri.parse(fileToOpen).fsPath)
+        if (!fileName.endsWith('.md')) {
+            return window.showErrorMessage('Parameter "fileToOpen" from URL is not a markdown file!')
+        }
+
+        /**
+         * cancel operation if user doesn't want to create set up project directory
+         */
+        const projectDirUri = await getProjectDir()
+        if (!projectDirUri) {
+            return
+        }
+
+        try {
+            const fileContent = (await got.get(fileToOpen)).body
+            const projectUri = Uri.joinPath(projectDirUri, uuidv4())
+            await workspace.fs.createDirectory(projectUri)
+
+            const enc = new TextEncoder()
+            await workspace.fs.writeFile(Uri.joinPath(projectUri, fileName), enc.encode(fileContent))
+            await writeBootstrapFile(projectUri, fileName)
+            await commands.executeCommand('vscode.openFolder', Uri.parse(projectUri.fsPath), {
+                forceNewWindow: true
+            })
+        } catch (err: unknown) {
+            return window.showErrorMessage(`Failed to set-up project from ${fileToOpen}: ${(err as Error).message}`)
+        }
+    }
+
     private async _cloneProject (
         progress: Progress<{ message?: string, increment?: number }>,
         targetDirUri: Uri,
diff --git a/src/extension/handler/utils.ts b/src/extension/handler/utils.ts
index 76e23cbb..a524aaaa 100644
--- a/src/extension/handler/utils.ts
+++ b/src/extension/handler/utils.ts
@@ -114,21 +114,22 @@ export async function waitForProjectCheckout (
          * write a runme file into the directory, so the extension knows it has
          * to open the readme file
          */
-        const fileExist = await workspace.fs.stat(Uri.joinPath(targetDirUri, fileToOpen))
-            .then(() => true, () => false)
-        if (fileExist) {
-            const enc = new TextEncoder()
-            await workspace.fs.writeFile(
-                Uri.joinPath(targetDirUri, BOOTFILE),
-                enc.encode(fileToOpen)
-            )
-            console.log(`[Runme] Created temporary bootstrap file to open ${fileToOpen}`)
-        }
+        await workspace.fs.stat(Uri.joinPath(targetDirUri, fileToOpen))
+            .then(() => writeBootstrapFile(targetDirUri, fileToOpen))
 
         cb(true)
     }, INTERVAL)
 }
 
+export async function writeBootstrapFile (targetDirUri: Uri, fileToOpen: string) {
+    const enc = new TextEncoder()
+    await workspace.fs.writeFile(
+        Uri.joinPath(targetDirUri, BOOTFILE),
+        enc.encode(fileToOpen)
+    )
+    console.log(`[Runme] Created temporary bootstrap file to open ${fileToOpen}`)
+}
+
 /**
  * verify repository url has the right format and get suggested name from provided repository url
  */


Test patch:
diff --git a/tests/extension/handler/uri.test.ts b/tests/extension/handler/uri.test.ts
index d1ef4978..d8be6a56 100644
--- a/tests/extension/handler/uri.test.ts
+++ b/tests/extension/handler/uri.test.ts
@@ -1,10 +1,11 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest'
 // @ts-expect-error mock feature
-import { commands, window, Uri, terminal } from 'vscode'
+import { commands, window, Uri, terminal, workspace } from 'vscode'
+import got from 'got'
 
 import { RunmeUriHandler } from '../../../src/extension/handler/uri'
 import {
-    getProjectDir, waitForProjectCheckout, getSuggestedProjectName
+    getProjectDir, waitForProjectCheckout, getSuggestedProjectName, writeBootstrapFile
 } from '../../../src/extension/handler/utils'
 
 vi.mock('vscode')
@@ -13,7 +14,11 @@ vi.mock('../../../src/extension/handler/utils', () => ({
     getProjectDir: vi.fn(),
     getTargetDirName: vi.fn(),
     waitForProjectCheckout: vi.fn(),
-    getSuggestedProjectName: vi.fn()
+    getSuggestedProjectName: vi.fn(),
+    writeBootstrapFile: vi.fn()
+}))
+vi.mock('got', () => ({
+    default: { get: vi.fn().mockResolvedValue({ body: 'some markdown' }) }
 }))
 
 describe('RunmeUriHandler', () => {
@@ -21,7 +26,10 @@ describe('RunmeUriHandler', () => {
         vi.mocked(window.showErrorMessage).mockClear()
         vi.mocked(window.showInformationMessage).mockClear()
         vi.mocked(commands.executeCommand).mockClear()
+        vi.mocked(workspace.fs.createDirectory).mockClear()
+        vi.mocked(workspace.fs.writeFile).mockClear()
         vi.mocked(Uri.parse).mockReset()
+        vi.mocked(getProjectDir).mockClear()
     })
 
     describe('handleUri', async () => {
@@ -30,6 +38,7 @@ describe('RunmeUriHandler', () => {
         beforeEach(() => {
             handler = new RunmeUriHandler()
             handler['_setupProject'] = vi.fn()
+            handler['_setupFile'] = vi.fn()
         })
 
         it('should fail if no command was found', async () => {
@@ -55,6 +64,13 @@ describe('RunmeUriHandler', () => {
             await handler.handleUri(Uri.parse('vscode://stateful.runme?foo=bar'))
             expect(handler['_setupProject']).toBeCalledWith('/sub/file.md', 'git@github.com:/foo/bar')
         })
+
+        it('runs _setupFile if command was "setup" but no repository param', async () => {
+            const fileToOpen = 'https://raw.githubusercontent.com/stateful/vscode-runme/main/examples/k8s/README.md'
+            vi.mocked(Uri.parse).mockReturnValue({ query: { command: 'setup', fileToOpen }, fsPath: '/foo/bar' } as any)
+            await handler.handleUri(Uri.parse('vscode://stateful.runme?foo=bar'))
+            expect(handler['_setupFile']).toBeCalledWith(fileToOpen)
+        })
     })
 
     describe('_setupProject', () => {
@@ -85,6 +101,44 @@ describe('RunmeUriHandler', () => {
         })
     })
 
+    describe('_setupFile', () => {
+        let handler: RunmeUriHandler
+
+        beforeEach(() => {
+            handler = new RunmeUriHandler()
+        })
+
+        it('shows warning if file is not a markdown', async () => {
+            vi.mocked(Uri.parse).mockReturnValue({ query: { command: 'setup' }, fsPath: '/foo/bar'} as any)
+            await handler['_setupFile']('/foo/bar.js')
+            expect(window.showErrorMessage)
+                .toBeCalledWith('Parameter "fileToOpen" from URL is not a markdown file!')
+            expect(getProjectDir).toBeCalledTimes(0)
+        })
+
+        it('should fail gracefully', async () => {
+            vi.mocked(got.get).mockRejectedValueOnce(new Error('ups'))
+            vi.mocked(Uri.parse).mockReturnValue({ query: { command: 'setup' }, fsPath: '/foo/bar.md'} as any)
+            vi.mocked(getProjectDir).mockResolvedValueOnce('foobar' as any)
+            await handler['_setupFile']('/foo/bar.md')
+            expect(vi.mocked(window.showErrorMessage).mock.calls[0][0]).toMatch(/Failed to set-up/)
+        })
+
+        it('should create new dir with file and open VS Code', async () => {
+            vi.mocked(Uri.parse).mockReturnValue({ query: { command: 'setup' }, fsPath: '/foo/bar.md'} as any)
+            vi.mocked(getProjectDir).mockResolvedValueOnce('foobar' as any)
+            await handler['_setupFile']('/foo/bar.md')
+            expect(workspace.fs.createDirectory).toBeCalledTimes(1)
+            expect(workspace.fs.writeFile).toBeCalledTimes(1)
+            expect(writeBootstrapFile).toBeCalledTimes(1)
+            expect(commands.executeCommand).toBeCalledWith(
+                'vscode.openFolder',
+                expect.any(Object),
+                { forceNewWindow: true }
+            )
+        })
+    })
+
     describe('_cloneProject', () => {
         let handler: RunmeUriHandler
         const progress = { report: vi.fn() }