diff --git a/packages/api/apps/disk.mts b/packages/api/apps/disk.mts index e1d30f32..4a1ec6e6 100644 --- a/packages/api/apps/disk.mts +++ b/packages/api/apps/disk.mts @@ -4,8 +4,7 @@ import { fileURLToPath } from 'node:url'; import { type App as DBAppType } from '../db/schema.mjs'; import { APPS_DIR } from '../constants.mjs'; import { toValidPackageName } from './utils.mjs'; -import { Dirent } from 'node:fs'; -import { FileType } from '@srcbook/shared'; +import { DirEntryType, FileType } from '@srcbook/shared'; export function pathToApp(id: string) { return Path.join(APPS_DIR, id); @@ -91,57 +90,58 @@ async function copyDir(srcDir: string, destDir: string) { } } -// TODO: This does not scale. -export async function getProjectFiles(app: DBAppType) { +export async function loadDirectory(app: DBAppType, path: string): Promise { const projectDir = Path.join(APPS_DIR, app.externalId); - - const { files, directories } = await getDiskEntries(projectDir, { - exclude: ['node_modules', 'dist'], + const dirPath = Path.join(projectDir, path); + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + const children = entries.map((entry) => { + const fullPath = Path.join(dirPath, entry.name); + const relativePath = Path.relative(projectDir, fullPath); + + if (entry.isDirectory()) { + return { + type: 'directory' as const, + name: entry.name, + path: relativePath, + children: null, + }; + } else { + return { + type: 'file' as const, + name: entry.name, + path: relativePath, + }; + } }); - const nestedFiles = await Promise.all( - directories.flatMap(async (dir) => { - const entries = await fs.readdir(Path.join(projectDir, dir.name), { - withFileTypes: true, - recursive: true, - }); - return entries.filter((entry) => entry.isFile()); - }), - ); - - const entries = [...files, ...nestedFiles.flat()]; - - return Promise.all( - entries.map(async (entry) => { - const fullPath = Path.join(entry.parentPath, entry.name); - const relativePath = Path.relative(projectDir, fullPath); - const contents = await fs.readFile(fullPath); - const binary = isBinary(entry.name); - const source = !binary ? contents.toString('utf-8') : `TODO: handle this`; - return { path: relativePath, source, binary }; - }), - ); -} + const relativePath = Path.relative(projectDir, dirPath); + const basename = Path.basename(relativePath); -async function getDiskEntries(projectDir: string, options: { exclude: string[] }) { - const result: { files: Dirent[]; directories: Dirent[] } = { - files: [], - directories: [], + return { + type: 'directory' as const, + name: basename === '' ? '.' : basename, + path: relativePath === '' ? '.' : relativePath, + children: children, }; +} - for (const entry of await fs.readdir(projectDir, { withFileTypes: true })) { - if (options.exclude.includes(entry.name)) { - continue; - } +export async function loadFile(app: DBAppType, path: string): Promise { + const projectDir = Path.join(APPS_DIR, app.externalId); + const filePath = Path.join(projectDir, path); + const relativePath = Path.relative(projectDir, filePath); + const basename = Path.basename(filePath); - if (entry.isFile()) { - result.files.push(entry); - } else { - result.directories.push(entry); - } + if (isBinary(basename)) { + return { path: relativePath, name: basename, source: `TODO: handle this`, binary: true }; + } else { + return { + path: relativePath, + name: basename, + source: await fs.readFile(filePath, 'utf-8'), + binary: false, + }; } - - return result; } // TODO: This does not scale. diff --git a/packages/api/server/channels/app.mts b/packages/api/server/channels/app.mts index cd2dcd62..101b15ab 100644 --- a/packages/api/server/channels/app.mts +++ b/packages/api/server/channels/app.mts @@ -15,7 +15,7 @@ import WebSocketServer, { type ConnectionContextType, } from '../ws-client.mjs'; import { loadApp } from '../../apps/app.mjs'; -import { fileUpdated, getProjectFiles, pathToApp } from '../../apps/disk.mjs'; +import { fileUpdated, pathToApp } from '../../apps/disk.mjs'; import { vite } from '../../exec.mjs'; type AppContextType = MessageContextType<'appId'>; @@ -126,9 +126,5 @@ export function register(wss: WebSocketServer) { { url: null, status: existingProcess ? 'running' : 'stopped' }, ]), ); - - for (const file of await getProjectFiles(app)) { - ws.send(JSON.stringify([topic, 'file', { file }])); - } }); } diff --git a/packages/api/server/http.mts b/packages/api/server/http.mts index c5678881..00451f65 100644 --- a/packages/api/server/http.mts +++ b/packages/api/server/http.mts @@ -36,6 +36,7 @@ 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 { loadDirectory, loadFile } from '../apps/disk.mjs'; import { CreateAppSchema } from '../apps/schemas.mjs'; const app: Application = express(); @@ -466,6 +467,46 @@ 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; + const path = typeof req.query.path === 'string' ? req.query.path : '.'; + + try { + const app = await loadApp(id); + + if (!app) { + return res.status(404).json({ error: 'App not found' }); + } + + const directory = await loadDirectory(app, path); + + 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; + const path = typeof req.query.path === 'string' ? req.query.path : '.'; + + try { + const app = await loadApp(id); + + if (!app) { + return res.status(404).json({ error: 'App not found' }); + } + + const file = await loadFile(app, path); + + return res.json({ data: file }); + } catch (e) { + return error500(res, e as Error); + } +}); + app.use('/api', router); export default app; diff --git a/packages/shared/src/schemas/apps.mts b/packages/shared/src/schemas/apps.mts index 5221bfd6..95f8a64b 100644 --- a/packages/shared/src/schemas/apps.mts +++ b/packages/shared/src/schemas/apps.mts @@ -2,6 +2,7 @@ import z from 'zod'; export const FileSchema = z.object({ path: z.string(), + name: z.string(), source: z.string(), binary: z.boolean(), }); diff --git a/packages/shared/src/types/apps.mts b/packages/shared/src/types/apps.mts index 001a35d8..a23f75af 100644 --- a/packages/shared/src/types/apps.mts +++ b/packages/shared/src/types/apps.mts @@ -11,4 +11,20 @@ export type AppType = { updatedAt: number; }; +export type DirEntryType = { + type: 'directory'; + name: string; + path: string; + // null if not loaded + children: FsEntryTreeType | null; +}; + +export type FileEntryType = { + type: 'file'; + name: string; + path: string; +}; + +export type FsEntryTreeType = Array; + export type FileType = z.infer; diff --git a/packages/web/src/clients/http/apps.ts b/packages/web/src/clients/http/apps.ts index b5fe3252..e2934714 100644 --- a/packages/web/src/clients/http/apps.ts +++ b/packages/web/src/clients/http/apps.ts @@ -1,4 +1,4 @@ -import type { AppType, CodeLanguageType } from '@srcbook/shared'; +import type { AppType, CodeLanguageType, DirEntryType, FileType } from '@srcbook/shared'; import SRCBOOK_CONFIG from '@/config'; const API_BASE_URL = `${SRCBOOK_CONFIG.api.origin}/api`; @@ -61,3 +61,35 @@ export async function loadApp(id: string): Promise<{ data: AppType }> { return response.json(); } + +export async function loadDirectory(id: string, path: string): Promise<{ data: DirEntryType }> { + const queryParams = new URLSearchParams({ path }); + + const response = await fetch(API_BASE_URL + `/apps/${id}/directories?${queryParams}`, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + + 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 }); + + const response = await fetch(API_BASE_URL + `/apps/${id}/files?${queryParams}`, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + + if (!response.ok) { + console.error(response); + throw new Error('Request failed'); + } + + return response.json(); +} diff --git a/packages/web/src/components/apps/lib/file-tree.ts b/packages/web/src/components/apps/lib/file-tree.ts new file mode 100644 index 00000000..c35f8fa7 --- /dev/null +++ b/packages/web/src/components/apps/lib/file-tree.ts @@ -0,0 +1,96 @@ +import type { DirEntryType, FsEntryTreeType } from '@srcbook/shared'; + +/** + * Sorts a file tree (in place) by name. Folders come first, then files. + */ +export function sortTree(tree: DirEntryType): DirEntryType { + tree.children?.sort((a, b) => { + if (a.type === 'directory') sortTree(a); + if (b.type === 'directory') sortTree(b); + if (a.type === 'directory' && b.type === 'file') return -1; + if (a.type === 'file' && b.type === 'directory') return 1; + return a.name.localeCompare(b.name); + }); + + return tree; +} + +/** + * Update a node in the file tree. + * + * This function is complex due to the merging of children. We do it to maintain + * nested state of a given tree. Consider the following file tree that the user + * has open in their file tree viewer: + * + * /src + * │ + * ├── components + * │ ├── ui + * │ │ └── table + * │ │ ├── index.tsx + * │ │ └── show.tsx + * │ │ + * │ └── use-files.tsx + * │ + * └── index.tsx + * + * If the user closes and then reopens the "components" folder, the reopening of + * the "components" folder will make a call to load its children. However, calls + * to load children only load the immediate children, not all nested children. + * This means that the call will not load the "ui" folder's children. + * + * Now, given that the user had previously opened the "ui" folder and we have the + * results of that folder loaded in our state, we don't want to throw away those + * values. So we merge the children of the new node and any nested children of + * the old node. + * + * This supports behavior where a user may open many nested folders and then close + * and later reopen a ancestor folder. We want the tree to look the same when the + * reopen occurs with only the immediate children updated. + */ +export function updateTree(tree: DirEntryType, node: DirEntryType): DirEntryType { + if (tree.path === node.path) { + if (node.children === null) { + return { ...node, children: tree.children }; + } else { + return { ...node, children: merge(tree.children, node.children) }; + } + } + + if (tree.children) { + return { + ...tree, + children: tree.children.map((entry) => { + if (entry.type === 'directory') { + return updateTree(entry, node); + } else { + return entry; + } + }), + }; + } + + return tree; +} + +function merge(oldChildren: FsEntryTreeType | null, newChildren: FsEntryTreeType): FsEntryTreeType { + if (!oldChildren) { + return newChildren; + } + + return newChildren.map((newChild) => { + const oldChild = oldChildren.find((old) => old.path === newChild.path); + + if (oldChild && oldChild.type === 'directory' && newChild.type === 'directory') { + return { + ...newChild, + children: + newChild.children === null + ? oldChild.children + : merge(oldChild.children, newChild.children), + }; + } + + return newChild; + }); +} diff --git a/packages/web/src/components/apps/panels/explorer.tsx b/packages/web/src/components/apps/panels/explorer.tsx index a44b3096..d75e3cd4 100644 --- a/packages/web/src/components/apps/panels/explorer.tsx +++ b/packages/web/src/components/apps/panels/explorer.tsx @@ -1,44 +1,87 @@ -import { Folder } from 'lucide-react'; -import { useFiles, type FileTreeType } from '../use-files'; -import { FileType } from '@srcbook/shared'; +import { FileIcon, ChevronRightIcon, type LucideIcon, ChevronDownIcon } from 'lucide-react'; +import { useFiles } from '../use-files'; +import type { DirEntryType } from '@srcbook/shared'; import { cn } from '@srcbook/components'; export default function ExplorerPanel() { - const { fileTree, openedFile, setOpenedFile } = useFiles(); - return ; + const { fileTree } = useFiles(); + + return ( +
    + +
