Skip to content

feat: add option to rename a project's folder from the Scenes view - #1357

Merged
nearnshaw merged 6 commits into
mainfrom
fix/791-rename-project-folder
Jul 29, 2026
Merged

feat: add option to rename a project's folder from the Scenes view#1357
nearnshaw merged 6 commits into
mainfrom
fix/791-rename-project-folder

Conversation

@decentraland-bot

Copy link
Copy Markdown
Contributor

Summary

  • Adds a "Rename Folder" option to the three-dot menu of each project card in the Scenes view, letting users rename the on-disk project folder directly (only reachable when the project isn't open, since that's the only place this menu exists).
  • The project's display title (scene.json) is intentionally left untouched — this only fixes the folder-name side of the desync described in the issue.
  • New name is validated client-side (no illegal characters, not empty/unchanged) before enabling submit; the preload layer independently re-validates and checks for path collisions before renaming, updating the tracked config.workspace.paths entry accordingly.

Plan

Full implementation plan (root cause analysis, file references, edge cases)

title: Option to rename folder
type: feat
date: 2026-07-06

Option to rename folder

When a project's display name (scene.json's display.title) is changed after creation, the
folder that holds the project's files on disk keeps its original name. This desyncs the folder
name from the project name and confuses users. Issue #791 asks for a targeted, simple fix: add a
"Rename folder" option to the three-dot menu of each project card in the Scenes view (the grid of
projects shown when no project is open), letting the user rename the on-disk folder directly.
This does not require handling the "project is currently open" case, since the Scenes view only
ever lists projects that are not open (opening a project navigates to /editor).

Root Cause Analysis

  • packages/creator-hub/preload/src/modules/workspace.ts has no folder rename/move primitive.
    duplicateProject copies (fs.cp) into a freshly generated available path, and deleteProject
    unlists + fs.rms — neither is a rename.
  • packages/creator-hub/preload/src/services/fs.ts wraps a small subset of fs/promises (cp,
    rm, mkdir, stat, ...) but has no rename.
  • Project identity throughout the renderer's Redux store (shared/types/projects.ts Project.path)
    and the persisted workspace config (shared/types/config.ts Config.workspace.paths: string[])
    is keyed by the on-disk path, so any rename must update both, not just move the folder.
  • There is no existing UI to trigger this — the three-dot menu only offered Duplicate, Open Folder
    Location, View Deployments, and Delete.
  • There is no filename validation utility anywhere in the codebase; the closest are
    validateScenesPath/isProjectPathAvailable, which check writability and path collision but not
    illegal characters.

Institutional Learnings

  • Preload owns direct Node fs access; main process only handles a smaller set of Electron/OS-level
    IPC. No new main IPC channel is needed — implemented entirely in preload/src/modules/workspace.ts
    like duplicateProject/deleteProject already are.
  • .editor/ (per-project metadata, incl. project.json with the project id) lives inside the
    project folder, so a plain fs.rename of the directory carries it along automatically — no
    special-casing needed to preserve project identity/settings across the rename.
  • Redux slice pattern: thunks are thin wrappers around preload functions; extraReducers match
    projects by path and replace the whole object on fulfilled.

User Flows & Edge Cases

Happy path: three-dot menu → "Rename Folder" → modal pre-filled with current folder name →
type new name → client-side validation passes → confirm → folder renamed on disk,
config.workspace.paths updated, Redux projects list reflects the new path.

