diff --git a/package-lock.json b/package-lock.json index 3727b87f0..8ea80a4fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5027,7 +5027,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz", "integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -5045,7 +5045,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -5062,7 +5062,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5080,7 +5080,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@babel/helper-globals": { @@ -5160,7 +5160,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -5235,7 +5235,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/template": "^7.25.9", @@ -5278,7 +5278,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -5295,7 +5295,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5311,7 +5311,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5327,7 +5327,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -5345,7 +5345,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -5380,7 +5380,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -5479,7 +5479,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5495,7 +5495,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5677,7 +5677,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", @@ -5694,7 +5694,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5710,7 +5710,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -5728,7 +5728,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", @@ -5746,7 +5746,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5762,7 +5762,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5794,7 +5794,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", @@ -5811,7 +5811,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -5832,7 +5832,7 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -5842,7 +5842,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -5859,7 +5859,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5875,7 +5875,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -5892,7 +5892,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5908,7 +5908,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -5925,7 +5925,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5941,7 +5941,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5957,7 +5957,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -5989,7 +5989,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -6006,7 +6006,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", @@ -6024,7 +6024,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6040,7 +6040,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6056,7 +6056,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6072,7 +6072,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6088,7 +6088,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -6121,7 +6121,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -6140,7 +6140,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -6157,7 +6157,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -6174,7 +6174,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6205,7 +6205,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6221,7 +6221,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", @@ -6239,7 +6239,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -6256,7 +6256,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6288,7 +6288,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6320,7 +6320,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -6338,7 +6338,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6423,7 +6423,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -6440,7 +6440,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -6457,7 +6457,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6473,7 +6473,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6489,7 +6489,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -6506,7 +6506,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6522,7 +6522,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6538,7 +6538,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6573,7 +6573,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -6589,7 +6589,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -6606,7 +6606,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -6623,7 +6623,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -6640,7 +6640,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.26.0", @@ -6741,7 +6741,7 @@ "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -12553,7 +12553,6 @@ "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", "dependencies": { "@babel/code-frame": "^7.10.4", @@ -12573,7 +12572,6 @@ "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", "engines": { "node": ">=10" @@ -12586,7 +12584,6 @@ "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", "dependencies": { "ansi-regex": "^5.0.1", @@ -12601,7 +12598,6 @@ "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" }, "node_modules/@testing-library/jest-dom": { @@ -12682,7 +12678,6 @@ "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" }, "node_modules/@types/babel__core": { @@ -12981,7 +12976,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, "license": "MIT" }, "node_modules/@types/lodash": { @@ -13004,7 +12998,6 @@ "version": "12.2.3", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/linkify-it": "*", @@ -13015,7 +13008,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -14507,7 +14499,6 @@ "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", "dependencies": { "dequal": "^2.0.3" @@ -14933,7 +14924,7 @@ "version": "0.4.12", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.22.6", @@ -14948,7 +14939,7 @@ "version": "0.10.6", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.2", @@ -14962,7 +14953,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.3" @@ -16819,7 +16810,7 @@ "version": "3.39.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "browserslist": "^4.24.2" @@ -18049,7 +18040,6 @@ "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" }, "node_modules/dom-converter": { @@ -21623,7 +21613,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -24038,7 +24028,6 @@ "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", "bin": { "lz-string": "bin/bin.js" @@ -25663,7 +25652,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -28056,14 +28045,14 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -28076,7 +28065,7 @@ "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.4" @@ -28086,7 +28075,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2", @@ -28104,14 +28093,14 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/regjsparser": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.0.2" @@ -28124,7 +28113,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -28316,7 +28305,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -30573,7 +30562,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -31767,7 +31756,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -31828,7 +31817,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -31838,7 +31827,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", @@ -31852,7 +31841,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -31862,7 +31851,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -32605,6 +32594,12 @@ "node": ">=0.10.0" } }, + "node_modules/workerpool": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.2.tgz", + "integrity": "sha512-8PCeZlCwu0+8hXruze1ahYNsY+M0LOCmbmySZ9BWWqWIXP9TAXa6FZCxACTDL/0j47pFcC4xW98Gr8nAC5oymg==", + "license": "Apache-2.0" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -35226,6 +35221,7 @@ "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", "uuid": "^10.0.0", + "workerpool": "10.0.2", "yup": "^0.32.11", "zod": "^4.1.8" }, @@ -36157,6 +36153,25 @@ "ohm-js": "^16.6.0" } }, + "packages/bruno-pool": { + "name": "@usebruno/pool", + "version": "0.1.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@usebruno/filestore": "0.1.0", + "workerpool": "10.0.2" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "23.0.2", + "@rollup/plugin-node-resolve": "15.0.1", + "@rollup/plugin-typescript": "12.1.2", + "@types/node": "^24.1.0", + "rollup": "3.30.0", + "tslib": "2.8.1", + "typescript": "5.4.5" + } + }, "packages/bruno-query": { "name": "@usebruno/query", "version": "0.1.0", @@ -36385,6 +36400,16 @@ "node": ">=14.17" } }, + "packages/bruno-storage": { + "name": "@usebruno/storage", + "version": "0.1.0", + "extraneous": true, + "license": "MIT", + "devDependencies": { + "@types/node": "^24.1.0", + "typescript": "5.4.5" + } + }, "packages/bruno-tests": { "name": "@usebruno/tests", "version": "0.0.1", diff --git a/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js index f19772f57..47331f93a 100644 --- a/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js @@ -2,11 +2,85 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` color: ${(props) => props.theme.text}; - - form.bruno-form { - label { - font-size: 0.8125rem; - } + + .cache-section-title { + text-transform: uppercase; + font-size: ${(props) => props.theme.font.size.sm}; + letter-spacing: 0.05em; + color: ${(props) => props.theme.colors.text.muted}; + margin-bottom: 0.75rem; + } + + .cache-item { + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.md}; + margin-bottom: 1rem; + overflow: hidden; + } + + .cache-item-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + gap: 1rem; + background: ${(props) => props.theme.background.surface0}; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + } + + .cache-item-title-group { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .cache-item-title { + font-size: ${(props) => props.theme.font.size.md}; + font-weight: 600; + } + + .beta-badge { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: ${(props) => props.theme.border.radius.sm}; + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 500; + line-height: 1.5; + background: ${(props) => props.theme.status.info.background}; + color: ${(props) => props.theme.status.info.text}; + } + + .cache-item-body { + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 0.875rem 1rem; + gap: 1.25rem; + } + + .cache-item-body-text { + flex: 1; + min-width: 0; + } + + .cache-item-description { + font-size: ${(props) => props.theme.font.size.base}; + color: ${(props) => props.theme.colors.text.muted}; + line-height: 1.5; + margin: 0; + } + + .cache-item-size { + font-size: ${(props) => props.theme.font.size.base}; + color: ${(props) => props.theme.colors.text.subtext2}; + margin: 0.5rem 0 0 0; + } + + .cache-item-size strong { + font-weight: 600; + color: ${(props) => props.theme.text}; + margin-left: 0.25rem; } `; diff --git a/packages/bruno-app/src/components/Preferences/Cache/index.js b/packages/bruno-app/src/components/Preferences/Cache/index.js index a17ef6caa..de922cb71 100644 --- a/packages/bruno-app/src/components/Preferences/Cache/index.js +++ b/packages/bruno-app/src/components/Preferences/Cache/index.js @@ -1,120 +1,147 @@ -import React, { useEffect, useCallback, useRef } from 'react'; -import { useFormik } from 'formik'; +import React, { useEffect, useState, useCallback } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { - savePreferences, - clearHttpHttpsAgentCache -} from 'providers/ReduxStore/slices/app'; +import { savePreferences, clearHttpHttpsAgentCache } from 'providers/ReduxStore/slices/app'; import toast from 'react-hot-toast'; -import StyledWrapper from './StyledWrapper'; -import * as Yup from 'yup'; -import debounce from 'lodash/debounce'; import get from 'lodash/get'; - -const cacheSchema = Yup.object().shape({ - sslSession: Yup.object({ - enabled: Yup.boolean() - }) -}); +import { IconEraser } from '@tabler/icons'; +import { useTheme } from 'providers/Theme'; +import ToggleSwitch from 'components/ToggleSwitch'; +import ActionIcon from 'ui/ActionIcon'; +import StyledWrapper from './StyledWrapper'; +import { formatSize } from 'utils/common'; const Cache = () => { const preferences = useSelector((state) => state.app.preferences); const dispatch = useDispatch(); + const { theme } = useTheme(); + const { ipcRenderer } = window; - const handleSave = useCallback( - (newCachePreferences) => { - dispatch( - savePreferences({ - ...preferences, - cache: newCachePreferences - }) - ).catch(() => toast.error('Failed to update cache preferences')); - }, - [dispatch, preferences] - ); + const fileCacheEnabled = get(preferences, 'cache.file.enabled', false); + const sslSessionEnabled = get(preferences, 'cache.sslSession.enabled', false); - const handleSaveRef = useRef(handleSave); - handleSaveRef.current = handleSave; + const [fileCacheSize, setFileCacheSize] = useState(null); - const formik = useFormik({ - initialValues: { - sslSession: { - enabled: get(preferences, 'cache.sslSession.enabled', false) - } - }, - validationSchema: cacheSchema, - onSubmit: async (values) => { - try { - const newPreferences = await cacheSchema.validate(values, { abortEarly: true }); - handleSave(newPreferences); - } catch (error) { - console.error('Cache preferences validation error:', error.message); - } - } - }); - - const debouncedSave = useCallback( - debounce((values) => { - cacheSchema - .validate(values, { abortEarly: true }) - .then((validatedValues) => handleSaveRef.current(validatedValues)) - .catch(() => {}); - }, 500), - [] - ); + const refreshFileCacheSize = useCallback(() => { + if (!ipcRenderer) return; + ipcRenderer + .invoke('renderer:get-file-cache-size') + .then((size) => setFileCacheSize(size)) + .catch(() => setFileCacheSize(null)); + }, [ipcRenderer]); useEffect(() => { - if (formik.dirty && formik.isValid) { - debouncedSave(formik.values); - } - return () => { - debouncedSave.flush(); - }; - }, [formik.values, formik.dirty, formik.isValid, debouncedSave]); + refreshFileCacheSize(); + }, [refreshFileCacheSize, fileCacheEnabled]); - const handleAgentCachingChange = (e) => { - formik.handleChange(e); - // Immediately evict all cached agents when caching is disabled - if (!e.target.checked) { + const persist = (next) => { + dispatch(savePreferences({ ...preferences, cache: next })).catch(() => { + toast.error('Failed to update cache preferences'); + }); + }; + + const handleToggleFileCache = () => { + persist({ + ...preferences.cache, + file: { enabled: !fileCacheEnabled } + }); + }; + + const handleToggleSslSession = () => { + const next = !sslSessionEnabled; + persist({ + ...preferences.cache, + sslSession: { enabled: next } + }); + if (!next) { dispatch(clearHttpHttpsAgentCache()).catch(() => {}); } }; - const handleResetCache = () => { + const handleClearFileCache = () => { + if (!ipcRenderer) return; + ipcRenderer + .invoke('renderer:clear-file-cache') + .then((size) => { + setFileCacheSize(size); + toast.success('File cache cleared'); + }) + .catch(() => toast.error('Failed to clear file cache')); + }; + + const handleClearSslSession = () => { dispatch(clearHttpHttpsAgentCache()) - .then(() => toast.success('ssl session cache cleared')) - .catch(() => toast.error('Failed to clear ssl session cache')); + .then(() => toast.success('SSL session cache cleared')) + .catch(() => toast.error('Failed to clear SSL session cache')); }; return ( -
-
Cache SSL Session
+
Cache
-
- +
+
+ File cache + Beta +
+ -
-
- Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every - request. +
+
+

