Skip to content

Commit

Permalink
feat: support glob patterns in editor.fileTree.allowEdits (#332)
Browse files Browse the repository at this point in the history
  • Loading branch information
AriPerkkio authored Sep 18, 2024
1 parent 5e5c60e commit c1a59f5
Show file tree
Hide file tree
Showing 28 changed files with 557 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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[] } }

```

Expand All @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'File in first level';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'File in second level';
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions e2e/test/file-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
7 changes: 6 additions & 1 deletion e2e/uno.config.ts
Original file line number Diff line number Diff line change
@@ -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: '**',
},
},
});
19 changes: 3 additions & 16 deletions packages/astro/src/default/components/LoginButton.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,21 +48,8 @@ export function LoginButton() {
}, [authStatus.status]);

return (
<button
className={classNames('flex font-500 disabled:opacity-32 items-center text-sm ml-2 px-4 py-1 rounded-md', {
'bg-tk-elements-topBar-primaryButton-backgroundColor text-tk-elements-topBar-primaryButton-textColor':
showLogin,
'bg-tk-elements-topBar-secondaryButton-backgroundColor text-tk-elements-topBar-secondaryButton-textColor':
!showLogin,
'hover:bg-tk-elements-topBar-primaryButton-backgroundColorHover hover:text-tk-elements-topBar-primaryButton-textColorHover':
!disabled && showLogin,
'hover:bg-tk-elements-topBar-secondaryButton-backgroundColorHover hover:text-tk-elements-topBar-secondaryButton-textColorHover':
!disabled && !showLogin,
})}
disabled={disabled}
onClick={onClick}
>
<Button className="ml-2" variant={showLogin ? 'primary' : 'secondary'} disabled={disabled} onClick={onClick}>
{showLogin ? 'Login' : 'Logout'}
</button>
</Button>
);
}
32 changes: 16 additions & 16 deletions packages/astro/src/default/styles/variables.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');

Expand Down
3 changes: 1 addition & 2 deletions packages/astro/src/default/utils/content.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions packages/react/src/Button.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) => {
return (
<button
ref={ref}
{...props}
className={classNames(
className,
'flex items-center font-500 text-sm px-4 py-1 rounded-md disabled:opacity-32',
variant === 'primary' &&
'bg-tk-elements-primaryButton-backgroundColor text-tk-elements-primaryButton-textColor',

!props.disabled &&
variant === 'primary' &&
'hover:bg-tk-elements-primaryButton-backgroundColorHover hover:text-tk-elements-primaryButton-textColorHover',

variant === 'secondary' &&
'bg-tk-elements-secondaryButton-backgroundColor text-tk-elements-secondaryButton-textColor',

!props.disabled &&
variant === 'secondary' &&
'hover:bg-tk-elements-secondaryButton-backgroundColorHover hover:text-tk-elements-secondaryButton-textColorHover',
)}
/>
);
});
3 changes: 3 additions & 0 deletions packages/react/src/Panels/EditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface Props {
helpAction?: 'solve' | 'reset';
editorDocument?: EditorDocument;
selectedFile?: string | undefined;
allowEditPatterns?: ComponentProps<typeof FileTree>['allowEditPatterns'];
onEditorChange?: OnEditorChange;
onEditorScroll?: OnEditorScroll;
onHelpClick?: () => void;
Expand All @@ -43,6 +44,7 @@ export function EditorPanel({
helpAction,
editorDocument,
selectedFile,
allowEditPatterns,
onEditorChange,
onEditorScroll,
onHelpClick,
Expand Down Expand Up @@ -83,6 +85,7 @@ export function EditorPanel({
hideRoot={hideRoot ?? true}
files={files}
scope={fileTreeScope}
allowEditPatterns={allowEditPatterns}
onFileSelect={onFileSelect}
onFileChange={onFileTreeChange}
/>
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/Panels/WorkspacePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
Loading

0 comments on commit c1a59f5

Please sign in to comment.