Skip to content

Commit 00f514a

Browse files
committed
feat: sync new files from WebContainer to editor
1 parent 431145a commit 00f514a

File tree

12 files changed

+109
-23
lines changed

12 files changed

+109
-23
lines changed

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

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

+1
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ import { ButtonWriteToFile } from '@components/ButtonWriteToFile';
1212

1313
<ButtonWriteToFile client:load access="webcontainer" filePath="/bar.txt" newContent='Something else' />
1414
<ButtonWriteToFile client:load access="webcontainer" filePath="/a/b/baz.txt" newContent='Foo' testId='write-to-file-in-subfolder' />
15+
<ButtonWriteToFile client:load access="webcontainer" filePath="/unknown/other.txt" newContent='Ignore this' testId='write-new-ignored-file' />

e2e/test/filesystem.test.ts

+33-1
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ 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+
await page.getByTestId('write-new-ignored-file').click();
2425
await page.getByRole('button', { name: 'baz.txt' }).click();
2526

2627
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
3233
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Foo', {
3334
useInnerText: true,
3435
});
36+
expect(await page.getByRole('button', { name: 'other.txt' }).count()).toEqual(0);
37+
});
38+
39+
test('editor should reflect changes made from webcontainer in specified paths', async ({ page }) => {
40+
const testCase = 'watch-glob';
41+
await page.goto(`${BASE_URL}/${testCase}`);
42+
43+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', {
44+
useInnerText: true,
45+
});
46+
47+
await page.getByTestId('write-to-file').click();
48+
49+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Something else', {
50+
useInnerText: true,
51+
});
52+
});
53+
54+
test('editor should reflect new files added in specified paths in webcontainer', async ({ page }) => {
55+
const testCase = 'watch-glob';
56+
await page.goto(`${BASE_URL}/${testCase}`);
57+
58+
await page.getByTestId('write-new-ignored-file').click();
59+
await page.getByTestId('write-new-file').click();
60+
61+
await page.getByRole('button', { name: 'new.txt' }).click();
62+
expect(await page.getByRole('button', { name: 'other.txt' }).count()).toEqual(0);
63+
64+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('New', {
65+
useInnerText: true,
66+
});
3567
});
3668

3769
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

+2-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

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

+31-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';
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,39 @@ 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+
// we ignore all paths that aren't exposed in the `_editorStore`
667+
const file = this._editorStore.documents.get()[filePath];
664668

665-
if (!file) {
666-
return;
667-
}
669+
if (!file) {
670+
return;
671+
}
672+
673+
scheduleReadFor(filePath, typeof file.value === 'string' ? 'utf-8' : null);
674+
} else if (eventType === 'rename' && Array.isArray(this._watchContentFromWebContainer)) {
675+
const segments = filePath.split('/');
676+
segments.forEach((_, index) => {
677+
if (index == segments.length - 1) {
678+
return;
679+
}
668680

669-
scheduleReadFor(filePath, typeof file.value === 'string' ? 'utf-8' : null);
681+
const folderPath = segments.slice(0, index + 1).join('/');
682+
683+
if (!this._editorStore.documents.get()[folderPath]) {
684+
this._editorStore.addFileOrFolder({ path: folderPath, type: 'folder' });
685+
}
686+
});
687+
this._editorStore.addFileOrFolder({ path: filePath, type: 'file' });
688+
this._updateCurrentFiles({ [filePath]: 'test' });
689+
scheduleReadFor(filePath, 'utf-8');
690+
}
670691
});
671692
}
672693

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>;

pnpm-lock.yaml

+9-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)