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 files from WebContainer to editor #334

Merged
merged 12 commits into from
Sep 18, 2024
33 changes: 31 additions & 2 deletions docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,35 @@ type Command = string

```

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

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.

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 directly, you may want to keep this to `false`.
Nemikolh marked this conversation as resolved.
Show resolved Hide resolved

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

The `FileSystem` type has the following shape:

```ts
type FileSystem = {
watch: boolean
}

```

Example values:

```yaml
filesystem:
watch: true # Filesystem changes are reflected in the editor

filesystem:
watch: false # Or if it's omitted, the default value is false
```


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

Expand Down Expand Up @@ -277,7 +306,7 @@ Navigating to a lesson that specifies `autoReload` will always reload the previe
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.
<PropertyTable inherited type="string" />

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

Expand All @@ -304,7 +333,7 @@ You can instruct Github to show the source code instead by adding `plain=1` quer

:::

### `openInStackBlitz`
##### `openInStackBlitz`
Display a link for opening current lesson in StackBlitz.
<PropertyTable inherited type="OpenInStackBlitz" />

Expand Down
12 changes: 11 additions & 1 deletion e2e/src/components/ButtonWriteToFile.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import tutorialStore from 'tutorialkit:store';
import { webcontainer } from 'tutorialkit:core';

interface Props {
filePath: string;
newContent: string;
useWebcontainer?: boolean;
Nemikolh marked this conversation as resolved.
Show resolved Hide resolved
testId?: string;
}

export function ButtonWriteToFile({ filePath, newContent, testId = 'write-to-file' }: Props) {
export function ButtonWriteToFile({ filePath, newContent, useWebcontainer = false, testId = 'write-to-file' }: Props) {
async function writeFile() {
if (useWebcontainer) {
const webcontainerInstance = await webcontainer;

await webcontainerInstance.fs.writeFile(filePath, newContent);

return;
}

await new Promise<void>((resolve) => {
tutorialStore.lessonFullyLoaded.subscribe((value) => {
if (value) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Baz
Nemikolh marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Initial content
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
type: lesson
title: Happy path
focus: /bar.txt
---

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

# Happy path filesystem test

<ButtonWriteToFile client:load filePath="/bar.txt" newContent='Something else' useWebcontainer />
<ButtonWriteToFile client:load filePath="/a/b/baz.txt" newContent='Foo' useWebcontainer testId='write-to-file-in-subfolder' />
6 changes: 6 additions & 0 deletions e2e/src/content/tutorial/tests/filesystem-sync/meta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: chapter
title: filesystem.watch
filesystem:
watch: true
---
35 changes: 35 additions & 0 deletions e2e/test/filesystem-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test, expect } from '@playwright/test';

const BASE_URL = '/tests/filesystem-sync';

test('editor should reflect changes made from webcontainer', async ({ page }) => {
const testCase = 'happy-path';
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 changes made from webcontainer in file in nested folder', async ({ page }) => {
const testCase = 'happy-path';
await page.goto(`${BASE_URL}/${testCase}`);

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

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

await page.getByTestId('write-to-file-in-subfolder').click();

await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Foo', {
useInnerText: true,
});
});
1 change: 1 addition & 0 deletions packages/astro/src/default/utils/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export async function getTutorial(): Promise<Tutorial> {
'i18n',
'editPageLink',
'openInStackBlitz',
'filesystem',
],
),
};
Expand Down
5 changes: 2 additions & 3 deletions packages/runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ A wrapper around the **[WebContainer API][webcontainer-api]** focused on providi

The runtime exposes the following:

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

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

## License

Expand Down
2 changes: 0 additions & 2 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export { LessonFilesFetcher } from './lesson-files.js';
export { TutorialRunner } from './tutorial-runner.js';
export type { Command, Commands, PreviewInfo, Step, Steps } from './webcontainer/index.js';
export { safeBoot } from './webcontainer/index.js';
export { TutorialStore } from './store/index.js';
2 changes: 1 addition & 1 deletion packages/runtime/src/store/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export class EditorStore {
});
}

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

if (!documentState) {
Expand Down
6 changes: 4 additions & 2 deletions packages/runtime/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { WebContainer } from '@webcontainer/api';
import { atom, type ReadableAtom } from 'nanostores';
import { LessonFilesFetcher } from '../lesson-files.js';
import { newTask, type Task } from '../tasks.js';
import { TutorialRunner } from '../tutorial-runner.js';
import type { ITerminal } from '../utils/terminal.js';
import type { EditorConfig } from '../webcontainer/editor-config.js';
import { bootStatus, unblockBoot, type BootStatus } from '../webcontainer/on-demand-boot.js';
Expand All @@ -13,6 +12,7 @@ import type { TerminalConfig } from '../webcontainer/terminal-config.js';
import { EditorStore, type EditorDocument, type EditorDocuments, type ScrollPosition } from './editor.js';
import { PreviewsStore } from './previews.js';
import { TerminalStore } from './terminal.js';
import { TutorialRunner } from './tutorial-runner.js';

interface StoreOptions {
webcontainer: Promise<WebContainer>;
Expand Down Expand Up @@ -59,7 +59,7 @@ export class TutorialStore {
this._lessonFilesFetcher = new LessonFilesFetcher(basePathname);
this._previewsStore = new PreviewsStore(this._webcontainer);
this._terminalStore = new TerminalStore(this._webcontainer, useAuth);
this._runner = new TutorialRunner(this._webcontainer, this._terminalStore, this._stepController);
this._runner = new TutorialRunner(this._webcontainer, this._terminalStore, this._editorStore, this._stepController);

/**
* By having this code under `import.meta.hot`, it gets:
Expand Down Expand Up @@ -150,6 +150,8 @@ export class TutorialStore {
return;
}

this._runner.setWatchFromWebContainer(lesson.data.filesystem?.watch ?? false);

this._lessonTask = newTask(
async (signal) => {
const templatePromise = this._lessonFilesFetcher.getLessonTemplate(lesson);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { resetProcessFactory, setProcessFactory } from '@tutorialkit/test-utils'
import type { MockedWebContainer } from '@tutorialkit/test-utils';
import { WebContainer } from '@webcontainer/api';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { TerminalStore } from './store/terminal.js';
import { withResolvers } from '../utils/promises.js';
import { StepsController } from '../webcontainer/steps.js';
import { EditorStore } from './editor.js';
import { TerminalStore } from './terminal.js';
import { TutorialRunner } from './tutorial-runner.js';
import { withResolvers } from './utils/promises.js';
import { StepsController } from './webcontainer/steps.js';

beforeEach(() => {
resetProcessFactory();
Expand All @@ -17,7 +18,12 @@ describe('TutorialRunner', () => {
test('prepareFiles should mount files to WebContainer', async () => {
const webcontainer = WebContainer.boot();
const mock = (await webcontainer) as MockedWebContainer;
const runner = new TutorialRunner(webcontainer, new TerminalStore(webcontainer, false), new StepsController());
const runner = new TutorialRunner(
webcontainer,
new TerminalStore(webcontainer, false),
new EditorStore(),
new StepsController(),
);

await runner.prepareFiles({
files: {
Expand Down Expand Up @@ -72,7 +78,12 @@ describe('TutorialRunner', () => {

setProcessFactory(processFactory);

const runner = new TutorialRunner(webcontainer, new TerminalStore(webcontainer, false), new StepsController());
const runner = new TutorialRunner(
webcontainer,
new TerminalStore(webcontainer, false),
new EditorStore(),
new StepsController(),
);

runner.setCommands({
mainCommand: 'some command',
Expand Down
Loading