From 7c4f353df2bcd49b6efc0ac0c9855f88ea75480c Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:45:21 +0300 Subject: [PATCH] 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 --- app/legacy/models.js | 18 ++++++++++++++- app/legacy/pipeline_state.js | 38 ++++++++++++++++++++++++++++++- app/legacy/pipeline_state.test.js | 28 +++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/legacy/models.js b/app/legacy/models.js index f60a0fa7..0b57c127 100644 --- a/app/legacy/models.js +++ b/app/legacy/models.js @@ -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"; diff --git a/app/legacy/pipeline_state.js b/app/legacy/pipeline_state.js index 19af9bc3..9c2fc114 100644 --- a/app/legacy/pipeline_state.js +++ b/app/legacy/pipeline_state.js @@ -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)), diff --git a/app/legacy/pipeline_state.test.js b/app/legacy/pipeline_state.test.js index 1feb387e..43fa16d2 100644 --- a/app/legacy/pipeline_state.test.js +++ b/app/legacy/pipeline_state.test.js @@ -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"); +});