Skip to content

feat: sync new files from WebContainer to editor #394

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

Merged
merged 11 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]: '' });
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.