Skip to content

Commit

Permalink
Implement top level create file and folder (srcbookdev#346)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjreinhart authored Oct 11, 2024
1 parent d3dae44 commit ea9d6df
Show file tree
Hide file tree
Showing 9 changed files with 295 additions and 41 deletions.
33 changes: 33 additions & 0 deletions packages/api/apps/disk.mts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,26 @@ export async function loadDirectory(
};
}

export async function createDirectory(
app: DBAppType,
dirname: string,
basename: string,
): Promise<DirEntryType> {
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<FileType> {
const projectDir = Path.join(APPS_DIR, app.externalId);
const filePath = Path.join(projectDir, path);
Expand All @@ -150,6 +170,19 @@ export async function loadFile(app: DBAppType, path: string): Promise<FileType>
}
}

export async function createFile(
app: DBAppType,
dirname: string,
basename: string,
source: string,
): Promise<FileEntryType> {
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);
Expand Down
61 changes: 60 additions & 1 deletion packages/api/server/http.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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;

Expand Down
4 changes: 2 additions & 2 deletions packages/components/src/components/ui/context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
'z-50 min-w-[8rem] overflow-hidden rounded-sm border bg-popover py-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
Expand All @@ -78,7 +78,7 @@ const ContextMenuItem = React.forwardRef<
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-xs px-2 py-1.5 text-sm outline-none focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
Expand Down
39 changes: 39 additions & 0 deletions packages/web/src/clients/http/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ export async function loadDirectory(id: string, path: string): Promise<{ data: D
return response.json();
}

export async function createDirectory(
id: string,
dirname: string,
basename: string,
): Promise<{ data: DirEntryType }> {
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 });

Expand All @@ -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 });

Expand Down
33 changes: 33 additions & 0 deletions packages/web/src/components/apps/lib/file-tree.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -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 };
}
25 changes: 25 additions & 0 deletions packages/web/src/components/apps/lib/path.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit ea9d6df

Please sign in to comment.