Skip to content

Commit

Permalink
File tree functionality and styles (srcbookdev#344)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjreinhart authored and BeRecursive22 committed Oct 13, 2024
1 parent 0d70d92 commit 6cbb4ab
Show file tree
Hide file tree
Showing 12 changed files with 395 additions and 154 deletions.
90 changes: 45 additions & 45 deletions packages/api/apps/disk.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<DirEntryType> {
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<FileType> {
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.
Expand Down
6 changes: 1 addition & 5 deletions packages/api/server/channels/app.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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'>;
Expand Down Expand Up @@ -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 }]));
}
});
}
41 changes: 41 additions & 0 deletions packages/api/server/http.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
1 change: 1 addition & 0 deletions packages/shared/src/schemas/apps.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
16 changes: 16 additions & 0 deletions packages/shared/src/types/apps.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileEntryType | DirEntryType>;

export type FileType = z.infer<typeof FileSchema>;
34 changes: 33 additions & 1 deletion packages/web/src/clients/http/apps.ts
Original file line number Diff line number Diff line change
@@ -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`;
Expand Down Expand Up @@ -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();
}
96 changes: 96 additions & 0 deletions packages/web/src/components/apps/lib/file-tree.ts
Original file line number Diff line number Diff line change
@@ -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;
});
}
Loading

0 comments on commit 6cbb4ab

Please sign in to comment.