From ea9d6dff14f776356f20833fecb60c7fa260cae4 Mon Sep 17 00:00:00 2001 From: Ben Reinhart Date: Thu, 10 Oct 2024 18:17:39 -0700 Subject: [PATCH] Implement top level create file and folder (#346) --- packages/api/apps/disk.mts | 33 +++++++ packages/api/server/http.mts | 61 ++++++++++++- .../src/components/ui/context-menu.tsx | 4 +- packages/web/src/clients/http/apps.ts | 39 ++++++++ .../web/src/components/apps/lib/file-tree.ts | 33 +++++++ packages/web/src/components/apps/lib/path.ts | 25 ++++++ .../src/components/apps/panels/explorer.tsx | 88 ++++++++++++++----- .../web/src/components/apps/use-files.tsx | 48 +++++++--- .../apps/workspace/editor/editor.tsx | 5 +- 9 files changed, 295 insertions(+), 41 deletions(-) create mode 100644 packages/web/src/components/apps/lib/path.ts diff --git a/packages/api/apps/disk.mts b/packages/api/apps/disk.mts index 45ef24b1..3b8d3958 100644 --- a/packages/api/apps/disk.mts +++ b/packages/api/apps/disk.mts @@ -132,6 +132,26 @@ export async function loadDirectory( }; } +export async function createDirectory( + app: DBAppType, + dirname: string, + basename: string, +): Promise { + const projectDir = Path.join(APPS_DIR, app.externalId); + const dirPath = Path.join(projectDir, dirname, basename); + + await fs.mkdir(dirPath, { recursive: false }); + + const relativePath = Path.relative(projectDir, dirPath); + + return { + type: 'directory' as const, + name: Path.basename(relativePath), + path: relativePath, + children: null, + }; +} + export async function loadFile(app: DBAppType, path: string): Promise { const projectDir = Path.join(APPS_DIR, app.externalId); const filePath = Path.join(projectDir, path); @@ -150,6 +170,19 @@ export async function loadFile(app: DBAppType, path: string): Promise } } +export async function createFile( + app: DBAppType, + dirname: string, + basename: string, + source: string, +): Promise { + const projectDir = Path.join(APPS_DIR, app.externalId); + const filePath = Path.join(projectDir, dirname, basename); + await fs.writeFile(filePath, source, 'utf-8'); + const relativePath = Path.relative(projectDir, filePath); + return { type: 'file' as const, path: relativePath, name: Path.basename(filePath) }; +} + export function deleteFile(app: DBAppType, path: string) { const filePath = Path.join(APPS_DIR, app.externalId, path); return fs.rm(filePath); diff --git a/packages/api/server/http.mts b/packages/api/server/http.mts index 6a5096cc..5c86bc1e 100644 --- a/packages/api/server/http.mts +++ b/packages/api/server/http.mts @@ -35,7 +35,14 @@ import { EXAMPLE_SRCBOOKS } from '../srcbook/examples.mjs'; import { pathToSrcbook } from '../srcbook/path.mjs'; import { isSrcmdPath } from '../srcmd/paths.mjs'; import { loadApps, loadApp, createApp, serializeApp, deleteApp } from '../apps/app.mjs'; -import { deleteFile, renameFile, loadDirectory, loadFile } from '../apps/disk.mjs'; +import { + deleteFile, + renameFile, + loadDirectory, + loadFile, + createFile, + createDirectory, +} from '../apps/disk.mjs'; import { CreateAppSchema } from '../apps/schemas.mjs'; const app: Application = express(); @@ -467,6 +474,8 @@ router.delete('/apps/:id', cors(), async (req, res) => { router.options('/apps/:id/directories', cors()); router.get('/apps/:id/directories', cors(), async (req, res) => { const { id } = req.params; + + // TODO: validate and ensure path is not absolute const path = typeof req.query.path === 'string' ? req.query.path : '.'; try { @@ -484,9 +493,33 @@ router.get('/apps/:id/directories', cors(), async (req, res) => { } }); +router.options('/apps/:id/directories', cors()); +router.post('/apps/:id/directories', cors(), async (req, res) => { + const { id } = req.params; + + // TODO: validate and ensure path is not absolute + const { dirname, basename } = req.body; + + try { + const app = await loadApp(id); + + if (!app) { + return res.status(404).json({ error: 'App not found' }); + } + + const directory = await createDirectory(app, dirname, basename); + + return res.json({ data: directory }); + } catch (e) { + return error500(res, e as Error); + } +}); + router.options('/apps/:id/files', cors()); router.get('/apps/:id/files', cors(), async (req, res) => { const { id } = req.params; + + // TODO: validate and ensure path is not absolute const path = typeof req.query.path === 'string' ? req.query.path : '.'; try { @@ -504,9 +537,33 @@ router.get('/apps/:id/files', cors(), async (req, res) => { } }); +router.options('/apps/:id/files', cors()); +router.post('/apps/:id/files', cors(), async (req, res) => { + const { id } = req.params; + + // TODO: validate and ensure path is not absolute + const { dirname, basename, source } = req.body; + + try { + const app = await loadApp(id); + + if (!app) { + return res.status(404).json({ error: 'App not found' }); + } + + const file = await createFile(app, dirname, basename, source); + + return res.json({ data: file }); + } catch (e) { + return error500(res, e as Error); + } +}); + router.options('/apps/:id/files', cors()); router.delete('/apps/:id/files', cors(), async (req, res) => { const { id } = req.params; + + // TODO: validate and ensure path is not absolute const path = typeof req.query.path === 'string' ? req.query.path : '.'; try { @@ -527,6 +584,8 @@ router.delete('/apps/:id/files', cors(), async (req, res) => { router.options('/apps/:id/files/rename', cors()); router.post('/apps/:id/files/rename', cors(), async (req, res) => { const { id } = req.params; + + // TODO: validate and ensure path is not absolute const path = typeof req.query.path === 'string' ? req.query.path : '.'; const name = req.query.name as string; diff --git a/packages/components/src/components/ui/context-menu.tsx b/packages/components/src/components/ui/context-menu.tsx index aff5183d..9836e0e4 100644 --- a/packages/components/src/components/ui/context-menu.tsx +++ b/packages/components/src/components/ui/context-menu.tsx @@ -60,7 +60,7 @@ const ContextMenuContent = React.forwardRef< { + const response = await fetch(API_BASE_URL + `/apps/${id}/directories`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ dirname, basename }), + }); + + if (!response.ok) { + console.error(response); + throw new Error('Request failed'); + } + + return response.json(); +} + export async function loadFile(id: string, path: string): Promise<{ data: FileType }> { const queryParams = new URLSearchParams({ path }); @@ -100,6 +119,26 @@ export async function loadFile(id: string, path: string): Promise<{ data: FileTy return response.json(); } +export async function createFile( + id: string, + dirname: string, + basename: string, + source: string, +): Promise<{ data: FileEntryType }> { + const response = await fetch(API_BASE_URL + `/apps/${id}/files`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ dirname, basename, source }), + }); + + if (!response.ok) { + console.error(response); + throw new Error('Request failed'); + } + + return response.json(); +} + export async function deleteFile(id: string, path: string): Promise<{ data: { deleted: true } }> { const queryParams = new URLSearchParams({ path }); diff --git a/packages/web/src/components/apps/lib/file-tree.ts b/packages/web/src/components/apps/lib/file-tree.ts index 62497a4e..26e1eb1a 100644 --- a/packages/web/src/components/apps/lib/file-tree.ts +++ b/packages/web/src/components/apps/lib/file-tree.ts @@ -1,5 +1,7 @@ import type { DirEntryType, FileEntryType, FsEntryTreeType } from '@srcbook/shared'; +import { dirname } from './path'; + /** * Sorts a file tree (in place) by name. Folders come first, then files. */ @@ -159,3 +161,34 @@ export function deleteNode(tree: DirEntryType, path: string): DirEntryType { return { ...tree, children }; } + +/** + * Create a new node in the file tree. + */ +export function createNode(tree: DirEntryType, node: DirEntryType | FileEntryType): DirEntryType { + return sortTree(doCreateNode(tree, node, dirname(node.path))); +} + +function doCreateNode( + tree: DirEntryType, + node: DirEntryType | FileEntryType, + dirname: string, +): DirEntryType { + if (tree.children === null) { + return tree; + } + + if (tree.path === dirname) { + return { ...tree, children: [...tree.children, node] }; + } + + const children = tree.children.map((entry) => { + if (entry.type === 'directory') { + return doCreateNode(entry, node, dirname); + } else { + return entry; + } + }); + + return { ...tree, children }; +} diff --git a/packages/web/src/components/apps/lib/path.ts b/packages/web/src/components/apps/lib/path.ts new file mode 100644 index 00000000..c44be407 --- /dev/null +++ b/packages/web/src/components/apps/lib/path.ts @@ -0,0 +1,25 @@ +// This file and client side code assumes posix paths. It is incomplete and handles basic +// functionality. That should be ok as we expect a subset of behavior and assume simple paths. + +const ROOT_PATH = '.'; + +export function dirname(path: string): string { + path = path.trim(); + + if (path === '' || path === ROOT_PATH) { + return ROOT_PATH; + } + + const parts = path.split('/'); + + if (parts.length === 1) { + return '.'; + } + + return parts.pop() || '.'; +} + +export function extname(path: string) { + const idx = path.lastIndexOf('.'); + return idx === -1 ? '' : path.slice(idx); +} diff --git a/packages/web/src/components/apps/panels/explorer.tsx b/packages/web/src/components/apps/panels/explorer.tsx index 6febf53e..3930b89e 100644 --- a/packages/web/src/components/apps/panels/explorer.tsx +++ b/packages/web/src/components/apps/panels/explorer.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { FileIcon, ChevronRightIcon, type LucideIcon, ChevronDownIcon } from 'lucide-react'; import { useFiles } from '../use-files'; import type { DirEntryType, FileEntryType } from '@srcbook/shared'; @@ -11,19 +11,53 @@ import { } from '@srcbook/components/src/components/ui/context-menu'; export default function ExplorerPanel() { - const { fileTree } = useFiles(); + const { fileTree, createFile, createFolder } = useFiles(); const [editingEntry, setEditingEntry] = useState(null); + const [newEntry, setNewEntry] = useState(null); return ( -
    - -
+ + +
    + + {newEntry && ( + { + if (newEntry.type === 'directory') { + createFolder('.', name); + } else { + createFile('.', name); + } + setNewEntry(null); + }} + onCancel={() => setNewEntry(null)} + /> + )} +
