Repository: marp-team/marp-vscode (ts)

Issue:
Handle the export command while non-file schema 
<!-- Please follow the contributing guideline both of Marp team common and repository-specific (if any). -->
<!-- Don't forget to search existed issue from marp-team organization before creating new issue! -->

The export command assumes to save into local file (`file:///c:/xxx`). On the other hand, Marp for VS Code is also working on remote container (e.g. `vscode-remote://` schema such as Docker container, SSH, and WSL). In this case, the export command may try to save into the host filesystem `vscode-local://` which is not known by CLI if selected `Show locals` in the save dialog.

We should consider one of them:

1. Simply output error if detected non-file schema in the path for save.
2. Handle the path to the host of container (Export to a temporal file and save to the host through VS Code API)

Pull request description:
Handle export into non-file scheme
The path for exporting slide may become non `file` scheme (e.g. `vscode-local` if used together with Remote extension). This PR is adding support of the export command for non-file scheme.

- If the output path had `file` scheme, Marp CLI tries to export into specified path as same as current.
- If the output path had other URI scheme, Marp CLI tries to export into tmpfile, and copy it to specified path via `vscode.workspace.fs.copy`.
  - In this case, Marp for VS Code will not try to open the output file after exporting.
  - When the URI scheme has marked as readonly, Marp for VS Code will notify users that and not try to export.

An exported slide that has processed in the remote container can save into local file system correctly.