+ Loads your workspace faster by caching opened collections. Bruno refreshes the cache when your collection + changes. Clearing it won't affect your original files. +

+

+ Cache size {fileCacheSize == null ? '—' : formatSize(fileCacheSize)} +

+
+ + +
+
-
- +
+
+
+ SSL session cache +
+
- +
+
+

+ Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh + connection for every request. +

+
+ + + +
+
); }; diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 2b527262f..b7d58871a 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -36,7 +36,7 @@ import toast from 'react-hot-toast'; import { useDispatch, useStore } from 'react-redux'; import { isElectron } from 'utils/common/platform'; import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments'; -import { collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByCredentialsId, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index'; +import { collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByCredentialsId, updateCollectionLoadingState, collectionLoadedFromTree } from 'providers/ReduxStore/slices/collections/index'; import { addLog } from 'providers/ReduxStore/slices/logs'; import { loadNotifications } from 'providers/ReduxStore/slices/notifications'; import { updateSystemResources } from 'providers/ReduxStore/slices/performance'; @@ -348,7 +348,22 @@ const useIpcEvents = () => { dispatch(loadNotifications(notifications)); }); + const removeCollectionTreeLoadedListener = ipcRenderer.on('main:collection-tree-loaded', ({ collectionUid, tree }) => { + dispatch(collectionLoadedFromTree({ collectionUid, tree })); + }); + + const removeCollectionLoadingStateV2Listener = ipcRenderer.on('main:collection-loading-state-updated-v2', (val) => { + dispatch(updateCollectionLoadingState(val)); + }); + + const removeBrunoConfigUpdateV2Listener = ipcRenderer.on('main:bruno-config-update-v2', (val) => { + dispatch(brunoConfigUpdateEvent(val)); + }); + return () => { + removeCollectionTreeLoadedListener(); + removeCollectionLoadingStateV2Listener(); + removeBrunoConfigUpdateV2Listener(); removeCollectionTreeUpdateListener(); removeApiSpecTreeUpdateListener(); removeOpenCollectionListener(); diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js index 034d31eba..08432475a 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js @@ -4,7 +4,7 @@ import filter from 'lodash/filter'; import { createListenerMiddleware } from '@reduxjs/toolkit'; import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app'; import { addTab } from 'providers/ReduxStore/slices/tabs'; -import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections'; +import { collectionAddFileEvent, collectionChangeFileEvent, collectionLoadedFromTree } from 'providers/ReduxStore/slices/collections'; import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index'; import { taskTypes } from './utils'; @@ -25,31 +25,53 @@ taskMiddleware.startListening({ const openRequestTasks = filter(state.app.taskQueue, { type: taskTypes.OPEN_REQUEST }); each(openRequestTasks, (task) => { - if (collectionUid === task.collectionUid) { - const collection = findCollectionByUid(state.collections.collections, collectionUid); - if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) { - const item = findItemInCollectionByPathname(collection, task.itemPathname); - if (item) { - listenerApi.dispatch( - addTab({ - uid: item.uid, - collectionUid: collection.uid, - type: item.type, - pathname: item.pathname, - requestPaneTab: getDefaultRequestPaneTab(item), - preview: task?.preview ?? true, - ...(item.isTransient ? { isTransient: true } : {}) - }) - ); - } - } + if (collectionUid !== task.collectionUid) return; + const collection = findCollectionByUid(state.collections.collections, collectionUid); + if (!collection || collection.mountStatus !== 'mounted' || collection.isLoading) return; + const item = findItemInCollectionByPathname(collection, task.itemPathname); + if (!item) return; + listenerApi.dispatch( + addTab({ + uid: item.uid, + collectionUid: collection.uid, + type: item.type, + pathname: item.pathname, + requestPaneTab: getDefaultRequestPaneTab(item), + preview: task?.preview ?? true, + ...(item.isTransient ? { isTransient: true } : {}) + }) + ); + listenerApi.dispatch(removeTaskFromQueue({ taskUid: task.uid })); + }); + } +}); - listenerApi.dispatch( - removeTaskFromQueue({ - taskUid: task.uid - }) - ); - } +// v2 tree push also acts as a signal for queued OPEN_REQUEST tasks. +taskMiddleware.startListening({ + actionCreator: collectionLoadedFromTree, + effect: (action, listenerApi) => { + const state = listenerApi.getState(); + const collectionUid = get(action, 'payload.collectionUid'); + + const openRequestTasks = filter(state.app.taskQueue, { type: taskTypes.OPEN_REQUEST }); + each(openRequestTasks, (task) => { + if (collectionUid !== task.collectionUid) return; + const collection = findCollectionByUid(state.collections.collections, collectionUid); + if (!collection || collection.mountStatus !== 'mounted' || collection.isLoading) return; + const item = findItemInCollectionByPathname(collection, task.itemPathname); + if (!item) return; + listenerApi.dispatch( + addTab({ + uid: item.uid, + collectionUid: collection.uid, + type: item.type, + pathname: item.pathname, + requestPaneTab: getDefaultRequestPaneTab(item), + preview: task?.preview ?? true, + ...(item.isTransient ? { isTransient: true } : {}) + }) + ); + listenerApi.dispatch(removeTaskFromQueue({ taskUid: task.uid })); }); } }); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 25500f889..db0da6353 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -3122,8 +3122,10 @@ export const mountCollection = ({ collectionUid, collectionPathname, brunoConfig, skipTabRestore = false, workspacePathname = null }) => (dispatch, getState) => { dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' })); + const fileCacheEnabled = getState().app?.preferences?.cache?.file?.enabled; + const channel = fileCacheEnabled ? 'renderer:mount-collection-v2' : 'renderer:mount-collection'; return new Promise(async (resolve, reject) => { - callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig }) + callIpc(channel, { collectionUid, collectionPathname, brunoConfig }) .then(async (transientDirPath) => { dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' })); dispatch(addTransientDirectory({ collectionUid, pathname: transientDirPath })); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 8015d7faa..545cdaa1d 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -1,6 +1,6 @@ import { parseQueryParams, buildQueryString as stringifyQueryParams } from '@usebruno/common/utils'; import { uuid } from 'utils/common'; -import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex } from 'lodash'; +import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex, pick } from 'lodash'; import { createSlice } from '@reduxjs/toolkit'; import { hexy as hexdump } from 'hexy'; import { @@ -27,6 +27,69 @@ import { getCollectionEnvironmentPath } from 'utils/snapshot'; import { getDataTypeFromValue } from '@usebruno/common/utils'; import * as exampleReducers from './exampleReducers'; +const FILE_DERIVED_REQUEST_FIELDS = [ + 'name', + 'type', + 'seq', + 'tags', + 'request', + 'settings', + 'examples', + 'filename', + 'pathname', + 'partial', + 'loading', + 'size', + 'error', + 'isTransient' +]; + +const FILE_DERIVED_FOLDER_FIELDS = [ + 'name', + 'filename', + 'pathname', + 'seq', + 'type', + 'root' +]; + +const mergeTreeItems = (existingItems, newItems) => { + if (!Array.isArray(existingItems) || existingItems.length === 0) return newItems; + const existingByUid = new Map(); + for (const item of existingItems) { + if (item && item.uid) existingByUid.set(item.uid, item); + } + + return newItems.map((newItem) => { + const existing = existingByUid.get(newItem.uid); + if (!existing) return newItem; + + if (newItem.type === 'folder') { + const merged = { ...existing, ...pick(newItem, FILE_DERIVED_FOLDER_FIELDS) }; + merged.items = mergeTreeItems(existing.items, newItem.items || []); + return merged; + } + + // seq-only change (reorder) — keep everything else, including the draft + if (areItemsTheSameExceptSeqUpdate(existing, newItem)) { + const merged = { ...existing, seq: newItem.seq }; + if (merged.draft) { + merged.draft = { ...merged.draft, seq: newItem.seq }; + if (areItemsTheSameExceptSeqUpdate(merged.draft, newItem)) { + merged.draft = null; + } + } + return merged; + } + + const merged = { ...existing, ...pick(newItem, FILE_DERIVED_REQUEST_FIELDS) }; + // only drop the draft if it matches what's on disk — user may still be typing + const draftMatchesFile = existing.draft && areItemsTheSameExceptSeqUpdate(existing.draft, newItem); + merged.draft = draftMatchesFile ? null : (existing.draft || null); + return merged; + }); +}; + // gRPC status code meanings const grpcStatusCodes = { 0: 'OK', @@ -3341,6 +3404,35 @@ export const collectionsSlice = createSlice({ } } }, + collectionLoadedFromTree: (state, action) => { + const { collectionUid, tree } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) return; + + collection.items = mergeTreeItems(collection.items, tree?.items || []); + collection.environments = tree?.environments || []; + if (tree?.root !== undefined) { + collection.root = tree.root; + } + if (tree?.brunoConfig) { + collection.brunoConfig = tree.brunoConfig; + } + const tempDirectory = state.tempDirectories?.[collectionUid]; + if (tempDirectory) { + const annotateTransient = (items) => { + for (const item of items) { + if (item.pathname && item.pathname.startsWith(tempDirectory)) { + item.isTransient = true; + } + if (item.type === 'folder' && Array.isArray(item.items)) { + annotateTransient(item.items); + } + } + }; + annotateTransient(collection.items); + } + addDepth(collection.items); + }, collectionAddOauth2CredentialsByUrl: (state, action) => { const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo, executionMode } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); @@ -3734,6 +3826,7 @@ export const { createCollection, updateCollectionMountStatus, updateCollectionLoadingState, + collectionLoadedFromTree, setCollectionSecurityConfig, brunoConfigUpdateEvent, renameCollection, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index 4cab266c4..375dd0f16 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -1344,11 +1344,20 @@ export const mountScratchCollection = (workspaceUid) => { ignore: ['node_modules', '.git'] }; - await ipcRenderer.invoke('renderer:add-collection-watcher', { - collectionPath: tempDirectoryPath, - collectionUid: scratchCollectionUid, - brunoConfig - }); + const fileCacheEnabled = state.app?.preferences?.cache?.file?.enabled; + if (fileCacheEnabled) { + await ipcRenderer.invoke('renderer:mount-collection-v2', { + collectionUid: scratchCollectionUid, + collectionPathname: tempDirectoryPath, + brunoConfig + }); + } else { + await ipcRenderer.invoke('renderer:add-collection-watcher', { + collectionPath: tempDirectoryPath, + collectionUid: scratchCollectionUid, + brunoConfig + }); + } // Map scratch collection to workspace so getProcessEnvVars can resolve workspace .env values if (workspace.pathname) { diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index a10e1cde3..4d0c53ba8 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -79,6 +79,7 @@ "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", "uuid": "^10.0.0", + "workerpool": "10.0.2", "yup": "^0.32.11", "zod": "^4.1.8" }, diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index d29399b63..217b6832c 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -33,6 +33,27 @@ const MAX_FILE_SIZE = 2.5 * 1024 * 1024; const environmentSecretsStore = new EnvironmentSecretsStore(); +// registered collections stage parsed data into the git-lite snapshot (no-op otherwise) +const fileIndexByCollection = new Map(); +const stageToCache = (collectionPath, pathname, data) => { + const index = fileIndexByCollection.get(collectionPath); + if (!index) return; + try { + index.stageParsed(collectionPath, pathname, data); + } catch (err) { + console.error('[collection-watcher] cache stage failed for', pathname, err); + } +}; +const unstageFromCache = (collectionPath, pathname) => { + const index = fileIndexByCollection.get(collectionPath); + if (!index) return; + try { + index.unstagePath(collectionPath, pathname); + } catch (err) { + console.error('[collection-watcher] cache unstage failed for', pathname, err); + } +}; + const isBrunoConfigFile = (pathname, collectionPath) => { const dirname = path.dirname(pathname); const basename = path.basename(pathname); @@ -107,6 +128,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath) let content = fs.readFileSync(pathname, 'utf8'); file.data = await parseEnvironment(content, { format }); + stageToCache(collectionPath, pathname, file.data); // Extract name by removing the extension const ext = path.extname(basename); @@ -148,6 +170,7 @@ const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPat const content = fs.readFileSync(pathname, 'utf8'); file.data = await parseEnvironment(content, { format }); + stageToCache(collectionPath, pathname, file.data); // Extract name by removing the extension const ext = path.extname(basename); @@ -203,6 +226,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread try { const content = fs.readFileSync(pathname, 'utf8'); let brunoConfig = JSON.parse(content); + stageToCache(collectionPath, pathname, brunoConfig); // Transform the config to add exists metadata for protobuf files and import paths brunoConfig = await transformBrunoConfigAfterRead(brunoConfig, collectionPath); @@ -249,6 +273,8 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread } file.data = collectionRoot; + // cache the full parse result (collectionRoot + brunoConfig for yml) + stageToCache(collectionPath, pathname, parsed); hydrateCollectionRootWithUuid(file.data); win.webContents.send('main:collection-tree-updated', 'addFile', file); @@ -288,6 +314,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread let format = getCollectionFormat(collectionPath); let content = fs.readFileSync(pathname, 'utf8'); file.data = await parseFolder(content, { format }); + stageToCache(collectionPath, pathname, file.data); hydrateCollectionRootWithUuid(file.data); win.webContents.send('main:collection-tree-updated', 'addFile', file); @@ -317,6 +344,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread if (!useWorkerThread) { try { file.data = await parseRequest(content, { format }); + stageToCache(collectionPath, pathname, file.data); file.partial = false; file.loading = false; file.size = sizeInMB(fileStats?.size); @@ -360,6 +388,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread format, filename: pathname }); + stageToCache(collectionPath, pathname, file.data); file.partial = false; file.loading = false; file.data.raw = content; @@ -429,6 +458,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => { try { const content = fs.readFileSync(pathname, 'utf8'); let brunoConfig = JSON.parse(content); + stageToCache(collectionPath, pathname, brunoConfig); // Transform the config to add file existence checks for protobuf files and import paths brunoConfig = await transformBrunoConfigAfterRead(brunoConfig, collectionPath); @@ -477,6 +507,8 @@ const change = async (win, pathname, collectionUid, collectionPath) => { } file.data = collectionRoot; + // cache the full parse result (collectionRoot + brunoConfig for yml) + stageToCache(collectionPath, pathname, parsed); hydrateCollectionRootWithUuid(file.data); win.webContents.send('main:collection-tree-updated', 'change', file); @@ -516,6 +548,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => { let format = getCollectionFormat(collectionPath); let content = fs.readFileSync(pathname, 'utf8'); file.data = await parseFolder(content, { format }); + stageToCache(collectionPath, pathname, file.data); hydrateCollectionRootWithUuid(file.data); win.webContents.send('main:collection-tree-updated', 'change', file); @@ -541,9 +574,11 @@ const change = async (win, pathname, collectionUid, collectionPath) => { const fileStats = fs.statSync(pathname); if (fileStats.size >= MAX_FILE_SIZE && format === 'bru') { + // redacted parse — do not write the redacted data through to the cache file.data = await parseLargeRequestWithRedaction(content, 'bru'); } else { file.data = await parseRequest(content, { format }); + stageToCache(collectionPath, pathname, file.data); } file.data.raw = content; @@ -562,6 +597,8 @@ const unlink = (win, pathname, collectionUid, collectionPath) => { return; } console.log(`watcher unlink: ${pathname}`); + // drop the file from the snapshot regardless of type (request/env/config/folder root) + unstageFromCache(collectionPath, pathname); if (isEnvironmentsFolder(pathname, collectionPath)) { return unlinkEnvironmentFile(win, pathname, collectionUid); @@ -729,11 +766,17 @@ class CollectionWatcher { delete this.loadingStates[collectionUid]; } - addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false, useWorkerThread) { + addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false, useWorkerThread, options = {}) { if (this.watchers[watchPath]) { this.watchers[watchPath].close(); } + // v2 already loaded the tree from cache; skip startup scan and stage live edits + const { ignoreInitial = false, fileIndex = null } = options; + if (fileIndex) { + fileIndexByCollection.set(watchPath, fileIndex); + } + this.initializeLoadingState(collectionUid); this.startCollectionDiscovery(win, collectionUid); @@ -746,7 +789,7 @@ class CollectionWatcher { setTimeout(() => { const watcher = chokidar.watch(watchPath, { - ignoreInitial: false, + ignoreInitial, usePolling: isWSLPath(watchPath) || forcePolling ? true : false, ignored: (filepath) => { const normalizedPath = normalizeAndResolvePath(filepath); @@ -802,7 +845,7 @@ class CollectionWatcher { 'Update your system config to allow more concurrently watched files with:', '"echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p"' ); - this.addWatcher(win, watchPath, collectionUid, brunoConfig, true, useWorkerThread); + this.addWatcher(win, watchPath, collectionUid, brunoConfig, true, useWorkerThread, options); } else { console.error(`An error occurred in the watcher for: ${watchPath}`, error); } @@ -824,6 +867,8 @@ class CollectionWatcher { this.watchers[watchPath] = null; } + fileIndexByCollection.delete(watchPath); + dotEnvWatcher.removeCollectionWatcher(watchPath); const tempDirectoryPath = this.tempDirectoryMap[watchPath]; diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 1f3a1419d..21ae958ac 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -46,6 +46,7 @@ const registerApiSpecIpc = require('./ipc/apiSpec'); const registerGitIpc = require('./ipc/git'); const registerOpenAPISyncIpc = require('./ipc/openapi-sync'); const registerAiIpc = require('./ipc/ai'); +const { registerMountIpc } = require('./ipc/mount'); const collectionWatcher = require('./app/collection-watcher'); const WorkspaceWatcher = require('./app/workspace-watcher'); const ApiSpecWatcher = require('./app/apiSpecsWatcher'); @@ -477,6 +478,7 @@ app.on('ready', async () => { registerGitIpc(mainWindow); registerOpenAPISyncIpc(mainWindow); registerAiIpc(mainWindow); + registerMountIpc(); // Internal delegator ipcMain.handle('main:cache-clear', async () => { @@ -505,6 +507,8 @@ app.on('before-quit', (event) => { ]); } catch {} + try { await require('./ipc/mount').shutdown(); } catch {} + if (useSingleInstance && gotTheLock) { try { app.releaseSingleInstanceLock(); } catch {} } diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 2e3b56f7b..853fb28dd 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -1263,6 +1263,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } } + await require('./mount').unmount(collectionUid).catch(() => {}); + // Clean up const { clearCollectionWorkspace } = require('../store/process-env'); clearCollectionWorkspace(collectionUid); diff --git a/packages/bruno-electron/src/ipc/mount.js b/packages/bruno-electron/src/ipc/mount.js new file mode 100644 index 000000000..628106cba --- /dev/null +++ b/packages/bruno-electron/src/ipc/mount.js @@ -0,0 +1,35 @@ +const { ipcMain, BrowserWindow } = require('electron'); +const { MountManager } = require('../services/mount'); + +const manager = new MountManager(); + +const registerMountIpc = () => { + ipcMain.handle('renderer:get-file-cache-size', () => manager.getCacheSize()); + + ipcMain.handle('renderer:clear-file-cache', () => { + manager.clearCache(); + return manager.getCacheSize(); + }); + + ipcMain.handle( + 'renderer:mount-collection-v2', + async (event, { collectionUid, collectionPathname, brunoConfig }) => { + const win = BrowserWindow.fromWebContents(event.sender); + const send = (channel, payload) => { + if (!win || win.isDestroyed?.()) return; + win.webContents.send(channel, payload); + }; + const emit = { + tree: (tree) => send('main:collection-tree-loaded', { collectionUid, tree }), + loading: (isLoading) => send('main:collection-loading-state-updated-v2', { collectionUid, isLoading }), + config: (brunoConfig) => send('main:bruno-config-update-v2', { collectionUid, brunoConfig }) + }; + return manager.mount({ win, collectionPath: collectionPathname, collectionUid, brunoConfig, emit }); + } + ); +}; + +const unmount = (collectionUid) => manager.unmount(collectionUid); +const shutdown = () => manager.shutdown(); + +module.exports = { registerMountIpc, unmount, shutdown }; diff --git a/packages/bruno-electron/src/services/mount/file-index.js b/packages/bruno-electron/src/services/mount/file-index.js new file mode 100644 index 000000000..8ea183e39 --- /dev/null +++ b/packages/bruno-electron/src/services/mount/file-index.js @@ -0,0 +1,188 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { Database } = require('../storage'); +const { + hashFile, + hashFileAsync, + normalize, + posixifyPath, + idForAbsolutePath, + resolveDenylist, + isDenied, + walk +} = require('../../utils/mount'); + +const MIGRATIONS = [ + { + version: 1, + up: ` + CREATE TABLE IF NOT EXISTS file_index_entries ( + collection_path TEXT NOT NULL, + relative_path TEXT NOT NULL, + id TEXT NOT NULL, + mtime INTEGER NOT NULL, + hash TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (collection_path, relative_path) + ) WITHOUT ROWID; + CREATE INDEX IF NOT EXISTS idx_collection_path ON file_index_entries(collection_path); + ` + } +]; + +class FileIndex { + #db; + #dbPath; + + constructor({ dbPath } = {}) { + this.#dbPath = dbPath || path.join(require('electron').app.getPath('userData'), 'mount-snapshots.db'); + this.#db = new Database({ path: this.#dbPath, migrations: MIGRATIONS, readBigInts: true }); + } + + close() { + this.#db.close(); + } + + async status(collectionPath, options = {}) { + const root = normalize(collectionPath); + const stored = this.#loadStored(root); + const denylist = resolveDenylist(options.denylist); + const added = []; + const updated = []; + const removed = []; + const seen = new Set(); + + const files = walk(root, denylist); + const results = await Promise.all(files.map(async ({ relativePath, absolutePath }) => { + const stat = await fs.promises.stat(absolutePath, { bigint: true }); + const mtime = stat.mtimeNs; + const prior = stored.get(relativePath); + + if (!prior) { + const hash = await hashFileAsync(absolutePath); + return { kind: 'added', entry: { relativePath, absolutePath, mtime, hash } }; + } + if (prior.mtime === mtime) return { kind: 'unchanged', relativePath }; + const hash = await hashFileAsync(absolutePath); + if (hash === prior.hash) return { kind: 'unchanged', relativePath }; + return { kind: 'updated', entry: { relativePath, absolutePath, mtime, hash, prevHash: prior.hash } }; + })); + + for (const r of results) { + if (r.kind === 'added') { + added.push(r.entry); + seen.add(r.entry.relativePath); + } else if (r.kind === 'updated') { + updated.push(r.entry); + seen.add(r.entry.relativePath); + } else { + seen.add(r.relativePath); + } + } + + for (const [relativePath, row] of stored) { + if (seen.has(relativePath)) continue; + if (isDenied(posixifyPath(relativePath), denylist)) continue; + removed.push({ relativePath, id: row.id, hash: row.hash }); + } + + return { added, updated, removed }; + } + + clear() { + this.#db.exec('DELETE FROM file_index_entries'); + // VACUUM so the file actually shrinks after the DELETE + this.#db.exec('VACUUM'); + } + + get dbPath() { + return this.#dbPath; + } + + entries(collectionPath) { + const root = normalize(collectionPath); + const rows = this.#db.all( + 'SELECT relative_path AS relativePath, data FROM file_index_entries WHERE collection_path = ?', + root + ); + const map = new Map(); + for (const row of rows) { + map.set(row.relativePath, { data: JSON.parse(row.data) }); + } + return map; + } + + stage(collectionPath, entry) { + const root = normalize(collectionPath); + const { op, relativePath } = entry; + + if (op === 'remove') { + this.#db.run( + 'DELETE FROM file_index_entries WHERE collection_path = ? AND relative_path = ?', + root, + relativePath + ); + return; + } + + const { mtime, hash, data } = entry; + const id = idForAbsolutePath(path.join(root, relativePath)); + this.#db.run( + ` + INSERT INTO file_index_entries (collection_path, relative_path, id, mtime, hash, data) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(collection_path, relative_path) DO UPDATE SET + mtime = excluded.mtime, + hash = excluded.hash, + data = excluded.data + `, + root, + relativePath, + id, + mtime, + hash, + JSON.stringify(data) + ); + } + + stageParsed(collectionPath, absolutePath, data) { + const root = normalize(collectionPath); + const relativePath = path.relative(root, normalize(absolutePath)); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) return; + const stat = fs.statSync(absolutePath, { bigint: true }); + this.stage(root, { + op: 'add', + relativePath, + mtime: stat.mtimeNs, + hash: hashFile(absolutePath), + data + }); + } + + unstagePath(collectionPath, absolutePath) { + const root = normalize(collectionPath); + const relativePath = path.relative(root, normalize(absolutePath)); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) return; + this.stage(root, { op: 'remove', relativePath }); + } + + transaction(callback) { + return this.#db.transaction(callback); + } + + #loadStored(collectionPath) { + const rows = this.#db.all( + 'SELECT relative_path AS relativePath, id, mtime, hash FROM file_index_entries WHERE collection_path = ?', + collectionPath + ); + const map = new Map(); + for (const row of rows) { + map.set(row.relativePath, row); + } + return map; + } +} + +module.exports = { + FileIndex +}; diff --git a/packages/bruno-electron/src/services/mount/index.js b/packages/bruno-electron/src/services/mount/index.js new file mode 100644 index 000000000..c8a1315ff --- /dev/null +++ b/packages/bruno-electron/src/services/mount/index.js @@ -0,0 +1,3 @@ +const { MountManager } = require('./manager'); + +module.exports = { MountManager }; diff --git a/packages/bruno-electron/src/services/mount/manager.js b/packages/bruno-electron/src/services/mount/manager.js new file mode 100644 index 000000000..5427e9813 --- /dev/null +++ b/packages/bruno-electron/src/services/mount/manager.js @@ -0,0 +1,223 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { JobType, getPool, destroyPool } = require('../pool'); +const { FileIndex } = require('./file-index'); +const { buildTree } = require('./tree-builder'); +const { defaultClassify, uidForSeed } = require('../../utils/mount'); + +// cold start only — collection-watcher handles live changes and writes through to the cache + +let _envSecretsStore = null; +const getEnvSecretsStore = () => { + if (!_envSecretsStore) { + const EnvironmentSecretsStore = require('../../store/env-secrets'); + _envSecretsStore = new EnvironmentSecretsStore(); + } + return _envSecretsStore; +}; + +const envHasSecrets = (env) => Array.isArray(env?.variables) && env.variables.some((v) => v.secret); + +const hydrateEnvironments = (collectionPath, environments) => { + if (!Array.isArray(environments)) return; + const { decryptStringSafe } = require('../../utils/encryption'); + for (const env of environments) { + if (!Array.isArray(env.variables)) continue; + env.variables.forEach((variable, i) => { + const key = variable.name || `index:${i}`; + variable.uid = uidForSeed(`${env.uid}#var#${key}`); + }); + if (!envHasSecrets(env)) continue; + try { + const envSecrets = getEnvSecretsStore().getEnvSecrets(collectionPath, env); + for (const secret of envSecrets || []) { + const variable = env.variables.find((v) => v.name === secret.name); + if (variable && secret.value) { + const decrypted = decryptStringSafe(secret.value); + variable.value = decrypted.value; + } + } + } catch (err) { + console.error('[mount] env secret hydration failed', err); + } + } +}; + +const sendTree = async (collectionUid, collectionPath, tree, emit) => { + if (tree.brunoConfig) { + try { + const { transformBrunoConfigAfterRead } = require('../../utils/transformBrunoConfig'); + const { setBrunoConfig } = require('../../store/bruno-config'); + const transformed = await transformBrunoConfigAfterRead(tree.brunoConfig, collectionPath); + tree.brunoConfig = transformed; + setBrunoConfig(collectionUid, transformed); + emit.config(transformed); + } catch (err) { + console.error(`[mount:${collectionUid}] brunoConfig transform failed:`, err); + } + } + hydrateEnvironments(collectionPath, tree.environments); + emit.tree(tree); +}; + +const ensureTransientDirectory = () => { + const base = path.join(require('electron').app.getPath('userData'), 'tmp', 'transient'); + if (!fs.existsSync(base)) fs.mkdirSync(base, { recursive: true }); + return fs.mkdtempSync(path.join(base, 'bruno-')); +}; + +class MountManager { + #index = null; + #mounts = new Map(); + + async mount({ win, collectionPath, collectionUid, brunoConfig, emit }) { + collectionPath = path.resolve(collectionPath); + + if (this.#mounts.has(collectionUid)) { + // renderer reload — pull fresh state from cache and re-emit + const existing = this.#mounts.get(collectionUid); + existing.win = win; + existing.emit = emit; + existing.brunoConfig = brunoConfig || existing.brunoConfig; + existing.state = this.#getIndex().entries(existing.collectionPath); + await this.#emitTree(collectionUid, existing); + return existing.tempDirectoryPath; + } + + const tempDirectoryPath = ensureTransientDirectory(); + fs.writeFileSync(path.join(tempDirectoryPath, 'metadata.json'), JSON.stringify({ collectionPath })); + + const entry = { + state: new Map(), + collectionPath, + tempDirectoryPath, + brunoConfig, + win, + emit + }; + this.#mounts.set(collectionUid, entry); + + entry.emit.loading(true); + try { + entry.state = this.#getIndex().entries(collectionPath); + await this.#reconcile(entry); + await this.#emitTree(collectionUid, entry); + + // skip the startup walk (already done) and stage live edits into the cache + const collectionWatcher = require('../../app/collection-watcher'); + collectionWatcher.addWatcher(entry.win, collectionPath, collectionUid, brunoConfig, false, false, { + ignoreInitial: true, + fileIndex: this.#getIndex() + }); + collectionWatcher.addTempDirectoryWatcher(entry.win, tempDirectoryPath, collectionUid, collectionPath); + } finally { + entry.emit.loading(false); + } + return tempDirectoryPath; + } + + async unmount(collectionUid) { + const entry = this.#mounts.get(collectionUid); + if (!entry) return; + this.#mounts.delete(collectionUid); + const collectionWatcher = require('../../app/collection-watcher'); + try { + collectionWatcher.removeWatcher(entry.collectionPath, entry.win, collectionUid); + } catch (_) {} + } + + async shutdown() { + await Promise.all( + Array.from(this.#mounts.keys()).map((uid) => this.unmount(uid).catch(() => {})) + ); + await destroyPool().catch(() => {}); + if (this.#index) { + this.#index.close(); + this.#index = null; + } + } + + getCacheSize() { + try { + return fs.statSync(this.#getIndex().dbPath).size; + } catch (err) { + if (err && err.code === 'ENOENT') return 0; + throw err; + } + } + + clearCache() { + this.#getIndex().clear(); + } + + async #reconcile(entry) { + const denylist = entry.brunoConfig?.ignore || []; + const { added, updated, removed } = await this.#getIndex().status(entry.collectionPath, { denylist }); + + const toParse = []; + for (const e of [...added, ...updated]) { + const cls = defaultClassify(e.relativePath); + if (!cls) continue; + toParse.push({ relativePath: e.relativePath, format: cls.format, type: cls.type }); + } + + const parsed = new Map(); + if (toParse.length > 0) { + const pool = getPool(); + await Promise.allSettled( + toParse.map(async (e) => { + try { + const result = await pool.run(JobType.ParseFile, { + collectionPath: entry.collectionPath, + relativePath: e.relativePath, + format: e.format, + type: e.type + }); + parsed.set(e.relativePath, result); + } catch (err) { + parsed.set(e.relativePath, { + relativePath: e.relativePath, + error: { message: err.message, stack: err.stack } + }); + } + }) + ); + } + + this.#getIndex().transaction(() => { + for (const e of toParse) { + const result = parsed.get(e.relativePath); + if (!result) continue; + if (result.error) { + entry.state.set(e.relativePath, { error: result.error }); + continue; + } + entry.state.set(e.relativePath, { data: result.data }); + this.#getIndex().stage(entry.collectionPath, { + op: 'add', + relativePath: e.relativePath, + mtime: result.mtime, + hash: result.hash, + data: result.data + }); + } + for (const e of removed) { + entry.state.delete(e.relativePath); + this.#getIndex().stage(entry.collectionPath, { op: 'remove', relativePath: e.relativePath }); + } + }); + } + + async #emitTree(collectionUid, entry) { + const { getRequestUid } = require('../../cache/requestUids'); + const tree = buildTree(entry.collectionPath, entry.state, { uidFor: getRequestUid }); + await sendTree(collectionUid, entry.collectionPath, tree, entry.emit); + } + + #getIndex() { + if (!this.#index) this.#index = new FileIndex({}); + return this.#index; + } +} + +module.exports = { MountManager }; diff --git a/packages/bruno-electron/src/services/mount/tree-builder.js b/packages/bruno-electron/src/services/mount/tree-builder.js new file mode 100644 index 000000000..57061f9a8 --- /dev/null +++ b/packages/bruno-electron/src/services/mount/tree-builder.js @@ -0,0 +1,237 @@ +const path = require('node:path'); +const { + posixifyPath, + idForAbsolutePath, + uidForSeed, + defaultClassify +} = require('../../utils/mount'); + +const REQUEST_EXT_RE = /\.(bru|yml|yaml)$/i; +const stripExt = (basename) => basename.replace(REQUEST_EXT_RE, ''); + +const isSeqValid = (seq) => Number.isFinite(seq) && Number.isInteger(seq) && seq > 0; + +// lists that get deterministic uids; section tag keeps reorders within one list local +const REQUEST_UID_PATHS = [ + ['request.params', 'params'], + ['request.headers', 'headers'], + ['request.vars.req', 'vars.req'], + ['request.vars.res', 'vars.res'], + ['request.assertions', 'assertions'], + ['request.body.formUrlEncoded', 'body.formUrlEncoded'], + ['request.body.multipartForm', 'body.multipartForm'], + ['request.body.file', 'body.file'] +]; + +const EXAMPLE_UID_PATHS = [ + ['request.params', 'params'], + ['request.headers', 'headers'], + ['request.body.formUrlEncoded', 'body.formUrlEncoded'], + ['request.body.multipartForm', 'body.multipartForm'], + ['request.body.file', 'body.file'], + ['response.headers', 'response.headers'] +]; + +const hydrateListAt = (root, dotPath, seed, section) => { + let cur = root; + for (const p of dotPath.split('.')) { + if (!cur || typeof cur !== 'object') return; + cur = cur[p]; + } + if (!Array.isArray(cur)) return; + cur.forEach((item, i) => { + item.uid = uidForSeed(`${seed}#${section}#${i}`); + }); +}; + +const hydrateRequestUuids = (data, parentUid, absolutePath) => { + if (!data || typeof data !== 'object') return; + const seed = posixifyPath(absolutePath); + for (const [p, sec] of REQUEST_UID_PATHS) hydrateListAt(data, p, seed, sec); + if (!Array.isArray(data.examples)) return; + data.examples.forEach((example, i) => { + const exSeed = `${seed}#example#${i}`; + example.uid = uidForSeed(exSeed); + if (parentUid) example.itemUid = parentUid; + for (const [p, sec] of EXAMPLE_UID_PATHS) hydrateListAt(example, p, exSeed, sec); + }); +}; + +// alpha by name, then any folder with a valid seq pins to that position (v1 quirk) +const sortByNameThenSequence = (items) => { + const sorted = [...items].sort((a, b) => + a.name && b.name ? a.name.localeCompare(b.name) : 0 + ); + const withoutSeq = sorted.filter((f) => !isSeqValid(f.seq)); + const withSeq = sorted.filter((f) => isSeqValid(f.seq)).sort((a, b) => a.seq - b.seq); + + withSeq.forEach((item) => { + const existing = withoutSeq[item.seq - 1]; + const collides = Array.isArray(existing) + ? existing[0]?.seq === item.seq + : existing?.seq === item.seq; + if (collides) { + const group = Array.isArray(existing) ? [...existing, item] : [existing, item]; + withoutSeq.splice(item.seq - 1, 1, group); + } else { + withoutSeq.splice(item.seq - 1, 0, item); + } + }); + return withoutSeq.flat(); +}; + +const sortLevel = (items) => { + const folders = items.filter((i) => i.type === 'folder'); + const requests = items.filter((i) => i.type !== 'folder'); + const sortedFolders = sortByNameThenSequence(folders); + for (const f of sortedFolders) f.items = sortLevel(f.items); + const sortedRequests = [...requests].sort((a, b) => (a.seq ?? 1) - (b.seq ?? 1)); + return [...sortedFolders, ...sortedRequests]; +}; + +const ensureFolder = (collectionPath, items, segments, uidFor) => { + let cursor = items; + let acc = ''; + let last = null; + for (const seg of segments) { + acc = acc ? path.join(acc, seg) : seg; + const absolutePath = path.join(collectionPath, acc); + let folder = cursor.find((i) => i.type === 'folder' && i.filename === seg); + if (!folder) { + folder = { + uid: uidFor(absolutePath), + name: seg, + filename: seg, + pathname: absolutePath, + type: 'folder', + collapsed: true, + items: [] + }; + cursor.push(folder); + } + cursor = folder.items; + last = folder; + } + return { cursor, folder: last }; +}; + +const buildRequestNode = (absolutePath, basename, entry, uidOverrides, uidFor) => { + const uid = uidOverrides?.get(absolutePath) || uidFor(absolutePath); + const data = entry.data || {}; + hydrateRequestUuids(data, uid, absolutePath); + return { + uid, + name: data.name || stripExt(basename), + type: data.type || 'http-request', + seq: data.seq, + tags: data.tags, + request: data.request, + settings: data.settings, + examples: data.examples, + filename: basename, + pathname: absolutePath, + draft: null, + partial: false, + loading: false, + ...(entry.error ? { error: entry.error, partial: true } : {}) + }; +}; + +const buildEnvironmentNode = (collectionPath, relativePath, entry, uidFor) => { + const basename = path.basename(relativePath); + const absolutePath = path.join(collectionPath, relativePath); + const data = entry.data || {}; + return { + uid: uidFor(absolutePath), + name: stripExt(basename), + variables: data.variables || [], + ...(entry.error ? { error: entry.error } : {}) + }; +}; + +const buildTree = (collectionPath, parserResults, options = {}) => { + const uidOverrides = options.uidOverrides; + const uidFor = options.uidFor || idForAbsolutePath; + const transientEntries = options.transientEntries || []; + + const tree = { + pathname: collectionPath, + brunoConfig: null, + root: null, + items: [], + environments: [] + }; + + const folderRoots = new Map(); + const requests = []; + + for (const [relativePath, entry] of parserResults) { + const cls = defaultClassify(relativePath); + if (!cls) continue; + + if (cls.type === 'config') { + tree.brunoConfig = entry.error ? null : entry.data; + } else if (cls.type === 'collection') { + if (!entry.error) { + const data = entry.data; + if (data && typeof data === 'object' && 'collectionRoot' in data && 'brunoConfig' in data) { + hydrateRequestUuids(data.collectionRoot, null, collectionPath); + tree.root = data.collectionRoot; + tree.brunoConfig = data.brunoConfig; + } else { + hydrateRequestUuids(data, null, collectionPath); + tree.root = data; + } + } + } else if (cls.type === 'folder') { + folderRoots.set(path.dirname(relativePath), entry); + } else if (cls.type === 'environment') { + tree.environments.push(buildEnvironmentNode(collectionPath, relativePath, entry, uidFor)); + } else { + requests.push({ relativePath, entry }); + } + } + + for (const { relativePath, entry } of requests) { + const segments = path.dirname(relativePath).split(path.sep).filter((s) => s && s !== '.'); + const { cursor } = ensureFolder(collectionPath, tree.items, segments, uidFor); + cursor.push(buildRequestNode( + path.join(collectionPath, relativePath), + path.basename(relativePath), + entry, + uidOverrides, + uidFor + )); + } + + for (const [dirRel, entry] of folderRoots) { + const segments = dirRel.split(path.sep).filter((s) => s && s !== '.'); + const { folder } = ensureFolder(collectionPath, tree.items, segments, uidFor); + if (!folder) continue; + const meta = entry.data?.meta || {}; + if (meta.name) folder.name = meta.name; + if (isSeqValid(meta.seq)) folder.seq = meta.seq; + if (!entry.error) { + hydrateRequestUuids(entry.data, folder.uid, folder.pathname); + folder.root = entry.data; + } + } + + for (const t of transientEntries) { + if (!t || !t.absolutePath) continue; + const node = buildRequestNode( + t.absolutePath, + path.basename(t.absolutePath), + t, + uidOverrides, + uidFor + ); + node.isTransient = true; + tree.items.push(node); + } + + tree.items = sortLevel(tree.items); + return tree; +}; + +module.exports = { buildTree }; diff --git a/packages/bruno-electron/src/services/pool/index.js b/packages/bruno-electron/src/services/pool/index.js new file mode 100644 index 000000000..57038962e --- /dev/null +++ b/packages/bruno-electron/src/services/pool/index.js @@ -0,0 +1,45 @@ +const workerpool = require('workerpool'); +const os = require('node:os'); +const path = require('node:path'); + +const JobType = Object.freeze({ + ParseFile: 'parse-file' +}); + +const WORKER_FILE = path.join(__dirname, 'worker.js'); + +class Pool { + #pool; + + constructor({ size } = {}) { + const workers = Math.max(1, size ?? os.availableParallelism()); + this.#pool = workerpool.pool(WORKER_FILE, { + maxWorkers: workers, + workerType: 'thread' + }); + } + + run(type, args) { + return this.#pool.exec(type, [args]); + } + + async destroy() { + await this.#pool.terminate(); + } +} + +let shared = null; + +const getPool = (options) => { + if (!shared) shared = new Pool(options); + return shared; +}; + +const destroyPool = async () => { + if (!shared) return; + const pool = shared; + shared = null; + await pool.destroy(); +}; + +module.exports = { Pool, getPool, destroyPool, JobType }; diff --git a/packages/bruno-electron/src/services/pool/jobs/parse-file.js b/packages/bruno-electron/src/services/pool/jobs/parse-file.js new file mode 100644 index 000000000..a837f083f --- /dev/null +++ b/packages/bruno-electron/src/services/pool/jobs/parse-file.js @@ -0,0 +1,34 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const filestore = require('@usebruno/filestore'); + +const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex'); + +const parseContent = (content, format, type) => { + if (type === 'config' || format === 'json') return JSON.parse(content); + const options = { format }; + switch (type) { + case 'request': + return filestore.parseRequest(content, options); + case 'collection': + return filestore.parseCollection(content, options); + case 'folder': + return filestore.parseFolder(content, options); + case 'environment': + return filestore.parseEnvironment(content, options); + default: + throw new Error(`Unknown type: ${type}`); + } +}; + +const parseFile = ({ collectionPath, relativePath, format, type }) => { + const absolutePath = path.join(collectionPath, relativePath); + const buf = fs.readFileSync(absolutePath); + const stat = fs.statSync(absolutePath, { bigint: true }); + const content = buf.toString('utf8'); + const data = parseContent(content, format, type); + return { relativePath, mtime: stat.mtimeNs, hash: sha256(buf), data, format, type }; +}; + +module.exports = parseFile; diff --git a/packages/bruno-electron/src/services/pool/worker.js b/packages/bruno-electron/src/services/pool/worker.js new file mode 100644 index 000000000..3ad7096cd --- /dev/null +++ b/packages/bruno-electron/src/services/pool/worker.js @@ -0,0 +1,7 @@ +const workerpool = require('workerpool'); +const parseFile = require('./jobs/parse-file'); +const { JobType } = require('./index'); + +workerpool.worker({ + [JobType.ParseFile]: parseFile +}); diff --git a/packages/bruno-electron/src/services/storage/index.js b/packages/bruno-electron/src/services/storage/index.js new file mode 100644 index 000000000..d9f9322fc --- /dev/null +++ b/packages/bruno-electron/src/services/storage/index.js @@ -0,0 +1,129 @@ +const { DatabaseSync } = require('node:sqlite'); + +// Lazy SQLite connection. The db is opened on first use, pragmas are applied, +// pending migrations are run (tracked via PRAGMA user_version), and prepared +// statements are cached by their SQL text. +class Database { + #db = null; + #path; + #migrations; + #pragmas; + #readBigInts; + #statements = new Map(); + + constructor({ path, migrations = [], pragmas, readBigInts = false }) { + this.#path = path; + this.#migrations = migrations; + this.#pragmas = pragmas; + this.#readBigInts = readBigInts; + this.#validateMigrations(); + } + + get path() { + return typeof this.#path === 'function' ? this.#path() : this.#path; + } + + get schemaVersion() { + const row = this.#connect().prepare('PRAGMA user_version').get(); + return row.user_version; + } + + setPath(p) { + this.close(); + this.#path = p; + } + + prepare(sql) { + const db = this.#connect(); + let stmt = this.#statements.get(sql); + if (!stmt) { + stmt = db.prepare(sql); + if (this.#readBigInts) stmt.setReadBigInts(true); + this.#statements.set(sql, stmt); + } + return stmt; + } + + run(sql, ...params) { + return this.prepare(sql).run(...params); + } + + get(sql, ...params) { + return this.prepare(sql).get(...params); + } + + all(sql, ...params) { + return this.prepare(sql).all(...params); + } + + exec(sql) { + this.#connect().exec(sql); + } + + transaction(fn) { + const db = this.#connect(); + db.exec('BEGIN'); + try { + const result = fn(); + db.exec('COMMIT'); + return result; + } catch (err) { + db.exec('ROLLBACK'); + throw err; + } + } + + close() { + if (!this.#db) return; + this.#statements.clear(); + this.#db.close(); + this.#db = null; + } + + #connect() { + if (this.#db) return this.#db; + const db = new DatabaseSync(this.path); + if (this.#pragmas) { + for (const [key, value] of Object.entries(this.#pragmas)) { + db.exec(`PRAGMA ${key} = ${value};`); + } + } + this.#migrate(db); + this.#db = db; + return db; + } + + #migrate(db) { + if (!this.#migrations.length) return; + const sorted = [...this.#migrations].sort((a, b) => a.version - b.version); + let current = db.prepare('PRAGMA user_version').get().user_version; + for (const migration of sorted) { + if (migration.version <= current) continue; + db.exec('BEGIN'); + try { + db.exec(migration.up); + db.exec(`PRAGMA user_version = ${migration.version}`); + db.exec('COMMIT'); + } catch (err) { + db.exec('ROLLBACK'); + throw err; + } + current = migration.version; + } + } + + #validateMigrations() { + const seen = new Set(); + for (const { version } of this.#migrations) { + if (!Number.isInteger(version) || version < 1) { + throw new Error(`Migration version must be a positive integer, got ${version}`); + } + if (seen.has(version)) { + throw new Error(`Duplicate migration version ${version}`); + } + seen.add(version); + } + } +} + +module.exports = { Database }; diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index 235243629..1e9df37c8 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -68,6 +68,9 @@ const defaultPreferences = { cache: { sslSession: { enabled: false + }, + file: { + enabled: false } }, ai: { @@ -145,6 +148,9 @@ const preferencesSchema = Yup.object().shape({ cache: Yup.object({ sslSession: Yup.object({ enabled: Yup.boolean() + }), + file: Yup.object({ + enabled: Yup.boolean() }) }).optional(), ai: Yup.object({ @@ -352,6 +358,9 @@ const preferencesUtil = { isSslSessionCachingEnabled: () => { return get(getPreferences(), 'cache.sslSession.enabled', false); }, + isFileCacheEnabled: () => { + return get(getPreferences(), 'cache.file.enabled', false); + }, hasLaunchedBefore: () => { return get(getPreferences(), 'onboarding.hasLaunchedBefore', false); }, diff --git a/packages/bruno-electron/src/utils/mount.js b/packages/bruno-electron/src/utils/mount.js new file mode 100644 index 000000000..30c466258 --- /dev/null +++ b/packages/bruno-electron/src/utils/mount.js @@ -0,0 +1,92 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { posixifyPath } = require('./filesystem'); + +const DENY_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', '.bruno']); +const DEFAULT_DENYLIST = ['**/.DS_Store', '**/Thumbs.db']; + +const sha256 = (input) => crypto.createHash('sha256').update(input).digest('hex'); +const hashFile = (absPath) => sha256(fs.readFileSync(absPath)); +const hashFileAsync = async (absPath) => sha256(await fs.promises.readFile(absPath)); +const normalize = (p) => path.resolve(p); +const idForAbsolutePath = (absolutePath) => sha256(posixifyPath(absolutePath)).slice(0, 21); +const uidForSeed = (seed) => sha256(seed).slice(0, 21); + +const resolveDenylist = (patterns) => [...DEFAULT_DENYLIST, ...(patterns || [])]; + +const isDenied = (relativePathPosix, patterns) => { + for (const pattern of patterns) { + if (path.matchesGlob(relativePathPosix, pattern)) return true; + } + return false; +}; + +const walk = (root, denylist) => { + const out = []; + const visit = (absDir, relDir) => { + const entries = fs.readdirSync(absDir, { withFileTypes: true }); + for (const entry of entries) { + const childAbs = path.join(absDir, entry.name); + const childRel = relDir ? path.join(relDir, entry.name) : entry.name; + if (entry.isDirectory()) { + if (DENY_DIRS.has(entry.name)) continue; + visit(childAbs, childRel); + } else if (entry.isFile()) { + if (isDenied(posixifyPath(childRel), denylist)) continue; + out.push({ relativePath: childRel, absolutePath: childAbs }); + } + } + }; + visit(root, ''); + return out; +}; + +const COLLECTION_ROOT_BASENAMES = new Set(['collection.bru', 'collection.yml', 'opencollection.yml']); +const FOLDER_ROOT_BASENAMES = new Set(['folder.bru', 'folder.yml']); +const BRUNO_CONFIG_BASENAME = 'bruno.json'; +const ENVIRONMENTS_DIR = 'environments'; + +const defaultClassify = (relativePath) => { + const basename = path.basename(relativePath); + const dirname = path.dirname(relativePath); + const segments = dirname === '.' || dirname === '' ? [] : dirname.split(path.sep).filter(Boolean); + + if (basename === BRUNO_CONFIG_BASENAME && segments.length === 0) { + return { format: 'json', type: 'config' }; + } + + const ext = path.extname(basename).slice(1).toLowerCase(); + let format; + if (ext === 'bru') format = 'bru'; + else if (ext === 'yml' || ext === 'yaml') format = 'yml'; + else return null; + + if (COLLECTION_ROOT_BASENAMES.has(basename) && segments.length === 0) { + return { format, type: 'collection' }; + } + if (FOLDER_ROOT_BASENAMES.has(basename)) { + return { format, type: 'folder' }; + } + if (segments[0] === ENVIRONMENTS_DIR && segments.length === 1) { + return { format, type: 'environment' }; + } + return { format, type: 'request' }; +}; + +module.exports = { + COLLECTION_ROOT_BASENAMES, + FOLDER_ROOT_BASENAMES, + BRUNO_CONFIG_BASENAME, + ENVIRONMENTS_DIR, + hashFile, + hashFileAsync, + normalize, + posixifyPath, + idForAbsolutePath, + uidForSeed, + resolveDenylist, + isDenied, + walk, + defaultClassify +}; diff --git a/tests/preferences/tab-switch-persistence/tab-switch-persistence.spec.ts b/tests/preferences/tab-switch-persistence/tab-switch-persistence.spec.ts index 12f089922..c8e01d736 100644 --- a/tests/preferences/tab-switch-persistence/tab-switch-persistence.spec.ts +++ b/tests/preferences/tab-switch-persistence/tab-switch-persistence.spec.ts @@ -78,13 +78,14 @@ test.describe('Preferences Tab Switch Persistence', () => { await page.getByRole('tab', { name: 'Cache' }).click(); await page.waitForTimeout(300); - // Get the initial state of SSL session caching checkbox - const sslSessionCheckbox = page.locator('#sslSession\\.enabled'); - await sslSessionCheckbox.waitFor({ state: 'visible' }); + // Get the initial state of SSL session caching toggle + const sslSessionToggle = page.getByTestId('sslSession.enabled'); + const sslSessionCheckbox = sslSessionToggle.locator('input[type="checkbox"]'); + await sslSessionToggle.waitFor({ state: 'visible' }); const initialChecked = await sslSessionCheckbox.isChecked(); // Toggle the checkbox - await sslSessionCheckbox.click(); + await sslSessionToggle.click(); // Immediately switch to another tab await page.getByRole('tab', { name: 'Themes' }).click(); @@ -92,14 +93,14 @@ test.describe('Preferences Tab Switch Persistence', () => { // Switch back to Cache tab await page.getByRole('tab', { name: 'Cache' }).click(); - await sslSessionCheckbox.waitFor({ state: 'visible' }); + await sslSessionToggle.waitFor({ state: 'visible' }); // Verify the setting was persisted const newChecked = await sslSessionCheckbox.isChecked(); expect(newChecked).toBe(!initialChecked); // Restore original state - await sslSessionCheckbox.click(); + await sslSessionToggle.click(); await page.waitForTimeout(600); }); @@ -154,13 +155,14 @@ test.describe('Preferences Tab Switch Persistence', () => { await page.getByRole('tab', { name: 'Cache' }).click(); await page.waitForTimeout(300); - // Get the initial state of SSL session caching checkbox - const sslSessionCheckbox = page.locator('#sslSession\\.enabled'); - await sslSessionCheckbox.waitFor({ state: 'visible' }); + // Get the initial state of SSL session caching toggle + const sslSessionToggle = page.getByTestId('sslSession.enabled'); + const sslSessionCheckbox = sslSessionToggle.locator('input[type="checkbox"]'); + await sslSessionToggle.waitFor({ state: 'visible' }); const initialCacheState = await sslSessionCheckbox.isChecked(); // Toggle the checkbox - await sslSessionCheckbox.click(); + await sslSessionToggle.click(); // Close preferences tab immediately const preferencesTab = page.locator('.request-tab').filter({ hasText: 'Preferences' }); @@ -178,13 +180,13 @@ test.describe('Preferences Tab Switch Persistence', () => { // Navigate to Cache tab await page.getByRole('tab', { name: 'Cache' }).click(); await page.waitForTimeout(300); - await sslSessionCheckbox.waitFor({ state: 'visible' }); + await sslSessionToggle.waitFor({ state: 'visible' }); // Verify the setting was persisted expect(await sslSessionCheckbox.isChecked()).toBe(!initialCacheState); // Restore original state - await sslSessionCheckbox.click(); + await sslSessionToggle.click(); await page.waitForTimeout(600); }); });