filename support for requests and folders (#4111)

This commit is contained in:
lohit
2025-03-14 20:07:33 +05:30
committed by GitHub
parent 5ac52a531f
commit 9bde3c44f7
36 changed files with 1446 additions and 606 deletions

296
package-lock.json generated
View File

@@ -359,28 +359,6 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/@aws-sdk/core/node_modules/fast-xml-parser": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz",
"integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
},
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/@aws-sdk/credential-provider-env": { "node_modules/@aws-sdk/credential-provider-env": {
"version": "3.654.0", "version": "3.654.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.654.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.654.0.tgz",
@@ -1355,28 +1333,6 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@aws-sdk/nested-clients/node_modules/fast-xml-parser": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz",
"integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
},
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/@aws-sdk/region-config-resolver": { "node_modules/@aws-sdk/region-config-resolver": {
"version": "3.654.0", "version": "3.654.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.654.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.654.0.tgz",
@@ -1841,24 +1797,26 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.26.10", "version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz",
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.26.9", "@babel/template": "^7.25.9",
"@babel/types": "^7.26.10" "@babel/types": "^7.26.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.26.10", "version": "7.26.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz",
"integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.26.10" "@babel/types": "^7.26.3"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@@ -3185,9 +3143,10 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.26.10", "version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
"license": "MIT",
"dependencies": { "dependencies": {
"regenerator-runtime": "^0.14.0" "regenerator-runtime": "^0.14.0"
}, },
@@ -3196,13 +3155,14 @@
} }
}, },
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.26.9", "version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz",
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.26.2", "@babel/code-frame": "^7.25.9",
"@babel/parser": "^7.26.9", "@babel/parser": "^7.25.9",
"@babel/types": "^7.26.9" "@babel/types": "^7.25.9"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -3250,9 +3210,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.26.10", "version": "7.26.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz",
"integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.25.9", "@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9" "@babel/helper-validator-identifier": "^7.25.9"
@@ -8904,9 +8865,10 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.8.3", "version": "1.7.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==", "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
"license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@@ -8925,6 +8887,17 @@
"js-md4": "^0.3.2" "js-md4": "^0.3.2"
} }
}, },
"node_modules/axios-ntlm/node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -10996,6 +10969,20 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/config-file-ts/node_modules/typescript": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/console-browserify": { "node_modules/console-browserify": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz",
@@ -13147,6 +13134,28 @@
"integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fast-xml-parser": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz",
"integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
},
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastest-levenshtein": { "node_modules/fastest-levenshtein": {
"version": "1.0.16", "version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@@ -24155,16 +24164,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.2", "version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=14.17" "node": ">=4.2.0"
} }
}, },
"node_modules/uc.micro": { "node_modules/uc.micro": {
@@ -25122,6 +25132,7 @@
"version": "10.3.0", "version": "10.3.0",
"resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz",
"integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==", "integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@jsep-plugin/assignment": "^1.3.0", "@jsep-plugin/assignment": "^1.3.0",
"@jsep-plugin/regex": "^1.0.4", "@jsep-plugin/regex": "^1.0.4",
@@ -26223,26 +26234,15 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"packages/bruno-cli/node_modules/fast-xml-parser": { "packages/bruno-cli/node_modules/axios": {
"version": "4.4.1", "version": "1.8.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
},
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"strnum": "^1.0.5" "follow-redirects": "^1.15.6",
}, "form-data": "^4.0.0",
"bin": { "proxy-from-env": "^1.1.0"
"fxparser": "src/cli/cli.js"
} }
}, },
"packages/bruno-cli/node_modules/fs-extra": { "packages/bruno-cli/node_modules/fs-extra": {
@@ -26274,19 +26274,6 @@
"typescript": "^4.8.4" "typescript": "^4.8.4"
} }
}, },
"packages/bruno-common/node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"packages/bruno-electron": { "packages/bruno-electron": {
"name": "bruno", "name": "bruno",
"version": "v1.38.1", "version": "v1.38.1",
@@ -27364,26 +27351,15 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"packages/bruno-electron/node_modules/fast-xml-parser": { "packages/bruno-electron/node_modules/axios": {
"version": "4.4.1", "version": "1.8.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
},
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
}
],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"strnum": "^1.0.5" "follow-redirects": "^1.15.6",
}, "form-data": "^4.0.0",
"bin": { "proxy-from-env": "^1.1.0"
"fxparser": "src/cli/cli.js"
} }
}, },
"packages/bruno-electron/node_modules/fs-extra": { "packages/bruno-electron/node_modules/fs-extra": {
@@ -27469,19 +27445,6 @@
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
}, },
"packages/bruno-graphql-docs/node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"packages/bruno-js": { "packages/bruno-js": {
"name": "@usebruno/js", "name": "@usebruno/js",
"version": "0.12.0", "version": "0.12.0",
@@ -27522,6 +27485,17 @@
"@usebruno/vm2": "^3.9.13" "@usebruno/vm2": "^3.9.13"
} }
}, },
"packages/bruno-js/node_modules/axios": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-js/node_modules/nanoid": { "packages/bruno-js/node_modules/nanoid": {
"version": "3.3.8", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
@@ -27566,19 +27540,6 @@
"typescript": "^4.8.4" "typescript": "^4.8.4"
} }
}, },
"packages/bruno-query/node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"packages/bruno-schema": { "packages/bruno-schema": {
"name": "@usebruno/schema", "name": "@usebruno/schema",
"version": "0.7.0", "version": "0.7.0",
@@ -27626,10 +27587,22 @@
"multer": "^1.4.5-lts.1" "multer": "^1.4.5-lts.1"
} }
}, },
"packages/bruno-tests/node_modules/axios": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-tests/node_modules/cookie": { "packages/bruno-tests/node_modules/cookie": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -27638,6 +27611,7 @@
"version": "4.21.2", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -27680,22 +27654,18 @@
} }
}, },
"packages/bruno-tests/node_modules/fast-xml-parser": { "packages/bruno-tests/node_modules/fast-xml-parser": {
"version": "4.5.1", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.1.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.0.9.tgz",
"integrity": "sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w==", "integrity": "sha512-2mBwCiuW3ycKQQ6SOesSB8WeF+fIGb6I/GG5vU5/XEptwFFhp9PE8b9O7fbs2dpq9fXn4ULR3UsfydNUCntf5A==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence" "url": "https://github.com/sponsors/NaturalIntelligence"
},
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
} }
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"strnum": "^1.0.5" "strnum": "^2.0.5"
}, },
"bin": { "bin": {
"fxparser": "src/cli/cli.js" "fxparser": "src/cli/cli.js"
@@ -27704,12 +27674,14 @@
"packages/bruno-tests/node_modules/path-to-regexp": { "packages/bruno-tests/node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
}, },
"packages/bruno-tests/node_modules/qs": { "packages/bruno-tests/node_modules/qs": {
"version": "6.13.0", "version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.0.6" "side-channel": "^1.0.6"
}, },
@@ -27720,6 +27692,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"packages/bruno-tests/node_modules/strnum": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.0.5.tgz",
"integrity": "sha512-YAT3K/sgpCUxhxNMrrdhtod3jckkpYwH6JAuwmUdXZsmzH1wUyzTMrrK2wYCEEqlKwrWDd35NeuUkbBy/1iK+Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"packages/bruno-toml": { "packages/bruno-toml": {
"name": "@usebruno/toml", "name": "@usebruno/toml",
"version": "0.1.0", "version": "0.1.0",

View File

@@ -8,9 +8,7 @@ import { useState } from 'react';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { useRef } from 'react'; import { useRef } from 'react';
import path from 'path'; import path from 'utils/common/path';
import slash from 'utils/common/slash';
import { isWindowsOS } from 'utils/common/platform';
const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => { const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
const certFilePathInputRef = useRef(); const certFilePathInputRef = useRef();
@@ -70,12 +68,7 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
const getFile = (e) => { const getFile = (e) => {
const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]); const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]);
if (filePath) { if (filePath) {
let relativePath; let relativePath = path.relative(root, filePath);
if (isWindowsOS()) {
relativePath = slash(path.win32.relative(root, filePath));
} else {
relativePath = path.posix.relative(root, filePath);
}
formik.setFieldValue(e.name, relativePath); formik.setFieldValue(e.name, relativePath);
} }
}; };
@@ -109,23 +102,23 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
<ul className="mt-4"> <ul className="mt-4">
{!clientCertConfig.length {!clientCertConfig.length
? 'No client certificates added' ? 'No client certificates added'
: clientCertConfig.map((clientCert) => ( : clientCertConfig.map((clientCert, index) => (
<li key={uuid()} className="flex items-center available-certificates p-2 rounded-lg mb-2"> <li key={`client-cert-${index}`} className="flex items-center available-certificates p-2 rounded-lg mb-2">
<div className="flex items-center w-full justify-between"> <div className="flex items-center w-full justify-between">
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<IconWorld className="mr-2" size={18} strokeWidth={1.5} /> <IconWorld className="mr-2" size={18} strokeWidth={1.5} />
{clientCert.domain} {clientCert.domain}
</div>
<div className="flex w-full items-center">
<IconCertificate className="mr-2 flex-shrink-0" size={18} strokeWidth={1.5} />
{clientCert.type === 'cert' ? clientCert.certFilePath : clientCert.pfxFilePath}
</div>
<button onClick={() => onRemove(clientCert)} className="remove-certificate ml-2">
<IconTrash size={18} strokeWidth={1.5} />
</button>
</div> </div>
</li> <div className="flex w-full items-center">
))} <IconCertificate className="mr-2 flex-shrink-0" size={18} strokeWidth={1.5} />
{clientCert.type === 'cert' ? clientCert.certFilePath : clientCert.pfxFilePath}
</div>
<button onClick={() => onRemove(clientCert)} className="remove-certificate ml-2">
<IconTrash size={18} strokeWidth={1.5} />
</button>
</div>
</li>
))}
</ul> </ul>
<h1 className="font-semibold mt-8 mb-2">Add Client Certificate</h1> <h1 className="font-semibold mt-8 mb-2">Add Client Certificate</h1>
@@ -198,9 +191,9 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<div <div
className="my-[3px] overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]" className="my-[3px] overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]"
title={path.basename(slash(formik.values.certFilePath))} title={path.basename(formik.values.certFilePath)}
> >
{path.basename(slash(formik.values.certFilePath))} {path.basename(formik.values.certFilePath)}
</div> </div>
<IconTrash <IconTrash
size={18} size={18}
@@ -238,9 +231,9 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<div <div
className="my-[3px] overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]" className="my-[3px] overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]"
title={path.basename(slash(formik.values.keyFilePath))} title={path.basename(formik.values.keyFilePath)}
> >
{path.basename(slash(formik.values.keyFilePath))} {path.basename(formik.values.keyFilePath)}
</div> </div>
<IconTrash <IconTrash
size={18} size={18}
@@ -281,9 +274,9 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<div <div
className="my-[3px] overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]" className="my-[3px] overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]"
title={path.basename(slash(formik.values.pfxFilePath))} title={path.basename(formik.values.pfxFilePath)}
> >
{path.basename(slash(formik.values.pfxFilePath))} {path.basename(formik.values.pfxFilePath)}
</div> </div>
<IconTrash <IconTrash
size={18} size={18}

View File

