diff --git a/e2e/src/components/ButtonWriteToFile.tsx b/e2e/src/components/ButtonWriteToFile.tsx index e0403e43..3d41c38d 100644 --- a/e2e/src/components/ButtonWriteToFile.tsx +++ b/e2e/src/components/ButtonWriteToFile.tsx @@ -16,6 +16,12 @@ export function ButtonWriteToFile({ filePath, newContent, access = 'store', test case 'webcontainer': { const webcontainerInstance = await webcontainer; + const folderPath = filePath.split('/').slice(0, -1).join('/'); + + if (folderPath) { + await webcontainerInstance.fs.mkdir(folderPath, { recursive: true }); + } + await webcontainerInstance.fs.writeFile(filePath, newContent); return; diff --git a/e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx b/e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx index aeac65eb..2ed52b5f 100644 --- a/e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx +++ b/e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx @@ -9,3 +9,4 @@ import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; # Watch filesystem test + diff --git a/e2e/src/content/tutorial/tests/filesystem/watch-glob/_files/a/b/baz.txt b/e2e/src/content/tutorial/tests/filesystem/watch-glob/_files/a/b/baz.txt new file mode 100644 index 00000000..a6881827 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/watch-glob/_files/a/b/baz.txt @@ -0,0 +1 @@ +Baz diff --git a/e2e/src/content/tutorial/tests/filesystem/watch-glob/_files/bar.txt b/e2e/src/content/tutorial/tests/filesystem/watch-glob/_files/bar.txt new file mode 100644 index 00000000..8430408a --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/watch-glob/_files/bar.txt @@ -0,0 +1 @@ +Initial content diff --git a/e2e/src/content/tutorial/tests/filesystem/watch-glob/content.mdx b/e2e/src/content/tutorial/tests/filesystem/watch-glob/content.mdx new file mode 100644 index 00000000..66b771ce --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/watch-glob/content.mdx @@ -0,0 +1,16 @@ +--- +type: lesson +title: Watch Glob +focus: /bar.txt +filesystem: + watch: ['/*', '/a/**/*', '/src/**/*'] +--- + +import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; + +# Watch filesystem test + + + + + diff --git a/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx b/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx index 46a0ed3b..79728780 100644 --- a/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx +++ b/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx @@ -12,3 +12,4 @@ import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; + diff --git a/e2e/test/filesystem.test.ts b/e2e/test/filesystem.test.ts index be14fef7..046c3a39 100644 --- a/e2e/test/filesystem.test.ts +++ b/e2e/test/filesystem.test.ts @@ -17,10 +17,11 @@ test('editor should reflect changes made from webcontainer', async ({ page }) => }); }); -test('editor should reflect changes made from webcontainer in file in nested folder', async ({ page }) => { +test('editor should reflect changes made from webcontainer in file in nested folder and not add new files', async ({ page }) => { const testCase = 'watch'; await page.goto(`${BASE_URL}/${testCase}`); + await page.getByTestId('write-new-ignored-file').click(); await page.getByRole('button', { name: 'baz.txt' }).click(); await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Baz', { @@ -32,6 +33,37 @@ test('editor should reflect changes made from webcontainer in file in nested fol await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Foo', { useInnerText: true, }); + expect(await page.getByRole('button', { name: 'other.txt' }).count()).toEqual(0); +}); + +test('editor should reflect changes made from webcontainer in specified paths', async ({ page }) => { + const testCase = 'watch-glob'; + await page.goto(`${BASE_URL}/${testCase}`); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', { + useInnerText: true, + }); + + await page.getByTestId('write-to-file').click(); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Something else', { + useInnerText: true, + }); +}); + +test('editor should reflect new files added in specified paths in webcontainer', async ({ page }) => { + const testCase = 'watch-glob'; + await page.goto(`${BASE_URL}/${testCase}`); + + await page.getByTestId('write-new-ignored-file').click(); + await page.getByTestId('write-new-file').click(); + + await page.getByRole('button', { name: 'new.txt' }).click(); + expect(await page.getByRole('button', { name: 'other.txt' }).count()).toEqual(0); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('New', { + useInnerText: true, + }); }); test('editor should not reflect changes made from webcontainer if watch is not set', async ({ page }) => { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index ad984f28..d28c5649 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -35,9 +35,11 @@ "dependencies": { "@tutorialkit/types": "workspace:*", "@webcontainer/api": "1.2.4", - "nanostores": "^0.10.3" + "nanostores": "^0.10.3", + "picomatch": "^4.0.2" }, "devDependencies": { + "@types/picomatch": "^3.0.1", "typescript": "^5.4.5", "vite": "^5.3.1", "vite-tsconfig-paths": "^4.3.2", diff --git a/packages/runtime/src/store/editor.ts b/packages/runtime/src/store/editor.ts index a0f305d6..5fb2af52 100644 --- a/packages/runtime/src/store/editor.ts +++ b/packages/runtime/src/store/editor.ts @@ -99,9 +99,9 @@ export class EditorStore { addFileOrFolder(file: FileDescriptor) { // when adding file or folder to empty folder, remove the empty folder from documents - const emptyFolder = this.files.get().find((f) => f.type === 'folder' && file.path.startsWith(f.path)); + const emptyFolder = this.files.get().find((f) => file.path.startsWith(f.path)); - if (emptyFolder && emptyFolder.type === 'folder') { + if (emptyFolder) { this.documents.setKey(emptyFolder.path, undefined); } diff --git a/packages/runtime/src/store/tutorial-runner.ts b/packages/runtime/src/store/tutorial-runner.ts index e1df25d7..4c32a921 100644 --- a/packages/runtime/src/store/tutorial-runner.ts +++ b/packages/runtime/src/store/tutorial-runner.ts @@ -1,5 +1,6 @@ import type { CommandsSchema, Files } from '@tutorialkit/types'; import type { IFSWatcher, WebContainer, WebContainerProcess } from '@webcontainer/api'; +import picomatch from 'picomatch'; import { newTask, type Task, type TaskCancelled } from '../tasks.js'; import { MultiCounter } from '../utils/multi-counter.js'; import { clearTerminal, escapeCodes, type ITerminal } from '../utils/terminal.js'; @@ -65,7 +66,7 @@ export class TutorialRunner { private _ignoreFileEvents = new MultiCounter(); private _watcher: IFSWatcher | undefined; - private _watchContentFromWebContainer = false; + private _watchContentFromWebContainer: string[] | boolean = false; private _readyToWatch = false; private _packageJsonDirty = false; @@ -82,7 +83,7 @@ export class TutorialRunner { private _stepController: StepsController, ) {} - setWatchFromWebContainer(value: boolean) { + setWatchFromWebContainer(value: boolean | string[]) { this._watchContentFromWebContainer = value; if (this._readyToWatch && this._watchContentFromWebContainer) { @@ -654,19 +655,39 @@ export class TutorialRunner { return; } - // for now we only care about 'change' event - if (eventType !== 'change') { + if ( + Array.isArray(this._watchContentFromWebContainer) && + !this._watchContentFromWebContainer.some((pattern) => picomatch.isMatch(filePath, pattern)) + ) { return; } - // we ignore all paths that aren't exposed in the `_editorStore` - const file = this._editorStore.documents.get()[filePath]; + if (eventType === 'change') { + // we ignore all paths that aren't exposed in the `_editorStore` + const file = this._editorStore.documents.get()[filePath]; - if (!file) { - return; - } + if (!file) { + return; + } + + scheduleReadFor(filePath, typeof file.value === 'string' ? 'utf-8' : null); + } else if (eventType === 'rename' && Array.isArray(this._watchContentFromWebContainer)) { + const segments = filePath.split('/'); + segments.forEach((_, index) => { + if (index == segments.length - 1) { + return; + } - scheduleReadFor(filePath, typeof file.value === 'string' ? 'utf-8' : null); + const folderPath = segments.slice(0, index + 1).join('/'); + + if (!this._editorStore.documents.get()[folderPath]) { + this._editorStore.addFileOrFolder({ path: folderPath, type: 'folder' }); + } + }); + this._editorStore.addFileOrFolder({ path: filePath, type: 'file' }); + this._updateCurrentFiles({ [filePath]: 'test' }); + scheduleReadFor(filePath, 'utf-8'); + } }); } diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts index 1cabc31b..4f3972a4 100644 --- a/packages/types/src/schemas/common.ts +++ b/packages/types/src/schemas/common.ts @@ -58,9 +58,11 @@ export type PreviewSchema = z.infer; export const fileSystemSchema = z.object({ watch: z - .boolean() - .optional() - .describe('When set to true, file changes in WebContainer are updated in the editor as well.'), + .union([z.boolean(), z.array(z.string())]) + .describe( + 'When set to true, file changes in WebContainer are updated in the editor as well. When set to an array, file changes or new files in the matching paths are updated in the editor.', + ) + .optional(), }); export type FileSystemSchema = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 152a529c..46bfbeab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -627,7 +627,13 @@ importers: nanostores: specifier: ^0.10.3 version: 0.10.3 + picomatch: + specifier: ^4.0.2 + version: 4.0.2 devDependencies: + '@types/picomatch': + specifier: ^3.0.1 + version: 3.0.1 typescript: specifier: ^5.4.5 version: 5.5.3 @@ -4012,7 +4018,7 @@ packages: '@unocss/core': 0.59.4 '@unocss/reset': 0.59.4 '@unocss/vite': 0.59.4(vite@5.4.2) - vite: 5.4.2(@types/node@22.4.2) + vite: 5.4.2(@types/node@22.4.2)(sass@1.77.6) transitivePeerDependencies: - rollup @@ -4211,7 +4217,7 @@ packages: chokidar: 3.6.0 fast-glob: 3.3.2 magic-string: 0.30.11 - vite: 5.4.2(@types/node@22.4.2) + vite: 5.4.2(@types/node@22.4.2)(sass@1.77.6) transitivePeerDependencies: - rollup @@ -6742,7 +6748,6 @@ packages: /immutable@4.3.6: resolution: {integrity: sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==} - dev: true /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} @@ -8835,7 +8840,6 @@ packages: chokidar: 3.6.0 immutable: 4.3.6 source-map-js: 1.2.0 - dev: true /sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} @@ -9644,7 +9648,7 @@ packages: '@unocss/transformer-directives': 0.59.4 '@unocss/transformer-variant-group': 0.59.4 '@unocss/vite': 0.59.4(vite@5.4.2) - vite: 5.4.2(@types/node@22.4.2) + vite: 5.4.2(@types/node@22.4.2)(sass@1.77.6) transitivePeerDependencies: - postcss - rollup @@ -9990,7 +9994,6 @@ packages: sass: 1.77.6 optionalDependencies: fsevents: 2.3.3 - dev: true /vitefu@0.2.5(vite@5.4.2): resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==}