Skip to content
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';
}