diff --git a/docs/tutorialkit.dev/src/components/react-examples/ExampleFileTree.tsx b/docs/tutorialkit.dev/src/components/react-examples/ExampleFileTree.tsx index 8510febb..74085176 100644 --- a/docs/tutorialkit.dev/src/components/react-examples/ExampleFileTree.tsx +++ b/docs/tutorialkit.dev/src/components/react-examples/ExampleFileTree.tsx @@ -1,26 +1,32 @@ -import { useState } from 'react'; +import { useState, type ComponentProps } from 'react'; import FileTree from '@tutorialkit/react/core/FileTree'; export default function ExampleFileTree() { - const [selectedFile, setSelectedFile] = useState(FILES[0]); + const [files, setFiles] = useState(INITIAL_FILES); + const [selectedFile, setSelectedFile] = useState(INITIAL_FILES[0].path); return ( { + if (event.method === 'add') { + setFiles([...files, { path: event.value, type: event.type }]); + } + }} /> ); } -const FILES = [ - '/src/index.js', - '/src/index.html', - '/src/assets/logo.svg', - '/package-lock.json', - '/package.json', - '/vite.config.js', +const INITIAL_FILES: ComponentProps['files'] = [ + { path: '/package-lock.json', type: 'file' }, + { path: '/package.json', type: 'file' }, + { path: '/src/assets/logo.svg', type: 'file' }, + { path: '/src/index.html', type: 'file' }, + { path: '/src/index.js', type: 'file' }, + { path: '/vite.config.js', type: 'file' }, ]; diff --git a/docs/tutorialkit.dev/src/components/react-examples/ExampleSimpleEditor.tsx b/docs/tutorialkit.dev/src/components/react-examples/ExampleSimpleEditor.tsx index dbb3c29e..ceda5f55 100644 --- a/docs/tutorialkit.dev/src/components/react-examples/ExampleSimpleEditor.tsx +++ b/docs/tutorialkit.dev/src/components/react-examples/ExampleSimpleEditor.tsx @@ -227,7 +227,7 @@ const FILES: Record = { }, }; -const FILE_PATHS = Object.keys(FILES); +const FILE_PATHS = Object.keys(FILES).map((path) => ({ path, type: 'file' }) as const); function stripIndent(string: string) { const indent = minIndent(string.slice(1)); diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx index 36720ff3..4884dce8 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx @@ -134,8 +134,34 @@ Defines which file should be opened in the [code editor](/guides/ui/#code-editor ##### `editor` -Configure whether or not the editor should be rendered. If an object is provided with `fileTree: false`, only the file tree is hidden. - +Configures options for the editor and its file tree. Editor can be hidden by providing `false`. +Optionally you can hide just file tree by providing `fileTree: false`. + +File tree can be set to allow file editing from right clicks by setting `fileTree.allowEdits: true`. + + + +The `Editor` type has the following shape: + +```ts +type Editor = + | false + | { editor: { allowEdits: boolean } } + +``` + +Example values: + +```yaml +editor: false # Editor is hidden + +editor: # Editor is visible + fileTree: false # File tree is hidden + +editor: # Editor is visible + fileTree: # File tree is visible + allowEdits: true # User can add new files and folders from the file tree +``` ##### `previews` 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. 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 3dd41c0e..e7042d82 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx @@ -107,12 +107,23 @@ A component to list files in a tree view. * `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. +* `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. + ```ts + interface FileChangeEvent { + type: 'file' | 'folder'; + method: 'add' | 'remove' | 'rename'; + value: string; + } + ``` + * `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. * `scope?: string` - Every file path that does not start with this scope will be hidden. +* `i18n?: object` - Texts for file tree's components. + * `className?: string` - A class name to apply to the root element of the component. diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_files/first-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_files/first-level/file.js new file mode 100644 index 00000000..7e6506ff --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_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-disabled/_files/first-level/second-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/_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-disabled/_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-disabled/content.md b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/content.md new file mode 100644 index 00000000..bdd99792 --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-disabled/content.md @@ -0,0 +1,11 @@ +--- +type: lesson +title: Allow Edits Disabled +previews: false +terminal: + panels: terminal +--- + +# File Tree test - Allow Edits Disabled + +Option `editor.fileTree.allowEdits` has default `false` value. diff --git a/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_files/first-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_files/first-level/file.js new file mode 100644 index 00000000..7e6506ff --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_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-enabled/_files/first-level/second-level/file.js b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/_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-enabled/_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-enabled/content.md b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/content.md new file mode 100644 index 00000000..b45c7cb4 --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/allow-edits-enabled/content.md @@ -0,0 +1,12 @@ +--- +type: lesson +title: Allow Edits Enabled +previews: false +editor: + fileTree: + allowEdits: true +terminal: + panels: terminal +--- + +# File Tree test - Allow Edits Enabled diff --git a/e2e/src/content/tutorial/tests/file-tree/hidden/_files/example.js b/e2e/src/content/tutorial/tests/file-tree/hidden/_files/example.js new file mode 100644 index 00000000..cd356077 --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/hidden/_files/example.js @@ -0,0 +1 @@ +export default 'Lesson file example.js content'; diff --git a/e2e/src/content/tutorial/tests/file-tree/hidden/content.md b/e2e/src/content/tutorial/tests/file-tree/hidden/content.md new file mode 100644 index 00000000..95888143 --- /dev/null +++ b/e2e/src/content/tutorial/tests/file-tree/hidden/content.md @@ -0,0 +1,9 @@ +--- +type: lesson +title: Hidden +editor: + fileTree: false +focus: /example.js +--- + +# File Tree test - Hidden diff --git a/e2e/test/file-tree.test.ts b/e2e/test/file-tree.test.ts index 2f062049..ef935215 100644 --- a/e2e/test/file-tree.test.ts +++ b/e2e/test/file-tree.test.ts @@ -57,3 +57,101 @@ test('user can see cannot click solve on lessons without solution files', async // reset-button should be immediately visible await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible(); }); + +// TODO: Requires #245 +test.skip('user should not see hidden file tree', async ({ page }) => { + await page.goto(`${BASE_URL}/hidden`); + await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Hidden' })).toBeVisible(); + + await expect(page.getByText('Files')).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'example.js' })).not.toBeVisible(); +}); + +test('user cannot create files or folders when lesson is not configured via allowEdits', async ({ page }) => { + await page.goto(`${BASE_URL}/allow-edits-disabled`); + + await expect(page.getByTestId('file-tree-root-context-menu')).not.toBeVisible(); + + await page.getByRole('button', { name: 'first-level' }).click({ button: 'right' }); + await expect(page.getByRole('menuitem', { name: 'Create file' })).not.toBeVisible(); +}); + +test('user can create files', async ({ page }) => { + await page.goto(`${BASE_URL}/allow-edits-enabled`); + await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Enabled' })).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 }); + + for (const [locator, filename] of [ + [page.getByTestId('file-tree-root-context-menu'), 'file-in-root.js'], + [page.getByRole('button', { name: 'first-level' }), 'file-in-first-level.js'], + [page.getByRole('button', { name: 'second-level' }), 'file-in-second-level.js'], + ] as const) { + await locator.click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Create file' }).click(); + + await page.locator('*:focus').fill(filename); + await page.locator('*:focus').press('Enter'); + await expect(page.getByRole('button', { name: filename, pressed: true })).toBeVisible(); + } + + // verify that all files are present on file tree after last creation + await expect(page.getByRole('button', { name: 'file-in-root.js' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'file-in-first-level' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'file-in-second-level' })).toBeVisible(); + + // verify that files are present on file system via terminal + for (const [directory, filename] of [ + ['./', 'file-in-root.js'], + ['./first-level', 'file-in-first-level.js'], + ['./first-level/second-level', 'file-in-second-level.js'], + ]) { + await terminal.fill(`clear; ls ${directory}`); + await terminal.press('Enter'); + + await expect(terminalOutput).toContainText(filename, { useInnerText: true }); + } +}); + +test('user can create folders', async ({ page }) => { + await page.goto(`${BASE_URL}/allow-edits-enabled`); + await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits Enabled' })).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 }); + + for (const [locator, folder] of [ + [page.getByTestId('file-tree-root-context-menu'), 'folder-1'], + [page.getByRole('button', { name: 'folder-1' }), 'folder-2'], + [page.getByRole('button', { name: 'folder-2' }), 'folder-3'], + ] as const) { + await locator.click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Create folder' }).click(); + + await page.locator('*:focus').fill(folder); + await page.locator('*:focus').press('Enter'); + await expect(page.getByRole('button', { name: folder })).toBeVisible(); + } + + // verify that all folders are present on file tree after last creation + await expect(page.getByRole('button', { name: 'folder-1' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'folder-2' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'folder-3' })).toBeVisible(); + + // verify that files are present on file system via terminal + for (const [directory, folder] of [ + ['./', 'folder-1'], + ['./folder-1', 'folder-2'], + ['./folder-1/folder-2', 'folder-3'], + ]) { + await terminal.fill(`clear; ls ${directory}`); + await terminal.press('Enter'); + + await expect(terminalOutput).toContainText(folder, { useInnerText: true }); + } +}); diff --git a/packages/astro/src/default/utils/content/default-localization.ts b/packages/astro/src/default/utils/content/default-localization.ts index 427a0f8d..a5cdc8c7 100644 --- a/packages/astro/src/default/utils/content/default-localization.ts +++ b/packages/astro/src/default/utils/content/default-localization.ts @@ -7,10 +7,12 @@ export const DEFAULT_LOCALIZATION = { editPageText: 'Edit this page', webcontainerLinkText: 'Powered by WebContainers', filesTitleText: 'Files', + fileTreeCreateFileText: 'Create file', + fileTreeCreateFolderText: 'Create folder', prepareEnvironmentTitleText: 'Preparing Environment', defaultPreviewTitleText: 'Preview', reloadPreviewTitle: 'Reload Preview', toggleTerminalButtonText: 'Toggle Terminal', solveButtonText: 'Solve', resetButtonText: 'Reset', -} satisfies Lesson['data']['i18n']; +} satisfies Required; diff --git a/packages/react/package.json b/packages/react/package.json index b4e50345..03d74458 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -74,6 +74,7 @@ "@lezer/lr": "^1.0.0", "@nanostores/react": "0.7.2", "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-context-menu": "^2.2.1", "@replit/codemirror-lang-svelte": "^6.0.0", "@tutorialkit/runtime": "workspace:*", "@tutorialkit/theme": "workspace:*", diff --git a/packages/react/src/Panels/EditorPanel.tsx b/packages/react/src/Panels/EditorPanel.tsx index 4e9a3923..4447182e 100644 --- a/packages/react/src/Panels/EditorPanel.tsx +++ b/packages/react/src/Panels/EditorPanel.tsx @@ -1,5 +1,5 @@ import type { I18n } from '@tutorialkit/types'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, type ComponentProps } from 'react'; import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels'; import { CodeMirrorEditor, @@ -17,7 +17,7 @@ const DEFAULT_FILE_TREE_SIZE = 25; interface Props { theme: Theme; id: unknown; - files: string[]; + files: ComponentProps['files']; i18n: I18n; hideRoot?: boolean; fileTreeScope?: string; @@ -29,6 +29,7 @@ interface Props { onEditorScroll?: OnEditorScroll; onHelpClick?: () => void; onFileSelect?: (value?: string) => void; + onFileTreeChange?: ComponentProps['onFileChange']; } export function EditorPanel({ @@ -46,6 +47,7 @@ export function EditorPanel({ onEditorScroll, onHelpClick, onFileSelect, + onFileTreeChange, }: Props) { const fileTreePanelRef = useRef(null); @@ -76,11 +78,13 @@ export function EditorPanel({ ['onFileTreeChange']>>[0]; + interface Props { tutorialStore: TutorialStore; theme: Theme; @@ -96,7 +98,9 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { const selectedFile = useStore(tutorialStore.selectedFile); const currentDocument = useStore(tutorialStore.currentDocument); const lessonFullyLoaded = useStore(tutorialStore.lessonFullyLoaded); + const editorConfig = useStore(tutorialStore.editorConfig); const storeRef = useStore(tutorialStore.ref); + const files = useStore(tutorialStore.files); const lesson = tutorialStore.lesson!; @@ -118,6 +122,16 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { } } + function onFileTreeChange({ method, type, value }: FileTreeChangeEvent) { + if (method === 'add' && type === 'file') { + return tutorialStore.addFile(value); + } + + if (method === 'add' && type === 'folder') { + return tutorialStore.addFolder(value); + } + } + useEffect(() => { if (tutorialStore.hasSolution()) { setHelpAction('solve'); @@ -140,12 +154,13 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) { theme={theme} showFileTree={tutorialStore.hasFileTree()} editorDocument={currentDocument} - files={lesson.files[1]} + files={files} i18n={lesson.data.i18n as I18n} hideRoot={lesson.data.hideRoot} helpAction={helpAction} onHelpClick={lessonFullyLoaded ? onHelpClick : undefined} onFileSelect={(filePath) => tutorialStore.setSelectedFile(filePath)} + onFileTreeChange={editorConfig.fileTree.allowEdits ? onFileTreeChange : undefined} selectedFile={selectedFile} onEditorScroll={(position) => tutorialStore.setCurrentDocumentScrollPosition(position)} onEditorChange={(update) => tutorialStore.setCurrentDocumentContent(update.content)} diff --git a/packages/react/src/core/ContextMenu.tsx b/packages/react/src/core/ContextMenu.tsx new file mode 100644 index 00000000..ddb3dfa8 --- /dev/null +++ b/packages/react/src/core/ContextMenu.tsx @@ -0,0 +1,130 @@ +import { useRef, useState, type ComponentProps } from 'react'; +import { Root, Portal, Content, Item, Trigger } from '@radix-ui/react-context-menu'; +import type { FileDescriptor, I18n } from '@tutorialkit/types'; + +interface FileChangeEvent { + type: FileDescriptor['type']; + method: 'add' | 'remove' | 'rename'; + value: string; +} + +interface FileRenameEvent extends FileChangeEvent { + method: 'rename'; + oldValue: string; +} + +interface Props extends ComponentProps<'div'> { + /** Callback invoked when file is changed. */ + onFileChange?: (event: FileChangeEvent | FileRenameEvent) => void; + + /** Directory of the clicked file. */ + directory: string; + + /** Whether to render new files/directories before or after the trigger element. Defaults to `'before'`. */ + position?: 'before' | 'after'; + + /** Localized texts for menu. */ + i18n?: Pick; + + /** Props for trigger wrapper. */ + triggerProps?: ComponentProps<'div'> & { 'data-testid'?: string }; +} + +export function ContextMenu({ + onFileChange, + directory, + i18n, + position = 'before', + children, + triggerProps, + ...props +}: Props) { + const [state, setState] = useState<'idle' | 'add_file' | 'add_folder'>('idle'); + const inputRef = useRef(null); + + if (!onFileChange) { + return children; + } + + function onFileNameEnd(event: React.KeyboardEvent | React.FocusEvent) { + const name = event.currentTarget.value; + + if (name) { + onFileChange?.({ + value: `${directory}/${name}`, + type: state === 'add_file' ? 'file' : 'folder', + method: 'add', + }); + } + + setState('idle'); + } + + function onFileNameKeyPress(event: React.KeyboardEvent) { + if (event.key === 'Enter' && event.currentTarget.value !== '') { + onFileNameEnd(event); + } + } + + function onCloseAutoFocus(event: Event) { + if ((state === 'add_file' || state === 'add_folder') && inputRef.current) { + event.preventDefault(); + inputRef.current.focus(); + } + } + + const element = ( + +
{children}
+
+ ); + + return ( + + {position === 'before' && element} + + {state !== 'idle' && ( +
+
+ +
+ )} + + {position === 'after' && element} + + + + setState('add_file')}> + {i18n?.fileTreeCreateFileText || 'Create file'} + + + setState('add_folder')}> + {i18n?.fileTreeCreateFolderText || 'Create folder'} + + + + + ); +} + +function MenuItem({ icon, children, ...props }: { icon: string } & ComponentProps) { + return ( + + + {children} + + ); +} diff --git a/packages/react/src/core/FileTree.spec.ts b/packages/react/src/core/FileTree.spec.ts new file mode 100644 index 00000000..41246728 --- /dev/null +++ b/packages/react/src/core/FileTree.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from 'vitest'; +import type { FileDescriptor } from '@tutorialkit/types'; +import { sortFiles } from './FileTree.js'; + +expect.addSnapshotSerializer({ + serialize: (val) => `[${val.type}] ${val.path}`, + test: (val) => val?.type === 'file' || val?.type === 'folder', +}); + +test('initial files are sorted', () => { + const files: FileDescriptor[] = [ + { path: 'test/math.test.ts', type: 'file' }, + { path: '.gitignore', type: 'file' }, + { path: 'src/math.ts', type: 'file' }, + { path: 'src/geometry.ts', type: 'file' }, + ]; + + files.sort(sortFiles); + + expect(files).toMatchInlineSnapshot(` + [ + [file] src/geometry.ts, + [file] src/math.ts, + [file] test/math.test.ts, + [file] .gitignore, + ] + `); +}); + +test('added files are sorted', () => { + const files: FileDescriptor[] = [ + { path: 'test/math.test.ts', type: 'file' }, + { path: '.gitignore', type: 'file' }, + { path: 'src/math.ts', type: 'file' }, + + // added files at the end of the list + { path: 'something.ts', type: 'file' }, + { path: 'another.css', type: 'file' }, + { path: 'no-extension', type: 'file' }, + ]; + + files.sort(sortFiles); + + expect(files).toMatchInlineSnapshot(` + [ + [file] src/math.ts, + [file] test/math.test.ts, + [file] .gitignore, + [file] another.css, + [file] no-extension, + [file] something.ts, + ] + `); +}); + +test('added folders are sorted', () => { + const files: FileDescriptor[] = [ + { path: 'test/math.test.ts', type: 'file' }, + { path: '.gitignore', type: 'file' }, + { path: 'src/math.ts', type: 'file' }, + + // added files at the end of the list + { path: 'src/components', type: 'folder' }, + { path: 'src/utils', type: 'folder' }, + { path: 'test/unit', type: 'folder' }, + { path: 'e2e', type: 'folder' }, + ]; + + files.sort(sortFiles); + + expect(files).toMatchInlineSnapshot(` + [ + [folder] e2e, + [folder] src/components, + [folder] src/utils, + [file] src/math.ts, + [folder] test/unit, + [file] test/math.test.ts, + [file] .gitignore, + ] + `); +}); diff --git a/packages/react/src/core/FileTree.tsx b/packages/react/src/core/FileTree.tsx index d3ccccd4..87bd9122 100644 --- a/packages/react/src/core/FileTree.tsx +++ b/packages/react/src/core/FileTree.tsx @@ -1,20 +1,34 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type ComponentProps, type ReactNode } from 'react'; +import type { FileDescriptor } from '@tutorialkit/types'; +import { ContextMenu } from './ContextMenu.js'; import { classNames } from '../utils/classnames.js'; const NODE_PADDING_LEFT = 12; const DEFAULT_HIDDEN_FILES = [/\/node_modules\//]; interface Props { - files: string[]; + files: FileDescriptor[]; selectedFile?: string; onFileSelect?: (filePath: string) => void; + onFileChange?: ComponentProps['onFileChange']; + i18n?: ComponentProps['i18n']; hideRoot: boolean; scope?: string; hiddenFiles?: Array; className?: string; } -export function FileTree({ files, onFileSelect, selectedFile, hideRoot, scope, hiddenFiles, className }: Props) { +export function FileTree({ + files, + onFileSelect, + onFileChange, + selectedFile, + hideRoot, + scope, + hiddenFiles, + i18n, + className, +}: Props) { const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]); const fileList = useMemo( @@ -73,7 +87,7 @@ export function FileTree({ files, onFileSelect, selectedFile, hideRoot, scope, h } return ( -
+
{filteredFileList.map((fileOrFolder) => { switch (fileOrFolder.kind) { case 'file': { @@ -91,13 +105,24 @@ export function FileTree({ files, onFileSelect, selectedFile, hideRoot, scope, h toggleCollapseState(fileOrFolder.id)} + onFileChange={onFileChange} /> ); } } })} + +
); } @@ -108,24 +133,28 @@ interface FolderProps { folder: FolderNode; collapsed: boolean; onClick: () => void; + onFileChange: Props['onFileChange']; + i18n: Props['i18n']; } -function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) { +function Folder({ folder: { depth, name, fullPath }, i18n, collapsed, onClick, onFileChange }: FolderProps) { return ( - - {name} - + + + {name} + + ); } @@ -170,8 +199,8 @@ function NodeButton({ depth, iconClasses, onClick, className, 'aria-pressed': ar return (