Skip to content

Commit 5c1de69

Browse files
authored
feat: sync files from WebContainer to editor (#334)
1 parent c1a59f5 commit 5c1de69

File tree

20 files changed

+337
-34
lines changed

20 files changed

+337
-34
lines changed

Diff for: docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx

+31-2
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,35 @@ type Command = string
260260
261261
```
262262

263+
##### `filesystem`
264+
Configures how changes such as files being modified or added in WebContainer should be reflected in the editor when they weren't caused by the user directly. By default, the editor will not reflect these changes.
265+
266+
An example use case is when a user runs a command that modifies a file. For instance when a `package.json` is modified by doing an `npm install <xyz>`. If `watch` is set to `true`, the file will be updated in the editor. If set to `false`, the file will not be updated.
267+
268+
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`.
269+
270+
<PropertyTable inherited type={'FileSystem'} />
271+
272+
The `FileSystem` type has the following shape:
273+
274+
```ts
275+
type FileSystem = {
276+
watch: boolean
277+
}
278+
279+
```
280+
281+
Example values:
282+
283+
```yaml
284+
filesystem:
285+
watch: true # Filesystem changes are reflected in the editor
286+
287+
filesystem:
288+
watch: false # Or if it's omitted, the default value is false
289+
```
290+
291+
263292
##### `terminal`
264293
Configures one or more terminals. TutorialKit provides two types of terminals: read-only, called `output`, and interactive, called `terminal`. Note, that there can be only one `output` terminal.
265294

@@ -319,7 +348,7 @@ Navigating to a lesson that specifies `autoReload` will always reload the previe
319348
Specifies which folder from the `src/templates/` directory should be used as the basis for the code. See the "[Code templates](/guides/creating-content/#code-templates)" guide for a detailed explainer.
320349
<PropertyTable inherited type="string" />
321350

322-
#### `editPageLink`
351+
##### `editPageLink`
323352
Display a link in lesson for editing the page content.
324353
The value is a URL pattern where `${path}` is replaced with the lesson's location relative to the `src/content/tutorial`.
325354

@@ -346,7 +375,7 @@ You can instruct Github to show the source code instead by adding `plain=1` quer
346375

347376
:::
348377

349-
### `openInStackBlitz`
378+
##### `openInStackBlitz`
350379
Display a link for opening current lesson in StackBlitz.
351380
<PropertyTable inherited type="OpenInStackBlitz" />
352381

Diff for: e2e/src/components/ButtonWriteToFile.tsx

+17-9
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
import tutorialStore from 'tutorialkit:store';
2+
import { webcontainer } from 'tutorialkit:core';
23

34
interface Props {
45
filePath: string;
56
newContent: string;
7+
8+
// default to 'store'
9+
access?: 'store' | 'webcontainer';
610
testId?: string;
711
}
812

9-
export function ButtonWriteToFile({ filePath, newContent, testId = 'write-to-file' }: Props) {
13+
export function ButtonWriteToFile({ filePath, newContent, access = 'store', testId = 'write-to-file' }: Props) {
1014
async function writeFile() {
11-
await new Promise<void>((resolve) => {
12-
tutorialStore.lessonFullyLoaded.subscribe((value) => {
13-
if (value) {
14-
resolve();
15-
}
16-
});
17-
});
15+
switch (access) {
16+
case 'webcontainer': {
17+
const webcontainerInstance = await webcontainer;
18+
19+
await webcontainerInstance.fs.writeFile(filePath, newContent);
1820

19-
tutorialStore.updateFile(filePath, newContent);
21+
return;
22+
}
23+
case 'store': {
24+
tutorialStore.updateFile(filePath, newContent);
25+
return;
26+
}
27+
}
2028
}
2129

2230
return (

Diff for: e2e/src/content/tutorial/tests/filesystem/meta.md

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
type: chapter
3+
title: filesystem
4+
---
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Initial content
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
type: lesson
3+
title: No watch
4+
focus: /bar.txt
5+
---
6+
7+
import { ButtonWriteToFile } from '@components/ButtonWriteToFile';
8+
9+
# Watch filesystem test
10+
11+
<ButtonWriteToFile client:load access="webcontainer" filePath="/bar.txt" newContent='Something else' />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Baz
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Initial content
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
type: lesson
3+
title: Watch
4+
focus: /bar.txt
5+
filesystem:
6+
watch: true
7+
---
8+
9+
import { ButtonWriteToFile } from '@components/ButtonWriteToFile';
10+
11+
# Watch filesystem test
12+
13+
<ButtonWriteToFile client:load access="webcontainer" filePath="/bar.txt" newContent='Something else' />
14+
<ButtonWriteToFile client:load access="webcontainer" filePath="/a/b/baz.txt" newContent='Foo' testId='write-to-file-in-subfolder' />

Diff for: e2e/test/filesystem.test.ts

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
const BASE_URL = '/tests/filesystem';
4+
5+
test('editor should reflect changes made from webcontainer', async ({ page }) => {
6+
const testCase = 'watch';
7+
await page.goto(`${BASE_URL}/${testCase}`);
8+
9+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', {
10+
useInnerText: true,
11+
});
12+
13+
await page.getByTestId('write-to-file').click();
14+
15+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Something else', {
16+
useInnerText: true,
17+
});
18+
});
19+
20+
test('editor should reflect changes made from webcontainer in file in nested folder', async ({ page }) => {
21+
const testCase = 'watch';
22+
await page.goto(`${BASE_URL}/${testCase}`);
23+
24+
await page.getByRole('button', { name: 'baz.txt' }).click();
25+
26+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Baz', {
27+
useInnerText: true,
28+
});
29+
30+
await page.getByTestId('write-to-file-in-subfolder').click();
31+
32+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Foo', {
33+
useInnerText: true,
34+
});
35+
});
36+
37+
test('editor should not reflect changes made from webcontainer if watch is not set', async ({ page }) => {
38+
const testCase = 'no-watch';
39+
await page.goto(`${BASE_URL}/${testCase}`);
40+
41+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', {
42+
useInnerText: true,
43+
});
44+
45+
await page.getByTestId('write-to-file').click();
46+
47+
await page.waitForTimeout(1_000);
48+
49+
await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', {
50+
useInnerText: true,
51+
});
52+
});