+ ); } type FileTreePropsType = { - tree: FileTreeType; - openedFile: FileType | null; - setOpenedFile: (file: FileType) => void; + depth: number; + tree: DirEntryType; }; -function FileTree({ tree, openedFile, setOpenedFile }: FileTreePropsType) { +function FileTree({ depth, tree }: FileTreePropsType) { + const { openFile, toggleFolder, isFolderOpen, openedFile } = useFiles(); + + if (tree.children === null) { + return null; + } + + return tree.children.flatMap((entry) => { + if (entry.type === 'directory') { + const opened = isFolderOpen(entry); + + const elements = [ +
  • + toggleFolder(entry)} + /> +
  • , + ]; + + if (opened) { + elements.push(); + } + + return elements; + } else { + return ( +
  • + openFile(entry)} + /> +
  • + ); + } + }); +} + +function Node(props: { + depth: number; + label: string; + icon: LucideIcon; + active: boolean; + onClick: () => void; +}) { + const { depth, label, icon: Icon, active, onClick } = props; + return ( -
      - {tree.map((entry) => - entry.directory ? ( -
    • -
      - {entry.name} -
      - -
    • - ) : ( -
    • - -
    • - ), + ); } diff --git a/packages/web/src/components/apps/sidebar.tsx b/packages/web/src/components/apps/sidebar.tsx index 49f16b49..6e01d36e 100644 --- a/packages/web/src/components/apps/sidebar.tsx +++ b/packages/web/src/components/apps/sidebar.tsx @@ -160,13 +160,13 @@ function Panel(props: {

      {props.title}

      -
      {props.children}
      +
      {props.children}
      ); } diff --git a/packages/web/src/components/apps/use-files.tsx b/packages/web/src/components/apps/use-files.tsx index c54ec7a3..af8a1031 100644 --- a/packages/web/src/components/apps/use-files.tsx +++ b/packages/web/src/components/apps/use-files.tsx @@ -8,69 +8,26 @@ import React, { useState, } from 'react'; -import type { FilePayloadType, FileType } from '@srcbook/shared'; +import type { + FilePayloadType, + FileType, + DirEntryType, + FileEntryType, + AppType, +} from '@srcbook/shared'; import { AppChannel } from '@/clients/websocket'; - -export type DirEntryType = { directory: true; name: string; children: FileTreeType }; -export type FileEntryType = { directory: false; name: string; file: FileType }; -export type FileTreeType = Array; - -function createSortedFileTree(files: FileType[]): FileTreeType { - const tree = createFileTree(files); - sortTree(tree); - return tree; -} - -function sortTree(tree: FileTreeType) { - tree.sort((a, b) => { - if (a.directory) sortTree(a.children); - if (b.directory) sortTree(b.children); - if (a.directory && !b.directory) return -1; - if (!a.directory && b.directory) return 1; - return a.name.localeCompare(b.name); - }); -} - -function createFileTree(files: FileType[]): FileTreeType { - const result: FileTreeType = []; - - for (const file of files) { - let current = result; - - const parts = file.path.split('/'); - - if (parts.length === 1) { - current.push({ directory: false, name: file.path, file }); - continue; - } - - const lastIdx = parts.length - 1; - - for (let i = 0; i < lastIdx; i++) { - const dirEntry = current.find((entry) => entry.directory && entry.name === parts[i]) as - | DirEntryType - | undefined; - - if (!dirEntry) { - const next: DirEntryType = { directory: true, name: parts[i]!, children: [] }; - current.push(next); - current = next.children; - } else { - current = dirEntry.children; - } - } - - current.push({ directory: false, name: parts[lastIdx]!, file }); - } - - return result; -} +import { loadDirectory, loadFile } from '@/clients/http/apps'; +import { sortTree, updateTree } from './lib/file-tree'; export interface FilesContextValue { files: FileType[]; - fileTree: FileTreeType; + fileTree: DirEntryType; + openFile: (entry: FileEntryType) => void; + openFolder: (entry: DirEntryType) => void; + closeFolder: (entry: DirEntryType) => void; + toggleFolder: (entry: DirEntryType) => void; + isFolderOpen: (entry: DirEntryType) => boolean; openedFile: FileType | null; - setOpenedFile: React.Dispatch>; createFile: (attrs: FileType) => void; updateFile: (file: FileType, attrs: Partial) => void; deleteFile: (file: FileType) => void; @@ -79,11 +36,13 @@ export interface FilesContextValue { const FilesContext = createContext(undefined); type ProviderPropsType = { + app: AppType; channel: AppChannel; children: React.ReactNode; + rootDirEntries: DirEntryType; }; -export function FilesProvider({ channel, children }: ProviderPropsType) { +export function FilesProvider({ app, channel, rootDirEntries, children }: ProviderPropsType) { // Because we use refs for our state, we need a way to trigger // component re-renders when the ref state changes. // @@ -92,6 +51,8 @@ export function FilesProvider({ channel, children }: ProviderPropsType) { const [, forceComponentRerender] = useReducer((x) => x + 1, 0); const filesRef = useRef>({}); + const fileTreeRef = useRef(sortTree(rootDirEntries)); + const openedDirectoriesRef = useRef>(new Set()); const [openedFile, setOpenedFile] = useState(null); @@ -104,6 +65,47 @@ export function FilesProvider({ channel, children }: ProviderPropsType) { return () => channel.off('file', onFile); }, [channel, forceComponentRerender]); + const openFile = useCallback( + async (entry: FileEntryType) => { + const { data: file } = await loadFile(app.id, entry.path); + filesRef.current[file.path] = file; + setOpenedFile(file); + }, + [app.id], + ); + + const isFolderOpen = useCallback((entry: DirEntryType) => { + return openedDirectoriesRef.current.has(entry.path); + }, []); + + const openFolder = useCallback( + async (entry: DirEntryType) => { + // Optimistically open the folder. + openedDirectoriesRef.current.add(entry.path); + forceComponentRerender(); + const { data: directory } = await loadDirectory(app.id, entry.path); + fileTreeRef.current = sortTree(updateTree(fileTreeRef.current, directory)); + forceComponentRerender(); + }, + [app.id], + ); + + const closeFolder = useCallback((entry: DirEntryType) => { + openedDirectoriesRef.current.delete(entry.path); + forceComponentRerender(); + }, []); + + const toggleFolder = useCallback( + (entry: DirEntryType) => { + if (isFolderOpen(entry)) { + closeFolder(entry); + } else { + openFolder(entry); + } + }, + [isFolderOpen, openFolder, closeFolder], + ); + const createFile = useCallback((file: FileType) => { filesRef.current[file.path] = file; forceComponentRerender(); @@ -125,13 +127,16 @@ export function FilesProvider({ channel, children }: ProviderPropsType) { }, []); const files = Object.values(filesRef.current); - const fileTree = createSortedFileTree(files); const context: FilesContextValue = { files, - fileTree, + fileTree: fileTreeRef.current, + openFile, openedFile, - setOpenedFile, + openFolder, + closeFolder, + toggleFolder, + isFolderOpen, createFile, updateFile, deleteFile, diff --git a/packages/web/src/components/apps/workspace/editor/header.tsx b/packages/web/src/components/apps/workspace/editor/header.tsx index 16a03ab5..a57ae0cc 100644 --- a/packages/web/src/components/apps/workspace/editor/header.tsx +++ b/packages/web/src/components/apps/workspace/editor/header.tsx @@ -37,7 +37,7 @@ export default function EditorHeader(props: PropsType) { >