diff --git a/README.md b/README.md index 4427b85..b4a3360 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,10 @@ Existing browser pages must be manually refreshed once after installing this fea Before refreshing, open workflows are backed up in that tab's `sessionStorage`. Backup serializes the graph directly, without running API prompt serializers; inactive tabs that have not loaded yet are loaded before backup. -After reload, drafts already restored by ComfyUI are reused; missing drafts are -opened as local recovery copies without overwriting saved files. Backups are bound +After reload, original workflow paths identify tabs already restored by ComfyUI; +view changes do not create duplicate tabs. Missing drafts and pending edits are +restored under their original paths and tab order without overwriting saved files. +Backups from older versions are matched by graph identity and content. Backups are bound to the authenticated account. If backup or recovery fails, automatic refresh pauses and displays a warning so the user can save manually. This protects graph edits; it does not preserve transient dialogs or file uploads in progress. diff --git a/tests/browser_frontend_reload.cjs b/tests/browser_frontend_reload.cjs index 9088a5b..e3af49c 100644 --- a/tests/browser_frontend_reload.cjs +++ b/tests/browser_frontend_reload.cjs @@ -113,11 +113,14 @@ async function draftImages(page) { const h = await harness(browser); await openDraft(h.page, 'one', 'unsaved-one.png'); await openDraft(h.page, 'two', 'unsaved-two.png'); + const originalPaths = await h.page.evaluate(() => window.comfyAPI.app.app.extensionManager.workflow.openWorkflows.map(item => item.path)); const before = h.navigations(); h.state.served = h.state.current = '200'; await Promise.all([h.page.waitForEvent('load'), reconnect(h.page)]); await h.page.waitForFunction(key => sessionStorage.getItem(key) === null && window.comfyAPI?.app?.app?.isGraphReady && document.querySelector('body'), backupKey); const images = await draftImages(h.page); + assert.deepEqual(await h.page.evaluate(() => window.comfyAPI.app.app.extensionManager.workflow.openWorkflows.map(item => item.path)), originalPaths, + 'Reload added duplicate workflow tabs'); assert(images.includes('unsaved-one.png'), 'Inactive unsaved draft was lost'); assert(images.includes('unsaved-two.png'), 'Active unsaved draft was lost'); assert.equal(await h.page.evaluate(() => window.comfyAPI.app.app.rootGraph.serialize().nodes.find(n => n.type === 'LoadImage').widgets_values[0]), 'unsaved-two.png'); @@ -128,13 +131,67 @@ async function draftImages(page) { await h.page.waitForTimeout(250); } assert.equal(h.navigations(), before + 1, 'Same/older workers caused a reload loop'); + for (const revision of ['210', '220']) { + h.state.served = h.state.current = revision; + await Promise.all([h.page.waitForEvent('load'), reconnect(h.page)]); + await h.page.waitForFunction(key => sessionStorage.getItem(key) === null && window.comfyAPI?.app?.app?.isGraphReady, backupKey); + assert.deepEqual(await h.page.evaluate(() => window.comfyAPI.app.app.extensionManager.workflow.openWorkflows.map(item => item.path)), originalPaths, + 'Repeated restarts added workflow tabs'); + assert.deepEqual((await draftImages(h.page)).sort(), images.slice().sort()); + } h.state.served = h.state.current = '300'; await h.page.reload(); await h.page.waitForTimeout(700); - assert.equal(h.navigations(), before + 2, 'An already updated page refreshed again'); + assert.equal(h.navigations(), before + 4, 'An already updated page refreshed again'); assert.deepEqual(h.errors, []); await h.context.close(); - console.log('PASS: unsaved tabs, reconnect, stale worker, once per revision, manual refresh'); + console.log('PASS: repeated restarts preserve tab paths, order and drafts without duplicates'); + + const saved = await harness(browser); + await openDraft(saved.page, 'saved', 'on-disk.png'); + const savedPath = await saved.page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const store = app.extensionManager.workflow; + store.activeWorkflow.changeTracker.prepareForSave(); + await store.saveWorkflow(store.activeWorkflow); + // Simulate an edit immediately before reload, before draft persistence runs. + const nodeId = app.rootGraph.serialize().nodes.find(n => n.type === 'LoadImage').id; + app.rootGraph.getNodeById(nodeId).widgets.find(w => w.name === 'image').value = 'latest-edit.png'; + return store.activeWorkflow.path; + }); + saved.state.served = saved.state.current = '200'; + await Promise.all([saved.page.waitForEvent('load'), reconnect(saved.page)]); + await saved.page.waitForFunction(key => sessionStorage.getItem(key) === null && window.comfyAPI?.app?.app?.isGraphReady, backupKey); + assert.equal(await saved.page.evaluate(() => window.comfyAPI.app.app.extensionManager.workflow.activeWorkflow.path), savedPath); + assert((await draftImages(saved.page)).includes('latest-edit.png')); + assert.equal(await saved.page.evaluate(() => window.comfyAPI.app.app.extensionManager.workflow.activeWorkflow.isModified), true); + const diskContent = await saved.page.evaluate(async path => { + const { api } = await import('/scripts/api.js'); + return (await api.getUserData(path)).json(); + }, savedPath); + assert.equal(diskContent.nodes.find(n => n.type === 'LoadImage').widgets_values[0], 'on-disk.png'); + // Backups created by the previous plugin version have no path field. + const savedPaths = await saved.page.evaluate(key => { + const app = window.comfyAPI.app.app; + const data = app.rootGraph.serialize(); + data.extra.ds = { scale: 0.5, offset: [123, 456] }; + sessionStorage.setItem(key, JSON.stringify({ user_id: 'user-a', active: 0, workflows: [{ name: 'saved', data }] })); + return app.extensionManager.workflow.openWorkflows.map(item => item.path).sort(); + }, backupKey); + await reconnect(saved.page); + await saved.page.waitForFunction(key => sessionStorage.getItem(key) === null, backupKey); + assert.deepEqual(await saved.page.evaluate(() => window.comfyAPI.app.app.extensionManager.workflow.openWorkflows.map(item => item.path).sort()), savedPaths); + await saved.page.evaluate(async () => { + const store = window.comfyAPI.app.app.extensionManager.workflow; + for (const tab of store.openWorkflows.slice()) if (tab !== store.activeWorkflow) await store.closeWorkflow(tab); + }); + saved.state.served = saved.state.current = '300'; + await Promise.all([saved.page.waitForEvent('load'), reconnect(saved.page)]); + await saved.page.waitForFunction(key => sessionStorage.getItem(key) === null && window.comfyAPI?.app?.app?.isGraphReady, backupKey); + assert.deepEqual(await saved.page.evaluate(() => window.comfyAPI.app.app.extensionManager.workflow.openWorkflows.map(item => item.path)), [savedPath]); + assert.deepEqual(saved.errors, []); + await saved.context.close(); + console.log('PASS: saved workflows retain unsaved edits, legacy backups reuse tabs, closed tabs stay closed'); const serializers = await harness(browser); await openDraft(serializers.page, 'api-draft', 'api-draft.png'); diff --git a/web/js/autoRefresh.js b/web/js/autoRefresh.js index a424199..c7edaed 100644 --- a/web/js/autoRefresh.js +++ b/web/js/autoRefresh.js @@ -1,5 +1,6 @@ import { app } from "/scripts/app.js"; import { api } from "/scripts/api.js"; +import { ChangeTracker } from "/scripts/changeTracker.js"; const BACKUP_KEY = "AccountManager.RefreshBackup"; const SEEN_KEY = "AccountManager.RefreshRevision"; @@ -35,6 +36,7 @@ async function snapshotWorkflows(info) { const workflow = app.rootGraph.serialize(); const open = store.openWorkflows; const workflows = open.map(item => ({ + path: item.path, name: item.filename, data: item === active ? workflow : item.activeState, })); @@ -56,31 +58,51 @@ async function restoreWorkflows(info) { return; } const store = app.extensionManager.workflow; - const activeGraph = app.rootGraph.serialize(); - const active = store.activeWorkflow; + for (const item of store.openWorkflows) { + if (item !== store.activeWorkflow && !item.activeState) await item.load(); + } const restored = []; for (const saved of backup.workflows) { - const serialized = JSON.stringify(saved.data); - const existing = store.openWorkflows.find(item => - !restored.includes(item) && JSON.stringify(item === active ? activeGraph : item.activeState) === serialized); - if (existing) { + const available = store.openWorkflows.filter(item => !restored.includes(item)); + const graphData = item => item === store.activeWorkflow ? app.rootGraph.serialize() : item.activeState; + const existing = saved.path + ? store.getWorkflowByPath(saved.path) + : available.find(item => saved.data.id && graphData(item)?.id === saved.data.id) + || available.find(item => ChangeTracker.graphEqual(graphData(item), saved.data)); + if (existing && !existing.activeState) await existing.load(); + if (existing && available.includes(existing) && ChangeTracker.graphEqual(graphData(existing), saved.data)) { restored.push(existing); continue; } - // Recover a local copy; never overwrite a workflow saved on the server. + // Keep the original path and tab instead of creating a renamed duplicate. const previous = store.activeWorkflow; - const result = await app.loadGraphData(saved.data, true, true, `${saved.name} (刷新恢复)`); - if (result === false || !store.activeWorkflow || store.activeWorkflow === previous) { + const initialState = existing?.changeTracker.initialState; + const target = existing || (saved.path?.startsWith("workflows/") + ? store.createTemporary(saved.path.slice("workflows/".length), saved.data) + : `${saved.name} (刷新恢复)`); + const result = await app.loadGraphData(saved.data, true, true, target); + if (result === false || !store.activeWorkflow || (typeof target !== "string" ? store.activeWorkflow.path !== target.path : store.activeWorkflow === previous)) { throw new Error("The recovered workflow could not be opened"); } + if (existing) { + existing.changeTracker.initialState = initialState; + existing.changeTracker.updateModified(); + } restored.push(store.activeWorkflow); } + for (let index = 0; index < restored.length; index++) { + const current = store.openWorkflows.indexOf(restored[index]); + if (current !== index) store.reorderWorkflows(current, index); + } const selected = restored[backup.active]; if (selected && selected !== store.activeWorkflow) { + const initialState = selected.changeTracker.initialState; const result = await app.loadGraphData(selected.activeState, true, true, selected); if (result === false || store.activeWorkflow !== selected) { throw new Error("The active workflow could not be restored"); } + selected.changeTracker.initialState = initialState; + selected.changeTracker.updateModified(); } sessionStorage.removeItem(BACKUP_KEY); } @@ -91,7 +113,7 @@ async function checkForUpdate() { pending = true; return; } - if (!app.isGraphReady || app.extensionManager.workflow.isBusy || app.configuringGraph) { + if (!app.isGraphReady || app.extensionManager.workflow.isBusy || app.extensionManager.workflow.isSyncLoading || app.configuringGraph) { clearTimeout(retryTimer); retryTimer = setTimeout(checkForUpdate, 500); return;