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

View File

@@ -8,9 +8,7 @@ import { useState } from 'react';
import StyledWrapper from './StyledWrapper';
import { useRef } from 'react';
import path from 'path';
import slash from 'utils/common/slash';
import { isWindowsOS } from 'utils/common/platform';
import path from 'utils/common/path';
const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
const certFilePathInputRef = useRef();
@@ -70,12 +68,7 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
const getFile = (e) => {
const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]);
if (filePath) {
let relativePath;
if (isWindowsOS()) {
relativePath = slash(path.win32.relative(root, filePath));
} else {
relativePath = path.posix.relative(root, filePath);
}
let relativePath = path.relative(root, filePath);
formik.setFieldValue(e.name, relativePath);
}
};
@@ -109,23 +102,23 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
<ul className="mt-4">
{!clientCertConfig.length
? 'No client certificates added'
: clientCertConfig.map((clientCert) => (
<li key={uuid()} className="flex items-center available-certificates p-2 rounded-lg mb-2">
<div className="flex items-center w-full justify-between">
<div className="flex w-full items-center">
<IconWorld className="mr-2" size={18} strokeWidth={1.5} />
{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>
: clientCertConfig.map((clientCert, index) => (
<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 w-full items-center">
<IconWorld className="mr-2" size={18} strokeWidth={1.5} />
{clientCert.domain}
</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>
<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="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>
<IconTrash
size={18}
@@ -238,9 +231,9 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
<div className="flex flex-row gap-2 items-center">
<div
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>
<IconTrash
size={18}
@@ -281,9 +274,9 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
<div className="flex flex-row gap-2 items-center">
<div
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>
<IconTrash
size={18}

View File

@@ -6,6 +6,7 @@ import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ collection, onClose }) => {
const dispatch = useDispatch();
@@ -23,7 +24,11 @@ const CreateEnvironment = ({ collection, onClose }) => {
validationSchema: Yup.object({
name: Yup.string()
.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')
.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 * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { validateName, validateNameError } from 'utils/common/regex';
const RenameEnvironment = ({ onClose, environment, collection }) => {
const dispatch = useDispatch();
@@ -18,7 +19,11 @@ const RenameEnvironment = ({ onClose, environment, collection }) => {
validationSchema: Yup.object({
name: Yup.string()
.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')
}),
onSubmit: (values) => {

View File

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

View File

@@ -6,6 +6,7 @@ import { useDispatch, useSelector } from 'react-redux';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ onClose }) => {
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
@@ -25,7 +26,11 @@ const CreateEnvironment = ({ onClose }) => {
validationSchema: Yup.object({
name: Yup.string()
.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')
.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 toast from 'react-hot-toast';
import { useFormik } from 'formik';
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
const RenameEnvironment = ({ onClose, environment }) => {
const dispatch = useDispatch();
@@ -19,7 +19,11 @@ const RenameEnvironment = ({ onClose, environment }) => {
validationSchema: Yup.object({
name: Yup.string()
.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')
}),
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 * as Yup from 'yup';
import toast from 'react-hot-toast';
import path from 'path';
import slash from 'utils/common/slash';
import path from 'utils/common/path';
import { IconTrash } from '@tabler/icons';
const General = ({ close }) => {
@@ -134,7 +133,7 @@ const General = ({ close }) => {
className={`flex items-center mt-2 pl-6 ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
>
<span className="flex items-center border px-2 rounded-md">
{path.basename(slash(formik.values.customCaCertificate.filePath))}
{path.basename(formik.values.customCaCertificate.filePath)}
<button
type="button"
tabIndex="-1"

View File

@@ -1,24 +1,19 @@
import React, { useState, useRef, useEffect } from 'react';
import path from 'path';
import path from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { get, cloneDeep } from 'lodash';
import { runCollectionFolder, cancelRunnerExecution } from 'providers/ReduxStore/slices/collections/actions';
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons';
import slash from 'utils/common/slash';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
const getDisplayName = (fullPath, pathname, name) => {
// convert to unix style path
fullPath = slash(fullPath);
pathname = slash(pathname);
const getDisplayName = (fullPath, pathname, name = '') => {
let relativePath = path.relative(fullPath, pathname);
const { dir } = path.parse(relativePath);
return [dir, name].filter(i => i).join('/');
const { dir = '' } = path.parse(relativePath);
return path.join(dir, name);
};
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 { cloneCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import InfoTip from 'components/InfoTip';
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 inputRef = useRef();
const dispatch = useDispatch();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const formik = useFormik({
enableReinitialize: true,
@@ -22,12 +26,15 @@ const CloneCollection = ({ onClose, collection }) => {
validationSchema: Yup.object({
collectionName: Yup.string()
.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'),
collectionFolderName: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
.max(255, 'must be 255 characters or less')
.test('is-valid-dir-name', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('folder name 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"
onChange={(e) => {
formik.handleChange(e);
if (formik.values.collectionName === formik.values.collectionFolderName) {
formik.setFieldValue('collectionFolderName', e.target.value);
}
!isEditingFilename && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
}}
autoComplete="off"
autoCorrect="off"
@@ -123,26 +128,42 @@ const CloneCollection = ({ onClose, collection }) => {
Browse
</span>
</div>
<label htmlFor="collection-folder-name" className="flex items-center mt-3">
<span className="font-semibold">Folder Name</span>
<InfoTip
content="This folder will be created under the selected location"
infotipId="collection-folder-name-infotip"
{isEditingFilename ?
<>
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" 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>
<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 ? (
<div className="text-red-500">{formik.errors.collectionFolderName}</div>
) : 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 { useFormik } from 'formik';
import * as Yup from 'yup';
@@ -6,24 +6,42 @@ import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
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 dispatch = useDispatch();
const isFolder = isItemAFolder(item);
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({
enableReinitialize: true,
initialValues: {
name: item.name
name: itemName,
filename: sanitizeName(itemFilename)
},
validationSchema: Yup.object({
name: Yup.string()
.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')
.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) => {
dispatch(cloneItem(values.name, item.uid, collection.uid))
dispatch(cloneItem(values.name, values.filename, item.uid, collection.uid))
.then(() => {
toast.success('Request cloned!');
onClose();
@@ -44,7 +62,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
return (
<Modal
size="sm"
size="md"
title={`Clone ${isFolder ? 'Folder' : 'Request'}`}
confirmText="Clone"
handleConfirm={onSubmit}
@@ -66,11 +84,58 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
autoCorrect="off"
autoCapitalize="off"
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 || ''}
/>
{formik.touched.name && formik.errors.name ? <div className="text-red-500">{formik.errors.name}</div> : null}
</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>
</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 * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
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 { closeTabs } from 'providers/ReduxStore/slices/tabs';
import PathDisplay from 'components/PathDisplay';
const RenameCollectionItem = ({ collection, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
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({
enableReinitialize: true,
initialValues: {
name: item.name
name: itemName,
filename: sanitizeName(itemFilename)
},
validationSchema: Yup.object({
name: Yup.string()
.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')
.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) => {
// if there is unsaved changes in the request,
// save them before renaming the request
if ((item.name === values.name) && (itemFilename === values.filename)) {
return;
}
if (!isFolder && item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true));
}
if (item.name === values.name) {
return;
const { name: newName, filename: newFilename } = values;
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 (
<Modal
size="sm"
size="md"
title={`Rename ${isFolder ? 'Folder' : 'Request'}`}
confirmText="Rename"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<form className="bruno-form" onSubmit={e => {e.preventDefault()}}>
<div className='flex flex-col mt-2'>
<label htmlFor="name" className="block font-semibold">
{isFolder ? 'Folder' : 'Request'} Name
</label>
@@ -75,11 +102,59 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
autoCorrect="off"
autoCapitalize="off"
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 || ''}
/>
{formik.touched.name && formik.errors.name ? <div className="text-red-500">{formik.errors.name}</div> : null}
</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>
</Modal>
);

View File

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

View File

@@ -5,12 +5,16 @@ import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { createCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import InfoTip from 'components/InfoTip';
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 inputRef = useRef();
const dispatch = useDispatch();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const formik = useFormik({
enableReinitialize: true,
@@ -22,12 +26,15 @@ const CreateCollection = ({ onClose }) => {
validationSchema: Yup.object({
collectionName: Yup.string()
.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'),
collectionFolderName: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
.max(255, 'must be 255 characters or less')
.test('is-valid-dir-name', function(value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('folder name 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"
onChange={(e) => {
formik.handleChange(e);
if (formik.values.collectionName === formik.values.collectionFolderName) {
formik.setFieldValue('collectionFolderName', e.target.value);
}
!isEditingFilename && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
}}
autoComplete="off"
autoCorrect="off"
@@ -116,26 +121,42 @@ const CreateCollection = ({ onClose }) => {
Browse
</span>
</div>
<label htmlFor="collection-folder-name" className="flex items-center mt-3">
<span className="font-semibold">Folder Name</span>
<InfoTip
content="This folder will be created under the selected location"
infotipId="collection-folder-name-infotip"
{isEditingFilename ?
<>
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" 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>
<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 ? (
<div className="text-red-500">{formik.errors.collectionFolderName}</div>
) : 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 toast from 'react-hot-toast';
import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
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 dispatch = useDispatch();
const inputRef = useRef();
const [isEditingFilename, toggleEditingFilename] = useState(false);
const formik = useFormik({
enableReinitialize: true,
initialValues: {
folderName: ''
folderName: '',
directoryName: ''
},
validationSchema: Yup.object({
folderName: Yup.string()
.trim()
.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({
name: 'folderName',
message: 'The folder name "environments" at the root of the collection is reserved in bruno',
test: (value) => {
if (item && item.uid) {
return true;
}
if (item?.uid) return true;
return value && !value.trim().toLowerCase().includes('environments');
}
})
}),
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(() => {
toast.success('New folder created!');
onClose()
onClose();
})
.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();
return (
<Modal size="sm" title="New Folder" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={e => e.preventDefault()}>
<Modal size="md" title="New Folder" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div>
<label htmlFor="folderName" className="block font-semibold">
Folder Name
@@ -65,13 +77,59 @@ const NewFolder = ({ collection, item, onClose }) => {
autoCorrect="off"
autoCapitalize="off"
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 || ''}
/>
{formik.touched.folderName && formik.errors.folderName ? (
<div className="text-red-500">{formik.errors.folderName}</div>
) : null}
</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>
</Modal>
);

View File

@@ -1,52 +1,45 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.method-selector-container {
border: solid 1px ${(props) => props.theme.modal.input.border};
border-right: none;
background-color: ${(props) => props.theme.modal.input.bg};
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
const StyledWrapper = styled.div`
div.method-selector-container {
border: solid 1px ${(props) => props.theme.modal.input.border};
border-right: none;
background-color: ${(props) => props.theme.modal.input.bg};
border-top-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;
.method-selector {
min-width: 80px;
}
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
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;
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 HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector';
import { getDefaultRequestPaneTab } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
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 { IconCaretDown } from '@tabler/icons';
import PathDisplay from 'components/PathDisplay';
import StyledWrapper from './StyledWrapper';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
@@ -55,6 +57,8 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
setCurlRequestTypeDetected(type);
};
const [isEditingFilename, toggleEditingFilename] = useState(false);
const getRequestType = (collectionPresets) => {
if (!collectionPresets || !collectionPresets.requestType) {
return 'http-request';
@@ -79,6 +83,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
enableReinitialize: true,
initialValues: {
requestName: '',
filename: '',
requestType: getRequestType(collectionPresets),
requestUrl: collectionPresets.requestUrl || '',
requestMethod: 'GET',
@@ -88,15 +93,18 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestName: Yup.string()
.trim()
.min(1, 'must be at least 1 character')
.required('name is required')
.test({
name: 'requestName',
message: `The request names - collection and folder is reserved in bruno`,
test: (value) => {
const trimmedValue = value ? value.trim().toLowerCase() : '';
return !['collection', 'folder'].includes(trimmedValue);
}
}),
.max(255, 'must be 255 characters or less')
.required('name is required'),
filename: Yup.string()
.trim()
.min(1, 'must be at least 1 character')
.max(255, 'must be 255 characters or less')
.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', {
is: (requestType) => requestType === 'from-curl',
then: Yup.string()
@@ -116,6 +124,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
newEphemeralHttpRequest({
uid: uid,
requestName: values.requestName,
filename: values.filename,
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
@@ -138,6 +147,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
dispatch(
newHttpRequest({
requestName: values.requestName,
filename: values.filename,
requestType: curlRequestTypeDetected,
requestUrl: request.url,
requestMethod: request.method,
@@ -157,6 +167,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
dispatch(
newHttpRequest({
requestName: values.requestName,
filename: values.filename,
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
@@ -221,7 +232,16 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
return (
<StyledWrapper>
<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>
<label htmlFor="requestName" className="block font-semibold">
Type
@@ -287,20 +307,64 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
autoCorrect="off"
autoCapitalize="off"
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 || ''}
/>
{formik.touched.requestName && formik.errors.requestName ? (
<div className="text-red-500">{formik.errors.requestName}</div>
) : null}
</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' ? (
<>
<div className="mt-4">
<label htmlFor="request-url" className="block font-semibold">
URL
</label>
<div className="flex items-center mt-2 ">
<div className="flex items-center h-full method-selector-container">
<HttpMethodSelector

View File

@@ -3,8 +3,9 @@ import cloneDeep from 'lodash/cloneDeep';
import filter from 'lodash/filter';
import find from 'lodash/find';
import get from 'lodash/get';
import set from 'lodash/set';
import trim from 'lodash/trim';
import path from 'path';
import path from 'utils/common/path';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import {
@@ -21,7 +22,6 @@ import {
transformRequestToSaveToFilesystem
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
import { PATH_SEPARATOR, getDirectoryName, isWindowsPath } from 'utils/common/platform';
import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network';
import { callIpc } from 'utils/common/ipc';
@@ -45,9 +45,9 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import slash from 'utils/common/slash';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
export const renameCollection = (newName, collectionUid) => (dispatch, 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 collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -355,14 +355,14 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
if (!itemUid) {
const folderWithSameNameExists = find(
collection.items,
(i) => i.type === 'folder' && trim(i.name) === trim(folderName)
(i) => i.type === 'folder' && trim(i.filename) === trim(directoryName)
);
if (!folderWithSameNameExists) {
const fullName = `${collection.pathname}${PATH_SEPARATOR}${folderName}`;
const fullName = path.join(collection.pathname, directoryName);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName)
.invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.catch((error) => reject(error));
} else {
@@ -373,14 +373,14 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
if (currentItem) {
const folderWithSameNameExists = find(
currentItem.items,
(i) => i.type === 'folder' && trim(i.name) === trim(folderName)
(i) => i.type === 'folder' && trim(i.filename) === trim(directoryName)
);
if (!folderWithSameNameExists) {
const fullName = `${currentItem.pathname}${PATH_SEPARATOR}${folderName}`;
const fullName = path.join(currentItem.pathname, directoryName);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName)
.invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.catch((error) => reject(error));
} else {
@@ -393,8 +393,7 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
});
};
// rename item
export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getState) => {
export const renameItem = ({ newName, newFilename, itemUid, collectionUid }) => (dispatch, getState) => {
const state = getState();
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'));
}
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;
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 collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -443,36 +473,41 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
const folderWithSameNameExists = find(
parentFolder.items,
(i) => i.type === 'folder' && trim(i.name) === trim(newName)
(i) => i.type === 'folder' && trim(i?.filename) === trim(newFilename)
);
if (folderWithSameNameExists) {
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);
return;
}
const parentItem = findParentItemInCollection(collectionCopy, itemUid);
const filename = resolveRequestFilename(newName);
const filename = resolveRequestFilename(newFilename);
const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(item));
itemToSave.name = trim(newName);
set(itemToSave, 'name', trim(newName));
set(itemToSave, 'filename', trim(filename));
if (!parentItem) {
const reqWithSameNameExists = find(
collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
);
if (!reqWithSameNameExists) {
const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`;
const fullPathname = path.join(collection.pathname, filename);
const { ipcRenderer } = window;
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
itemSchema
.validate(itemToSave)
.then(() => ipcRenderer.invoke('renderer:new-request', fullName, itemToSave))
.then(() => ipcRenderer.invoke('renderer:new-request', fullPathname, itemToSave))
.then(resolve)
.catch(reject);
@@ -481,7 +516,7 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
itemPathname: fullName
itemPathname: fullPathname
})
);
} else {
@@ -493,8 +528,8 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
);
if (!reqWithSameNameExists) {
const dirname = getDirectoryName(item.pathname);
const fullName = isWindowsPath(item.pathname) ? path.win32.join(dirname, filename) : path.join(dirname, filename);
const dirname = path.dirname(item.pathname);
const fullName = path.join(dirname, filename);
const { ipcRenderer } = window;
const requestItems = filter(parentItem.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
@@ -719,7 +754,7 @@ export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (di
};
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) => {
const state = getState();
@@ -747,6 +782,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
uid: uuid(),
type: requestType,
name: requestName,
filename,
request: {
method: requestMethod,
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
const filename = resolveRequestFilename(requestName);
const resolvedFilename = resolveRequestFilename(filename);
if (!itemUid) {
const reqWithSameNameExists = find(
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');
item.seq = requestItems.length + 1;
if (!reqWithSameNameExists) {
const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`;
const fullName = path.join(collection.pathname, resolvedFilename);
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
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);
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({
@@ -818,6 +828,35 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
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 {
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
@@ -860,15 +899,17 @@ export const importEnvironment = (name, variables, collectionUid) => (dispatch,
return reject(new Error('Collection not found'));
}
const sanitizedName = sanitizeName(name);
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, name, variables)
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
.then(
dispatch(
updateLastAction({
collectionUid,
lastAction: {
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'));
}
const sanitizedName = sanitizeName(name);
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, name, baseEnv.variables)
.invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
.then(
dispatch(
updateLastAction({
collectionUid,
lastAction: {
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'));
}
const sanitizedName = sanitizeName(newName);
const oldName = environment.name;
environment.name = newName;
environment.name = sanitizedName;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, newName))
.then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, sanitizedName))
.then(resolve)
.catch(reject);
});

View File

@@ -16,10 +16,10 @@ import {
isItemARequest
} from 'utils/collections';
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 mime from 'mime-types';
import path from 'node:path';
import path from 'utils/common/path';
const initialState = {
collections: [],
@@ -1655,25 +1655,29 @@ export const collectionsSlice = createSlice({
}
if (isFolderRoot) {
const folderPath = getDirectoryName(file.meta.pathname);
const folderPath = path.dirname(file.meta.pathname);
const folderItem = findItemInCollectionByPathname(collection, folderPath);
if (folderItem) {
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
}
folderItem.root = file.data;
}
return;
}
if (collection) {
const dirname = getDirectoryName(file.meta.pathname);
const dirname = path.dirname(file.meta.pathname);
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
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) {
childItem = {
uid: uuid(),
pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`,
pathname: currentPath,
name: directoryName,
collapsed: true,
type: 'folder',
@@ -1681,8 +1685,6 @@ export const collectionsSlice = createSlice({
};
currentSubItems.push(childItem);
}
currentPath = `${currentPath}${PATH_SEPARATOR}${directoryName}`;
currentSubItems = childItem.items;
}
@@ -1732,20 +1734,20 @@ export const collectionsSlice = createSlice({
let currentPath = collection.pathname;
let currentSubItems = collection.items;
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) {
childItem = {
uid: uuid(),
pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`,
name: directoryName,
pathname: currentPath,
name: dir?.meta?.name || directoryName,
filename: directoryName,
collapsed: true,
type: 'folder',
items: []
};
currentSubItems.push(childItem);
}
currentPath = `${currentPath}${PATH_SEPARATOR}${directoryName}`;
currentSubItems = childItem.items;
}
addDepth(collection.items);
@@ -1753,11 +1755,25 @@ export const collectionsSlice = createSlice({
},
collectionChangeFileEvent: (state, action) => {
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);
if (isCollectionRoot) {
if (collection) {
collection.root = file.data;
}
return;
}
// check and update collection root
if (collection && file.meta.collectionRoot) {
collection.root = file.data;
if (isFolderRoot) {
const folderPath = path.dirname(file.meta.pathname);
const folderItem = findItemInCollectionByPathname(collection, folderPath);
if (folderItem) {
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
}
folderItem.root = file.data;
}
return;
}

View File

@@ -1,7 +1,6 @@
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
import path from 'path';
import slash from 'utils/common/slash';
import path from 'utils/common/path';
const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if (!str || !str.length || !isString(str)) {
@@ -90,7 +89,7 @@ export const findCollectionByItemUid = (collections, itemUid) => {
};
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) => {
@@ -307,6 +306,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
uid: si.uid,
type: si.type,
name: si.name,
filename: si.filename,
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 path from 'path';
import slash from './slash';
import platform from 'platform';
import path from './path';
export const isElectron = () => {
if (!window) {
@@ -16,35 +15,11 @@ export const resolveRequestFilename = (name) => {
};
export const getSubdirectoriesFromRoot = (rootPath, pathname) => {
// convert to unix style path
pathname = slash(pathname);
rootPath = slash(rootPath);
const relativePath = path.relative(rootPath, pathname);
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 = () => {
const os = platform.os;
const osFamily = os.family.toLowerCase();
@@ -59,8 +34,6 @@ export const isMacOS = () => {
return osFamily.includes('os x');
};
export const PATH_SEPARATOR = isWindowsOS() ? '\\' : '/';
export const getAppInstallDate = () => {
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 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) => {
const transformItems = (items = []) => {
each(items, (item) => {
item.name = normalizeFileName(item.name);
if (['http', 'graphql'].includes(item.type)) {
item.type = `${item.type}-request`;

View File

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

View File

@@ -2,8 +2,8 @@ const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath, sizeInMB } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson, bruToJsonViaWorker ,collectionBruToJson } = require('../bru');
const { hasBruExtension, isWSLPath, normalizeAndResolvePath, sizeInMB } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson, bruToJsonViaWorker, collectionBruToJson } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang');
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');
if (pathname === envDirectory) {
return;
}
let name = path.basename(pathname);
const directory = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
name
}
};
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)) {
try {
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');
if (pathname === envDirectory) {
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 = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
name
}
};
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
@@ -477,14 +516,13 @@ class Watcher {
setTimeout(() => {
const watcher = chokidar.watch(watchPath, {
ignoreInitial: false,
usePolling: watchPath.startsWith('\\\\') || forcePolling ? true : false,
usePolling: isWSLPath(watchPath) || forcePolling ? true : false,
ignored: (filepath) => {
const normalizedPath = isWSLPath(filepath) ? normalizeWslPath(filepath) : normalizeAndResolvePath(filepath);
const normalizedPath = normalizeAndResolvePath(filepath);
const relativePath = path.relative(watchPath, normalizedPath);
return ignores.some((ignorePattern) => {
const normalizedIgnorePattern = isWSLPath(ignorePattern) ? normalizeWslPath(ignorePattern) : ignorePattern.replace(/\\/g, '/');
return relativePath === normalizedIgnorePattern || relativePath.startsWith(normalizedIgnorePattern);
return relativePath === ignorePattern || relativePath.startsWith(ignorePattern);
});
},
persistent: true,

View File

@@ -4,10 +4,9 @@ const fsExtra = require('fs-extra');
const os = require('os');
const path = require('path');
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 {
isValidPathname,
writeFile,
hasBruExtension,
isDirectory,
@@ -15,16 +14,15 @@ const {
browseFiles,
createDirectory,
searchForBruFiles,
sanitizeDirectoryName,
sanitizeName,
isWSLPath,
normalizeWslPath,
normalizeAndResolvePath,
safeToRename,
isWindowsOS,
isValidFilename,
validateName,
hasSubDirectories,
getCollectionStats,
sizeInMB
sizeInMB,
safeWriteFileSync
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@@ -74,7 +72,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
'renderer:create-collection',
async (event, collectionName, collectionFolderName, collectionLocation) => {
try {
collectionFolderName = sanitizeDirectoryName(collectionFolderName);
collectionFolderName = sanitizeName(collectionFolderName);
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(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`);
}
}
if (!isValidPathname(path.basename(dirPath))) {
if (!validateName(path.basename(dirPath))) {
throw new Error(`collection: invalid pathname - ${dirPath}`);
}
@@ -116,13 +115,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle(
'renderer:clone-collection',
async (event, collectionName, collectionFolderName, collectionLocation, previousPath) => {
collectionFolderName = sanitizeDirectoryName(collectionFolderName);
collectionFolderName = sanitizeName(collectionFolderName);
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) {
throw new Error(`collection: ${dirPath} already exists`);
}
if (!isValidPathname(path.basename(dirPath))) {
if (!validateName(path.basename(dirPath))) {
throw new Error(`collection: invalid pathname - ${dirPath}`);
}
@@ -221,8 +220,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(pathname)) {
throw new Error(`path: ${pathname} already exists`);
}
if (!isValidFilename(request.name)) {
throw new Error(`path: ${request.name}.bru is not a valid filename`);
// For the actual filename part, we want to be strict
if (!validateName(request?.filename)) {
throw new Error(`${request.filename}.bru is not a valid filename`);
}
const content = await jsonToBruViaWorker(request);
await writeFile(pathname, content);
@@ -358,18 +358,53 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
});
// rename item
ipcMain.handle('renderer:rename-item', async (event, oldPath, newPath, 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;
ipcMain.handle('renderer:rename-item-name', async (event, { itemPath, newName }) => {
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
if (!fs.existsSync(oldPath)) {
throw new Error(`path: ${oldPath} does not exist`);
@@ -380,6 +415,22 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
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);
for (let bruFile of bruFilesAtSource) {
@@ -387,19 +438,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
moveRequestUid(bruFile, newBruFilePath);
}
// watcher.unlinkItemPathInWatcher(parentDir);
// parentDirUnwatched = true;
/**
* 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
* 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
* This is an alternative approach to avoid that error
*/
if (isWindowsOSAndNotWSLAndItemHasSubDirectories) {
if (isWindowsOSAndNotWSLPathAndItemHasSubDirectories) {
await fsExtra.copy(oldPath, tempDir);
await fsExtra.remove(oldPath);
await fsExtra.move(tempDir, newPath, { overwrite: true });
@@ -407,8 +455,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
} else {
await fs.renameSync(oldPath, newPath);
}
// watcher.addItemPathInWatcher(parentDir);
// parentDirRewatched = true;
return newPath;
}
@@ -417,8 +463,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`path: ${oldPath} is not a bru file`);
}
if (!isValidFilename(newName)) {
throw new Error(`path: ${newName} is not a valid filename`);
if (!validateName(newFilename)) {
throw new Error(`path: ${newFilename} is not a valid filename`);
}
// update name in file and save new copy, then delete old copy
@@ -433,15 +479,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
return newPath;
} 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
// 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)) {
try {
await fsExtra.copy(tempDir, oldPath);
@@ -457,12 +497,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
});
// new folder
ipcMain.handle('renderer:new-folder', async (event, pathname) => {
const resolvedFolderName = sanitizeDirectoryName(path.basename(pathname));
ipcMain.handle('renderer:new-folder', async (event, pathname, folderName) => {
const resolvedFolderName = sanitizeName(path.basename(pathname));
pathname = path.join(path.dirname(pathname), resolvedFolderName);
try {
if (!fs.existsSync(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 {
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) => {
try {
let collectionName = sanitizeDirectoryName(collection.name);
let collectionName = sanitizeName(collection.name);
let collectionPath = path.join(collectionLocation, collectionName);
if (fs.existsSync(collectionPath)) {
@@ -533,13 +581,14 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const parseCollectionItems = (items = [], currentPath) => {
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
const content = await jsonToBruViaWorker(item);
const filePath = path.join(currentPath, `${item.name}.bru`);
fs.writeFileSync(filePath, content);
const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, content);
}
if (item.type === 'folder') {
item.name = sanitizeDirectoryName(item.name);
const folderPath = path.join(currentPath, item.name);
let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath);
if (item?.root?.meta?.name) {
@@ -548,7 +597,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
item.root,
true // isFolder
);
fs.writeFileSync(folderBruFilePath, folderContent);
safeWriteFileSync(folderBruFilePath, folderContent);
}
if (item.items && item.items.length) {
@@ -557,8 +606,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
// Handle items of type 'js'
if (item.type === 'js') {
const filePath = path.join(currentPath, `${item.name}.js`);
fs.writeFileSync(filePath, item.fileContent);
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`);
const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, item.fileContent);
}
});
};
@@ -571,8 +621,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
environments.forEach(async (env) => {
const content = await envJsonToBru(env);
const filePath = path.join(envDirPath, `${env.name}.bru`);
fs.writeFileSync(filePath, content);
let sanitizedEnvFilename = sanitizeName(`${env.name}.bru`);
const filePath = path.join(envDirPath, sanitizedEnvFilename);
safeWriteFileSync(filePath, content);
});
};
@@ -631,19 +682,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
const content = await jsonToBruViaWorker(item);
const filePath = path.join(currentPath, `${item.name}.bru`);
fs.writeFileSync(filePath, content);
const filePath = path.join(currentPath, item.filename);
safeWriteFileSync(filePath, content);
}
if (item.type === 'folder') {
const folderPath = path.join(currentPath, item.name);
const folderPath = path.join(currentPath, item.filename);
fs.mkdirSync(folderPath);
// If folder has a root element, then I should write its folder.bru file
if (item.root) {
const folderContent = await jsonToCollectionBru(item.root, true);
folderContent.name = item.name;
if (folderContent) {
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);
if (folderContent) {
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);
fs.unlinkSync(itemPath);
fs.writeFileSync(newItemPath, itemContent);
safeWriteFileSync(newItemPath, itemContent);
} catch (error) {
return Promise.reject(error);
}

View File

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

View File

@@ -44,6 +44,11 @@ const hasSubDirectories = (dir) => {
};
const normalizeAndResolvePath = (pathname) => {
if (isWSLPath(pathname)) {
return normalizeWSLPath(pathname);
}
if (isSymbolicLink(pathname)) {
const absPath = path.dirname(pathname);
const targetPath = path.resolve(absPath, fs.readlinkSync(pathname));
@@ -59,18 +64,20 @@ const normalizeAndResolvePath = (pathname) => {
function isWSLPath(pathname) {
// Check if the path starts with the WSL prefix
// 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
// 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, '\\');
}
const writeFile = async (pathname, content, isBinary = false) => {
try {
await fs.writeFile(pathname, content, {
await safeWriteFile(pathname, content, {
encoding: !isBinary ? "utf-8" : null
});
} catch (err) {
@@ -110,7 +117,7 @@ const browseDirectory = async (win) => {
return false;
}
const resolvedPath = normalizeAndResolvePath(filePaths[0]);
const resolvedPath = path.resolve(filePaths[0]);
return isDirectory(resolvedPath) ? resolvedPath : false;
};
@@ -124,7 +131,7 @@ const browseFiles = async (win, filters = [], properties = []) => {
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 = '') => {
@@ -154,28 +161,36 @@ const searchForBruFiles = (dir) => {
return searchForFiles(dir, '.bru');
};
const sanitizeDirectoryName = (name) => {
return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-').trim();
const sanitizeName = (name) => {
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 = () => {
return os.platform() === 'win32';
}
const isValidFilename = (fileName) => {
const inValidChars = /[\\/:*?"<>|]/;
const validateName = (name) => {
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)) {
return false;
}
if (reservedDeviceNames.test(name)) return false; // windows reserved names
if (fileName.endsWith(' ') || fileName.endsWith('.') || fileName.startsWith('.')) {
return false;
}
return true;
return (
firstCharacter.test(name) &&
middleCharacters.test(name) &&
lastCharacter.test(name)
);
};
const safeToRename = (oldPath, newPath) => {
try {
// If the new path doesn't exist, it's safe to rename
@@ -244,6 +259,29 @@ const sizeInMB = (size) => {
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 = {
isValidPathname,
exists,
@@ -252,7 +290,7 @@ module.exports = {
isDirectory,
normalizeAndResolvePath,
isWSLPath,
normalizeWslPath,
normalizeWSLPath,
writeFile,
hasJsonExtension,
hasBruExtension,
@@ -262,11 +300,13 @@ module.exports = {
chooseFileToSave,
searchForFiles,
searchForBruFiles,
sanitizeDirectoryName,
sanitizeName,
isWindowsOS,
safeToRename,
isValidFilename,
validateName,
hasSubDirectories,
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', () => {
const input = '<>:"/\\|?*\x00-\x1F';
const expectedOutput = '---';
expect(sanitizeDirectoryName(input)).toEqual(expectedOutput);
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 = '----------------------------------------';
expect(sanitizeName(input)).toEqual(expectedOutput);
});
it('should not modify valid directory names', () => {
const input = 'my-directory';
expect(sanitizeDirectoryName(input)).toEqual(input);
expect(sanitizeName(input)).toEqual(input);
});
it('should replace multiple invalid characters with a single hyphen', () => {
const input = 'my<>invalid?directory';
const expectedOutput = 'my-invalid-directory';
expect(sanitizeDirectoryName(input)).toEqual(expectedOutput);
const expectedOutput = 'my--invalid-directory';
expect(sanitizeName(input)).toEqual(expectedOutput);
});
it('should handle names with slashes', () => {
const input = '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();
bru.runRequest(vm.dump(args))
.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));
})
.catch((err) => {