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
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion scripts/check-desktop-boundaries.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand All @@ -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}().`);
}

Expand Down
1 change: 1 addition & 0 deletions scripts/check-electron-preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const methods = [
'getAppInfo',
'createProject',
'openProject',
'openProjectScene',
'closeProject',
'deleteProject',
'saveProjectScene',
Expand Down
32 changes: 29 additions & 3 deletions src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 });
Expand All @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions src/main/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
48 changes: 48 additions & 0 deletions src/renderer/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -126,13 +127,15 @@ async function bootstrap() {
currentFilePath = null;
currentWorld = null;
assets = [];
projectScenes = [];
scene = new SceneDocument("Untitled Scene");
history.clear();
renderer.rebuild(scene);
clearSelection();
updateHistoryButtons();
updateProjectUI();
renderAssets();
renderProjectScenes();
setDirty(false);
}
function resolveUnsavedDecision(decision) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -567,6 +573,7 @@ async function bootstrap() {
updateHistoryButtons();
updateProjectUI();
renderAssets();
renderProjectScenes();
setDirty(false);
status.textContent = result.historyWarning
? `Project opened without local history: ${result.historyWarning}`
Expand Down Expand Up @@ -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 = `<span>◇</span><div><strong>${escapeHtml(entry.name)}</strong><small>${escapeHtml(entry.relativePath)}</small></div>`;
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.";
Expand Down Expand Up @@ -803,6 +850,7 @@ async function bootstrap() {
frame();
renderHierarchy();
renderAssets();
renderProjectScenes();
updateProjectUI();
selectById(scene.root.children[1].id);
updateHistoryButtons();
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
<button id="delete-project" class="danger-subtle" title="Move the active project to the Recycle Bin" disabled>Delete</button>
</div>
</div>
<div class="project-scenes-head"><strong>Scenes</strong><span id="scene-count">No project</span></div>
<div id="project-scenes" class="project-scenes"></div>
<div class="panel-title">
<strong>Hierarchy</strong>
<div class="panel-actions">
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading