Skip to content

Commit 7782bdc

Browse files
authored
feat: add files via file tree (#314)
1 parent 38df1f3 commit 7782bdc

File tree

30 files changed

+1192
-68
lines changed

30 files changed

+1192
-68
lines changed
Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,32 @@
1-
import { useState } from 'react';
1+
import { useState, type ComponentProps } from 'react';
22
import FileTree from '@tutorialkit/react/core/FileTree';
33

44
export default function ExampleFileTree() {
5-
const [selectedFile, setSelectedFile] = useState(FILES[0]);
5+
const [files, setFiles] = useState(INITIAL_FILES);
6+
const [selectedFile, setSelectedFile] = useState(INITIAL_FILES[0].path);
67

78
return (
89
<FileTree
9-
files={FILES}
10+
files={files}
1011
hideRoot
1112
className="my-file-tree"
1213
hiddenFiles={['package-lock.json']}
1314
selectedFile={selectedFile}
1415
onFileSelect={setSelectedFile}
16+
onFileChange={(event) => {
17+
if (event.method === 'add') {
18+
setFiles([...files, { path: event.value, type: event.type }]);
19+
}
20+
}}
1521
/>
1622
);
1723
}
1824

19-
const FILES = [
20-
'/src/index.js',
21-
'/src/index.html',
22-
'/src/assets/logo.svg',
23-
'/package-lock.json',
24-
'/package.json',
25-
'/vite.config.js',
25+
const INITIAL_FILES: ComponentProps<typeof FileTree>['files'] = [
26+
{ path: '/package-lock.json', type: 'file' },
27+
{ path: '/package.json', type: 'file' },
28+
{ path: '/src/assets/logo.svg', type: 'file' },
29+
{ path: '/src/index.html', type: 'file' },
30+
{ path: '/src/index.js', type: 'file' },
31+
{ path: '/vite.config.js', type: 'file' },
2632
];

docs/tutorialkit.dev/src/components/react-examples/ExampleSimpleEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ const FILES: Record<string, EditorDocument> = {
227227
},
228228
};
229229

230-
const FILE_PATHS = Object.keys(FILES);
230+
const FILE_PATHS = Object.keys(FILES).map((path) => ({ path, type: 'file' }) as const);
231231

232232
function stripIndent(string: string) {
233233
const indent = minIndent(string.slice(1));

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,34 @@ Defines which file should be opened in the [code editor](/guides/ui/#code-editor
134134
<PropertyTable inherited type="string" />
135135

136136
##### `editor`
137-
Configure whether or not the editor should be rendered. If an object is provided with `fileTree: false`, only the file tree is hidden.
138-
<PropertyTable inherited type="boolean | { fileTree: false }" />
137+
Configures options for the editor and its file tree. Editor can be hidden by providing `false`.
138+
Optionally you can hide just file tree by providing `fileTree: false`.
139+
140+
File tree can be set to allow file editing from right clicks by setting `fileTree.allowEdits: true`.
141+
142+
<PropertyTable inherited type={'Editor'} />
143+
144+
The `Editor` type has the following shape:
145+
146+
```ts
147+
type Editor =
148+
| false
149+
| { editor: { allowEdits: boolean } }
150+
151+
```
152+
153+
Example values:
154+
155+
```yaml
156+
editor: false # Editor is hidden
157+
158+
editor: # Editor is visible
159+
fileTree: false # File tree is hidden
160+
161+
editor: # Editor is visible
162+
fileTree: # File tree is visible
163+
allowEdits: true # User can add new files and folders from the file tree
164+
```
139165
140166
##### `previews`
141167
Configure which ports should be used for the previews allowing you to align the behavior with your demo application's dev server setup. If not specified, the lowest port will be used.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,23 @@ A component to list files in a tree view.
107107

108108
* `onFileSelect: (file: string) => void` - A callback that will be called when a file is clicked. The path of the file that was clicked will be passed as an argument.
109109

110+
* `onFileChange: (event: FileChangeEvent) => void` - An optional callback that will be called when a new file or folder is created from the file tree's context menu. When callback is not passed, file tree does not allow adding new files.
111+
```ts
112+
interface FileChangeEvent {
113+
type: 'file' | 'folder';
114+
method: 'add' | 'remove' | 'rename';
115+
value: string;
116+
}
117+
```
118+
110119
* `hideRoot: boolean` - Whether or not to hide the root directory in the tree. Defaults to `false`.
111120

112121
* `hiddenFiles: (string | RegExp)[]` - A list of file paths that should be hidden from the tree.
113122

114123
* `scope?: string` - Every file path that does not start with this scope will be hidden.
115124

125+
* `i18n?: object` - Texts for file tree's components.
126+
116127
* `className?: string` - A class name to apply to the root element of the component.
117128

118129

Lines changed: 1 addition & 0 deletions
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';
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
type: lesson
3+
title: Allow Edits Disabled
4+
previews: false
5+
terminal:
6+
panels: terminal
7+
---
8+
9+
# File Tree test - Allow Edits Disabled
10+
11+
Option `editor.fileTree.allowEdits` has default `false` value.
Lines changed: 1 addition & 0 deletions
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';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
type: lesson
3+
title: Allow Edits Enabled
4+
previews: false
5+
editor:
6+
fileTree:
7+
allowEdits: true
8+
terminal:
9+
panels: terminal
10+
---
11+
12+
# File Tree test - Allow Edits Enabled
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default 'Lesson file example.js content';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
type: lesson
3+
title: Hidden
4+
editor:
5+
fileTree: false
6+
focus: /example.js
7+
---
8+
9+
# File Tree test - Hidden

e2e/test/file-tree.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,101 @@ test('user can see cannot click solve on lessons without solution files', async
5757
// reset-button should be immediately visible
5858
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
5959
});
60+
61+
// TODO: Requires #245
62+
test.skip('user should not see hidden file tree', async ({ page }) => {
63+
await page.goto(`${BASE_URL}/hidden`);
64+
await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Hidden' })).toBeVisible();
65+
66+
await expect(page.getByText('Files')).not.toBeVisible();
67+
await expect(page.getByRole('button', { name: 'example.js' })).not.toBeVisible();
68+
});
69+
70+
test('user cannot create files or folders when lesson is not configured via allowEdits', async ({ page }) => {
71+
await page.goto(`${BASE_URL}/allow-edits-disabled`);
72+
73+
await expect(page.getByTestId('file-tree-root-context-menu')).not.toBeVisible();
74+
75+
await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' });
76+
await expect(page.getByRole('menuitem', { name: 'Create file' })).not.toBeVisible();
77+
});
78+
79+
test('user can create files', async ({ page }) => {
80+
await page.goto(`${BASE_URL}/allow-edits-enabled`);
81+
await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Enabled' })).toBeVisible();
82+
83+
// wait for terminal to start
84+
const terminal = page.getByRole('textbox', { name: 'Terminal input' });
85+
const terminalOutput = page.getByRole('tabpanel', { name: 'Terminal' });
86+
await expect(terminalOutput).toContainText('~/tutorial', { useInnerText: true });
87+
88+
for (const [locator, filename] of [
89+
[page.getByTestId('file-tree-root-context-menu'), 'file-in-root.js'],
90+
[page.getByRole('button', { name: 'first-level' }), 'file-in-first-level.js'],
91+
[page.getByRole('button', { name: 'second-level' }), 'file-in-second-level.js'],
92+
] as const) {
93+
await locator.click({ button: 'right' });
94+
await page.getByRole('menuitem', { name: 'Create file' }).click();
95+
96+
await page.locator('*:focus').fill(filename);
97+
await page.locator('*:focus').press('Enter');
98+
await expect(page.getByRole('button', { name: filename, pressed: true })).toBeVisible();
99+
}
100+
101+
// verify that all files are present on file tree after last creation
102+
await expect(page.getByRole('button', { name: 'file-in-root.js' })).toBeVisible();
103+
await expect(page.getByRole('button', { name: 'file-in-first-level' })).toBeVisible();
104+
await expect(page.getByRole('button', { name: 'file-in-second-level' })).toBeVisible();
105+
106+
// verify that files are present on file system via terminal
107+
for (const [directory, filename] of [
108+
['./', 'file-in-root.js'],
109+
['./first-level', 'file-in-first-level.js'],
110+
['./first-level/second-level', 'file-in-second-level.js'],
111+
]) {
112+
await terminal.fill(`clear; ls ${directory}`);
113+
await terminal.press('Enter');
114+
115+
await expect(terminalOutput).toContainText(filename, { useInnerText: true });
116+
}
117+
});
118+
119+
test('user can create folders', async ({ page }) => {
120+
await page.goto(`${BASE_URL}/allow-edits-enabled`);
121+
await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Enabled' })).toBeVisible();
122+
123+
// wait for terminal to start
124+
const terminal = page.getByRole('textbox', { name: 'Terminal input' });
125+
const terminalOutput = page.getByRole('tabpanel', { name: 'Terminal' });
126+
await expect(terminalOutput).toContainText('~/tutorial', { useInnerText: true });
127+
128+
for (const [locator, folder] of [
129+
[page.getByTestId('file-tree-root-context-menu'), 'folder-1'],
130+
[page.getByRole('button', { name: 'folder-1' }), 'folder-2'],
131+
[page.getByRole('button', { name: 'folder-2' }), 'folder-3'],
132+
] as const) {
133+
await locator.click({ button: 'right' });
134+
await page.getByRole('menuitem', { name: 'Create folder' }).click();
135+
136+
await page.locator('*:focus').fill(folder);
137+
await page.locator('*:focus').press('Enter');
138+
await expect(page.getByRole('button', { name: folder })).toBeVisible();
139+
}
140+
141+
// verify that all folders are present on file tree after last creation
142+
await expect(page.getByRole('button', { name: 'folder-1' })).toBeVisible();
143+
await expect(page.getByRole('button', { name: 'folder-2' })).toBeVisible();
144+
await expect(page.getByRole('button', { name: 'folder-3' })).toBeVisible();
145+
146+
// verify that files are present on file system via terminal
147+
for (const [directory, folder] of [
148+
['./', 'folder-1'],
149+
['./folder-1', 'folder-2'],
150+
['./folder-1/folder-2', 'folder-3'],
151+
]) {
152+
await terminal.fill(`clear; ls ${directory}`);
153+
await terminal.press('Enter');
154+
155+
await expect(terminalOutput).toContainText(folder, { useInnerText: true });
156+
}
157+
});

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ export const DEFAULT_LOCALIZATION = {
77
editPageText: 'Edit this page',
88
webcontainerLinkText: 'Powered by WebContainers',
99
filesTitleText: 'Files',
10+
fileTreeCreateFileText: 'Create file',
11+
fileTreeCreateFolderText: 'Create folder',
1012
prepareEnvironmentTitleText: 'Preparing Environment',
1113
defaultPreviewTitleText: 'Preview',
1214
reloadPreviewTitle: 'Reload Preview',
1315
toggleTerminalButtonText: 'Toggle Terminal',
1416
solveButtonText: 'Solve',
1517
resetButtonText: 'Reset',
16-
} satisfies Lesson['data']['i18n'];
18+
} satisfies Required<Lesson['data']['i18n']>;

packages/react/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"@lezer/lr": "^1.0.0",
7575
"@nanostores/react": "0.7.2",
7676
"@radix-ui/react-accordion": "^1.2.0",
77+
"@radix-ui/react-context-menu": "^2.2.1",
7778
"@replit/codemirror-lang-svelte": "^6.0.0",
7879
"@tutorialkit/runtime": "workspace:*",
7980
"@tutorialkit/theme": "workspace:*",

packages/react/src/Panels/EditorPanel.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { I18n } from '@tutorialkit/types';
2-
import { useEffect, useRef } from 'react';
2+
import { useEffect, useRef, type ComponentProps } from 'react';
33
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
44
import {
55
CodeMirrorEditor,
@@ -17,7 +17,7 @@ const DEFAULT_FILE_TREE_SIZE = 25;
1717
interface Props {
1818
theme: Theme;
1919
id: unknown;
20-
files: string[];
20+
files: ComponentProps<typeof FileTree>['files'];
2121
i18n: I18n;
2222
hideRoot?: boolean;
2323
fileTreeScope?: string;
@@ -29,6 +29,7 @@ interface Props {
2929
onEditorScroll?: OnEditorScroll;
3030
onHelpClick?: () => void;
3131
onFileSelect?: (value?: string) => void;
32+
onFileTreeChange?: ComponentProps<typeof FileTree>['onFileChange'];
3233
}
3334

3435
export function EditorPanel({
@@ -46,6 +47,7 @@ export function EditorPanel({
4647
onEditorScroll,
4748
onHelpClick,
4849
onFileSelect,
50+
onFileTreeChange,
4951
}: Props) {
5052
const fileTreePanelRef = useRef<ImperativePanelHandle>(null);
5153

@@ -76,11 +78,13 @@ export function EditorPanel({
7678
</div>
7779
<FileTree
7880
className="flex-grow py-2 border-r border-tk-elements-app-borderColor text-sm"
81+
i18n={i18n}
7982
selectedFile={selectedFile}
8083
hideRoot={hideRoot ?? true}
8184
files={files}
8285
scope={fileTreeScope}
8386
onFileSelect={onFileSelect}
87+
onFileChange={onFileTreeChange}
8488
/>
8589
</Panel>
8690
<PanelResizeHandle

packages/react/src/Panels/WorkspacePanel.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useStore } from '@nanostores/react';
2-
import { TutorialStore } from '@tutorialkit/runtime';
2+
import type { TutorialStore } from '@tutorialkit/runtime';
33
import type { I18n } from '@tutorialkit/types';
4-
import { useCallback, useEffect, useRef, useState } from 'react';
4+
import { useCallback, useEffect, useRef, useState, type ComponentProps } from 'react';
55
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
66
import type { Theme } from '../core/types.js';
77
import resizePanelStyles from '../styles/resize-panel.module.css';
@@ -12,6 +12,8 @@ import { TerminalPanel } from './TerminalPanel.js';
1212

1313
const DEFAULT_TERMINAL_SIZE = 25;
1414

15+
type FileTreeChangeEvent = Parameters<NonNullable<ComponentProps<typeof EditorPanel>['onFileTreeChange']>>[0];
16+
1517
interface Props {
1618
tutorialStore: TutorialStore;
1719
theme: Theme;
@@ -96,7 +98,9 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) {
9698
const selectedFile = useStore(tutorialStore.selectedFile);
9799
const currentDocument = useStore(tutorialStore.currentDocument);
98100
const lessonFullyLoaded = useStore(tutorialStore.lessonFullyLoaded);
101+
const editorConfig = useStore(tutorialStore.editorConfig);
99102
const storeRef = useStore(tutorialStore.ref);
103+
const files = useStore(tutorialStore.files);
100104

101105
const lesson = tutorialStore.lesson!;
102106

@@ -118,6 +122,16 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) {
118122
}
119123
}
120124

125+
function onFileTreeChange({ method, type, value }: FileTreeChangeEvent) {
126+
if (method === 'add' && type === 'file') {
127+
return tutorialStore.addFile(value);
128+
}
129+
130+
if (method === 'add' && type === 'folder') {
131+
return tutorialStore.addFolder(value);
132+
}
133+
}
134+
121135
useEffect(() => {
122136
if (tutorialStore.hasSolution()) {
123137
setHelpAction('solve');
@@ -140,12 +154,13 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) {
140154
theme={theme}
141155
showFileTree={tutorialStore.hasFileTree()}
142156
editorDocument={currentDocument}
143-
files={lesson.files[1]}
157+
files={files}
144158
i18n={lesson.data.i18n as I18n}
145159
hideRoot={lesson.data.hideRoot}
146160
helpAction={helpAction}
147161
onHelpClick={lessonFullyLoaded ? onHelpClick : undefined}
148162
onFileSelect={(filePath) => tutorialStore.setSelectedFile(filePath)}
163+
onFileTreeChange={editorConfig.fileTree.allowEdits ? onFileTreeChange : undefined}
149164
selectedFile={selectedFile}
150165
onEditorScroll={(position) => tutorialStore.setCurrentDocumentScrollPosition(position)}
151166
onEditorChange={(update) => tutorialStore.setCurrentDocumentContent(update.content)}

0 commit comments

Comments
 (0)