diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx index 89c1ab12a..31707ca94 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx @@ -284,13 +284,15 @@ An example use case is when a user runs a command that modifies a file. For inst This property is by default set to `false` as it can impact performance. If you are creating a lesson where the user is expected to modify files outside the editor, you may want to keep this to `false`. +If you would like files to be added or removed from the editor automatically, you need to specify an array of globs that will determine which folders and files to watch for changes. + The `FileSystem` type has the following shape: ```ts type FileSystem = { - watch: boolean + watch: boolean | string[] } ``` @@ -299,10 +301,13 @@ Example values: ```yaml filesystem: - watch: true # Filesystem changes are reflected in the editor + watch: true # Filesystem changes to files already in the editor are reflected in the editor filesystem: watch: false # Or if it's omitted, the default value is false + +filesystem: + watch: ['/*.json', '/src/**/*'] # Files changed, added or deleted that match one of the globs are updated in the editor ``` diff --git a/e2e/src/components/ButtonDeleteFile.tsx b/e2e/src/components/ButtonDeleteFile.tsx new file mode 100644 index 000000000..503a92b2f --- /dev/null +++ b/e2e/src/components/ButtonDeleteFile.tsx @@ -0,0 +1,34 @@ +import { webcontainer } from 'tutorialkit:core'; + +interface Props { + filePath: string; + newContent: string; + + // default to 'webcontainer' + access?: 'store' | 'webcontainer'; + testId?: string; +} + +export function ButtonDeleteFile({ filePath, access = 'webcontainer', testId = 'delete-file' }: Props) { + async function deleteFile() { + switch (access) { + case 'webcontainer': { + const webcontainerInstance = await webcontainer; + + await webcontainerInstance.fs.rm(filePath); + + return; + } + case 'store': { + throw new Error('Delete from store not implemented'); + return; + } + } + } + + return ( + + ); +} diff --git a/e2e/src/components/ButtonWriteToFile.tsx b/e2e/src/components/ButtonWriteToFile.tsx index e0403e43a..3d41c38d6 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 aeac65eb4..2ed52b5fb 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 000000000..a68818270 --- /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 000000000..8430408a5 --- /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 000000000..5054653d3 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/watch-glob/content.mdx @@ -0,0 +1,19 @@ +--- +type: lesson +title: Watch Glob +focus: /bar.txt +filesystem: + watch: ['/*.txt', '/a/**/*', '/src/**/*'] +--- + +import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; +import { ButtonDeleteFile } from '@components/ButtonDeleteFile'; + +# 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 46a0ed3b7..4498c7297 100644 --- a/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx +++ b/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx @@ -7,8 +7,12 @@ filesystem: --- import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; +import { ButtonDeleteFile } from '@components/ButtonDeleteFile'; # Watch filesystem test + + + diff --git a/e2e/test/filesystem.test.ts b/e2e/test/filesystem.test.ts index be14fef7f..8db15888b 100644 --- a/e2e/test/filesystem.test.ts +++ b/e2e/test/filesystem.test.ts @@ -17,10 +17,14 @@ 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}`); + // set up actions that shouldn't do anything + await page.getByTestId('write-new-ignored-file').click(); + await page.getByTestId('delete-file').click(); + await page.getByRole('button', { name: 'baz.txt' }).click(); await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Baz', { @@ -32,6 +36,54 @@ test('editor should reflect changes made from webcontainer in file in nested fol await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Foo', { useInnerText: true, }); + + // test that ignored actions are ignored + await expect(page.getByRole('button', { name: 'other.txt' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'bar.txt' })).toBeVisible(); +}); + +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(); + await expect(async () => { + await expect(page.getByRole('button', { name: 'unknown' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'other.txt' })).not.toBeVisible(); + }).toPass(); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('New', { + useInnerText: true, + }); +}); + +test('editor should remove deleted files in specified paths in webcontainer', async ({ page }) => { + const testCase = 'watch-glob'; + await page.goto(`${BASE_URL}/${testCase}`); + + await page.getByTestId('delete-file').click(); + + await expect(async () => { + await expect(page.getByRole('button', { name: 'bar.txt' })).not.toBeVisible(); + }).toPass(); }); 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 ad984f28a..d28c5649a 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 a0f305d63..d215bdece 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); } @@ -133,6 +133,18 @@ export class EditorStore { return contentChanged; } + deleteFile(filePath: string): boolean { + const documentState = this.documents.get()[filePath]; + + if (!documentState) { + return false; + } + + this.documents.setKey(filePath, undefined); + + return true; + } + onDocumentChanged(filePath: string, callback: (document: Readonly) => void) { const unsubscribeFromCurrentDocument = this.currentDocument.subscribe((document) => { if (document?.filePath === filePath) { diff --git a/packages/runtime/src/store/tutorial-runner.ts b/packages/runtime/src/store/tutorial-runner.ts index e1df25d7c..09aec75c7 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/posix.js'; 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,54 @@ 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') { + /** + * Update file + * 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 file = this._editorStore.documents.get()[filePath]; + + if (file) { + // remove file + this._editorStore.deleteFile(filePath); + } else { + // add file + 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' }); + } + }); + + if (!this._editorStore.documents.get()[filePath]) { + this._editorStore.addFileOrFolder({ path: filePath, type: 'file' }); + } + + this._updateCurrentFiles({ [filePath]: '' }); + scheduleReadFor(filePath, 'utf-8'); + } + } }); } diff --git a/packages/runtime/src/types.d.ts b/packages/runtime/src/types.d.ts index 11f02fe2a..b0698638d 100644 --- a/packages/runtime/src/types.d.ts +++ b/packages/runtime/src/types.d.ts @@ -1 +1,6 @@ /// + +// https://github.com/micromatch/picomatch?tab=readme-ov-file#api +declare module 'picomatch/posix.js' { + export { default } from 'picomatch'; +} diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts index 1cabc31b2..4f3972a40 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 152a529cf..46bfbeabb 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==}