Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/creator-hub/preload/src/modules/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { DEFAULT_DEPENDENCY_UPDATE_STRATEGY } from '/shared/types/settings';
import type { GetProjectsOpts, Template, Workspace } from '/shared/types/workspace';
import { FileSystemStorage } from '/shared/types/storage';
import { fetch } from '/shared/fetch';
import { isValidFolderName } from '/shared/utils';
import { STUDIOS_ADMIN_URL } from '/shared/urls';

import type { Services } from '../services';
Expand Down Expand Up @@ -376,6 +377,55 @@ export function initializeWorkspace(services: Services) {
return project;
}

/**
* Renames a project's folder on disk. The project keeps its identity (`.editor/` metadata,
* `scene.json` title, etc.) since those live inside the folder and simply move along with it.
*
* @param path - The current path of the project directory to rename.
* @param newName - The desired new folder name (not a full path).
* @returns A Promise that resolves to the renamed Project.
*/
async function renameProject({
path: _path,
newName,
}: {
path: string;
newName: string;
}): Promise<Project> {
const trimmedName = newName.trim();
if (!isValidFolderName(trimmedName)) {
throw new Error(`Invalid folder name: "${newName}"`);
}

const newPath = path.join(path.dirname(_path), trimmedName);

if (newPath === _path) {
return getProject({ path: _path });
}

// On case-insensitive filesystems (APFS, NTFS) a case-only rename makes `fs.exists(newPath)`
// match the project's own folder, so the collision check must be skipped for it —
// `fs.rename` handles case-only renames fine on those filesystems.
const isCaseOnlyRename = newPath.toLowerCase() === _path.toLowerCase();
if (!isCaseOnlyRename && (await fs.exists(newPath))) {
throw new Error(`A folder named "${trimmedName}" already exists`);
}

await fs.rename(_path, newPath);

try {
await config.setConfig(draft => {
draft.workspace.paths = draft.workspace.paths.map($ => ($ === _path ? newPath : $));
});
} catch (error) {
// Keep disk and config consistent: undo the rename if the config write fails.
await fs.rename(newPath, _path);
throw error;
}

return getProject({ path: newPath });
}

