Skip to content

Commit 1b512d0

Browse files
committed
feat(react): <FileTree> to support file and folder editing
1 parent 0d1d3e4 commit 1b512d0

File tree

16 files changed

+762
-31
lines changed

16 files changed

+762
-31
lines changed
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,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.
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,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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default 'Lesson file example.js content';
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

+98
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

+3-1
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

+1
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

+1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export function EditorPanel({
7878
</div>
7979
<FileTree
8080
className="flex-grow py-2 border-r border-tk-elements-app-borderColor text-sm"
81+
i18n={i18n}
8182
selectedFile={selectedFile}
8283
hideRoot={hideRoot ?? true}
8384
files={files}
+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { useRef, useState, type ComponentProps } from 'react';
2+
import { Root, Portal, Content, Item, Trigger } from '@radix-ui/react-context-menu';
3+
import type { I18n } from '@tutorialkit/types';
4+
5+
interface FileChangeEvent {
6+
type: 'FILE' | 'FOLDER';
7+
method: 'ADD' | 'REMOVE' | 'RENAME';
8+
value: string;
9+
}
10+
11+
interface FileRenameEvent extends FileChangeEvent {
12+
method: 'RENAME';
13+
oldValue: string;
14+
}
15+
16+
interface Props extends ComponentProps<'div'> {
17+
/** Callback invoked when file is changed. */
18+
onFileChange?: (event: FileChangeEvent | FileRenameEvent) => void;
19+
20+
/** Directory of the clicked file. */
21+
directory: string;
22+
23+
/** Whether to render new files/directories before or after the trigger element. Defaults to `'before'`. */
24+
position?: 'before' | 'after';
25+
26+
/** Localized texts for menu. */
27+
i18n?: Pick<I18n, 'fileTreeCreateFileText' | 'fileTreeCreateFolderText'>;
28+
29+
/** Props for trigger wrapper. */
30+
triggerProps?: ComponentProps<'div'> & { 'data-testid'?: string };
31+
}
32+
33+
export function ContextMenu({
34+
onFileChange,
35+
directory,
36+
i18n,
37+
position = 'before',
38+
children,
39+
triggerProps,
40+
...props
41+
}: Props) {
42+
const [state, setState] = useState<'IDLE' | 'ADD_FILE' | 'ADD_FOLDER'>('IDLE');
43+
const inputRef = useRef<HTMLInputElement>(null);
44+
45+
if (!onFileChange) {
46+
return children;
47+
}
48+
49+
function onFileNameEnd(event: React.KeyboardEvent<HTMLInputElement> | React.FocusEvent<HTMLInputElement>) {
50+
const name = event.currentTarget.value;
51+
const isFile = state === 'ADD_FILE';
52+
53+
// files must contain extension
54+
if (name && (!isFile || name.includes('.'))) {
55+
onFileChange?.({
56+
value: `${directory}/${name}`,
57+
type: isFile ? 'FILE' : 'FOLDER',
58+
method: 'ADD',
59+
});
60+
}
61+
62+
setState('IDLE');
63+
}
64+
65+
function onFileNameKeyPress(event: React.KeyboardEvent<HTMLInputElement>) {
66+
if (event.key === 'Enter' && event.currentTarget.value !== '') {
67+
onFileNameEnd(event);
68+
}
69+
}
70+
71+
function onCloseAutoFocus(event: Event) {
72+
if ((state === 'ADD_FILE' || state === 'ADD_FOLDER') && inputRef.current) {
73+
event.preventDefault();
74+
inputRef.current.focus();
75+
}
76+
}
77+
78+
const element = (
79+
<Trigger asChild>
80+
<div {...triggerProps}>{children}</div>
81+
</Trigger>
82+
);
83+
84+
return (
85+
<Root>
86+
{position === 'before' && element}
87+
88+
{state !== 'IDLE' && (
89+
<div className="flex items-center gap-2 border-2 border-solid border-transparent" {...props}>
90+
<div className={`scale-120 shrink-0 ${state === 'ADD_FILE' ? 'i-ph-file-duotone' : 'i-ph-folder-duotone'}`} />
91+
<input
92+
ref={inputRef}
93+
autoFocus
94+
type="text"
95+
onBlur={onFileNameEnd}
96+
onKeyUp={onFileNameKeyPress}
97+
className="text-current bg-transparent w-20 outline-var(--tk-border-accent)"
98+
/>
99+
</div>
100+
)}
101+
102+
{position === 'after' && element}
103+
104+
<Portal>
105+
<Content
106+
onCloseAutoFocus={onCloseAutoFocus}
107+
className="border border-tk-border-brighter b-rounded-md bg-tk-background-brighter py-2"
108+
>
109+
<MenuItem icon="i-ph-file-plus" onClick={() => setState('ADD_FILE')}>
110+
{i18n?.fileTreeCreateFileText || 'Create file'}
111+
</MenuItem>
112+
113+
<MenuItem icon="i-ph-folder-plus" onClick={() => setState('ADD_FOLDER')}>
114+
{i18n?.fileTreeCreateFolderText || 'Create folder'}
115+
</MenuItem>
116+
</Content>
117+
</Portal>
118+
</Root>
119+
);
120+
}
121+
122+
function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProps<typeof Item>) {
123+
return (
124+
<Item
125+
{...props}
126+
className="flex items-center gap-2 px-4 py-1 text-sm cursor-pointer ws-nowrap text-tk-elements-fileTree-folder-textColor hover:bg-tk-elements-fileTree-file-backgroundColorHover"
127+
>
128+
<span className={`${icon} scale-120 shrink-0`}></span>
129+
<span>{children}</span>
130+
</Item>
131+
);
132+
}

0 commit comments

Comments
 (0)