diff --git a/package-lock.json b/package-lock.json index b9518fcb..b7c33a84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2891,6 +2891,29 @@ "license": "MIT", "optional": true }, + "node_modules/@lvce-editor/api": { + "version": "8.84.0", + "resolved": "https://registry.npmjs.org/@lvce-editor/api/-/api-8.84.0.tgz", + "integrity": "sha512-7Zck/G4gOz75lw25KHNuI3YPSjEPRgrYeMlWnWkSNph7Gi8oxPFiE3LLTnF8Ip7Xd2qkWk45LqulUmo68yRU4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lvce-editor/command": "^2.0.0", + "@lvce-editor/rpc": "^6.4.0", + "@lvce-editor/rpc-registry": "^9.24.0", + "@lvce-editor/virtual-dom-worker": "^10.0.0" + } + }, + "node_modules/@lvce-editor/api/node_modules/@lvce-editor/virtual-dom-worker": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@lvce-editor/virtual-dom-worker/-/virtual-dom-worker-10.2.0.tgz", + "integrity": "sha512-URtDWcAFadP7SLOqfepRxomJmNRQDmON8GFKRfsA1dOgcqBnoBR4G5xwo6oDeZvP4pnRAzHR3QB3b8I6nASU+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lvce-editor/constants": "^5.14.0" + } + }, "node_modules/@lvce-editor/assert": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@lvce-editor/assert/-/assert-1.9.0.tgz", @@ -15808,7 +15831,9 @@ "version": "0.0.0-dev", "license": "MIT", "devDependencies": { - "@lvce-editor/test-with-playwright": "^22.29.0" + "@lvce-editor/api": "^8.84.0", + "@lvce-editor/test-with-playwright": "^22.29.0", + "esbuild": "^0.28.2" } }, "packages/server": { diff --git a/package.json b/package.json index f8a80323..8460f6fb 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,11 @@ "trigger-ci-branches": "bash packages/build/trigger-ci-branches.sh", "type-check": "tsc -b" }, + "knip": { + "ignoreDependencies": [ + "@lvce-editor/api" + ] + }, "prettier": { "semi": false, "singleQuote": true, diff --git a/packages/diff-view/src/parts/ReadFile/ReadFile.ts b/packages/diff-view/src/parts/ReadFile/ReadFile.ts index 0acc1be7..f97c7dc3 100644 --- a/packages/diff-view/src/parts/ReadFile/ReadFile.ts +++ b/packages/diff-view/src/parts/ReadFile/ReadFile.ts @@ -7,15 +7,26 @@ interface FileSystemProviderResult { readonly result?: unknown } +interface BlobLike { + readonly text: () => Promise +} + +const isBlobLike = (value: unknown): value is BlobLike => { + return typeof value === 'object' && value !== null && Object.prototype.toString.call(value) === '[object Blob]' && 'text' in value && typeof value.text === 'function' +} + const readExtensionFile = async (protocol: string, uri: string): Promise => { const response = (await ExtensionManagementWorker.invoke('Extensions.executeFileSystemProviderReadFile', protocol, uri)) as FileSystemProviderResult if (!response.found) { throw new Error(`no file system provider found for ${protocol}`) } - if (typeof response.result !== 'string') { - throw new TypeError(`expected file system provider ${protocol} to return a string`) + if (typeof response.result === 'string') { + return response.result + } + if (isBlobLike(response.result)) { + return response.result.text() } - return response.result + throw new TypeError(`expected file system provider ${protocol} to return a string or Blob`) } export const readFile = async (uri: string): Promise => { diff --git a/packages/diff-view/test/ReadFile.test.ts b/packages/diff-view/test/ReadFile.test.ts index 3d6ad1fc..ab7dc8da 100644 --- a/packages/diff-view/test/ReadFile.test.ts +++ b/packages/diff-view/test/ReadFile.test.ts @@ -2,6 +2,11 @@ import { expect, test } from '@jest/globals' import { ExtensionManagementWorker, FileSystemWorker, RendererWorker } from '@lvce-editor/rpc-registry' import { readFile } from '../src/parts/ReadFile/ReadFile.ts' +const createBlob = (parts: readonly string[]): unknown => { + const BlobConstructor = (globalThis as unknown as { readonly Blob: new (parts: readonly string[]) => unknown }).Blob + return new BlobConstructor(parts) +} + test('readFile returns empty content for untitled uri', async (): Promise => { const result = await readFile('untitled://Untitled-1') @@ -80,6 +85,36 @@ test('readFile reads extension protocols through extension management worker', a ]) }) +test('readFile decodes Blob content from extension file system providers', async (): Promise => { + ExtensionManagementWorker.registerMockRpc({ + 'Extensions.executeFileSystemProviderReadFile': async (): Promise => ({ + found: true, + result: createBlob(['before 😀\nafter']), + }), + }) + + await expect(readFile('remote-ssh:///workspace/file.txt')).resolves.toBe('before 😀\nafter') +}) + +test('readFile returns empty content from an empty Blob extension provider', async (): Promise => { + ExtensionManagementWorker.registerMockRpc({ + 'Extensions.executeFileSystemProviderReadFile': async (): Promise => ({ + found: true, + result: createBlob([]), + }), + }) + + await expect(readFile('remote-ssh:///workspace/empty.txt')).resolves.toBe('') +}) + +test('readFile rejects invalid extension file system provider results', async (): Promise => { + ExtensionManagementWorker.registerMockRpc({ + 'Extensions.executeFileSystemProviderReadFile': async (): Promise => ({ found: true, result: { content: 'not text' } }), + }) + + await expect(readFile('remote-ssh:///workspace/file.txt')).rejects.toThrow('expected file system provider remote-ssh to return a string or Blob') +}) + test('readFile rejects when no isolated extension provides the protocol', async (): Promise => { ExtensionManagementWorker.registerMockRpc({ 'Extensions.executeFileSystemProviderReadFile': async (): Promise => ({ found: false }), diff --git a/packages/e2e/fixtures/blob-file-system/extension.json b/packages/e2e/fixtures/blob-file-system/extension.json new file mode 100644 index 00000000..b8f5c92b --- /dev/null +++ b/packages/e2e/fixtures/blob-file-system/extension.json @@ -0,0 +1,10 @@ +{ + "id": "test.blob-file-system", + "isolated": true, + "fileSystemProviders": [ + { + "id": "blob-fixture" + } + ], + "browser": ".tmp/main.js" +} diff --git a/packages/e2e/fixtures/blob-file-system/main.js b/packages/e2e/fixtures/blob-file-system/main.js new file mode 100644 index 00000000..e3464c4f --- /dev/null +++ b/packages/e2e/fixtures/blob-file-system/main.js @@ -0,0 +1,18 @@ +import { activate as activateExtensionApi, registerFileSystemProvider } from '@lvce-editor/api' + +const fileSystemProvider = { + id: 'blob-fixture', + async readFile(uri) { + if (uri.endsWith('/empty.txt')) { + return new Blob([]) + } + return new Blob(['remote 😀 content']) + }, +} + +const main = async () => { + await activateExtensionApi() + registerFileSystemProvider(fileSystemProvider) +} + +main().catch(console.error) diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 8c8f0661..64bf3b62 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -5,15 +5,18 @@ "type": "module", "main": "index.js", "scripts": { - "e2e": "test-with-playwright --only-extension=. --test-path=. --reuse-page", - "e2e:firefox": "test-with-playwright --only-extension=. --test-path=. --browser=firefox --reuse-page", - "e2e:firefox:headless": "test-with-playwright --only-extension=. --test-path=. --browser=firefox --headless --reuse-page", - "e2e:headless": "test-with-playwright --only-extension=. --test-path=. --headless --reuse-page", - "e2e:webkit": "test-with-playwright --only-extension=. --test-path=. --browser=webkit --reuse-page", - "e2e:webkit:headless": "test-with-playwright --only-extension=. --test-path=. --browser=webkit --headless --reuse-page", + "e2e": "npm run e2e:prepare && test-with-playwright --only-extension=. --test-path=. --reuse-page", + "e2e:firefox": "npm run e2e:prepare && test-with-playwright --only-extension=. --test-path=. --browser=firefox --reuse-page", + "e2e:firefox:headless": "npm run e2e:prepare && test-with-playwright --only-extension=. --test-path=. --browser=firefox --headless --reuse-page", + "e2e:headless": "npm run e2e:prepare && test-with-playwright --only-extension=. --test-path=. --headless --reuse-page", + "e2e:prepare": "node ./scripts/prepare-extension.js", + "e2e:webkit": "npm run e2e:prepare && test-with-playwright --only-extension=. --test-path=. --browser=webkit --reuse-page", + "e2e:webkit:headless": "npm run e2e:prepare && test-with-playwright --only-extension=. --test-path=. --browser=webkit --headless --reuse-page", "type-check": "tsc" }, "devDependencies": { - "@lvce-editor/test-with-playwright": "^22.29.0" + "@lvce-editor/api": "^8.84.0", + "@lvce-editor/test-with-playwright": "^22.29.0", + "esbuild": "^0.28.2" } } diff --git a/packages/e2e/scripts/prepare-extension.js b/packages/e2e/scripts/prepare-extension.js new file mode 100644 index 00000000..d8a6c164 --- /dev/null +++ b/packages/e2e/scripts/prepare-extension.js @@ -0,0 +1,19 @@ +import { mkdir, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const extensionDirectory = join(packageRoot, 'fixtures', 'blob-file-system') +const temporaryDirectory = join(extensionDirectory, '.tmp') + +await rm(temporaryDirectory, { force: true, recursive: true }) +await mkdir(temporaryDirectory, { recursive: true }) +await build({ + bundle: true, + entryPoints: [join(extensionDirectory, 'main.js')], + external: ['electron', 'node:*'], + format: 'esm', + outfile: join(temporaryDirectory, 'main.js'), + platform: 'browser', +}) diff --git a/packages/e2e/src/diff.extension-blob-file-system-provider.ts b/packages/e2e/src/diff.extension-blob-file-system-provider.ts new file mode 100644 index 00000000..9bcfd00f --- /dev/null +++ b/packages/e2e/src/diff.extension-blob-file-system-provider.ts @@ -0,0 +1,16 @@ +import type { Test } from '@lvce-editor/test-with-playwright' + +export const name = 'diff.extension-blob-file-system-provider' + +export const test: Test = async ({ DiffView, expect, Extension, FileSystem, Locator, Workspace }) => { + const tmpDir = await FileSystem.getTmpDir() + await Workspace.setPath(tmpDir) + await Extension.addWebExtension(import.meta.resolve('../fixtures/blob-file-system')) + await DiffView.open('data://before content', 'blob-fixture:///workspace/file.txt') + + const leftRows = Locator('.DiffEditorContentLeft .DiffEditorRows') + const rightRowsOrError = Locator('.DiffEditorContentRight .DiffEditorRows, .DiffEditorContentRight .DiffEditorErrorMessage') + + await expect(leftRows).toHaveText('before content') + await expect(rightRowsOrError).toHaveText('remote 😀 content') +}