diff --git a/.github/workflows/publish-unsigned-preview.yml b/.github/workflows/publish-unsigned-preview.yml index 81a1aac..e41780b 100644 --- a/.github/workflows/publish-unsigned-preview.yml +++ b/.github/workflows/publish-unsigned-preview.yml @@ -12,12 +12,12 @@ on: description: New prerelease tag; an existing release is never overwritten required: true type: string - default: v0.5.0-beta.6 + default: v0.5.0-beta.7 preview_title: description: Public prerelease title required: true type: string - default: Parlyn Engine v0.5.0 Beta 6 (unsigned) + default: Parlyn Engine v0.5.0 Beta 7 (unsigned) permissions: contents: write @@ -29,8 +29,8 @@ jobs: env: CSC_IDENTITY_AUTO_DISCOVERY: false GH_TOKEN: ${{ github.token }} - PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.6' }} - PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 6 (unsigned)' }} + PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.7' }} + PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 7 (unsigned)' }} steps: - name: Checkout @@ -68,13 +68,12 @@ jobs: shell: pwsh run: | @" - # Parlyn Engine v0.5.0 Beta 6 + # Parlyn Engine v0.5.0 Beta 7 - This unsigned Windows beta expands Phase 2 with hierarchy and viewport - multi-selection, safe one-step bulk deletion and the first complete - Multi-Scene Project Browser workflow. Project scenes can be validated, - opened, created, renamed and moved within the scenes directory while - preserving unsaved-change and project-path protections. + This unsigned Windows beta adds safe project-asset management to Phase 2. + Imported assets can be selected, renamed and moved into validated nested + folders. Parlyn preserves file extensions, refuses existing targets and + blocks project-path or symbolic-link escapes. **Important:** This beta is unsigned and may be blocked by Windows Smart App Control. It is published to document and validate the packaged @@ -87,7 +86,7 @@ jobs: remain pending. This release is not SignPath-signed. Maintainer acceptance instructions: - https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md + https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md "@ | Set-Content -LiteralPath release/PREVIEW-NOTES.md -Encoding utf8 - name: Publish GitHub pre-release diff --git a/CHANGES.md b/CHANGES.md index f139570..87af563 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ ## Unreleased — Foundation Stabilization +- Added safe project-asset selection, rename and move operations with guarded nested folders, extension preservation and no-overwrite behavior. - Added hierarchy and viewport multi-selection with Undo/Redo-safe bulk deletion. - Added a validated Multi-Scene Project Browser with scene creation, switching, rename and move operations. - Added guarded creation of scene subfolders without allowing path or symbolic-link escapes. diff --git a/README.md b/README.md index f5d71aa..1fb90d0 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ See [`docs/WINDOWS-DISTRIBUTION.md`](docs/WINDOWS-DISTRIBUTION.md) for signing, verification and the maintainer acceptance test. Phase 1 acceptance is complete. The current Phase 2 editor candidate uses the -repeatable [`v0.5.0-beta.6 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md). +repeatable [`v0.5.0-beta.7 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md). ### Code signing policy diff --git a/docs/ASSET-PIPELINE.md b/docs/ASSET-PIPELINE.md index 69afd97..5ea6588 100644 --- a/docs/ASSET-PIPELINE.md +++ b/docs/ASSET-PIPELINE.md @@ -10,3 +10,5 @@ Preferred direction: - extensible importers for additional formats Marketplace compatibility is a technical goal only. The user remains responsible for ensuring that a third-party asset license permits use in Parlyn. + +The Phase 2 editor can safely rename and move imported files inside a project's `assets/` directory. It creates validated subfolders, refuses overwrites and path escapes, ignores symbolic links during listing, and preserves the original file extension. Stable asset IDs, importer metadata, reimport and automatic reference migration remain Phase 4 work. diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md new file mode 100644 index 0000000..2bf3615 --- /dev/null +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md @@ -0,0 +1,31 @@ +# Parlyn Engine v0.5.0 Beta 7 — Maintainer Acceptance + +This pass verifies safe project-asset rename and move operations against the +published `v0.5.0-beta.7` Windows artifact. + +## 1. Install and launch + +1. Download the Beta 7 installer and `SHA256SUMS.txt`. +2. Verify the installer checksum. +3. Install normally and launch Parlyn without weakening Windows security. + +## 2. Asset rename and move + +1. Create or open a disposable Parlyn project. +2. Import at least two image, model or audio files. +3. Select one card in the **Assets** panel and confirm **Rename / Move** becomes available. +4. Rename that asset while preserving its file extension. +5. Move it to a nested path such as `assets/environment/trees/old-oak.png`. +6. Restart Parlyn, reopen the project and confirm the moved asset remains listed. +7. Attempt to move it onto the path of the second imported asset and confirm + Parlyn refuses without changing either file. +8. Attempt to change its extension and confirm Parlyn refuses the operation. +9. Confirm a failed operation remains visible in the error dialog. + +## 3. Regression + +Confirm project creation, Save, Close, Open, scene switching, scene rename/move, +multi-selection, Undo/Redo, duplication and hierarchy reparenting still work. + +Beta 7 passes only when all required checks succeed or a defect is corrected and +the affected check is repeated against a new artifact. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 620d7f5..b04ac64 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,7 +51,7 @@ Completion evidence: - durable, validated local Scene History — initial foundation complete; - multi-selection where appropriate — initial hierarchy and viewport selection complete; - multi-scene project browser — validated listing, switching, creation, rename and move complete; -- rename and move project assets; +- rename and move project assets — initial safe editor workflow complete; - unsaved-change protection; - editor preferences; - stronger error reporting; diff --git a/docs/TESTING-v0.5.0.md b/docs/TESTING-v0.5.0.md index 83acfd6..0e84e0e 100644 --- a/docs/TESTING-v0.5.0.md +++ b/docs/TESTING-v0.5.0.md @@ -1,7 +1,7 @@ # Parlyn Engine v0.5.0 Test Guide For the current Phase 2 editor acceptance pass, use the shorter artifact-specific -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md) +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md) and record the result in Issue #29. The guide below remains the detailed feature reference. This test focuses on the two new foundations introduced in v0.5.0: viewport transform gizmos and the module lifecycle. diff --git a/docs/WINDOWS-DISTRIBUTION.md b/docs/WINDOWS-DISTRIBUTION.md index 19f111f..8ef69ba 100644 --- a/docs/WINDOWS-DISTRIBUTION.md +++ b/docs/WINDOWS-DISTRIBUTION.md @@ -133,5 +133,5 @@ pending separately from the resolved development-binary blocker. Phase 1 functional acceptance completed with the unsigned `v0.5.0-beta.4` prerelease and Issue #23. The current Phase 2 editor candidate is -`v0.5.0-beta.6`; its repeatable checklist is documented in -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.6.md). +`v0.5.0-beta.7`; its repeatable checklist is documented in +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md). diff --git a/package-lock.json b/package-lock.json index 1a95fe0..8245ee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.6", + "version": "0.5.0-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parlyn-engine", - "version": "0.5.0-beta.6", + "version": "0.5.0-beta.7", "license": "MIT", "dependencies": { "three": "0.185.1" diff --git a/package.json b/package.json index c66b718..7c51cb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.6", + "version": "0.5.0-beta.7", "private": true, "description": "Parlyn Engine - open-source 2.5D-first game engine editor", "author": "Parlyn Engine contributors", diff --git a/scripts/check-asset-files.cjs b/scripts/check-asset-files.cjs new file mode 100644 index 0000000..3f3f436 --- /dev/null +++ b/scripts/check-asset-files.cjs @@ -0,0 +1,43 @@ +const assert = require('assert/strict'); +const fs = require('fs/promises'); +const os = require('os'); +const path = require('path'); +const { requireAssetPath, listAssets, moveAsset } = require('../src/main/assetFiles'); + +(async () => { + assert.equal(requireAssetPath('assets/trees/oak.png'), 'assets/trees/oak.png'); + assert.throws(() => requireAssetPath('../oak.png'), /invalid segment/); + assert.throws(() => requireAssetPath('assets/../oak.png'), /invalid segment/); + assert.throws(() => requireAssetPath('assets\\oak.png'), /forward slashes/); + assert.throws(() => requireAssetPath('assets/no-extension'), /extension/); + + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'parlyn-assets-')); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'parlyn-assets-outside-')); + try { + await fs.mkdir(path.join(root, 'assets')); + await fs.writeFile(path.join(root, 'assets', 'oak.png'), 'oak'); + await fs.writeFile(path.join(root, 'assets', 'taken.png'), 'taken'); + assert.deepEqual((await listAssets(root)).map((asset) => asset.relativePath), ['assets/oak.png', 'assets/taken.png']); + + const moved = await moveAsset(root, 'assets/oak.png', 'assets/trees/old-oak.png'); + assert.equal(moved.relativePath, 'assets/trees/old-oak.png'); + assert.equal(await fs.readFile(path.join(root, 'assets', 'trees', 'old-oak.png'), 'utf8'), 'oak'); + await assert.rejects(fs.access(path.join(root, 'assets', 'oak.png'))); + await assert.rejects(moveAsset(root, 'assets/trees/old-oak.png', 'assets/taken.png'), /already exists/); + await assert.rejects(moveAsset(root, 'assets/trees/old-oak.png', 'assets/trees/old-oak.glb'), /preserve its file extension/); + await assert.rejects(moveAsset(root, 'assets/trees/old-oak.png', 'scenes/old-oak.png'), /assets folder/); + + try { + await fs.writeFile(path.join(outside, 'outside.png'), 'outside'); + await fs.symlink(path.join(outside, 'outside.png'), path.join(root, 'assets', 'linked.png')); + await assert.rejects(moveAsset(root, 'assets/linked.png', 'assets/moved.png'), /symbolic link/); + assert.ok(!(await listAssets(root)).some((asset) => asset.relativePath === 'assets/linked.png')); + } catch (error) { + if (!['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) throw error; + } + } finally { + await fs.rm(root, { recursive:true, force:true }); + await fs.rm(outside, { recursive:true, force:true }); + } + console.log('Project asset listing and move contract check passed.'); +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/check-desktop-boundaries.cjs b/scripts/check-desktop-boundaries.cjs index f72f07d..9310e32 100644 --- a/scripts/check-desktop-boundaries.cjs +++ b/scripts/check-desktop-boundaries.cjs @@ -63,6 +63,7 @@ const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableP assert.match(main, /secureHandle\('parlyn:project:open-scene'/); assert.match(main, /secureHandle\('parlyn:project:create-scene'/); assert.match(main, /secureHandle\('parlyn:project:move-scene'/); + assert.match(main, /secureHandle\('parlyn:project:move-asset'/); assert.match(main, /secureHandle\('parlyn:project:close'/); assert.match(main, /secureHandle\('parlyn:project:delete'/); assert.match(main, /parlyn-scene-history/); @@ -80,7 +81,7 @@ const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableP } }, { filename:'src/main/preload.js' }); assert.ok(exposedHost, 'Preload must expose window.parlynHost.'); - for (const method of ['getAppInfo','createProject','openProject','openProjectScene','createProjectScene','moveProjectScene','closeProject','deleteProject','saveProjectScene','saveProjectWorld','saveSceneAs','openScene','importAssets']) { + for (const method of ['getAppInfo','createProject','openProject','openProjectScene','createProjectScene','moveProjectScene','closeProject','deleteProject','saveProjectScene','saveProjectWorld','saveSceneAs','openScene','importAssets','moveProjectAsset']) { assert.equal(typeof exposedHost[method], 'function', `Preload host is missing ${method}().`); } diff --git a/scripts/check.cjs b/scripts/check.cjs index ae34858..97e173f 100644 --- a/scripts/check.cjs +++ b/scripts/check.cjs @@ -13,6 +13,7 @@ const required = [ 'src/main/documentFiles.mjs', 'src/main/ipcSecurity.js', 'src/main/projectPaths.js', + 'src/main/assetFiles.js', 'src/main/ProjectSession.js', 'src/main/preload.js', 'src/renderer/index.html', @@ -32,6 +33,7 @@ const required = [ 'docs/WINDOWS-DISTRIBUTION.md', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md', + 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.7.md', 'build/icon.ico', '.github/workflows/windows-installer.yml', 'scripts/check-smart-systems.mjs', @@ -39,6 +41,7 @@ const required = [ 'scripts/check-core-invariants.mjs', 'scripts/check-desktop-boundaries.cjs', 'scripts/check-project-session.cjs', + 'scripts/check-asset-files.cjs', 'scripts/check-electron-preload.cjs', 'scripts/check-windows-distribution.cjs', 'scripts/verify-windows-artifacts.ps1' @@ -50,7 +53,7 @@ for (const rel of required) { const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); if (pkg.name !== 'parlyn-engine') throw new Error('Unexpected package name.'); -if (pkg.version !== '0.5.0-beta.6') throw new Error(`Unexpected package version: ${pkg.version}`); +if (pkg.version !== '0.5.0-beta.7') throw new Error(`Unexpected package version: ${pkg.version}`); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { @@ -70,6 +73,7 @@ cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-core-persisten cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-core-invariants.mjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-desktop-boundaries.cjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-project-session.cjs')], { stdio: 'inherit' }); +cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-asset-files.cjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-windows-distribution.cjs')], { stdio: 'inherit' }); console.log(`Parlyn structure check passed (${codeFiles.length} JavaScript modules checked).`); diff --git a/src/main/assetFiles.js b/src/main/assetFiles.js new file mode 100644 index 0000000..aed3fa3 --- /dev/null +++ b/src/main/assetFiles.js @@ -0,0 +1,56 @@ +const fs = require('fs/promises'); +const path = require('path'); +const { validateRelativeProjectPath, resolveExistingProjectPath, resolveWritableProjectPathCreatingParents } = require('./projectPaths'); + +function requireAssetPath(value, label = 'Asset path') { + const relativePath = validateRelativeProjectPath(value, label); + if (!/^assets\/.+/.test(relativePath)) throw new Error(`${label} must stay inside the project's assets folder.`); + if (!path.posix.extname(relativePath)) throw new Error(`${label} must identify a file with an extension.`); + return relativePath; +} + +async function listAssets(projectRoot) { + if (!projectRoot) return []; + let assetsRoot; + try { assetsRoot = await resolveExistingProjectPath(projectRoot, 'assets', 'Assets directory'); } + catch (error) { if (error.code === 'ENOENT') return []; throw error; } + const result = []; + async function walk(directory) { + let entries; + try { entries = await fs.readdir(directory, { withFileTypes:true }); } + catch (error) { if (error.code === 'ENOENT') return; throw error; } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) await walk(fullPath); + else if (entry.isFile()) result.push({ name:entry.name, relativePath:`assets/${path.relative(assetsRoot, fullPath).replace(/\\/g, '/')}`, extension:path.extname(entry.name).toLowerCase() }); + } + } + await walk(assetsRoot); + return result.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); +} + +async function moveAsset(projectRoot, sourceValue, targetValue) { + const sourcePath = requireAssetPath(sourceValue, 'Existing asset path'); + const targetPath = requireAssetPath(targetValue, 'New asset path'); + if (path.posix.extname(sourcePath).toLowerCase() !== path.posix.extname(targetPath).toLowerCase()) { + throw new Error('Renaming an asset must preserve its file extension. Reimport the file to change its format.'); + } + const source = await resolveExistingProjectPath(projectRoot, sourcePath, 'Existing asset'); + if (!(await fs.stat(source)).isFile()) throw new Error('The selected asset is not a regular file.'); + if (sourcePath === targetPath) return { relativePath:sourcePath, assets:await listAssets(projectRoot) }; + const target = await resolveWritableProjectPathCreatingParents(projectRoot, targetPath, 'New asset path'); + try { await fs.link(source, target); } + catch (error) { + if (error.code === 'EEXIST') throw new Error('An asset already exists at that project path.'); + throw error; + } + try { await fs.unlink(source); } + catch (error) { + await fs.unlink(target).catch(() => {}); + throw error; + } + return { relativePath:targetPath, assets:await listAssets(projectRoot) }; +} + +module.exports = { requireAssetPath, listAssets, moveAsset }; diff --git a/src/main/main.js b/src/main/main.js index 3dc4225..91aec93 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -5,6 +5,7 @@ const { pathToFileURL } = require('url'); const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableProjectPathCreatingParents } = require('./projectPaths'); const { assertTrustedIpcEvent, assertIpcPayload } = require('./ipcSecurity'); const { ProjectSession } = require('./ProjectSession'); +const { listAssets, moveAsset } = require('./assetFiles'); const persistence = import('../engine/persistence/DocumentPersistence.mjs'); const documentFiles = import('./documentFiles.mjs'); @@ -75,31 +76,6 @@ const projectSession = new ProjectSession({ } }); -async function listAssets(projectRoot) { - if (!projectRoot) return []; - let assetsRoot; - try { - assetsRoot = await resolveExistingProjectPath(projectRoot, 'assets', 'Assets directory'); - } catch (error) { - if (error.code === 'ENOENT') return []; - throw error; - } - const result = []; - async function walk(dir) { - let entries; - try { entries = await fs.readdir(dir, { withFileTypes:true }); } - catch (error) { if (error.code === 'ENOENT') return; throw error; } - for (const entry of entries) { - if (entry.isSymbolicLink()) continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) await walk(full); - else result.push({ name:entry.name, relativePath:path.relative(projectRoot, full).replace(/\\/g,'/'), extension:path.extname(entry.name).toLowerCase() }); - } - } - await walk(assetsRoot); - return result.sort((a,b)=>a.relativePath.localeCompare(b.relativePath)); -} - async function listProjectScenes(projectRoot) { if (!projectRoot) return []; const scenesRoot = await resolveExistingProjectPath(projectRoot, 'scenes', 'Scenes directory'); @@ -332,6 +308,12 @@ secureHandle('parlyn:project:import-assets', async () => { return { canceled:false, assets:await listAssets(activeProjectRoot) }; }); +secureHandle('parlyn:project:move-asset', async (payload) => { + const activeProjectRoot = projectSession.activeProjectRoot; + if (!activeProjectRoot) throw new Error('Open a project before moving an asset.'); + return { ok:true, ...await moveAsset(activeProjectRoot, payload?.sourcePath, payload?.targetPath) }; +}, { payload:true }); + app.whenReady().then(()=>{ createWindow(); app.on('activate',()=>{ if (BrowserWindow.getAllWindows().length===0) createWindow(); }); diff --git a/src/main/preload.js b/src/main/preload.js index 007d4fd..959a614 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -13,5 +13,6 @@ contextBridge.exposeInMainWorld('parlynHost', { deleteProject:(payload)=>ipcRenderer.invoke('parlyn:project:delete',payload), saveProjectScene:(payload)=>ipcRenderer.invoke('parlyn:project:save-scene',payload), saveProjectWorld:(payload)=>ipcRenderer.invoke('parlyn:project:save-world',payload), - importAssets:()=>ipcRenderer.invoke('parlyn:project:import-assets') + importAssets:()=>ipcRenderer.invoke('parlyn:project:import-assets'), + moveProjectAsset:(payload)=>ipcRenderer.invoke('parlyn:project:move-asset',payload) }); diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs index 786494e..d51d9b8 100644 --- a/src/renderer/app.mjs +++ b/src/renderer/app.mjs @@ -25,6 +25,7 @@ async function bootstrap() { let currentSceneRelativePath = null; let currentWorld = null; let assets = []; + let selectedAssetPath = null; let projectScenes = []; let inspectorStartSnapshot = null; let dirty = false; @@ -127,6 +128,7 @@ async function bootstrap() { currentFilePath = null; currentWorld = null; assets = []; + selectedAssetPath = null; projectScenes = []; scene = new SceneDocument("Untitled Scene"); history.clear(); @@ -537,6 +539,7 @@ async function bootstrap() { currentSceneRelativePath = currentProject.startupScene; currentFilePath = null; assets = result.assets ?? []; + selectedAssetPath = null; projectScenes = result.scenes ?? []; history.clear(); updateHistoryButtons(); @@ -560,6 +563,7 @@ async function bootstrap() { currentSceneRelativePath = currentProject.startupScene; currentFilePath = null; assets = result.assets ?? []; + selectedAssetPath = null; projectScenes = result.scenes ?? []; if (result.scene) { scene = SceneDocument.fromJSON(result.scene); @@ -638,6 +642,8 @@ async function bootstrap() { const grid = $("asset-grid"); grid.replaceChildren(); $("asset-count").textContent = currentProject ? `${assets.length} imported` : "No project"; + if (!assets.some((asset) => asset.relativePath === selectedAssetPath)) selectedAssetPath = null; + $("move-asset").disabled = !currentProject || !selectedAssetPath; if (!currentProject) { grid.innerHTML = '
Create or open a project to import assets.
'; return; @@ -648,9 +654,14 @@ async function bootstrap() { } for (const asset of assets) { const card = document.createElement("button"); - card.className = "asset-card"; + card.className = "asset-card" + (asset.relativePath === selectedAssetPath ? " active" : ""); card.title = asset.relativePath; card.innerHTML = `${assetIcon(asset.extension)}${escapeHtml(asset.name)}${escapeHtml(asset.relativePath)}`; + card.addEventListener("click", () => { + selectedAssetPath = asset.relativePath; + renderAssets(); + status.textContent = `Selected asset: ${asset.relativePath}`; + }); grid.appendChild(card); } } @@ -755,6 +766,20 @@ async function bootstrap() { showError("Asset import failed", error); } } + async function moveProjectAsset() { + if (!currentProject || !selectedAssetPath) return; + const sourcePath = selectedAssetPath; + try { + const result = await host.moveProjectAsset({ sourcePath, targetPath:$("move-asset-path").value.trim() }); + assets = result.assets ?? []; + selectedAssetPath = result.relativePath; + renderAssets(); + $("move-asset-dialog").close(); + status.textContent = `Asset moved: ${result.relativePath}`; + } catch (error) { + showError("Asset move failed", error); + } + } function shortPath(filePath) { return filePath ? filePath.split(/[\\/]/).slice(-2).join("/") : ""; } @@ -849,6 +874,13 @@ async function bootstrap() { $("undo").addEventListener("click", undo); $("redo").addEventListener("click", redo); $("import-asset").addEventListener("click", importAssets); + $("move-asset").addEventListener("click", () => { + if (!selectedAssetPath) return; + $("move-asset-path").value = selectedAssetPath; + $("move-asset-dialog").showModal(); + }); + $("cancel-move-asset").addEventListener("click", () => $("move-asset-dialog").close()); + $("confirm-move-asset").addEventListener("click", moveProjectAsset); window.addEventListener("keydown", (event) => { const tag = document.activeElement?.tagName?.toLowerCase(); const editing = tag === "input" || tag === "select" || tag === "textarea"; diff --git a/src/renderer/index.html b/src/renderer/index.html index 961982e..7aa7aa0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -70,7 +70,7 @@
AssetsNo project
- +
@@ -140,6 +140,12 @@

Camera

+ +
Rename or Move AssetChoose a path inside the project's assets folder. The file extension must stay unchanged.
+

Existing files are never overwritten.

+
+
+
Create Parlyn ProjectParlyn will create scenes, assets and internal metadata folders.