Edge cases handled:

  • Empty / whitespace-only name → submit disabled.
  • Name unchanged (trimmed value equals current folder's basename) → submit disabled (no-op rename
    avoided).
  • Illegal filesystem characters (/ \ : * ? " < > |, control chars), reserved Windows device names
    (CON, PRN, AUX, NUL, COM1-9, LPT1-9), or names over 255 chars → rejected by
    isValidFolderName, inline error shown.
  • Target path collision (sibling folder, project or not) → preload checks fs.exists(newPath)
    before renaming and throws; surfaced as an inline error.
  • Project folder goes missing between opening the menu and submitting → fs.rename throws
    (ENOENT); thunk's rejected case sets state.error, same as existing thunks.
  • Cross-device rename (EXDEV) is not a concern here since the new path is always a sibling of the
    existing one (same parent directory, only the last path segment changes).
  • Deployment history keyed by the old path becomes orphaned after a rename — this is an existing
    limitation shared with duplicateProject (which also changes path); out of scope for Option to rename folder #791,
    noted here for future follow-up.
  • Renaming while the project is open is not reachable from this UI, since the Scenes view only
    lists non-open projects (opening navigates to /editor) — matches the issue's own scoping.

Proposed Changes

  • Added isValidFolderName(name) and getBaseName(path) to shared/utils.ts (pure, importable
    from both preload and renderer — renderer has no Node built-ins).
  • Added rename() to preload/src/services/fs.ts, wrapping fs.promises.rename.
  • Added renameProject({ path, newName }) to preload/src/modules/workspace.ts: validates the new
    name, computes the sibling path, checks for collisions, calls fs.rename, updates
    config.workspace.paths (old path → new path), returns the refreshed Project.
  • Added renameProject thunk + extraReducers cases in the workspace Redux slice, and a
    renameProject wrapper in useWorkspace().
  • New RenameFolder modal (renderer/src/components/Modals/RenameFolder), modeled on
    CreateProject (validated input, inline error, disabled submit) and DeleteProject
    (project-scoped modal props).
  • Wired a new "Rename Folder" option into the three-dot dropdown in
    renderer/src/components/SceneList/Projects/component.tsx.
  • New translation keys under scene_list.project_actions.rename_folder and modal.rename_folder.*
    in en.json.

Testing

Ran from a clean npm install of the monorepo:

  • cd packages/creator-hub && npm run test:unit (main + preload + renderer + shared) — 176/176 tests passing, including new coverage for isValidFolderName, getBaseName, and renameProject (happy path, name-collision rejection, invalid-name rejection, unchanged-name no-op, and config.workspace.paths update).
  • npm run typecheck (root, all three sub-projects) — no errors.
  • npm run lint (root) — no errors. Also explicitly linted the two changed .tsx files, since the root lint script's --ext js,cjs,ts doesn't include .tsx — no errors.
  • npm run format (root, Prettier check) — no errors.
  • Did not run make build/make build-creator-hub — this sandbox can't complete Electron's postinstall binary download, so a full Electron build couldn't be exercised here. Worth a CI check before merge.

Closes

Closes #791


🤖 Created via Slack with Claude
Requested by Nico Earnshaw via Slack

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Test this pull request on windows-latest

Download the correct version for your architecture:

win-x64

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Test this pull request on macos-latest

Download the correct version for your architecture:

mac-x64
mac-arm64

Click here if you don't know which version to download

For running this unsigned version of the app, you will need to run the xattr command on it:

  1. Extract the app from the downloaded .dmg file (double-click it)
  2. Place the extracted app anywhere you like in your file system
  3. Open a terminal on the directory where the app is
  4. Run xattr -c app-name, replacing "app-name" for the actual name of the app
  5. Double-click the app ✅

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #1357 — feat: add option to rename a project's folder from the Scenes view

Overall Assessment

Clean, well-structured PR that follows established codebase patterns closely. The feature spans all layers (shared → preload → renderer) correctly, the validation is thorough, and test coverage is solid (happy path, collision, invalid name, no-op, and config update all tested). No P0 or P1 issues found — this looks good to merge.

Findings

[P2] Submit button enabled when input is empty

renderer/src/components/Modals/RenameFolder/component.tsx:36, 68

When the user clears the input field:

  • trimmedName = ""
  • isInvalid = false (short-circuited by trimmedName.length > 0)
  • isUnchanged = false (empty ≠ currentName)
  • Button disabled = false || false || falseenabled

Clicking Submit sends an empty name to the preload layer, which rejects it, and the modal shows "Couldn't rename the folder. A folder with that name may already exist" — a misleading message for an empty name.

Suggested fix — add an isEmpty guard to the disabled condition:

const isEmpty = trimmedName.length === 0;
// ...
disabled={loading || isEmpty || isUnchanged || isInvalid}

[P2] Case-only rename blocked on case-insensitive filesystems (macOS/Windows)

preload/src/modules/workspace.ts:402-406

Renaming "My Scene" → "my scene":

  1. newPath !== _path (case-sensitive string comparison) → proceeds
  2. fs.exists(newPath)true (HFS+/NTFS are case-insensitive)
  3. Throws: "A folder named 'my scene' already exists"

The user cannot change only the case of a folder name. A two-step rename via a temp name would fix this, but it's a non-trivial edge case — fine to defer to a follow-up.

[P2] Windows trailing-dot normalization mismatch

shared/utils.ts:27-35

Names ending with . (e.g. "My Scene.") pass isValidFolderName but Windows silently strips trailing dots, creating a mismatch between the stored path and the actual filesystem path. Consider rejecting trailing dots/spaces after trimming:

if (/[. ]$/.test(trimmed)) return false;

[P2] No rollback if config update fails after rename

preload/src/modules/workspace.ts:410-414

If fs.rename succeeds but config.setConfig throws (IPC failure, disk write error), the folder is moved on disk but config.workspace.paths still references the old path — the project appears "missing" on next load. This is consistent with existing patterns (duplicateProject, createProject all have the same non-transactional shape), so not blocking, but a rollback catch would make this more robust.

[P2] Parameter shadowing in config callback

preload/src/modules/workspace.ts:412

config.setConfig(config => { ... }) — the callback parameter config shadows the outer config service variable. Renaming to draft would prevent accidental misuse:

await config.setConfig(draft => {
  draft.workspace.paths = draft.workspace.paths.map($ => ($ === _path ? newPath : $));
});

[P2] Branch name / PR title type mismatch

Branch is fix/791-rename-project-folder but PR title uses feat:. Per ADR-6, the branch type should match the commit type. Since this adds a new feature, feat: is correct — the branch name is the mismatch. Cosmetic since squash-merge uses the PR title.

[P2] Module-level vi.mock('node:fs/promises') broadens test scope

preload/tests/modules/workspace.spec.ts

The new module-level mock affects all tests in the file, not just the renameProject suite. The comments explain the rationale (FileSystemStorage uses node:fs/promises directly), but this could mask future issues in other test suites. Consider scoping the mock to the describe('renameProject', ...) block if vitest supports it.

Security Review

No security issues found:

  • Path traversal: Well-defended — /, \, .., null bytes, and control characters all blocked by isValidFolderName. Unicode lookalikes do not resolve to path separators at the OS level.
  • Injection: No shell commands spawned with user input. Errors use translated strings, not raw input. React auto-escapes in JSX.
  • Secrets: None found.
  • TOCTOU race: Between fs.exists and fs.rename — negligible risk in a single-user desktop app.

CI Status

All checks passing: lint ✅ typecheck ✅ unit tests ✅ E2E tests ✅ builds (macOS + Windows) ✅


Reviewed by Jarvis 🤖 · Requested by Gabriel Díaz (<@U03MGHMAJL8>) via Slack

</Button>
<Button
onClick={handleSubmit}
disabled={loading || isUnchanged || isInvalid}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The submit button is enabled when the input is empty. When trimmedName is "", isInvalid is false (short-circuited by trimmedName.length > 0) and isUnchanged is false (empty ≠ currentName), so none of the disabled conditions fire.

The backend catches it gracefully, but the user sees a misleading error ("A folder with that name may already exist") instead of the button simply being disabled.

Suggested fix:

disabled={loading || !trimmedName || isUnchanged || isInvalid}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7f6a1f0 — added an isEmpty guard to both the disabled condition and handleSubmit.


await fs.rename(_path, newPath);

await config.setConfig(config => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Minor: config parameter shadows the outer config service variable. Consider renaming to draft for clarity:

await config.setConfig(draft => {
  draft.workspace.paths = draft.workspace.paths.map($ => ($ === _path ? newPath : $));
});

This matches what the parameter actually represents (an Immer draft) and prevents accidental misuse in future edits.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7f6a1f0 — renamed the callback parameter to draft.

if (trimmed === '.' || trimmed === '..') return false;
if (trimmed.length > MAX_FOLDER_NAME_LENGTH) return false;
if (ILLEGAL_FILENAME_CHARS.test(trimmed)) return false;
if (RESERVED_WINDOWS_NAMES.test(trimmed)) return false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] On Windows, trailing dots and spaces in folder names are silently stripped by NTFS (e.g. "My Scene.""My Scene"). This creates a mismatch between the path stored in config and the actual directory. Consider adding:

if (/[. ]$/.test(trimmed)) return false;

after the existing checks.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7f6a1f0isValidFolderName now rejects names ending with a dot (trailing spaces are already removed by the trim()), with tests.

@nicoecheza nicoecheza left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The architecture is right (rename lives in preload beside duplicateProject/deleteProject, no unnecessary main IPC, preload independently re-validates, traversal genuinely blocked) and the validation/config-remap tests are solid. But three things need fixing before merge:

1. Every rename failure is invisible — the modal's inline error UI is unreachable. renameProject.pending sets state.status = 'loading' (workspace/slice.ts), and ScenesPage swaps the entire SceneList — including the open modal — for a full-page loader while any workspace thunk is loading. So the modal unmounts mid-await, and when the preload rejects (collision, invalid name, EBUSY/EPERM on Windows with the folder open) the catch's setError fires on an unmounted component: modal vanishes, spinner flashes, no error anywhere. This contradicts the PR's "surfaced as an inline error" claim. The fix has in-repo precedent one case above: duplicateProject.pending is deliberately a no-op for exactly this reason — make renameProject.pending a no-op too (the modal already has its own loading state).

2. Case-only renames are always blocked on macOS/Windows. newPath === _path is case-sensitive, so "my scene" → "My Scene" proceeds to the collision check — where fs.exists(newPath) resolves case-insensitively on APFS/NTFS and matches the project's own folder → "already exists". Since re-syncing the folder name with the display title is the feature's stated purpose, casing fixes are a primary use case. Skip the collision check when the paths differ only by case (fs.rename handles case-only renames fine on both platforms), and add a test for it.

3. es.json/zh.json were not updated — only en.json has the rename_folder keys (verified), and getKeys(locale) has no merge-with-en fallback, so Spanish/Chinese users see raw key ids in the dropdown and modal.

Smaller points, non-blocking:

  • Empty/whitespace name leaves Confirm enabled (isInvalid is only computed when trimmedName.length > 0), contradicting the PR's "empty → submit disabled" claim; the resulting preload throw is then invisible per #1.
  • The single generic error message conflates collision vs EBUSY/EPERM vs other failures; the preload already throws distinct messages — surface them (at least the collision case).
  • isValidFolderName allows trailing dots ("Foo."), which Win32 strips at the API level — recreating exactly the name/desync this feature exists to fix. Also COM0/LPT0 are over-blocked (harmless) and length is counted in UTF-16 code units rather than bytes.
  • If fs.rename succeeds but the config write or final getProject throws, the store keeps the old path while disk has the new one until the next workspace refresh — consider tolerating/refreshing on that partial failure.

- Make renameProject.pending a no-op so the rename modal survives the
  await and its inline error UI stays reachable (same pattern as
  duplicateProject)
- Allow case-only renames on case-insensitive filesystems by skipping
  the collision check when paths differ only by case
- Add missing rename_folder translation keys to es.json and zh.json
- Disable Confirm when the name is empty/whitespace
- Surface a distinct error message for folder-name collisions
- Reject folder names ending with a dot (Windows strips them silently)
- Undo the rename if the config write fails, keeping disk and config
  consistent
- Rename shadowed config callback param to draft

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nearnshaw

Copy link
Copy Markdown
Member

Addressed the review feedback in 7f6a1f0:

Blocking items

  1. Modal unmounting on renamerenameProject.pending is now a no-op (same deliberate pattern as duplicateProject.pending), so the modal stays mounted through the await and its inline error UI is reachable.
  2. Case-only renames — the collision check is skipped when the old and new paths differ only by case, so "my scene" → "My Scene" works on APFS/NTFS. Covered by a new unit test.
  3. Missing translations — added the rename_folder dropdown and modal keys to es.json and zh.json.

Smaller points

  • Empty/whitespace names now disable Confirm (and are guarded in handleSubmit).
  • Collision failures now show a distinct message (errors.name_taken) instead of the generic one; rename_failed no longer speculates about the cause.
  • isValidFolderName rejects names ending with a dot (Windows strips them silently), with tests. Left COM0/LPT0 over-blocking and UTF-16 length counting as-is since they're harmless.
  • If the config write fails after fs.rename, the rename is now rolled back so disk and config stay consistent (with a test).
  • Renamed the shadowed config callback param to draft.

Not changed

  • The module-level vi.mock('node:fs/promises') in workspace.spec.ts can't be scoped to a describe block — vitest hoists vi.mock calls to the top of the file by design. The alternative (per-test vi.doMock + dynamic imports) would restructure the whole file; happy to do that in a follow-up if preferred.

All unit tests, typecheck, lint, and prettier pass locally.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Test @dcl/inspector package

  • Preview: link
  • Install via NPM:
    npm install "https://sdk-team-cdn.decentraland.org/creator-hub/branch/fix/791-rename-project-folder/@dcl/inspector/dcl-inspector-7.36.4-commit-68cffe061b34538bb86be5ea906c4b8f60f2cd81.tgz"

@DafGreco
DafGreco self-requested a review July 29, 2026 13:12

@DafGreco DafGreco left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✔️ PR reviewed and approved by QA on both platforms following instructions playing both happy and un-happy path

Regressions for this ticket had been performed in order to verify that the normal flow is working as expected:

Screen.Recording.2026-07-29.at.14.10.31.mov
20260729-1307-25.5767423.mp4

@nearnshaw
nearnshaw merged commit 98977dd into main Jul 29, 2026
5 checks passed
@nearnshaw
nearnshaw deleted the fix/791-rename-project-folder branch July 29, 2026 13:50
@github-project-automation github-project-automation Bot moved this from Todo to To Release in Creators Tools Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To Release

Development

Successfully merging this pull request may close these issues.

Option to rename folder

4 participants