Skip to content

Commit

Permalink
feat(runtime): fs.watch to support syncing new files from webcontai…
Browse files Browse the repository at this point in the history
…ner (#394)
  • Loading branch information
isaacplmann authored Nov 5, 2024
1 parent e1e9160 commit 3beda90
Show file tree
Hide file tree
Showing 15 changed files with 208 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,15 @@ An example use case is when a user runs a command that modifies a file. For inst

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`.

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.

<PropertyTable inherited type={'FileSystem'} />

The `FileSystem` type has the following shape:

```ts
type FileSystem = {
watch: boolean
watch: boolean | string[]
}
```
Expand All @@ -299,10 +301,13 @@ Example values:

```yaml
filesystem:
watch: true # Filesystem changes are reflected in the editor
watch: true # Filesystem changes to files already in the editor are reflected in the editor
filesystem:
watch: false # Or if it's omitted, the default value is false
filesystem:
watch: ['/*.json', '/src/**/*'] # Files changed, added or deleted that match one of the globs are updated in the editor
```


Expand Down
34 changes: 34 additions & 0 deletions e2e/src/components/ButtonDeleteFile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { webcontainer } from 'tutorialkit:core';

interface Props {
filePath: string;
newContent: string;

// default to 'webcontainer'
access?: 'store' | 'webcontainer';
testId?: string;
}

export function ButtonDeleteFile({ filePath, access = 'webcontainer', testId = 'delete-file' }: Props) {
async function deleteFile() {
switch (access) {
case 'webcontainer': {
const webcontainerInstance = await webcontainer;

await webcontainerInstance.fs.rm(filePath);

return;
}
case 'store': {
throw new Error('Delete from store not implemented');
return;
}
}
}

return (
<button data-testid={testId} onClick={deleteFile}>
Delete File
</button>
);
}
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
19 changes: 19 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,19 @@
---
type: lesson
title: Watch Glob
focus: /bar.txt
filesystem:
watch: ['/*.txt', '/a/**/*', '/src/**/*']
---

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

# 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' />

<ButtonDeleteFile client:load access="webcontainer" filePath="/bar.txt" testId='delete-file' />
4 changes: 4 additions & 0 deletions e2e/src/content/tutorial/tests/filesystem/watch/content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ filesystem:
---

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

# 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="/unknown/other.txt" newContent='Ignore this' testId='write-new-ignored-file' />

<ButtonDeleteFile client:load access="webcontainer" filePath="/bar.txt" testId='delete-file' />
54 changes: 53 additions & 1 deletion e2e/test/filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ 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}`);

// set up actions that shouldn't do anything
await page.getByTestId('write-new-ignored-file').click();
await page.getByTestId('delete-file').click();

await page.getByRole('button', { name: 'baz.txt' }).click();

await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Baz', {
Expand All @@ -32,6 +36,54 @@ test('editor should reflect changes made from webcontainer in file in nested fol
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Foo', {
useInnerText: true,
});

// test that ignored actions are ignored
await expect(page.getByRole('button', { name: 'other.txt' })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'bar.txt' })).toBeVisible();
});

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();
await expect(async () => {
await expect(page.getByRole('button', { name: 'unknown' })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'other.txt' })).not.toBeVisible();
}).toPass();

await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('New', {
useInnerText: true,
});
});

test('editor should remove deleted files in specified paths in webcontainer', async ({ page }) => {
const testCase = 'watch-glob';
await page.goto(`${BASE_URL}/${testCase}`);

await page.getByTestId('delete-file').click();

await expect(async () => {
await expect(page.getByRole('button', { name: 'bar.txt' })).not.toBeVisible();
}).toPass();
});

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
16 changes: 14 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 Expand Up @@ -133,6 +133,18 @@ export class EditorStore {
return contentChanged;
}

deleteFile(filePath: string): boolean {
const documentState = this.documents.get()[filePath];

if (!documentState) {
return false;
}

this.documents.setKey(filePath, undefined);

return true;
}

onDocumentChanged(filePath: string, callback: (document: Readonly<EditorDocument>) => void) {
const unsubscribeFromCurrentDocument = this.currentDocument.subscribe((document) => {
if (document?.filePath === filePath) {
Expand Down
56 changes: 46 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/posix.js';
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,54 @@ 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') {
/**
* Update file
* 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 file = this._editorStore.documents.get()[filePath];

if (file) {
// remove file
this._editorStore.deleteFile(filePath);
} else {
// add file
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' });
}
});

if (!this._editorStore.documents.get()[filePath]) {
this._editorStore.addFileOrFolder({ path: filePath, type: 'file' });
}

this._updateCurrentFiles({ [filePath]: '' });
scheduleReadFor(filePath, 'utf-8');
}
}
});
}

Expand Down
5 changes: 5 additions & 0 deletions packages/runtime/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
/// <reference types="vite/client" />

// https://github.com/micromatch/picomatch?tab=readme-ov-file#api
declare module 'picomatch/posix.js' {
export { default } from 'picomatch';
}
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
Loading

0 comments on commit 3beda90

Please sign in to comment.