Resolve #262.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ddc1eb8..050c3e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@
 - Diagnostics for `math` global directive ([#267](https://github.com/marp-team/marp-vscode/pull/267))
   - `define-math-global-directive`: Recommend to declare math typesetting library
   - `ignored-math-global-directive`: Report ignored `math` global directive if disabled math by the extension setting
+- Handle the export command into non-file scheme ([#262](https://github.com/marp-team/marp-vscode/issues/262), [#269](https://github.com/marp-team/marp-vscode/pull/269))
+  - Support direct export from [the remote container](https://code.visualstudio.com/docs/remote/containers) to the local file system
 
 ### Fixed
 
diff --git a/package-lock.json b/package-lock.json
index 9e7fb70..5000388 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,8 +9,7 @@
       "license": "MIT",
       "dependencies": {
         "@marp-team/marp-cli": "^1.2.0",
-        "@marp-team/marp-core": "^2.1.0",
-        "axios": "^0.21.1"
+        "@marp-team/marp-core": "^2.1.0"
       },
       "devDependencies": {
         "@babel/preset-env": "^7.14.8",
@@ -3556,14 +3555,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/axios": {
-      "version": "0.21.1",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
-      "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
-      "dependencies": {
-        "follow-redirects": "^1.10.0"
-      }
-    },
     "node_modules/azure-devops-node-api": {
       "version": "11.0.1",
       "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-11.0.1.tgz",
@@ -6115,25 +6106,6 @@
       "integrity": "sha512-OMQjaErSFHmHqZe+PSidH5n8j3O0F2DdnVh8JB4j4eUQ2k6KvB0qGfrKIhapvez5JerBbmWkaLYUYWISaESoXg==",
       "dev": true
     },
-    "node_modules/follow-redirects": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz",
-      "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==",
-      "funding": [
-        {
-          "type": "individual",
-          "url": "https://github.com/sponsors/RubenVerborgh"
-        }
-      ],
-      "engines": {
-        "node": ">=4.0"
-      },
-      "peerDependenciesMeta": {
-        "debug": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/form-data": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
@@ -17229,14 +17201,6 @@
         }
       }
     },
-    "axios": {
-      "version": "0.21.1",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
-      "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
-      "requires": {
-        "follow-redirects": "^1.10.0"
-      }
-    },
     "azure-devops-node-api": {
       "version": "11.0.1",
       "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-11.0.1.tgz",
@@ -19199,11 +19163,6 @@
       "integrity": "sha512-OMQjaErSFHmHqZe+PSidH5n8j3O0F2DdnVh8JB4j4eUQ2k6KvB0qGfrKIhapvez5JerBbmWkaLYUYWISaESoXg==",
       "dev": true
     },
-    "follow-redirects": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz",
-      "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg=="
-    },
     "form-data": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts
index 8fe7612..f1cb9fa 100644
--- a/src/__mocks__/vscode.ts
+++ b/src/__mocks__/vscode.ts
@@ -140,6 +140,7 @@ export const env = {
 }
 
 export const FileSystem = {
+  copy: jest.fn(),
   stat: jest.fn(async () => ({
     ctime: 0,
     mtime: new Date().getTime(),
@@ -147,6 +148,7 @@ export const FileSystem = {
     type: FileType.File,
   })),
   readFile: jest.fn().mockResolvedValue(new TextEncoder().encode('readFile')),
+  isWritableFileSystem: jest.fn((scheme: string) => scheme === 'file'),
 }
 
 export enum FileType {
@@ -190,6 +192,7 @@ export const window = {
   createTextEditorDecorationType: jest.fn((t) => t),
   onDidChangeActiveTextEditor: jest.fn(),
   showErrorMessage: jest.fn(),
+  showInformationMessage: jest.fn(),
   showQuickPick: jest.fn(),
   showSaveDialog: jest.fn(),
   showTextDocument: jest.fn(async () => ({})),
diff --git a/src/commands/export.ts b/src/commands/export.ts
index 881c140..ae36e84 100644
--- a/src/commands/export.ts
+++ b/src/commands/export.ts
@@ -1,4 +1,8 @@
+import fs from 'fs'
+import os from 'os'
 import path from 'path'
+import { promisify } from 'util'
+import { nanoid } from 'nanoid'
 import {
   commands,
   env,
@@ -19,6 +23,8 @@ import {
   WorkspaceProxyServer,
 } from '../workspace-proxy-server'
 
+const unlink = promisify(fs.unlink)
+
 export enum Types {
   html = 'html',
   pdf = 'pdf',
@@ -92,16 +98,51 @@ export const doExport = async (uri: Uri, document: TextDocument) => {
     const input = await createWorkFile(document)
 
     try {
+      let outputPath = uri.fsPath
+      const outputToLocalFS = uri.scheme === 'file'
+
+      // NOTE: It may return `undefined` if VS Code does not know about the
+      // filesystem. In this case, Marp may be able to write to the output path.
+      if (workspace.fs.isWritableFileSystem(uri.scheme) === false) {
+        throw new Error(`Could not write to ${uri.scheme} file system.`)
+      }
+
+      if (!outputToLocalFS) {
+        outputPath = path.join(
+          os.tmpdir(),
+          `marp-vscode-tmp-${nanoid()}${path.extname(uri.path)}`
+        )
+      }
+
       // Run Marp CLI
       const conf = await createConfigFile(document, {
         allowLocalFiles: !proxyServer,
       })
 
       try {
-        await marpCli(['-c', conf.path, input.path, '-o', uri.fsPath], {
+        await marpCli(['-c', conf.path, input.path, '-o', outputPath], {
           baseUrl,
         })
-        env.openExternal(uri)
+
+        if (outputToLocalFS) {
+          env.openExternal(uri)
+        } else {
+          try {
+            await workspace.fs.copy(Uri.file(outputPath), uri, {
+              overwrite: true,
+            })
+          } finally {
+            try {
+              await unlink(outputPath)
+            } catch (e) {
+              // no ops
+            }
+          }
+
+          window.showInformationMessage(
+            `Marp slide deck was successfully exported to ${uri.toString()}.`
+          )
+        }
       } finally {
         conf.cleanup()
       }
@@ -143,7 +184,7 @@ export const saveDialog = async (document: TextDocument) => {
     await window.withProgress(
       {
         location: ProgressLocation.Notification,
-        title: `Exporting Marp slide deck to ${saveURI.path}...`,
+        title: `Exporting Marp slide deck to ${saveURI.toString()}...`,
       },
       () => doExport(saveURI, document)
     )


Test patch:
diff --git a/src/commands/export.test.ts b/src/commands/export.test.ts
index 4353f93..08e2c25 100644
--- a/src/commands/export.test.ts
+++ b/src/commands/export.test.ts
@@ -5,6 +5,7 @@ import * as exportModule from './export'
 
 const exportCommand = exportModule.default
 
+jest.mock('fs')
 jest.mock('vscode')
 jest.mock('../workspace-proxy-server', () => ({
   createWorkspaceProxyServer: jest.fn().mockResolvedValue({
@@ -126,7 +127,11 @@ describe('#saveDialog', () => {
   })
 
   it('runs exporting with notification when file path is specified', async () => {
-    const saveURI: any = { path: 'PATH', fsPath: '/tmp/saveTo.pdf' }
+    const saveURI: any = {
+      scheme: 'file',
+      toString: () => 'PATH',
+      fsPath: '/tmp/saveTo.pdf',
+    }
     jest.spyOn(window, 'showSaveDialog').mockImplementation(() => saveURI)
 
     const doExportMock: jest.SpyInstance = jest
@@ -144,7 +149,11 @@ describe('#saveDialog', () => {
 })
 
 describe('#doExport', () => {
-  const saveURI: any = { path: '/tmp/to.pdf', fsPath: '/tmp/to.pdf' }
+  const saveURI: any = {
+    scheme: 'file',
+    path: '/tmp/to.pdf',
+    fsPath: '/tmp/to.pdf',
+  }
   const document: any = {
     uri: { scheme: 'file', path: '/tmp/md.md', fsPath: '/tmp/md.md' },
   }
@@ -166,6 +175,54 @@ describe('#doExport', () => {
     )
   })
 
+  describe('when the save path has non-file scheme', () => {
+    const saveURI = (scheme: string, ext: string): any => {
+      const instance = {
+        scheme,
+        path: `/tmp/to.${ext}`,
+        fsPath: `/tmp/to.${ext}`,
+        toString: () => `${scheme}://${instance.path}`,
+      }
+      return instance
+    }
+
+    it('exports the document into temporally path and copy it to the save path', async () => {
+      jest.spyOn(marpCli, 'default').mockImplementation()
+
+      for (const isWritableFileSystem of [true, undefined]) {
+        jest
+          .spyOn(workspace.fs, 'isWritableFileSystem')
+          .mockReturnValue(isWritableFileSystem)
+
+        const saveURIinstance = saveURI('unknown-scheme', 'pdf')
+        await exportModule.doExport(saveURIinstance, document)
+        expect(workspace.fs.copy).toHaveBeenCalled()
+
+        const { calls } = (workspace.fs.copy as jest.Mock).mock
+        const [source, target] = calls[calls.length - 1]
+
+        expect(source.path).toContain('marp-vscode-tmp')
+        expect(source.path).toMatch(/\.pdf$/)
+        expect(source.scheme).toBe('file')
+        expect(target).toBe(saveURIinstance)
+
+        expect(window.showInformationMessage).toHaveBeenCalledWith(
+          'Marp slide deck was successfully exported to unknown-scheme:///tmp/to.pdf.'
+        )
+      }
+    })
+
+    it('shows warning when the scheme of save path has not-writable file system', async () => {
+      jest.spyOn(workspace.fs, 'isWritableFileSystem').mockReturnValue(false)
+
+      await exportModule.doExport(saveURI('readonly', 'pdf'), document)
+
+      expect(window.showErrorMessage).toHaveBeenCalledWith(
+        expect.stringContaining('Could not write to readonly file system.')
+      )
+    })
+  })
+
   describe('when the document belongs to the virtual file system', () => {
     const vfsDocument: any = {
       uri: {
@@ -207,7 +264,11 @@ describe('#doExport', () => {
     })
 
     it('does not open workspace proxy server while exporting to html', async () => {
-      const saveURIHtml: any = { path: '/tmp/to.html', fsPath: '/tmp/to.html' }
+      const saveURIHtml: any = {
+        scheme: 'file',
+        path: '/tmp/to.html',
+        fsPath: '/tmp/to.html',
+      }
 
       jest.spyOn(marpCli, 'default').mockImplementation()
       jest