+
+ + setNewEntry({ type: 'file', path: 'untitled', name: 'untitled' })} + > + New file... + + + setNewEntry({ type: 'directory', path: 'untitled', name: 'untitled', children: null }) + } + > + New folder... + + +
); } @@ -73,7 +107,7 @@ function FileTree(props: { } else { return entry.name === editingEntry?.name ? (
  • - { @@ -120,28 +154,42 @@ function FileNode(props: { ); } -function RenameFileNode(props: { +function EditNameNode(props: { depth: number; name: string; onSubmit: (name: string) => void; onCancel: () => void; }) { - const [input, setInput] = useState(props.name); + const ref = useRef(null); + + useEffect(() => { + const timeout = setTimeout(() => { + const input = ref.current; + if (!input) return; + const idx = input.value.lastIndexOf('.'); + input.setSelectionRange(0, idx === -1 ? input.value.length : idx); + input.focus(); + }, 25); + + return () => clearTimeout(timeout); + }, []); + return ( { - if (e.key === 'Enter') { - props.onSubmit(input); + if (e.key === 'Enter' && ref.current) { + e.preventDefault(); + e.stopPropagation(); + props.onSubmit(ref.current.value); } else if (e.key === 'Escape') { - props.onCancel(); + ref.current?.blur(); } }} - onChange={(e) => setInput(e.currentTarget.value)} /> ); } diff --git a/packages/web/src/components/apps/use-files.tsx b/packages/web/src/components/apps/use-files.tsx index a1a8587f..1f0a494c 100644 --- a/packages/web/src/components/apps/use-files.tsx +++ b/packages/web/src/components/apps/use-files.tsx @@ -17,25 +17,28 @@ import type { } from '@srcbook/shared'; import { AppChannel } from '@/clients/websocket'; import { + createFile as doCreateFile, deleteFile as doDeleteFile, renameFile as doRenameFile, + createDirectory, loadDirectory, loadFile, } from '@/clients/http/apps'; -import { deleteNode, sortTree, updateDirNode, updateFileNode } from './lib/file-tree'; +import { createNode, deleteNode, sortTree, updateDirNode, updateFileNode } from './lib/file-tree'; export interface FilesContextValue { files: FileType[]; fileTree: DirEntryType; openFile: (entry: FileEntryType) => void; + createFile: (dirname: string, basename: string, source?: string) => void; renameFile: (entry: FileEntryType, name: string) => void; deleteFile: (entry: FileEntryType) => void; openFolder: (entry: DirEntryType) => void; closeFolder: (entry: DirEntryType) => void; toggleFolder: (entry: DirEntryType) => void; isFolderOpen: (entry: DirEntryType) => boolean; + createFolder: (dirname: string, basename: string) => void; openedFile: FileType | null; - createFile: (attrs: FileType) => void; updateFile: (file: FileType, attrs: Partial) => void; } @@ -80,6 +83,27 @@ export function FilesProvider({ app, channel, rootDirEntries, children }: Provid [app.id], ); + const createFile = useCallback( + async (dirname: string, basename: string, source?: string) => { + source = source || ''; + const { data: fileEntry } = await doCreateFile(app.id, dirname, basename, source); + fileTreeRef.current = createNode(fileTreeRef.current, fileEntry); + forceComponentRerender(); // required + openFile(fileEntry); + }, + [app.id, openFile], + ); + + const updateFile = useCallback( + (file: FileType, attrs: Partial) => { + const updatedFile: FileType = { ...file, ...attrs }; + filesRef.current[file.path] = updatedFile; + channel.push('file:updated', { file: updatedFile }); + forceComponentRerender(); + }, + [channel], + ); + const deleteFile = useCallback( async (entry: FileEntryType) => { await doDeleteFile(app.id, entry.path); @@ -149,19 +173,14 @@ export function FilesProvider({ app, channel, rootDirEntries, children }: Provid [isFolderOpen, openFolder, closeFolder], ); - const createFile = useCallback((file: FileType) => { - filesRef.current[file.path] = file; - forceComponentRerender(); - }, []); - - const updateFile = useCallback( - (file: FileType, attrs: Partial) => { - const updatedFile: FileType = { ...file, ...attrs }; - filesRef.current[file.path] = updatedFile; - channel.push('file:updated', { file: updatedFile }); - forceComponentRerender(); + const createFolder = useCallback( + async (dirname: string, basename: string) => { + const { data: folderEntry } = await createDirectory(app.id, dirname, basename); + fileTreeRef.current = createNode(fileTreeRef.current, folderEntry); + forceComponentRerender(); // required + openFolder(folderEntry); }, - [channel], + [app.id, openFolder], ); const files = Object.values(filesRef.current); @@ -177,6 +196,7 @@ export function FilesProvider({ app, channel, rootDirEntries, children }: Provid closeFolder, toggleFolder, isFolderOpen, + createFolder, createFile, updateFile, }; diff --git a/packages/web/src/components/apps/workspace/editor/editor.tsx b/packages/web/src/components/apps/workspace/editor/editor.tsx index e871387f..9c4daa60 100644 --- a/packages/web/src/components/apps/workspace/editor/editor.tsx +++ b/packages/web/src/components/apps/workspace/editor/editor.tsx @@ -8,6 +8,7 @@ import useTheme from '@srcbook/components/src/components/use-theme'; import { useFiles } from '../../use-files'; import { AppType, FileType } from '@srcbook/shared'; import EditorHeader from './header'; +import { extname } from '../../lib/path'; type PropsType = { app: AppType; @@ -32,10 +33,6 @@ export function Editor(props: PropsType) { ); } -function extname(path: string) { - return '.' + path.split('.').pop(); -} - function CodeEditor({ file, onChange,