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
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
expect(await page.getByRole('button', { name: 'other.txt' }).count()).toEqual(0);
expect(await page.getByRole('button', { name: 'bar.txt' }).count()).toEqual(1);
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
});

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 () => {
expect(await page.getByRole('button', { name: 'unknown' }).count()).toEqual(0);
expect(await page.getByRole('button', { name: 'other.txt' }).count()).toEqual(0);
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
}).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 () => {
expect(await page.getByRole('button', { name: 'bar.txt' }).count()).toEqual(0);
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
}).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) {
isaacplmann marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -640,6 +641,23 @@ export class TutorialRunner {
* cleanup the allocated buffers.
*/
const scheduleReadFor = (filePath: string, encoding: 'utf-8' | null) => {
const segments = filePath.split('/');
segments.forEach((_, index) => {
if (index == segments.length - 1) {
return;
}

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' });
}

filesToRead.set(filePath, encoding);

clearTimeout(timeoutId);
Expand All @@ -654,19 +672,37 @@ 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') {
/**
* 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);
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
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(),
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.