/**
* Returns whether or not the provided directory is a valid base path to create new scenes/projects.
* A valid base path is a writable directory.
Expand Down Expand Up @@ -522,6 +572,7 @@ export function initializeWorkspace(services: Services) {
unlistProjects,
deleteProject,
duplicateProject,
renameProject,
reimportProject,
saveThumbnail,
openFolder,
Expand Down
4 changes: 4 additions & 0 deletions packages/creator-hub/preload/src/services/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export async function rm(path: string, options?: { recursive?: boolean }) {
await fs.rm(path, options);
}

export async function rename(oldPath: string, newPath: string) {
await fs.rename(oldPath, newPath);
}

export async function readdir(path: string) {
return fs.readdir(path);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/creator-hub/preload/tests/modules/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ export const getMockServices = (): DeepMock<Services> => ({
readdir: vi.fn(),
isDirectory: vi.fn(),
cp: vi.fn(),
rename: vi.fn(),
},
ipc: {
invoke: vi.fn(),
},
path: {
join: vi.fn((...args) => args.join('/')),
dirname: vi.fn(path => path.split('/').slice(0, -1).join('/')),
} as any, // temp until we have a "path" service...
npm: {
install: vi.fn(),
Expand Down
103 changes: 103 additions & 0 deletions packages/creator-hub/preload/tests/modules/workspace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ import type { Scene } from '@dcl/schemas';
import { initializeWorkspace } from '../../src/modules/workspace';
import { getScenesPath } from '../../src/modules/settings';
import { getScene } from '../../src/modules/scene';
import { getProjectId } from '../../src/modules/analytics';
import { NEW_SCENE_NAME, EMPTY_SCENE_TEMPLATE_REPO } from '../../src/modules/constants';

import { getMockServices } from './services';

vi.mock('../../src/modules/scene');
vi.mock('../../src/modules/settings');
// `getProjectId` goes through the real `services/ipc.ts` (backed by electron's `ipcRenderer`)
// instead of the injected `ipc` service, so it can't be exercised in this Node test environment.
vi.mock('../../src/modules/analytics');
// `getProject` (used by `renameProject`) reads/writes a per-project metadata file via
// `FileSystemStorage`, which uses `node:fs/promises` directly instead of the mocked `fs` service.
// Auto-mock it so those tests never touch the real filesystem.
vi.mock('node:fs/promises');

describe('initializeWorkspace', () => {
const services = getMockServices();
Expand All @@ -27,6 +35,7 @@ describe('initializeWorkspace', () => {

vi.mocked(getScenesPath).mockResolvedValue(mockAppHome);
vi.mocked(getScene).mockResolvedValue({ ...mockScene } as Scene);
vi.mocked(getProjectId).mockResolvedValue('mock-id');
});

describe('getPath', () => {
Expand Down Expand Up @@ -192,6 +201,100 @@ describe('initializeWorkspace', () => {
});
});

describe('renameProject', () => {
const currentPath = `${mockAppHome}/My Scene`;

beforeEach(() => {
services.fs.stat.mockResolvedValue({
birthtime: new Date(0),
mtime: new Date(0),
size: 0,
} as any);
services.ipc.invoke.mockResolvedValue(undefined);
vi.mocked(getScene).mockResolvedValue({
...mockScene,
scene: { parcels: [] },
} as unknown as Scene);
});

it('should reject an invalid folder name without touching the filesystem', async () => {
const workspace = initializeWorkspace(services);

await expect(
workspace.renameProject({ path: currentPath, newName: 'in/valid' }),
).rejects.toThrow(/Invalid folder name/);
expect(services.fs.rename).not.toHaveBeenCalled();
expect(services.config.setConfig).not.toHaveBeenCalled();
});

it('should reject a name that collides with an existing folder', async () => {
services.fs.exists.mockResolvedValue(true);

const workspace = initializeWorkspace(services);

await expect(
workspace.renameProject({ path: currentPath, newName: 'New Name' }),
).rejects.toThrow(/already exists/);
expect(services.fs.rename).not.toHaveBeenCalled();
expect(services.config.setConfig).not.toHaveBeenCalled();
});

it('should do nothing and return the current project if the name is unchanged', async () => {
services.fs.exists.mockResolvedValue(false);

const workspace = initializeWorkspace(services);
const result = await workspace.renameProject({ path: currentPath, newName: 'My Scene' });

expect(services.fs.rename).not.toHaveBeenCalled();
expect(services.config.setConfig).not.toHaveBeenCalled();
expect(result.path).toBe(currentPath);
});

it('should rename the folder and update the workspace config with the new path', async () => {
services.fs.exists.mockResolvedValue(false);
const newPath = `${mockAppHome}/New Name`;

const workspace = initializeWorkspace(services);
const result = await workspace.renameProject({ path: currentPath, newName: 'New Name' });

expect(services.fs.rename).toHaveBeenCalledWith(currentPath, newPath);
expect(services.config.setConfig).toHaveBeenCalled();

const drafter = services.config.setConfig.mock.calls[0][0];
const draftConfig = { workspace: { paths: [currentPath, '/other/project'] } };
drafter(draftConfig);
expect(draftConfig.workspace.paths).toEqual([newPath, '/other/project']);

expect(result.path).toBe(newPath);
});

it('should allow a case-only rename even when the filesystem reports the target as existing', async () => {
// On case-insensitive filesystems (APFS, NTFS) the target path matches the project's own folder.
services.fs.exists.mockResolvedValue(true);
const newPath = `${mockAppHome}/my scene`;

const workspace = initializeWorkspace(services);
const result = await workspace.renameProject({ path: currentPath, newName: 'my scene' });

expect(services.fs.rename).toHaveBeenCalledWith(currentPath, newPath);
expect(result.path).toBe(newPath);
});

it('should undo the rename if updating the workspace config fails', async () => {
services.fs.exists.mockResolvedValue(false);
services.config.setConfig.mockRejectedValueOnce(new Error('disk write failed'));
const newPath = `${mockAppHome}/New Name`;

const workspace = initializeWorkspace(services);

await expect(
workspace.renameProject({ path: currentPath, newName: 'New Name' }),
).rejects.toThrow(/disk write failed/);
expect(services.fs.rename).toHaveBeenNthCalledWith(1, currentPath, newPath);
expect(services.fs.rename).toHaveBeenNthCalledWith(2, newPath, currentPath);
});
});

describe('when getting the scene source file', () => {
describe('and the file exists', () => {
let projectPath: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useCallback, useEffect, useState } from 'react';
import { OutlinedInput, Typography, FormGroup, CircularProgress as Loader } from 'decentraland-ui2';

import { t } from '/@/modules/store/translation/utils';
import { getBaseName, isValidFolderName } from '/shared/utils';

import { Modal } from '..';
import { Button } from '../../Button';

import type { Props } from './types';

import './styles.css';

export function RenameFolder({ open, project, onClose, onSubmit }: Props) {
const currentName = getBaseName(project.path);
const [name, setName] = useState(currentName);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

// Reset the field whenever the modal is (re)opened for a project.
useEffect(() => {
if (open) {
setName(currentName);
setError(null);
setLoading(false);
}
}, [open, currentName]);

const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
setName(event.target.value);
}, []);

const trimmedName = name.trim();
const isEmpty = trimmedName.length === 0;
const isUnchanged = trimmedName === currentName;
const isInvalid = !isEmpty && !isValidFolderName(trimmedName);

const handleSubmit = useCallback(async () => {
if (isEmpty || isUnchanged || isInvalid || loading) return;
setLoading(true);
setError(null);
try {
await onSubmit(project, trimmedName);
onClose();
} catch (error) {
// `unwrap()` rejects with a SerializedError (plain object), not an Error instance.
const message = (error as { message?: string } | null)?.message ?? '';
const isCollision = message.includes('already exists');
setError(
isCollision
? t('modal.rename_folder.errors.name_taken')
: t('modal.rename_folder.errors.rename_failed'),
);
setLoading(false);
}
}, [onSubmit, onClose, project, trimmedName, isEmpty, isUnchanged, isInvalid, loading]);

return (
<Modal
open={open}
title={t('modal.rename_folder.title')}
size="tiny"
className="RenameFolderModal"
onClose={onClose}
actions={
<>
<Button
color="secondary"
onClick={onClose}
>
{t('modal.cancel')}
</Button>
<Button
onClick={handleSubmit}
disabled={loading || isEmpty || isUnchanged || isInvalid}
>
{loading ? <Loader size={20} /> : t('modal.confirm')}
</Button>
</>
}
>
<FormGroup className="RenameFolderFormControl">
<Typography variant="body1">{t('modal.rename_folder.field_label')}</Typography>
<OutlinedInput
color="secondary"
value={name}
onChange={handleChange}
autoFocus
/>
{isInvalid && (
<Typography
variant="body1"
className="error"
>
{t('modal.rename_folder.errors.invalid_name')}
</Typography>
)}
{error && (
<Typography
variant="body1"
className="error"
>
{error}
</Typography>
)}
</FormGroup>
</Modal>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { RenameFolder } from './component';
export { RenameFolder };
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.RenameFolderModal .MuiInputBase-root + .MuiTypography-root {
margin-top: 16px;
}

.RenameFolderModal .error {
color: var(--error);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Project } from '/shared/types/projects';

export type Props = {
open: boolean;
project: Project;
onClose: () => void;
onSubmit: (project: Project, newName: string) => Promise<unknown>;
};
Loading
Loading