diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b01457c..cca8224 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -50,7 +50,7 @@ Completion evidence: - duplication and reparenting — initial editor commands complete; - durable, validated local Scene History — initial foundation complete; - multi-selection where appropriate — initial hierarchy and viewport selection complete; -- multi-scene project browser; +- multi-scene project browser — initial validated scene listing and switching complete; - rename and move project assets; - unsaved-change protection; - editor preferences; diff --git a/scripts/check-desktop-boundaries.cjs b/scripts/check-desktop-boundaries.cjs index 3f1b7e1..a2b83c1 100644 --- a/scripts/check-desktop-boundaries.cjs +++ b/scripts/check-desktop-boundaries.cjs @@ -57,6 +57,7 @@ const { resolveExistingProjectPath, resolveWritableProjectPath } = require('../s assert.match(main, /will-navigate/); assert.match(main, /secureHandle\('parlyn:app:get-info'/); assert.match(main, /secureHandle\('parlyn:project:open'/); + assert.match(main, /secureHandle\('parlyn:project:open-scene'/); assert.match(main, /secureHandle\('parlyn:project:close'/); assert.match(main, /secureHandle\('parlyn:project:delete'/); assert.match(main, /parlyn-scene-history/); @@ -74,7 +75,7 @@ const { resolveExistingProjectPath, resolveWritableProjectPath } = require('../s } }, { filename:'src/main/preload.js' }); assert.ok(exposedHost, 'Preload must expose window.parlynHost.'); - for (const method of ['getAppInfo','createProject','openProject','closeProject','deleteProject','saveProjectScene','saveProjectWorld','saveSceneAs','openScene','importAssets']) { + for (const method of ['getAppInfo','createProject','openProject','openProjectScene','closeProject','deleteProject','saveProjectScene','saveProjectWorld','saveSceneAs','openScene','importAssets']) { assert.equal(typeof exposedHost[method], 'function', `Preload host is missing ${method}().`); } diff --git a/scripts/check-electron-preload.cjs b/scripts/check-electron-preload.cjs index 2e9a777..c4766d9 100644 --- a/scripts/check-electron-preload.cjs +++ b/scripts/check-electron-preload.cjs @@ -6,6 +6,7 @@ const methods = [ 'getAppInfo', 'createProject', 'openProject', + 'openProjectScene', 'closeProject', 'deleteProject', 'saveProjectScene', diff --git a/src/main/main.js b/src/main/main.js index 7d0c0eb..c89b09d 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -100,6 +100,21 @@ async function listAssets(projectRoot) { 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'); + const entries = await fs.readdir(scenesRoot, { withFileTypes:true }); + const scenes = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.parlyn-scene.json')) continue; + const relativePath = `scenes/${entry.name}`; + const filePath = await resolveExistingProjectPath(projectRoot, relativePath, 'Project scene'); + const scene = await readDocument(filePath, 'parlyn-scene', 'Project scene'); + scenes.push({ name:scene.name, relativePath }); + } + return scenes.sort((a,b) => a.relativePath.localeCompare(b.relativePath)); +} + function createWindow() { const win = new BrowserWindow({ width:1500, @@ -178,7 +193,7 @@ secureHandle('parlyn:project:create', async (payload) => { throw error; } const activeProjectRoot=projectSession.activate(await fs.realpath(projectRoot)); - return { canceled:false, projectRoot:activeProjectRoot, project, world, assets:await listAssets(activeProjectRoot) }; + return { canceled:false, projectRoot:activeProjectRoot, project, world, assets:await listAssets(activeProjectRoot), scenes:await listProjectScenes(activeProjectRoot) }; }, { payload:true }); secureHandle('parlyn:project:open', async () => { @@ -193,9 +208,20 @@ secureHandle('parlyn:project:open', async () => { const world=await readDocument(worldPath, 'parlyn-world', 'World document'); const sceneHistory=await loadSceneHistory(projectRoot, project.startupScene, scene); projectSession.activate(projectRoot); - return { canceled:false, projectRoot, project, scene, world, assets:await listAssets(projectRoot), history:sceneHistory.history, historyWarning:sceneHistory.warning }; + return { canceled:false, projectRoot, project, scene, world, assets:await listAssets(projectRoot), scenes:await listProjectScenes(projectRoot), history:sceneHistory.history, historyWarning:sceneHistory.warning }; }); +secureHandle('parlyn:project:open-scene', async (payload) => { + const activeProjectRoot=projectSession.activeProjectRoot; + if (!activeProjectRoot) throw new Error('Open a project before selecting a project scene.'); + const relativePath=payload?.relativePath; + if (typeof relativePath !== 'string' || !relativePath.startsWith('scenes/') || !relativePath.endsWith('.parlyn-scene.json')) throw new Error('Invalid project scene path.'); + const scenePath=await resolveExistingProjectPath(activeProjectRoot,relativePath,'Project scene'); + const scene=await readDocument(scenePath, 'parlyn-scene', 'Project scene'); + const sceneHistory=await loadSceneHistory(activeProjectRoot, relativePath, scene); + return { ok:true, relativePath, scene, history:sceneHistory.history, historyWarning:sceneHistory.warning }; +}, { payload:true }); + secureHandle('parlyn:project:close', async () => projectSession.close()); secureHandle('parlyn:project:delete', async (payload) => projectSession.moveToTrash(payload?.confirmationName), { payload:true }); @@ -221,7 +247,7 @@ secureHandle('parlyn:project:save-scene', async (payload) => { const project=await readDocument(projectFile, 'parlyn-project', 'Parlyn project file'); project.updatedAt=new Date().toISOString(); await writeDocumentAtomic(projectFile, project, 'parlyn-project', 'Parlyn project file'); - return { ok:true, filePath:target, relativePath, historyWarning }; + return { ok:true, filePath:target, relativePath, historyWarning, scenes:await listProjectScenes(activeProjectRoot) }; }, { payload:true }); secureHandle('parlyn:project:save-world', async (payload) => { diff --git a/src/main/preload.js b/src/main/preload.js index fe84e61..3ca0b43 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('parlynHost', { openScene:()=>ipcRenderer.invoke('parlyn:scene:open'), createProject:(payload)=>ipcRenderer.invoke('parlyn:project:create',payload), openProject:()=>ipcRenderer.invoke('parlyn:project:open'), + openProjectScene:(payload)=>ipcRenderer.invoke('parlyn:project:open-scene',payload), closeProject:()=>ipcRenderer.invoke('parlyn:project:close'), deleteProject:(payload)=>ipcRenderer.invoke('parlyn:project:delete',payload), saveProjectScene:(payload)=>ipcRenderer.invoke('parlyn:project:save-scene',payload), diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs index c38f1a5..0fcde38 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 projectScenes = []; let inspectorStartSnapshot = null; let dirty = false; let pendingUnsavedDecision = null; @@ -126,6 +127,7 @@ async function bootstrap() { currentFilePath = null; currentWorld = null; assets = []; + projectScenes = []; scene = new SceneDocument("Untitled Scene"); history.clear(); renderer.rebuild(scene); @@ -133,6 +135,7 @@ async function bootstrap() { updateHistoryButtons(); updateProjectUI(); renderAssets(); + renderProjectScenes(); setDirty(false); } function resolveUnsavedDecision(decision) { @@ -534,11 +537,13 @@ async function bootstrap() { currentSceneRelativePath = currentProject.startupScene; currentFilePath = null; assets = result.assets ?? []; + projectScenes = result.scenes ?? []; history.clear(); updateHistoryButtons(); $("project-dialog").close(); updateProjectUI(); renderAssets(); + renderProjectScenes(); setDirty(false); status.textContent = `Project created: ${currentProject.name}`; } catch (error) { @@ -555,6 +560,7 @@ async function bootstrap() { currentSceneRelativePath = currentProject.startupScene; currentFilePath = null; assets = result.assets ?? []; + projectScenes = result.scenes ?? []; if (result.scene) { scene = SceneDocument.fromJSON(result.scene); renderer.rebuild(scene); @@ -567,6 +573,7 @@ async function bootstrap() { updateHistoryButtons(); updateProjectUI(); renderAssets(); + renderProjectScenes(); setDirty(false); status.textContent = result.historyWarning ? `Project opened without local history: ${result.historyWarning}` @@ -645,6 +652,46 @@ async function bootstrap() { grid.appendChild(card); } } + function renderProjectScenes() { + const list = $("project-scenes"); + list.replaceChildren(); + $("scene-count").textContent = currentProject ? `${projectScenes.length} available` : "No project"; + if (!currentProject || !projectScenes.length) { + const empty = document.createElement("div"); + empty.className = "scene-list-empty"; + empty.textContent = currentProject ? "No project scenes found." : "Open or create a project."; + list.appendChild(empty); + return; + } + for (const entry of projectScenes) { + const button = document.createElement("button"); + button.className = "scene-entry" + (entry.relativePath === currentSceneRelativePath ? " active" : ""); + button.innerHTML = `◇