Skip to content

Commit 0a880c8

Browse files
authored
Add support for deleting and renaming files (#345)
* Add support for deleting and renaming files * Fix lint
1 parent 937d164 commit 0a880c8

File tree

9 files changed

+716
-27
lines changed

9 files changed

+716
-27
lines changed

packages/api/apps/disk.mts

+27-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
44
import { type App as DBAppType } from '../db/schema.mjs';
55
import { APPS_DIR } from '../constants.mjs';
66
import { toValidPackageName } from './utils.mjs';
7-
import { DirEntryType, FileType } from '@srcbook/shared';
7+
import { DirEntryType, FileEntryType, FileType } from '@srcbook/shared';
88

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

147+
export function deleteFile(app: DBAppType, path: string) {
148+
const filePath = Path.join(APPS_DIR, app.externalId, path);
149+
return fs.rm(filePath);
150+
}
151+
152+
export async function renameFile(
153+
app: DBAppType,
154+
path: string,
155+
name: string,
156+
): Promise<FileEntryType> {
157+
const projectDir = Path.join(APPS_DIR, app.externalId);
158+
const oldPath = Path.join(projectDir, path);
159+
const dirname = Path.dirname(oldPath);
160+
const newPath = Path.join(dirname, name);
161+
await fs.rename(oldPath, newPath);
162+
163+
const relativePath = Path.relative(projectDir, newPath);
164+
const basename = Path.basename(newPath);
165+
166+
return {
167+
type: 'file' as const,
168+
name: basename,
169+
path: relativePath,
170+
};
171+
}
172+
147173
// TODO: This does not scale.
148174
// What's the best way to know whether a file is a "binary"
149175
// file or not? Inspecting bytes for invalid utf8?

packages/api/server/http.mts

+42-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { EXAMPLE_SRCBOOKS } from '../srcbook/examples.mjs';
3535
import { pathToSrcbook } from '../srcbook/path.mjs';
3636
import { isSrcmdPath } from '../srcmd/paths.mjs';
3737
import { loadApps, loadApp, createApp, serializeApp, deleteApp } from '../apps/app.mjs';
38-
import { loadDirectory, loadFile } from '../apps/disk.mjs';
38+
import { deleteFile, renameFile, loadDirectory, loadFile } from '../apps/disk.mjs';
3939
import { CreateAppSchema } from '../apps/schemas.mjs';
4040

4141
const app: Application = express();
@@ -504,6 +504,47 @@ router.get('/apps/:id/files', cors(), async (req, res) => {
504504
}
505505
});
506506

507+
router.options('/apps/:id/files', cors());
508+
router.delete('/apps/:id/files', cors(), async (req, res) => {
509+
const { id } = req.params;
510+
const path = typeof req.query.path === 'string' ? req.query.path : '.';
511+
512+
try {
513+
const app = await loadApp(id);
514+
515+
if (!app) {
516+
return res.status(404).json({ error: 'App not found' });
517+
}
518+
519+
await deleteFile(app, path);
520+
521+
return res.json({ data: { deleted: true } });
522+
} catch (e) {
523+
return error500(res, e as Error);
524+
}
525+
});
526+
527+
router.options('/apps/:id/files/rename', cors());
528+
router.post('/apps/:id/files/rename', cors(), async (req, res) => {
529+
const { id } = req.params;
530+
const path = typeof req.query.path === 'string' ? req.query.path : '.';
531+
const name = req.query.name as string;
532+
533+
try {
534+
const app = await loadApp(id);
535+
536+
if (!app) {
537+
return res.status(404).json({ error: 'App not found' });
538+
}
539+
540+
const file = await renameFile(app, path, name);
541+
542+
return res.json({ data: file });
543+
} catch (e) {
544+
return error500(res, e as Error);
545+
}
546+
});
547+
507548
app.use('/api', router);
508549

509550
export default app;

