mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
Add undo/redo to workflow editor (SKY-8869) (#5484)
This commit is contained in:
parent
50a196c5a5
commit
3db5dd4e5b
10 changed files with 2094 additions and 2 deletions
654
skyvern-frontend/package-lock.json
generated
654
skyvern-frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
})
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
22
skyvern-frontend/src/util/platform.ts
Normal file
22
skyvern-frontend/src/util/platform.ts
Normal 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 || "");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue