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/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==}