Skip to content
27 changes: 26 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 14 additions & 3 deletions packages/diff-view/src/parts/ReadFile/ReadFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,26 @@ interface FileSystemProviderResult {
readonly result?: unknown
}

interface BlobLike {
readonly text: () => Promise<string>
}

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<string> => {
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<string> => {
Expand Down
35 changes: 35 additions & 0 deletions packages/diff-view/test/ReadFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
const result = await readFile('untitled://Untitled-1')

Expand Down Expand Up @@ -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<void> => {
ExtensionManagementWorker.registerMockRpc({
'Extensions.executeFileSystemProviderReadFile': async (): Promise<unknown> => ({
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<void> => {
ExtensionManagementWorker.registerMockRpc({
'Extensions.executeFileSystemProviderReadFile': async (): Promise<unknown> => ({
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<void> => {
ExtensionManagementWorker.registerMockRpc({
'Extensions.executeFileSystemProviderReadFile': async (): Promise<unknown> => ({ 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<void> => {
ExtensionManagementWorker.registerMockRpc({
'Extensions.executeFileSystemProviderReadFile': async (): Promise<unknown> => ({ found: false }),
Expand Down
10 changes: 10 additions & 0 deletions packages/e2e/fixtures/blob-file-system/extension.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "test.blob-file-system",
"isolated": true,
"fileSystemProviders": [
{
"id": "blob-fixture"
}
],
"browser": ".tmp/main.js"
}
18 changes: 18 additions & 0 deletions packages/e2e/fixtures/blob-file-system/main.js
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 10 additions & 7 deletions packages/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
19 changes: 19 additions & 0 deletions packages/e2e/scripts/prepare-extension.js
Original file line number Diff line number Diff line change
@@ -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',
})
16 changes: 16 additions & 0 deletions packages/e2e/src/diff.extension-blob-file-system-provider.ts
Original file line number Diff line number Diff line change
@@ -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')
}