Skip to content

Commit 1dc68ba

Browse files
committed
feat: add project renaming functionality with validation and UI integration
1 parent fb73607 commit 1dc68ba

6 files changed

Lines changed: 236 additions & 5 deletions

File tree

apps/web/messages/cs.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@
3232
"project_btn_create": "Vytvořit nový projekt",
3333
"project_btn_import": "Importovat projekt",
3434
"project_delete": "Smazat projekt",
35+
"project_rename": "Přejmenovat",
36+
"project_rename_title": "Přejmenovat projekt",
37+
"project_rename_description": "Použijte pouze písmena a čísla.",
38+
"project_rename_placeholder": "Nový název projektu",
39+
"project_rename_cancel": "Zrušit",
40+
"project_rename_confirm": "Přejmenovat",
41+
"project_rename_required": "Název projektu je povinný.",
42+
"project_rename_invalid": "Použijte pouze písmena a čísla.",
43+
"project_rename_failed": "Nepodařilo se přejmenovat projekt.",
3544
"project_download_zip": "Stáhnout jako ZIP",
3645
"project_created_at": "Vytvořeno:",
3746
"project_updated_at": "Aktualizováno:",

apps/web/messages/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@
3434
"project_btn_create": "Create New Project",
3535
"project_btn_import": "Import Project",
3636
"project_delete": "Delete Project",
37+
"project_rename": "Rename",
38+
"project_rename_title": "Rename project",
39+
"project_rename_description": "Use only letters and numbers.",
40+
"project_rename_placeholder": "New project name",
41+
"project_rename_cancel": "Cancel",
42+
"project_rename_confirm": "Rename",
43+
"project_rename_required": "Project name is required.",
44+
"project_rename_invalid": "Use only letters and numbers.",
45+
"project_rename_failed": "Failed to rename project.",
3746
"project_download_zip": "Download as ZIP",
3847
"project_created_at": "Created At:",
3948
"project_updated_at": "Updated At:",

apps/web/src/lib/db/project-repository.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,34 @@ export class ProjectRepository {
4444
});
4545
}
4646

47+
async renameWithId(
48+
oldId: string,
49+
newId: string,
50+
newName: string
51+
): Promise<void> {
52+
if (oldId === newId) {
53+
await this.rename(oldId, newName);
54+
return;
55+
}
56+
57+
const project = await this.db.projects.get(oldId);
58+
if (!project) throw new Error('Project not found');
59+
60+
const existing = await this.db.projects.get(newId);
61+
if (existing) throw new Error('Project already exists');
62+
63+
const now = Date.now();
64+
await this.db.transaction('rw', this.db.projects, async () => {
65+
await this.db.projects.add({
66+
...project,
67+
id: newId,
68+
name: newName,
69+
modifiedAt: now,
70+
});
71+
await this.db.projects.delete(oldId);
72+
});
73+
}
74+
4775
async touch(id: string): Promise<void> {
4876
await this.db.projects.update(id, { modifiedAt: Date.now() });
4977
}

apps/web/src/routes/project/index.tsx

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,21 @@ import {
2121
DropdownMenuItem,
2222
DropdownMenuTrigger,
2323
} from '@/features/shared/components/ui/dropdown-menu';
24+
import { Input } from '@/features/shared/components/ui/input';
2425
import type { FSInterface } from '@jaculus/project/fs';
26+
import path from 'path';
2527
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
2628
import { useLiveQuery } from 'dexie-react-hooks';
27-
import { Blocks, Code, Download, MoreVertical, Trash } from 'lucide-react';
29+
import {
30+
Blocks,
31+
Code,
32+
Download,
33+
MoreVertical,
34+
Pencil,
35+
Trash,
36+
} from 'lucide-react';
2837
import { useState } from 'react';
38+
import { loadPackageJson, savePackageJson } from '@jaculus/project/package';
2939

