Add undo/redo to workflow editor (SKY-8869) (#5484)

This commit is contained in:
Aaron Perez 2026-04-13 23:26:23 -05:00 committed by GitHub
parent 50a196c5a5
commit 3db5dd4e5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2094 additions and 2 deletions

View file

@ -71,6 +71,7 @@
"zustand": "^4.5.2"
},
"devDependencies": {
"@testing-library/react": "^16.1.0",
"@types/node": "^20.11.30",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
@ -83,6 +84,7 @@
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.26",
"jsdom": "^25.0.1",
"lint-staged": "^15.5.2",
"postcss": "^8.4.37",
"prettier": "^3.8.1",
@ -113,6 +115,47 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
@ -284,6 +327,121 @@
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@dagrejs/dagre": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz",
@ -3871,6 +4029,63 @@
"react": "^18.0.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/chai": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
@ -4438,6 +4653,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@ -4531,6 +4756,17 @@
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -5193,6 +5429,27 @@
"node": ">=4"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cssstyle/node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -5295,6 +5552,20 @@
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -5312,6 +5583,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@ -5382,6 +5660,17 @@
"node": ">= 0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@ -5430,6 +5719,14 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -5499,6 +5796,19 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
@ -6412,6 +6722,19 @@
"node": ">= 0.4"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@ -6427,6 +6750,34 @@
"node": ">= 0.8"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
@ -6614,6 +6965,13 @@
"node": ">=8"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
@ -6686,6 +7044,47 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": {
"version": "25.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
"decimal.js": "^10.4.3",
"form-data": "^4.0.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.5",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.12",
"parse5": "^7.1.2",
"rrweb-cssom": "^0.7.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.0.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^2.11.2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@ -6940,6 +7339,17 @@
"node": ">=12"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
@ -7180,6 +7590,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nwsapi": {
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -7325,6 +7742,19 @@
"node": ">=6"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -7742,6 +8172,44 @@
}
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/pretty-format/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -8168,6 +8636,13 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-cssom": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
"dev": true,
"license": "MIT"
},
"node_modules/run-applescript": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
@ -8226,6 +8701,19 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
@ -8801,6 +9289,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/synckit": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
@ -8990,6 +9485,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -9009,6 +9524,32 @@
"node": ">=0.6"
}
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ts-api-utils": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
@ -9953,6 +10494,80 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -10093,6 +10708,45 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",

View file

