feat: Models & Pipeline has Save and Cancel; Cancel puts back the pipeline as it was when the window opened

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 02:45:21 +03:00
co-authored by Claude Sonnet 5.5
parent 196f8cd807
commit 7c4f353df2
3 changed files with 82 additions and 2 deletions
+17 -1
View File
@@ -509,15 +509,31 @@
_body.append(content);
_mounted = { content };
if (ctx && ctx.sub && ctx.sub.startsWith("node:")) openNodeId = ctx.sub.slice(5);
let snap = null; // the pipeline as it was when this window opened
if (ctx && ctx.setActions) {
const card = el("button", "btn ghost tiny", "🖼 Export settings…");
card.title = "Render this pipeline as a PNG — loaders, typed-in node values, "
+ "and the host's torch / CUDA / attention";
card.onclick = openSettingsCard;
ctx.setActions([card]);
// Changes apply as they are made (the pipeline is the live one); Cancel puts back what was here
// when the window opened, Save keeps it and closes.
const cancel = el("button", "btn ghost tiny", "Cancel");
cancel.title = "Undo everything changed in this window since it opened";
cancel.onclick = async () => {
if (snap) {
const res = await PS.restore(snap);
if (res.refused.length) { alert(res.refused[0]); return; }
}
window.SettingsWindow.close();
};
const save = el("button", "btn primary tiny", "Save");
save.title = "Keep the changes and close";
save.onclick = () => window.SettingsWindow.close();
ctx.setActions([card, cancel, save]);
}
render();
PS.ensureLoaded().then(() => {
snap = PS.snapshot();
if (openNodeId) {
const slot = (PS.slots() || []).find((s) => s.id === openNodeId);
if (slot) group = slot.group || "Other";
+37 -1
View File
@@ -345,6 +345,42 @@
return { refused: refusedNow };
}
// The pipeline as it stands, to come back to: Models & Pipeline's Cancel puts this back.
function snapshot() {
if (slots === null) return null;
return { slots: JSON.parse(JSON.stringify(slots)), removed: [...removed], unwired: JSON.parse(JSON.stringify(unwired)) };
}
// -> {refused: [...]}; empty when the snapshot is back. Announced as a change, so the project follows.
async function restore(snap) {
if (!snap || !Array.isArray(snap.slots)) return { refused: ["Nothing to go back to."] };
if (adoptGate) await adoptGate;
for (let waited = 0; saving && waited < 10000; waited += 20) await new Promise((r) => setTimeout(r, 20));
if (saving) return { refused: ["A save is still in progress: try again in a moment."] };
const mine = epoch;
saving = true;
let refusedNow = [];
try {
const res = await API.editPipeline({ slots: JSON.parse(JSON.stringify(snap.slots)) });
if (mine !== epoch) return { refused: ["The project was changed while this was being sent."] };
refusedNow = (res && res.refused) || [];
if (res && res.slots && !refusedNow.length) {
slots = res.slots;
removed = new Set(snap.removed || []);
unwired = JSON.parse(JSON.stringify(snap.unwired || {}));
groupEdits = {}; pendingValues = {}; pendingBody = null; pending = false;
incomplete = res.incomplete || []; refused = []; queueable = !!res.queueable;
saveNotes = res.notes || [];
try { await refreshManifest(); } catch (_) { /* the next save retries it */ }
_changed();
}
} catch (e) {
refusedNow = [e && e.message ? e.message : String(e)];
} finally {
saving = false;
}
return { refused: refusedNow };
}
// A slot's group is the person's own to name (any node in any group).
// Kept until it has landed: a save already in flight answers with the slots as they were when
// it left, and would otherwise put the old group back.
@@ -444,7 +480,7 @@
}
window.PipelineState = {
ensureLoaded, save, edit, setGroup, adopt, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, valuesAlreadyPlaced, currentValues, setModuleValue,
ensureLoaded, save, edit, restore, snapshot, setGroup, adopt, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, valuesAlreadyPlaced, currentValues, setModuleValue,
modulesById: () => modulesById,
removedIds: () => [...removed],
unwiredMap: () => JSON.parse(JSON.stringify(unwired)),
+28
View File
@@ -211,3 +211,31 @@ test("a group change queued behind a save in flight is not re-stamped over a lat
await PS.setGroup("gen", "Other");
assert.strictEqual(PS.slots().find((s) => s.id === "gen").group, "Other");
});
test("restore puts back the pipeline as snapshotted: slots, removed, unwired, and tells the project", async () => {
const PS = load([]);
await PS.ensureLoaded();
const snap = PS.snapshot();
await PS.save({ inputs: { model: { file: "changed" } } });
global.window.MovieEditorAPI.editPipeline = async (body) => ({ refused: [], incomplete: [], queueable: true, slots: body.slots });
await PS.edit({ action: "remove", slot: "gen" });
assert.deepStrictEqual(PS.removedIds(), ["gen"]);
let heard = 0;
PS.subscribe(() => { heard++; });
const res = await PS.restore(snap);
assert.deepStrictEqual(res.refused, []);
assert.deepStrictEqual(PS.slots(), snap.slots);
assert.deepStrictEqual(PS.removedIds(), []);
assert.strictEqual(heard, 1);
});
test("a refused restore leaves the pipeline as it is", async () => {
const PS = load([]);
await PS.ensureLoaded();
const snap = PS.snapshot();
await PS.save({ inputs: { model: { file: "changed" } } });
global.window.MovieEditorAPI.editPipeline = async () => ({ refused: ["no"], slots: [] });
const res = await PS.restore(snap);
assert.deepStrictEqual(res.refused, ["no"]);
assert.strictEqual(PS.slots().find((s) => s.id === "model").inputs.file, "changed");
});