diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx
index 4884dce8..33e8ba6e 100644
--- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx
+++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx
@@ -84,6 +84,34 @@ type I18nText = {
*/
filesTitleText?: string,
+ /**
+ * Text shown on file tree's context menu's file creation button.
+ *
+ * @default 'Create file'
+ */
+ fileTreeCreateFileText?: string,
+
+ /**
+ * Text shown on file tree's context menu's folder creation button.
+ *
+ * @default 'Create folder'
+ */
+ fileTreeCreateFolderText?: string,
+
+ /**
+ * Text shown on dialog when user attempts to edit files that don't match allowed patterns.
+ *
+ * @default 'This action is not allowed'
+ */
+ fileTreeActionNotAllowed?: string,
+
+ /**
+ * Text shown on dialog describing allowed patterns when file or folder createion failed.
+ *
+ * @default 'Created files and folders must match following patterns:'
+ */
+ fileTreeAllowedPatternsText?: string,
+
/**
* Text shown on top of the steps section.
*
@@ -144,9 +172,11 @@ File tree can be set to allow file editing from right clicks by setting `fileTre
The `Editor` type has the following shape:
```ts
+type GlobPattern = string
+
type Editor =
| false
- | { editor: { allowEdits: boolean } }
+ | { editor: { allowEdits: boolean | GlobPattern | GlobPattern[] } }
```
@@ -161,6 +191,18 @@ editor: # Editor is visible
editor: # Editor is visible
fileTree: # File tree is visible
allowEdits: true # User can add new files and folders from the file tree
+
+
+editor: # Editor is visible
+ fileTree: # File tree is visible
+ allowEdits: "/src/**" # User can add files and folders anywhere inside "/src/"
+
+editor: # Editor is visible
+ fileTree: # File tree is visible
+ allowEdits:
+ - "/*" # User can add files and folders directly in the root
+ - "/first-level/allowed-filename-only.js" # Only "allowed-filename-only.js" inside "/first-level" folder
+ - "**/second-level/**" # Anything inside "second-level" folders anywhere
```
##### `previews`
diff --git a/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx b/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx
index e7042d82..a659578c 100644
--- a/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx
+++ b/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx
@@ -116,6 +116,8 @@ A component to list files in a tree view.
}
```
+* `allowEditPatterns?: string[]` - Glob patterns for paths that allow editing files and folders. Disabled by default.
+
* `hideRoot: boolean` - Whether or not to hide the root directory in the tree. Defaults to `false`.
* `hiddenFiles: (string | RegExp)[]` - A list of file paths that should be hidden from the tree.
diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_files/first-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_files/first-level/file.js
new file mode 100644
index 00000000..7e6506ff
--- /dev/null
+++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_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-glob/_files/first-level/second-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/_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-glob/_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-glob/content.md b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/content.md
new file mode 100644
index 00000000..36d1b764
--- /dev/null
+++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-glob/content.md
@@ -0,0 +1,18 @@
+---
+type: lesson
+title: Allow Edits Glob
+previews: false
+editor:
+ fileTree:
+ allowEdits:
+ # Items in root
+ - "/*"
+ # Only "allowed-filename-only.js" inside "/first-level" folder
+ - "/first-level/allowed-filename-only.js"
+ # Anything inside "second-level" folders anywhere
+ - "**/second-level/**"
+terminal:
+ panels: terminal
+---
+
+# File Tree test - Allow Edits Glob
diff --git a/e2e/test/file-tree.test.ts b/e2e/test/file-tree.test.ts
index ef935215..72c3bc51 100644
--- a/e2e/test/file-tree.test.ts
+++ b/e2e/test/file-tree.test.ts
@@ -155,3 +155,70 @@ test('user can create folders', async ({ page }) => {
await expect(terminalOutput).toContainText(folder, { useInnerText: true });
}
});
+
+test('user can create files and folders in allowed directories', async ({ page }) => {
+ await page.goto(`${BASE_URL}/allow-edits-glob`);
+ await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Glob' })).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 });
+
+ // can create files in root and inside "second-level"
+ for (const [locator, name, type] of [
+ [page.getByTestId('file-tree-root-context-menu'), 'new-file.js', 'file'],
+ [page.getByRole('button', { name: 'second-level' }), 'new-folder.js', 'folder'],
+ ] as const) {
+ await locator.click({ button: 'right' });
+ await page.getByRole('menuitem', { name: `Create ${type}` }).click();
+
+ await page.locator('*:focus').fill(name);
+ await page.locator('*:focus').press('Enter');
+ await expect(page.getByRole('button', { name })).toBeVisible();
+ }
+
+ await expect(page.getByRole('button', { name: 'new-file.js' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'new-folder' })).toBeVisible();
+
+ // verify that files are present on file system via terminal
+ for (const [directory, folder] of [
+ ['./', 'new-file.js'],
+ ['./first-level/second-level/', 'new-folder'],
+ ]) {
+ await terminal.fill(`clear; ls ${directory}`);
+ await terminal.press('Enter');
+
+ await expect(terminalOutput).toContainText(folder, { useInnerText: true });
+ }
+});
+
+test('user cannot create files or folders in disallowed directories', async ({ page }) => {
+ await page.goto(`${BASE_URL}/allow-edits-glob`);
+ await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Glob' })).toBeVisible();
+
+ // wait for terminal to start
+ const terminalOutput = page.getByRole('tabpanel', { name: 'Terminal' });
+ await expect(terminalOutput).toContainText('~/tutorial', { useInnerText: true });
+
+ for (const [name, type] of [
+ ['new-file.js', 'file'],
+ ['new-folder', 'folder'],
+ ] as const) {
+ await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' });
+ await page.getByRole('menuitem', { name: `Create ${type}` }).click();
+
+ await page.locator('*:focus').fill(name);
+ await page.locator('*:focus').press('Enter');
+
+ const dialog = page.getByRole('dialog', { name: 'This action is not allowed' });
+
+ await expect(dialog.getByText('Created files and folders must match following patterns:')).toBeVisible();
+ await expect(dialog.getByRole('listitem').nth(0)).toHaveText('/*');
+ await expect(dialog.getByRole('listitem').nth(1)).toHaveText('/first-level/allowed-filename-only.js');
+ await expect(dialog.getByRole('listitem').nth(2)).toHaveText('**/second-level/**');
+
+ await dialog.getByRole('button', { name: 'OK' }).click();
+ await expect(dialog).not.toBeVisible();
+ }
+});
diff --git a/e2e/uno.config.ts b/e2e/uno.config.ts
index 949120d9..74c8497f 100644
--- a/e2e/uno.config.ts
+++ b/e2e/uno.config.ts
@@ -1,5 +1,10 @@
import { defineConfig } from '@tutorialkit/theme';
export default defineConfig({
- // add your UnoCSS config here: https://unocss.dev/guide/config-file
+ // required for TutorialKit monorepo development mode
+ content: {
+ pipeline: {
+ include: '**',
+ },
+ },
});
diff --git a/packages/astro/src/default/components/LoginButton.tsx b/packages/astro/src/default/components/LoginButton.tsx
index 24870297..6745f72c 100644
--- a/packages/astro/src/default/components/LoginButton.tsx
+++ b/packages/astro/src/default/components/LoginButton.tsx
@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react';
-import { classNames } from '@tutorialkit/react';
+import { Button } from '@tutorialkit/react';
import { useEffect, useRef, useState } from 'react';
import { authStore } from '../stores/auth-store';
import { login, logout } from './webcontainer';
@@ -48,21 +48,8 @@ export function LoginButton() {
}, [authStatus.status]);
return (
-
);
}
diff --git a/packages/astro/src/default/styles/variables.css b/packages/astro/src/default/styles/variables.css
index f748dc0a..fffe6dbc 100644
--- a/packages/astro/src/default/styles/variables.css
+++ b/packages/astro/src/default/styles/variables.css
@@ -142,6 +142,22 @@
--tk-elements-link-secondaryColor: var(--tk-text-secondary);
--tk-elements-link-secondaryColorHover: var(--tk-text-primary);
+ /* Primary Button */
+ --tk-elements-primaryButton-backgroundColor: var(--tk-background-accent-secondary);
+ --tk-elements-primaryButton-backgroundColorHover: var(--tk-background-accent-active);
+ --tk-elements-primaryButton-textColor: var(--tk-text-primary-inverted);
+ --tk-elements-primaryButton-textColorHover: var(--tk-text-primary-inverted);
+ --tk-elements-primaryButton-iconColor: var(--tk-text-primary-inverted);
+ --tk-elements-primaryButton-iconColorHover: var(--tk-text-primary-inverted);
+
+ /* Secondary Button */
+ --tk-elements-secondaryButton-backgroundColor: var(--tk-elements-app-backgroundColor);
+ --tk-elements-secondaryButton-backgroundColorHover: var(--tk-background-secondary);
+ --tk-elements-secondaryButton-textColor: var(--tk-text-secondary);
+ --tk-elements-secondaryButton-textColorHover: var(--tk-text-primary);
+ --tk-elements-secondaryButton-iconColor: var(--tk-text-secondary);
+ --tk-elements-secondaryButton-iconColorHover: var(--tk-text-primary);
+
/* Content */
--tk-elements-content-textColor: var(--tk-text-body);
--tk-elements-content-headingTextColor: var(--tk-text-primary);
@@ -163,22 +179,6 @@
--tk-elements-topBar-logo-color: var(--tk-text-active);
--tk-elements-topBar-logo-colorHover: var(--tk-text-active);
- /* Top Bar > Primary Button */
- --tk-elements-topBar-primaryButton-backgroundColor: var(--tk-background-accent-secondary);
- --tk-elements-topBar-primaryButton-backgroundColorHover: var(--tk-background-accent-active);
- --tk-elements-topBar-primaryButton-textColor: var(--tk-text-primary-inverted);
- --tk-elements-topBar-primaryButton-textColorHover: var(--tk-text-primary-inverted);
- --tk-elements-topBar-primaryButton-iconColor: var(--tk-text-primary-inverted);
- --tk-elements-topBar-primaryButton-iconColorHover: var(--tk-text-primary-inverted);
-
- /* Top Bar > Secondary Button */
- --tk-elements-topBar-secondaryButton-backgroundColor: var(--tk-elements-topBar-backgroundColor);
- --tk-elements-topBar-secondaryButton-backgroundColorHover: var(--tk-background-secondary);
- --tk-elements-topBar-secondaryButton-textColor: var(--tk-text-secondary);
- --tk-elements-topBar-secondaryButton-textColorHover: var(--tk-text-primary);
- --tk-elements-topBar-secondaryButton-iconColor: var(--tk-text-secondary);
- --tk-elements-topBar-secondaryButton-iconColorHover: var(--tk-text-primary);
-
/* Previews */
--tk-elements-previews-borderColor: theme('colors.gray.200');
diff --git a/packages/astro/src/default/utils/content.ts b/packages/astro/src/default/utils/content.ts
index 4f879bd6..0ad2103b 100644
--- a/packages/astro/src/default/utils/content.ts
+++ b/packages/astro/src/default/utils/content.ts
@@ -1,8 +1,7 @@
import path from 'node:path';
import type { ChapterSchema, Lesson, LessonSchema, PartSchema, Tutorial, TutorialSchema } from '@tutorialkit/types';
-import { interpolateString } from '@tutorialkit/types';
+import { interpolateString, DEFAULT_LOCALIZATION } from '@tutorialkit/types';
import { getCollection } from 'astro:content';
-import { DEFAULT_LOCALIZATION } from './content/default-localization';
import { getFilesRefList } from './content/files-ref';
import { squash } from './content/squash.js';
import { logger } from './logger';
diff --git a/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap b/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap
index 67e94228..62b060f9 100644
--- a/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap
+++ b/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap
@@ -304,7 +304,6 @@ exports[`create and eject a project 1`] = `
"src/utils/constants.ts",
"src/utils/content",
"src/utils/content.ts",
- "src/utils/content/default-localization.ts",
"src/utils/content/files-ref.ts",
"src/utils/content/squash.ts",
"src/utils/logger.ts",
diff --git a/packages/react/package.json b/packages/react/package.json
index 80825087..0d5b8dda 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -75,6 +75,7 @@
"@nanostores/react": "0.7.2",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-context-menu": "^2.2.1",
+ "@radix-ui/react-dialog": "^1.1.1",
"@replit/codemirror-lang-svelte": "^6.0.0",
"@tutorialkit/runtime": "workspace:*",
"@tutorialkit/theme": "workspace:*",
@@ -85,12 +86,14 @@
"codemirror": "^6.0.1",
"framer-motion": "^11.2.11",
"nanostores": "^0.10.3",
+ "picomatch": "^4.0.2",
"react": "^18.3.1",
"react-resizable-panels": "^2.0.19"
},
"devDependencies": {
"@codemirror/search": "^6.5.6",
"@tutorialkit/types": "workspace:*",
+ "@types/picomatch": "^3.0.1",
"@types/react": "^18.3.3",
"chokidar": "3.6.0",
"execa": "^9.2.0",
diff --git a/packages/react/src/Button.tsx b/packages/react/src/Button.tsx
new file mode 100644
index 00000000..06b08a4b
--- /dev/null
+++ b/packages/react/src/Button.tsx
@@ -0,0 +1,32 @@
+import { type ComponentProps, forwardRef, type Ref } from 'react';
+import { classNames } from './utils/classnames.js';
+
+interface Props extends ComponentProps<'button'> {
+ variant?: 'primary' | 'secondary';
+}
+
+export const Button = forwardRef(({ className, variant = 'primary', ...props }: Props, ref: Ref) => {
+ return (
+
+ );
+});
diff --git a/packages/react/src/Panels/EditorPanel.tsx b/packages/react/src/Panels/EditorPanel.tsx
index 4447182e..789349b3 100644
--- a/packages/react/src/Panels/EditorPanel.tsx
+++ b/packages/react/src/Panels/EditorPanel.tsx
@@ -25,6 +25,7 @@ interface Props {
helpAction?: 'solve' | 'reset';
editorDocument?: EditorDocument;
selectedFile?: string | undefined;
+ allowEditPatterns?: ComponentProps['allowEditPatterns'];
onEditorChange?: OnEditorChange;
onEditorScroll?: OnEditorScroll;
onHelpClick?: () => void;
@@ -43,6 +44,7 @@ export function EditorPanel({
helpAction,
editorDocument,
selectedFile,
+ allowEditPatterns,
onEditorChange,
onEditorScroll,
onHelpClick,
@@ -83,6 +85,7 @@ export function EditorPanel({
hideRoot={hideRoot ?? true}
files={files}
scope={fileTreeScope}
+ allowEditPatterns={allowEditPatterns}
onFileSelect={onFileSelect}
onFileChange={onFileTreeChange}
/>
diff --git a/packages/react/src/Panels/WorkspacePanel.tsx b/packages/react/src/Panels/WorkspacePanel.tsx
index fbe27323..9546fcfa 100644
--- a/packages/react/src/Panels/WorkspacePanel.tsx
+++ b/packages/react/src/Panels/WorkspacePanel.tsx
@@ -160,7 +160,8 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) {
helpAction={helpAction}
onHelpClick={lessonFullyLoaded ? onHelpClick : undefined}
onFileSelect={(filePath) => tutorialStore.setSelectedFile(filePath)}
- onFileTreeChange={editorConfig.fileTree.allowEdits ? onFileTreeChange : undefined}
+ onFileTreeChange={onFileTreeChange}
+ allowEditPatterns={editorConfig.fileTree.allowEdits || undefined}
selectedFile={selectedFile}
onEditorScroll={(position) => tutorialStore.setCurrentDocumentScrollPosition(position)}
onEditorChange={(update) => tutorialStore.setCurrentDocumentContent(update.content)}
diff --git a/packages/react/src/core/ContextMenu.tsx b/packages/react/src/core/ContextMenu.tsx
index c556dd8f..0b70004a 100644
--- a/packages/react/src/core/ContextMenu.tsx
+++ b/packages/react/src/core/ContextMenu.tsx
@@ -1,6 +1,10 @@
import { Root, Portal, Content, Item, Trigger } from '@radix-ui/react-context-menu';
-import type { FileDescriptor, I18n } from '@tutorialkit/types';
-import { useRef, useState, type ComponentProps } from 'react';
+import * as RadixDialog from '@radix-ui/react-dialog';
+import { DEFAULT_LOCALIZATION, type FileDescriptor, type I18n } from '@tutorialkit/types';
+import picomatch from 'picomatch/posix';
+import { useRef, useState, type ComponentProps, type ReactNode } from 'react';
+import { Button } from '../Button.js';
+import { classNames } from '../utils/classnames.js';
interface FileChangeEvent {
type: FileDescriptor['type'];
@@ -17,6 +21,9 @@ interface Props extends ComponentProps<'div'> {
/** Callback invoked when file is changed. */
onFileChange?: (event: FileChangeEvent | FileRenameEvent) => void;
+ /** Glob patterns for paths that allow editing files and folders. Disabled by default. */
+ allowEditPatterns?: string[];
+
/** Directory of the clicked file. */
directory: string;
@@ -24,7 +31,14 @@ interface Props extends ComponentProps<'div'> {
position?: 'before' | 'after';
/** Localized texts for menu. */
- i18n?: Pick;
+ i18n?: Pick<
+ I18n,
+ | 'fileTreeCreateFileText'
+ | 'fileTreeCreateFolderText'
+ | 'fileTreeActionNotAllowedText'
+ | 'fileTreeAllowedPatternsText'
+ | 'confirmationText'
+ >;
/** Props for trigger wrapper. */
triggerProps?: ComponentProps<'div'> & { 'data-testid'?: string };
@@ -32,6 +46,7 @@ interface Props extends ComponentProps<'div'> {
export function ContextMenu({
onFileChange,
+ allowEditPatterns,
directory,
i18n,
position = 'before',
@@ -39,22 +54,33 @@ export function ContextMenu({
triggerProps,
...props
}: Props) {
- const [state, setState] = useState<'idle' | 'add_file' | 'add_folder'>('idle');
+ const [state, setState] = useState<'idle' | 'add_file' | 'add_folder' | 'add_failed'>('idle');
const inputRef = useRef(null);
- if (!onFileChange) {
+ if (!allowEditPatterns?.length) {
return children;
}
function onFileNameEnd(event: React.KeyboardEvent | React.FocusEvent) {
+ if (state !== 'add_file' && state !== 'add_folder') {
+ return;
+ }
+
const name = event.currentTarget.value;
if (name) {
- onFileChange?.({
- value: `${directory}/${name}`,
- type: state === 'add_file' ? 'file' : 'folder',
- method: 'add',
- });
+ const value = `${directory}/${name}`;
+ const isAllowed = picomatch.isMatch(value, allowEditPatterns!);
+
+ if (isAllowed) {
+ onFileChange?.({
+ value,
+ type: state === 'add_file' ? 'file' : 'folder',
+ method: 'add',
+ });
+ } else {
+ return setState('add_failed');
+ }
}
setState('idle');
@@ -105,14 +131,31 @@ export function ContextMenu({
className="border border-tk-border-brighter b-rounded-md bg-tk-background-brighter py-2"
>
+
+ {state === 'add_failed' && (
+
+ )}
);
}
@@ -128,3 +171,35 @@ function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProp
);
}
+
+function Dialog({
+ title,
+ confirmText,
+ onClose,
+ children,
+}: {
+ title: string;
+ confirmText: string;
+ onClose: () => void;
+ children: ReactNode;
+}) {
+ return (
+ !open && onClose()}>
+
+
+
+
+
+
{title}
+
+
{children}
+
+
+ {confirmText}
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/core/FileTree.tsx b/packages/react/src/core/FileTree.tsx
index c010202f..90d084dc 100644
--- a/packages/react/src/core/FileTree.tsx
+++ b/packages/react/src/core/FileTree.tsx
@@ -11,6 +11,7 @@ interface Props {
selectedFile?: string;
onFileSelect?: (filePath: string) => void;
onFileChange?: ComponentProps['onFileChange'];
+ allowEditPatterns?: ComponentProps['allowEditPatterns'];
i18n?: ComponentProps['i18n'];
hideRoot: boolean;
scope?: string;
@@ -22,6 +23,7 @@ export function FileTree({
files,
onFileSelect,
onFileChange,
+ allowEditPatterns,
selectedFile,
hideRoot,
scope,
@@ -109,6 +111,7 @@ export function FileTree({
collapsed={collapsedFolders.has(fileOrFolder.id)}
onClick={() => toggleCollapseState(fileOrFolder.id)}
onFileChange={onFileChange}
+ allowEditPatterns={allowEditPatterns}
/>
);
}
@@ -121,6 +124,7 @@ export function FileTree({
style={getDepthStyle(0)}
directory=""
onFileChange={onFileChange}
+ allowEditPatterns={allowEditPatterns}
triggerProps={{ className: 'h-full', 'data-testid': 'file-tree-root-context-menu' }}
/>
@@ -134,12 +138,26 @@ interface FolderProps {
collapsed: boolean;
onClick: () => void;
onFileChange: Props['onFileChange'];
+ allowEditPatterns: Props['allowEditPatterns'];
i18n: Props['i18n'];
}
-function Folder({ folder: { depth, name, fullPath }, i18n, collapsed, onClick, onFileChange }: FolderProps) {
+function Folder({
+ folder: { depth, name, fullPath },
+ i18n,
+ collapsed,
+ onClick,
+ onFileChange,
+ allowEditPatterns,
+}: FolderProps) {
return (
-
+
| ClassNamesArg[];
+type ClassNamesArg = undefined | false | string | Record | ClassNamesArg[];
/**
* A simple JavaScript utility for conditionally joining classNames together.
diff --git a/packages/runtime/src/webcontainer/editor-config.spec.ts b/packages/runtime/src/webcontainer/editor-config.spec.ts
new file mode 100644
index 00000000..34cdd2a1
--- /dev/null
+++ b/packages/runtime/src/webcontainer/editor-config.spec.ts
@@ -0,0 +1,125 @@
+import { expect, test } from 'vitest';
+import { EditorConfig } from './editor-config.js';
+
+expect.addSnapshotSerializer({
+ serialize: (val: EditorConfig) => JSON.stringify({ visible: val.visible, fileTree: val.fileTree }, null, 2),
+ test: (val) => val instanceof EditorConfig,
+});
+
+test('sets default values', () => {
+ const config = new EditorConfig();
+
+ expect(config).toMatchInlineSnapshot(`
+ {
+ "visible": true,
+ "fileTree": {
+ "visible": true,
+ "allowEdits": false
+ }
+ }
+ `);
+});
+
+test('false hides editor', () => {
+ const config = new EditorConfig(false);
+
+ expect(config).toMatchInlineSnapshot(`
+ {
+ "visible": false,
+ "fileTree": {
+ "visible": false,
+ "allowEdits": false
+ }
+ }
+ `);
+});
+
+test('true enables editor, doesnt allow editing', () => {
+ const config = new EditorConfig(true);
+
+ expect(config).toMatchInlineSnapshot(`
+ {
+ "visible": true,
+ "fileTree": {
+ "visible": true,
+ "allowEdits": false
+ }
+ }
+ `);
+});
+
+test('"fileTree: false" hides fileTree', () => {
+ const config = new EditorConfig({ fileTree: false });
+
+ expect(config).toMatchInlineSnapshot(`
+ {
+ "visible": true,
+ "fileTree": {
+ "visible": false,
+ "allowEdits": false
+ }
+ }
+ `);
+});
+
+test('"fileTree: true" enables fileTree, disables editing', () => {
+ const config = new EditorConfig({ fileTree: true });
+
+ expect(config.fileTree).toMatchInlineSnapshot(`
+ {
+ "allowEdits": false,
+ "visible": true,
+ }
+ `);
+});
+
+test('"fileTree.allowEdits: true" allows editing all entries', () => {
+ const config = new EditorConfig({ fileTree: { allowEdits: true } });
+
+ expect(config.fileTree).toMatchInlineSnapshot(`
+ {
+ "allowEdits": [
+ "**",
+ ],
+ "visible": true,
+ }
+ `);
+});
+
+test('"fileTree.allowEdits: false" disables all editing', () => {
+ const config = new EditorConfig({ fileTree: { allowEdits: false } });
+
+ expect(config.fileTree).toMatchInlineSnapshot(`
+ {
+ "allowEdits": false,
+ "visible": true,
+ }
+ `);
+});
+
+test('"fileTree.allowEdits" is converted to array', () => {
+ const config = new EditorConfig({ fileTree: { allowEdits: '/src/**' } });
+
+ expect(config.fileTree).toMatchInlineSnapshot(`
+ {
+ "allowEdits": [
+ "/src/**",
+ ],
+ "visible": true,
+ }
+ `);
+});
+
+test('"fileTree.allowEdits: [...]" works', () => {
+ const config = new EditorConfig({ fileTree: { allowEdits: ['/src/**', '**/*.test.ts'] } });
+
+ expect(config.fileTree).toMatchInlineSnapshot(`
+ {
+ "allowEdits": [
+ "/src/**",
+ "**/*.test.ts",
+ ],
+ "visible": true,
+ }
+ `);
+});
diff --git a/packages/runtime/src/webcontainer/editor-config.ts b/packages/runtime/src/webcontainer/editor-config.ts
index 976a50c5..b1e07dfd 100644
--- a/packages/runtime/src/webcontainer/editor-config.ts
+++ b/packages/runtime/src/webcontainer/editor-config.ts
@@ -9,7 +9,7 @@ interface NormalizedEditorConfig {
visible: boolean;
/** Whether to allow file and folder editing in file tree */
- allowEdits: boolean;
+ allowEdits: false | string[];
};
}
@@ -60,11 +60,25 @@ function normalizeEditorConfig(config?: EditorSchema): NormalizedEditorConfig {
};
}
+ if (typeof config.fileTree?.allowEdits === 'boolean' || !config.fileTree?.allowEdits) {
+ return {
+ visible: true,
+ fileTree: {
+ visible: true,
+ allowEdits: config.fileTree?.allowEdits ? ['**'] : false,
+ },
+ };
+ }
+
return {
visible: true,
fileTree: {
visible: true,
- allowEdits: config.fileTree?.allowEdits || false,
+ allowEdits: toArray(config.fileTree.allowEdits),
},
};
}
+
+function toArray(items: T | T[]): T[] {
+ return Array.isArray(items) ? items : [items];
+}
diff --git a/packages/theme/src/theme.ts b/packages/theme/src/theme.ts
index b7bf219d..904d0629 100644
--- a/packages/theme/src/theme.ts
+++ b/packages/theme/src/theme.ts
@@ -150,6 +150,22 @@ export const theme = {
secondaryColor: 'var(--tk-elements-link-secondaryColor)',
secondaryColorHover: 'var(--tk-elements-link-secondaryColorHover)',
},
+ primaryButton: {
+ backgroundColor: 'var(--tk-elements-primaryButton-backgroundColor)',
+ backgroundColorHover: 'var(--tk-elements-primaryButton-backgroundColorHover)',
+ textColor: 'var(--tk-elements-primaryButton-textColor)',
+ textColorHover: 'var(--tk-elements-primaryButton-textColorHover)',
+ iconColor: 'var(--tk-elements-primaryButton-iconColor)',
+ iconColorHover: 'var(--tk-elements-primaryButton-iconColorHover)',
+ },
+ secondaryButton: {
+ backgroundColor: 'var(--tk-elements-secondaryButton-backgroundColor)',
+ backgroundColorHover: 'var(--tk-elements-secondaryButton-backgroundColorHover)',
+ textColor: 'var(--tk-elements-secondaryButton-textColor)',
+ textColorHover: 'var(--tk-elements-secondaryButton-textColorHover)',
+ iconColor: 'var(--tk-elements-secondaryButton-iconColor)',
+ iconColorHover: 'var(--tk-elements-secondaryButton-iconColorHover)',
+ },
content: {
textColor: 'var(--tk-elements-content-textColor)',
headingTextColor: 'var(--tk-elements-content-headingTextColor)',
@@ -170,22 +186,6 @@ export const theme = {
color: 'var(--tk-elements-topBar-logo-color)',
colorHover: 'var(--tk-elements-topBar-logo-colorHover)',
},
- primaryButton: {
- backgroundColor: 'var(--tk-elements-topBar-primaryButton-backgroundColor)',
- backgroundColorHover: 'var(--tk-elements-topBar-primaryButton-backgroundColorHover)',
- textColor: 'var(--tk-elements-topBar-primaryButton-textColor)',
- textColorHover: 'var(--tk-elements-topBar-primaryButton-textColorHover)',
- iconColor: 'var(--tk-elements-topBar-primaryButton-iconColor)',
- iconColorHover: 'var(--tk-elements-topBar-primaryButton-iconColorHover)',
- },
- secondaryButton: {
- backgroundColor: 'var(--tk-elements-topBar-secondaryButton-backgroundColor)',
- backgroundColorHover: 'var(--tk-elements-topBar-secondaryButton-backgroundColorHover)',
- textColor: 'var(--tk-elements-topBar-secondaryButton-textColor)',
- textColorHover: 'var(--tk-elements-topBar-secondaryButton-textColorHover)',
- iconColor: 'var(--tk-elements-topBar-secondaryButton-iconColor)',
- iconColorHover: 'var(--tk-elements-topBar-secondaryButton-iconColorHover)',
- },
},
panel: {
backgroundColor: 'var(--tk-elements-panel-backgroundColor)',
diff --git a/packages/astro/src/default/utils/content/default-localization.ts b/packages/types/src/default-localization.ts
similarity index 74%
rename from packages/astro/src/default/utils/content/default-localization.ts
rename to packages/types/src/default-localization.ts
index a5cdc8c7..6d3c95dd 100644
--- a/packages/astro/src/default/utils/content/default-localization.ts
+++ b/packages/types/src/default-localization.ts
@@ -1,4 +1,4 @@
-import type { Lesson } from '@tutorialkit/types';
+import type { Lesson } from './entities/index.js';
export const DEFAULT_LOCALIZATION = {
partTemplate: 'Part ${index}: ${title}',
@@ -9,6 +9,9 @@ export const DEFAULT_LOCALIZATION = {
filesTitleText: 'Files',
fileTreeCreateFileText: 'Create file',
fileTreeCreateFolderText: 'Create folder',
+ fileTreeActionNotAllowedText: 'This action is not allowed',
+ fileTreeAllowedPatternsText: 'Created files and folders must match following patterns:',
+ confirmationText: 'OK',
prepareEnvironmentTitleText: 'Preparing Environment',
defaultPreviewTitleText: 'Preview',
reloadPreviewTitle: 'Reload Preview',
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 1b085220..1f905179 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -2,3 +2,4 @@ export type * from './entities/index.js';
export * from './schemas/index.js';
export * from './files-ref.js';
export { interpolateString } from './utils/interpolation.js';
+export { DEFAULT_LOCALIZATION } from './default-localization.js';
diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts
index b1473cb8..585f1ef8 100644
--- a/packages/types/src/schemas/common.ts
+++ b/packages/types/src/schemas/common.ts
@@ -174,7 +174,16 @@ export const editorSchema = z.union([
// or configure file tree with options
z.strictObject({
allowEdits: z
- .boolean()
+ .union([
+ // allow editing all files or disable completely
+ z.boolean(),
+
+ // limit file editing to files and folders that match a single glob pattern
+ z.string(),
+
+ // limit file editing to files and folders that match one of multiple glob patterns
+ z.array(z.string()),
+ ])
.describe(
'Allow file tree’s items to be edited by right clicking them. Supports file and folder creation.',
),
diff --git a/packages/types/src/schemas/i18n.ts b/packages/types/src/schemas/i18n.ts
index 1f34834b..5c596dea 100644
--- a/packages/types/src/schemas/i18n.ts
+++ b/packages/types/src/schemas/i18n.ts
@@ -72,6 +72,33 @@ export const i18nSchema = z.object({
.optional()
.describe("Text shown on file tree's context menu's folder creation button."),
+ /**
+ * Text shown on dialog when user attempts to edit files that don't match allowed patterns.
+ *
+ * @default 'This action is not allowed'
+ */
+ fileTreeActionNotAllowedText: z
+ .string()
+ .optional()
+ .describe("Text shown on dialog when user attempts to edit files that don't match allowed patterns."),
+
+ /**
+ * Text shown on dialog describing allowed patterns when file or folder creation failed.
+ *
+ * @default 'Created files and folders must match following patterns:'
+ */
+ fileTreeAllowedPatternsText: z
+ .string()
+ .optional()
+ .describe('Text shown on dialog describing allowed patterns when file or folder creation failed.'),
+
+ /**
+ * Text shown on confirmation buttons on dialogs.
+ *
+ * @default 'OK'
+ */
+ confirmationText: z.string().optional().describe('Text shown on confirmation buttons on dialogs.'),
+
/**
* Text shown on top of the steps section.
*
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bc14d1cc..6eb84128 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -539,6 +539,9 @@ importers:
'@radix-ui/react-context-menu':
specifier: ^2.2.1
version: 2.2.1(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-dialog':
+ specifier: ^1.1.1
+ version: 1.1.1(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@replit/codemirror-lang-svelte':
specifier: ^6.0.0
version: 6.0.0(@codemirror/autocomplete@6.16.3)(@codemirror/lang-css@6.2.1)(@codemirror/lang-html@6.4.9)(@codemirror/lang-javascript@6.2.2)(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(@lezer/common@1.2.1)(@lezer/highlight@1.2.0)(@lezer/javascript@1.4.17)(@lezer/lr@1.4.1)
@@ -569,6 +572,9 @@ importers:
nanostores:
specifier: ^0.10.3
version: 0.10.3
+ picomatch:
+ specifier: ^4.0.2
+ version: 4.0.2
react:
specifier: ^18.3.1
version: 18.3.1
@@ -582,6 +588,9 @@ importers:
'@tutorialkit/types':
specifier: workspace:*
version: link:../types
+ '@types/picomatch':
+ specifier: ^3.0.1
+ version: 3.0.1
'@types/react':
specifier: ^18.3.3
version: 18.3.3
@@ -2947,6 +2956,38 @@ packages:
react: 18.3.1
dev: false
+ /@radix-ui/react-dialog@1.1.1(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@radix-ui/primitive': 1.1.0
+ '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.1(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.0.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@types/react': 18.3.3
+ aria-hidden: 1.2.4
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.5.7(@types/react@18.3.3)(react@18.3.1)
+ dev: false
+
/@radix-ui/react-direction@1.1.0(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==}
peerDependencies:
@@ -3734,6 +3775,10 @@ packages:
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
dev: true
+ /@types/picomatch@3.0.1:
+ resolution: {integrity: sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==}
+ dev: true
+
/@types/prop-types@15.7.12:
resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==}
@@ -6099,7 +6144,7 @@ packages:
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
- micromatch: 4.0.7
+ micromatch: 4.0.8
/fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -6163,7 +6208,7 @@ packages:
/find-yarn-workspace-root2@1.2.16:
resolution: {integrity: sha512-hr6hb1w8ePMpPVUK39S4RlwJzi+xPLuVuG8XlwXU3KD5Yn3qgBWVfy3AzNlDhWvE1EORCE65/Qm26rFQt3VLVA==}
dependencies:
- micromatch: 4.0.7
+ micromatch: 4.0.8
pkg-dir: 4.2.0
/flat-cache@4.0.1:
@@ -7679,13 +7724,6 @@ packages:
transitivePeerDependencies:
- supports-color
- /micromatch@4.0.7:
- resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==}
- engines: {node: '>=8.6'}
- dependencies:
- braces: 3.0.3
- picomatch: 2.3.1
-
/micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -8125,7 +8163,6 @@ packages:
/picomatch@4.0.2:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'}
- dev: true
/pify@4.0.1:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}