@ -82,6 +82,7 @@
"zustand": "^4.5.2"
},
"devDependencies": {
"@testing-library/react": "^16.1.0",
"@types/node": "^20.11.30",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
@ -94,6 +95,7 @@
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.26",
"jsdom": "^25.0.1",
"lint-staged": "^15.5.2",
"postcss": "^8.4.37",
"prettier": "^3.8.1",

View file

@ -1053,7 +1053,13 @@ function FlowRenderer({
return (
change.type === "add" ||
change.type === "remove" ||
change.type === "replace"
change.type === "replace" ||
// User drag-drop. `dragging === false` fires once at the
// end of a drag gesture. Programmatic position updates
// (mount-time layout, setNodes from node components)
// leave `dragging` undefined, so this filter doesn't
// falsely trip for them.
(change.type === "position" && change.dragging === false)
);
})
) {

View file

@ -8,8 +8,9 @@ import {
CopyIcon,
PlayIcon,
ReloadIcon,
ResetIcon,
} from "@radix-ui/react-icons";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { SaveIcon } from "@/components/icons/SaveIcon";
import { BrowserIcon } from "@/components/icons/BrowserIcon";
@ -37,6 +38,7 @@ import { useDebugStore } from "@/store/useDebugStore";
import { useRecordingStore } from "@/store/useRecordingStore";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { isMacPlatform } from "@/util/platform";
import { cn } from "@/util/utils";
import { CacheKeyValuesResponse } from "@/routes/workflows/types/scriptTypes";
@ -48,6 +50,8 @@ type Props = {
cacheKeyValue: string | null;
cacheKeyValues: CacheKeyValuesResponse | undefined;
cacheKeyValuesPanelOpen: boolean;
canUndo: boolean;
canRedo: boolean;
isGeneratingCode?: boolean;
isTemplate?: boolean;
parametersPanelOpen: boolean;
@ -63,6 +67,8 @@ type Props = {
onShowAllCodeClick?: () => void;
onCacheKeyValuesClick: () => void;
onSave: () => void;
onUndo: () => void;
onRedo: () => void;
onRun?: () => void;
onHistory?: () => void;
};
@ -71,6 +77,8 @@ function WorkflowHeader({
cacheKeyValue,
cacheKeyValues,
cacheKeyValuesPanelOpen,
canUndo,
canRedo,
isGeneratingCode,
isTemplate,
parametersPanelOpen,
@ -86,6 +94,8 @@ function WorkflowHeader({
onShowAllCodeClick,
onCacheKeyValuesClick,
onSave,
onUndo,
onRedo,
onRun,
onHistory,
}: Readonly<Props>) {
@ -106,6 +116,17 @@ function WorkflowHeader({
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
// Keyboard shortcut labels shown in tooltips - keep them platform-aware
// so Windows/Linux users don't see the Mac Command glyph. Memoized so
// we don't re-sniff the platform on every render (it's stable for the
// session).
const { undoShortcutLabel, redoShortcutLabel } = useMemo(() => {
const mac = isMacPlatform();
return {
undoShortcutLabel: mac ? "⌘Z" : "Ctrl+Z",
redoShortcutLabel: mac ? "⌘⇧Z" : "Ctrl+Shift+Z",
};
}, []);
const templateMutation = useMutation({
mutationFn: async (newIsTemplate: boolean) => {
@ -323,6 +344,40 @@ function WorkflowHeader({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="tertiary"
className="size-10 min-w-[2.5rem]"
disabled={!canUndo || isRecording}
onClick={onUndo}
aria-label="Undo"
>
<ResetIcon className="size-6" />
</Button>
</TooltipTrigger>
<TooltipContent>Undo ({undoShortcutLabel})</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="tertiary"
className="size-10 min-w-[2.5rem]"
disabled={!canRedo || isRecording}
onClick={onRedo}
aria-label="Redo"
>
<ResetIcon className="size-6 -scale-x-100" />
</Button>
</TooltipTrigger>
<TooltipContent>Redo ({redoShortcutLabel})</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>

View file

@ -80,9 +80,11 @@ import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils";
import { DebuggerBlockRuns } from "@/routes/workflows/debugger/DebuggerBlockRuns";
import { copyText } from "@/util/copyText";
import { isMacPlatform } from "@/util/platform";
import { cn } from "@/util/utils";
import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer";
import { useWorkflowHistory } from "./hooks/useWorkflowHistory";
import { AppNode, isWorkflowBlockNode, WorkflowBlockNode } from "./nodes";
import { ConditionalNodeData } from "./nodes/ConditionalNode/types";
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
@ -246,6 +248,12 @@ function Workspace({
const postHog = usePostHog();
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const {
undo: undoWorkflowEdit,
redo: redoWorkflowEdit,
canUndo: canUndoWorkflowEdit,
canRedo: canRedoWorkflowEdit,
} = useWorkflowHistory({ nodes, edges, setNodes, setEdges });
const { getNodes, getEdges } = useReactFlow();
const saveWorkflow = useWorkflowSave({ status: "published" });
const { data: workflowRun } = useWorkflowRunQuery();
@ -395,6 +403,64 @@ function Workspace({
};
}, []);
// Undo/redo keyboard shortcuts. Skip when the user is typing inside an
// editable element so the browser's native per-input undo keeps working.
const isRecording = recordingStore.isRecording;
// macOS users expect Cmd+Y to be browser "History Forward" (some apps
// bind it to "Redo Typing"), so we only honour Ctrl+Y on non-Mac.
// Memoized so the platform sniff runs exactly once per mount.
const isMac = useMemo(() => isMacPlatform(), []);
useEffect(() => {
const isEditableTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
return true;
}
if (target.isContentEditable) return true;
// Monaco wraps its editor surface in a div with role="textbox"; let
// it keep native undo as well.
if (target.getAttribute("role") === "textbox") return true;
return false;
};
const handleKeyDown = (event: KeyboardEvent) => {
// Recording owns the editor - don't let hotkeys mutate state behind
// the disabled toolbar buttons.
if (isRecording) return;
// IME composition (CJK, accents) fires keydown events we must not
// intercept - those belong to the composition flow.
if (event.isComposing) return;
const mod = event.metaKey || event.ctrlKey;
if (!mod) return;
// Match the typed character via event.key rather than event.code.
// On QWERTZ / Dvorak / AZERTY the labeled Z key is at a different
// physical position than US QWERTY, so matching event.code would
// either miss the user's Cmd+Z entirely or fire undo when they
// press a different key. event.key honors the keycap label.
const key = event.key.toLowerCase();
const isZ = key === "z";
const isY = key === "y";
if (!isZ && !isY) return;
if (isY && isMac) return;
if (isEditableTarget(event.target)) return;
if (isZ && !event.shiftKey) {
event.preventDefault();
undoWorkflowEdit();
} else if ((isZ && event.shiftKey) || isY) {
// Cmd/Ctrl+Shift+Z is the universal redo; Ctrl+Y is the
// Windows/Linux alternate redo binding (not accepted on Mac).
event.preventDefault();
redoWorkflowEdit();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [undoWorkflowEdit, redoWorkflowEdit, isRecording, isMac]);
useEffect(() => {
const currentUrlValue = searchParams.get("cache-key-value");
@ -1289,6 +1355,10 @@ function Workspace({
<WorkflowHeader
cacheKeyValue={cacheKeyValue}
cacheKeyValues={cacheKeyValues}
canUndo={canUndoWorkflowEdit}
canRedo={canRedoWorkflowEdit}
onUndo={undoWorkflowEdit}
onRedo={redoWorkflowEdit}
isGeneratingCode={isGeneratingCode}
isTemplate={workflow?.is_template}
saving={workflowChangesStore.saveIsPending}

View file

@ -0,0 +1,270 @@
// @vitest-environment jsdom
// Mock transitive env-dependent imports so the zustand store can load
// without requiring API base URLs at test time.
vi.mock("@/api/AxiosClient", () => ({ getClient: vi.fn() }));
vi.mock("@/hooks/useCredentialGetter", () => ({
useCredentialGetter: () => null,
}));
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { Edge } from "@xyflow/react";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import type { AppNode } from "../nodes";
import { useWorkflowHistory } from "./useWorkflowHistory";
function makeNode(id: string, label = id, extra: Record<string, unknown> = {}) {
return {
id,
type: "task",
position: { x: 0, y: 0 },
data: { label, ...extra },
} as unknown as AppNode;
}
function resetStore() {
const s = useWorkflowHasChangesStore.getState();
s.setHasChanges(false);
// Drain any leftover internal-update count.
while (useWorkflowHasChangesStore.getState().internalUpdateCount > 0) {
s.endInternalUpdate();
}
}
describe("useWorkflowHistory hook", () => {
beforeEach(() => {
vi.useFakeTimers();
resetStore();
});
afterEach(() => {
vi.useRealTimers();
resetStore();
});
function setup(initialNodes: AppNode[] = [], initialEdges: Edge[] = []) {
let nodes = initialNodes;
let edges = initialEdges;
const setNodes = vi.fn(
(update: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
nodes = typeof update === "function" ? update(nodes) : update;
},
);
const setEdges = vi.fn((update: Edge[] | ((prev: Edge[]) => Edge[])) => {
edges = typeof update === "function" ? update(edges) : update;
});
const hook = renderHook(
({ n, e }: { n: AppNode[]; e: Edge[] }) =>
useWorkflowHistory({ nodes: n, edges: e, setNodes, setEdges }),
{ initialProps: { n: nodes, e: edges } },
);
return {
hook,
getNodes: () => nodes,
getEdges: () => edges,
setNodes,
setEdges,
rerender: (n: AppNode[], e: Edge[]) => {
nodes = n;
edges = e;
hook.rerender({ n, e });
},
};
}
it("captures a baseline and reports canUndo=false initially", () => {
const { hook } = setup([makeNode("a")]);
act(() => {
vi.advanceTimersByTime(500);
});
expect(hook.result.current.canUndo).toBe(false);
expect(hook.result.current.canRedo).toBe(false);
});
it("capture -> undo -> redo round-trip", () => {
const n1 = [makeNode("a", "v1")];
const e1: Edge[] = [];
const { hook, rerender, setNodes } = setup(n1, e1);
// Mark dirty so pushes land as user edits (not baseline drift).
act(() => {
useWorkflowHasChangesStore.getState().setHasChanges(true);
});
// Edit 1: change the label.
const n2 = [makeNode("a", "v2")];
act(() => {
rerender(n2, e1);
});
act(() => {
vi.advanceTimersByTime(500);
});
expect(hook.result.current.canUndo).toBe(true);
// Undo back to v1.
act(() => {
hook.result.current.undo();
});
expect(setNodes).toHaveBeenCalled();
expect(hook.result.current.canRedo).toBe(true);
// Redo forward to v2.
act(() => {
hook.result.current.redo();
});
expect(hook.result.current.canUndo).toBe(true);
expect(hook.result.current.canRedo).toBe(false);
});
it("capture after undo clears the redo stack", () => {
const n1 = [makeNode("a", "v1")];
const e1: Edge[] = [];
const { hook, rerender } = setup(n1, e1);
act(() => {
useWorkflowHasChangesStore.getState().setHasChanges(true);
});
// v1 -> v2
act(() => {
rerender([makeNode("a", "v2")], e1);
});
act(() => {
vi.advanceTimersByTime(500);
});
// Undo to v1
act(() => {
hook.result.current.undo();
});
expect(hook.result.current.canRedo).toBe(true);
// Simulate React re-rendering with the undo'd state (v1) before
// the user makes a new edit. In the real app setNodes triggers this
// automatically; in the test we drive it manually.
act(() => {
rerender(n1, e1);
});
// New edit v3 (diverge from v2)
act(() => {
rerender([makeNode("a", "v3")], e1);
});
act(() => {
vi.advanceTimersByTime(500);
});
// Redo should be gone since we diverged.
expect(hook.result.current.canRedo).toBe(false);
});
it("debounce tail does not fire after undo (C2 regression)", () => {
const n1 = [makeNode("a", "v1")];
const e1: Edge[] = [];
const { hook, rerender } = setup(n1, e1);
act(() => {
useWorkflowHasChangesStore.getState().setHasChanges(true);
});
// Push v2 so we have something to undo to.
act(() => {
rerender([makeNode("a", "v2")], e1);
});
act(() => {
vi.advanceTimersByTime(500);
});
// Start typing v3 (debounce timer starts but hasn't fired).
act(() => {
rerender([makeNode("a", "v3")], e1);
});
// Don't advance timers — the debounce is still pending.
// Undo back to v1 (flushes the pending v3 first, then undoes).
act(() => {
hook.result.current.undo();
});
// Now advance past the original debounce window. The guarded
// timer should be a no-op because undo cleared it.
act(() => {
vi.advanceTimersByTime(500);
});
// We should be able to redo (the undo worked and no stale
// capture overwrote it).
expect(hook.result.current.canRedo).toBe(true);
});
it("undo is blocked during internal updates", () => {
const n1 = [makeNode("a", "v1")];
const e1: Edge[] = [];
const { hook, rerender } = setup(n1, e1);
act(() => {
useWorkflowHasChangesStore.getState().setHasChanges(true);
});
act(() => {
rerender([makeNode("a", "v2")], e1);
});
act(() => {
vi.advanceTimersByTime(500);
});
expect(hook.result.current.canUndo).toBe(true);
// Begin an internal update.
act(() => {
useWorkflowHasChangesStore.getState().beginInternalUpdate();
});
// Undo should be a no-op while internal update is active.
act(() => {
hook.result.current.undo();
});
// canUndo should still be true (undo didn't fire).
expect(hook.result.current.canUndo).toBe(true);
act(() => {
useWorkflowHasChangesStore.getState().endInternalUpdate();
});
});
it("undo is blocked while a node is mid-drag (R5)", () => {
const draggingNode = {
...makeNode("a", "v1"),
dragging: true,
} as unknown as AppNode;
const e1: Edge[] = [];
const { hook, rerender } = setup([makeNode("a", "v1")], e1);
act(() => {
useWorkflowHasChangesStore.getState().setHasChanges(true);
});
act(() => {
rerender([makeNode("a", "v2")], e1);
});
act(() => {
vi.advanceTimersByTime(500);
});
expect(hook.result.current.canUndo).toBe(true);
// Simulate mid-drag: rerender with dragging=true on the node.
act(() => {
rerender([draggingNode], e1);
});
// Undo should bail because a node is dragging.
act(() => {
hook.result.current.undo();
});
expect(hook.result.current.canUndo).toBe(true);
});
});

View file

@ -0,0 +1,372 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type Dispatch,
type SetStateAction,
} from "react";
import type { Edge } from "@xyflow/react";
import { usePostHog } from "posthog-js/react";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import type { AppNode } from "../nodes";
import {
canRedo as historyCanRedo,
canUndo as historyCanUndo,
cloneSnapshot,
createInitialHistoryState,
MAX_HISTORY_ENTRIES,
pushSnapshot,
redo as historyRedo,
replacePresent,
snapshotsEqual,
undo as historyUndo,
type WorkflowHistoryState,
type WorkflowSnapshot,
} from "./workflowHistoryState";
// Debounced so typing into a text field doesn't flood the stack with
// one entry per keystroke.
const CAPTURE_DEBOUNCE_MS = 300;
type UseWorkflowHistoryParams = {
nodes: AppNode[];
edges: Edge[];
// Match React Flow's useNodesState / useEdgesState signatures so
// callers can pass the setter dispatcher straight through (and use
// the updater-function form elsewhere if they want).
setNodes: Dispatch<SetStateAction<AppNode[]>>;
setEdges: Dispatch<SetStateAction<Edge[]>>;
};
type UseWorkflowHistoryResult = {
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
};
/**
* In-memory undo/redo for the workflow editor canvas.
*
* Watches `nodes`/`edges`, captures debounced snapshots onto a bounded
* history stack, and exposes undo/redo callbacks that restore snapshots
* via `setNodes`/`setEdges`. Not persisted - a browser refresh clears
* the stack. See SKY-8869.
*/
export function useWorkflowHistory({
nodes,
edges,
setNodes,
setEdges,
}: UseWorkflowHistoryParams): UseWorkflowHistoryResult {
const posthog = usePostHog();
const historyRef = useRef<WorkflowHistoryState>(createInitialHistoryState());
// One-shot: applySnapshot sets, capture effect consumes on next fire.
const isApplyingRef = useRef(false);
// Raised while internalUpdateCount > 0; consumed by the sync flush path.
const wasInternalUpdateRef = useRef(false);
// Set via the zustand subscribe below on any 0→1+ internalUpdateCount
// transition. Lets us detect internal updates even when begin/end are
// batched into a single React commit (so the capture effect wouldn't
// otherwise observe count > 0 in isolation).
const internalUpdateObservedRef = useRef(false);
// Eagerly-cloned snapshot of the pre-internal-update state. Populated
// by the subscribe callback *before* React commits the internal
// update's nodes/edges changes, so a pending user edit that predates
// the internal update can be pushed as its own history entry instead
// of being merged with the internal-update state.
const pendingSnapshotRef = useRef<WorkflowSnapshot | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestNodesRef = useRef(nodes);
const latestEdgesRef = useRef(edges);
const [flags, setFlags] = useState<{ canUndo: boolean; canRedo: boolean }>({
canUndo: false,
canRedo: false,
});
const internalUpdateCount = useWorkflowHasChangesStore(
(state) => state.internalUpdateCount,
);
// useLayoutEffect with no dep array: runs synchronously after every
// commit so a user-triggered flush (undo keydown) always sees the
// latest values, not a stale snapshot between commit and the next
// regular useEffect fire.
useLayoutEffect(() => {
latestNodesRef.current = nodes;
latestEdgesRef.current = edges;
});
const refreshFlags = useCallback(() => {
const state = historyRef.current;
const nextCanUndo = historyCanUndo(state);
const nextCanRedo = historyCanRedo(state);
setFlags((prev) =>
prev.canUndo === nextCanUndo && prev.canRedo === nextCanRedo
? prev
: { canUndo: nextCanUndo, canRedo: nextCanRedo },
);
}, []);
// Commit a specific snapshot into history. Routing logic:
// - No baseline yet → seed.
// - Snapshot equal to present → no-op (clear internal-update flag).
// - `hasChanges === false` → drift baseline (pre-first-edit gate).
// - Internal update in progress → drift baseline (caller contract).
// - Otherwise → push as an undoable entry.
//
// `forceAsUserEdit` bypasses the internal-update drift gate. It's used
// when flushing a pending snapshot we know predates the internal update
// (because it was cloned before the begin/end subscribe fired).
const commitSnapshot = useCallback(
(snapshot: WorkflowSnapshot, options?: { forceAsUserEdit?: boolean }) => {
const current = historyRef.current;
if (current.present === null) {
historyRef.current = { past: [], present: snapshot, future: [] };
refreshFlags();
return;
}
if (snapshotsEqual(current.present, snapshot)) {
wasInternalUpdateRef.current = false;
return;
}
const storeState = useWorkflowHasChangesStore.getState();
if (!storeState.hasChanges) {
historyRef.current = replacePresent(current, snapshot);
wasInternalUpdateRef.current = false;
refreshFlags();
return;
}
if (
!options?.forceAsUserEdit &&
(wasInternalUpdateRef.current || storeState.internalUpdateCount > 0)
) {
historyRef.current = replacePresent(current, snapshot);
wasInternalUpdateRef.current = false;
refreshFlags();
return;
}
historyRef.current = pushSnapshot(current, snapshot);
refreshFlags();
},
[refreshFlags],
);
// Convenience wrapper: commit the CURRENT nodes/edges (via
// latestNodesRef). Used by the normal debounce path and the
// internal-update-exit sync flush.
const captureIfChanged = useCallback(
(options?: { forceAsUserEdit?: boolean }) => {
const snapshot = cloneSnapshot(
latestNodesRef.current,
latestEdgesRef.current,
);
commitSnapshot(snapshot, options);
},
[commitSnapshot],
);
// Seed the baseline synchronously so a user who edits within the
// 300ms debounce window has a present to undo back to.
useEffect(() => {
if (historyRef.current.present === null) {
historyRef.current = {
past: [],
present: cloneSnapshot(latestNodesRef.current, latestEdgesRef.current),
future: [],
};
refreshFlags();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Detect internal-update transitions via a direct zustand subscribe,
// independent of React's render/batch cycle. Fires synchronously
// inside `beginInternalUpdate()`'s setState stack - critically, BEFORE
// the handler goes on to modify nodes/edges.
//
// INVARIANT: latestNodesRef must reflect the latest user-committed
// state when this callback fires. It is maintained by a bare
// useLayoutEffect (no dep array) which runs synchronously after every
// React commit. This means every user edit that changes nodes/edges
// MUST have been committed (rendered + layout-effected) before any
// code path calls beginInternalUpdate(). If React 18 concurrent
// features (startTransition, useDeferredValue) defer a user-edit
// commit past a synchronous beginInternalUpdate call, the ref will
// hold a stale value and the cloned pending snapshot will predate the
// user's edit. All current callers of beginInternalUpdate run in
// useEffect or event handlers (synchronous commit boundaries), so
// this invariant holds today.
useEffect(() => {
return useWorkflowHasChangesStore.subscribe((state, prevState) => {
if (state.internalUpdateCount > prevState.internalUpdateCount) {
internalUpdateObservedRef.current = true;
if (pendingSnapshotRef.current === null) {
pendingSnapshotRef.current = cloneSnapshot(
latestNodesRef.current,
latestEdgesRef.current,
);
}
}
});
}, []);
const flushPendingCapture = useCallback(() => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (isApplyingRef.current) return;
if (useWorkflowHasChangesStore.getState().internalUpdateCount > 0) return;
captureIfChanged();
}, [captureIfChanged]);
useEffect(() => {
if (isApplyingRef.current) {
isApplyingRef.current = false;
return;
}
// An internal update was seen since the last capture-effect fire -
// possibly merged into this same commit via React batching. Push
// the pre-internal-update pending snapshot (captured eagerly by
// the subscribe) as a proper user-edit entry before handling the
// current state.
if (internalUpdateObservedRef.current) {
internalUpdateObservedRef.current = false;
const pending = pendingSnapshotRef.current;
pendingSnapshotRef.current = null;
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (pending !== null) {
commitSnapshot(pending, { forceAsUserEdit: true });
}
wasInternalUpdateRef.current = true;
// Fall through: if count is now 0, the internal-update exit
// branch below will sync-flush the drift.
}
if (internalUpdateCount > 0) {
wasInternalUpdateRef.current = true;
return;
}
// Exiting an internal update: flush synchronously so the drift
// lands before a subsequent user edit can coalesce into it.
if (wasInternalUpdateRef.current) {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
captureIfChanged();
return;
}
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
if (isApplyingRef.current) return;
captureIfChanged();
}, CAPTURE_DEBOUNCE_MS);
return () => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
};
}, [nodes, edges, internalUpdateCount, captureIfChanged, commitSnapshot]);
const applySnapshot = useCallback(
(snapshotNodes: AppNode[], snapshotEdges: Edge[]) => {
// Fresh copies - downstream mutates node.data in place.
const cloned = cloneSnapshot(snapshotNodes, snapshotEdges);
isApplyingRef.current = true;
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
setNodes(cloned.nodes);
setEdges(cloned.edges);
// Undo/redo across a save boundary diverges the canvas from the
// persisted state - mark dirty so the leave-page warning fires
// and the save button activates. Worst case is a false positive
// when the user undoes back exactly to the last-saved state, but
// silently losing divergence is the bigger failure.
//
// TODO(SKY-8869): once a "last saved snapshot" is tracked in the
// hook, compare against it here and only flip the flag when the
// applied state actually diverges.
useWorkflowHasChangesStore.getState().setHasChanges(true);
},
[setNodes, setEdges],
);
const undo = useCallback(() => {
// History navigation is unsafe while an internal update is in
// flight: the subscribe path has already queued a pending
// pre-internal snapshot and an observed flag, and consuming them
// after an undo would rewrite history behind the user's back
// (clearing redo, pushing a stale entry onto past). Bail and let
// the internal update finish first.
if (useWorkflowHasChangesStore.getState().internalUpdateCount > 0) return;
// Bail if any node is mid-drag. Cmd/Ctrl+Z during a drag gesture
// would pop a snapshot while React Flow's drag controller still
// holds a reference to the dragging node, causing a desync between
// RF's internal drag state and the restored snapshot.
if (latestNodesRef.current.some((n) => n.dragging)) return;
// Flush first so an edit still pending in the debounce window isn't dropped.
flushPendingCapture();
const result = historyUndo(historyRef.current);
if (result === null) return;
historyRef.current = result.state;
applySnapshot(result.applied.nodes, result.applied.edges);
refreshFlags();
posthog.capture("builder.undo_redo.used", {
action: "undo",
node_count: result.applied.nodes.length,
edge_count: result.applied.edges.length,
history_depth: result.state.past.length,
cap_reached: result.state.past.length >= MAX_HISTORY_ENTRIES,
});
}, [applySnapshot, flushPendingCapture, refreshFlags, posthog]);
const redo = useCallback(() => {
if (useWorkflowHasChangesStore.getState().internalUpdateCount > 0) return;
if (latestNodesRef.current.some((n) => n.dragging)) return;
flushPendingCapture();
const result = historyRedo(historyRef.current);
if (result === null) return;
historyRef.current = result.state;
applySnapshot(result.applied.nodes, result.applied.edges);
refreshFlags();
posthog.capture("builder.undo_redo.used", {
action: "redo",
node_count: result.applied.nodes.length,
edge_count: result.applied.edges.length,
history_depth: result.state.past.length,
cap_reached: result.state.past.length >= MAX_HISTORY_ENTRIES,
});
}, [applySnapshot, flushPendingCapture, refreshFlags, posthog]);
return {
undo,
redo,
canUndo: flags.canUndo,
canRedo: flags.canRedo,
};
}

View file

@ -0,0 +1,382 @@
import { describe, expect, it } from "vitest";
import type { Edge } from "@xyflow/react";
import type { AppNode } from "../nodes";
import {
canRedo,
canUndo,
cloneSnapshot,
createInitialHistoryState,
MAX_HISTORY_ENTRIES,
pushSnapshot,
redo,
replacePresent,
snapshotsEqual,
undo,
type WorkflowHistoryState,
type WorkflowSnapshot,
} from "./workflowHistoryState";
// Shape of a node we build for tests. The production type is a large
// discriminated union; casting keeps the tests focused on history logic
// without dragging in every node type.
function makeNode(id: string, label = id, extra: Record<string, unknown> = {}) {
return {
id,
type: "task",
position: { x: 0, y: 0 },
data: { label, ...extra },
} as unknown as AppNode;
}
function makeEdge(id: string, source: string, target: string): Edge {
return { id, source, target };
}
function snapshot(nodes: AppNode[], edges: Edge[] = []): WorkflowSnapshot {
return { nodes, edges };
}
function snapshotWithLabel(id: string, label: string): WorkflowSnapshot {
return snapshot([makeNode(id, label)]);
}
describe("workflowHistoryState / cloneSnapshot", () => {
it("returns a deep copy so mutations don't leak into history", () => {
const node = makeNode("a", "first");
const edge = makeEdge("e1", "a", "b");
const clone = cloneSnapshot([node], [edge]);
// Mutate the source and ensure the clone is unaffected.
(node.data as { label: string }).label = "mutated";
edge.source = "z";
expect((clone.nodes[0]!.data as { label: string }).label).toBe("first");
expect(clone.edges[0]!.source).toBe("a");
});
it("strips React Flow runtime `selected` from cloned edges", () => {
const edge = {
id: "e1",
source: "a",
target: "b",
selected: true,
} as unknown as Edge;
const clone = cloneSnapshot([], [edge]);
expect(clone.edges[0] as Record<string, unknown>).not.toHaveProperty(
"selected",
);
expect(clone.edges[0]!.id).toBe("e1");
});
it("strips React Flow runtime-maintained fields from cloned nodes", () => {
// React Flow mutates these on the fly from the rendered DOM; keeping
// them in a snapshot would reapply stale dimensions on undo/redo.
const node = {
id: "a",
type: "task",
position: { x: 0, y: 0 },
data: { label: "a" },
measured: { width: 200, height: 80 },
width: 200,
height: 80,
selected: true,
dragging: true,
positionAbsolute: { x: 100, y: 100 },
} as unknown as AppNode;
const clone = cloneSnapshot([node], []);
const cloned = clone.nodes[0] as Record<string, unknown>;
expect(cloned).not.toHaveProperty("measured");
expect(cloned).not.toHaveProperty("width");
expect(cloned).not.toHaveProperty("height");
expect(cloned).not.toHaveProperty("selected");
expect(cloned).not.toHaveProperty("dragging");
expect(cloned).not.toHaveProperty("positionAbsolute");
// Core fields must still round-trip.
expect(cloned.id).toBe("a");
expect(cloned.type).toBe("task");
});
it("clones nested data objects", () => {
const node = makeNode("a", "a", {
parameters: { inputs: ["x"] },
});
const clone = cloneSnapshot([node], []);
const clonedData = clone.nodes[0]!.data as unknown as {
parameters: { inputs: string[] };
};
clonedData.parameters.inputs.push("y");
const sourceData = node.data as unknown as {
parameters: { inputs: string[] };
};
expect(sourceData.parameters.inputs).toEqual(["x"]);
});
});
describe("workflowHistoryState / snapshotsEqual", () => {
it("returns true for structurally identical snapshots", () => {
const a = snapshot([makeNode("n1")], [makeEdge("e1", "n1", "n2")]);
const b = snapshot([makeNode("n1")], [makeEdge("e1", "n1", "n2")]);
expect(snapshotsEqual(a, b)).toBe(true);
});
it("returns false when node counts differ", () => {
const a = snapshot([makeNode("n1")]);
const b = snapshot([makeNode("n1"), makeNode("n2")]);
expect(snapshotsEqual(a, b)).toBe(false);
});
it("returns false when nested data differs", () => {
expect(
snapshotsEqual(
snapshotWithLabel("n1", "old"),
snapshotWithLabel("n1", "new"),
),
).toBe(false);
});
it("is independent of node array ordering", () => {
// React Flow may reorder nodes between renders (reconciliation,
// copy-paste, etc.). The history comparator must not treat that as
// a structural change.
const a = snapshot([makeNode("n1", "v1"), makeNode("n2", "v2")]);
const b = snapshot([makeNode("n2", "v2"), makeNode("n1", "v1")]);
expect(snapshotsEqual(a, b)).toBe(true);
});
it("is independent of edge array ordering", () => {
const nodes = [makeNode("n1"), makeNode("n2"), makeNode("n3")];
const a = snapshot(nodes, [
makeEdge("e1", "n1", "n2"),
makeEdge("e2", "n2", "n3"),
]);
const b = snapshot(nodes, [
makeEdge("e2", "n2", "n3"),
makeEdge("e1", "n1", "n2"),
]);
expect(snapshotsEqual(a, b)).toBe(true);
});
it("is key-order independent", () => {
// React Flow sometimes reshuffles key order between renders (e.g. when
// `measured` shows up); a JSON.stringify comparison would flag these
// as different and create spurious history entries.
const a = snapshot([
{
id: "n1",
type: "task",
position: { x: 0, y: 0 },
data: { label: "same" },
} as unknown as AppNode,
]);
const b = snapshot([
{
data: { label: "same" },
position: { y: 0, x: 0 },
type: "task",
id: "n1",
} as unknown as AppNode,
]);
expect(snapshotsEqual(a, b)).toBe(true);
});
});
describe("workflowHistoryState / pushSnapshot", () => {
it("seeds the present on the first push", () => {
const state = pushSnapshot(
createInitialHistoryState(),
snapshotWithLabel("n1", "first"),
);
expect(state.present).not.toBeNull();
expect(state.past).toHaveLength(0);
expect(state.future).toHaveLength(0);
});
it("moves the old present into past and clears future on a new push", () => {
let state = pushSnapshot(
createInitialHistoryState(),
snapshotWithLabel("n1", "v1"),
);
state = pushSnapshot(state, snapshotWithLabel("n1", "v2"));
// Simulate an outstanding redo stack that should be cleared by a new edit.
state = { ...state, future: [snapshotWithLabel("n1", "stale")] };
state = pushSnapshot(state, snapshotWithLabel("n1", "v3"));
expect(state.past).toHaveLength(2);
expect(state.future).toHaveLength(0);
expect((state.present!.nodes[0]!.data as { label: string }).label).toBe(
"v3",
);
});
it("is a no-op when the snapshot equals the current present", () => {
const seeded = pushSnapshot(
createInitialHistoryState(),
snapshotWithLabel("n1", "same"),
);
const after = pushSnapshot(seeded, snapshotWithLabel("n1", "same"));
expect(after).toBe(seeded);
});
it("caps the past stack at MAX_HISTORY_ENTRIES", () => {
let state: WorkflowHistoryState = createInitialHistoryState();
// Seed once so the first real push lands in `past`.
state = pushSnapshot(state, snapshotWithLabel("n1", "v0"));
for (let i = 1; i <= MAX_HISTORY_ENTRIES + 5; i++) {
state = pushSnapshot(state, snapshotWithLabel("n1", `v${i}`));
}
expect(state.past.length).toBe(MAX_HISTORY_ENTRIES);
// Oldest surviving entry should be v5 - we overflowed by 5.
const oldest = state.past[0]!;
expect((oldest.nodes[0]!.data as { label: string }).label).toBe("v5");
});
});
describe("workflowHistoryState / undo", () => {
it("returns null when there is nothing to undo", () => {
expect(undo(createInitialHistoryState())).toBeNull();
const seeded = pushSnapshot(
createInitialHistoryState(),
snapshotWithLabel("n1", "only"),
);
expect(undo(seeded)).toBeNull();
});
it("restores the previous snapshot and pushes present into future", () => {
let state: WorkflowHistoryState = createInitialHistoryState();
state = pushSnapshot(state, snapshotWithLabel("n1", "v1"));
state = pushSnapshot(state, snapshotWithLabel("n1", "v2"));
state = pushSnapshot(state, snapshotWithLabel("n1", "v3"));
const result = undo(state)!;
expect(result).not.toBeNull();
expect((result.applied.nodes[0]!.data as { label: string }).label).toBe(
"v2",
);
expect(result.state.past).toHaveLength(1);
expect(result.state.future).toHaveLength(1);
expect(
(result.state.future[0]!.nodes[0]!.data as { label: string }).label,
).toBe("v3");
});
});
describe("workflowHistoryState / redo", () => {
it("returns null when the future stack is empty", () => {
const state = pushSnapshot(
createInitialHistoryState(),
snapshotWithLabel("n1", "v1"),
);
expect(redo(state)).toBeNull();
});
it("moves the next future snapshot back into present", () => {
let state: WorkflowHistoryState = createInitialHistoryState();
state = pushSnapshot(state, snapshotWithLabel("n1", "v1"));
state = pushSnapshot(state, snapshotWithLabel("n1", "v2"));
const undone = undo(state)!;
const redone = redo(undone.state)!;
expect((redone.applied.nodes[0]!.data as { label: string }).label).toBe(
"v2",
);
expect(redone.state.future).toHaveLength(0);
expect(redone.state.past).toHaveLength(1);
});
it("is cleared when a new push happens after an undo", () => {
let state: WorkflowHistoryState = createInitialHistoryState();
state = pushSnapshot(state, snapshotWithLabel("n1", "v1"));
state = pushSnapshot(state, snapshotWithLabel("n1", "v2"));
state = pushSnapshot(state, snapshotWithLabel("n1", "v3"));
state = undo(state)!.state;
expect(state.future).toHaveLength(1);
state = pushSnapshot(state, snapshotWithLabel("n1", "v2b"));
expect(state.future).toHaveLength(0);
expect(redo(state)).toBeNull();
});
});
describe("workflowHistoryState / replacePresent", () => {
it("updates the baseline without touching the past stack", () => {
let state: WorkflowHistoryState = createInitialHistoryState();
state = pushSnapshot(state, snapshotWithLabel("n1", "v1"));
state = pushSnapshot(state, snapshotWithLabel("n1", "v2"));
const pastBefore = state.past;
const replaced = replacePresent(state, snapshotWithLabel("n1", "v2-int"));
expect(replaced.past).toBe(pastBefore);
expect((replaced.present!.nodes[0]!.data as { label: string }).label).toBe(
"v2-int",
);
});
it("clears the redo stack so stale snapshots aren't reachable", () => {
// User edits v1→v2→v3, undoes twice, then an internal update (branch
// switch, layout pass) replaces the present. The existing redo stack
// is from the pre-replace timeline and must not be reachable.
let state: WorkflowHistoryState = createInitialHistoryState();
state = pushSnapshot(state, snapshotWithLabel("n1", "v1"));
state = pushSnapshot(state, snapshotWithLabel("n1", "v2"));
state = pushSnapshot(state, snapshotWithLabel("n1", "v3"));
state = undo(state)!.state;
state = undo(state)!.state;
expect(state.future).toHaveLength(2);
const replaced = replacePresent(state, snapshotWithLabel("n1", "branchX"));
expect(replaced.future).toHaveLength(0);
expect(redo(replaced)).toBeNull();
});
it("returns the original state when the snapshot equals the present and future is empty", () => {
const seeded = pushSnapshot(
createInitialHistoryState(),
snapshotWithLabel("n1", "same"),
);
const replaced = replacePresent(seeded, snapshotWithLabel("n1", "same"));
expect(replaced).toBe(seeded);
});
it("still clears future even when the snapshot matches the present", () => {
// If there's a stale redo stack sitting around, we must not return
// the unchanged state - the redo entries are still invalid.
let state: WorkflowHistoryState = createInitialHistoryState();
state = pushSnapshot(state, snapshotWithLabel("n1", "v1"));
state = pushSnapshot(state, snapshotWithLabel("n1", "v2"));
state = undo(state)!.state;
expect(state.future).toHaveLength(1);
const replaced = replacePresent(state, snapshotWithLabel("n1", "v1"));
expect(replaced.future).toHaveLength(0);
});
});
describe("workflowHistoryState / canUndo & canRedo flags", () => {
it("tracks availability of undo/redo through a full edit cycle", () => {
let state: WorkflowHistoryState = createInitialHistoryState();
expect(canUndo(state)).toBe(false);
expect(canRedo(state)).toBe(false);
state = pushSnapshot(state, snapshotWithLabel("n1", "v1"));
expect(canUndo(state)).toBe(false);
state = pushSnapshot(state, snapshotWithLabel("n1", "v2"));
expect(canUndo(state)).toBe(true);
expect(canRedo(state)).toBe(false);
state = undo(state)!.state;
expect(canUndo(state)).toBe(false);
expect(canRedo(state)).toBe(true);
state = redo(state)!.state;
expect(canRedo(state)).toBe(false);
expect(canUndo(state)).toBe(true);
});
});

View file

@ -0,0 +1,259 @@
import type { Edge } from "@xyflow/react";
import type { AppNode } from "../nodes";
// Snapshot of the workflow editor canvas at a point in time. We store the
// full nodes/edges arrays (deep-cloned at capture time) so that rolling back
// is just a matter of calling setNodes/setEdges with the previous snapshot.
export type WorkflowSnapshot = {
nodes: AppNode[];
edges: Edge[];
};
export type WorkflowHistoryState = {
past: WorkflowSnapshot[];
present: WorkflowSnapshot | null;
future: WorkflowSnapshot[];
};
// Hard cap on how many undo entries we keep. When a new entry would
// push the stack past this limit, the oldest entry is dropped - so
// from the user's perspective Undo walks back at most this many steps
// and then bottoms out silently on whatever baseline is left. 50
// mirrors typical editor behaviour (VSCode-ish) and bounds memory
// growth for large workflows.
export const MAX_HISTORY_ENTRIES = 50;
export function createInitialHistoryState(): WorkflowHistoryState {
return { past: [], present: null, future: [] };
}
// Deep clone a snapshot. Node/edge data objects are mutable in the editor
// (see updateNodeData() callers in the node components) so a shallow copy
// would let later edits bleed into history entries.
export function cloneSnapshot(
nodes: readonly AppNode[],
edges: readonly Edge[],
): WorkflowSnapshot {
return {
nodes: nodes.map((node) => cloneNode(node)),
edges: edges.map((edge) => cloneEdge(edge)),
};
}
// Fields React Flow maintains at runtime from the rendered DOM. They must
// NOT be part of a history snapshot: reapplying a stale `measured` / width
// / height / selected / dragging causes a flicker because RF thinks the
// node is already measured at outdated dimensions before re-measuring.
const REACT_FLOW_RUNTIME_FIELDS = [
"measured",
"width",
"height",
"selected",
"dragging",
"positionAbsolute",
] as const;
function cloneNode(node: AppNode): AppNode {
// Strip runtime fields first, then deep-clone the whole filtered
// object. This protects against in-place mutation of any field
// (data, style, className, position, etc.) leaking into snapshots
// already pushed onto history.
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node)) {
if ((REACT_FLOW_RUNTIME_FIELDS as readonly string[]).includes(key)) {
continue;
}
filtered[key] = value;
}
return deepClone(filtered) as AppNode;
}
// React Flow sets `selected` on edges from user interaction; treat it the
// same as its node-level runtime counterparts so undo/redo doesn't briefly
// re-highlight an edge before RF clears it. Extend this list if additional
// edge flicker is observed on snapshot restore, mirroring the node-side
// REACT_FLOW_RUNTIME_FIELDS pattern above.
const REACT_FLOW_EDGE_RUNTIME_FIELDS = ["selected"] as const;
function cloneEdge(edge: Edge): Edge {
// Same pattern as cloneNode: strip runtime fields, then deep-clone
// the whole object so mutations to edge.style / edge.markerEnd /
// edge.label etc. can't bleed into snapshots on the history stack.
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(edge)) {
if ((REACT_FLOW_EDGE_RUNTIME_FIELDS as readonly string[]).includes(key)) {
continue;
}
filtered[key] = value;
}
return deepClone(filtered) as Edge;
}
function deepClone<T>(value: T): T {
// structuredClone is universally available in our target environments
// (all supported browsers and Node >= 17). Snapshot data is plain
// JSON-ish - nodes/edges and their `data` objects - so the clone
// never encounters functions or other unstructured values.
return structuredClone(value);
}
// Order-independent structural equality. We don't use JSON.stringify because
// React Flow occasionally reorders keys between renders (e.g. when `measured`
// is added), which would falsely report two semantically-identical snapshots
// as different and generate spurious history entries.
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (
a === null ||
b === null ||
typeof a !== "object" ||
typeof b !== "object"
) {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (Array.isArray(a)) {
const bArr = b as unknown[];
if (a.length !== bArr.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], bArr[i])) return false;
}
return true;
}
const aRec = a as Record<string, unknown>;
const bRec = b as Record<string, unknown>;
const aKeys = Object.keys(aRec);
const bKeys = Object.keys(bRec);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!Object.prototype.hasOwnProperty.call(bRec, key)) return false;
if (!deepEqual(aRec[key], bRec[key])) return false;
}
return true;
}
// Compare two node arrays by id, independent of array order. React Flow
// occasionally reorders nodes between renders (reconciliation passes,
// copy-paste insertions, etc.), which would make a positional compare
// flag a semantically identical state as different.
function nodesEqualById(a: AppNode[], b: AppNode[]): boolean {
if (a.length !== b.length) return false;
const bMap = new Map<string, AppNode>();
for (const node of b) bMap.set(node.id, node);
for (const an of a) {
const bn = bMap.get(an.id);
if (!bn) return false;
if (!deepEqual(an, bn)) return false;
}
return true;
}
// Compare two edge arrays by id, independent of array order. Same
// rationale as nodesEqualById.
function edgesEqualById(a: Edge[], b: Edge[]): boolean {
if (a.length !== b.length) return false;
const bMap = new Map<string, Edge>();
for (const edge of b) bMap.set(edge.id, edge);
for (const ae of a) {
const be = bMap.get(ae.id);
if (!be) return false;
if (!deepEqual(ae, be)) return false;
}
return true;
}
/** Both snapshots must be pre-cloned (via {@link cloneSnapshot}) before
* comparison; this function does not strip React Flow runtime fields. */
export function snapshotsEqual(
a: WorkflowSnapshot,
b: WorkflowSnapshot,
): boolean {
if (a === b) return true;
if (a.nodes.length !== b.nodes.length) return false;
if (a.edges.length !== b.edges.length) return false;
return nodesEqualById(a.nodes, b.nodes) && edgesEqualById(a.edges, b.edges);
}
// Push a new snapshot as the present state, moving the previous present into
// the past stack and clearing any redo stack. Returns the original state
// unchanged when the snapshot is identical to the current present (no-op).
export function pushSnapshot(
state: WorkflowHistoryState,
snapshot: WorkflowSnapshot,
): WorkflowHistoryState {
if (state.present === null) {
return { past: [], present: snapshot, future: [] };
}
if (snapshotsEqual(state.present, snapshot)) {
return state;
}
const nextPast = [...state.past, state.present];
if (nextPast.length > MAX_HISTORY_ENTRIES) {
// Drop the oldest entry so the stack stays bounded.
nextPast.shift();
}
return { past: nextPast, present: snapshot, future: [] };
}
// Replace the present without growing the past stack. Used when we want the
// history baseline to catch up after an internal (non-user) update like a
// branch switch, or during the mount-time settle window, without creating
// an undo step the user didn't ask for.
//
// The redo (`future`) stack is cleared because any entries it contained are
// snapshots from the pre-replace timeline and would be invalid to jump back
// to. `past` is preserved so ordinary layout-only updates don't nuke the
// user's edit history.
export function replacePresent(
state: WorkflowHistoryState,
snapshot: WorkflowSnapshot,
): WorkflowHistoryState {
if (
state.present !== null &&
state.future.length === 0 &&
snapshotsEqual(state.present, snapshot)
) {
return state;
}
return { past: state.past, present: snapshot, future: [] };
}
export function canUndo(state: WorkflowHistoryState): boolean {
return state.past.length > 0 && state.present !== null;
}
export function canRedo(state: WorkflowHistoryState): boolean {
return state.future.length > 0 && state.present !== null;
}
// Walk one step back. Returns the new state and the snapshot to apply to the
// editor, or null when there is nothing to undo.
export function undo(state: WorkflowHistoryState): {
state: WorkflowHistoryState;
applied: WorkflowSnapshot;
} | null {
if (!canUndo(state)) return null;
const nextPast = state.past.slice(0, -1);
const applied = state.past[state.past.length - 1]!;
const nextFuture = [state.present!, ...state.future];
return {
state: { past: nextPast, present: applied, future: nextFuture },
applied,
};
}
// Walk one step forward. Returns the new state and the snapshot to apply, or
// null when there is nothing to redo.
export function redo(state: WorkflowHistoryState): {
state: WorkflowHistoryState;
applied: WorkflowSnapshot;
} | null {
if (!canRedo(state)) return null;
const applied = state.future[0]!;
const nextFuture = state.future.slice(1);
const nextPast = [...state.past, state.present!];
return {
state: { past: nextPast, present: applied, future: nextFuture },
applied,
};
}

View file

@ -0,0 +1,22 @@
// True when running in a Mac/iOS user agent. Prefers the newer
// `navigator.userAgentData.platform` (User-Agent Client Hints) when the
// browser exposes it, and falls back to `navigator.userAgent` string
// sniffing. `navigator.platform` is deliberately avoided because it's
// spec-deprecated and produces console warnings in recent Chrome.
//
// Safe to call in SSR contexts: returns `false` when `navigator` is
// undefined.
export function isMacPlatform(): boolean {
if (typeof navigator === "undefined") return false;
const uaData = (
navigator as Navigator & {
userAgentData?: { platform?: string };
}
).userAgentData;
if (uaData && typeof uaData.platform === "string") {
return /mac/i.test(uaData.platform);
}
return /mac|iphone|ipad|ipod/i.test(navigator.userAgent || "");
}