From fa231432e7ca1a6b88980b3747e0530fe7ef46be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Wed, 4 Sep 2024 09:10:18 +0300 Subject: [PATCH 01/15] feat(runtime): reactive source for editor files --- packages/react/src/Panels/WorkspacePanel.tsx | 3 ++- packages/runtime/src/store/editor.ts | 1 + packages/runtime/src/store/index.ts | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react/src/Panels/WorkspacePanel.tsx b/packages/react/src/Panels/WorkspacePanel.tsx index 98b4b72f..10586139 100644 --- a/packages/react/src/Panels/WorkspacePanel.tsx +++ b/packages/react/src/Panels/WorkspacePanel.tsx @@ -97,6 +97,7 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { const currentDocument = useStore(tutorialStore.currentDocument); const lessonFullyLoaded = useStore(tutorialStore.lessonFullyLoaded); const storeRef = useStore(tutorialStore.ref); + const files = useStore(tutorialStore.files); const lesson = tutorialStore.lesson!; @@ -140,7 +141,7 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { theme={theme} showFileTree={tutorialStore.hasFileTree()} editorDocument={currentDocument} - files={lesson.files[1]} + files={files} i18n={lesson.data.i18n as I18n} hideRoot={lesson.data.hideRoot} helpAction={helpAction} diff --git a/packages/runtime/src/store/editor.ts b/packages/runtime/src/store/editor.ts index d613909b..f5e92f64 100644 --- a/packages/runtime/src/store/editor.ts +++ b/packages/runtime/src/store/editor.ts @@ -19,6 +19,7 @@ export class EditorStore { selectedFile = atom(); documents = map({}); + files = computed(this.documents, (documents) => Object.keys(documents).sort()); currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => { if (!selectedFile) { return undefined; diff --git a/packages/runtime/src/store/index.ts b/packages/runtime/src/store/index.ts index 71b5b798..0d476625 100644 --- a/packages/runtime/src/store/index.ts +++ b/packages/runtime/src/store/index.ts @@ -207,6 +207,10 @@ export class TutorialStore { return this._editorStore.documents; } + get files(): ReadableAtom { + return this._editorStore.files; + } + get template(): Files | undefined { return this._lessonTemplate; } From 35b666c677b2466fb6753151dadb7e5fc400bac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Mon, 9 Sep 2024 19:07:42 +0300 Subject: [PATCH 02/15] feat(runtime): store to support file and folder creation --- packages/react/src/Panels/EditorPanel.tsx | 5 ++++- packages/react/src/Panels/WorkspacePanel.tsx | 15 +++++++++++++- packages/react/src/core/FileTree.tsx | 7 +++++++ packages/runtime/src/store/editor.ts | 17 +++++++++++++++- packages/runtime/src/store/index.ts | 21 ++++++++++++++++++++ packages/runtime/src/tutorial-runner.ts | 17 ++++++++++++++++ 6 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/react/src/Panels/EditorPanel.tsx b/packages/react/src/Panels/EditorPanel.tsx index 4e9a3923..0e535d30 100644 --- a/packages/react/src/Panels/EditorPanel.tsx +++ b/packages/react/src/Panels/EditorPanel.tsx @@ -1,5 +1,5 @@ import type { I18n } from '@tutorialkit/types'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, type ComponentProps } from 'react'; import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels'; import { CodeMirrorEditor, @@ -29,6 +29,7 @@ interface Props { onEditorScroll?: OnEditorScroll; onHelpClick?: () => void; onFileSelect?: (value?: string) => void; + onFileTreeChange?: ComponentProps['onFileChange']; } export function EditorPanel({ @@ -46,6 +47,7 @@ export function EditorPanel({ onEditorScroll, onHelpClick, onFileSelect, + onFileTreeChange, }: Props) { const fileTreePanelRef = useRef(null); @@ -81,6 +83,7 @@ export function EditorPanel({ files={files} scope={fileTreeScope} onFileSelect={onFileSelect} + onFileChange={onFileTreeChange} /> ['onFileTreeChange']>>[0]; + interface Props { tutorialStore: TutorialStore; theme: Theme; @@ -119,6 +121,16 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { } } + function onFileTreeChange({ method, type, value }: FileTreeChangeEvent) { + if (method == 'ADD' && type === 'FILE') { + return tutorialStore.addFile(value); + } + + if (method == 'ADD' && type === 'FOLDER') { + return tutorialStore.addFolder(value); + } + } + useEffect(() => { if (tutorialStore.hasSolution()) { setHelpAction('solve'); @@ -147,6 +159,7 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { helpAction={helpAction} onHelpClick={lessonFullyLoaded ? onHelpClick : undefined} onFileSelect={(filePath) => tutorialStore.setSelectedFile(filePath)} + onFileTreeChange={onFileTreeChange} selectedFile={selectedFile} onEditorScroll={(position) => tutorialStore.setCurrentDocumentScrollPosition(position)} onEditorChange={(update) => tutorialStore.setCurrentDocumentContent(update.content)} diff --git a/packages/react/src/core/FileTree.tsx b/packages/react/src/core/FileTree.tsx index d3ccccd4..ce2f7ecb 100644 --- a/packages/react/src/core/FileTree.tsx +++ b/packages/react/src/core/FileTree.tsx @@ -4,10 +4,17 @@ import { classNames } from '../utils/classnames.js'; const NODE_PADDING_LEFT = 12; const DEFAULT_HIDDEN_FILES = [/\/node_modules\//]; +interface FileChangeEvent { + type: 'FILE' | 'FOLDER'; + method: 'ADD' | 'REMOVE' | 'RENAME'; + value: string; +} + interface Props { files: string[]; selectedFile?: string; onFileSelect?: (filePath: string) => void; + onFileChange?: (event: FileChangeEvent) => void; hideRoot: boolean; scope?: string; hiddenFiles?: Array; diff --git a/packages/runtime/src/store/editor.ts b/packages/runtime/src/store/editor.ts index f5e92f64..326fff4a 100644 --- a/packages/runtime/src/store/editor.ts +++ b/packages/runtime/src/store/editor.ts @@ -13,7 +13,7 @@ export interface ScrollPosition { left: number; } -export type EditorDocuments = Record; +export type EditorDocuments = Record; export class EditorStore { selectedFile = atom(); @@ -83,6 +83,21 @@ export class EditorStore { }); } + addFileOrFolder(filePath: string) { + // when adding file to empty folder, remove the empty folder from documents + const emptyFolder = this.files.value?.find((path) => filePath.startsWith(path)); + + if (emptyFolder) { + this.documents.setKey(emptyFolder, undefined); + } + + this.documents.setKey(filePath, { + filePath, + value: '', + loading: false, + }); + } + updateFile(filePath: string, content: string): boolean { const documentState = this.documents.get()[filePath]; diff --git a/packages/runtime/src/store/index.ts b/packages/runtime/src/store/index.ts index 0d476625..a5fa4e1f 100644 --- a/packages/runtime/src/store/index.ts +++ b/packages/runtime/src/store/index.ts @@ -308,6 +308,27 @@ export class TutorialStore { this._editorStore.setSelectedFile(filePath); } + addFile(filePath: string) { + // prevent creating duplicates + if (this._editorStore.files.get().includes(filePath)) { + return this.setSelectedFile(filePath); + } + + this._editorStore.addFileOrFolder(filePath); + this.setSelectedFile(filePath); + this._runner.updateFile(filePath, ''); + } + + addFolder(folderPath: string) { + // prevent creating duplicates + if (this._editorStore.files.get().some((file) => file.startsWith(folderPath))) { + return; + } + + this._editorStore.addFileOrFolder(folderPath); + this._runner.createFolder(folderPath); + } + updateFile(filePath: string, content: string) { const hasChanged = this._editorStore.updateFile(filePath, content); diff --git a/packages/runtime/src/tutorial-runner.ts b/packages/runtime/src/tutorial-runner.ts index 1d73bbc6..3f9f16fc 100644 --- a/packages/runtime/src/tutorial-runner.ts +++ b/packages/runtime/src/tutorial-runner.ts @@ -98,6 +98,23 @@ export class TutorialRunner { this._currentCommandProcess?.resize({ cols, rows }); } + createFolder(folderPath: string): void { + const previousLoadPromise = this._currentLoadTask?.promise; + + this._currentLoadTask = newTask( + async (signal) => { + await previousLoadPromise; + + const webcontainer = await this._webcontainer; + + signal.throwIfAborted(); + + await webcontainer.fs.mkdir(folderPath); + }, + { ignoreCancel: true }, + ); + } + /** * Update the content of a single file in WebContainer. * From 0d1d3e4be263cfc5622c73693407b10fb6587b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Tue, 10 Sep 2024 17:30:24 +0300 Subject: [PATCH 03/15] feat(runtime): new metadata option `editor.fileTree.allowEdits` --- packages/react/src/Panels/WorkspacePanel.tsx | 3 +- packages/runtime/src/store/editor.ts | 8 ++- packages/runtime/src/store/index.ts | 14 ++-- .../runtime/src/webcontainer/editor-config.ts | 70 +++++++++++++++++++ .../1-basics/1-introduction/2-foo/content.mdx | 3 + packages/types/src/schemas/common.ts | 38 +++++++--- 6 files changed, 117 insertions(+), 19 deletions(-) create mode 100644 packages/runtime/src/webcontainer/editor-config.ts diff --git a/packages/react/src/Panels/WorkspacePanel.tsx b/packages/react/src/Panels/WorkspacePanel.tsx index a6eb0e6c..2f52b2f0 100644 --- a/packages/react/src/Panels/WorkspacePanel.tsx +++ b/packages/react/src/Panels/WorkspacePanel.tsx @@ -98,6 +98,7 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { const selectedFile = useStore(tutorialStore.selectedFile); const currentDocument = useStore(tutorialStore.currentDocument); const lessonFullyLoaded = useStore(tutorialStore.lessonFullyLoaded); + const editorConfig = useStore(tutorialStore.editorConfig); const storeRef = useStore(tutorialStore.ref); const files = useStore(tutorialStore.files); @@ -159,7 +160,7 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { helpAction={helpAction} onHelpClick={lessonFullyLoaded ? onHelpClick : undefined} onFileSelect={(filePath) => tutorialStore.setSelectedFile(filePath)} - onFileTreeChange={onFileTreeChange} + onFileTreeChange={editorConfig.fileTree.allowEdits ? onFileTreeChange : undefined} selectedFile={selectedFile} onEditorScroll={(position) => tutorialStore.setCurrentDocumentScrollPosition(position)} onEditorChange={(update) => tutorialStore.setCurrentDocumentContent(update.content)} diff --git a/packages/runtime/src/store/editor.ts b/packages/runtime/src/store/editor.ts index 326fff4a..739ba194 100644 --- a/packages/runtime/src/store/editor.ts +++ b/packages/runtime/src/store/editor.ts @@ -1,5 +1,6 @@ -import type { FilesRefList, Files } from '@tutorialkit/types'; +import type { FilesRefList, Files, EditorSchema } from '@tutorialkit/types'; import { atom, map, computed } from 'nanostores'; +import { EditorConfig } from '../webcontainer/editor-config.js'; export interface EditorDocument { value: string | Uint8Array; @@ -16,6 +17,7 @@ export interface ScrollPosition { export type EditorDocuments = Record; export class EditorStore { + editorConfig = atom(new EditorConfig()); selectedFile = atom(); documents = map({}); @@ -28,6 +30,10 @@ export class EditorStore { return documents[selectedFile]; }); + setEditorConfig(config?: EditorSchema) { + this.editorConfig.set(new EditorConfig(config)); + } + setSelectedFile(filePath: string | undefined) { this.selectedFile.set(filePath); } diff --git a/packages/runtime/src/store/index.ts b/packages/runtime/src/store/index.ts index a5fa4e1f..b0e75ae4 100644 --- a/packages/runtime/src/store/index.ts +++ b/packages/runtime/src/store/index.ts @@ -8,6 +8,7 @@ import type { ITerminal } from '../utils/terminal.js'; import { bootStatus, unblockBoot, type BootStatus } from '../webcontainer/on-demand-boot.js'; import type { PreviewInfo } from '../webcontainer/preview-info.js'; import { StepsController } from '../webcontainer/steps.js'; +import type { EditorConfig } from '../webcontainer/editor-config.js'; import type { TerminalConfig } from '../webcontainer/terminal-config.js'; import { EditorStore, type EditorDocument, type EditorDocuments, type ScrollPosition } from './editor.js'; import { PreviewsStore } from './previews.js'; @@ -141,6 +142,7 @@ export class TutorialStore { this._previewsStore.setPreviews(lesson.data.previews ?? true); this._terminalStore.setTerminalConfiguration(lesson.data.terminal); + this._editorStore.setEditorConfig(lesson.data.editor); this._runner.setCommands(lesson.data); this._editorStore.setDocuments(lesson.files); @@ -195,6 +197,10 @@ export class TutorialStore { return this._terminalStore.terminalConfig; } + get editorConfig(): ReadableAtom { + return this._editorStore.editorConfig; + } + get currentDocument(): ReadableAtom { return this._editorStore.currentDocument; } @@ -243,9 +249,7 @@ export class TutorialStore { return false; } - const { editor } = this._lesson.data; - - return editor === undefined || editor === true || (editor !== false && editor?.fileTree !== false); + return this.editorConfig.get().fileTree.visible; } hasEditor(): boolean { @@ -253,9 +257,7 @@ export class TutorialStore { return false; } - const { editor } = this._lesson.data; - - return editor !== false; + return this.editorConfig.get().visible; } hasPreviews(): boolean { diff --git a/packages/runtime/src/webcontainer/editor-config.ts b/packages/runtime/src/webcontainer/editor-config.ts new file mode 100644 index 00000000..976a50c5 --- /dev/null +++ b/packages/runtime/src/webcontainer/editor-config.ts @@ -0,0 +1,70 @@ +import type { EditorSchema } from '@tutorialkit/types'; + +interface NormalizedEditorConfig { + /** Visibility of editor */ + visible: boolean; + + fileTree: { + /** Visibility of file tree */ + visible: boolean; + + /** Whether to allow file and folder editing in file tree */ + allowEdits: boolean; + }; +} + +export class EditorConfig { + private _config: NormalizedEditorConfig; + + constructor(config?: EditorSchema) { + this._config = normalizeEditorConfig(config); + } + + get visible() { + return this._config.visible; + } + + get fileTree() { + return this._config.fileTree; + } +} + +function normalizeEditorConfig(config?: EditorSchema): NormalizedEditorConfig { + if (config === false) { + return { + visible: false, + fileTree: { + visible: false, + allowEdits: false, + }, + }; + } + + if (config === undefined || config === true) { + return { + visible: true, + fileTree: { + visible: true, + allowEdits: false, + }, + }; + } + + if (typeof config.fileTree === 'boolean') { + return { + visible: true, + fileTree: { + visible: config.fileTree, + allowEdits: false, + }, + }; + } + + return { + visible: true, + fileTree: { + visible: true, + allowEdits: config.fileTree?.allowEdits || false, + }, + }; +} diff --git a/packages/template/src/content/tutorial/1-basics/1-introduction/2-foo/content.mdx b/packages/template/src/content/tutorial/1-basics/1-introduction/2-foo/content.mdx index 614725e1..dfb614e9 100644 --- a/packages/template/src/content/tutorial/1-basics/1-introduction/2-foo/content.mdx +++ b/packages/template/src/content/tutorial/1-basics/1-introduction/2-foo/content.mdx @@ -8,6 +8,9 @@ previews: - [1, 'Test Runner'] - '2/some/custom/pathname' - '2/another/pathname' +editor: + fileTree: + allowEdits: true terminal: panels: 'terminal' editPageLink: 'https://tutorialkit.dev' diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts index 98698db2..b1473cb8 100644 --- a/packages/types/src/schemas/common.ts +++ b/packages/types/src/schemas/common.ts @@ -161,8 +161,32 @@ export const terminalSchema = z.union([ }), ]); +export const editorSchema = z.union([ + // can either be completely removed by setting it to `false` + z.boolean().optional(), + + z.strictObject({ + fileTree: z + .union([ + // or you can only remove the file tree + z.boolean(), + + // or configure file tree with options + z.strictObject({ + allowEdits: z + .boolean() + .describe( + 'Allow file tree’s items to be edited by right clicking them. Supports file and folder creation.', + ), + }), + ]) + .optional(), + }), +]); + export type TerminalPanelType = z.infer; export type TerminalSchema = z.infer; +export type EditorSchema = z.infer; export const webcontainerSchema = commandsSchema.extend({ previews: previewSchema @@ -191,18 +215,10 @@ export const webcontainerSchema = commandsSchema.extend({ .string() .optional() .describe('Defines which file should be opened in the code editor by default when lesson loads.'), - editor: z - .union([ - // can either be completely removed by setting it to `false` - z.boolean().optional(), - - // or you can only remove the file tree - z.strictObject({ - fileTree: z.boolean().optional(), - }), - ]) + editor: editorSchema + .optional() .describe( - 'Configure whether or not the editor should be rendered. If an object is provided with fileTree: false, only the file tree is hidden.', + 'Configure whether or not the editor should be rendered. File tree can be configured by proving an object with fileTree option.', ), i18n: i18nSchema .optional() From 1b512d0c204fdccc9a624bc23544c6310ba41374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Tue, 10 Sep 2024 17:42:29 +0300 Subject: [PATCH 04/15] feat(react): `` to support file and folder editing --- .../_files/first-level/file.js | 1 + .../_files/first-level/second-level/file.js | 1 + .../file-tree/allow-edits-disabled/content.md | 11 + .../_files/first-level/file.js | 1 + .../_files/first-level/second-level/file.js | 1 + .../file-tree/allow-edits-enabled/content.md | 12 + .../tests/file-tree/hidden/_files/example.js | 1 + .../tests/file-tree/hidden/content.md | 9 + e2e/test/file-tree.test.ts | 98 +++++ .../utils/content/default-localization.ts | 4 +- packages/react/package.json | 1 + packages/react/src/Panels/EditorPanel.tsx | 1 + packages/react/src/core/ContextMenu.tsx | 132 ++++++ packages/react/src/core/FileTree.tsx | 86 ++-- packages/types/src/schemas/i18n.ts | 20 + pnpm-lock.yaml | 414 ++++++++++++++++++ 16 files changed, 762 insertions(+), 31 deletions(-) create mode 100644 e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_files/first-level/file.js create mode 100644 e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_files/first-level/second-level/file.js create mode 100644 e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/content.md create mode 100644 e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_files/first-level/file.js create mode 100644 e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_files/first-level/second-level/file.js create mode 100644 e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/content.md create mode 100644 e2e/src/content/tutorial/tests/file-tree/hidden/_files/example.js create mode 100644 e2e/src/content/tutorial/tests/file-tree/hidden/content.md create mode 100644 packages/react/src/core/ContextMenu.tsx diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_files/first-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_files/first-level/file.js new file mode 100644 index 00000000..7e6506ff --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_files/first-level/file.js @@ -0,0 +1 @@ +export default 'File in first level'; diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_files/first-level/second-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_files/first-level/second-level/file.js new file mode 100644 index 00000000..6c549d4e --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_files/first-level/second-level/file.js @@ -0,0 +1 @@ +export default 'File in second level'; diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/content.md b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/content.md new file mode 100644 index 00000000..bdd99792 --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/content.md @@ -0,0 +1,11 @@ +--- +type: lesson +title: Allow Edits Disabled +previews: false +terminal: + panels: terminal +--- + +# File Tree test - Allow Edits Disabled + +Option `editor.fileTree.allowEdits` has default `false` value. diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_files/first-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_files/first-level/file.js new file mode 100644 index 00000000..7e6506ff --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_files/first-level/file.js @@ -0,0 +1 @@ +export default 'File in first level'; diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_files/first-level/second-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_files/first-level/second-level/file.js new file mode 100644 index 00000000..6c549d4e --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_files/first-level/second-level/file.js @@ -0,0 +1 @@ +export default 'File in second level'; diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/content.md b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/content.md new file mode 100644 index 00000000..b45c7cb4 --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/content.md @@ -0,0 +1,12 @@ +--- +type: lesson +title: Allow Edits Enabled +previews: false +editor: + fileTree: + allowEdits: true +terminal: + panels: terminal +--- + +# File Tree test - Allow Edits Enabled diff --git a/e2e/src/content/tutorial/tests/file-tree/hidden/_files/example.js b/e2e/src/content/tutorial/tests/file-tree/hidden/_files/example.js new file mode 100644 index 00000000..cd356077 --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/hidden/_files/example.js @@ -0,0 +1 @@ +export default 'Lesson file example.js content'; diff --git a/e2e/src/content/tutorial/tests/file-tree/hidden/content.md b/e2e/src/content/tutorial/tests/file-tree/hidden/content.md new file mode 100644 index 00000000..95888143 --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/hidden/content.md @@ -0,0 +1,9 @@ +--- +type: lesson +title: Hidden +editor: + fileTree: false +focus: /example.js +--- + +# File Tree test - Hidden diff --git a/e2e/test/file-tree.test.ts b/e2e/test/file-tree.test.ts index 2f062049..ef935215 100644 --- a/e2e/test/file-tree.test.ts +++ b/e2e/test/file-tree.test.ts @@ -57,3 +57,101 @@ test('user can see cannot click solve on lessons without solution files', async // reset-button should be immediately visible await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible(); }); + +// TODO: Requires #245 +test.skip('user should not see hidden file tree', async ({ page }) => { + await page.goto(`${BASE_URL}/hidden`); + await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Hidden' })).toBeVisible(); + + await expect(page.getByText('Files')).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'example.js' })).not.toBeVisible(); +}); + +test('user cannot create files or folders when lesson is not configured via allowEdits', async ({ page }) => { + await page.goto(`${BASE_URL}/allow-edits-disabled`); + + await expect(page.getByTestId('file-tree-root-context-menu')).not.toBeVisible(); + + await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' }); + await expect(page.getByRole('menuitem', { name: 'Create file' })).not.toBeVisible(); +}); + +test('user can create files', async ({ page }) => { + await page.goto(`${BASE_URL}/allow-edits-enabled`); + await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Enabled' })).toBeVisible(); + + // wait for terminal to start + const terminal = page.getByRole('textbox', { name: 'Terminal input' }); + const terminalOutput = page.getByRole('tabpanel', { name: 'Terminal' }); + await expect(terminalOutput).toContainText('~/tutorial', { useInnerText: true }); + + for (const [locator, filename] of [ + [page.getByTestId('file-tree-root-context-menu'), 'file-in-root.js'], + [page.getByRole('button', { name: 'first-level' }), 'file-in-first-level.js'], + [page.getByRole('button', { name: 'second-level' }), 'file-in-second-level.js'], + ] as const) { + await locator.click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Create file' }).click(); + + await page.locator('*:focus').fill(filename); + await page.locator('*:focus').press('Enter'); + await expect(page.getByRole('button', { name: filename, pressed: true })).toBeVisible(); + } + + // verify that all files are present on file tree after last creation + await expect(page.getByRole('button', { name: 'file-in-root.js' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'file-in-first-level' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'file-in-second-level' })).toBeVisible(); + + // verify that files are present on file system via terminal + for (const [directory, filename] of [ + ['./', 'file-in-root.js'], + ['./first-level', 'file-in-first-level.js'], + ['./first-level/second-level', 'file-in-second-level.js'], + ]) { + await terminal.fill(`clear; ls ${directory}`); + await terminal.press('Enter'); + + await expect(terminalOutput).toContainText(filename, { useInnerText: true }); + } +}); + +test('user can create folders', async ({ page }) => { + await page.goto(`${BASE_URL}/allow-edits-enabled`); + await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Enabled' })).toBeVisible(); + + // wait for terminal to start + const terminal = page.getByRole('textbox', { name: 'Terminal input' }); + const terminalOutput = page.getByRole('tabpanel', { name: 'Terminal' }); + await expect(terminalOutput).toContainText('~/tutorial', { useInnerText: true }); + + for (const [locator, folder] of [ + [page.getByTestId('file-tree-root-context-menu'), 'folder-1'], + [page.getByRole('button', { name: 'folder-1' }), 'folder-2'], + [page.getByRole('button', { name: 'folder-2' }), 'folder-3'], + ] as const) { + await locator.click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Create folder' }).click(); + + await page.locator('*:focus').fill(folder); + await page.locator('*:focus').press('Enter'); + await expect(page.getByRole('button', { name: folder })).toBeVisible(); + } + + // verify that all folders are present on file tree after last creation + await expect(page.getByRole('button', { name: 'folder-1' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'folder-2' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'folder-3' })).toBeVisible(); + + // verify that files are present on file system via terminal + for (const [directory, folder] of [ + ['./', 'folder-1'], + ['./folder-1', 'folder-2'], + ['./folder-1/folder-2', 'folder-3'], + ]) { + await terminal.fill(`clear; ls ${directory}`); + await terminal.press('Enter'); + + await expect(terminalOutput).toContainText(folder, { useInnerText: true }); + } +}); diff --git a/packages/astro/src/default/utils/content/default-localization.ts b/packages/astro/src/default/utils/content/default-localization.ts index 427a0f8d..a5cdc8c7 100644 --- a/packages/astro/src/default/utils/content/default-localization.ts +++ b/packages/astro/src/default/utils/content/default-localization.ts @@ -7,10 +7,12 @@ export const DEFAULT_LOCALIZATION = { editPageText: 'Edit this page', webcontainerLinkText: 'Powered by WebContainers', filesTitleText: 'Files', + fileTreeCreateFileText: 'Create file', + fileTreeCreateFolderText: 'Create folder', prepareEnvironmentTitleText: 'Preparing Environment', defaultPreviewTitleText: 'Preview', reloadPreviewTitle: 'Reload Preview', toggleTerminalButtonText: 'Toggle Terminal', solveButtonText: 'Solve', resetButtonText: 'Reset', -} satisfies Lesson['data']['i18n']; +} satisfies Required; diff --git a/packages/react/package.json b/packages/react/package.json index b4e50345..03d74458 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -74,6 +74,7 @@ "@lezer/lr": "^1.0.0", "@nanostores/react": "0.7.2", "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-context-menu": "^2.2.1", "@replit/codemirror-lang-svelte": "^6.0.0", "@tutorialkit/runtime": "workspace:*", "@tutorialkit/theme": "workspace:*", diff --git a/packages/react/src/Panels/EditorPanel.tsx b/packages/react/src/Panels/EditorPanel.tsx index 0e535d30..28aff0e8 100644 --- a/packages/react/src/Panels/EditorPanel.tsx +++ b/packages/react/src/Panels/EditorPanel.tsx @@ -78,6 +78,7 @@ export function EditorPanel({ { + /** Callback invoked when file is changed. */ + onFileChange?: (event: FileChangeEvent | FileRenameEvent) => void; + + /** Directory of the clicked file. */ + directory: string; + + /** Whether to render new files/directories before or after the trigger element. Defaults to `'before'`. */ + position?: 'before' | 'after'; + + /** Localized texts for menu. */ + i18n?: Pick; + + /** Props for trigger wrapper. */ + triggerProps?: ComponentProps<'div'> & { 'data-testid'?: string }; +} + +export function ContextMenu({ + onFileChange, + directory, + i18n, + position = 'before', + children, + triggerProps, + ...props +}: Props) { + const [state, setState] = useState<'IDLE' | 'ADD_FILE' | 'ADD_FOLDER'>('IDLE'); + const inputRef = useRef(null); + + if (!onFileChange) { + return children; + } + + function onFileNameEnd(event: React.KeyboardEvent | React.FocusEvent) { + const name = event.currentTarget.value; + const isFile = state === 'ADD_FILE'; + + // files must contain extension + if (name && (!isFile || name.includes('.'))) { + onFileChange?.({ + value: `${directory}/${name}`, + type: isFile ? 'FILE' : 'FOLDER', + method: 'ADD', + }); + } + + setState('IDLE'); + } + + function onFileNameKeyPress(event: React.KeyboardEvent) { + if (event.key === 'Enter' && event.currentTarget.value !== '') { + onFileNameEnd(event); + } + } + + function onCloseAutoFocus(event: Event) { + if ((state === 'ADD_FILE' || state === 'ADD_FOLDER') && inputRef.current) { + event.preventDefault(); + inputRef.current.focus(); + } + } + + const element = ( + +
{children}
+
+ ); + + return ( + + {position === 'before' && element} + + {state !== 'IDLE' && ( +
+
+ +
+ )} + + {position === 'after' && element} + + + + setState('ADD_FILE')}> + {i18n?.fileTreeCreateFileText || 'Create file'} + + + setState('ADD_FOLDER')}> + {i18n?.fileTreeCreateFolderText || 'Create folder'} + + + + + ); +} + +function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProps) { + return ( + + + {children} + + ); +} diff --git a/packages/react/src/core/FileTree.tsx b/packages/react/src/core/FileTree.tsx index ce2f7ecb..9d5232a3 100644 --- a/packages/react/src/core/FileTree.tsx +++ b/packages/react/src/core/FileTree.tsx @@ -1,27 +1,33 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type ComponentProps, type ReactNode } from 'react'; +import { ContextMenu } from './ContextMenu.js'; import { classNames } from '../utils/classnames.js'; const NODE_PADDING_LEFT = 12; const DEFAULT_HIDDEN_FILES = [/\/node_modules\//]; -interface FileChangeEvent { - type: 'FILE' | 'FOLDER'; - method: 'ADD' | 'REMOVE' | 'RENAME'; - value: string; -} - interface Props { files: string[]; selectedFile?: string; onFileSelect?: (filePath: string) => void; - onFileChange?: (event: FileChangeEvent) => void; + onFileChange?: ComponentProps['onFileChange']; + i18n?: ComponentProps['i18n']; hideRoot: boolean; scope?: string; hiddenFiles?: Array; className?: string; } -export function FileTree({ files, onFileSelect, selectedFile, hideRoot, scope, hiddenFiles, className }: Props) { +export function FileTree({ + files, + onFileSelect, + onFileChange, + selectedFile, + hideRoot, + scope, + hiddenFiles, + i18n, + className, +}: Props) { const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]); const fileList = useMemo( @@ -80,7 +86,7 @@ export function FileTree({ files, onFileSelect, selectedFile, hideRoot, scope, h } return ( -
+
{filteredFileList.map((fileOrFolder) => { switch (fileOrFolder.kind) { case 'file': { @@ -98,13 +104,24 @@ export function FileTree({ files, onFileSelect, selectedFile, hideRoot, scope, h toggleCollapseState(fileOrFolder.id)} + onFileChange={onFileChange} /> ); } } })} + +
); } @@ -115,24 +132,28 @@ interface FolderProps { folder: FolderNode; collapsed: boolean; onClick: () => void; + onFileChange: Props['onFileChange']; + i18n: Props['i18n']; } -function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) { +function Folder({ folder: { depth, name, fullPath }, i18n, collapsed, onClick, onFileChange }: FolderProps) { return ( - - {name} - + + + {name} + + ); } @@ -177,8 +198,8 @@ function NodeButton({ depth, iconClasses, onClick, className, 'aria-pressed': ar return (