packages/components/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@lezer/highlight": "^1.2.1",
2222
"@lezer/javascript": "^1.4.17",
2323
"@radix-ui/react-collapsible": "^1.1.0",
24+
"@radix-ui/react-context-menu": "^2.2.2",
2425
"@radix-ui/react-dialog": "^1.1.1",
2526
"@radix-ui/react-dropdown-menu": "^2.1.1",
2627
"@radix-ui/react-icons": "^1.3.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import * as React from 'react';
2+
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
3+
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons';
4+
5+
import { cn } from '../../lib/utils';
6+
7+
const ContextMenu = ContextMenuPrimitive.Root;
8+
9+
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
10+
11+
const ContextMenuGroup = ContextMenuPrimitive.Group;
12+
13+
const ContextMenuPortal = ContextMenuPrimitive.Portal;
14+
15+
const ContextMenuSub = ContextMenuPrimitive.Sub;
16+
17+
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
18+
19+
const ContextMenuSubTrigger = React.forwardRef<
20+
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
21+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
22+
inset?: boolean;
23+
}
24+
>(({ className, inset, children, ...props }, ref) => (
25+
<ContextMenuPrimitive.SubTrigger
26+
ref={ref}
27+
className={cn(
28+
'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',
29+
inset && 'pl-8',
30+
className,
31+
)}
32+
{...props}
33+
>
34+
{children}
35+
<ChevronRightIcon className="ml-auto h-4 w-4" />
36+
</ContextMenuPrimitive.SubTrigger>
37+
));
38+
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
39+
40+
const ContextMenuSubContent = React.forwardRef<
41+
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
42+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
43+
>(({ className, ...props }, ref) => (
44+
<ContextMenuPrimitive.SubContent
45+
ref={ref}
46+
className={cn(
47+
'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',
48+
className,
49+
)}
50+
{...props}
51+
/>
52+
));
53+
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
54+
55+
const ContextMenuContent = React.forwardRef<
56+
React.ElementRef<typeof ContextMenuPrimitive.Content>,
57+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
58+
>(({ className, ...props }, ref) => (
59+
<ContextMenuPrimitive.Portal>
60+
<ContextMenuPrimitive.Content
61+
ref={ref}
62+
className={cn(
63+
'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',
64+
className,
65+
)}
66+
{...props}
67+
/>
68+
</ContextMenuPrimitive.Portal>
69+
));
70+
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
71+
72+
const ContextMenuItem = React.forwardRef<
73+
React.ElementRef<typeof ContextMenuPrimitive.Item>,
74+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
75+
inset?: boolean;
76+
}
77+
>(({ className, inset, ...props }, ref) => (
78+
<ContextMenuPrimitive.Item
79+
ref={ref}
80+
className={cn(
81+
'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',
82+
inset && 'pl-8',
83+
className,
84+
)}
85+
{...props}
86+
/>
87+
));
88+
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
89+
90+
const ContextMenuCheckboxItem = React.forwardRef<
91+
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
92+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
93+
>(({ className, children, checked, ...props }, ref) => (
94+
<ContextMenuPrimitive.CheckboxItem
95+
ref={ref}
96+
className={cn(
97+
'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',
98+
className,
99+
)}
100+
checked={checked}
101+
{...props}
102+
>
103+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
104+
<ContextMenuPrimitive.ItemIndicator>
105+
<CheckIcon className="h-4 w-4" />
106+
</ContextMenuPrimitive.ItemIndicator>
107+
</span>
108+
{children}
109+
</ContextMenuPrimitive.CheckboxItem>
110+
));
111+
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
112+
113+
const ContextMenuRadioItem = React.forwardRef<
114+
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
115+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
116+
>(({ className, children, ...props }, ref) => (
117+
<ContextMenuPrimitive.RadioItem
118+
ref={ref}
119+
className={cn(
120+
'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',
121+
className,
122+
)}
123+
{...props}
124+
>
125+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
126+
<ContextMenuPrimitive.ItemIndicator>
127+
<DotFilledIcon className="h-4 w-4 fill-current" />
128+
</ContextMenuPrimitive.ItemIndicator>
129+
</span>
130+
{children}
131+
</ContextMenuPrimitive.RadioItem>
132+
));
133+
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
134+
135+
const ContextMenuLabel = React.forwardRef<
136+
React.ElementRef<typeof ContextMenuPrimitive.Label>,
137+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
138+
inset?: boolean;
139+
}
140+
>(({ className, inset, ...props }, ref) => (
141+
<ContextMenuPrimitive.Label
142+
ref={ref}
143+
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
144+
{...props}
145+
/>
146+
));
147+
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
148+
149+
const ContextMenuSeparator = React.forwardRef<
150+
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
151+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
152+
>(({ className, ...props }, ref) => (
153+
<ContextMenuPrimitive.Separator
154+
ref={ref}
155+
className={cn('-mx-1 my-1 h-px bg-border', className)}
156+
{...props}
157+
/>
158+
));
159+
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
160+
161+
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
162+
return (
163+
<span
164+
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
165+
{...props}
166+
/>
167+
);
168+
};
169+
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
170+
171+
export {
172+
ContextMenu,
173+
ContextMenuTrigger,
174+
ContextMenuContent,
175+
ContextMenuItem,
176+
ContextMenuCheckboxItem,
177+
ContextMenuRadioItem,
178+
ContextMenuLabel,
179+
ContextMenuSeparator,
180+
ContextMenuShortcut,
181+
ContextMenuGroup,
182+
ContextMenuPortal,
183+
ContextMenuSub,
184+
ContextMenuSubContent,
185+
ContextMenuSubTrigger,
186+
ContextMenuRadioGroup,
187+
};

packages/web/src/clients/http/apps.ts

+43-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { AppType, CodeLanguageType, DirEntryType, FileType } from '@srcbook/shared';
1+
import type {
2+
AppType,
3+
CodeLanguageType,
4+
DirEntryType,
5+
FileEntryType,
6+
FileType,
7+
} from '@srcbook/shared';
28
import SRCBOOK_CONFIG from '@/config';
39

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

94100
return response.json();
95101
}
102+
103+
export async function deleteFile(id: string, path: string): Promise<{ data: { deleted: true } }> {
104+
const queryParams = new URLSearchParams({ path });
105+
106+
const response = await fetch(API_BASE_URL + `/apps/${id}/files?${queryParams}`, {
107+
method: 'DELETE',
108+
headers: { 'content-type': 'application/json' },
109+
});
110+
111+
if (!response.ok) {
112+
console.error(response);
113+
throw new Error('Request failed');
114+
}
115+
116+
return response.json();
117+
}
118+
119+
export async function renameFile(
120+
id: string,
121+
path: string,
122+
name: string,
123+
): Promise<{ data: FileEntryType }> {
124+
const queryParams = new URLSearchParams({ path, name });
125+
126+
const response = await fetch(API_BASE_URL + `/apps/${id}/files/rename?${queryParams}`, {
127+
method: 'POST',
128+
headers: { 'content-type': 'application/json' },
129+
});
130+
131+
if (!response.ok) {
132+
console.error(response);
133+
throw new Error('Request failed');
134+
}
135+
136+
return response.json();
137+
}

0 commit comments

Comments
 (0)