Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: sync new files from WebContainer to editor #394

Merged
merged 11 commits into from
Nov 5, 2024
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) {
isaacplmann marked this conversation as resolved.
Show resolved Hide resolved
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';
isaacplmann marked this conversation as resolved.
Show resolved Hide resolved
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))
isaacplmann marked this conversation as resolved.
Show resolved Hide resolved
) {
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]: '' });
scheduleReadFor(filePath, 'utf-8');
isaacplmann marked this conversation as resolved.
Show resolved Hide resolved
}
});
}

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(),
isaacplmann marked this conversation as resolved.
Show resolved Hide resolved
});

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.