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
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 'store'
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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ type: lesson
title: Watch Glob
focus: /bar.txt
filesystem:
watch: ['/*', '/a/**/*', '/src/**/*']
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' />
3 changes: 3 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,9 +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' />
22 changes: 21 additions & 1 deletion e2e/test/filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ test('editor should reflect changes made from webcontainer in file in nested fol
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 @@ -33,7 +36,10 @@ 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);
});

test('editor should reflect changes made from webcontainer in specified paths', async ({ page }) => {
Expand All @@ -59,13 +65,27 @@ test('editor should reflect new files added in specified paths in webcontainer',
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(async () => {
expect(await page.getByRole('button', { name: 'unknown' }).count()).toEqual(0);
expect(await page.getByRole('button', { name: 'other.txt' }).count()).toEqual(0);
}).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);
}).toPass();
});

test('editor should not reflect changes made from webcontainer if watch is not set', async ({ page }) => {
const testCase = 'no-watch';
await page.goto(`${BASE_URL}/${testCase}`);
Expand Down
12 changes: 12 additions & 0 deletions packages/runtime/src/store/editor.ts
Original file line number Diff line number Diff line change
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
47 changes: 31 additions & 16 deletions packages/runtime/src/store/tutorial-runner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CommandsSchema, Files } from '@tutorialkit/types';
import type { IFSWatcher, WebContainer, WebContainerProcess } from '@webcontainer/api';
import picomatch from 'picomatch';
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 @@ -641,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 @@ -663,7 +680,10 @@ export class TutorialRunner {
}

if (eventType === 'change') {
// we ignore all paths that aren't exposed in the `_editorStore`
/**
* Update file
* we ignore all paths that aren't exposed in the `_editorStore`
*/
const file = this._editorStore.documents.get()[filePath];

if (!file) {
Expand All @@ -672,21 +692,16 @@ export class TutorialRunner {

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

const folderPath = segments.slice(0, index + 1).join('/');
const file = this._editorStore.documents.get()[filePath];

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