diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx index 33e8ba6ee..e1f3ef41a 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx @@ -260,6 +260,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 `. 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 outside the editor, you may want to keep this to `false`. + + + +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. @@ -319,7 +348,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. -#### `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`. @@ -346,7 +375,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. diff --git a/e2e/src/components/ButtonWriteToFile.tsx b/e2e/src/components/ButtonWriteToFile.tsx index 5262ce893..e0403e43a 100644 --- a/e2e/src/components/ButtonWriteToFile.tsx +++ b/e2e/src/components/ButtonWriteToFile.tsx @@ -1,22 +1,30 @@ import tutorialStore from 'tutorialkit:store'; +import { webcontainer } from 'tutorialkit:core'; interface Props { filePath: string; newContent: string; + + // default to 'store' + access?: 'store' | 'webcontainer'; testId?: string; } -export function ButtonWriteToFile({ filePath, newContent, testId = 'write-to-file' }: Props) { +export function ButtonWriteToFile({ filePath, newContent, access = 'store', testId = 'write-to-file' }: Props) { async function writeFile() { - await new Promise((resolve) => { - tutorialStore.lessonFullyLoaded.subscribe((value) => { - if (value) { - resolve(); - } - }); - }); + switch (access) { + case 'webcontainer': { + const webcontainerInstance = await webcontainer; + + await webcontainerInstance.fs.writeFile(filePath, newContent); - tutorialStore.updateFile(filePath, newContent); + return; + } + case 'store': { + tutorialStore.updateFile(filePath, newContent); + return; + } + } } return ( diff --git a/e2e/src/content/tutorial/tests/filesystem/meta.md b/e2e/src/content/tutorial/tests/filesystem/meta.md new file mode 100644 index 000000000..06e99f712 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/meta.md @@ -0,0 +1,4 @@ +--- +type: chapter +title: filesystem +--- diff --git a/e2e/src/content/tutorial/tests/filesystem/no-watch/_files/bar.txt b/e2e/src/content/tutorial/tests/filesystem/no-watch/_files/bar.txt new file mode 100644 index 000000000..8430408a5 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/no-watch/_files/bar.txt @@ -0,0 +1 @@ +Initial content diff --git a/e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx b/e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx new file mode 100644 index 000000000..aeac65eb4 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx @@ -0,0 +1,11 @@ +--- +type: lesson +title: No watch +focus: /bar.txt +--- + +import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; + +# Watch filesystem test + + diff --git a/e2e/src/content/tutorial/tests/filesystem/watch/_files/a/b/baz.txt b/e2e/src/content/tutorial/tests/filesystem/watch/_files/a/b/baz.txt new file mode 100644 index 000000000..a68818270 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/watch/_files/a/b/baz.txt @@ -0,0 +1 @@ +Baz diff --git a/e2e/src/content/tutorial/tests/filesystem/watch/_files/bar.txt b/e2e/src/content/tutorial/tests/filesystem/watch/_files/bar.txt new file mode 100644 index 000000000..8430408a5 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/watch/_files/bar.txt @@ -0,0 +1 @@ +Initial content diff --git a/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx b/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx new file mode 100644 index 000000000..46a0ed3b7 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx @@ -0,0 +1,14 @@ +--- +type: lesson +title: Watch +focus: /bar.txt +filesystem: + watch: true +--- + +import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; + +# Watch filesystem test + + + diff --git a/e2e/test/filesystem.test.ts b/e2e/test/filesystem.test.ts new file mode 100644 index 000000000..be14fef7f --- /dev/null +++ b/e2e/test/filesystem.test.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; + +const BASE_URL = '/tests/filesystem'; + +test('editor should reflect changes made from webcontainer', async ({ page }) => { + const testCase = 'watch'; + 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 = 'watch'; + 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, + }); +}); + +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}`); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', { + useInnerText: true, + }); + + await page.getByTestId('write-to-file').click(); + + await page.waitForTimeout(1_000); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', { + useInnerText: true, + }); +}); diff --git a/packages/astro/src/default/utils/content.ts b/packages/astro/src/default/utils/content.ts index 0ad2103b2..328209d90 100644 --- a/packages/astro/src/default/utils/content.ts +++ b/packages/astro/src/default/utils/content.ts @@ -247,6 +247,7 @@ export async function getTutorial(): Promise { 'i18n', 'editPageLink', 'openInStackBlitz', + 'filesystem', ], ), }; diff --git a/packages/runtime/README.md b/packages/runtime/README.md index ee44a8124..46e41bafe 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -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 diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 36a2bae32..7af9c49bc 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -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'; diff --git a/packages/runtime/src/store/editor.ts b/packages/runtime/src/store/editor.ts index 89fec3dc3..a0f305d63 100644 --- a/packages/runtime/src/store/editor.ts +++ b/packages/runtime/src/store/editor.ts @@ -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) { diff --git a/packages/runtime/src/store/index.ts b/packages/runtime/src/store/index.ts index a88e95ed6..91efbe405 100644 --- a/packages/runtime/src/store/index.ts +++ b/packages/runtime/src/store/index.ts @@ -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'; @@ -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; @@ -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: @@ -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); diff --git a/packages/runtime/src/tutorial-runner.spec.ts b/packages/runtime/src/store/tutorial-runner.spec.ts similarity index 85% rename from packages/runtime/src/tutorial-runner.spec.ts rename to packages/runtime/src/store/tutorial-runner.spec.ts index cc313bf6e..99eba7949 100644 --- a/packages/runtime/src/tutorial-runner.spec.ts +++ b/packages/runtime/src/store/tutorial-runner.spec.ts @@ -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(); @@ -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: { @@ -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', diff --git a/packages/runtime/src/tutorial-runner.ts b/packages/runtime/src/store/tutorial-runner.ts similarity index 76% rename from packages/runtime/src/tutorial-runner.ts rename to packages/runtime/src/store/tutorial-runner.ts index 3f9f16fc9..5f565e632 100644 --- a/packages/runtime/src/tutorial-runner.ts +++ b/packages/runtime/src/store/tutorial-runner.ts @@ -1,11 +1,13 @@ import type { CommandsSchema, Files } from '@tutorialkit/types'; -import type { WebContainer, WebContainerProcess } from '@webcontainer/api'; -import type { TerminalStore } from './store/terminal.js'; -import { newTask, type Task, type TaskCancelled } from './tasks.js'; -import { clearTerminal, escapeCodes, type ITerminal } from './utils/terminal.js'; -import { Command, Commands } from './webcontainer/command.js'; -import { StepsController } from './webcontainer/steps.js'; -import { diffFiles, toFileTree } from './webcontainer/utils/files.js'; +import type { IFSWatcher, WebContainer, WebContainerProcess } from '@webcontainer/api'; +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'; +import { Command, Commands } from '../webcontainer/command.js'; +import { StepsController } from '../webcontainer/steps.js'; +import { diffFiles, toFileTree } from '../webcontainer/utils/files.js'; +import type { EditorStore } from './editor.js'; +import type { TerminalStore } from './terminal.js'; interface LoadFilesOptions { /** @@ -60,6 +62,12 @@ export class TutorialRunner { private _currentTemplate: Files | undefined = undefined; private _currentFiles: Files | undefined = undefined; private _currentRunCommands: Commands | undefined = undefined; + + private _ignoreFileEvents = new MultiCounter(); + private _watcher: IFSWatcher | undefined; + private _watchContentFromWebContainer = false; + private _readyToWatch = false; + private _packageJsonDirty = false; private _commandsChanged = false; @@ -70,9 +78,20 @@ export class TutorialRunner { constructor( private _webcontainer: Promise, private _terminalStore: TerminalStore, + private _editorStore: EditorStore, private _stepController: StepsController, ) {} + setWatchFromWebContainer(value: boolean) { + this._watchContentFromWebContainer = value; + + if (this._readyToWatch && this._watchContentFromWebContainer) { + this._webcontainer.then((webcontainer) => this._setupWatcher(webcontainer)); + } else if (!this._watchContentFromWebContainer) { + this._stopWatcher(); + } + } + /** * Set the commands to run. This updates the reported `steps` if any have changed. * @@ -109,6 +128,8 @@ export class TutorialRunner { signal.throwIfAborted(); + this._ignoreFileEvents.increment(folderPath); + await webcontainer.fs.mkdir(folderPath); }, { ignoreCancel: true }, @@ -132,6 +153,8 @@ export class TutorialRunner { signal.throwIfAborted(); + this._ignoreFileEvents.increment(filePath); + await webcontainer.fs.writeFile(filePath, content); this._updateCurrentFiles({ [filePath]: content }); @@ -156,6 +179,8 @@ export class TutorialRunner { signal.throwIfAborted(); + this._ignoreFileEvents.increment(Object.keys(files)); + await webcontainer.mount(toFileTree(files)); this._updateCurrentFiles(files); @@ -185,6 +210,12 @@ export class TutorialRunner { async (signal) => { await previousLoadPromise; + // no watcher should be installed + this._readyToWatch = false; + + // stop current watcher if they are any + this._stopWatcher(); + const webcontainer = await this._webcontainer; signal.throwIfAborted(); @@ -375,7 +406,7 @@ export class TutorialRunner { const abortListener = () => this._currentCommandProcess?.kill(); signal.addEventListener('abort', abortListener, { once: true }); - const hasMainCommand = !!commands.mainCommand; + let shouldClearDirtyFlag = true; try { const commandList = [...commands]; @@ -419,6 +450,9 @@ export class TutorialRunner { } if (isMainCommand) { + shouldClearDirtyFlag = false; + + this._setupWatcher(webcontainer); this._clearDirtyState(); } @@ -431,6 +465,13 @@ export class TutorialRunner { }); this._stepController.skipRemaining(index + 1); + + /** + * We don't clear the dirty flag in that case as there was an error and re-running all commands + * is the probably better than not running anything. + */ + shouldClearDirtyFlag = false; + break; } else { this._stepController.updateStep(index, { @@ -447,9 +488,17 @@ export class TutorialRunner { } } - if (!hasMainCommand) { + /** + * All commands were run but we didn't clear the dirty state. + * We have to, otherwise we would re-run those commands when moving + * to a lesson that has the exact same set of commands. + */ + if (shouldClearDirtyFlag) { this._clearDirtyState(); } + + // make sure the watcher is configured + this._setupWatcher(webcontainer); } finally { signal.removeEventListener('abort', abortListener); } @@ -503,6 +552,85 @@ export class TutorialRunner { this._updateDirtyState(files); } + private _stopWatcher(): void { + // if there was a watcher terminate it + if (this._watcher) { + this._watcher.close(); + this._watcher = undefined; + } + } + + private _setupWatcher(webcontainer: WebContainer) { + // inform that the watcher could be installed if we wanted to + this._readyToWatch = true; + + // if the watcher is alreay setup or we don't sync content we exit + if (this._watcher || !this._watchContentFromWebContainer) { + return; + } + + const filesToRead = new Map(); + + let timeoutId: ReturnType | undefined; + + const readFiles = () => { + const files = [...filesToRead.entries()]; + + filesToRead.clear(); + + Promise.all( + files.map(async ([filePath, encoding]) => { + // casts could be removed with an `if` but it feels weird + const content = (await webcontainer.fs.readFile(filePath, encoding as any)) as Uint8Array | string; + + return [filePath, content] as const; + }), + ).then((fileContents) => { + for (const [filePath, content] of fileContents) { + this._editorStore.updateFile(filePath, content); + } + }); + }; + + /** + * Add a file to the list of files to read and schedule a read for later, effectively debouncing the reads. + * + * This does not cancel any existing requests because those are expected to be completed really + * fast. However every read request allocate memory that needs to be freed. The reason we debounce + * is to avoid running into OOM issues (which has happened in the past) and give time to the GC to + * cleanup the allocated buffers. + */ + const scheduleReadFor = (filePath: string, encoding: 'utf-8' | null) => { + filesToRead.set(filePath, encoding); + + clearTimeout(timeoutId); + timeoutId = setTimeout(readFiles, 100); + }; + + this._watcher = webcontainer.fs.watch('.', { recursive: true }, (eventType, filename) => { + const filePath = `/${filename}`; + + // events we should ignore because we caused them in the TutorialRunner + if (!this._ignoreFileEvents.decrement(filePath)) { + return; + } + + // for now we only care about 'change' event + if (eventType !== 'change') { + return; + } + + // we ignore all paths that aren't exposed in the `_editorStore` + const file = this._editorStore.documents.get()[filePath]; + + if (!file) { + return; + } + + scheduleReadFor(filePath, typeof file.value === 'string' ? 'utf-8' : null); + }); + } + private _clearDirtyState() { this._packageJsonDirty = false; } diff --git a/packages/runtime/src/utils/multi-counter.ts b/packages/runtime/src/utils/multi-counter.ts new file mode 100644 index 000000000..99b30e342 --- /dev/null +++ b/packages/runtime/src/utils/multi-counter.ts @@ -0,0 +1,27 @@ +export class MultiCounter { + private _counts = new Map(); + + increment(name: string | string[]) { + if (typeof name === 'string') { + const currentValue = this._counts.get(name) ?? 0; + + this._counts.set(name, currentValue + 1); + + return; + } + + name.forEach((value) => this.increment(value)); + } + + decrement(name: string): boolean { + const currentValue = this._counts.get(name) ?? 0; + + if (currentValue === 0) { + return true; + } + + this._counts.set(name, currentValue - 1); + + return currentValue - 1 === 0; + } +} diff --git a/packages/runtime/src/utils/promises.ts b/packages/runtime/src/utils/promises.ts index ebd1e927c..0bb6baf3b 100644 --- a/packages/runtime/src/utils/promises.ts +++ b/packages/runtime/src/utils/promises.ts @@ -28,7 +28,7 @@ export function wait(ms: number): Promise { * @returns A promise that resolves after the tick. */ export function tick() { - return new Promise((resolve) => { + return new Promise((resolve) => { setTimeout(resolve); }); } diff --git a/packages/template/src/content/tutorial/1-basics/meta.md b/packages/template/src/content/tutorial/1-basics/meta.md index 92180c79b..3e201fed6 100644 --- a/packages/template/src/content/tutorial/1-basics/meta.md +++ b/packages/template/src/content/tutorial/1-basics/meta.md @@ -1,4 +1,6 @@ --- type: part title: Basics +filesystem: + watch: true --- diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts index 585f1ef81..dcb4f9c5b 100644 --- a/packages/types/src/schemas/common.ts +++ b/packages/types/src/schemas/common.ts @@ -55,6 +55,15 @@ export const previewSchema = z.union([ export type PreviewSchema = z.infer; +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.'), +}); + +export type FileSystemSchema = z.infer; + const panelTypeSchema = z .union([z.literal('output'), z.literal('terminal')]) .describe(`The type of the terminal which can either be 'output' or 'terminal'.`); @@ -209,6 +218,11 @@ export const webcontainerSchema = commandsSchema.extend({ .describe( 'Navigating to a lesson that specifies autoReload will always reload the preview. This is typically only needed if your server does not support HMR.', ), + filesystem: fileSystemSchema + .optional() + .describe( + 'Configure how changes happening on the filesystem should impact the Tutorial. For instance, when new files are being changed, whether those change should be reflected in the editor.', + ), template: z .string() .optional()