Skip to content

Commit

Permalink
feat: sync new files from WebContainer to editor
Browse files Browse the repository at this point in the history
  • Loading branch information
isaacplmann committed Oct 29, 2024
1 parent 431145a commit 00f514a
Show file tree
Hide file tree
Showing 12 changed files with 109 additions and 23 deletions.
6 changes: 6 additions & 0 deletions e2e/src/components/ButtonWriteToFile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ import { ButtonWriteToFile } from '@components/ButtonWriteToFile';
# Watch filesystem test

<ButtonWriteToFile client:load access="webcontainer" filePath="/bar.txt" newContent='Something else' />
<ButtonWriteToFile client:load access="webcontainer" filePath="/src/new.txt" newContent='New' testId='write-new-file' />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Baz
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Initial content
16 changes: 16 additions & 0 deletions e2e/src/content/tutorial/tests/filesystem/watch-glob/content.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
type: lesson
title: Watch Glob
focus: /bar.txt
filesystem:
watch: ['/*', '/a/**/*', '/src/**/*']
---

import { ButtonWriteToFile } from '@components/ButtonWriteToFile';

# Watch filesystem test

<ButtonWriteToFile client:load access="webcontainer" filePath="/bar.txt" newContent='Something else' />
<ButtonWriteToFile client:load access="webcontainer" filePath="/a/b/baz.txt" newContent='Foo' testId='write-to-file-in-subfolder' />
<ButtonWriteToFile client:load access="webcontainer" filePath="/src/new.txt" newContent='New' testId='write-new-file' />
<ButtonWriteToFile client:load access="webcontainer" filePath="/unknown/other.txt" newContent='Ignore this' testId='write-new-ignored-file' />
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ import { ButtonWriteToFile } from '@components/ButtonWriteToFile';

<ButtonWriteToFile client:load access="webcontainer" filePath="/bar.txt" newContent='Something else' />
<ButtonWriteToFile client:load access="webcontainer" filePath="/a/b/baz.txt" newContent='Foo' testId='write-to-file-in-subfolder' />
<ButtonWriteToFile client:load access="webcontainer" filePath="/unknown/other.txt" newContent='Ignore this' testId='write-new-ignored-file' />
34 changes: 33 additions & 1 deletion e2e/test/filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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 }) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime/src/store/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
41 changes: 31 additions & 10 deletions packages/runtime/src/store/tutorial-runner.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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');
}
});
}

Expand Down
8 changes: 5 additions & 3 deletions packages/types/src/schemas/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@ export type PreviewSchema = z.infer<typeof previewSchema>;

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<typeof fileSystemSchema>;
Expand Down
15 changes: 9 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 00f514a

Please sign in to comment.