Diff for: packages/astro/src/default/utils/content.ts

+1
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export async function getTutorial(): Promise<Tutorial> {
247247
'i18n',
248248
'editPageLink',
249249
'openInStackBlitz',
250+
'filesystem',
250251
],
251252
),
252253
};

Diff for: packages/runtime/README.md

+2-3
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ A wrapper around the **[WebContainer API][webcontainer-api]** focused on providi
44

55
The runtime exposes the following:
66

7-
- `lessonFilesFetcher`: A singleton that lets you fetch the contents of the lesson files
8-
- `TutorialRunner`: The API to manage your tutorial content in WebContainer
7+
- `TutorialStore`: A store to manage your tutorial content in WebContainer and in your components.
98

10-
Only a single instance of `TutorialRunner` should be created in your application and its lifetime is bound by the lifetime of the WebContainer instance.
9+
Only a single instance of `TutorialStore` should be created in your application and its lifetime is bound by the lifetime of the WebContainer instance.
1110

1211
## License
1312

Diff for: packages/runtime/src/index.ts

-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
export { LessonFilesFetcher } from './lesson-files.js';
2-
export { TutorialRunner } from './tutorial-runner.js';
31
export type { Command, Commands, PreviewInfo, Step, Steps } from './webcontainer/index.js';
42
export { safeBoot } from './webcontainer/index.js';
53
export { TutorialStore } from './store/index.js';

Diff for: packages/runtime/src/store/editor.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export class EditorStore {
113113
});
114114
}
115115

