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 = `
${escapeHtml(entry.name)}${escapeHtml(entry.relativePath)}
`; + button.addEventListener("click", () => openProjectScene(entry.relativePath)); + list.appendChild(button); + } + } + async function openProjectScene(relativePath) { + if (!currentProject || relativePath === currentSceneRelativePath || !await mayCloseProject()) return; + try { + const result = await host.openProjectScene({ relativePath }); + scene = SceneDocument.fromJSON(result.scene); + currentSceneRelativePath = result.relativePath; + currentFilePath = null; + if (result.history) { + try { history.restoreState(result.history); } + catch (error) { console.warn("Saved scene history was ignored:", error); history.clear(); } + } else history.clear(); + renderer.rebuild(scene); + clearSelection(); + updateHistoryButtons(); + renderProjectScenes(); + setDirty(false); + status.textContent = result.historyWarning ? `Scene opened without local history: ${result.historyWarning}` : `Opened project scene: ${relativePath}`; + } catch (error) { + showError("Project scene open failed", error); + } + } async function importAssets() { if (!currentProject) { status.textContent = "Create or open a Parlyn project before importing assets."; @@ -803,6 +850,7 @@ async function bootstrap() { frame(); renderHierarchy(); renderAssets(); + renderProjectScenes(); updateProjectUI(); selectById(scene.root.children[1].id); updateHistoryButtons(); diff --git a/src/renderer/index.html b/src/renderer/index.html index 63d7eee..8ef6f64 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -37,6 +37,8 @@ +
ScenesNo project
+
Hierarchy
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 46273a3..ca97cd7 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -12,3 +12,5 @@ .error-dialog{width:min(540px,calc(100vw - 40px))}.error-dialog .dialog-head{border-left:4px solid #d56c6c}.error-body{padding:16px}.error-body p{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;color:#f1dede;font-size:12px;line-height:1.5} .project-strip{padding:8px 9px 8px 11px;display:flex;align-items:center;justify-content:space-between;gap:8px}.project-identity{display:grid;gap:2px;min-width:0}.project-actions{display:flex;gap:4px;flex:0 0 auto}.project-actions button{padding:4px 6px;font-size:9px}.danger-subtle{color:#e1a4a4}.delete-project-body p{margin:0 0 9px}.delete-project-body code{display:block;padding:8px;border:1px solid var(--parlyn-border);border-radius:4px;background:var(--parlyn-input);color:var(--parlyn-muted);font-size:10px;overflow-wrap:anywhere}.delete-project-body .danger-note{margin:12px 0 0;padding:8px;border-left:3px solid #b85a60;background:#24171b;color:#ffd2d2;font-size:11px}.delete-project-body .recovery-note{margin:12px 0 0;color:var(--parlyn-muted);font-size:10px;line-height:1.4}.danger{border-color:#914d53;background:#542a2f;color:#fff}.danger:hover{border-color:#c56a72;background:#6b3339} .tree-item.child{padding-left:calc(11px + var(--tree-depth,1) * 16px)} + +.project-scenes-head{height:32px;padding:0 11px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--parlyn-border);font-size:10px}.project-scenes-head span{color:var(--parlyn-muted)}.project-scenes{max-height:132px;overflow:auto;padding:5px;border-bottom:1px solid var(--parlyn-border)}.scene-entry{width:100%;display:grid;grid-template-columns:auto minmax(0,1fr);gap:7px;align-items:center;padding:6px 7px;border:0;background:transparent;text-align:left}.scene-entry span{color:var(--parlyn-blue2)}.scene-entry div{min-width:0}.scene-entry strong,.scene-entry small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scene-entry strong{font-size:10px}.scene-entry small{font-size:9px;color:var(--parlyn-muted)}.scene-entry.active{background:var(--parlyn-blue-soft);border:1px solid #315d90}.scene-list-empty{padding:10px;color:var(--parlyn-muted);font-size:10px;text-align:center}