Skip to content

Commit

Permalink
Add support for deleting and renaming files (srcbookdev#345)
Browse files Browse the repository at this point in the history
* Add support for deleting and renaming files

* Fix lint
  • Loading branch information
benjreinhart authored and BeRecursive22 committed Oct 13, 2024
1 parent 6cbb4ab commit d2bc60a
Show file tree
Hide file tree
Showing 9 changed files with 716 additions and 27 deletions.
28 changes: 27 additions & 1 deletion packages/api/apps/disk.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +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 { DirEntryType, FileType } from '@srcbook/shared';
import { DirEntryType, FileEntryType, FileType } from '@srcbook/shared';

export function pathToApp(id: string) {
return Path.join(APPS_DIR, id);
Expand Down Expand Up @@ -144,6 +144,32 @@ export async function loadFile(app: DBAppType, path: string): Promise<FileType>
}
}

export function deleteFile(app: DBAppType, path: string) {
const filePath = Path.join(APPS_DIR, app.externalId, path);
return fs.rm(filePath);
}

export async function renameFile(
app: DBAppType,
path: string,
name: string,
): Promise<FileEntryType> {
const projectDir = Path.join(APPS_DIR, app.externalId);
const oldPath = Path.join(projectDir, path);
const dirname = Path.dirname(oldPath);
const newPath = Path.join(dirname, name);
await fs.rename(oldPath, newPath);

const relativePath = Path.relative(projectDir, newPath);
const basename = Path.basename(newPath);

return {
type: 'file' as const,
name: basename,
path: relativePath,
};
}

// TODO: This does not scale.
// What's the best way to know whether a file is a "binary"
// file or not? Inspecting bytes for invalid utf8?
Expand Down
43 changes: 42 additions & 1 deletion packages/api/server/http.mts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +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 { deleteFile, renameFile, loadDirectory, loadFile } from '../apps/disk.mjs';
import { CreateAppSchema } from '../apps/schemas.mjs';

const app: Application = express();
Expand Down Expand Up @@ -507,6 +507,47 @@ router.get('/apps/:id/files', cors(), async (req, res) => {
}
});

router.options('/apps/:id/files', cors());
router.delete('/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' });
}

await deleteFile(app, path);

return res.json({ data: { deleted: true } });
} catch (e) {
return error500(res, e as Error);
}
});

router.options('/apps/:id/files/rename', cors());
router.post('/apps/:id/files/rename', cors(), async (req, res) => {
const { id } = req.params;
const path = typeof req.query.path === 'string' ? req.query.path : '.';
const name = req.query.name as string;

try {
const app = await loadApp(id);

if (!app) {
return res.status(404).json({ error: 'App not found' });
}

const file = await renameFile(app, path, name);

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/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@lezer/highlight": "^1.2.1",
"@lezer/javascript": "^1.4.17",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.2",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-icons": "^1.3.0",
Expand Down
187 changes: 187 additions & 0 deletions packages/components/src/components/ui/context-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons';

import { cn } from '../../lib/utils';

const ContextMenu = ContextMenuPrimitive.Root;

const ContextMenuTrigger = ContextMenuPrimitive.Trigger;

const ContextMenuGroup = ContextMenuPrimitive.Group;

const ContextMenuPortal = ContextMenuPrimitive.Portal;

const ContextMenuSub = ContextMenuPrimitive.Sub;

const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;

const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'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-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;

const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;

const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<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',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;

const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<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',
inset && 'pl-8',
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;

const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;

const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;

const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;

const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;

const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';

export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
44 changes: 43 additions & 1 deletion packages/web/src/clients/http/apps.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { AppType, CodeLanguageType, DirEntryType, FileType } from '@srcbook/shared';
import type {
AppType,
CodeLanguageType,
DirEntryType,
FileEntryType,
FileType,
} from '@srcbook/shared';
import SRCBOOK_CONFIG from '@/config';

const API_BASE_URL = `${SRCBOOK_CONFIG.api.origin}/api`;
Expand Down Expand Up @@ -93,3 +99,39 @@ export async function loadFile(id: string, path: string): Promise<{ data: FileTy

return response.json();
}

export async function deleteFile(id: string, path: string): Promise<{ data: { deleted: true } }> {
const queryParams = new URLSearchParams({ path });

const response = await fetch(API_BASE_URL + `/apps/${id}/files?${queryParams}`, {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
});

if (!response.ok) {
console.error(response);
throw new Error('Request failed');
}

return response.json();
}

export async function renameFile(
id: string,
path: string,
name: string,
): Promise<{ data: FileEntryType }> {
const queryParams = new URLSearchParams({ path, name });

const response = await fetch(API_BASE_URL + `/apps/${id}/files/rename?${queryParams}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
});

if (!response.ok) {
console.error(response);
throw new Error('Request failed');
}

return response.json();
}
Loading

0 comments on commit d2bc60a

Please sign in to comment.