Skip to content

Commit c1a59f5

Browse files
authored
feat: support glob patterns in editor.fileTree.allowEdits (#332)
1 parent 5e5c60e commit c1a59f5

File tree

28 files changed

+557
-83
lines changed

28 files changed

+557
-83
lines changed

docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx

+43-1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,34 @@ type I18nText = {
8484
*/
8585
filesTitleText?: string,
8686

87+
/**
88+
* Text shown on file tree's context menu's file creation button.
89+
*
90+
* @default 'Create file'
91+
*/
92+
fileTreeCreateFileText?: string,
93+
94+
/**
95+
* Text shown on file tree's context menu's folder creation button.
96+
*
97+
* @default 'Create folder'
98+
*/
99+
fileTreeCreateFolderText?: string,
100+
101+
/**
102+
* Text shown on dialog when user attempts to edit files that don't match allowed patterns.
103+
*
104+
* @default 'This action is not allowed'
105+
*/
106+
fileTreeActionNotAllowed?: string,
107+
108+
/**
109+
* Text shown on dialog describing allowed patterns when file or folder createion failed.
110+
*
111+
* @default 'Created files and folders must match following patterns:'
112+
*/
113+
fileTreeAllowedPatternsText?: string,
114+
87115
/**
88116
* Text shown on top of the steps section.
89117
*
@@ -144,9 +172,11 @@ File tree can be set to allow file editing from right clicks by setting `fileTre
144172
The `Editor` type has the following shape:
145173

146174
```ts
175+
type GlobPattern = string
176+
147177
type Editor =
148178
| false
149-
| { editor: { allowEdits: boolean } }
179+
| { editor: { allowEdits: boolean | GlobPattern | GlobPattern[] } }
150180

151181
```
152182

@@ -161,6 +191,18 @@ editor: # Editor is visible
161191
editor: # Editor is visible
162192
fileTree: # File tree is visible
163193
allowEdits: true # User can add new files and folders from the file tree
194+
195+
196+
editor: # Editor is visible
197+
fileTree: # File tree is visible
198+
allowEdits: "/src/**" # User can add files and folders anywhere inside "/src/"
199+
200+
editor: # Editor is visible
201+
fileTree: # File tree is visible
202+
allowEdits:
203+
- "/*" # User can add files and folders directly in the root
204+
- "/first-level/allowed-filename-only.js" # Only "allowed-filename-only.js" inside "/first-level" folder
205+
- "**/second-level/**" # Anything inside "second-level" folders anywhere
164206
```
165207
166208
##### `previews`

docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx

+2
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ A component to list files in a tree view.
116116
}
117117
```
118118

119+
* `allowEditPatterns?: string[]` - Glob patterns for paths that allow editing files and folders. Disabled by default.
120+
119121
* `hideRoot: boolean` - Whether or not to hide the root directory in the tree. Defaults to `false`.
120122

121123
* `hiddenFiles: (string | RegExp)[]` - A list of file paths that should be hidden from the tree.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default 'File in first level';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default 'File in second level';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
type: lesson
3+
title: Allow Edits Glob
4+
previews: false
5+
editor:
6+
fileTree:
7+
allowEdits:
8+
# Items in root
9+
- "/*"
10+
# Only "allowed-filename-only.js" inside "/first-level" folder
11+
- "/first-level/allowed-filename-only.js"
12+
# Anything inside "second-level" folders anywhere
13+
- "**/second-level/**"
14+
terminal:
15+
panels: terminal
16+
---
17+
18+
# File Tree test - Allow Edits Glob

e2e/test/file-tree.test.ts

+67
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,70 @@ test('user can create folders', async ({ page }) => {
155155
await expect(terminalOutput).toContainText(folder, { useInnerText: true });
156156
}
157157
});
158+
159+
test('user can create files and folders in allowed directories', async ({ page }) => {
160+
await page.goto(`${BASE_URL}/allow-edits-glob`);
161+
await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Glob' })).toBeVisible();
162+
163+
// wait for terminal to start
164+
const terminal = page.getByRole('textbox', { name: 'Terminal input' });
165+
const terminalOutput = page.getByRole('tabpanel', { name: 'Terminal' });
166+
await expect(terminalOutput).toContainText('~/tutorial', { useInnerText: true });
167+
168+
// can create files in root and inside "second-level"
169+
for (const [locator, name, type] of [
170+
[page.getByTestId('file-tree-root-context-menu'), 'new-file.js', 'file'],
171+
[page.getByRole('button', { name: 'second-level' }), 'new-folder.js', 'folder'],
172+
] as const) {
173+
await locator.click({ button: 'right' });
174+
await page.getByRole('menuitem', { name: `Create ${type}` }).click();
175+
176+
await page.locator('*:focus').fill(name);
177+
await page.locator('*:focus').press('Enter');
178+
await expect(page.getByRole('button', { name })).toBeVisible();
179+
}
180+
181+
await expect(page.getByRole('button', { name: 'new-file.js' })).toBeVisible();
182+
await expect(page.getByRole('button', { name: 'new-folder' })).toBeVisible();
183+
184+
// verify that files are present on file system via terminal
185+
for (const [directory, folder] of [
186+
['./', 'new-file.js'],
187+
['./first-level/second-level/', 'new-folder'],
188+
]) {
189+
await terminal.fill(`clear; ls ${directory}`);
190+
await terminal.press('Enter');
191+
192+
await expect(terminalOutput).toContainText(folder, { useInnerText: true });
193+
}
194+
});
195+
196+
test('user cannot create files or folders in disallowed directories', async ({ page }) => {
197+
await page.goto(`${BASE_URL}/allow-edits-glob`);
198+
await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Glob' })).toBeVisible();
199+
200+
// wait for terminal to start
201+
const terminalOutput = page.getByRole('tabpanel', { name: 'Terminal' });
202+
await expect(terminalOutput).toContainText('~/tutorial', { useInnerText: true });
203+
204+
for (const [name, type] of [
205+
['new-file.js', 'file'],
206+
['new-folder', 'folder'],
207+
] as const) {
208+
await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' });
209+
await page.getByRole('menuitem', { name: `Create ${type}` }).click();
210+
211+
await page.locator('*:focus').fill(name);
212+
await page.locator('*:focus').press('Enter');
213+
214+
const dialog = page.getByRole('dialog', { name: 'This action is not allowed' });
215+
216+
await expect(dialog.getByText('Created files and folders must match following patterns:')).toBeVisible();
217+
await expect(dialog.getByRole('listitem').nth(0)).toHaveText('/*');
218+
await expect(dialog.getByRole('listitem').nth(1)).toHaveText('/first-level/allowed-filename-only.js');
219+
await expect(dialog.getByRole('listitem').nth(2)).toHaveText('**/second-level/**');
220+
221+
await dialog.getByRole('button', { name: 'OK' }).click();
222+
await expect(dialog).not.toBeVisible();
223+
}
224+
});

e2e/uno.config.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { defineConfig } from '@tutorialkit/theme';
22

33
export default defineConfig({
4-
// add your UnoCSS config here: https://unocss.dev/guide/config-file
4+
// required for TutorialKit monorepo development mode
5+
content: {
6+
pipeline: {
7+
include: '**',
8+
},
9+
},
510
});

packages/astro/src/default/components/LoginButton.tsx

+3-16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useStore } from '@nanostores/react';
2-
import { classNames } from '@tutorialkit/react';
2+
import { Button } from '@tutorialkit/react';
33
import { useEffect, useRef, useState } from 'react';
44
import { authStore } from '../stores/auth-store';
55
import { login, logout } from './webcontainer';
@@ -48,21 +48,8 @@ export function LoginButton() {
4848
}, [authStatus.status]);
4949

5050
return (
51-
<button
52-
className={classNames('flex font-500 disabled:opacity-32 items-center text-sm ml-2 px-4 py-1 rounded-md', {
53-
'bg-tk-elements-topBar-primaryButton-backgroundColor text-tk-elements-topBar-primaryButton-textColor':
54-
showLogin,
55-
'bg-tk-elements-topBar-secondaryButton-backgroundColor text-tk-elements-topBar-secondaryButton-textColor':
56-
!showLogin,
57-
'hover:bg-tk-elements-topBar-primaryButton-backgroundColorHover hover:text-tk-elements-topBar-primaryButton-textColorHover':
58-
!disabled && showLogin,
59-
'hover:bg-tk-elements-topBar-secondaryButton-backgroundColorHover hover:text-tk-elements-topBar-secondaryButton-textColorHover':
60-
!disabled && !showLogin,
61-
})}
62-
disabled={disabled}
63-
onClick={onClick}
64-
>
51+
<Button className="ml-2" variant={showLogin ? 'primary' : 'secondary'} disabled={disabled} onClick={onClick}>
6552
{showLogin ? 'Login' : 'Logout'}
66-
</button>
53+
</Button>
6754
);
6855
}

packages/astro/src/default/styles/variables.css

+16-16
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,22 @@
142142
--tk-elements-link-secondaryColor: var(--tk-text-secondary);
143143
--tk-elements-link-secondaryColorHover: var(--tk-text-primary);
144144

145+
/* Primary Button */
146+
--tk-elements-primaryButton-backgroundColor: var(--tk-background-accent-secondary);
147+
--tk-elements-primaryButton-backgroundColorHover: var(--tk-background-accent-active);
148+
--tk-elements-primaryButton-textColor: var(--tk-text-primary-inverted);
149+
--tk-elements-primaryButton-textColorHover: var(--tk-text-primary-inverted);
150+
--tk-elements-primaryButton-iconColor: var(--tk-text-primary-inverted);
151+
--tk-elements-primaryButton-iconColorHover: var(--tk-text-primary-inverted);
152+
153+
/* Secondary Button */
154+
--tk-elements-secondaryButton-backgroundColor: var(--tk-elements-app-backgroundColor);
155+
--tk-elements-secondaryButton-backgroundColorHover: var(--tk-background-secondary);
156+
--tk-elements-secondaryButton-textColor: var(--tk-text-secondary);
157+
--tk-elements-secondaryButton-textColorHover: var(--tk-text-primary);
158+
--tk-elements-secondaryButton-iconColor: var(--tk-text-secondary);
159+
--tk-elements-secondaryButton-iconColorHover: var(--tk-text-primary);
160+
145161
/* Content */
146162
--tk-elements-content-textColor: var(--tk-text-body);
147163
--tk-elements-content-headingTextColor: var(--tk-text-primary);
@@ -163,22 +179,6 @@
163179
--tk-elements-topBar-logo-color: var(--tk-text-active);
164180
--tk-elements-topBar-logo-colorHover: var(--tk-text-active);
165181

166-
/* Top Bar > Primary Button */
167-
--tk-elements-topBar-primaryButton-backgroundColor: var(--tk-background-accent-secondary);
168-
--tk-elements-topBar-primaryButton-backgroundColorHover: var(--tk-background-accent-active);
169-
--tk-elements-topBar-primaryButton-textColor: var(--tk-text-primary-inverted);
170-
--tk-elements-topBar-primaryButton-textColorHover: var(--tk-text-primary-inverted);
171-
--tk-elements-topBar-primaryButton-iconColor: var(--tk-text-primary-inverted);
172-
--tk-elements-topBar-primaryButton-iconColorHover: var(--tk-text-primary-inverted);
173-
174-
/* Top Bar > Secondary Button */
175-
--tk-elements-topBar-secondaryButton-backgroundColor: var(--tk-elements-topBar-backgroundColor);
176-
--tk-elements-topBar-secondaryButton-backgroundColorHover: var(--tk-background-secondary);
177-
--tk-elements-topBar-secondaryButton-textColor: var(--tk-text-secondary);
178-
--tk-elements-topBar-secondaryButton-textColorHover: var(--tk-text-primary);
179-
--tk-elements-topBar-secondaryButton-iconColor: var(--tk-text-secondary);
180-
--tk-elements-topBar-secondaryButton-iconColorHover: var(--tk-text-primary);
181-
182182
/* Previews */
183183
--tk-elements-previews-borderColor: theme('colors.gray.200');
184184

packages/astro/src/default/utils/content.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import path from 'node:path';
22
import type { ChapterSchema, Lesson, LessonSchema, PartSchema, Tutorial, TutorialSchema } from '@tutorialkit/types';
3-
import { interpolateString } from '@tutorialkit/types';
3+
import { interpolateString, DEFAULT_LOCALIZATION } from '@tutorialkit/types';
44
import { getCollection } from 'astro:content';
5-
import { DEFAULT_LOCALIZATION } from './content/default-localization';
65
import { getFilesRefList } from './content/files-ref';
76
import { squash } from './content/squash.js';
87
import { logger } from './logger';

packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap

-1
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,6 @@ exports[`create and eject a project 1`] = `
304304
"src/utils/constants.ts",
305305
"src/utils/content",
306306
"src/utils/content.ts",
307-
"src/utils/content/default-localization.ts",
308307
"src/utils/content/files-ref.ts",
309308
"src/utils/content/squash.ts",
310309
"src/utils/logger.ts",

packages/react/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@nanostores/react": "0.7.2",
7676
"@radix-ui/react-accordion": "^1.2.0",
7777
"@radix-ui/react-context-menu": "^2.2.1",
78+
"@radix-ui/react-dialog": "^1.1.1",
7879
"@replit/codemirror-lang-svelte": "^6.0.0",
7980
"@tutorialkit/runtime": "workspace:*",
8081
"@tutorialkit/theme": "workspace:*",
@@ -85,12 +86,14 @@
8586
"codemirror": "^6.0.1",
8687
"framer-motion": "^11.2.11",
8788
"nanostores": "^0.10.3",
89+
"picomatch": "^4.0.2",
8890
"react": "^18.3.1",
8991
"react-resizable-panels": "^2.0.19"
9092
},
9193
"devDependencies": {
9294
"@codemirror/search": "^6.5.6",
9395
"@tutorialkit/types": "workspace:*",
96+
"@types/picomatch": "^3.0.1",
9497
"@types/react": "^18.3.3",
9598
"chokidar": "3.6.0",
9699
"execa": "^9.2.0",

packages/react/src/Button.tsx

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { type ComponentProps, forwardRef, type Ref } from 'react';
2+
import { classNames } from './utils/classnames.js';
3+
4+
interface Props extends ComponentProps<'button'> {
5+
variant?: 'primary' | 'secondary';
6+
}
7+
8+
export const Button = forwardRef(({ className, variant = 'primary', ...props }: Props, ref: Ref<HTMLButtonElement>) => {
9+
return (
10+
<button
11+
ref={ref}
12+
{...props}
13+
className={classNames(
14+
className,
15+
'flex items-center font-500 text-sm px-4 py-1 rounded-md disabled:opacity-32',
16+
variant === 'primary' &&
17+
'bg-tk-elements-primaryButton-backgroundColor text-tk-elements-primaryButton-textColor',
18+
19+
!props.disabled &&
20+
variant === 'primary' &&
21+
'hover:bg-tk-elements-primaryButton-backgroundColorHover hover:text-tk-elements-primaryButton-textColorHover',
22+
23+
variant === 'secondary' &&
24+
'bg-tk-elements-secondaryButton-backgroundColor text-tk-elements-secondaryButton-textColor',
25+
26+
!props.disabled &&
27+
variant === 'secondary' &&
28+
'hover:bg-tk-elements-secondaryButton-backgroundColorHover hover:text-tk-elements-secondaryButton-textColorHover',
29+
)}
30+
/>
31+
);
32+
});

packages/react/src/Panels/EditorPanel.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface Props {
2525
helpAction?: 'solve' | 'reset';
2626
editorDocument?: EditorDocument;
2727
selectedFile?: string | undefined;
28+
allowEditPatterns?: ComponentProps<typeof FileTree>['allowEditPatterns'];
2829
onEditorChange?: OnEditorChange;
2930
onEditorScroll?: OnEditorScroll;
3031
onHelpClick?: () => void;
@@ -43,6 +44,7 @@ export function EditorPanel({
4344
helpAction,
4445
editorDocument,
4546
selectedFile,
47+
allowEditPatterns,
4648
onEditorChange,
4749
onEditorScroll,
4850
onHelpClick,
@@ -83,6 +85,7 @@ export function EditorPanel({
8385
hideRoot={hideRoot ?? true}
8486
files={files}
8587
scope={fileTreeScope}
88+
allowEditPatterns={allowEditPatterns}
8689
onFileSelect={onFileSelect}
8790
onFileChange={onFileTreeChange}
8891
/>

packages/react/src/Panels/WorkspacePanel.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) {
160160
helpAction={helpAction}
161161
onHelpClick={lessonFullyLoaded ? onHelpClick : undefined}
162162
onFileSelect={(filePath) => tutorialStore.setSelectedFile(filePath)}
163-
onFileTreeChange={editorConfig.fileTree.allowEdits ? onFileTreeChange : undefined}
163+
onFileTreeChange={onFileTreeChange}
164+
allowEditPatterns={editorConfig.fileTree.allowEdits || undefined}
164165
selectedFile={selectedFile}
165166
onEditorScroll={(position) => tutorialStore.setCurrentDocumentScrollPosition(position)}
166167
onEditorChange={(update) => tutorialStore.setCurrentDocumentContent(update.content)}

0 commit comments

Comments
 (0)