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

Lines changed: 7 additions & 2 deletions
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

Lines changed: 34 additions & 0 deletions
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

Lines changed: 6 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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' />
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Baz
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Initial content
Lines changed: 19 additions & 0 deletions
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

Lines changed: 4 additions & 0 deletions
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

Lines changed: 53 additions & 1 deletion
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

Lines changed: 3 additions & 1 deletion
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",

0 commit comments

Comments
 (0)