116-
updateFile(filePath: string, content: string): boolean {
116+
updateFile(filePath: string, content: string | Uint8Array): boolean {
117117
const documentState = this.documents.get()[filePath];
118118

119119
if (!documentState) {

Diff for: packages/runtime/src/store/index.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { WebContainer } from '@webcontainer/api';
33
import { atom, type ReadableAtom } from 'nanostores';
44
import { LessonFilesFetcher } from '../lesson-files.js';
55
import { newTask, type Task } from '../tasks.js';
6-
import { TutorialRunner } from '../tutorial-runner.js';
76
import type { ITerminal } from '../utils/terminal.js';
87
import type { EditorConfig } from '../webcontainer/editor-config.js';
98
import { bootStatus, unblockBoot, type BootStatus } from '../webcontainer/on-demand-boot.js';
@@ -13,6 +12,7 @@ import type { TerminalConfig } from '../webcontainer/terminal-config.js';
1312
import { EditorStore, type EditorDocument, type EditorDocuments, type ScrollPosition } from './editor.js';
1413
import { PreviewsStore } from './previews.js';
1514
import { TerminalStore } from './terminal.js';
15+
import { TutorialRunner } from './tutorial-runner.js';
1616

1717
interface StoreOptions {
1818
webcontainer: Promise<WebContainer>;
@@ -59,7 +59,7 @@ export class TutorialStore {
5959
this._lessonFilesFetcher = new LessonFilesFetcher(basePathname);
6060
this._previewsStore = new PreviewsStore(this._webcontainer);
6161
this._terminalStore = new TerminalStore(this._webcontainer, useAuth);
62-
this._runner = new TutorialRunner(this._webcontainer, this._terminalStore, this._stepController);
62+
this._runner = new TutorialRunner(this._webcontainer, this._terminalStore, this._editorStore, this._stepController);
6363

6464
/**
6565
* By having this code under `import.meta.hot`, it gets:
@@ -150,6 +150,8 @@ export class TutorialStore {
150150
return;
151151
}
152152

153+
this._runner.setWatchFromWebContainer(lesson.data.filesystem?.watch ?? false);
154+
153155
this._lessonTask = newTask(
154156
async (signal) => {
155157
const templatePromise = this._lessonFilesFetcher.getLessonTemplate(lesson);

Diff for: packages/runtime/src/tutorial-runner.spec.ts renamed to packages/runtime/src/store/tutorial-runner.spec.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { resetProcessFactory, setProcessFactory } from '@tutorialkit/test-utils'
44
import type { MockedWebContainer } from '@tutorialkit/test-utils';
55
import { WebContainer } from '@webcontainer/api';
66
import { beforeEach, describe, expect, test, vi } from 'vitest';
7-
import { TerminalStore } from './store/terminal.js';
7+
import { withResolvers } from '../utils/promises.js';
8+
import { StepsController } from '../webcontainer/steps.js';
9+
import { EditorStore } from './editor.js';
10+
import { TerminalStore } from './terminal.js';
811
import { TutorialRunner } from './tutorial-runner.js';
9-
import { withResolvers } from './utils/promises.js';
10-
import { StepsController } from './webcontainer/steps.js';
1112

1213
beforeEach(() => {
1314
resetProcessFactory();
@@ -17,7 +18,12 @@ describe('TutorialRunner', () => {
1718
test('prepareFiles should mount files to WebContainer', async () => {
1819
const webcontainer = WebContainer.boot();
1920
const mock = (await webcontainer) as MockedWebContainer;
20-
const runner = new TutorialRunner(webcontainer, new TerminalStore(webcontainer, false), new StepsController());
21+
const runner = new TutorialRunner(
22+
webcontainer,
23+
new TerminalStore(webcontainer, false),
24+
new EditorStore(),
25+
new StepsController(),
26+
);
2127

2228
await runner.prepareFiles({
2329
files: {
@@ -72,7 +78,12 @@ describe('TutorialRunner', () => {
7278

7379
setProcessFactory(processFactory);
7480

75-
const runner = new TutorialRunner(webcontainer, new TerminalStore(webcontainer, false), new StepsController());
81+
const runner = new TutorialRunner(
82+
webcontainer,
83+
new TerminalStore(webcontainer, false),
84+
new EditorStore(),
85+
new StepsController(),
86+
);
7687

7788
runner.setCommands({
7889
mainCommand: 'some command',

0 commit comments

Comments
 (0)