3040
export const Route = createFileRoute('/project/')({
3141
component: EditorList,
@@ -36,6 +46,13 @@ function EditorList() {
3646
Route.useRouteContext();
3747
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
3848
const [projectToDelete, setProjectToDelete] = useState<string | null>(null);
49+
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
50+
const [projectToRename, setProjectToRename] = useState<{
51+
id: string;
52+
name: string;
53+
} | null>(null);
54+
const [renameValue, setRenameValue] = useState('');
55+
const [renameError, setRenameError] = useState<string | null>(null);
3956

4057
const projects = useLiveQuery(
4158
() => runtimeService.listProjects(),
@@ -51,6 +68,66 @@ function EditorList() {
5168
setProjectToDelete(null);
5269
}
5370

71+
const projectNamePattern = /^[a-zA-Z0-9-_ ]+$/;
72+
const projectNamePatternJson = /^[a-z0-9-_]+$/;
73+
74+
function startRename(projectId: string, projectName: string) {
75+
setProjectToRename({ id: projectId, name: projectName });
76+
setRenameValue(projectName);
77+
setRenameError(null);
78+
setRenameDialogOpen(true);
79+
}
80+
81+
async function confirmRename() {
82+
if (!projectToRename) return;
83+
const nextName = renameValue.trim();
84+
85+
if (!nextName) {
86+
setRenameError(m.project_rename_required());
87+
return;
88+
}
89+
90+
if (!projectNamePattern.test(nextName)) {
91+
setRenameError(m.project_rename_invalid());
92+
return;
93+
}
94+
95+
setRenameError(null);
96+
97+
try {
98+
const projectId = projectToRename.id;
99+
100+
if (nextName === projectToRename.name) {
101+
setRenameDialogOpen(false);
102+
setProjectToRename(null);
103+
return;
104+
}
105+
const nextNamePackage = nextName.replace(/[^a-zA-Z0-9-_]/g, '-');
106+
107+
if (projectNamePatternJson.test(nextNamePackage)) {
108+
await projectFsService.withMount(
109+
projectId,
110+
async ({ fs, projectPath }) => {
111+
const packageJsonPath = path.join(projectPath, 'package.json');
112+
const pkgJson = await loadPackageJson(fs, packageJsonPath);
113+
await savePackageJson(fs, packageJsonPath, {
114+
...pkgJson,
115+
name: nextName,
116+
});
117+
}
118+
);
119+
}
120+
121+
await runtimeService.renameProject(projectId, nextName);
122+
123+
setRenameDialogOpen(false);
124+
setProjectToRename(null);
125+
} catch (error) {
126+
console.error('Failed to rename project:', error);
127+
setRenameError(m.project_rename_failed());
128+
}
129+
}
130+
54131
return (
55132
<div>
56133
<h1 className="text-2xl font-bold mb-4 text-center">
@@ -119,6 +196,15 @@ function EditorList() {
119196
<Trash className="w-4 h-4 mr-2" />
120197
{m.project_delete()}
121198
</DropdownMenuItem>
199+
<DropdownMenuItem
200+
onClick={e => {
201+
e.stopPropagation();
202+
startRename(project.id, project.name);
203+
}}
204+
>
205+
<Pencil className="w-4 h-4 mr-2" />
206+
{m.project_rename()}
207+
</DropdownMenuItem>
122208
<DropdownMenuItem
123209
onClick={async e => {
124210
e.stopPropagation();
@@ -180,6 +266,47 @@ function EditorList() {
180266
</DialogFooter>
181267
</DialogContent>
182268
</Dialog>
269+
270+
<Dialog
271+
open={renameDialogOpen}
272+
onOpenChange={open => {
273+
setRenameDialogOpen(open);
274+
if (!open) {
275+
setRenameError(null);
276+
setProjectToRename(null);
277+
}
278+
}}
279+
>
280+
<DialogContent>
281+
<DialogHeader>
282+
<DialogTitle>{m.project_rename_title()}</DialogTitle>
283+
<DialogDescription>
284+
{m.project_rename_description()}
285+
</DialogDescription>
286+
</DialogHeader>
287+
<div className="space-y-2">
288+
<Input
289+
value={renameValue}
290+
onChange={e => setRenameValue(e.target.value)}
291+
placeholder={m.project_rename_placeholder()}
292+
/>
293+
{renameError && (
294+
<p className="text-sm text-destructive">{renameError}</p>
295+
)}
296+
</div>
297+
<DialogFooter>
298+
<Button
299+
variant="outline"
300+
onClick={() => setRenameDialogOpen(false)}
301+
>
302+
{m.project_rename_cancel()}
303+
</Button>
304+
<Button onClick={confirmRename}>
305+
{m.project_rename_confirm()}
306+
</Button>
307+
</DialogFooter>
308+
</DialogContent>
309+
</Dialog>
183310
</>
184311
) : (
185312
<p className="text-center text-muted-foreground">{m.project_empty()}</p>

apps/web/src/services/project-fs-service.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import {
88
} from '@zenfs/core';
99
import { IndexedDB } from '@zenfs/dom';
1010
import { Zip } from '@zenfs/archives';
11+
import { enqueueSnackbar } from 'notistack';
12+
import { copyFolder, type FSInterface } from '@jaculus/project/fs';
1113

1214
export interface ProjectFsInterface {
13-
fs: typeof fs;
15+
fs: FSInterface;
1416
projectPath: string;
1517
}
1618

@@ -48,7 +50,7 @@ export async function mountProject(
4850
const mountPath = getMountPath(projectId);
4951

5052
if (isMounted(projectId)) {
51-
return { fs, projectPath: mountPath };
53+
return { fs: fs as unknown as FSInterface, projectPath: mountPath };
5254
}
5355

5456
try {
@@ -59,7 +61,7 @@ export async function mountProject(
5961
mount(mountPath, backend);
6062
} catch (error) {
6163
if (error instanceof Error && error.message.includes('already in use')) {
62-
return { fs, projectPath: mountPath };
64+
return { fs: fs as unknown as FSInterface, projectPath: mountPath };
6365
}
6466
throw error;
6567
}
@@ -71,7 +73,7 @@ export async function mountProject(
7173
window.fs = fs;
7274
window.fsp = fs.promises;
7375

74-
return { fs, projectPath: mountPath };
76+
return { fs: fs as unknown as FSInterface, projectPath: mountPath };
7577
}
7678

7779
export function unmountProject(projectId: string): void {
@@ -85,6 +87,53 @@ export function unmountProject(projectId: string): void {
8587
}
8688
}
8789

90+
/**
91+
* Delete the IndexedDB store backing a project.
92+
* The project must be unmounted first.
93+
*/
94+
function deleteProjectStore(projectId: string): Promise<void> {
95+
const storeName = getStoreName(projectId);
96+
return new Promise((resolve, reject) => {
97+
const req = indexedDB.deleteDatabase(storeName);
98+
req.onsuccess = () => resolve();
99+
req.onerror = event => reject(event);
100+
req.onblocked = () => {
101+
const message = `Deletion of IndexedDB store "${storeName}" is blocked. Reloading page in 3 seconds...`;
102+
console.warn(message);
103+
enqueueSnackbar(message, { variant: 'warning' });
104+
setTimeout(() => {
105+
window.location.reload();
106+
}, 3000);
107+
};
108+
});
109+
}
110+
111+
/**
112+
* Rename a project by copying its filesystem to a new IndexedDB store
113+
* and deleting the old one.
114+
*
115+
* 1. Mount both old and new projects
116+
* 2. Copy all files using copyFolder
117+
* 3. Unmount both
118+
* 4. Delete the old IndexedDB store
119+
*/
120+
export async function renameProject(
121+
oldProjectId: string,
122+
newProjectId: string
123+
): Promise<void> {
124+
const oldFs = await mountProject(oldProjectId);
125+
const newFs = await mountProject(newProjectId);
126+
127+
try {
128+
await copyFolder(oldFs.fs, oldFs.projectPath, newFs.fs, newFs.projectPath);
129+
} finally {
130+
unmountProject(newProjectId);
131+
unmountProject(oldProjectId);
132+
}
133+
134+
await deleteProjectStore(oldProjectId);
135+
}
136+
88137
/**
89138
* Service class for dependency injection in React context.
90139
* Wraps the module functions for easier testing and provider usage.
@@ -93,6 +142,7 @@ export class ProjectFsService {
93142
isMounted = isMounted;
94143
mount = mountProject;
95144
unmount = unmountProject;
145+
rename = renameProject;
96146

97147
async withMount<T>(
98148
projectId: string,

apps/web/src/services/project-runtime-service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ export class ProjectManagementService {
3030
await this.repo.rename(id, newName);
3131
}
3232

33+
async renameProjectWithId(
34+
oldId: string,
35+
newId: string,
36+
newName: string
37+
): Promise<void> {
38+
await this.repo.renameWithId(oldId, newId, newName);
39+
}
40+
3341
async listProjects(): Promise<IDbProject[]> {
3442
return await this.repo.list();
3543
}

0 commit comments

Comments
 (0)