@@ -6,6 +6,7 @@ import * as Yup from 'yup';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import Portal from 'components/Portal'; import Portal from 'components/Portal';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ collection, onClose }) => { const CreateEnvironment = ({ collection, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -23,7 +24,11 @@ const CreateEnvironment = ({ collection, onClose }) => {
validationSchema: Yup.object({ validationSchema: Yup.object({
name: Yup.string() name: Yup.string()
.min(1, 'Must be at least 1 character') .min(1, 'Must be at least 1 character')
.max(50, 'Must be 50 characters or less') .max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('Name is required') .required('Name is required')
.test('duplicate-name', 'Environment already exists', validateEnvironmentName) .test('duplicate-name', 'Environment already exists', validateEnvironmentName)
}), }),

View File

@@ -6,6 +6,7 @@ import { useFormik } from 'formik';
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions'; import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { validateName, validateNameError } from 'utils/common/regex';
const RenameEnvironment = ({ onClose, environment, collection }) => { const RenameEnvironment = ({ onClose, environment, collection }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -18,7 +19,11 @@ const RenameEnvironment = ({ onClose, environment, collection }) => {
validationSchema: Yup.object({ validationSchema: Yup.object({
name: Yup.string() name: Yup.string()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less') .max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('name is required') .required('name is required')
}), }),
onSubmit: (values) => { onSubmit: (values) => {

View File

@@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import path from 'path'; import path from 'utils/common/path';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import { IconX } from '@tabler/icons'; import { IconX } from '@tabler/icons';
import { isWindowsOS } from 'utils/common/platform'; import { isWindowsOS } from 'utils/common/platform';
import slash from 'utils/common/slash';
const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false }) => { const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -27,7 +26,7 @@ const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = fa
const collectionDir = collection.pathname; const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) { if (filePath.startsWith(collectionDir)) {
return path.relative(slash(collectionDir), slash(filePath)); return path.relative(collectionDir, filePath);
} }
return filePath; return filePath;

View File

@@ -6,6 +6,7 @@ import { useDispatch, useSelector } from 'react-redux';
import Portal from 'components/Portal'; import Portal from 'components/Portal';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ onClose }) => { const CreateEnvironment = ({ onClose }) => {
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments); const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
@@ -25,7 +26,11 @@ const CreateEnvironment = ({ onClose }) => {
validationSchema: Yup.object({ validationSchema: Yup.object({
name: Yup.string() name: Yup.string()
.min(1, 'Must be at least 1 character') .min(1, 'Must be at least 1 character')
.max(50, 'Must be 50 characters or less') .max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('Name is required') .required('Name is required')
.test('duplicate-name', 'Global Environment already exists', validateEnvironmentName) .test('duplicate-name', 'Global Environment already exists', validateEnvironmentName)
}), }),

View File

@@ -3,10 +3,10 @@ import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index'; import Modal from 'components/Modal/index';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
const RenameEnvironment = ({ onClose, environment }) => { const RenameEnvironment = ({ onClose, environment }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -19,7 +19,11 @@ const RenameEnvironment = ({ onClose, environment }) => {
validationSchema: Yup.object({ validationSchema: Yup.object({
name: Yup.string() name: Yup.string()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less') .max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('name is required') .required('name is required')
}), }),
onSubmit: (values) => { onSubmit: (values) => {

View File

@@ -0,0 +1,22 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.path-display {
background: ${(props) => props.theme.requestTabPanel.url.bg};
border-radius: 4px;
padding: 8px 12px;
.filename {
color: ${(props) => props.theme.brand};
font-weight: 500;
min-height: 1.25rem;
}
.file-extension {
color: ${(props) => props.theme.text};
opacity: 0.5;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { IconEdit, IconFolder, IconFile } from '@tabler/icons';
import path from 'utils/common/path';
import StyledWrapper from './StyledWrapper';
const PathDisplay = ({
collection,
item,
filename,
extension = '.bru',
showExtension = true,
toggleEditingFilename,
showDirectory = false
}) => {
const relativePath = item?.pathname && path.relative(collection?.pathname, showDirectory ? path.dirname(item?.pathname) : item?.pathname);
const pathSegments = relativePath?.split(path.sep).filter(Boolean);
return (
<StyledWrapper>
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<label className="block font-semibold">Location Path</label>
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(true)}
/>
</div>
<div className="path-display">
<div className="flex flex-wrap items-center gap-1 text-sm">
<div className="flex items-center gap-1">
{showExtension ? <IconFile size={16} className="text-gray-500" /> : <IconFolder size={16} className="text-gray-500" />}
<span className="font-medium">{collection?.name}</span>
</div>
{pathSegments?.length > 0 && pathSegments?.map((segment, index) => (
<div key={index} className="flex items-center gap-1">
<span className="text-gray-400">/</span>
<span>{segment}</span>
</div>
))}
<div className="flex items-center gap-1">
{collection && <span className="text-gray-400">/</span>}
<span className="filename">
{filename}
{showExtension && filename?.length ? (
<span className="file-extension">{extension}</span>
) : null}
</span>
</div>
</div>
</div>
</div>
</StyledWrapper>
);
};
export default PathDisplay;

View File

@@ -6,8 +6,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup'; import * as Yup from 'yup';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import path from 'path'; import path from 'utils/common/path';
import slash from 'utils/common/slash';
import { IconTrash } from '@tabler/icons'; import { IconTrash } from '@tabler/icons';
const General = ({ close }) => { const General = ({ close }) => {
@@ -134,7 +133,7 @@ const General = ({ close }) => {
className={`flex items-center mt-2 pl-6 ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`} className={`flex items-center mt-2 pl-6 ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
> >
<span className="flex items-center border px-2 rounded-md"> <span className="flex items-center border px-2 rounded-md">
{path.basename(slash(formik.values.customCaCertificate.filePath))} {path.basename(formik.values.customCaCertificate.filePath)}
<button <button
type="button" type="button"
tabIndex="-1" tabIndex="-1"

View File

@@ -1,24 +1,19 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import path from 'path'; import path from 'utils/common/path';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { get, cloneDeep } from 'lodash'; import { get, cloneDeep } from 'lodash';
import { runCollectionFolder, cancelRunnerExecution } from 'providers/ReduxStore/slices/collections/actions'; import { runCollectionFolder, cancelRunnerExecution } from 'providers/ReduxStore/slices/collections/actions';
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections'; import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections'; import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons'; import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons';
import slash from 'utils/common/slash';
import ResponsePane from './ResponsePane'; import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections'; import { areItemsLoading } from 'utils/collections';
const getDisplayName = (fullPath, pathname, name) => { const getDisplayName = (fullPath, pathname, name = '') => {
// convert to unix style path
fullPath = slash(fullPath);
pathname = slash(pathname);
let relativePath = path.relative(fullPath, pathname); let relativePath = path.relative(fullPath, pathname);
const { dir } = path.parse(relativePath); const { dir = '' } = path.parse(relativePath);
return [dir, name].filter(i => i).join('/'); return path.join(dir, name);
}; };
export default function RunnerResults({ collection }) { export default function RunnerResults({ collection }) {

View File

@@ -5,12 +5,16 @@ import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions'; import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import InfoTip from 'components/InfoTip';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import PathDisplay from 'components/PathDisplay/index';
import { useState } from 'react';
import { IconArrowBackUp } from "@tabler/icons";
const CloneCollection = ({ onClose, collection }) => { const CloneCollection = ({ onClose, collection }) => {
const inputRef = useRef(); const inputRef = useRef();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const formik = useFormik({ const formik = useFormik({
enableReinitialize: true, enableReinitialize: true,
@@ -22,12 +26,15 @@ const CloneCollection = ({ onClose, collection }) => {
validationSchema: Yup.object({ validationSchema: Yup.object({
collectionName: Yup.string() collectionName: Yup.string()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less') .max(255, 'must be 255 characters or less')
.required('collection name is required'), .required('collection name is required'),
collectionFolderName: Yup.string() collectionFolderName: Yup.string()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less') .max(255, 'must be 255 characters or less')
.matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters') .test('is-valid-dir-name', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('folder name is required'), .required('folder name is required'),
collectionLocation: Yup.string().min(1, 'location is required').required('location is required') collectionLocation: Yup.string().min(1, 'location is required').required('location is required')
}), }),
@@ -85,9 +92,7 @@ const CloneCollection = ({ onClose, collection }) => {
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full"
onChange={(e) => { onChange={(e) => {
formik.handleChange(e); formik.handleChange(e);
if (formik.values.collectionName === formik.values.collectionFolderName) { !isEditingFilename && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
formik.setFieldValue('collectionFolderName', e.target.value);
}
}} }}
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
@@ -123,26 +128,42 @@ const CloneCollection = ({ onClose, collection }) => {
Browse Browse
</span> </span>
</div> </div>
{isEditingFilename ?
<label htmlFor="collection-folder-name" className="flex items-center mt-3"> <>
<span className="font-semibold">Folder Name</span> <div className="mt-4">
<InfoTip <div className="flex items-center justify-between">
content="This folder will be created under the selected location" <label htmlFor="filename" className="block font-semibold">
infotipId="collection-folder-name-infotip" Directory Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
</div>
<input
id="collection-folder-name"
type="text"
name="collectionFolderName"
className="block textbox mt-2 w-full"
onChange={formik.handleChange}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionFolderName || ''}
/>
</div>
</>
:
<PathDisplay
filename={formik.values.collectionFolderName}
showExtension={false}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
/> />
</label> }
<input
id="collection-folder-name"
type="text"
name="collectionFolderName"
className="block textbox mt-2 w-full"
onChange={formik.handleChange}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionFolderName || ''}
/>
{formik.touched.collectionFolderName && formik.errors.collectionFolderName ? ( {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
<div className="text-red-500">{formik.errors.collectionFolderName}</div> <div className="text-red-500">{formik.errors.collectionFolderName}</div>
) : null} ) : null}

View File

@@ -1,4 +1,4 @@
import React, { useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
@@ -6,24 +6,42 @@ import Modal from 'components/Modal';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs'; import { isItemAFolder } from 'utils/tabs';
import { cloneItem } from 'providers/ReduxStore/slices/collections/actions'; import { cloneItem } from 'providers/ReduxStore/slices/collections/actions';
import { IconArrowBackUp } from '@tabler/icons';
import * as path from 'path';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import PathDisplay from 'components/PathDisplay/index';
const CloneCollectionItem = ({ collection, item, onClose }) => { const CloneCollectionItem = ({ collection, item, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isFolder = isItemAFolder(item); const isFolder = isItemAFolder(item);
const inputRef = useRef(); const inputRef = useRef();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const itemName = item?.name;
const itemType = item?.type;
const itemFilename = item?.filename ? path.parse(item?.filename).name : '';
const formik = useFormik({ const formik = useFormik({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
name: item.name name: itemName,
filename: sanitizeName(itemFilename)
}, },
validationSchema: Yup.object({ validationSchema: Yup.object({
name: Yup.string() name: Yup.string()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less') .max(255, 'must be 255 characters or less')
.required('name is required'),
filename: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'must be 255 characters or less')
.required('name is required') .required('name is required')
.test('is-valid-filename', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}), }),
onSubmit: (values) => { onSubmit: (values) => {
dispatch(cloneItem(values.name, item.uid, collection.uid)) dispatch(cloneItem(values.name, values.filename, item.uid, collection.uid))
.then(() => { .then(() => {
toast.success('Request cloned!'); toast.success('Request cloned!');
onClose(); onClose();
@@ -44,7 +62,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
return ( return (
<Modal <Modal
size="sm" size="md"
title={`Clone ${isFolder ? 'Folder' : 'Request'}`} title={`Clone ${isFolder ? 'Folder' : 'Request'}`}
confirmText="Clone" confirmText="Clone"
handleConfirm={onSubmit} handleConfirm={onSubmit}
@@ -66,11 +84,58 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
spellCheck="false" spellCheck="false"
onChange={formik.handleChange} onChange={e => {
formik.setFieldValue('name', e.target.value);
!isEditingFilename && formik.setFieldValue('filename', sanitizeName(e.target.value));
}}
value={formik.values.name || ''} value={formik.values.name || ''}
/> />
{formik.touched.name && formik.errors.name ? <div className="text-red-500">{formik.errors.name}</div> : null} {formik.touched.name && formik.errors.name ? <div className="text-red-500">{formik.errors.name}</div> : null}
</div> </div>
{isEditingFilename ? (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="block font-semibold">
{isFolder ? 'Directory' : 'File'} Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
</div>
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="file-name"
type="text"
name="filename"
placeholder="File Name"
className={`!pr-10 block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.filename || ''}
/>
{itemType !== 'folder' && <span className='absolute right-2 top-4 flex justify-center items-center file-extension'>.bru</span>}
</div>
</div>
) : (
<PathDisplay
collection={collection}
item={item}
filename={formik.values.filename}
showExtension={itemType !== 'folder'}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
showDirectory={true}
/>
)}
{formik.touched.filename && formik.errors.filename ? (
<div className="text-red-500">{formik.errors.filename}</div>
) : null}
</form> </form>
</Modal> </Modal>
); );

View File

@@ -0,0 +1,39 @@
import React from 'react';
import Modal from 'components/Modal';
import * as path from 'path';
const CollectionItemInfo = ({ collection, item, onClose }) => {
const { pathname: collectionPathname } = collection;
const { name, filename, pathname, type } = item;
const relativePathname = path.relative(collectionPathname, pathname);
return (
<Modal
size="md"
title={`Info`}
handleCancel={onClose}
hideCancel={true}
hideFooter={true}
>
<div className="w-fit flex flex-col h-full">
<table className="w-full border-collapse">
<tbody>
<tr className="">
<td className="py-2 px-2 text-right opacity-50">Name&nbsp;:</td>
<td className="py-2 px-2 text-nowrap truncate max-w-[500px]" title={name}>{name}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right opacity-50">{type=='folder' ? 'Directory Name' : 'File Name'}&nbsp;:</td>
<td className="py-2 px-2 break-all text-nowrap truncate max-w-[500px]" title={filename}>{filename}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right opacity-50">Pathname&nbsp;:</td>
<td className="py-2 px-2 break-all text-nowrap truncate max-w-[500px]" title={relativePathname}>{relativePathname}</td>
</tr>
</tbody>
</table>
</div>
</Modal>
);
};
export default CollectionItemInfo;

View File

@@ -1,46 +1,73 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs'; import { isItemAFolder } from 'utils/tabs';
import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import path from 'utils/common/path';
import { IconArrowBackUp } from '@tabler/icons';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { closeTabs } from 'providers/ReduxStore/slices/tabs'; import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import PathDisplay from 'components/PathDisplay';
const RenameCollectionItem = ({ collection, item, onClose }) => { const RenameCollectionItem = ({ collection, item, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isFolder = isItemAFolder(item); const isFolder = isItemAFolder(item);
const inputRef = useRef(); const inputRef = useRef();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const itemName = item?.name;
const itemType = item?.type;
const itemFilename = item?.filename ? path.parse(item?.filename).name : '';
const formik = useFormik({ const formik = useFormik({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
name: item.name name: itemName,
filename: sanitizeName(itemFilename)
}, },
validationSchema: Yup.object({ validationSchema: Yup.object({
name: Yup.string() name: Yup.string()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less') .max(255, 'must be 255 characters or less')
.required('name is required'),
filename: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'must be 255 characters or less')
.required('name is required') .required('name is required')
.test('is-valid-filename', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}), }),
onSubmit: async (values) => { onSubmit: async (values) => {
// if there is unsaved changes in the request, // if there is unsaved changes in the request,
// save them before renaming the request // save them before renaming the request
if ((item.name === values.name) && (itemFilename === values.filename)) {
return;
}
if (!isFolder && item.draft) { if (!isFolder && item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true)); await dispatch(saveRequest(item.uid, collection.uid, true));
} }
if (item.name === values.name) { const { name: newName, filename: newFilename } = values;
return; try {
let renameConfig = {
itemUid: item.uid,
collectionUid: collection.uid,
};
renameConfig['newName'] = newName;
if (itemFilename !== newFilename) {
renameConfig['newFilename'] = newFilename;
}
await dispatch(renameItem(renameConfig));
if (isFolder) {
dispatch(closeTabs({ tabUids: [item.uid] }));
}
onClose();
} catch (error) {
toast.error(error.message || 'An error occurred while renaming');
} }
dispatch(renameItem(values.name, item.uid, collection.uid))
.then(() => {
isFolder && dispatch(closeTabs({ tabUids: [item.uid] }));
toast.success(isFolder ? 'Folder renamed' : 'Request renamed');
onClose();
})
.catch((err) => {
toast.error(err ? err.message : 'An error occurred while renaming the request');
});
} }
}); });
@@ -54,14 +81,14 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
return ( return (
<Modal <Modal
size="sm" size="md"
title={`Rename ${isFolder ? 'Folder' : 'Request'}`} title={`Rename ${isFolder ? 'Folder' : 'Request'}`}
confirmText="Rename" confirmText="Rename"
handleConfirm={onSubmit} handleConfirm={onSubmit}
handleCancel={onClose} handleCancel={onClose}
> >
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}> <form className="bruno-form" onSubmit={e => {e.preventDefault()}}>
<div> <div className='flex flex-col mt-2'>
<label htmlFor="name" className="block font-semibold"> <label htmlFor="name" className="block font-semibold">
{isFolder ? 'Folder' : 'Request'} Name {isFolder ? 'Folder' : 'Request'} Name
</label> </label>
@@ -75,11 +102,59 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
spellCheck="false" spellCheck="false"
onChange={formik.handleChange} onChange={e => {
formik.setFieldValue('name', e.target.value);
!isEditingFilename && formik.setFieldValue('filename', sanitizeName(e.target.value));
}}
value={formik.values.name || ''} value={formik.values.name || ''}
/> />
{formik.touched.name && formik.errors.name ? <div className="text-red-500">{formik.errors.name}</div> : null} {formik.touched.name && formik.errors.name ? <div className="text-red-500">{formik.errors.name}</div> : null}
</div> </div>
{isEditingFilename ? (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="block font-semibold">
{isFolder ? 'Directory' : 'File'} Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
</div>
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="file-name"
type="text"
name="filename"
placeholder="File Name"
className={`!pr-10 block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.filename || ''}
/>
{itemType !== 'folder' && <span className='absolute right-2 top-4 flex justify-center items-center file-extension'>.bru</span>}
</div>
</div>
) : (
<PathDisplay
collection={collection}
item={item}
filename={formik.values.filename}
showExtension={itemType !== 'folder'}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
showDirectory={true}
/>
)}
{formik.touched.filename && formik.errors.filename ? (
<div className="text-red-500">{formik.errors.filename}</div>
) : null}
</form> </form>
</Modal> </Modal>
); );

View File

@@ -23,6 +23,7 @@ import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index'; import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import { findItemInCollection } from 'utils/collections'; import { findItemInCollection } from 'utils/collections';
import CollectionItemIcon from './CollectionItemIcon'; import CollectionItemIcon from './CollectionItemIcon';
import { scrollToTheActiveTab } from 'utils/tabs'; import { scrollToTheActiveTab } from 'utils/tabs';
@@ -41,7 +42,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false); const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false); const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
const hasSearchText = searchText && searchText?.trim()?.length; const hasSearchText = searchText && searchText?.trim()?.length;
const itemIsCollapsed = hasSearchText ? false : item.collapsed; const itemIsCollapsed = hasSearchText ? false : item.collapsed;
@@ -259,6 +260,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
{generateCodeItemModalOpen && ( {generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} /> <GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)} )}
{itemInfoModalOpen && (
<CollectionItemInfo item={item} collection={collection} onClose={() => setItemInfoModalOpen(false)} />
)}
<div className={itemRowClassName} ref={collectionItemRef}> <div className={itemRowClassName} ref={collectionItemRef}>
<div className="flex items-center h-full w-full"> <div className="flex items-center h-full w-full">
{indents && indents.length {indents && indents.length
@@ -413,6 +417,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
Settings Settings
</div> </div>
)} )}
<div
className="dropdown-item item-info"
onClick={(e) => {
dropdownTippyRef.current.hide();
setItemInfoModalOpen(true);
}}
>
Info
</div>
</Dropdown> </Dropdown>
</div> </div>
</div> </div>

View File

@@ -5,12 +5,16 @@ import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { createCollection } from 'providers/ReduxStore/slices/collections/actions'; import { createCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import InfoTip from 'components/InfoTip';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import PathDisplay from 'components/PathDisplay/index';
import { useState } from 'react';
import { IconArrowBackUp } from '@tabler/icons';
const CreateCollection = ({ onClose }) => { const CreateCollection = ({ onClose }) => {
const inputRef = useRef(); const inputRef = useRef();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const formik = useFormik({ const formik = useFormik({
enableReinitialize: true, enableReinitialize: true,
@@ -22,12 +26,15 @@ const CreateCollection = ({ onClose }) => {
validationSchema: Yup.object({ validationSchema: Yup.object({
collectionName: Yup.string() collectionName: Yup.string()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less') .max(255, 'must be 255 characters or less')
.required('collection name is required'), .required('collection name is required'),
collectionFolderName: Yup.string() collectionFolderName: Yup.string()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less') .max(255, 'must be 255 characters or less')
.matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters') .test('is-valid-dir-name', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('folder name is required'), .required('folder name is required'),
collectionLocation: Yup.string().min(1, 'location is required').required('location is required') collectionLocation: Yup.string().min(1, 'location is required').required('location is required')
}), }),
@@ -78,9 +85,7 @@ const CreateCollection = ({ onClose }) => {
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full"
onChange={(e) => { onChange={(e) => {
formik.handleChange(e); formik.handleChange(e);
if (formik.values.collectionName === formik.values.collectionFolderName) { !isEditingFilename && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
formik.setFieldValue('collectionFolderName', e.target.value);
}
}} }}
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
@@ -116,26 +121,42 @@ const CreateCollection = ({ onClose }) => {
Browse Browse
</span> </span>
</div> </div>
{isEditingFilename ?
<label htmlFor="collection-folder-name" className="flex items-center mt-3"> <>
<span className="font-semibold">Folder Name</span> <div className="mt-4">
<InfoTip <div className="flex items-center justify-between">
content="This folder will be created under the selected location" <label htmlFor="filename" className="block font-semibold">
infotipId="collection-folder-name-infotip" Directory Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
</div>
<input
id="collection-folder-name"
type="text"
name="collectionFolderName"
className="block textbox mt-2 w-full"
onChange={formik.handleChange}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionFolderName || ''}
/>
</div>
</>
:
<PathDisplay
filename={formik.values.collectionFolderName}
showExtension={false}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
/> />
</label> }
<input
id="collection-folder-name"
type="text"
name="collectionFolderName"
className="block textbox mt-2 w-full"
onChange={formik.handleChange}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionFolderName || ''}
/>
{formik.touched.collectionFolderName && formik.errors.collectionFolderName ? ( {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
<div className="text-red-500">{formik.errors.collectionFolderName}</div> <div className="text-red-500">{formik.errors.collectionFolderName}</div>
) : null} ) : null}

View File

@@ -1,40 +1,52 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import * as Yup from 'yup'; import * as Yup from 'yup';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { newFolder } from 'providers/ReduxStore/slices/collections/actions'; import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
import { IconArrowBackUp } from '@tabler/icons';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import PathDisplay from 'components/PathDisplay';
const NewFolder = ({ collection, item, onClose }) => { const NewFolder = ({ collection, item, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const inputRef = useRef(); const inputRef = useRef();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const formik = useFormik({ const formik = useFormik({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
folderName: '' folderName: '',
directoryName: ''
}, },
validationSchema: Yup.object({ validationSchema: Yup.object({
folderName: Yup.string() folderName: Yup.string()
.trim() .trim()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.required('name is required') .required('name is required'),
directoryName: Yup.string()
.trim()
.min(1, 'must be at least 1 character')
.required('foldername is required')
.test('is-valid-folder-name', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.test({ .test({
name: 'folderName', name: 'folderName',
message: 'The folder name "environments" at the root of the collection is reserved in bruno', message: 'The folder name "environments" at the root of the collection is reserved in bruno',
test: (value) => { test: (value) => {
if (item && item.uid) { if (item?.uid) return true;
return true;
}
return value && !value.trim().toLowerCase().includes('environments'); return value && !value.trim().toLowerCase().includes('environments');
} }
}) })
}), }),
onSubmit: (values) => { onSubmit: (values) => {
dispatch(newFolder(values.folderName, collection.uid, item ? item.uid : null)) dispatch(newFolder(values.folderName, values.directoryName, collection.uid, item ? item.uid : null))
.then(() => { .then(() => {
toast.success('New folder created!'); toast.success('New folder created!');
onClose() onClose();
}) })
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the folder')); .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the folder'));
} }
@@ -49,8 +61,8 @@ const NewFolder = ({ collection, item, onClose }) => {
const onSubmit = () => formik.handleSubmit(); const onSubmit = () => formik.handleSubmit();
return ( return (
<Modal size="sm" title="New Folder" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="md" title="New Folder" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={e => e.preventDefault()}> <form className="bruno-form" onSubmit={formik.handleSubmit}>
<div> <div>
<label htmlFor="folderName" className="block font-semibold"> <label htmlFor="folderName" className="block font-semibold">
Folder Name Folder Name
@@ -65,13 +77,59 @@ const NewFolder = ({ collection, item, onClose }) => {
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
spellCheck="false" spellCheck="false"
onChange={formik.handleChange} onChange={e => {
formik.setFieldValue('folderName', e.target.value);
!isEditingFilename && formik.setFieldValue('directoryName', sanitizeName(e.target.value));
}}
value={formik.values.folderName || ''} value={formik.values.folderName || ''}
/> />
{formik.touched.folderName && formik.errors.folderName ? ( {formik.touched.folderName && formik.errors.folderName ? (
<div className="text-red-500">{formik.errors.folderName}</div> <div className="text-red-500">{formik.errors.folderName}</div>
) : null} ) : null}
</div> </div>
{isEditingFilename ? (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="directoryName" className="block font-semibold">
Directory Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
</div>
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="file-name"
type="text"
name="directoryName"
placeholder="Directory Name"
className={`block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.directoryName || ''}
/>
</div>
</div>
) : (
<PathDisplay
collection={collection}
item={item}
filename={formik.values.directoryName}
showExtension={false}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
/>
)}
{formik.touched.directoryName && formik.errors.directoryName ? (
<div className="text-red-500">{formik.errors.directoryName}</div>
) : null}
</form> </form>
</Modal> </Modal>
); );

View File

@@ -1,52 +1,45 @@
import styled from 'styled-components'; import styled from 'styled-components';
const StyledWrapper = styled.div` const StyledWrapper = styled.div`
div.method-selector-container { div.method-selector-container {
border: solid 1px ${(props) => props.theme.modal.input.border}; border: solid 1px ${(props) => props.theme.modal.input.border};
border-right: none; border-right: none;
background-color: ${(props) => props.theme.modal.input.bg}; background-color: ${(props) => props.theme.modal.input.bg};
border-top-left-radius: 3px; border-top-left-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
.method-selector {
min-width: 80px;
}
}
div.method-selector-container,
div.input-container {
background-color: ${(props) => props.theme.modal.input.bg};
height: 2.3rem;
}
div.input-container {
border: solid 1px ${(props) => props.theme.modal.input.border};
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
input {
background-color: ${(props) => props.theme.modal.input.bg};
outline: none;
box-shadow: none;
&:focus {
outline: none !important;
box-shadow: none !important;
}
}
}
textarea.curl-command {
min-height: 150px;
}
.dropdown {
width: fit-content;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
.method-selector { export default StyledWrapper;
min-width: 80px;
}
}
div.method-selector-container,
div.input-container {
background-color: ${(props) => props.theme.modal.input.bg};
height: 2.3rem;
}
div.input-container {
border: solid 1px ${(props) => props.theme.modal.input.border};
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
input {
background-color: ${(props) => props.theme.modal.input.bg};
outline: none;
box-shadow: none;
&:focus {
outline: none !important;
box-shadow: none !important;
}
}
}
textarea.curl-command {
min-height: 150px;
}
.dropdown {
width: fit-content;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
export default StyledWrapper;

View File

@@ -10,10 +10,12 @@ import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions'
import { addTab } from 'providers/ReduxStore/slices/tabs'; import { addTab } from 'providers/ReduxStore/slices/tabs';
import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector'; import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector';
import { getDefaultRequestPaneTab } from 'utils/collections'; import { getDefaultRequestPaneTab } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { getRequestFromCurlCommand } from 'utils/curl'; import { getRequestFromCurlCommand } from 'utils/curl';
import { IconArrowBackUp, IconCaretDown } from '@tabler/icons';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import Dropdown from 'components/Dropdown'; import Dropdown from 'components/Dropdown';
import { IconCaretDown } from '@tabler/icons'; import PathDisplay from 'components/PathDisplay';
import StyledWrapper from './StyledWrapper';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => { const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -55,6 +57,8 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
setCurlRequestTypeDetected(type); setCurlRequestTypeDetected(type);
}; };
const [isEditingFilename, toggleEditingFilename] = useState(false);
const getRequestType = (collectionPresets) => { const getRequestType = (collectionPresets) => {
if (!collectionPresets || !collectionPresets.requestType) { if (!collectionPresets || !collectionPresets.requestType) {
return 'http-request'; return 'http-request';
@@ -79,6 +83,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
requestName: '', requestName: '',
filename: '',
requestType: getRequestType(collectionPresets), requestType: getRequestType(collectionPresets),
requestUrl: collectionPresets.requestUrl || '', requestUrl: collectionPresets.requestUrl || '',
requestMethod: 'GET', requestMethod: 'GET',
@@ -88,15 +93,18 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestName: Yup.string() requestName: Yup.string()
.trim() .trim()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.required('name is required') .max(255, 'must be 255 characters or less')
.test({ .required('name is required'),
name: 'requestName', filename: Yup.string()
message: `The request names - collection and folder is reserved in bruno`, .trim()
test: (value) => { .min(1, 'must be at least 1 character')
const trimmedValue = value ? value.trim().toLowerCase() : ''; .max(255, 'must be 255 characters or less')
return !['collection', 'folder'].includes(trimmedValue); .required('filename is required')
} .test('is-valid-filename', function(value) {
}), const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value)),
curlCommand: Yup.string().when('requestType', { curlCommand: Yup.string().when('requestType', {
is: (requestType) => requestType === 'from-curl', is: (requestType) => requestType === 'from-curl',
then: Yup.string() then: Yup.string()
@@ -116,6 +124,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
newEphemeralHttpRequest({ newEphemeralHttpRequest({
uid: uid, uid: uid,
requestName: values.requestName, requestName: values.requestName,
filename: values.filename,
requestType: values.requestType, requestType: values.requestType,
requestUrl: values.requestUrl, requestUrl: values.requestUrl,
requestMethod: values.requestMethod, requestMethod: values.requestMethod,
@@ -138,6 +147,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
dispatch( dispatch(
newHttpRequest({ newHttpRequest({
requestName: values.requestName, requestName: values.requestName,
filename: values.filename,
requestType: curlRequestTypeDetected, requestType: curlRequestTypeDetected,
requestUrl: request.url, requestUrl: request.url,
requestMethod: request.method, requestMethod: request.method,
@@ -157,6 +167,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
dispatch( dispatch(
newHttpRequest({ newHttpRequest({
requestName: values.requestName, requestName: values.requestName,
filename: values.filename,
requestType: values.requestType, requestType: values.requestType,
requestUrl: values.requestUrl, requestUrl: values.requestUrl,
requestMethod: values.requestMethod, requestMethod: values.requestMethod,
@@ -221,7 +232,16 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
return ( return (
<StyledWrapper> <StyledWrapper>
<Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={e => e.preventDefault()}> <form
className="bruno-form"
onSubmit={formik.handleSubmit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.handleSubmit();
}
}}
>
<div> <div>
<label htmlFor="requestName" className="block font-semibold"> <label htmlFor="requestName" className="block font-semibold">
Type Type
@@ -287,20 +307,64 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
spellCheck="false" spellCheck="false"
onChange={formik.handleChange} onChange={e => {
formik.setFieldValue('requestName', e.target.value);
!isEditingFilename && formik.setFieldValue('filename', sanitizeName(e.target.value));
}}
value={formik.values.requestName || ''} value={formik.values.requestName || ''}
/> />
{formik.touched.requestName && formik.errors.requestName ? ( {formik.touched.requestName && formik.errors.requestName ? (
<div className="text-red-500">{formik.errors.requestName}</div> <div className="text-red-500">{formik.errors.requestName}</div>
) : null} ) : null}
</div> </div>
{isEditingFilename ? (
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="block font-semibold">
File Name
</label>
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditingFilename(false)}
/>
</div>
<div className='relative flex flex-row gap-1 items-center justify-between'>
<input
id="file-name"
type="text"
name="filename"
placeholder="File Name"
className={`!pr-10 block textbox mt-2 w-full`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.filename || ''}
/>
<span className='absolute right-2 top-4 flex justify-center items-center file-extension'>.bru</span>
</div>
</div>
) : (
<PathDisplay
collection={collection}
item={item}
filename={formik.values.filename}
isEditingFilename={isEditingFilename}
toggleEditingFilename={toggleEditingFilename}
/>
)}
{formik.touched.filename && formik.errors.filename ? (
<div className="text-red-500">{formik.errors.filename}</div>
) : null}
{formik.values.requestType !== 'from-curl' ? ( {formik.values.requestType !== 'from-curl' ? (
<> <>
<div className="mt-4"> <div className="mt-4">
<label htmlFor="request-url" className="block font-semibold"> <label htmlFor="request-url" className="block font-semibold">
URL URL
</label> </label>
<div className="flex items-center mt-2 "> <div className="flex items-center mt-2 ">
<div className="flex items-center h-full method-selector-container"> <div className="flex items-center h-full method-selector-container">
<HttpMethodSelector <HttpMethodSelector

View File

@@ -3,8 +3,9 @@ import cloneDeep from 'lodash/cloneDeep';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
import find from 'lodash/find'; import find from 'lodash/find';
import get from 'lodash/get'; import get from 'lodash/get';
import set from 'lodash/set';
import trim from 'lodash/trim'; import trim from 'lodash/trim';
import path from 'path'; import path from 'utils/common/path';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app'; import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { import {
@@ -21,7 +22,6 @@ import {
transformRequestToSaveToFilesystem transformRequestToSaveToFilesystem
} from 'utils/collections'; } from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common'; import { uuid, waitForNextTick } from 'utils/common';
import { PATH_SEPARATOR, getDirectoryName, isWindowsPath } from 'utils/common/platform';
import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network'; import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network';
import { callIpc } from 'utils/common/ipc'; import { callIpc } from 'utils/common/ipc';
@@ -45,9 +45,9 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform'; import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index'; import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import slash from 'utils/common/slash';
import { getGlobalEnvironmentVariables } from 'utils/collections/index'; import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index'; import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
export const renameCollection = (newName, collectionUid) => (dispatch, getState) => { export const renameCollection = (newName, collectionUid) => (dispatch, getState) => {
const state = getState(); const state = getState();
@@ -343,7 +343,7 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
}); });
}; };
export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getState) => { export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => {
const state = getState(); const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -355,14 +355,14 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
if (!itemUid) { if (!itemUid) {
const folderWithSameNameExists = find( const folderWithSameNameExists = find(
collection.items, collection.items,
(i) => i.type === 'folder' && trim(i.name) === trim(folderName) (i) => i.type === 'folder' && trim(i.filename) === trim(directoryName)
); );
if (!folderWithSameNameExists) { if (!folderWithSameNameExists) {
const fullName = `${collection.pathname}${PATH_SEPARATOR}${folderName}`; const fullName = path.join(collection.pathname, directoryName);
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer ipcRenderer
.invoke('renderer:new-folder', fullName) .invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve()) .then(() => resolve())
.catch((error) => reject(error)); .catch((error) => reject(error));
} else { } else {
@@ -373,14 +373,14 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
if (currentItem) { if (currentItem) {
const folderWithSameNameExists = find( const folderWithSameNameExists = find(
currentItem.items, currentItem.items,
(i) => i.type === 'folder' && trim(i.name) === trim(folderName) (i) => i.type === 'folder' && trim(i.filename) === trim(directoryName)
); );
if (!folderWithSameNameExists) { if (!folderWithSameNameExists) {
const fullName = `${currentItem.pathname}${PATH_SEPARATOR}${folderName}`; const fullName = path.join(currentItem.pathname, directoryName);
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer ipcRenderer
.invoke('renderer:new-folder', fullName) .invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve()) .then(() => resolve())
.catch((error) => reject(error)); .catch((error) => reject(error));
} else { } else {
@@ -393,8 +393,7 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
}); });
}; };
// rename item export const renameItem = ({ newName, newFilename, itemUid, collectionUid }) => (dispatch, getState) => {
export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getState) => {
const state = getState(); const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -409,22 +408,53 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
return reject(new Error('Unable to locate item')); return reject(new Error('Unable to locate item'));
} }
const dirname = getDirectoryName(item.pathname);
let newPathname = '';
if (item.type === 'folder') {
newPathname = path.join(dirname, trim(newName));
} else {
const filename = resolveRequestFilename(newName);
newPathname = path.join(dirname, filename);
}
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:rename-item', slash(item.pathname), newPathname, newName).then(resolve).catch(reject); const renameName = async () => {
return ipcRenderer.invoke('renderer:rename-item-name', { itemPath: item.pathname, newName })
.catch((err) => {
toast.error('Failed to rename the item name');
console.error(err);
throw new Error('Failed to rename the item name');
});
};
const renameFile = async () => {
const dirname = path.dirname(item.pathname);
let newPath = '';
if (item.type === 'folder') {
newPath = path.join(dirname, trim(newFilename));
} else {
const filename = resolveRequestFilename(newFilename);
newPath = path.join(dirname, filename);
}
return ipcRenderer.invoke('renderer:rename-item-filename', { oldPath: item.pathname, newPath, newName, newFilename })
.catch((err) => {
toast.error('Failed to rename the file');
console.error(err);
throw new Error('Failed to rename the file');
});
};
let renameOperation = null;
if (newName) renameOperation = renameName;
if (newFilename) renameOperation = renameFile;
if (!renameOperation) {
resolve();
}
renameOperation()
.then(() => {
toast.success('Item renamed successfully');
resolve();
})
.catch((err) => reject(err));
}); });
}; };
export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getState) => { export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (dispatch, getState) => {
const state = getState(); const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -443,36 +473,41 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
const folderWithSameNameExists = find( const folderWithSameNameExists = find(
parentFolder.items, parentFolder.items,
(i) => i.type === 'folder' && trim(i.name) === trim(newName) (i) => i.type === 'folder' && trim(i?.filename) === trim(newFilename)
); );
if (folderWithSameNameExists) { if (folderWithSameNameExists) {
return reject(new Error('Duplicate folder names under same parent folder are not allowed')); return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
} }
const collectionPath = `${parentFolder.pathname}${PATH_SEPARATOR}${newName}`; set(item, 'name', newName);
set(item, 'filename', newFilename);
set(item, 'root.meta.name', newName);
const collectionPath = path.join(parentFolder.pathname, newFilename);
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject); ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
return; return;
} }
const parentItem = findParentItemInCollection(collectionCopy, itemUid); const parentItem = findParentItemInCollection(collectionCopy, itemUid);
const filename = resolveRequestFilename(newName); const filename = resolveRequestFilename(newFilename);
const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(item)); const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(item));
itemToSave.name = trim(newName); set(itemToSave, 'name', trim(newName));
set(itemToSave, 'filename', trim(filename));
if (!parentItem) { if (!parentItem) {
const reqWithSameNameExists = find( const reqWithSameNameExists = find(
collection.items, collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename) (i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
); );
if (!reqWithSameNameExists) { if (!reqWithSameNameExists) {
const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`; const fullPathname = path.join(collection.pathname, filename);
const { ipcRenderer } = window; const { ipcRenderer } = window;
const requestItems = filter(collection.items, (i) => i.type !== 'folder'); const requestItems = filter(collection.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1; itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
itemSchema itemSchema
.validate(itemToSave) .validate(itemToSave)
.then(() => ipcRenderer.invoke('renderer:new-request', fullName, itemToSave)) .then(() => ipcRenderer.invoke('renderer:new-request', fullPathname, itemToSave))
.then(resolve) .then(resolve)
.catch(reject); .catch(reject);
@@ -481,7 +516,7 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
uid: uuid(), uid: uuid(),
type: 'OPEN_REQUEST', type: 'OPEN_REQUEST',
collectionUid, collectionUid,
itemPathname: fullName itemPathname: fullPathname
}) })
); );
} else { } else {
@@ -493,8 +528,8 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename) (i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
); );
if (!reqWithSameNameExists) { if (!reqWithSameNameExists) {
const dirname = getDirectoryName(item.pathname); const dirname = path.dirname(item.pathname);
const fullName = isWindowsPath(item.pathname) ? path.win32.join(dirname, filename) : path.join(dirname, filename); const fullName = path.join(dirname, filename);
const { ipcRenderer } = window; const { ipcRenderer } = window;
const requestItems = filter(parentItem.items, (i) => i.type !== 'folder'); const requestItems = filter(parentItem.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1; itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
@@ -719,7 +754,7 @@ export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (di
}; };
export const newHttpRequest = (params) => (dispatch, getState) => { export const newHttpRequest = (params) => (dispatch, getState) => {
const { requestName, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params; const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const state = getState(); const state = getState();
@@ -747,6 +782,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
uid: uuid(), uid: uuid(),
type: requestType, type: requestType,
name: requestName, name: requestName,
filename,
request: { request: {
method: requestMethod, method: requestMethod,
url: requestUrl, url: requestUrl,
@@ -769,46 +805,20 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
}; };
// itemUid is null when we are creating a new request at the root level // itemUid is null when we are creating a new request at the root level
const filename = resolveRequestFilename(requestName); const resolvedFilename = resolveRequestFilename(filename);
if (!itemUid) { if (!itemUid) {
const reqWithSameNameExists = find( const reqWithSameNameExists = find(
collection.items, collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename) (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
); );
const requestItems = filter(collection.items, (i) => i.type !== 'folder'); const requestItems = filter(collection.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1; item.seq = requestItems.length + 1;
if (!reqWithSameNameExists) { if (!reqWithSameNameExists) {
const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`; const fullName = path.join(collection.pathname, resolvedFilename);
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject); ipcRenderer.invoke('renderer:new-request', fullName, item).then(() => {
// task middleware will track this and open the new request in a new tab once request is created
dispatch(
insertTaskIntoQueue({
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
itemPathname: fullName
})
);
} else {
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
} else {
const currentItem = findItemInCollection(collection, itemUid);
if (currentItem) {
const reqWithSameNameExists = find(
currentItem.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
);
const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
if (!reqWithSameNameExists) {
const fullName = `${currentItem.pathname}${PATH_SEPARATOR}${filename}`;
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
// task middleware will track this and open the new request in a new tab once request is created // task middleware will track this and open the new request in a new tab once request is created
dispatch( dispatch(
insertTaskIntoQueue({ insertTaskIntoQueue({
@@ -818,6 +828,35 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
itemPathname: fullName itemPathname: fullName
}) })
); );
resolve();
}).catch(reject);
} else {
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
} else {
const currentItem = findItemInCollection(collection, itemUid);
if (currentItem) {
const reqWithSameNameExists = find(
currentItem.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
if (!reqWithSameNameExists) {
const fullName = path.join(currentItem.pathname, resolvedFilename);
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:new-request', fullName, item).then(() => {
// task middleware will track this and open the new request in a new tab once request is created
dispatch(
insertTaskIntoQueue({
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
itemPathname: fullName
})
);
resolve();
}).catch(reject);
} else { } else {
return reject(new Error('Duplicate request names are not allowed under the same folder')); return reject(new Error('Duplicate request names are not allowed under the same folder'));
} }
@@ -859,16 +898,18 @@ export const importEnvironment = (name, variables, collectionUid) => (dispatch,
if (!collection) { if (!collection) {
return reject(new Error('Collection not found')); return reject(new Error('Collection not found'));
} }
const sanitizedName = sanitizeName(name);
ipcRenderer ipcRenderer
.invoke('renderer:create-environment', collection.pathname, name, variables) .invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
.then( .then(
dispatch( dispatch(
updateLastAction({ updateLastAction({
collectionUid, collectionUid,
lastAction: { lastAction: {
type: 'ADD_ENVIRONMENT', type: 'ADD_ENVIRONMENT',
payload: name payload: sanitizedName
} }
}) })
) )
@@ -891,15 +932,17 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
return reject(new Error('Environment not found')); return reject(new Error('Environment not found'));
} }
const sanitizedName = sanitizeName(name);
ipcRenderer ipcRenderer
.invoke('renderer:create-environment', collection.pathname, name, baseEnv.variables) .invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
.then( .then(
dispatch( dispatch(
updateLastAction({ updateLastAction({
collectionUid, collectionUid,
lastAction: { lastAction: {
type: 'ADD_ENVIRONMENT', type: 'ADD_ENVIRONMENT',
payload: name payload: sanitizedName
} }
}) })
) )
@@ -923,12 +966,13 @@ export const renameEnvironment = (newName, environmentUid, collectionUid) => (di
return reject(new Error('Environment not found')); return reject(new Error('Environment not found'));
} }
const sanitizedName = sanitizeName(newName);
const oldName = environment.name; const oldName = environment.name;
environment.name = newName; environment.name = sanitizedName;
environmentSchema environmentSchema
.validate(environment) .validate(environment)
.then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, newName)) .then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, sanitizedName))
.then(resolve) .then(resolve)
.catch(reject); .catch(reject);
}); });

View File

@@ -16,10 +16,10 @@ import {
isItemARequest isItemARequest
} from 'utils/collections'; } from 'utils/collections';
import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url'; import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
import { getDirectoryName, getSubdirectoriesFromRoot, PATH_SEPARATOR } from 'utils/common/platform'; import { getSubdirectoriesFromRoot } from 'utils/common/platform';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import mime from 'mime-types'; import mime from 'mime-types';
import path from 'node:path'; import path from 'utils/common/path';
const initialState = { const initialState = {
collections: [], collections: [],
@@ -1655,25 +1655,29 @@ export const collectionsSlice = createSlice({
} }
if (isFolderRoot) { if (isFolderRoot) {
const folderPath = getDirectoryName(file.meta.pathname); const folderPath = path.dirname(file.meta.pathname);
const folderItem = findItemInCollectionByPathname(collection, folderPath); const folderItem = findItemInCollectionByPathname(collection, folderPath);
if (folderItem) { if (folderItem) {
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
}
folderItem.root = file.data; folderItem.root = file.data;
} }
return; return;
} }
if (collection) { if (collection) {
const dirname = getDirectoryName(file.meta.pathname); const dirname = path.dirname(file.meta.pathname);
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname); const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
let currentPath = collection.pathname; let currentPath = collection.pathname;
let currentSubItems = collection.items; let currentSubItems = collection.items;
for (const directoryName of subDirectories) { for (const directoryName of subDirectories) {
let childItem = currentSubItems.find((f) => f.type === 'folder' && f.name === directoryName); let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
currentPath = path.join(currentPath, directoryName);
if (!childItem) { if (!childItem) {
childItem = { childItem = {
uid: uuid(), uid: uuid(),
pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`, pathname: currentPath,
name: directoryName, name: directoryName,
collapsed: true, collapsed: true,
type: 'folder', type: 'folder',
@@ -1681,8 +1685,6 @@ export const collectionsSlice = createSlice({
}; };
currentSubItems.push(childItem); currentSubItems.push(childItem);
} }
currentPath = `${currentPath}${PATH_SEPARATOR}${directoryName}`;
currentSubItems = childItem.items; currentSubItems = childItem.items;
} }
@@ -1732,20 +1734,20 @@ export const collectionsSlice = createSlice({
let currentPath = collection.pathname; let currentPath = collection.pathname;
let currentSubItems = collection.items; let currentSubItems = collection.items;
for (const directoryName of subDirectories) { for (const directoryName of subDirectories) {
let childItem = currentSubItems.find((f) => f.type === 'folder' && f.name === directoryName); let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
currentPath = path.join(currentPath, directoryName);
if (!childItem) { if (!childItem) {
childItem = { childItem = {
uid: uuid(), uid: uuid(),
pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`, pathname: currentPath,
name: directoryName, name: dir?.meta?.name || directoryName,
filename: directoryName,
collapsed: true, collapsed: true,
type: 'folder', type: 'folder',
items: [] items: []
}; };
currentSubItems.push(childItem); currentSubItems.push(childItem);
} }
currentPath = `${currentPath}${PATH_SEPARATOR}${directoryName}`;
currentSubItems = childItem.items; currentSubItems = childItem.items;
} }
addDepth(collection.items); addDepth(collection.items);
@@ -1753,11 +1755,25 @@ export const collectionsSlice = createSlice({
}, },
collectionChangeFileEvent: (state, action) => { collectionChangeFileEvent: (state, action) => {
const { file } = action.payload; const { file } = action.payload;
const isCollectionRoot = file.meta.collectionRoot ? true : false;
const isFolderRoot = file.meta.folderRoot ? true : false;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid); const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
if (isCollectionRoot) {
if (collection) {
collection.root = file.data;
}
return;
}
// check and update collection root if (isFolderRoot) {
if (collection && file.meta.collectionRoot) { const folderPath = path.dirname(file.meta.pathname);
collection.root = file.data; const folderItem = findItemInCollectionByPathname(collection, folderPath);
if (folderItem) {
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
}
folderItem.root = file.data;
}
return; return;
} }

View File

@@ -1,7 +1,6 @@
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash'; import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common'; import { uuid } from 'utils/common';
import path from 'path'; import path from 'utils/common/path';
import slash from 'utils/common/slash';
const replaceTabsWithSpaces = (str, numSpaces = 2) => { const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if (!str || !str.length || !isString(str)) { if (!str || !str.length || !isString(str)) {
@@ -90,7 +89,7 @@ export const findCollectionByItemUid = (collections, itemUid) => {
}; };
export const findItemByPathname = (items = [], pathname) => { export const findItemByPathname = (items = [], pathname) => {
return find(items, (i) => slash(i.pathname) === slash(pathname)); return find(items, (i) => i.pathname === pathname);
}; };
export const findItemInCollectionByPathname = (collection, pathname) => { export const findItemInCollectionByPathname = (collection, pathname) => {
@@ -307,6 +306,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
uid: si.uid, uid: si.uid,
type: si.type, type: si.type,
name: si.name, name: si.name,
filename: si.filename,
seq: si.seq seq: si.seq
}; };

View File

@@ -0,0 +1,12 @@
import platform from 'platform';
import path from 'path';
const isWindowsOS = () => {
const os = platform.os;
const osFamily = os.family.toLowerCase();
return osFamily.includes('windows');
};
const brunoPath = isWindowsOS() ? path.win32 : path.posix;
export default brunoPath;

View File

@@ -1,7 +1,6 @@
import trim from 'lodash/trim'; import trim from 'lodash/trim';
import path from 'path';
import slash from './slash';
import platform from 'platform'; import platform from 'platform';
import path from './path';
export const isElectron = () => { export const isElectron = () => {
if (!window) { if (!window) {
@@ -16,35 +15,11 @@ export const resolveRequestFilename = (name) => {
}; };
export const getSubdirectoriesFromRoot = (rootPath, pathname) => { export const getSubdirectoriesFromRoot = (rootPath, pathname) => {
// convert to unix style path
pathname = slash(pathname);
rootPath = slash(rootPath);
const relativePath = path.relative(rootPath, pathname); const relativePath = path.relative(rootPath, pathname);
return relativePath ? relativePath.split(path.sep) : []; return relativePath ? relativePath.split(path.sep) : [];
}; };
export const isWindowsPath = (pathname) => {
if (!isWindowsOS()) {
return false;
}
// Check for Windows drive letter format (e.g., "C:\")
const hasDriveLetter = /^[a-zA-Z]:\\/.test(pathname);
// Check for UNC path format (e.g., "\\server\share") a.k.a. network path || WSL path
const isUNCPath = pathname.startsWith('\\\\');
return hasDriveLetter || isUNCPath;
};
export const getDirectoryName = (pathname) => {
return isWindowsPath(pathname) ? path.win32.dirname(pathname) : path.dirname(pathname);
};
export const isWindowsOS = () => { export const isWindowsOS = () => {
const os = platform.os; const os = platform.os;
const osFamily = os.family.toLowerCase(); const osFamily = os.family.toLowerCase();
@@ -59,8 +34,6 @@ export const isMacOS = () => {
return osFamily.includes('os x'); return osFamily.includes('os x');
}; };
export const PATH_SEPARATOR = isWindowsOS() ? '\\' : '/';
export const getAppInstallDate = () => { export const getAppInstallDate = () => {
let dateString = localStorage.getItem('bruno.installedOn'); let dateString = localStorage.getItem('bruno.installedOn');

View File

@@ -1 +1,55 @@
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // replace invalid characters with hyphens
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
export const variableNameRegex = /^[\w-.]*$/; export const variableNameRegex = /^[\w-.]*$/;
export const sanitizeName = (name) => {
name = name
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
return name;
};
export const validateName = (name) => {
if (!name) return false;
if (name.length > 255) return false; // max name length
if (reservedDeviceNames.test(name)) return false; // windows reserved names
return (
firstCharacter.test(name) &&
middleCharacters.test(name) &&
lastCharacter.test(name)
);
};
export const validateNameError = (name) => {
if (!name) return "Name cannot be empty.";
if (name.length > 255) {
return "Name cannot exceed 255 characters.";
}
if (reservedDeviceNames.test(name)) {
return "Name cannot be a reserved device name.";
}
if (!firstCharacter.test(name[0])) {
return "Invalid first character.";
}
for (let i = 1; i < name.length - 1; i++) {
if (!middleCharacters.test(name[i])) {
return `Invalid character '${name[i]}' at position ${i + 1}.`;
}
}
if (!lastCharacter.test(name[name.length - 1])) {
return "Invalid last character.";
}
return '';
};

View File

@@ -0,0 +1,166 @@
const { describe, it, expect } = require('@jest/globals');
import { sanitizeName, validateName } from './regex';
describe('regex validators', () => {
describe('sanitize name', () => {
it('should remove invalid characters', () => {
expect(sanitizeName('hello world')).toBe('hello world');
expect(sanitizeName('hello-world')).toBe('hello-world');
expect(sanitizeName('hello_world')).toBe('hello_world');
expect(sanitizeName('hello_world-')).toBe('hello_world-');
expect(sanitizeName('hello_world-123')).toBe('hello_world-123');
expect(sanitizeName('hello_world-123!@#$%^&*()')).toBe('hello_world-123!@#$%^&-()');
expect(sanitizeName('hello_world?')).toBe('hello_world-');
expect(sanitizeName('foo/bar/')).toBe('foo-bar-');
expect(sanitizeName('foo\\bar\\')).toBe('foo-bar-');
});
it('should remove leading hyphens', () => {
expect(sanitizeName('-foo')).toBe('foo');
expect(sanitizeName('---foo')).toBe('foo');
expect(sanitizeName('-foo-bar')).toBe('foo-bar');
});
it('should remove trailing periods', () => {
expect(sanitizeName('.file')).toBe('file');
expect(sanitizeName('.file.')).toBe('file');
expect(sanitizeName('file.')).toBe('file');
expect(sanitizeName('file.name.')).toBe('file.name');
expect(sanitizeName('hello world.')).toBe('hello world');
});
it('should handle filenames with only invalid characters', () => {
expect(sanitizeName('<>:"/\\|?*')).toBe('');
expect(sanitizeName('::::')).toBe('');
});
it('should handle filenames with a mix of valid and invalid characters', () => {
expect(sanitizeName('test<>:"/\\|?*')).toBe('test---------');
expect(sanitizeName('foo<bar>')).toBe('foo-bar-');
});
it('should remove control characters', () => {
expect(sanitizeName('foo\x00bar')).toBe('foo-bar');
expect(sanitizeName('file\x1Fname')).toBe('file-name');
});
it('should return an empty string if the name is empty or consists only of invalid characters', () => {
expect(sanitizeName('')).toBe('');
expect(sanitizeName('<>:"/\\|?*')).toBe('');
});
it('should handle filenames with multiple consecutive invalid characters', () => {
expect(sanitizeName('foo<<bar')).toBe('foo--bar');
expect(sanitizeName('test||name')).toBe('test--name');
});
it('should handle names with spaces only', () => {
expect(sanitizeName(' ')).toBe('');
});
it('should handle names with leading/trailing spaces', () => {
expect(sanitizeName(' foo bar ')).toBe('foo bar');
});
it('should preserve valid non-ASCII characters', () => {
expect(sanitizeName('brunó')).toBe('brunó');
expect(sanitizeName('文件')).toBe('文件');
expect(sanitizeName('brunfais')).toBe('brunfais');
expect(sanitizeName('brunai')).toBe('brunai');
expect(sanitizeName('brunsборка')).toBe('brunsборка');
expect(sanitizeName('brunпривет')).toBe('brunпривет');
expect(sanitizeName('🐶')).toBe('🐶');
expect(sanitizeName('brunfais🐶')).toBe('brunfais🐶');
expect(sanitizeName('file-🐶-bruno')).toBe('file-🐶-bruno');
expect(sanitizeName('helló')).toBe('helló');
});
it('should preserve case sensitivity', () => {
expect(sanitizeName('FileName')).toBe('FileName');
expect(sanitizeName('fileNAME')).toBe('fileNAME');
});
it('should handle filenames with multiple consecutive periods (only remove trailing)', () => {
expect(sanitizeName('file.name...')).toBe('file.name');
expect(sanitizeName('...file')).toBe('file');
expect(sanitizeName('file.name... ')).toBe('file.name');
expect(sanitizeName(' ...file')).toBe('file');
expect(sanitizeName(' ...file ')).toBe('file');
expect(sanitizeName(' ...file.... ')).toBe('file');
});
it('should handle very long filenames', () => {
const longName = 'a'.repeat(250) + '.txt';
expect(sanitizeName(longName)).toBe(longName);
});
it('should handle names with leading/trailing invalid characters', () => {
expect(sanitizeName('-foo/bar-')).toBe('foo-bar-');
expect(sanitizeName('/foo\\bar/')).toBe('foo-bar-');
});
it('should handle different language unicode characters', () => {
expect(sanitizeName('你好世界!?@#$%^&*()')).toBe('你好世界!-@#$%^&-()');
expect(sanitizeName('こんにちは世界!?@#$%^&*()')).toBe('こんにちは世界!-@#$%^&-()');
expect(sanitizeName('안녕하세요 세계!?@#$%^&*()')).toBe('안녕하세요 세계!-@#$%^&-()');
expect(sanitizeName('مرحبا بالعالم!?@#$%^&*()')).toBe('مرحبا بالعالم!-@#$%^&-()');
expect(sanitizeName('Здравствуй мир!?@#$%^&*()')).toBe('Здравствуй мир!-@#$%^&-()');
expect(sanitizeName('नमस्ते दुनिया!?@#$%^&*()')).toBe('नमस्ते दुनिया!-@#$%^&-()');
expect(sanitizeName('สวัสดีชาวโลก!?@#$%^&*()')).toBe('สวัสดีชาวโลก!-@#$%^&-()');
expect(sanitizeName('γειά σου κόσμος!?@#$%^&*()')).toBe('γειά σου κόσμος!-@#$%^&-()');
});
});
});
describe('sanitizeName and validateName', () => {
it('should sanitize and then validate valid names', () => {
const validNames = [
'valid_filename.txt',
' valid name ',
' valid-name ',
'valid<>name.txt',
'file/with?invalid*chars'
];
validNames.forEach(name => {
const sanitized = sanitizeName(name);
expect(validateName(sanitized)).toBe(true);
});
});
it('should sanitize and then validate names with reserved device names', () => {
const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT2'];
reservedNames.forEach(name => {
const sanitized = sanitizeName(name);
expect(validateName(sanitized)).toBe(false);
});
});
it('should sanitize invalid names to empty strings', () => {
const invalidNames = [
' <>:"/\\|?* ',
' ... ',
' ',
];
invalidNames.forEach(name => {
const sanitized = sanitizeName(name);
expect(validateName(sanitized)).toBe(false);
});
});
it('should return false for reserved device names with leading/trailing spaces', () => {
const mixedNames = [
'AUX ',
' COM1 '
];
mixedNames.forEach(name => {
const sanitized = sanitizeName(name);
expect(validateName(sanitized)).toBe(false);
});
});
});

View File

@@ -1,20 +0,0 @@
/**
* MIT License
*
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const slash = (path) => {
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
if (isExtendedLengthPath) {
return path;
}
return path.replace(/\\/g, '/');
};
export default slash;

View File

@@ -62,7 +62,6 @@ export const updateUidsInCollection = (_collection) => {
export const transformItemsInCollection = (collection) => { export const transformItemsInCollection = (collection) => {
const transformItems = (items = []) => { const transformItems = (items = []) => {
each(items, (item) => { each(items, (item) => {
item.name = normalizeFileName(item.name);
if (['http', 'graphql'].includes(item.type)) { if (['http', 'graphql'].includes(item.type)) {
item.type = `${item.type}-request`; item.type = `${item.type}-request`;

View File

@@ -45,9 +45,8 @@ const openCollectionDialog = async (win, watcher) => {
const { filePaths } = await dialog.showOpenDialog(win, { const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory'] properties: ['openDirectory', 'createDirectory']
}); });
if (filePaths && filePaths[0]) { if (filePaths && filePaths[0]) {
const resolvedPath = normalizeAndResolvePath(filePaths[0]); const resolvedPath = path.resolve(filePaths[0]);
if (isDirectory(resolvedPath)) { if (isDirectory(resolvedPath)) {
openCollection(win, watcher, resolvedPath); openCollection(win, watcher, resolvedPath);
} else { } else {

View File

@@ -2,8 +2,8 @@ const _ = require('lodash');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const chokidar = require('chokidar'); const chokidar = require('chokidar');
const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath, sizeInMB } = require('../utils/filesystem'); const { hasBruExtension, isWSLPath, normalizeAndResolvePath, sizeInMB } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson, bruToJsonViaWorker ,collectionBruToJson } = require('../bru'); const { bruToEnvJson, bruToJson, bruToJsonViaWorker, collectionBruToJson } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang'); const { dotenvToJson } = require('@usebruno/lang');
const { uuid } = require('../utils/common'); const { uuid } = require('../utils/common');
@@ -319,20 +319,24 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
} }
}; };
const addDirectory = (win, pathname, collectionUid, collectionPath) => { const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments'); const envDirectory = path.join(collectionPath, 'environments');
if (pathname === envDirectory) { if (pathname === envDirectory) {
return; return;
} }
let name = path.basename(pathname);
const directory = { const directory = {
meta: { meta: {
collectionUid, collectionUid,
pathname, pathname,
name: path.basename(pathname) name
} }
}; };
win.webContents.send('main:collection-tree-updated', 'addDir', directory); win.webContents.send('main:collection-tree-updated', 'addDir', directory);
}; };
@@ -399,6 +403,30 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
} }
} }
if (path.basename(pathname) === 'folder.bru') {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname),
folderRoot: true
}
};
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = await collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'change', file);
return;
} catch (err) {
console.error(err);
return;
}
}
if (hasBruExtension(pathname)) { if (hasBruExtension(pathname)) {
try { try {
const file = { const file = {
@@ -439,18 +467,29 @@ const unlink = (win, pathname, collectionUid, collectionPath) => {
} }
}; };
const unlinkDir = (win, pathname, collectionUid, collectionPath) => { const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments'); const envDirectory = path.join(collectionPath, 'environments');
if (pathname === envDirectory) { if (pathname === envDirectory) {
return; return;
} }
const folderBruFilePath = path.join(pathname, `folder.bru`);
let name = path.basename(pathname);
if (fs.existsSync(folderBruFilePath)) {
let folderBruFileContent = fs.readFileSync(folderBruFilePath, 'utf8');
let folderBruData = await collectionBruToJson(folderBruFileContent);
name = folderBruData?.meta?.name || name;
}
const directory = { const directory = {
meta: { meta: {
collectionUid, collectionUid,
pathname, pathname,
name: path.basename(pathname) name
} }
}; };
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory); win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
@@ -477,14 +516,13 @@ class Watcher {
setTimeout(() => { setTimeout(() => {
const watcher = chokidar.watch(watchPath, { const watcher = chokidar.watch(watchPath, {
ignoreInitial: false, ignoreInitial: false,
usePolling: watchPath.startsWith('\\\\') || forcePolling ? true : false, usePolling: isWSLPath(watchPath) || forcePolling ? true : false,
ignored: (filepath) => { ignored: (filepath) => {
const normalizedPath = isWSLPath(filepath) ? normalizeWslPath(filepath) : normalizeAndResolvePath(filepath); const normalizedPath = normalizeAndResolvePath(filepath);
const relativePath = path.relative(watchPath, normalizedPath); const relativePath = path.relative(watchPath, normalizedPath);
return ignores.some((ignorePattern) => { return ignores.some((ignorePattern) => {
const normalizedIgnorePattern = isWSLPath(ignorePattern) ? normalizeWslPath(ignorePattern) : ignorePattern.replace(/\\/g, '/'); return relativePath === ignorePattern || relativePath.startsWith(ignorePattern);
return relativePath === normalizedIgnorePattern || relativePath.startsWith(normalizedIgnorePattern);
}); });
}, },
persistent: true, persistent: true,

View File

@@ -4,10 +4,9 @@ const fsExtra = require('fs-extra');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { ipcMain, shell, dialog, app } = require('electron'); const { ipcMain, shell, dialog, app } = require('electron');
const { envJsonToBru, bruToJson, jsonToBruViaWorker, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru'); const { envJsonToBru, bruToJson, jsonToBru, jsonToBruViaWorker, collectionBruToJson, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru');
const { const {
isValidPathname,
writeFile, writeFile,
hasBruExtension, hasBruExtension,
isDirectory, isDirectory,
@@ -15,16 +14,15 @@ const {
browseFiles, browseFiles,
createDirectory, createDirectory,
searchForBruFiles, searchForBruFiles,
sanitizeDirectoryName, sanitizeName,
isWSLPath, isWSLPath,
normalizeWslPath,
normalizeAndResolvePath,
safeToRename, safeToRename,
isWindowsOS, isWindowsOS,
isValidFilename, validateName,
hasSubDirectories, hasSubDirectories,
getCollectionStats, getCollectionStats,
sizeInMB sizeInMB,
safeWriteFileSync
} = require('../utils/filesystem'); } = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections'); const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common'); const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@@ -74,7 +72,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
'renderer:create-collection', 'renderer:create-collection',
async (event, collectionName, collectionFolderName, collectionLocation) => { async (event, collectionName, collectionFolderName, collectionLocation) => {
try { try {
collectionFolderName = sanitizeDirectoryName(collectionFolderName); collectionFolderName = sanitizeName(collectionFolderName);
const dirPath = path.join(collectionLocation, collectionFolderName); const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) { if (fs.existsSync(dirPath)) {
const files = fs.readdirSync(dirPath); const files = fs.readdirSync(dirPath);
@@ -83,7 +81,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`collection: ${dirPath} already exists and is not empty`); throw new Error(`collection: ${dirPath} already exists and is not empty`);
} }
} }
if (!isValidPathname(path.basename(dirPath))) {
if (!validateName(path.basename(dirPath))) {
throw new Error(`collection: invalid pathname - ${dirPath}`); throw new Error(`collection: invalid pathname - ${dirPath}`);
} }
@@ -116,13 +115,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle( ipcMain.handle(
'renderer:clone-collection', 'renderer:clone-collection',
async (event, collectionName, collectionFolderName, collectionLocation, previousPath) => { async (event, collectionName, collectionFolderName, collectionLocation, previousPath) => {
collectionFolderName = sanitizeDirectoryName(collectionFolderName); collectionFolderName = sanitizeName(collectionFolderName);
const dirPath = path.join(collectionLocation, collectionFolderName); const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) { if (fs.existsSync(dirPath)) {
throw new Error(`collection: ${dirPath} already exists`); throw new Error(`collection: ${dirPath} already exists`);
} }
if (!isValidPathname(path.basename(dirPath))) { if (!validateName(path.basename(dirPath))) {
throw new Error(`collection: invalid pathname - ${dirPath}`); throw new Error(`collection: invalid pathname - ${dirPath}`);
} }
@@ -221,8 +220,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(pathname)) { if (fs.existsSync(pathname)) {
throw new Error(`path: ${pathname} already exists`); throw new Error(`path: ${pathname} already exists`);
} }
if (!isValidFilename(request.name)) { // For the actual filename part, we want to be strict
throw new Error(`path: ${request.name}.bru is not a valid filename`); if (!validateName(request?.filename)) {
throw new Error(`${request.filename}.bru is not a valid filename`);
} }
const content = await jsonToBruViaWorker(request); const content = await jsonToBruViaWorker(request);
await writeFile(pathname, content); await writeFile(pathname, content);
@@ -358,18 +358,53 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}); });
// rename item // rename item
ipcMain.handle('renderer:rename-item', async (event, oldPath, newPath, newName) => { ipcMain.handle('renderer:rename-item-name', async (event, { itemPath, newName }) => {
const tempDir = path.join(os.tmpdir(), `temp-folder-${Date.now()}`);
// const parentDir = path.dirname(oldPath);
const isWindowsOSAndNotWSLAndItemHasSubDirectories = isDirectory(oldPath) && isWindowsOS() && !isWSLPath(oldPath) && hasSubDirectories(oldPath);
// let parentDirUnwatched = false;
// let parentDirRewatched = false;
try { try {
// Normalize paths if they are WSL paths
oldPath = isWSLPath(oldPath) ? normalizeWslPath(oldPath) : normalizeAndResolvePath(oldPath);
newPath = isWSLPath(newPath) ? normalizeWslPath(newPath) : normalizeAndResolvePath(newPath);
if (!fs.existsSync(itemPath)) {
throw new Error(`path: ${itemPath} does not exist`);
}
if (isDirectory(itemPath)) {
const folderBruFilePath = path.join(itemPath, 'folder.bru');
let folderBruFileJsonContent;
if (fs.existsSync(folderBruFilePath)) {
const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
} else {
folderBruFileJsonContent = {};
}
folderBruFileJsonContent.meta = {
name: newName,
};
const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
await writeFile(folderBruFilePath, folderBruFileContent);
return;
}
const isBru = hasBruExtension(itemPath);
if (!isBru) {
throw new Error(`path: ${itemPath} is not a bru file`);
}
const data = fs.readFileSync(itemPath, 'utf8');
const jsonData = await bruToJson(data);
jsonData.name = newName;
const content = await jsonToBru(jsonData);
await writeFile(itemPath, content);
} catch (error) {
return Promise.reject(error);
}
});
// rename item
ipcMain.handle('renderer:rename-item-filename', async (event, { oldPath, newPath, newName, newFilename }) => {
const tempDir = path.join(os.tmpdir(), `temp-folder-${Date.now()}`);
const isWindowsOSAndNotWSLPathAndItemHasSubDirectories = isDirectory(oldPath) && isWindowsOS() && !isWSLPath(oldPath) && hasSubDirectories(oldPath);
try {
// Check if the old path exists // Check if the old path exists
if (!fs.existsSync(oldPath)) { if (!fs.existsSync(oldPath)) {
throw new Error(`path: ${oldPath} does not exist`); throw new Error(`path: ${oldPath} does not exist`);
@@ -380,6 +415,22 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
} }
if (isDirectory(oldPath)) { if (isDirectory(oldPath)) {
const folderBruFilePath = path.join(oldPath, 'folder.bru');
let folderBruFileJsonContent;
if (fs.existsSync(folderBruFilePath)) {
const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
} else {
folderBruFileJsonContent = {};
}
folderBruFileJsonContent.meta = {
name: newName,
};
const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
await writeFile(folderBruFilePath, folderBruFileContent);
const bruFilesAtSource = await searchForBruFiles(oldPath); const bruFilesAtSource = await searchForBruFiles(oldPath);
for (let bruFile of bruFilesAtSource) { for (let bruFile of bruFilesAtSource) {
@@ -387,19 +438,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
moveRequestUid(bruFile, newBruFilePath); moveRequestUid(bruFile, newBruFilePath);
} }
// watcher.unlinkItemPathInWatcher(parentDir);
// parentDirUnwatched = true;
/** /**
* If it is windows OS * If it is windows OS
* And it is not WSL path (meaning it's not linux running on windows using WSL) * And it is not a WSL path (meaning it is not running in WSL (linux pathtype))
* And it has sub directories * And it has sub directories
* Only then we need to use the temp dir approach to rename the folder * Only then we need to use the temp dir approach to rename the folder
* *
* Windows OS would sometimes throw error when renaming a folder with sub directories * Windows OS would sometimes throw error when renaming a folder with sub directories
* This is an alternative approach to avoid that error * This is an alternative approach to avoid that error
*/ */
if (isWindowsOSAndNotWSLAndItemHasSubDirectories) { if (isWindowsOSAndNotWSLPathAndItemHasSubDirectories) {
await fsExtra.copy(oldPath, tempDir); await fsExtra.copy(oldPath, tempDir);
await fsExtra.remove(oldPath); await fsExtra.remove(oldPath);
await fsExtra.move(tempDir, newPath, { overwrite: true }); await fsExtra.move(tempDir, newPath, { overwrite: true });
@@ -407,8 +455,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
} else { } else {
await fs.renameSync(oldPath, newPath); await fs.renameSync(oldPath, newPath);
} }
// watcher.addItemPathInWatcher(parentDir);
// parentDirRewatched = true;
return newPath; return newPath;
} }
@@ -417,8 +463,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`path: ${oldPath} is not a bru file`); throw new Error(`path: ${oldPath} is not a bru file`);
} }
if (!isValidFilename(newName)) { if (!validateName(newFilename)) {
throw new Error(`path: ${newName} is not a valid filename`); throw new Error(`path: ${newFilename} is not a valid filename`);
} }
// update name in file and save new copy, then delete old copy // update name in file and save new copy, then delete old copy
@@ -433,15 +479,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
return newPath; return newPath;
} catch (error) { } catch (error) {
// in case an error occurs during the rename file operations after unlinking the parent dir
// and the rewatch fails, we need to add it back to watcher
// if (parentDirUnwatched && !parentDirRewatched) {
// watcher.addItemPathInWatcher(parentDir);
// }
// in case the rename file operations fails, and we see that the temp dir exists // in case the rename file operations fails, and we see that the temp dir exists
// and the old path does not exist, we need to restore the data from the temp dir to the old path // and the old path does not exist, we need to restore the data from the temp dir to the old path
if (isWindowsOSAndNotWSLAndItemHasSubDirectories) { if (isWindowsOSAndNotWSLPathAndItemHasSubDirectories) {
if (fsExtra.pathExistsSync(tempDir) && !fsExtra.pathExistsSync(oldPath)) { if (fsExtra.pathExistsSync(tempDir) && !fsExtra.pathExistsSync(oldPath)) {
try { try {
await fsExtra.copy(tempDir, oldPath); await fsExtra.copy(tempDir, oldPath);
@@ -457,12 +497,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}); });
// new folder // new folder
ipcMain.handle('renderer:new-folder', async (event, pathname) => { ipcMain.handle('renderer:new-folder', async (event, pathname, folderName) => {
const resolvedFolderName = sanitizeDirectoryName(path.basename(pathname)); const resolvedFolderName = sanitizeName(path.basename(pathname));
pathname = path.join(path.dirname(pathname), resolvedFolderName); pathname = path.join(path.dirname(pathname), resolvedFolderName);
try { try {
if (!fs.existsSync(pathname)) { if (!fs.existsSync(pathname)) {
fs.mkdirSync(pathname); fs.mkdirSync(pathname);
const folderBruFilePath = path.join(pathname, 'folder.bru');
let data = {
meta: {
name: folderName,
}
};
const content = await jsonToCollectionBru(data, true); // isFolder flag
await writeFile(folderBruFilePath, content);
} else { } else {
return Promise.reject(new Error('The directory already exists')); return Promise.reject(new Error('The directory already exists'));
} }
@@ -522,7 +570,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:import-collection', async (event, collection, collectionLocation) => { ipcMain.handle('renderer:import-collection', async (event, collection, collectionLocation) => {
try { try {
let collectionName = sanitizeDirectoryName(collection.name); let collectionName = sanitizeName(collection.name);
let collectionPath = path.join(collectionLocation, collectionName); let collectionPath = path.join(collectionLocation, collectionName);
if (fs.existsSync(collectionPath)) { if (fs.existsSync(collectionPath)) {
@@ -533,13 +581,14 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const parseCollectionItems = (items = [], currentPath) => { const parseCollectionItems = (items = [], currentPath) => {
items.forEach(async (item) => { items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) { if (['http-request', 'graphql-request'].includes(item.type)) {
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
const content = await jsonToBruViaWorker(item); const content = await jsonToBruViaWorker(item);
const filePath = path.join(currentPath, `${item.name}.bru`); const filePath = path.join(currentPath, sanitizedFilename);
fs.writeFileSync(filePath, content); safeWriteFileSync(filePath, content);
} }
if (item.type === 'folder') { if (item.type === 'folder') {
item.name = sanitizeDirectoryName(item.name); let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
const folderPath = path.join(currentPath, item.name); const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath); fs.mkdirSync(folderPath);
if (item?.root?.meta?.name) { if (item?.root?.meta?.name) {
@@ -548,7 +597,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
item.root, item.root,
true // isFolder true // isFolder
); );
fs.writeFileSync(folderBruFilePath, folderContent); safeWriteFileSync(folderBruFilePath, folderContent);
} }
if (item.items && item.items.length) { if (item.items && item.items.length) {
@@ -557,8 +606,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
} }
// Handle items of type 'js' // Handle items of type 'js'
if (item.type === 'js') { if (item.type === 'js') {
const filePath = path.join(currentPath, `${item.name}.js`); let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`);
fs.writeFileSync(filePath, item.fileContent); const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, item.fileContent);
} }
}); });
}; };
@@ -571,8 +621,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
environments.forEach(async (env) => { environments.forEach(async (env) => {
const content = await envJsonToBru(env); const content = await envJsonToBru(env);
const filePath = path.join(envDirPath, `${env.name}.bru`); let sanitizedEnvFilename = sanitizeName(`${env.name}.bru`);
fs.writeFileSync(filePath, content); const filePath = path.join(envDirPath, sanitizedEnvFilename);
safeWriteFileSync(filePath, content);
}); });
}; };
@@ -630,20 +681,21 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const parseCollectionItems = (items = [], currentPath) => { const parseCollectionItems = (items = [], currentPath) => {
items.forEach(async (item) => { items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) { if (['http-request', 'graphql-request'].includes(item.type)) {
const content = await jsonToBruViaWorker(item); const content = await jsonToBruViaWorker(item);
const filePath = path.join(currentPath, `${item.name}.bru`); const filePath = path.join(currentPath, item.filename);
fs.writeFileSync(filePath, content); safeWriteFileSync(filePath, content);
} }
if (item.type === 'folder') { if (item.type === 'folder') {
const folderPath = path.join(currentPath, item.name); const folderPath = path.join(currentPath, item.filename);
fs.mkdirSync(folderPath); fs.mkdirSync(folderPath);
// If folder has a root element, then I should write its folder.bru file // If folder has a root element, then I should write its folder.bru file
if (item.root) { if (item.root) {
const folderContent = await jsonToCollectionBru(item.root, true); const folderContent = await jsonToCollectionBru(item.root, true);
folderContent.name = item.name;
if (folderContent) { if (folderContent) {
const bruFolderPath = path.join(folderPath, `folder.bru`); const bruFolderPath = path.join(folderPath, `folder.bru`);
fs.writeFileSync(bruFolderPath, folderContent); safeWriteFileSync(bruFolderPath, folderContent);
} }
} }
@@ -661,7 +713,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const folderContent = await jsonToCollectionBru(itemFolder.root, true); const folderContent = await jsonToCollectionBru(itemFolder.root, true);
if (folderContent) { if (folderContent) {
const bruFolderPath = path.join(collectionPath, `folder.bru`); const bruFolderPath = path.join(collectionPath, `folder.bru`);
fs.writeFileSync(bruFolderPath, folderContent); safeWriteFileSync(bruFolderPath, folderContent);
} }
} }
@@ -697,7 +749,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
moveRequestUid(itemPath, newItemPath); moveRequestUid(itemPath, newItemPath);
fs.unlinkSync(itemPath); fs.unlinkSync(itemPath);
fs.writeFileSync(newItemPath, itemContent); safeWriteFileSync(newItemPath, itemContent);
} catch (error) { } catch (error) {
return Promise.reject(error); return Promise.reject(error);
} }

View File

@@ -254,16 +254,8 @@ const hydrateRequestWithUuid = (request, pathname) => {
return request; return request;
}; };
const slash = (path) => {
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
if (isExtendedLengthPath) {
return path;
}
return path?.replace?.(/\\/g, '/');
};
const findItemByPathname = (items = [], pathname) => { const findItemByPathname = (items = [], pathname) => {
return find(items, (i) => slash(i.pathname) === slash(pathname)); return find(items, (i) => i.pathname === pathname);
}; };
const findItemInCollectionByPathname = (collection, pathname) => { const findItemInCollectionByPathname = (collection, pathname) => {
@@ -280,7 +272,6 @@ module.exports = {
flattenItems, flattenItems,
findItem, findItem,
findItemInCollection, findItemInCollection,
slash,
findItemByPathname, findItemByPathname,
findItemInCollectionByPathname, findItemInCollectionByPathname,
findParentItemInCollection, findParentItemInCollection,

View File

@@ -44,6 +44,11 @@ const hasSubDirectories = (dir) => {
}; };
const normalizeAndResolvePath = (pathname) => { const normalizeAndResolvePath = (pathname) => {
if (isWSLPath(pathname)) {
return normalizeWSLPath(pathname);
}
if (isSymbolicLink(pathname)) { if (isSymbolicLink(pathname)) {
const absPath = path.dirname(pathname); const absPath = path.dirname(pathname);
const targetPath = path.resolve(absPath, fs.readlinkSync(pathname)); const targetPath = path.resolve(absPath, fs.readlinkSync(pathname));
@@ -59,18 +64,20 @@ const normalizeAndResolvePath = (pathname) => {
function isWSLPath(pathname) { function isWSLPath(pathname) {
// Check if the path starts with the WSL prefix // Check if the path starts with the WSL prefix
// eg. "\\wsl.localhost\Ubuntu\home\user\bruno\collection\scripting\api\req\getHeaders.bru" // eg. "\\wsl.localhost\Ubuntu\home\user\bruno\collection\scripting\api\req\getHeaders.bru"
return pathname.startsWith('/wsl.localhost/') || pathname.startsWith('\\wsl.localhost\\'); return pathname.startsWith('\\\\') || pathname.startsWith('//') || pathname.startsWith('/wsl.localhost/') || pathname.startsWith('\\wsl.localhost');
} }
function normalizeWslPath(pathname) { function normalizeWSLPath(pathname) {
// Replace the WSL path prefix and convert forward slashes to backslashes // Replace the WSL path prefix and convert forward slashes to backslashes
// This is done to achieve WSL paths (linux style) to Windows UNC equivalent (Universal Naming Conversion) // This is done to achieve WSL paths (linux style) to Windows UNC equivalent (Universal Naming Conversion)
return pathname.replace(/^\/wsl.localhost/, '\\\\wsl.localhost').replace(/\//g, '\\'); return pathname.replace(/^\/wsl.localhost/, '\\\\wsl.localhost').replace(/\//g, '\\');
} }
const writeFile = async (pathname, content, isBinary = false) => { const writeFile = async (pathname, content, isBinary = false) => {
try { try {
await fs.writeFile(pathname, content, { await safeWriteFile(pathname, content, {
encoding: !isBinary ? "utf-8" : null encoding: !isBinary ? "utf-8" : null
}); });
} catch (err) { } catch (err) {
@@ -110,7 +117,7 @@ const browseDirectory = async (win) => {
return false; return false;
} }
const resolvedPath = normalizeAndResolvePath(filePaths[0]); const resolvedPath = path.resolve(filePaths[0]);
return isDirectory(resolvedPath) ? resolvedPath : false; return isDirectory(resolvedPath) ? resolvedPath : false;
}; };
@@ -124,7 +131,7 @@ const browseFiles = async (win, filters = [], properties = []) => {
return []; return [];
} }
return filePaths.map((path) => normalizeAndResolvePath(path)).filter((path) => isFile(path)); return filePaths.map((path) => path.resolve(path)).filter((path) => isFile(path));
}; };
const chooseFileToSave = async (win, preferredFileName = '') => { const chooseFileToSave = async (win, preferredFileName = '') => {
@@ -154,28 +161,36 @@ const searchForBruFiles = (dir) => {
return searchForFiles(dir, '.bru'); return searchForFiles(dir, '.bru');
}; };
const sanitizeDirectoryName = (name) => { const sanitizeName = (name) => {
return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-').trim(); const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g;
name = name
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[.\s]+/, '') // remove leading dots and and spaces
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
return name;
}; };
const isWindowsOS = () => { const isWindowsOS = () => {
return os.platform() === 'win32'; return os.platform() === 'win32';
} }
const isValidFilename = (fileName) => { const validateName = (name) => {
const inValidChars = /[\\/:*?"<>|]/; const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
if (name.length > 255) return false; // max name length
if (!fileName || inValidChars.test(fileName)) { if (reservedDeviceNames.test(name)) return false; // windows reserved names
return false;
}
if (fileName.endsWith(' ') || fileName.endsWith('.') || fileName.startsWith('.')) { return (
return false; firstCharacter.test(name) &&
} middleCharacters.test(name) &&
lastCharacter.test(name)
return true; );
}; };
const safeToRename = (oldPath, newPath) => { const safeToRename = (oldPath, newPath) => {
try { try {
// If the new path doesn't exist, it's safe to rename // If the new path doesn't exist, it's safe to rename
@@ -244,6 +259,29 @@ const sizeInMB = (size) => {
return size / (1024 * 1024); return size / (1024 * 1024);
} }
const getSafePathToWrite = (filePath) => {
const MAX_FILENAME_LENGTH = 255; // Common limit on most filesystems
let dir = path.dirname(filePath);
let ext = path.extname(filePath);
let base = path.basename(filePath, ext);
if (base.length + ext.length > MAX_FILENAME_LENGTH) {
base = sanitizeName(base);
base = base.slice(0, MAX_FILENAME_LENGTH - ext.length);
}
let safePath = path.join(dir, base + ext);
return safePath;
}
async function safeWriteFile(filePath, data, options) {
const safePath = getSafePathToWrite(filePath);
await fs.writeFile(safePath, data, options);
}
function safeWriteFileSync(filePath, data) {
const safePath = getSafePathToWrite(filePath);
fs.writeFileSync(safePath, data);
}
module.exports = { module.exports = {
isValidPathname, isValidPathname,
exists, exists,
@@ -252,7 +290,7 @@ module.exports = {
isDirectory, isDirectory,
normalizeAndResolvePath, normalizeAndResolvePath,
isWSLPath, isWSLPath,
normalizeWslPath, normalizeWSLPath,
writeFile, writeFile,
hasJsonExtension, hasJsonExtension,
hasBruExtension, hasBruExtension,
@@ -262,11 +300,13 @@ module.exports = {
chooseFileToSave, chooseFileToSave,
searchForFiles, searchForFiles,
searchForBruFiles, searchForBruFiles,
sanitizeDirectoryName, sanitizeName,
isWindowsOS, isWindowsOS,
safeToRename, safeToRename,
isValidFilename, validateName,
hasSubDirectories, hasSubDirectories,
getCollectionStats, getCollectionStats,
sizeInMB sizeInMB,
safeWriteFile,
safeWriteFileSync
}; };

View File

@@ -1,26 +1,84 @@
const { sanitizeDirectoryName } = require('./filesystem.js'); const { sanitizeName, isWSLPath, normalizeWSLPath, normalizeAndResolvePath } = require('./filesystem.js');
describe('sanitizeDirectoryName', () => { describe('sanitizeName', () => {
it('should replace invalid characters with hyphens', () => { it('should replace invalid characters with hyphens', () => {
const input = '<>:"/\\|?*\x00-\x1F'; const input = '<>:"/\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F';
const expectedOutput = '---'; const expectedOutput = '----------------------------------------';
expect(sanitizeDirectoryName(input)).toEqual(expectedOutput); expect(sanitizeName(input)).toEqual(expectedOutput);
}); });
it('should not modify valid directory names', () => { it('should not modify valid directory names', () => {
const input = 'my-directory'; const input = 'my-directory';
expect(sanitizeDirectoryName(input)).toEqual(input); expect(sanitizeName(input)).toEqual(input);
}); });
it('should replace multiple invalid characters with a single hyphen', () => { it('should replace multiple invalid characters with a single hyphen', () => {
const input = 'my<>invalid?directory'; const input = 'my<>invalid?directory';
const expectedOutput = 'my-invalid-directory'; const expectedOutput = 'my--invalid-directory';
expect(sanitizeDirectoryName(input)).toEqual(expectedOutput); expect(sanitizeName(input)).toEqual(expectedOutput);
}); });
it('should handle names with slashes', () => { it('should handle names with slashes', () => {
const input = 'my/invalid/directory'; const input = 'my/invalid/directory';
const expectedOutput = 'my-invalid-directory'; const expectedOutput = 'my-invalid-directory';
expect(sanitizeDirectoryName(input)).toEqual(expectedOutput); expect(sanitizeName(input)).toEqual(expectedOutput);
});
});
describe('WSL Path Utilities', () => {
describe('isWSLPath', () => {
it('should identify WSL paths starting with double backslash', () => {
expect(isWSLPath('\\\\wsl.localhost\\Ubuntu\\home\\user')).toBe(true);
});
it('should identify WSL paths starting with double forward slash', () => {
expect(isWSLPath('//wsl.localhost/Ubuntu/home/user')).toBe(true);
});
it('should identify WSL paths starting with /wsl.localhost/', () => {
expect(isWSLPath('/wsl.localhost/Ubuntu/home/user')).toBe(true);
});
it('should identify WSL paths starting with \\wsl.localhost', () => {
expect(isWSLPath('\\wsl.localhost\\Ubuntu\\home\\user')).toBe(true);
});
it('should return false for non-WSL paths', () => {
expect(isWSLPath('C:\\Users\\user\\Documents')).toBe(false);
expect(isWSLPath('/home/user/documents')).toBe(false);
expect(isWSLPath('relative/path')).toBe(false);
});
});
describe('normalizeWSLPath', () => {
it('should convert forward slash WSL paths to backslash format', () => {
const input = '/wsl.localhost/Ubuntu/home/user/file.txt';
const expected = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
expect(normalizeWSLPath(input)).toBe(expected);
});
it('should handle paths already in backslash format', () => {
const input = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
expect(normalizeWSLPath(input)).toBe(input);
});
it('should convert mixed slash formats to backslash format', () => {
const input = '/wsl.localhost\\Ubuntu/home\\user/file.txt';
const expected = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
expect(normalizeWSLPath(input)).toBe(expected);
});
});
describe('normalizeAndResolvePath with WSL paths', () => {
it('should normalize WSL paths', () => {
const input = '/wsl.localhost/Ubuntu/home/user/file.txt';
const expected = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
expect(normalizeAndResolvePath(input)).toBe(expected);
});
it('should handle already normalized WSL paths', () => {
const input = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
expect(normalizeAndResolvePath(input)).toBe(input);
});
}); });
}); });

View File

@@ -189,7 +189,7 @@ const addBruShimToContext = (vm, bru) => {
const promise = vm.newPromise(); const promise = vm.newPromise();
bru.runRequest(vm.dump(args)) bru.runRequest(vm.dump(args))
.then((response) => { .then((response) => {
const { status, statusText, headers, data, dataBuffer, size } = response || {}; const { status, headers, data, dataBuffer, size, statusText } = response || {};
promise.resolve(marshallToVm(cleanJson({ status, statusText, headers, data, dataBuffer, size }), vm)); promise.resolve(marshallToVm(cleanJson({ status, statusText, headers, data, dataBuffer, size }), vm));
}) })
.catch((err) => { .catch((err) => {