Skip to content

Commit 3beda90

Browse files
authored
feat(runtime): fs.watch to support syncing new files from webcontainer (#394)
1 parent e1e9160 commit 3beda90

File tree

15 files changed

+208
-25
lines changed

15 files changed

+208
-25
lines changed

docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx

+7-2
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,15 @@ An example use case is when a user runs a command that modifies a file. For inst
284284

285285
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`.
286286

287+
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.
288+
287289
<PropertyTable inherited type={'FileSystem'} />
288290

289291
The `FileSystem` type has the following shape:
290292

291293
```ts
292294
type FileSystem = {
293-
watch: boolean
295+
watch: boolean | string[]
294296
}
295297
296298
```
@@ -299,10 +301,13 @@ Example values:
299301

300302
```yaml
301303
filesystem:
302-
watch: true # Filesystem changes are reflected in the editor
304+
watch: true # Filesystem changes to files already in the editor are reflected in the editor
303305
304306
filesystem:
305307
watch: false # Or if it's omitted, the default value is false
308+
309+
filesystem:
310+
watch: ['/*.json', '/src/**/*'] # Files changed, added or deleted that match one of the globs are updated in the editor
306311
```
307312

308313

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { webcontainer } from 'tutorialkit:core';
2+
3+
interface Props {
4+
filePath: string;
5+
newContent: string;
6+
7+
// default to 'webcontainer'
8+
access?: 'store' | 'webcontainer';
9+
testId?: string;
10+
}
11+
12+
export function ButtonDeleteFile({ filePath, access = 'webcontainer', testId = 'delete-file' }: Props) {
13+
async function deleteFile() {
14+
switch (access) {
15+
case 'webcontainer': {
16+
const webcontainerInstance = await webcontainer;
17+
18+
await webcontainerInstance.fs.rm(filePath);
19+
20+
return;
21+
}
22+
case 'store': {
23+
throw new Error('Delete from store not implemented');
24+
return;
25+
}
26+
}
27+
}
28+
29+
return (
30+
<button data-testid={testId} onClick={deleteFile}>
31+
Delete File
32+
</button>
33+
);
34+
}

e2e/src/components/ButtonWriteToFile.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export function ButtonWriteToFile({ filePath, newContent, access = 'store', test
1616
case 'webcontainer': {
1717
const webcontainerInstance = await webcontainer;
1818

19+
const folderPath = filePath.split('/').slice(0, -1).join('/');
20+
21+
if (folderPath) {
22+
await webcontainerInstance.fs.mkdir(folderPath, { recursive: true });
23+
}
24+
1925
await webcontainerInstance.fs.writeFile(filePath, newContent);
2026

2127
return;

e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx

+1
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ import { ButtonWriteToFile } from '@components/ButtonWriteToFile';
99
# Watch filesystem test
1010

1111
<ButtonWriteToFile client:load access="webcontainer" filePath="/bar.txt" newContent='Something else' />
12+
<ButtonWriteToFile client:load access="webcontainer" filePath="/src/new.txt" newContent='New' testId='write-new-file' />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Baz
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Initial content
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
type: lesson
3+
title: Watch Glob
4+
focus: /bar.txt
5+
filesystem:
6+
watch: ['/*.txt', '/a/**/*', '/src/**/*']
7+
---
8+
9+
import { ButtonWriteToFile } from '@components/ButtonWriteToFile';
10+
import { ButtonDeleteFile } from '@components/ButtonDeleteFile';
11+
12+
# Watch filesystem test
13+
14+
<ButtonWriteToFile client:load access="webcontainer" filePath="/bar.txt" newContent='Something else' />
15+
<ButtonWriteToFile client:load access="webcontainer" filePath="/a/b/baz.txt" newContent='Foo' testId='write-to-file-in-subfolder' />
16+
<ButtonWriteToFile client:load access="webcontainer" filePath="/src/new.txt" newContent='New' testId='write-new-file' />
17+
<ButtonWriteToFile client:load access="webcontainer" filePath="/unknown/other.txt" newContent='Ignore this' testId='write-new-ignored-file' />
18+
19+
<ButtonDeleteFile client:load access="webcontainer" filePath="/bar.txt" testId='delete-file' />

e2e/src/content/tutorial/tests/filesystem/watch/content.mdx

+4
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ filesystem:
77
---
88

99
import { ButtonWriteToFile } from '@components/ButtonWriteToFile';
10+
import { ButtonDeleteFile } from '@components/ButtonDeleteFile';
1011

1112
# Watch filesystem test
1213

1314
<ButtonWriteToFile client:load access="webcontainer" filePath="/bar.txt" newContent='Something else' />
1415
<ButtonWriteToFile client:load access="webcontainer" filePath="/a/b/baz.txt" newContent='Foo' testId='write-to-file-in-subfolder' />
16+
<ButtonWriteToFile client:load access="webcontainer" filePath="/unknown/other.txt" newContent='Ignore this' testId='write-new-ignored-file' />
17+
18+
<ButtonDeleteFile client:load access="webcontainer" filePath="/bar.txt" testId='delete-file' />

e2e/test/filesystem.test.ts

+53-1
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@ test('editor should reflect changes made from webcontainer', async ({ page }) =>
1717
});
1818
});
1919

20-
test('editor should reflect changes made from webcontainer in file in nested folder', async ({ page }) => {
20+
test('editor should reflect changes made from webcontainer in file in nested folder and not add new files', async ({ page }) => {
2121
const testCase = 'watch';
2222
await page.goto(`${BASE_URL}/${testCase}`);
2323

24+
// set up actions that shouldn't do anything
25+
await page.getByTestId('write-new-ignored-file').click();
26+
await page.getByTestId('delete-file').click();
27+
2428
await page.getByRole('button', { name: 'baz.txt' }).click();
2529

2630
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
3236
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Foo', {
3337
useInnerText: true,
3438
});
39+
40+
// test that ignored actions are ignored
41+
await expect(page.getByRole('button', { name: 'other.txt' })).not.toBeVisible();
42+
await expect(page.getByRole('button', { name: 'bar.txt' })).toBeVisible();
43+
});
44+
45+
test('editor should reflect changes made from webcontainer in specified paths', async ({ page }) => {
46+
const testCase = 'watch-glob';
47+
await page.goto(`${BASE_URL}/${testCase}`);
48+
49+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', {
50+
useInnerText: true,
51+
});
52+
53+
await page.getByTestId('write-to-file').click();
54+
55+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Something else', {
56+
useInnerText: true,
57+
});
58+
});
59+
60+
test('editor should reflect new files added in specified paths in webcontainer', async ({ page }) => {
61+
const testCase = 'watch-glob';
62+
await page.goto(`${BASE_URL}/${testCase}`);
63+
64+
await page.getByTestId('write-new-ignored-file').click();
65+
await page.getByTestId('write-new-file').click();
66+
67+
await page.getByRole('button', { name: 'new.txt' }).click();
68+
await expect(async () => {
69+
await expect(page.getByRole('button', { name: 'unknown' })).not.toBeVisible();
70+
await expect(page.getByRole('button', { name: 'other.txt' })).not.toBeVisible();
71+
}).toPass();
72+
73+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('New', {
74+
useInnerText: true,
75+
});
76+
});
77+
78+
test('editor should remove deleted files in specified paths in webcontainer', async ({ page }) => {
79+
const testCase = 'watch-glob';
80+
await page.goto(`${BASE_URL}/${testCase}`);
81+
82+
await page.getByTestId('delete-file').click();
83+
84+
await expect(async () => {
85+
await expect(page.getByRole('button', { name: 'bar.txt' })).not.toBeVisible();
86+
}).toPass();
3587
});
3688

3789
test('editor should not reflect changes made from webcontainer if watch is not set', async ({ page }) => {

packages/runtime/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@
3535
"dependencies": {
3636
"@tutorialkit/types": "workspace:*",
3737
"@webcontainer/api": "1.2.4",
38-
"nanostores": "^0.10.3"
38+
"nanostores": "^0.10.3",
39+
"picomatch": "^4.0.2"
3940
},
4041
"devDependencies": {
42+
"@types/picomatch": "^3.0.1",
4143
"typescript": "^5.4.5",
4244
"vite": "^5.3.1",
4345
"vite-tsconfig-paths": "^4.3.2",

packages/runtime/src/store/editor.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ export class EditorStore {
9999

100100
addFileOrFolder(file: FileDescriptor) {
101101
// when adding file or folder to empty folder, remove the empty folder from documents
102-
const emptyFolder = this.files.get().find((f) => f.type === 'folder' && file.path.startsWith(f.path));
102+
const emptyFolder = this.files.get().find((f) => file.path.startsWith(f.path));
103103

104-
if (emptyFolder && emptyFolder.type === 'folder') {
104+
if (emptyFolder) {
105105
this.documents.setKey(emptyFolder.path, undefined);
106106
}
107107

@@ -133,6 +133,18 @@ export class EditorStore {
133133
return contentChanged;
134134
}
135135

136+
deleteFile(filePath: string): boolean {
137+
const documentState = this.documents.get()[filePath];
138+
139+
if (!documentState) {
140+
return false;
141+
}
142+
143+
this.documents.setKey(filePath, undefined);
144+
145+
return true;
146+
}
147+
136148
onDocumentChanged(filePath: string, callback: (document: Readonly<EditorDocument>) => void) {
137149
const unsubscribeFromCurrentDocument = this.currentDocument.subscribe((document) => {
138150
if (document?.filePath === filePath) {

packages/runtime/src/store/tutorial-runner.ts

+46-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CommandsSchema, Files } from '@tutorialkit/types';
22
import type { IFSWatcher, WebContainer, WebContainerProcess } from '@webcontainer/api';
3+
import picomatch from 'picomatch/posix.js';
34
import { newTask, type Task, type TaskCancelled } from '../tasks.js';
45
import { MultiCounter } from '../utils/multi-counter.js';
56
import { clearTerminal, escapeCodes, type ITerminal } from '../utils/terminal.js';
@@ -65,7 +66,7 @@ export class TutorialRunner {
6566

6667
private _ignoreFileEvents = new MultiCounter();
6768
private _watcher: IFSWatcher | undefined;
68-
private _watchContentFromWebContainer = false;
69+
private _watchContentFromWebContainer: string[] | boolean = false;
6970
private _readyToWatch = false;
7071

7172
private _packageJsonDirty = false;
@@ -82,7 +83,7 @@ export class TutorialRunner {
8283
private _stepController: StepsController,
8384
) {}
8485

85-
setWatchFromWebContainer(value: boolean) {
86+
setWatchFromWebContainer(value: boolean | string[]) {
8687
this._watchContentFromWebContainer = value;
8788

8889
if (this._readyToWatch && this._watchContentFromWebContainer) {
@@ -654,19 +655,54 @@ export class TutorialRunner {
654655
return;
655656
}
656657

657-
// for now we only care about 'change' event
658-
if (eventType !== 'change') {
658+
if (
659+
Array.isArray(this._watchContentFromWebContainer) &&
660+
!this._watchContentFromWebContainer.some((pattern) => picomatch.isMatch(filePath, pattern))
661+
) {
659662
return;
660663
}
661664

662-
// we ignore all paths that aren't exposed in the `_editorStore`
663-
const file = this._editorStore.documents.get()[filePath];
665+
if (eventType === 'change') {
666+
/**
667+
* Update file
668+
* we ignore all paths that aren't exposed in the `_editorStore`
669+
*/
670+
const file = this._editorStore.documents.get()[filePath];
664671

665-
if (!file) {
666-
return;
667-
}
672+
if (!file) {
673+
return;
674+
}
675+
676+
scheduleReadFor(filePath, typeof file.value === 'string' ? 'utf-8' : null);
677+
} else if (eventType === 'rename' && Array.isArray(this._watchContentFromWebContainer)) {
678+
const file = this._editorStore.documents.get()[filePath];
679+
680+
if (file) {
681+
// remove file
682+
this._editorStore.deleteFile(filePath);
683+
} else {
684+
// add file
685+
const segments = filePath.split('/');
686+
segments.forEach((_, index) => {
687+
if (index == segments.length - 1) {
688+
return;
689+
}
668690

669-
scheduleReadFor(filePath, typeof file.value === 'string' ? 'utf-8' : null);
691+
const folderPath = segments.slice(0, index + 1).join('/');
692+
693+
if (!this._editorStore.documents.get()[folderPath]) {
694+
this._editorStore.addFileOrFolder({ path: folderPath, type: 'folder' });
695+
}
696+
});
697+
698+
if (!this._editorStore.documents.get()[filePath]) {
699+
this._editorStore.addFileOrFolder({ path: filePath, type: 'file' });
700+
}
701+
702+
this._updateCurrentFiles({ [filePath]: '' });
703+
scheduleReadFor(filePath, 'utf-8');
704+
}
705+
}
670706
});
671707
}
672708

packages/runtime/src/types.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
/// <reference types="vite/client" />
2+
3+
// https://github.com/micromatch/picomatch?tab=readme-ov-file#api
4+
declare module 'picomatch/posix.js' {
5+
export { default } from 'picomatch';
6+
}

packages/types/src/schemas/common.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,11 @@ export type PreviewSchema = z.infer<typeof previewSchema>;
5858

5959
export const fileSystemSchema = z.object({
6060
watch: z
61-
.boolean()
62-
.optional()
63-
.describe('When set to true, file changes in WebContainer are updated in the editor as well.'),
61+
.union([z.boolean(), z.array(z.string())])
62+
.describe(
63+
'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.',
64+
)
65+
.optional(),
6466
});
6567

6668
export type FileSystemSchema = z.infer<typeof fileSystemSchema>;

0 commit comments

Comments
 (0)