mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
50 Commits
feat/ssl-s
...
v3.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77916019cd | ||
|
|
02aa669578 | ||
|
|
d8809e09e7 | ||
|
|
04fdd6f8a9 | ||
|
|
3097f3aa76 | ||
|
|
9c3eabdda2 | ||
|
|
7c4da8b8bc | ||
|
|
1e4c3464d2 | ||
|
|
5695f69430 | ||
|
|
d0bbac6b66 | ||
|
|
51e2c045ec | ||
|
|
b585c3e943 | ||
|
|
8150a21395 | ||
|
|
0470e8d1a7 | ||
|
|
031b373bac | ||
|
|
586fd6b7f6 | ||
|
|
51765da0b1 | ||
|
|
5b02aad92a | ||
|
|
606d03180f | ||
|
|
a86551ad27 | ||
|
|
60f8611dd7 | ||
|
|
09be7131cc | ||
|
|
d45a975335 | ||
|
|
6f82eae80f | ||
|
|
ab2326deb3 | ||
|
|
5a4d337ed3 | ||
|
|
cc197e0c30 | ||
|
|
9fa6acca4e | ||
|
|
da892243d2 | ||
|
|
994b60678e | ||
|
|
e001b6ba51 | ||
|
|
59453536a6 | ||
|
|
7fc4ff274d | ||
|
|
663ece708e | ||
|
|
4d17809562 | ||
|
|
f123a2b574 | ||
|
|
facfe325b1 | ||
|
|
707fd405ff | ||
|
|
12ebfee9c6 | ||
|
|
553c45833c | ||
|
|
af4c4b24e6 | ||
|
|
1b8cee4706 | ||
|
|
5151d29aac | ||
|
|
1748741d7f | ||
|
|
b2f8b3bb5b | ||
|
|
f5e437adaf | ||
|
|
39f8ce2a2f | ||
|
|
5944a9cf06 | ||
|
|
fb65edea9e | ||
|
|
84d8051c18 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -50,6 +50,10 @@ bruno.iml
|
||||
.vscode
|
||||
.cursor
|
||||
.claude
|
||||
.codex
|
||||
.agents
|
||||
.agent
|
||||
skills-lock.json
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
@@ -178,7 +178,8 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'error'
|
||||
'no-undef': 'error',
|
||||
'no-case-declarations': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
9229
package-lock.json
generated
9229
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
transform: {
|
||||
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
|
||||
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
|
||||
'^.+\\.[jt]sx?$': 'babel-jest'
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
const babelJest = require('babel-jest')
|
||||
|
||||
module.exports = {
|
||||
process(sourceText, sourcePath, options) {
|
||||
const transformer = babelJest.createTransformer();
|
||||
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
|
||||
}
|
||||
};
|
||||
@@ -27,6 +27,7 @@
|
||||
"codemirror": "5.65.2",
|
||||
"codemirror-graphql": "2.1.1",
|
||||
"cookie": "0.7.1",
|
||||
"diff2html": "^3.4.47",
|
||||
"dompurify": "^3.2.4",
|
||||
"escape-html": "^1.0.3",
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
@@ -38,7 +39,7 @@
|
||||
"github-markdown-css": "^5.2.0",
|
||||
"graphiql": "3.7.1",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^3.7.0",
|
||||
"graphql-request": "4.2.0",
|
||||
"hexy": "^0.3.5",
|
||||
"httpsnippet": "^3.0.9",
|
||||
"i18next": "24.1.2",
|
||||
@@ -101,7 +102,7 @@
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@rsbuild/core": "^1.1.2",
|
||||
"@rsbuild/plugin-babel": "^1.0.3",
|
||||
"@rsbuild/plugin-node-polyfill": "^1.2.0",
|
||||
"@rsbuild/plugin-node-polyfill": "1.2.0",
|
||||
"@rsbuild/plugin-react": "^1.0.7",
|
||||
"@rsbuild/plugin-sass": "^1.1.0",
|
||||
"@rsbuild/plugin-styled-components": "1.1.0",
|
||||
|
||||
3213
packages/bruno-app/public/static/diff2Html.js
Normal file
3213
packages/bruno-app/public/static/diff2Html.js
Normal file
File diff suppressed because it is too large
Load Diff
713
packages/bruno-app/public/static/diff2Html.min.css
vendored
Normal file
713
packages/bruno-app/public/static/diff2Html.min.css
vendored
Normal file
@@ -0,0 +1,713 @@
|
||||
:host,
|
||||
:root {
|
||||
--d2h-bg-color: #fff;
|
||||
--d2h-border-color: #ddd;
|
||||
--d2h-dim-color: rgba(0, 0, 0, 0.3);
|
||||
--d2h-line-border-color: #eee;
|
||||
--d2h-file-header-bg-color: #f7f7f7;
|
||||
--d2h-file-header-border-color: #d8d8d8;
|
||||
--d2h-empty-placeholder-bg-color: #f1f1f1;
|
||||
--d2h-empty-placeholder-border-color: #e1e1e1;
|
||||
--d2h-selected-color: #c8e1ff;
|
||||
--d2h-ins-bg-color: #dfd;
|
||||
--d2h-ins-border-color: #b4e2b4;
|
||||
--d2h-ins-highlight-bg-color: #97f295;
|
||||
--d2h-ins-label-color: #399839;
|
||||
--d2h-del-bg-color: #fee8e9;
|
||||
--d2h-del-border-color: #e9aeae;
|
||||
--d2h-del-highlight-bg-color: #ffb6ba;
|
||||
--d2h-del-label-color: #c33;
|
||||
--d2h-change-del-color: #fdf2d0;
|
||||
--d2h-change-ins-color: #ded;
|
||||
--d2h-info-bg-color: #f8fafd;
|
||||
--d2h-info-border-color: #d5e4f2;
|
||||
--d2h-change-label-color: #d0b44c;
|
||||
--d2h-moved-label-color: #3572b0;
|
||||
--d2h-dark-color: #e6edf3;
|
||||
--d2h-dark-bg-color: #0d1117;
|
||||
--d2h-dark-border-color: #30363d;
|
||||
--d2h-dark-dim-color: #6e7681;
|
||||
--d2h-dark-line-border-color: #21262d;
|
||||
--d2h-dark-file-header-bg-color: #161b22;
|
||||
--d2h-dark-file-header-border-color: #30363d;
|
||||
--d2h-dark-empty-placeholder-bg-color: hsla(215, 8%, 47%, 0.1);
|
||||
--d2h-dark-empty-placeholder-border-color: #30363d;
|
||||
--d2h-dark-selected-color: rgba(56, 139, 253, 0.1);
|
||||
--d2h-dark-ins-bg-color: rgba(46, 160, 67, 0.15);
|
||||
--d2h-dark-ins-border-color: rgba(46, 160, 67, 0.4);
|
||||
--d2h-dark-ins-highlight-bg-color: rgba(46, 160, 67, 0.4);
|
||||
--d2h-dark-ins-label-color: #3fb950;
|
||||
--d2h-dark-del-bg-color: rgba(248, 81, 73, 0.1);
|
||||
--d2h-dark-del-border-color: rgba(248, 81, 73, 0.4);
|
||||
--d2h-dark-del-highlight-bg-color: rgba(248, 81, 73, 0.4);
|
||||
--d2h-dark-del-label-color: #f85149;
|
||||
--d2h-dark-change-del-color: rgba(210, 153, 34, 0.2);
|
||||
--d2h-dark-change-ins-color: rgba(46, 160, 67, 0.25);
|
||||
--d2h-dark-info-bg-color: rgba(56, 139, 253, 0.1);
|
||||
--d2h-dark-info-border-color: rgba(56, 139, 253, 0.4);
|
||||
--d2h-dark-change-label-color: #d29922;
|
||||
--d2h-dark-moved-label-color: #3572b0;
|
||||
}
|
||||
.d2h-wrapper {
|
||||
text-align: left;
|
||||
}
|
||||
.d2h-file-header {
|
||||
background-color: #f7f7f7;
|
||||
background-color: var(--d2h-file-header-bg-color);
|
||||
border-bottom: 1px solid #d8d8d8;
|
||||
border-bottom: 1px solid var(--d2h-file-header-border-color);
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
font-family: Source Sans Pro, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
height: 35px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.d2h-file-header.d2h-sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.d2h-file-stats {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.d2h-lines-added {
|
||||
border: 1px solid #b4e2b4;
|
||||
border: 1px solid var(--d2h-ins-border-color);
|
||||
border-radius: 5px 0 0 5px;
|
||||
color: #399839;
|
||||
color: var(--d2h-ins-label-color);
|
||||
padding: 2px;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.d2h-lines-deleted {
|
||||
border: 1px solid #e9aeae;
|
||||
border: 1px solid var(--d2h-del-border-color);
|
||||
border-radius: 0 5px 5px 0;
|
||||
color: #c33;
|
||||
color: var(--d2h-del-label-color);
|
||||
margin-left: 1px;
|
||||
padding: 2px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.d2h-file-name-wrapper {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
.d2h-file-name {
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.d2h-file-wrapper {
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--d2h-border-color);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.d2h-file-collapse {
|
||||
-webkit-box-pack: end;
|
||||
-ms-flex-pack: end;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
justify-content: flex-end;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--d2h-border-color);
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.d2h-file-collapse.d2h-selected {
|
||||
background-color: #c8e1ff;
|
||||
background-color: var(--d2h-selected-color);
|
||||
}
|
||||
.d2h-file-collapse-input {
|
||||
margin: 0 4px 0 0;
|
||||
}
|
||||
.d2h-diff-table {
|
||||
border-collapse: collapse;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
.d2h-files-diff {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.d2h-file-diff {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.d2h-file-diff.d2h-d-none,
|
||||
.d2h-files-diff.d2h-d-none {
|
||||
display: none;
|
||||
}
|
||||
.d2h-file-side-diff {
|
||||
display: inline-block;
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
width: 50%;
|
||||
}
|
||||
.d2h-code-line {
|
||||
padding: 0 8em;
|
||||
width: calc(100% - 16em);
|
||||
}
|
||||
.d2h-code-line,
|
||||
.d2h-code-side-line {
|
||||
display: inline-block;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.d2h-code-side-line {
|
||||
padding: 0 4.5em;
|
||||
width: calc(100% - 9em);
|
||||
}
|
||||
.d2h-code-line-ctn {
|
||||
background: none;
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
word-wrap: normal;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
vertical-align: middle;
|
||||
white-space: pre;
|
||||
width: 100%;
|
||||
}
|
||||
.d2h-code-line del,
|
||||
.d2h-code-side-line del {
|
||||
background-color: #ffb6ba;
|
||||
background-color: var(--d2h-del-highlight-bg-color);
|
||||
}
|
||||
.d2h-code-line del,
|
||||
.d2h-code-line ins,
|
||||
.d2h-code-side-line del,
|
||||
.d2h-code-side-line ins {
|
||||
border-radius: 0.2em;
|
||||
display: inline-block;
|
||||
margin-top: -1px;
|
||||
-webkit-text-decoration: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.d2h-code-line ins,
|
||||
.d2h-code-side-line ins {
|
||||
background-color: #97f295;
|
||||
background-color: var(--d2h-ins-highlight-bg-color);
|
||||
text-align: left;
|
||||
}
|
||||
.d2h-code-line-prefix {
|
||||
background: none;
|
||||
display: inline;
|
||||
padding: 0;
|
||||
word-wrap: normal;
|
||||
white-space: pre;
|
||||
}
|
||||
.line-num1 {
|
||||
float: left;
|
||||
}
|
||||
.line-num1,
|
||||
.line-num2 {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 0 0.5em;
|
||||
text-overflow: ellipsis;
|
||||
width: 3.5em;
|
||||
}
|
||||
.line-num2 {
|
||||
float: right;
|
||||
}
|
||||
.d2h-code-linenumber {
|
||||
background-color: #fff;
|
||||
background-color: var(--d2h-bg-color);
|
||||
border: solid #eee;
|
||||
border: solid var(--d2h-line-border-color);
|
||||
border-width: 0 1px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--d2h-dim-color);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
text-align: right;
|
||||
width: 7.5em;
|
||||
}
|
||||
.d2h-code-linenumber:after {
|
||||
content: '\200b';
|
||||
}
|
||||
.d2h-code-side-linenumber {
|
||||
background-color: #fff;
|
||||
background-color: var(--d2h-bg-color);
|
||||
border: solid #eee;
|
||||
border: solid var(--d2h-line-border-color);
|
||||
border-width: 0 1px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--d2h-dim-color);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
padding: 0 0.5em;
|
||||
position: absolute;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
width: 4em;
|
||||
}
|
||||
.d2h-code-side-linenumber:after {
|
||||
content: '\200b';
|
||||
}
|
||||
.d2h-code-side-emptyplaceholder,
|
||||
.d2h-emptyplaceholder {
|
||||
background-color: #f1f1f1;
|
||||
background-color: var(--d2h-empty-placeholder-bg-color);
|
||||
border-color: #e1e1e1;
|
||||
border-color: var(--d2h-empty-placeholder-border-color);
|
||||
}
|
||||
.d2h-code-line-prefix,
|
||||
.d2h-code-linenumber,
|
||||
.d2h-code-side-linenumber,
|
||||
.d2h-emptyplaceholder {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.d2h-code-linenumber,
|
||||
.d2h-code-side-linenumber {
|
||||
direction: rtl;
|
||||
}
|
||||
.d2h-del {
|
||||
background-color: #fee8e9;
|
||||
background-color: var(--d2h-del-bg-color);
|
||||
border-color: #e9aeae;
|
||||
border-color: var(--d2h-del-border-color);
|
||||
}
|
||||
.d2h-ins {
|
||||
background-color: #dfd;
|
||||
background-color: var(--d2h-ins-bg-color);
|
||||
border-color: #b4e2b4;
|
||||
border-color: var(--d2h-ins-border-color);
|
||||
}
|
||||
.d2h-info {
|
||||
background-color: #f8fafd;
|
||||
background-color: var(--d2h-info-bg-color);
|
||||
border-color: #d5e4f2;
|
||||
border-color: var(--d2h-info-border-color);
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--d2h-dim-color);
|
||||
}
|
||||
.d2h-file-diff .d2h-del.d2h-change {
|
||||
background-color: #fdf2d0;
|
||||
background-color: var(--d2h-change-del-color);
|
||||
}
|
||||
.d2h-file-diff .d2h-ins.d2h-change {
|
||||
background-color: #ded;
|
||||
background-color: var(--d2h-change-ins-color);
|
||||
}
|
||||
.d2h-file-list-wrapper {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.d2h-file-list-wrapper a {
|
||||
-webkit-text-decoration: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.d2h-file-list-wrapper a,
|
||||
.d2h-file-list-wrapper a:visited {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-moved-label-color);
|
||||
}
|
||||
.d2h-file-list-header {
|
||||
text-align: left;
|
||||
}
|
||||
.d2h-file-list-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.d2h-file-list-line {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
text-align: left;
|
||||
}
|
||||
.d2h-file-list {
|
||||
display: block;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.d2h-file-list > li {
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-bottom: 1px solid var(--d2h-border-color);
|
||||
margin: 0;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.d2h-file-list > li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.d2h-file-switch {
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
}
|
||||
.d2h-icon {
|
||||
margin-right: 10px;
|
||||
vertical-align: middle;
|
||||
fill: currentColor;
|
||||
}
|
||||
.d2h-deleted {
|
||||
color: #c33;
|
||||
color: var(--d2h-del-label-color);
|
||||
}
|
||||
.d2h-added {
|
||||
color: #399839;
|
||||
color: var(--d2h-ins-label-color);
|
||||
}
|
||||
.d2h-changed {
|
||||
color: #d0b44c;
|
||||
color: var(--d2h-change-label-color);
|
||||
}
|
||||
.d2h-moved {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-moved-label-color);
|
||||
}
|
||||
.d2h-tag {
|
||||
background-color: #fff;
|
||||
background-color: var(--d2h-bg-color);
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
font-size: 10px;
|
||||
margin-left: 5px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.d2h-deleted-tag {
|
||||
border: 1px solid #c33;
|
||||
border: 1px solid var(--d2h-del-label-color);
|
||||
}
|
||||
.d2h-added-tag {
|
||||
border: 1px solid #399839;
|
||||
border: 1px solid var(--d2h-ins-label-color);
|
||||
}
|
||||
.d2h-changed-tag {
|
||||
border: 1px solid #d0b44c;
|
||||
border: 1px solid var(--d2h-change-label-color);
|
||||
}
|
||||
.d2h-moved-tag {
|
||||
border: 1px solid #3572b0;
|
||||
border: 1px solid var(--d2h-moved-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
color: #e6edf3;
|
||||
color: var(--d2h-dark-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-header {
|
||||
background-color: #161b22;
|
||||
background-color: var(--d2h-dark-file-header-bg-color);
|
||||
border-bottom: #30363d;
|
||||
border-bottom: var(--d2h-dark-file-header-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-lines-added {
|
||||
border: 1px solid rgba(46, 160, 67, 0.4);
|
||||
border: 1px solid var(--d2h-dark-ins-border-color);
|
||||
color: #3fb950;
|
||||
color: var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-lines-deleted {
|
||||
border: 1px solid rgba(248, 81, 73, 0.4);
|
||||
border: 1px solid var(--d2h-dark-del-border-color);
|
||||
color: #f85149;
|
||||
color: var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-code-line del,
|
||||
.d2h-dark-color-scheme .d2h-code-side-line del {
|
||||
background-color: rgba(248, 81, 73, 0.4);
|
||||
background-color: var(--d2h-dark-del-highlight-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-code-line ins,
|
||||
.d2h-dark-color-scheme .d2h-code-side-line ins {
|
||||
background-color: rgba(46, 160, 67, 0.4);
|
||||
background-color: var(--d2h-dark-ins-highlight-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-diff-tbody {
|
||||
border-color: #30363d;
|
||||
border-color: var(--d2h-dark-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-code-side-linenumber {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
border-color: #21262d;
|
||||
border-color: var(--d2h-dark-line-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,
|
||||
.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder {
|
||||
background-color: hsla(215, 8%, 47%, 0.1);
|
||||
background-color: var(--d2h-dark-empty-placeholder-bg-color);
|
||||
border-color: #30363d;
|
||||
border-color: var(--d2h-dark-empty-placeholder-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-code-linenumber {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
border-color: #21262d;
|
||||
border-color: var(--d2h-dark-line-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-del {
|
||||
background-color: rgba(248, 81, 73, 0.1);
|
||||
background-color: var(--d2h-dark-del-bg-color);
|
||||
border-color: rgba(248, 81, 73, 0.4);
|
||||
border-color: var(--d2h-dark-del-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-ins {
|
||||
background-color: rgba(46, 160, 67, 0.15);
|
||||
background-color: var(--d2h-dark-ins-bg-color);
|
||||
border-color: rgba(46, 160, 67, 0.4);
|
||||
border-color: var(--d2h-dark-ins-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-info {
|
||||
background-color: rgba(56, 139, 253, 0.1);
|
||||
background-color: var(--d2h-dark-info-bg-color);
|
||||
border-color: rgba(56, 139, 253, 0.4);
|
||||
border-color: var(--d2h-dark-info-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change {
|
||||
background-color: rgba(210, 153, 34, 0.2);
|
||||
background-color: var(--d2h-dark-change-del-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change {
|
||||
background-color: rgba(46, 160, 67, 0.25);
|
||||
background-color: var(--d2h-dark-change-ins-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-wrapper {
|
||||
border: 1px solid #30363d;
|
||||
border: 1px solid var(--d2h-dark-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-collapse {
|
||||
border: 1px solid #0d1117;
|
||||
border: 1px solid var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected {
|
||||
background-color: rgba(56, 139, 253, 0.1);
|
||||
background-color: var(--d2h-dark-selected-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-list-wrapper a,
|
||||
.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-list > li {
|
||||
border-bottom: 1px solid #0d1117;
|
||||
border-bottom: 1px solid var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-deleted {
|
||||
color: #f85149;
|
||||
color: var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-added {
|
||||
color: #3fb950;
|
||||
color: var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-changed {
|
||||
color: #d29922;
|
||||
color: var(--d2h-dark-change-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-moved {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-tag {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-deleted-tag {
|
||||
border: 1px solid #f85149;
|
||||
border: 1px solid var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-added-tag {
|
||||
border: 1px solid #3fb950;
|
||||
border: 1px solid var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-changed-tag {
|
||||
border: 1px solid #d29922;
|
||||
border: 1px solid var(--d2h-dark-change-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-moved-tag {
|
||||
border: 1px solid #3572b0;
|
||||
border: 1px solid var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.d2h-auto-color-scheme {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
color: #e6edf3;
|
||||
color: var(--d2h-dark-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-header {
|
||||
background-color: #161b22;
|
||||
background-color: var(--d2h-dark-file-header-bg-color);
|
||||
border-bottom: #30363d;
|
||||
border-bottom: var(--d2h-dark-file-header-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-lines-added {
|
||||
border: 1px solid rgba(46, 160, 67, 0.4);
|
||||
border: 1px solid var(--d2h-dark-ins-border-color);
|
||||
color: #3fb950;
|
||||
color: var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-lines-deleted {
|
||||
border: 1px solid rgba(248, 81, 73, 0.4);
|
||||
border: 1px solid var(--d2h-dark-del-border-color);
|
||||
color: #f85149;
|
||||
color: var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-code-line del,
|
||||
.d2h-auto-color-scheme .d2h-code-side-line del {
|
||||
background-color: rgba(248, 81, 73, 0.4);
|
||||
background-color: var(--d2h-dark-del-highlight-bg-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-code-line ins,
|
||||
.d2h-auto-color-scheme .d2h-code-side-line ins {
|
||||
background-color: rgba(46, 160, 67, 0.4);
|
||||
background-color: var(--d2h-dark-ins-highlight-bg-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-diff-tbody {
|
||||
border-color: #30363d;
|
||||
border-color: var(--d2h-dark-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-code-side-linenumber {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
border-color: #21262d;
|
||||
border-color: var(--d2h-dark-line-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,
|
||||
.d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder {
|
||||
background-color: hsla(215, 8%, 47%, 0.1);
|
||||
background-color: var(--d2h-dark-empty-placeholder-bg-color);
|
||||
border-color: #30363d;
|
||||
border-color: var(--d2h-dark-empty-placeholder-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-code-linenumber {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
border-color: #21262d;
|
||||
border-color: var(--d2h-dark-line-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-del {
|
||||
background-color: rgba(248, 81, 73, 0.1);
|
||||
background-color: var(--d2h-dark-del-bg-color);
|
||||
border-color: rgba(248, 81, 73, 0.4);
|
||||
border-color: var(--d2h-dark-del-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-ins {
|
||||
background-color: rgba(46, 160, 67, 0.15);
|
||||
background-color: var(--d2h-dark-ins-bg-color);
|
||||
border-color: rgba(46, 160, 67, 0.4);
|
||||
border-color: var(--d2h-dark-ins-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-info {
|
||||
background-color: rgba(56, 139, 253, 0.1);
|
||||
background-color: var(--d2h-dark-info-bg-color);
|
||||
border-color: rgba(56, 139, 253, 0.4);
|
||||
border-color: var(--d2h-dark-info-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change {
|
||||
background-color: rgba(210, 153, 34, 0.2);
|
||||
background-color: var(--d2h-dark-change-del-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change {
|
||||
background-color: rgba(46, 160, 67, 0.25);
|
||||
background-color: var(--d2h-dark-change-ins-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-wrapper {
|
||||
border: 1px solid #30363d;
|
||||
border: 1px solid var(--d2h-dark-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-collapse {
|
||||
border: 1px solid #0d1117;
|
||||
border: 1px solid var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-collapse.d2h-selected {
|
||||
background-color: rgba(56, 139, 253, 0.1);
|
||||
background-color: var(--d2h-dark-selected-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-list-wrapper a,
|
||||
.d2h-auto-color-scheme .d2h-file-list-wrapper a:visited {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-list > li {
|
||||
border-bottom: 1px solid #0d1117;
|
||||
border-bottom: 1px solid var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-deleted {
|
||||
color: #f85149;
|
||||
color: var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-added {
|
||||
color: #3fb950;
|
||||
color: var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-changed {
|
||||
color: #d29922;
|
||||
color: var(--d2h-dark-change-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-moved {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-tag {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-deleted-tag {
|
||||
border: 1px solid #f85149;
|
||||
border: 1px solid var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-added-tag {
|
||||
border: 1px solid #3fb950;
|
||||
border: 1px solid var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-changed-tag {
|
||||
border: 1px solid #d29922;
|
||||
border: 1px solid var(--d2h-dark-change-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-moved-tag {
|
||||
border: 1px solid #3572b0;
|
||||
border: 1px solid var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.CodeMirror {
|
||||
height: calc(100vh - 4rem);
|
||||
height: calc(100vh - 9rem);
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
font-family: ${(props) => (props.font ? props.font : 'default')};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from './CodeEditor/index';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import { saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { useState } from 'react';
|
||||
|
||||
const FileEditor = ({ apiSpec }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [content, setContent] = useState(apiSpec?.raw);
|
||||
|
||||
const onEdit = (value) => {
|
||||
setContent(value);
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
dispatch(saveApiSpecToFile({ uid: apiSpec?.uid, content }));
|
||||
};
|
||||
|
||||
const hasChanges = Boolean(content != apiSpec?.raw);
|
||||
|
||||
const editorMode = 'yaml';
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow relative">
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode={editorMode}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
<IconDeviceFloppy
|
||||
onClick={onSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer oapcity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
||||
@@ -2,15 +2,868 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.swagger-root {
|
||||
height: calc(100vh - 4rem);
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
height: calc(100vh - 7rem);
|
||||
border-left: solid 1px ${(props) => props.theme.border.border1};
|
||||
overflow-y: auto;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-bottom: 20px;
|
||||
|
||||
&.dark {
|
||||
.swagger-ui {
|
||||
filter: invert(88%) hue-rotate(180deg);
|
||||
/* ── Global reset ── */
|
||||
.swagger-ui {
|
||||
font-family: inherit;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
* {
|
||||
border-color: ${(props) => props.theme.border.border1};
|
||||
}
|
||||
.swagger-ui .microlight {
|
||||
filter: invert(100%) hue-rotate(180deg);
|
||||
|
||||
.auth-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
select {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 0 20px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* ── Info section ── */
|
||||
.info {
|
||||
margin: 16px 0 12px;
|
||||
|
||||
hgroup.main {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
small {
|
||||
padding: 2px 6px !important;
|
||||
font-size: 10px;
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
|
||||
pre {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.base-url {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
p, li {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin: 3px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Version / OAS badges */
|
||||
.version-stamp span.version {
|
||||
background: ${(props) => props.theme.border.border1} !important;
|
||||
border: 1px solid ${(props) => props.theme.colors.text.muted} !important;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.version-pragma {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
/* ── Tag section headings ── */
|
||||
.opblock-tag-section {
|
||||
.opblock-tag {
|
||||
font-size: ${(props) => props.theme.font.size.md};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-bottom: none;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.background.mantle};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Operation blocks (GET, POST, PUT, DELETE, PATCH) ── */
|
||||
.opblock {
|
||||
margin: 0 0 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${(props) => props.theme.border.border1} !important;
|
||||
background: ${(props) => props.theme.bg} !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
.opblock-summary {
|
||||
padding: 6px 10px;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
.opblock-summary-method {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.opblock-summary-path {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
|
||||
a, span {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
}
|
||||
|
||||
.opblock-summary-description {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.opblock-summary-control {
|
||||
svg {
|
||||
fill: ${(props) => props.theme.colors.text.muted};
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.opblock-body {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.bg};
|
||||
border-top: 1px solid ${(props) => props.theme.border.border1};
|
||||
|
||||
.opblock-description-wrapper,
|
||||
.opblock-section {
|
||||
p {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
}
|
||||
|
||||
.tab-header .tab-item {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 3px;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 3px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Method badge colors — keep them but tone down */
|
||||
.opblock.opblock-get .opblock-summary-method { background: #61affe; color: #fff; }
|
||||
.opblock.opblock-post .opblock-summary-method { background: #49cc90; color: #fff; }
|
||||
.opblock.opblock-put .opblock-summary-method { background: #fca130; color: #fff; }
|
||||
.opblock.opblock-delete .opblock-summary-method { background: #f93e3e; color: #fff; }
|
||||
.opblock.opblock-patch .opblock-summary-method { background: #50e3c2; color: #000; }
|
||||
|
||||
/* Lock / authorization icons */
|
||||
.authorization__btn {
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.colors.text.muted};
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
table {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
|
||||
thead {
|
||||
tr {
|
||||
th {
|
||||
font-size: ${(props) => props.theme.font.size.xs} !important;
|
||||
color: ${(props) => props.theme.colors.text.muted} !important;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1} !important;
|
||||
padding: 6px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.parameter__name {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&.required::after {
|
||||
color: ${(props) => props.theme.colors.text.danger || '#c0392b'};
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
}
|
||||
}
|
||||
|
||||
.parameter__type {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.parameter__in {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
/* ── Models / Schemas ── */
|
||||
section.models {
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-bottom: 0px;
|
||||
margin-bottom: 40px;
|
||||
margin-top: 8px;
|
||||
|
||||
h4 {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-bottom: none;
|
||||
padding: 6px 10px;
|
||||
margin: 0;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.colors.text.muted};
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-container {
|
||||
background: ${(props) => props.theme.bg} !important;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.model-box {
|
||||
background: ${(props) => props.theme.bg} !important;
|
||||
padding: 2px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.model {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.text};
|
||||
line-height: 1.4;
|
||||
|
||||
.prop-type {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.prop-format {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
span.prop-enum {
|
||||
display: block;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.model-example {
|
||||
|
||||
.tab li {
|
||||
color: ${(props) => props.theme.colors.text.muted} !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Model expand/collapse toggle */
|
||||
.model-toggle {
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&::after {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
/* Model box inner styling */
|
||||
.model-box {
|
||||
background: ${(props) => props.theme.bg} !important;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
/* Inner model details */
|
||||
.inner-object {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
/* Model title (schema name) */
|
||||
.model-title {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── JSON Schema 2020-12 (OpenAPI 3.1) schema overrides ── */
|
||||
.json-schema-2020-12-accordion,
|
||||
.json-schema-2020-12-expand-deep-button,
|
||||
section.models h4 button,
|
||||
.model-box button,
|
||||
.models-control,
|
||||
.opblock-summary,
|
||||
.opblock-summary-control,
|
||||
.opblock-tag {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
.opblock-summary:focus-visible,
|
||||
.opblock-tag:focus-visible,
|
||||
.models-control:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.textLink} !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.json-schema-2020-12__title {
|
||||
font-size: 12px !important;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
.json-schema-2020-12-head {
|
||||
padding: 4px 8px !important;
|
||||
background: ${(props) => props.theme.bg} !important;
|
||||
|
||||
.json-schema-2020-12-accordion {
|
||||
padding: 0 !important;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* chevron / arrow icon */
|
||||
.json-schema-2020-12-accordion__icon {
|
||||
fill: ${(props) => props.theme.colors.text.muted} !important;
|
||||
}
|
||||
|
||||
button.json-schema-2020-12-expand-deep-button {
|
||||
font-size: 10px !important;
|
||||
color: ${(props) => props.theme.colors.text.muted} !important;
|
||||
background: transparent !important;
|
||||
padding: 0 4px !important;
|
||||
}
|
||||
|
||||
strong.json-schema-2020-12__attribute--primary {
|
||||
font-size: 11px !important;
|
||||
color: ${(props) => props.theme.textLink} !important;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.json-schema-2020-12-body {
|
||||
font-size: 11px !important;
|
||||
margin-left: 16px;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
|
||||
.json-schema-2020-12-property {
|
||||
margin-left: 8px;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
border-color: ${(props) => props.theme.border.border1} !important;
|
||||
}
|
||||
|
||||
/* property names */
|
||||
.json-schema-2020-12__title {
|
||||
font-size: 11px !important;
|
||||
font-weight: normal;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
/* type badges inside expanded schema */
|
||||
strong.json-schema-2020-12__attribute--primary {
|
||||
font-size: 10px !important;
|
||||
color: ${(props) => props.theme.textLink} !important;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
strong.json-schema-2020-12__attribute {
|
||||
font-size: 10px !important;
|
||||
color: ${(props) => props.theme.colors.text.muted} !important;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.json-schema-2020-12 {
|
||||
font-size: 11px !important;
|
||||
margin: 0 !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
background: ${(props) => props.theme.bg} !important;
|
||||
}
|
||||
|
||||
/* JSON viewer (Examples section inside schema properties) */
|
||||
.json-schema-2020-12-json-viewer {
|
||||
background: transparent !important;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
.json-schema-2020-12-json-viewer__name {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
.json-schema-2020-12-json-viewer__name--secondary {
|
||||
color: ${(props) => props.theme.colors.text.muted} !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
.json-schema-2020-12-json-viewer__value {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
.json-schema-2020-12-json-viewer__value--secondary {
|
||||
color: ${(props) => props.theme.colors.text.subtext0} !important;
|
||||
}
|
||||
|
||||
.json-schema-2020-12-json-viewer__value--string,
|
||||
.json-schema-2020-12-json-viewer__value--string.json-schema-2020-12-json-viewer__value--secondary {
|
||||
color: ${(props) => props.theme.colors.text.green} !important;
|
||||
}
|
||||
|
||||
.json-schema-2020-12-json-viewer__value--number,
|
||||
.json-schema-2020-12-json-viewer__value--bigint,
|
||||
.json-schema-2020-12-json-viewer__value--number.json-schema-2020-12-json-viewer__value--secondary,
|
||||
.json-schema-2020-12-json-viewer__value--bigint.json-schema-2020-12-json-viewer__value--secondary {
|
||||
color: ${(props) => props.theme.textLink} !important;
|
||||
}
|
||||
|
||||
.json-schema-2020-12-json-viewer__value--boolean,
|
||||
.json-schema-2020-12-json-viewer__value--boolean.json-schema-2020-12-json-viewer__value--secondary {
|
||||
color: ${(props) => props.theme.colors.text.warning} !important;
|
||||
}
|
||||
|
||||
.json-schema-2020-12-json-viewer__value--null,
|
||||
.json-schema-2020-12-json-viewer__value--undefined {
|
||||
color: ${(props) => props.theme.colors.text.muted} !important;
|
||||
}
|
||||
|
||||
/* enum/keyword example values container */
|
||||
.json-schema-2020-12-keyword--examples,
|
||||
[data-json-schema-keyword="examples"] {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
/* Model collapse/expand all link */
|
||||
span.model-toggle {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Brace styling in models */
|
||||
.brace-open, .brace-close {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── Code / Response blocks ── */
|
||||
.microlight {
|
||||
background: ${(props) => props.theme.codemirror.bg} !important;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
|
||||
.highlight-code {
|
||||
background: ${(props) => props.theme.codemirror.bg} !important;
|
||||
|
||||
> .microlight {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.response-col_status {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.response-col_description {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.responses-inner {
|
||||
h4, h5 {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
border-radius: 4px;
|
||||
box-shadow: none !important;
|
||||
color: ${(props) => props.theme.text};
|
||||
border-color: ${(props) => props.theme.border.border1};
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn.authorize {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-color: ${(props) => props.theme.border.border1};
|
||||
background: transparent;
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
span {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.btn.execute {
|
||||
background: ${(props) => props.theme.primary?.solid || props.theme.textLink};
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
.btn {
|
||||
background: ${(props) => props.theme.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Links ── */
|
||||
a {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
}
|
||||
|
||||
/* ── Servers / Scheme container ── */
|
||||
.scheme-container {
|
||||
background: ${(props) => props.theme.background.mantle} !important;
|
||||
border-top: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
padding: 10px;
|
||||
box-shadow: none !important;
|
||||
|
||||
.schemes-title {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
select {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── SVGs / icons ── */
|
||||
svg {
|
||||
fill: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
svg.arrow {
|
||||
fill: ${(props) => props.theme.text};
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.expand-operation svg {
|
||||
fill: ${(props) => props.theme.colors.text.muted};
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* ── Misc / catch-all ── */
|
||||
.loading-container .loading::after {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
|
||||
.renderedMarkdown p {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
|
||||
.opblock-section-header {
|
||||
background: ${(props) => props.theme.background.mantle} !important;
|
||||
box-shadow: none !important;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
padding: 6px 10px;
|
||||
|
||||
h4 {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
.copy-to-clipboard {
|
||||
button {
|
||||
background: ${(props) => props.theme.background.mantle};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dialog / modal overrides */
|
||||
.dialog-ux {
|
||||
.modal-ux {
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 6px;
|
||||
color: ${(props) => props.theme.text};
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
|
||||
.modal-ux-header {
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
padding: 12px 0px;
|
||||
|
||||
h3 {
|
||||
font-size: ${(props) => props.theme.font.size.md};
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
opacity: 0.6;
|
||||
&:hover { opacity: 1; }
|
||||
svg { fill: ${(props) => props.theme.text}; }
|
||||
}
|
||||
}
|
||||
|
||||
.modal-ux-content {
|
||||
color: ${(props) => props.theme.text};
|
||||
padding: 12px 16px;
|
||||
|
||||
p {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
/* Section headings like "api_key (apiKey)" */
|
||||
h4, h5, h6 {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.textLink};
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
|
||||
/* Labels: "Name:", "In:", "Flow:", "Value:", etc. */
|
||||
label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
> span {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
/* "Scopes:" heading */
|
||||
.scopes h2 {
|
||||
font-size: ${(props) => props.theme.font.size.sm} !important;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
}
|
||||
|
||||
/* Scope item name + description */
|
||||
.scopes .checkbox {
|
||||
p.name {
|
||||
font-size: ${(props) => props.theme.font.size.sm} !important;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p.description {
|
||||
font-size: ${(props) => props.theme.font.size.xs} !important;
|
||||
color: ${(props) => props.theme.colors.text.muted} !important;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Text inputs */
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"] {
|
||||
background: ${(props) => props.theme.background.mantle} !important;
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
border: 1px solid ${(props) => props.theme.border.border1} !important;
|
||||
border-radius: 4px !important;
|
||||
font-size: ${(props) => props.theme.font.size.sm} !important;
|
||||
padding: 6px 10px !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.textLink} !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Checkboxes — custom styled to match theme */
|
||||
input[type="checkbox"] {
|
||||
appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
min-width: 14px;
|
||||
border: 1px solid ${(props) => props.theme.border.border1} !important;
|
||||
border-radius: 3px !important;
|
||||
background: ${(props) => props.theme.background.mantle} !important;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
|
||||
&:checked {
|
||||
background: ${(props) => props.theme.textLink} !important;
|
||||
border-color: ${(props) => props.theme.textLink} !important;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 8px;
|
||||
border: 2px solid #fff;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* "select all / select none" links */
|
||||
a {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.textLink};
|
||||
}
|
||||
|
||||
/* Dividers between auth sections */
|
||||
hr {
|
||||
border-color: ${(props) => props.theme.border.border1};
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* Authorize / Close buttons */
|
||||
.btn-done,
|
||||
.auth-btn-wrapper .btn {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-radius: 4px;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.background.mantle};
|
||||
}
|
||||
|
||||
&.modal-btn-operation {
|
||||
background: ${(props) => props.theme.textLink};
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop-ux {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const Swagger = ({ string }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
console.log('string', string);
|
||||
|
||||
const Swagger = ({ spec }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className={`swagger-root w-full overflow-y-scroll ${displayedTheme}`}>
|
||||
<SwaggerUI spec={string} />
|
||||
<div className="swagger-root w-full">
|
||||
<SwaggerUI spec={spec} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
71
packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js
Normal file
71
packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useEffect, Suspense } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import CodeEditor from './FileEditor/CodeEditor/index';
|
||||
import Swagger from './Renderers/Swagger';
|
||||
|
||||
/**
|
||||
* Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right).
|
||||
*
|
||||
* Props:
|
||||
* - content (string) The spec content (YAML/JSON string)
|
||||
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
|
||||
* - onSave (function) Called with current editor content on save (editable mode only)
|
||||
*/
|
||||
const SpecViewer = ({ content, readOnly, onSave }) => {
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [editorContent, setEditorContent] = useState(content);
|
||||
|
||||
// Sync editor when saved content changes from outside (e.g. after save completes)
|
||||
useEffect(() => {
|
||||
setEditorContent(content);
|
||||
}, [content]);
|
||||
|
||||
const hasChanges = !readOnly && editorContent !== content;
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave) onSave(editorContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="main flex flex-grow pl-4 relative">
|
||||
<div className="w-full grid grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<div className="flex flex-grow relative">
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={readOnly ? content : editorContent}
|
||||
readOnly={readOnly ? 'nocursor' : false}
|
||||
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
|
||||
onSave={readOnly ? undefined : handleSave}
|
||||
mode="yaml"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
{!readOnly && onSave && (
|
||||
<IconDeviceFloppy
|
||||
onClick={handleSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Suspense fallback="">
|
||||
<Swagger spec={content} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecViewer;
|
||||
@@ -3,13 +3,11 @@ import find from 'lodash/find';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconFileCode, IconDots } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FileEditor from './FileEditor';
|
||||
import SpecViewer from './SpecViewer';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { openApiSpec, saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { useState } from 'react';
|
||||
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
|
||||
import { Suspense } from 'react';
|
||||
import Swagger from './Renderers/Swagger';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const ApiSpecPanel = () => {
|
||||
@@ -78,18 +76,10 @@ const ApiSpecPanel = () => {
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<section className="main flex flex-grow px-4 relative">
|
||||
<div className="w-full grid grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<FileEditor apiSpec={apiSpec} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Suspense fallback="">
|
||||
<Swagger string={raw} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<SpecViewer
|
||||
content={raw}
|
||||
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -160,7 +160,6 @@ const AppTitleBar = () => {
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
toast.success('Workspace created!');
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import stripJsonComments from 'strip-json-comments';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -47,9 +46,6 @@ export default class CodeEditor extends React.Component {
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -221,9 +217,6 @@ export default class CodeEditor extends React.Component {
|
||||
|
||||
// Setup lint error tooltip on line number hover
|
||||
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(editor, this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,12 +288,6 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
if (this.props.onScroll) {
|
||||
this.props.onScroll(this.editor);
|
||||
|
||||
@@ -99,24 +99,6 @@ const Wrapper = styled.div`
|
||||
.name-cell-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.name-highlight-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.search-highlight {
|
||||
background: ${(props) => props.theme.colors.accent}55;
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
|
||||
@@ -31,15 +31,6 @@ const TableRow = React.memo(
|
||||
}
|
||||
);
|
||||
|
||||
const highlightText = (text, query) => {
|
||||
if (!query?.trim() || !text) return text;
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
|
||||
);
|
||||
};
|
||||
|
||||
const EnvironmentVariablesTable = ({
|
||||
environment,
|
||||
collection,
|
||||
@@ -51,8 +42,7 @@ const EnvironmentVariablesTable = ({
|
||||
renderExtraValueContent,
|
||||
searchQuery = ''
|
||||
}) => {
|
||||
const { storedTheme, theme } = useTheme();
|
||||
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
|
||||
@@ -60,7 +50,7 @@ const EnvironmentVariablesTable = ({
|
||||
const [tableHeight, setTableHeight] = useState(MIN_H);
|
||||
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
|
||||
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -103,6 +93,13 @@ const EnvironmentVariablesTable = ({
|
||||
setTableHeight(h);
|
||||
}, []);
|
||||
|
||||
const handleRowFocus = useCallback((uid) => {
|
||||
setPinnedData((prev) => ({
|
||||
query: searchQuery,
|
||||
uids: prev.query === searchQuery ? new Set([...prev.uids, uid]) : new Set([uid])
|
||||
}));
|
||||
}, [searchQuery]);
|
||||
|
||||
const prevEnvUidRef = useRef(null);
|
||||
const prevEnvVariablesRef = useRef(environment.variables);
|
||||
const mountedRef = useRef(false);
|
||||
@@ -205,6 +202,10 @@ const EnvironmentVariablesTable = ({
|
||||
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
|
||||
}, [environment.variables]);
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedData({ query: '', uids: new Set() });
|
||||
}, [savedValuesJson]);
|
||||
|
||||
// Sync modified state
|
||||
useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
@@ -358,6 +359,7 @@ const EnvironmentVariablesTable = ({
|
||||
onSave(cloneDeep(variablesToSave))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
onDraftClear();
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
@@ -376,7 +378,7 @@ const EnvironmentVariablesTable = ({
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
});
|
||||
}, [formik.values, environment.variables, onSave, setIsModified]);
|
||||
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const originalVars = environment.variables || [];
|
||||
@@ -418,12 +420,20 @@ const EnvironmentVariablesTable = ({
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
|
||||
return allVariables.filter(({ variable }) => {
|
||||
if (effectivePins.has(variable.uid)) return true;
|
||||
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
|
||||
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
|
||||
const valueText
|
||||
= typeof variable.value === 'string'
|
||||
? variable.value
|
||||
: typeof variable.value === 'number' || typeof variable.value === 'boolean'
|
||||
? String(variable.value)
|
||||
: '';
|
||||
const valueMatch = valueText.toLowerCase().includes(query);
|
||||
return !!(nameMatch || valueMatch);
|
||||
});
|
||||
}, [formik.values, searchQuery]);
|
||||
}, [formik.values, searchQuery, pinnedData]);
|
||||
|
||||
const isSearchActive = !!searchQuery?.trim();
|
||||
|
||||
@@ -460,11 +470,6 @@ const EnvironmentVariablesTable = ({
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
const activeQuery = searchQuery?.trim().toLowerCase();
|
||||
const valueMatchesOnly = activeQuery
|
||||
&& !(variable.name?.toLowerCase().includes(activeQuery))
|
||||
&& typeof variable.value === 'string'
|
||||
&& variable.value.toLowerCase().includes(activeQuery);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -475,8 +480,7 @@ const EnvironmentVariablesTable = ({
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
@@ -494,29 +498,25 @@ const EnvironmentVariablesTable = ({
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
readOnly={isSearchActive}
|
||||
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
onBlur={() => {
|
||||
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
|
||||
handleNameBlur(actualIndex);
|
||||
}}
|
||||
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
|
||||
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
|
||||
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
|
||||
/>
|
||||
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
|
||||
<div className="name-highlight-overlay">
|
||||
{highlightText(variable.name || '', searchQuery)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="flex flex-row flex-nowrap items-center"
|
||||
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
|
||||
style={{ width: columnWidths.value }}
|
||||
>
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<div
|
||||
className="overflow-hidden grow w-full relative"
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
@@ -524,7 +524,7 @@ const EnvironmentVariablesTable = ({
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={isSearchActive || typeof variable.value !== 'string'}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
|
||||
// Clear ephemeral metadata when user manually edits the value
|
||||
@@ -555,14 +555,13 @@ const EnvironmentVariablesTable = ({
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import useDeferredLoading from 'hooks/useDeferredLoading';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DotEnvTableView from './DotEnvTableView';
|
||||
@@ -31,6 +32,7 @@ const DotEnvFileEditor = ({
|
||||
const [rawValue, setRawValue] = useState(initialRawValue);
|
||||
const [prevViewMode, setPrevViewMode] = useState(viewMode);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const showSaving = useDeferredLoading(isSaving, 200);
|
||||
|
||||
const formikRef = useRef(null);
|
||||
|
||||
@@ -311,7 +313,7 @@ const DotEnvFileEditor = ({
|
||||
onChange={handleRawChange}
|
||||
onSave={handleSaveRaw}
|
||||
onReset={handleReset}
|
||||
isSaving={isSaving}
|
||||
isSaving={showSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
@@ -335,7 +337,7 @@ const DotEnvFileEditor = ({
|
||||
onRemoveVar={handleRemoveVar}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
isSaving={isSaving}
|
||||
isSaving={showSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ const Wrapper = styled.div`
|
||||
border: 1px solid ${(props) => props.theme.app.collection.toolbar.environmentSelector.border};
|
||||
line-height: 1rem;
|
||||
transition: all 0.15s ease;
|
||||
height: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBorder};
|
||||
@@ -73,7 +74,7 @@ const Wrapper = styled.div`
|
||||
border-top: 0.0625rem solid ${(props) => props.theme.dropdown.separator};
|
||||
z-index: 10;
|
||||
margin: 0;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.bg + ' !important'};
|
||||
}
|
||||
@@ -119,7 +120,7 @@ const Wrapper = styled.div`
|
||||
.environment-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: calc(75vh - 8rem);
|
||||
max-height: calc(75vh - 8rem);
|
||||
padding-bottom: 2.625rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 8px 20px;
|
||||
padding: 9px 20px 8px 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.title {
|
||||
|
||||
@@ -72,7 +72,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
@@ -110,7 +110,15 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-inline: 4px !important;
|
||||
padding-left: 6px !important;
|
||||
border-radius: 6px ;
|
||||
padding-right: 3px !important;
|
||||
padding-block: 4px !important;
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
@@ -153,7 +161,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: 13px;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
.environment-name {
|
||||
|
||||
@@ -49,7 +49,6 @@ const EnvironmentList = ({
|
||||
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
|
||||
const envListSearchInputRef = useRef(null);
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
|
||||
@@ -84,6 +83,8 @@ const EnvironmentList = ({
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
const environmentsDraftUid = collection?.environmentsDraft?.environmentUid;
|
||||
|
||||
const handleDotEnvModifiedChange = useCallback((modified) => {
|
||||
setIsDotEnvModified(modified);
|
||||
if (modified) {
|
||||
@@ -92,10 +93,10 @@ const EnvironmentList = ({
|
||||
environmentUid: `dotenv:${selectedDotEnvFile}`,
|
||||
variables: []
|
||||
}));
|
||||
} else {
|
||||
} else if (environmentsDraftUid?.startsWith('dotenv:')) {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
}
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile]);
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile, environmentsDraftUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
@@ -558,51 +559,65 @@ const EnvironmentList = ({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
const next = !isEnvListSearchExpanded;
|
||||
setIsEnvListSearchExpanded(next);
|
||||
if (!next) setSearchText('');
|
||||
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleCreateEnvClick();
|
||||
}}
|
||||
title="Search environments"
|
||||
title="Create environment"
|
||||
>
|
||||
<IconSearch size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleImportClick();
|
||||
}}
|
||||
title="Import environment"
|
||||
>
|
||||
<IconDownload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleExportClick();
|
||||
}}
|
||||
title="Export environment"
|
||||
>
|
||||
<IconUpload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{isEnvListSearchExpanded && (
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
className="env-list-search-clear"
|
||||
title="Clear search"
|
||||
onClick={() => setSearchText('')}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { IconChevronDown, IconChevronRight } from '@tabler/icons';
|
||||
|
||||
const CollapsibleDiffRow = ({ title, isCollapsed, onToggle, oldContent, newContent, hasOldContent, hasNewContent }) => {
|
||||
if (!hasOldContent && !hasNewContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="diff-row">
|
||||
<div className="diff-row-header" onClick={onToggle}>
|
||||
<span className="collapse-toggle">
|
||||
{isCollapsed ? (
|
||||
<IconChevronRight size={14} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<span className="diff-row-title">{title}</span>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="diff-row-content">
|
||||
<div className="diff-row-pane old">
|
||||
{hasOldContent ? oldContent : <div className="empty-placeholder" />}
|
||||
</div>
|
||||
<div className="diff-row-pane new">
|
||||
{hasNewContent ? newContent : <div className="empty-placeholder" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleDiffRow;
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
const AUTH_TYPE_LABELS = {
|
||||
awsv4: 'AWS Signature v4',
|
||||
basic: 'Basic Auth',
|
||||
bearer: 'Bearer Token',
|
||||
digest: 'Digest Auth',
|
||||
ntlm: 'NTLM',
|
||||
oauth2: 'OAuth 2.0',
|
||||
wsse: 'WSSE',
|
||||
apikey: 'API Key'
|
||||
};
|
||||
|
||||
const AUTH_FIELD_LABELS = {
|
||||
// AWS v4
|
||||
accessKeyId: 'Access Key ID',
|
||||
secretAccessKey: 'Secret Access Key',
|
||||
sessionToken: 'Session Token',
|
||||
service: 'Service',
|
||||
region: 'Region',
|
||||
profileName: 'Profile Name',
|
||||
// Basic/Digest/NTLM/WSSE
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
domain: 'Domain',
|
||||
// Bearer
|
||||
token: 'Token',
|
||||
// API Key
|
||||
key: 'Key',
|
||||
value: 'Value',
|
||||
placement: 'Placement',
|
||||
// OAuth2
|
||||
grantType: 'Grant Type',
|
||||
callbackUrl: 'Callback URL',
|
||||
authorizationUrl: 'Authorization URL',
|
||||
accessTokenUrl: 'Access Token URL',
|
||||
refreshTokenUrl: 'Refresh Token URL',
|
||||
clientId: 'Client ID',
|
||||
clientSecret: 'Client Secret',
|
||||
scope: 'Scope',
|
||||
state: 'State',
|
||||
pkce: 'PKCE',
|
||||
credentialsPlacement: 'Credentials Placement',
|
||||
credentialsId: 'Credentials ID',
|
||||
tokenPlacement: 'Token Placement',
|
||||
tokenHeaderPrefix: 'Token Header Prefix',
|
||||
tokenQueryKey: 'Token Query Key',
|
||||
autoFetchToken: 'Auto Fetch Token',
|
||||
autoRefreshToken: 'Auto Refresh Token'
|
||||
};
|
||||
|
||||
const VisualDiffAuth = ({ oldData, newData, showSide }) => {
|
||||
const oldAuth = get(oldData, 'request.auth', {});
|
||||
const newAuth = get(newData, 'request.auth', {});
|
||||
|
||||
const currentAuth = showSide === 'old' ? oldAuth : newAuth;
|
||||
const otherAuth = showSide === 'old' ? newAuth : oldAuth;
|
||||
|
||||
const authTypes = useMemo(() => {
|
||||
const types = new Set([...Object.keys(currentAuth), ...Object.keys(otherAuth)]);
|
||||
types.delete('mode');
|
||||
return Array.from(types);
|
||||
}, [currentAuth, otherAuth]);
|
||||
|
||||
const authSections = useMemo(() => {
|
||||
return authTypes.map((authType) => {
|
||||
const rawCurrentConfig = currentAuth[authType];
|
||||
const rawOtherConfig = otherAuth[authType];
|
||||
const currentConfig = (typeof rawCurrentConfig === 'object' && rawCurrentConfig !== null) ? rawCurrentConfig : {};
|
||||
const otherConfig = (typeof rawOtherConfig === 'object' && rawOtherConfig !== null) ? rawOtherConfig : {};
|
||||
|
||||
if (Object.keys(currentConfig).length === 0 && showSide === 'old') {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(currentConfig).length === 0 && showSide === 'new') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let sectionStatus = 'unchanged';
|
||||
if (Object.keys(otherConfig).length === 0) {
|
||||
sectionStatus = showSide === 'old' ? 'deleted' : 'added';
|
||||
} else if (!isEqual(currentConfig, otherConfig)) {
|
||||
sectionStatus = 'modified';
|
||||
}
|
||||
|
||||
const allFields = new Set([...Object.keys(currentConfig), ...Object.keys(otherConfig)]);
|
||||
const fields = Array.from(allFields).map((field) => {
|
||||
const currentValue = currentConfig[field];
|
||||
const otherValue = otherConfig[field];
|
||||
|
||||
let status = 'unchanged';
|
||||
if (otherValue === undefined) {
|
||||
status = showSide === 'old' ? 'deleted' : 'added';
|
||||
} else if (currentValue !== otherValue) {
|
||||
status = 'modified';
|
||||
}
|
||||
|
||||
let displayValue = currentValue;
|
||||
if (typeof displayValue === 'boolean') {
|
||||
displayValue = displayValue ? 'true' : 'false';
|
||||
} else if (displayValue === undefined || displayValue === null) {
|
||||
displayValue = '';
|
||||
}
|
||||
|
||||
return {
|
||||
key: AUTH_FIELD_LABELS[field] || field,
|
||||
value: String(displayValue),
|
||||
status
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
type: authType,
|
||||
label: AUTH_TYPE_LABELS[authType] || authType,
|
||||
status: sectionStatus,
|
||||
fields
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}, [authTypes, currentAuth, otherAuth, showSide]);
|
||||
|
||||
const currentMode = currentAuth.mode;
|
||||
const otherMode = otherAuth.mode;
|
||||
const modeStatus = currentMode !== otherMode ? (otherMode === undefined ? (showSide === 'old' ? 'deleted' : 'added') : 'modified') : 'unchanged';
|
||||
|
||||
if (authSections.length === 0 && !currentMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentMode && (
|
||||
<div className="diff-section">
|
||||
<table className="diff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '30px' }}></th>
|
||||
<th style={{ width: '40%' }}>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className={modeStatus}>
|
||||
<td>
|
||||
{modeStatus !== 'unchanged' && (
|
||||
<span className={`status-badge ${modeStatus}`}>
|
||||
{modeStatus === 'added' ? 'A' : modeStatus === 'deleted' ? 'D' : 'M'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="key-cell">Auth Mode</td>
|
||||
<td className="value-cell">{AUTH_TYPE_LABELS[currentMode] || currentMode}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{authSections.map((section) => (
|
||||
<div key={section.type} className="diff-section">
|
||||
<div className="diff-section-header">
|
||||
<span>{section.label}</span>
|
||||
{section.status !== 'unchanged' && (
|
||||
<span className={`status-badge ${section.status}`}>
|
||||
{section.status === 'added' ? 'A' : section.status === 'deleted' ? 'D' : 'M'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<table className="diff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '30px' }}></th>
|
||||
<th style={{ width: '40%' }}>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.fields.map((field, index) => (
|
||||
<tr key={index} className={field.status}>
|
||||
<td>
|
||||
{field.status !== 'unchanged' && (
|
||||
<span className={`status-badge ${field.status}`}>
|
||||
{field.status === 'added' ? 'A' : field.status === 'deleted' ? 'D' : 'M'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="key-cell">{field.key}</td>
|
||||
<td className="value-cell">{field.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualDiffAuth;
|
||||
@@ -0,0 +1,353 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { computeLineDiffForOld, computeLineDiffForNew } from './utils/diffUtils';
|
||||
|
||||
const BODY_TYPE_LABELS = {
|
||||
json: 'JSON',
|
||||
text: 'Text',
|
||||
xml: 'XML',
|
||||
sparql: 'SPARQL',
|
||||
graphql: 'GraphQL',
|
||||
formUrlEncoded: 'Form URL Encoded',
|
||||
multipartForm: 'Multipart Form',
|
||||
file: 'File',
|
||||
grpc: 'gRPC',
|
||||
ws: 'WebSocket'
|
||||
};
|
||||
|
||||
const TEXT_BODY_TYPES = ['json', 'text', 'xml', 'sparql'];
|
||||
const FORM_BODY_TYPES = ['formUrlEncoded', 'multipartForm'];
|
||||
const ALL_BODY_TYPES = Object.keys(BODY_TYPE_LABELS);
|
||||
|
||||
const VisualDiffBody = ({ oldData, newData, showSide }) => {
|
||||
const oldBody = get(oldData, 'request.body', {});
|
||||
const newBody = get(newData, 'request.body', {});
|
||||
|
||||
const currentBody = showSide === 'old' ? oldBody : newBody;
|
||||
const otherBody = showSide === 'old' ? newBody : oldBody;
|
||||
|
||||
const bodyTypes = useMemo(() => {
|
||||
const currentMode = currentBody.mode;
|
||||
const otherMode = otherBody.mode;
|
||||
|
||||
// Collect body types that match either side's active mode
|
||||
const relevantTypes = new Set();
|
||||
if (currentMode && currentMode !== 'none') {
|
||||
relevantTypes.add(currentMode);
|
||||
}
|
||||
if (otherMode && otherMode !== 'none') {
|
||||
relevantTypes.add(otherMode);
|
||||
}
|
||||
|
||||
// If neither side has a mode (legacy data), fall back to showing all defined types
|
||||
if (relevantTypes.size === 0) {
|
||||
return ALL_BODY_TYPES.filter((type) => {
|
||||
const currentVal = currentBody[type];
|
||||
const otherVal = otherBody[type];
|
||||
return currentVal !== undefined || otherVal !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
// Only show body types that match the active mode on either side
|
||||
return ALL_BODY_TYPES.filter((type) => {
|
||||
if (!relevantTypes.has(type)) return false;
|
||||
const currentVal = currentBody[type];
|
||||
const otherVal = otherBody[type];
|
||||
return currentVal !== undefined || otherVal !== undefined;
|
||||
});
|
||||
}, [currentBody, otherBody]);
|
||||
|
||||
const renderLineDiff = (segments) => {
|
||||
return segments.map((segment, index) => (
|
||||
<div key={index} className={`diff-line ${segment.status}`}>
|
||||
{segment.text || '\u00A0'}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
const renderFormData = (items, otherItems) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
const otherItemMap = new Map();
|
||||
(otherItems || []).forEach((item) => {
|
||||
otherItemMap.set(item.name, item);
|
||||
});
|
||||
|
||||
return (
|
||||
<table className="diff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '30px' }}></th>
|
||||
<th className="checkbox-cell"></th>
|
||||
<th style={{ width: '40%' }}>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => {
|
||||
const otherItem = otherItemMap.get(item.name);
|
||||
let status = 'unchanged';
|
||||
if (!otherItem) {
|
||||
status = showSide === 'old' ? 'deleted' : 'added';
|
||||
} else if (item.value !== otherItem.value || item.enabled !== otherItem.enabled) {
|
||||
status = 'modified';
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={`${item.name}-${index}`} className={status}>
|
||||
<td>
|
||||
{status !== 'unchanged' && (
|
||||
<span className={`status-badge ${status}`}>
|
||||
{status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.enabled !== false}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</td>
|
||||
<td className="key-cell">{item.name}</td>
|
||||
<td className="value-cell">{item.value}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFileBody = (files, otherFiles) => {
|
||||
if (!files || files.length === 0) return null;
|
||||
|
||||
const otherFileMap = new Map();
|
||||
(otherFiles || []).forEach((f, idx) => {
|
||||
otherFileMap.set(f.filePath || idx, f);
|
||||
});
|
||||
|
||||
return (
|
||||
<table className="diff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '30px' }}></th>
|
||||
<th className="checkbox-cell"></th>
|
||||
<th>File Path</th>
|
||||
<th style={{ width: '100px' }}>Content Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file, index) => {
|
||||
const otherFile = otherFileMap.get(file.filePath || index);
|
||||
let status = 'unchanged';
|
||||
if (!otherFile) {
|
||||
status = showSide === 'old' ? 'deleted' : 'added';
|
||||
} else if (file.filePath !== otherFile.filePath || file.contentType !== otherFile.contentType) {
|
||||
status = 'modified';
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={index} className={status}>
|
||||
<td>
|
||||
{status !== 'unchanged' && (
|
||||
<span className={`status-badge ${status}`}>
|
||||
{status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="checkbox-cell">
|
||||
<input type="checkbox" checked={file.selected !== false} readOnly disabled />
|
||||
</td>
|
||||
<td className="value-cell">{file.filePath}</td>
|
||||
<td className="value-cell">{file.contentType || '-'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessageBody = (messages, otherMessages, typeLabel) => {
|
||||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
return messages.map((msg, index) => {
|
||||
const otherMsg = (otherMessages || [])[index];
|
||||
const contentDiff = showSide === 'old'
|
||||
? computeLineDiffForOld(msg.content || '', otherMsg?.content || '')
|
||||
: computeLineDiffForNew(otherMsg?.content || '', msg.content || '');
|
||||
|
||||
let msgStatus = 'unchanged';
|
||||
if (!otherMsg) {
|
||||
msgStatus = showSide === 'old' ? 'deleted' : 'added';
|
||||
} else if (msg.name !== otherMsg.name || msg.type !== otherMsg.type) {
|
||||
msgStatus = 'modified';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="diff-section-header">
|
||||
<span>{typeLabel}: {msg.name || `Message ${index + 1}`}{msg.type ? ` (${msg.type})` : ''}</span>
|
||||
{msgStatus !== 'unchanged' && (
|
||||
<span className={`status-badge ${msgStatus}`}>
|
||||
{msgStatus === 'added' ? 'A' : msgStatus === 'deleted' ? 'D' : 'M'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="code-diff-content">{renderLineDiff(contentDiff)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderGraphqlBody = (graphql, otherGraphql) => {
|
||||
const currentQuery = graphql?.query || '';
|
||||
const otherQuery = otherGraphql?.query || '';
|
||||
const currentVariables = graphql?.variables || '';
|
||||
const otherVariables = otherGraphql?.variables || '';
|
||||
|
||||
const queryDiff = showSide === 'old'
|
||||
? computeLineDiffForOld(currentQuery, otherQuery)
|
||||
: computeLineDiffForNew(otherQuery, currentQuery);
|
||||
|
||||
const variablesDiff = showSide === 'old'
|
||||
? computeLineDiffForOld(currentVariables, otherVariables)
|
||||
: computeLineDiffForNew(otherVariables, currentVariables);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(currentQuery || otherQuery) && (
|
||||
<div>
|
||||
<div className="diff-section-header">Query</div>
|
||||
<div className="code-diff-content">{renderLineDiff(queryDiff)}</div>
|
||||
</div>
|
||||
)}
|
||||
{(currentVariables || otherVariables) && (
|
||||
<div>
|
||||
<div className="diff-section-header">Variables</div>
|
||||
<div className="code-diff-content">{renderLineDiff(variablesDiff)}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTextBody = (currentContent, otherContent) => {
|
||||
const diffSegments = showSide === 'old'
|
||||
? computeLineDiffForOld(currentContent || '', otherContent || '')
|
||||
: computeLineDiffForNew(otherContent || '', currentContent || '');
|
||||
|
||||
return (
|
||||
<div className="code-diff-content">
|
||||
{renderLineDiff(diffSegments)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBodyType = (type) => {
|
||||
const currentVal = currentBody[type];
|
||||
const otherVal = otherBody[type];
|
||||
|
||||
if (currentVal === undefined && otherVal === undefined) return null;
|
||||
|
||||
// For text-based body types
|
||||
if (TEXT_BODY_TYPES.includes(type)) {
|
||||
if (!currentVal) return null;
|
||||
return renderTextBody(currentVal, otherVal);
|
||||
}
|
||||
|
||||
// For form data types
|
||||
if (FORM_BODY_TYPES.includes(type)) {
|
||||
return renderFormData(currentVal, otherVal);
|
||||
}
|
||||
|
||||
// GraphQL
|
||||
if (type === 'graphql') {
|
||||
return renderGraphqlBody(currentVal, otherVal);
|
||||
}
|
||||
|
||||
// File
|
||||
if (type === 'file') {
|
||||
return renderFileBody(currentVal, otherVal);
|
||||
}
|
||||
|
||||
// gRPC
|
||||
if (type === 'grpc') {
|
||||
return renderMessageBody(currentVal, otherVal, 'gRPC');
|
||||
}
|
||||
|
||||
// WebSocket
|
||||
if (type === 'ws') {
|
||||
return renderMessageBody(currentVal, otherVal, 'WebSocket');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Show body mode if present
|
||||
const currentMode = currentBody.mode;
|
||||
const otherMode = otherBody.mode;
|
||||
const modeStatus = currentMode !== otherMode ? (otherMode === undefined ? (showSide === 'old' ? 'deleted' : 'added') : 'modified') : 'unchanged';
|
||||
|
||||
if (bodyTypes.length === 0 && !currentMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentMode && (
|
||||
<div className="diff-section">
|
||||
<table className="diff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '30px' }}></th>
|
||||
<th style={{ width: '40%' }}>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className={modeStatus}>
|
||||
<td>
|
||||
{modeStatus !== 'unchanged' && (
|
||||
<span className={`status-badge ${modeStatus}`}>
|
||||
{modeStatus === 'added' ? 'A' : modeStatus === 'deleted' ? 'D' : 'M'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="key-cell">Body Mode</td>
|
||||
<td className="value-cell">{BODY_TYPE_LABELS[currentMode] || currentMode}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{bodyTypes.map((type) => {
|
||||
const content = renderBodyType(type);
|
||||
if (!content) return null;
|
||||
|
||||
const currentVal = currentBody[type];
|
||||
const otherVal = otherBody[type];
|
||||
const hasChanges = !isEqual(currentVal, otherVal);
|
||||
|
||||
return (
|
||||
<div key={type} className="diff-section">
|
||||
<div className="diff-section-header">
|
||||
<span>{BODY_TYPE_LABELS[type] || type}</span>
|
||||
{hasChanges && (
|
||||
<span className={`status-badge ${otherVal === undefined ? (showSide === 'old' ? 'deleted' : 'added') : 'modified'}`}>
|
||||
{otherVal === undefined ? (showSide === 'old' ? 'D' : 'A') : 'M'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualDiffBody;
|
||||
@@ -0,0 +1,443 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.visual-diff-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.diff-header-row {
|
||||
display: flex;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.diff-header-pane {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
text-transform: uppercase;
|
||||
|
||||
&.old {
|
||||
border-right: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
}
|
||||
|
||||
.diff-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.diff-row {
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diff-row-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.diff-row-title {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.diff-row-content {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: ${(props) => props.theme.background.base};
|
||||
}
|
||||
|
||||
.diff-row-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&.old {
|
||||
border-left: 2px solid ${(props) => props.theme.colors.text.danger}20;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
&.new {
|
||||
border-left: 2px solid ${(props) => props.theme.colors.text.green}20;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
border: 1px dashed ${(props) => props.theme.border.border1};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
}
|
||||
|
||||
.empty-placeholder::after {
|
||||
content: 'No content';
|
||||
}
|
||||
|
||||
.diff-section {
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
overflow: hidden;
|
||||
|
||||
&.added {
|
||||
border-color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
&.deleted {
|
||||
border-color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
|
||||
.diff-section-header {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
font-weight: 500;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.diff-section-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.url-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
|
||||
.method {
|
||||
font-weight: 600;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
text-transform: uppercase;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background: ${(props) => props.theme.brand}15;
|
||||
color: ${(props) => props.theme.brand};
|
||||
}
|
||||
|
||||
.url {
|
||||
flex: 1;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.text};
|
||||
word-break: break-all;
|
||||
|
||||
&.changed {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 20%, transparent);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
}
|
||||
}
|
||||
|
||||
.method.changed {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 30%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.warning};
|
||||
}
|
||||
}
|
||||
|
||||
.diff-inline {
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
|
||||
&.added {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 25%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
&.deleted {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 25%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&.modified {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 25%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.warning};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.diff-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
|
||||
th, td {
|
||||
padding: 0.375rem 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
}
|
||||
|
||||
tr.added {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 10%, transparent);
|
||||
}
|
||||
|
||||
tr.deleted {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 10%, transparent);
|
||||
}
|
||||
|
||||
tr.modified {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 10%, transparent);
|
||||
}
|
||||
|
||||
.checkbox-cell {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: default;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
accent-color: ${(props) => props.theme.colors.accent};
|
||||
vertical-align: middle;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.key-cell {
|
||||
font-family: 'Fira Code', monospace;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
font-family: 'Fira Code', monospace;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
border-radius: 2px;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
|
||||
&.added {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 13%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
&.deleted {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 13%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
&.modified {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 13%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.warning};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-diff-content {
|
||||
max-height: 250px;
|
||||
overflow: auto;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
line-height: 1.5;
|
||||
|
||||
.diff-line {
|
||||
padding: 0 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
&.unchanged {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.added {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
&.deleted {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 15%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.example-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.example-block {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.example-block-header {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.example-subsection {
|
||||
margin-bottom: 0.375rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.example-subsection-title {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.example-description {
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
|
||||
.status-code {
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
|
||||
&.changed {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 20%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.warning};
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.changed {
|
||||
color: ${(props) => props.theme.colors.text.warning};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.example-subsection .diff-table {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.example-subsection .code-diff-content {
|
||||
max-height: 150px;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
font-family: 'Fira Code', monospace;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
|
||||
&.added {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
|
||||
border-color: ${(props) => props.theme.colors.text.green};
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
&.deleted {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 15%, transparent);
|
||||
border-color: ${(props) => props.theme.colors.text.danger};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&.modified {
|
||||
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 15%, transparent);
|
||||
border-color: ${(props) => props.theme.colors.text.warning};
|
||||
color: ${(props) => props.theme.colors.text.warning};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import CollapsibleDiffRow from '../CollapsibleDiffRow';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
/**
|
||||
* VisualDiffContent - Presentational component for rendering visual diffs
|
||||
*
|
||||
* This is a reusable component that renders the visual diff UI.
|
||||
* It can be used by:
|
||||
* - Git VisualDiffViewer (for git diffs)
|
||||
* - OpenAPI ChangeSection (for spec diffs)
|
||||
*
|
||||
* Props:
|
||||
* - oldData: The "before" data
|
||||
* - newData: The "after" data
|
||||
* - sections: Array of section configs { key, title, Component, hasContent }
|
||||
* - sectionHasChanges: Function (sectionKey, oldData, newData) => boolean
|
||||
* - oldLabel: Label for the left/old pane (default: "Before")
|
||||
* - newLabel: Label for the right/new pane (default: "After")
|
||||
* - hideUnchanged: Hide sections without changes entirely (default: false)
|
||||
*/
|
||||
const VisualDiffContent = ({
|
||||
oldData,
|
||||
newData,
|
||||
sections,
|
||||
sectionHasChanges,
|
||||
oldLabel = 'Before',
|
||||
newLabel = 'After',
|
||||
hideUnchanged = false
|
||||
}) => {
|
||||
const [collapsedSections, setCollapsedSections] = useState({});
|
||||
|
||||
const toggleSection = (sectionKey) => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[sectionKey]: !prev[sectionKey]
|
||||
}));
|
||||
};
|
||||
|
||||
// Auto-collapse unchanged sections (collapsed but still visible)
|
||||
useEffect(() => {
|
||||
if (!sectionHasChanges || (!oldData && !newData)) return;
|
||||
|
||||
const initialCollapsed = {};
|
||||
sections.forEach(({ key }) => {
|
||||
const hasChanges = sectionHasChanges(key, oldData, newData);
|
||||
initialCollapsed[key] = !hasChanges;
|
||||
});
|
||||
|
||||
setCollapsedSections(initialCollapsed);
|
||||
}, [oldData, newData, sections, sectionHasChanges]);
|
||||
|
||||
if (!oldData && !newData) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="empty-state">
|
||||
No content to display
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
|
||||
<div className="visual-diff-content">
|
||||
<div className="diff-header-row">
|
||||
<div className="diff-header-pane old">{oldLabel}</div>
|
||||
<div className="diff-header-pane new">{newLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="diff-sections">
|
||||
{sections.map(({ key, title, Component, hasContent: checkContent }) => {
|
||||
const hasOld = oldData && checkContent(oldData);
|
||||
const hasNew = newData && checkContent(newData);
|
||||
|
||||
if (!hasOld && !hasNew) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hide sections without changes entirely when hideUnchanged is enabled
|
||||
if (hideUnchanged && sectionHasChanges && !sectionHasChanges(key, oldData, newData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsibleDiffRow
|
||||
key={key}
|
||||
title={title}
|
||||
isCollapsed={collapsedSections[key] || false}
|
||||
onToggle={() => toggleSection(key)}
|
||||
hasOldContent={hasOld}
|
||||
hasNewContent={hasNew}
|
||||
oldContent={
|
||||
<Component oldData={oldData} newData={newData} showSide="old" />
|
||||
}
|
||||
newContent={
|
||||
<Component oldData={oldData} newData={newData} showSide="new" />
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualDiffContent;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const VisualDiffHeaders = ({ oldData, newData, showSide }) => {
|
||||
const oldHeaders = get(oldData, 'request.headers', []);
|
||||
const newHeaders = get(newData, 'request.headers', []);
|
||||
|
||||
const currentHeaders = showSide === 'old' ? oldHeaders : newHeaders;
|
||||
const otherHeaders = showSide === 'old' ? newHeaders : oldHeaders;
|
||||
|
||||
const headersWithStatus = useMemo(() => {
|
||||
const otherHeaderMap = new Map();
|
||||
otherHeaders.forEach((h) => {
|
||||
otherHeaderMap.set(h.name, h);
|
||||
});
|
||||
|
||||
return currentHeaders.map((header) => {
|
||||
const otherHeader = otherHeaderMap.get(header.name);
|
||||
|
||||
let status = 'unchanged';
|
||||
if (!otherHeader) {
|
||||
status = showSide === 'old' ? 'deleted' : 'added';
|
||||
} else if (header.value !== otherHeader.value || header.enabled !== otherHeader.enabled) {
|
||||
status = 'modified';
|
||||
}
|
||||
|
||||
return { ...header, status };
|
||||
});
|
||||
}, [currentHeaders, otherHeaders, showSide]);
|
||||
|
||||
if (headersWithStatus.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="diff-section">
|
||||
<table className="diff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '30px' }}></th>
|
||||
<th className="checkbox-cell"></th>
|
||||
<th style={{ width: '40%' }}>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{headersWithStatus.map((header, index) => (
|
||||
<tr key={`${header.name}-${index}`} className={header.status}>
|
||||
<td>
|
||||
{header.status !== 'unchanged' && (
|
||||
<span className={`status-badge ${header.status}`}>
|
||||
{header.status === 'added' ? 'A' : header.status === 'deleted' ? 'D' : 'M'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={header.enabled !== false}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</td>
|
||||
<td className="key-cell">{header.name}</td>
|
||||
<td className="value-cell">{header.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualDiffHeaders;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const VisualDiffParams = ({ oldData, newData, showSide }) => {
|
||||
const oldParams = get(oldData, 'request.params', []);
|
||||
const newParams = get(newData, 'request.params', []);
|
||||
|
||||
const currentParams = showSide === 'old' ? oldParams : newParams;
|
||||
const otherParams = showSide === 'old' ? newParams : oldParams;
|
||||
|
||||
const paramsWithStatus = useMemo(() => {
|
||||
const otherParamMap = new Map();
|
||||
otherParams.forEach((p) => {
|
||||
otherParamMap.set(p.name, p);
|
||||
});
|
||||
|
||||
return currentParams.map((param) => {
|
||||
const otherParam = otherParamMap.get(param.name);
|
||||
|
||||
let status = 'unchanged';
|
||||
if (!otherParam) {
|
||||
status = showSide === 'old' ? 'deleted' : 'added';
|
||||
} else if (param.value !== otherParam.value || param.enabled !== otherParam.enabled) {
|
||||
status = 'modified';
|
||||
}
|
||||
|
||||
return { ...param, status };
|
||||
});
|
||||
}, [currentParams, otherParams, showSide]);
|
||||
|
||||
const queryParams = paramsWithStatus.filter((p) => p.type === 'query');
|
||||
const pathParams = paramsWithStatus.filter((p) => p.type === 'path');
|
||||
|
||||
if (queryParams.length === 0 && pathParams.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderTable = (params, title) => {
|
||||
if (params.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="diff-section">
|
||||
<div className="diff-section-header">{title}</div>
|
||||
<table className="diff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '30px' }}></th>
|
||||
<th className="checkbox-cell"></th>
|
||||
<th style={{ width: '40%' }}>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{params.map((param, index) => (
|
||||
<tr key={`${param.name}-${index}`} className={param.status}>
|
||||
<td>
|
||||
{param.status !== 'unchanged' && (
|
||||
<span className={`status-badge ${param.status}`}>
|
||||
{param.status === 'added' ? 'A' : param.status === 'deleted' ? 'D' : 'M'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={param.enabled !== false}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</td>
|
||||
<td className="key-cell">{param.name}</td>
|
||||
<td className="value-cell">{param.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderTable(queryParams, 'Query Parameters')}
|
||||
{renderTable(pathParams, 'Path Parameters')}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualDiffParams;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { computeWordDiffForOld, computeWordDiffForNew } from './utils/diffUtils';
|
||||
import { getMethod, getUrl } from './utils/bruUtils';
|
||||
|
||||
const VisualDiffUrlBar = ({ oldData, newData, showSide }) => {
|
||||
const oldMethod = getMethod(oldData);
|
||||
const newMethod = getMethod(newData);
|
||||
const oldUrl = getUrl(oldData);
|
||||
const newUrl = getUrl(newData);
|
||||
|
||||
const currentMethod = showSide === 'old' ? oldMethod : newMethod;
|
||||
|
||||
const urlDiffSegments = useMemo(() => {
|
||||
if (showSide === 'old') {
|
||||
return computeWordDiffForOld(oldUrl, newUrl);
|
||||
} else {
|
||||
return computeWordDiffForNew(oldUrl, newUrl);
|
||||
}
|
||||
}, [oldUrl, newUrl, showSide]);
|
||||
|
||||
const methodChanged = oldMethod !== newMethod;
|
||||
const methodStatus = useMemo(() => {
|
||||
if (!methodChanged) return 'unchanged';
|
||||
if (showSide === 'old') return 'deleted';
|
||||
return 'added';
|
||||
}, [methodChanged, showSide]);
|
||||
|
||||
const renderDiffSegments = (segments) => {
|
||||
return segments.map((segment, index) => {
|
||||
if (segment.status === 'unchanged') {
|
||||
return <span key={index}>{segment.text}</span>;
|
||||
}
|
||||
return (
|
||||
<span key={index} className={`diff-inline ${segment.status}`}>
|
||||
{segment.text}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="diff-section">
|
||||
<div className="url-bar">
|
||||
<span className={`method ${methodStatus !== 'unchanged' ? `diff-inline ${methodStatus}` : ''}`}>
|
||||
{currentMethod?.toUpperCase() || 'GET'}
|
||||
</span>
|
||||
<span className="url">
|
||||
{renderDiffSegments(urlDiffSegments)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualDiffUrlBar;
|
||||
@@ -0,0 +1,53 @@
|
||||
import get from 'lodash/get';
|
||||
|
||||
export const DIFF_STATUS = Object.freeze({
|
||||
ADDED: 'added',
|
||||
DELETED: 'deleted',
|
||||
MODIFIED: 'modified',
|
||||
UNCHANGED: 'unchanged'
|
||||
});
|
||||
|
||||
export const getBodyContent = (body) => {
|
||||
if (!body) return '';
|
||||
if (body.json) return body.json;
|
||||
if (body.text) return body.text;
|
||||
if (body.xml) return body.xml;
|
||||
if (body.sparql) return body.sparql;
|
||||
if (body.graphql?.query) return body.graphql.query;
|
||||
if (body.content) return body.content;
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getBodyMode = (body) => {
|
||||
if (!body) return 'none';
|
||||
if (body.json !== undefined) return 'json';
|
||||
if (body.text !== undefined) return 'text';
|
||||
if (body.xml !== undefined) return 'xml';
|
||||
if (body.sparql !== undefined) return 'sparql';
|
||||
if (body.graphql) return 'graphql';
|
||||
if (body.formUrlEncoded) return 'formUrlEncoded';
|
||||
if (body.multipartForm) return 'multipartForm';
|
||||
if (body.file) return 'file';
|
||||
if (body.grpc) return 'grpc';
|
||||
if (body.ws) return 'ws';
|
||||
if (body.mode === 'none') return 'none';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
export const getMethod = (data) => {
|
||||
return get(data, 'request.method', 'GET');
|
||||
};
|
||||
|
||||
export const getUrl = (data) => {
|
||||
return get(data, 'request.url', '');
|
||||
};
|
||||
|
||||
export const computeItemDiffStatus = (currentItem, otherItem, showSide) => {
|
||||
if (!otherItem) {
|
||||
return showSide === 'old' ? DIFF_STATUS.DELETED : DIFF_STATUS.ADDED;
|
||||
}
|
||||
if (currentItem.value !== otherItem.value || currentItem.enabled !== otherItem.enabled) {
|
||||
return DIFF_STATUS.MODIFIED;
|
||||
}
|
||||
return DIFF_STATUS.UNCHANGED;
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
|
||||
import {
|
||||
getBodyContent,
|
||||
getBodyMode,
|
||||
getMethod,
|
||||
getUrl,
|
||||
computeItemDiffStatus
|
||||
} from './bruUtils';
|
||||
|
||||
describe('bruUtils', () => {
|
||||
describe('getBodyContent', () => {
|
||||
it('should return empty string for null or undefined body', () => {
|
||||
expect(getBodyContent(null)).toBe('');
|
||||
expect(getBodyContent(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for empty body', () => {
|
||||
expect(getBodyContent({})).toBe('');
|
||||
});
|
||||
|
||||
it('should return json content', () => {
|
||||
expect(getBodyContent({ json: '{"key": "value"}' })).toBe('{"key": "value"}');
|
||||
});
|
||||
|
||||
it('should return text content', () => {
|
||||
expect(getBodyContent({ text: 'plain text content' })).toBe('plain text content');
|
||||
});
|
||||
|
||||
it('should return xml content', () => {
|
||||
expect(getBodyContent({ xml: '<root><item>value</item></root>' })).toBe('<root><item>value</item></root>');
|
||||
});
|
||||
|
||||
it('should return sparql content', () => {
|
||||
expect(getBodyContent({ sparql: 'SELECT * WHERE { ?s ?p ?o }' })).toBe('SELECT * WHERE { ?s ?p ?o }');
|
||||
});
|
||||
|
||||
it('should return graphql query content', () => {
|
||||
expect(getBodyContent({ graphql: { query: 'query { users { id } }' } })).toBe('query { users { id } }');
|
||||
});
|
||||
|
||||
it('should return generic content', () => {
|
||||
expect(getBodyContent({ content: 'generic content' })).toBe('generic content');
|
||||
});
|
||||
|
||||
it('should return empty string for graphql without query', () => {
|
||||
expect(getBodyContent({ graphql: {} })).toBe('');
|
||||
expect(getBodyContent({ graphql: { variables: '{}' } })).toBe('');
|
||||
});
|
||||
|
||||
it('should prioritize json over other types', () => {
|
||||
expect(getBodyContent({ json: '{"a":1}', text: 'text' })).toBe('{"a":1}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBodyMode', () => {
|
||||
it('should return none for null or undefined body', () => {
|
||||
expect(getBodyMode(null)).toBe('none');
|
||||
expect(getBodyMode(undefined)).toBe('none');
|
||||
});
|
||||
|
||||
it('should return none for empty body', () => {
|
||||
expect(getBodyMode({})).toBe('none');
|
||||
});
|
||||
|
||||
it('should return json mode', () => {
|
||||
expect(getBodyMode({ json: '{}' })).toBe('json');
|
||||
expect(getBodyMode({ json: '' })).toBe('json');
|
||||
});
|
||||
|
||||
it('should return text mode', () => {
|
||||
expect(getBodyMode({ text: 'content' })).toBe('text');
|
||||
expect(getBodyMode({ text: '' })).toBe('text');
|
||||
});
|
||||
|
||||
it('should return xml mode', () => {
|
||||
expect(getBodyMode({ xml: '<root/>' })).toBe('xml');
|
||||
});
|
||||
|
||||
it('should return sparql mode', () => {
|
||||
expect(getBodyMode({ sparql: 'SELECT *' })).toBe('sparql');
|
||||
});
|
||||
|
||||
it('should return graphql mode', () => {
|
||||
expect(getBodyMode({ graphql: { query: '' } })).toBe('graphql');
|
||||
});
|
||||
|
||||
it('should return formUrlEncoded mode', () => {
|
||||
expect(getBodyMode({ formUrlEncoded: [] })).toBe('formUrlEncoded');
|
||||
expect(getBodyMode({ formUrlEncoded: [{ name: 'key', value: 'val' }] })).toBe('formUrlEncoded');
|
||||
});
|
||||
|
||||
it('should return multipartForm mode', () => {
|
||||
expect(getBodyMode({ multipartForm: [] })).toBe('multipartForm');
|
||||
});
|
||||
|
||||
it('should return file mode', () => {
|
||||
expect(getBodyMode({ file: [] })).toBe('file');
|
||||
});
|
||||
|
||||
it('should return grpc mode', () => {
|
||||
expect(getBodyMode({ grpc: [] })).toBe('grpc');
|
||||
});
|
||||
|
||||
it('should return ws mode', () => {
|
||||
expect(getBodyMode({ ws: [] })).toBe('ws');
|
||||
});
|
||||
|
||||
it('should return none for explicit none mode', () => {
|
||||
expect(getBodyMode({ mode: 'none' })).toBe('none');
|
||||
});
|
||||
|
||||
it('should prioritize json over other modes', () => {
|
||||
expect(getBodyMode({ json: '{}', text: 'text' })).toBe('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMethod', () => {
|
||||
it('should return GET as default', () => {
|
||||
expect(getMethod(null)).toBe('GET');
|
||||
expect(getMethod(undefined)).toBe('GET');
|
||||
expect(getMethod({})).toBe('GET');
|
||||
});
|
||||
|
||||
it('should return request method', () => {
|
||||
expect(getMethod({ request: { method: 'POST' } })).toBe('POST');
|
||||
expect(getMethod({ request: { method: 'PUT' } })).toBe('PUT');
|
||||
expect(getMethod({ request: { method: 'DELETE' } })).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('should return GET when request exists but method is missing', () => {
|
||||
expect(getMethod({ request: {} })).toBe('GET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUrl', () => {
|
||||
it('should return empty string as default', () => {
|
||||
expect(getUrl(null)).toBe('');
|
||||
expect(getUrl(undefined)).toBe('');
|
||||
expect(getUrl({})).toBe('');
|
||||
});
|
||||
|
||||
it('should return request url', () => {
|
||||
expect(getUrl({ request: { url: 'https://api.example.com/users' } })).toBe('https://api.example.com/users');
|
||||
});
|
||||
|
||||
it('should return empty string when request exists but url is missing', () => {
|
||||
expect(getUrl({ request: {} })).toBe('');
|
||||
});
|
||||
|
||||
it('should return url with different protocols', () => {
|
||||
expect(getUrl({ request: { url: 'http://localhost:3000' } })).toBe('http://localhost:3000');
|
||||
expect(getUrl({ request: { url: 'ws://localhost:8080' } })).toBe('ws://localhost:8080');
|
||||
expect(getUrl({ request: { url: 'grpc://localhost:50051' } })).toBe('grpc://localhost:50051');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeItemDiffStatus', () => {
|
||||
it('should return deleted when other item is missing and showing old side', () => {
|
||||
expect(computeItemDiffStatus({ name: 'key', value: 'val' }, null, 'old')).toBe('deleted');
|
||||
expect(computeItemDiffStatus({ name: 'key', value: 'val' }, undefined, 'old')).toBe('deleted');
|
||||
});
|
||||
|
||||
it('should return added when other item is missing and showing new side', () => {
|
||||
expect(computeItemDiffStatus({ name: 'key', value: 'val' }, null, 'new')).toBe('added');
|
||||
expect(computeItemDiffStatus({ name: 'key', value: 'val' }, undefined, 'new')).toBe('added');
|
||||
});
|
||||
|
||||
it('should return unchanged when items are equal', () => {
|
||||
const item = { name: 'key', value: 'val', enabled: true };
|
||||
const otherItem = { name: 'key', value: 'val', enabled: true };
|
||||
expect(computeItemDiffStatus(item, otherItem, 'old')).toBe('unchanged');
|
||||
expect(computeItemDiffStatus(item, otherItem, 'new')).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('should return modified when values differ', () => {
|
||||
const item = { name: 'key', value: 'val1', enabled: true };
|
||||
const otherItem = { name: 'key', value: 'val2', enabled: true };
|
||||
expect(computeItemDiffStatus(item, otherItem, 'old')).toBe('modified');
|
||||
});
|
||||
|
||||
it('should return modified when enabled status differs', () => {
|
||||
const item = { name: 'key', value: 'val', enabled: true };
|
||||
const otherItem = { name: 'key', value: 'val', enabled: false };
|
||||
expect(computeItemDiffStatus(item, otherItem, 'old')).toBe('modified');
|
||||
});
|
||||
|
||||
it('should handle undefined enabled as different from explicit false', () => {
|
||||
const item = { name: 'key', value: 'val' };
|
||||
const otherItem = { name: 'key', value: 'val', enabled: false };
|
||||
expect(computeItemDiffStatus(item, otherItem, 'old')).toBe('modified');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// Matches word-boundary separators: whitespace, slashes, query/path delimiters (?&=), dots, hyphens, underscores, colons, @
|
||||
const WORD_SEPARATOR = /[\s\/\?\&\=\.\-\_\:\@]/;
|
||||
|
||||
const splitWithSeparators = (str) => {
|
||||
const result = [];
|
||||
let current = '';
|
||||
for (const char of str) {
|
||||
if (WORD_SEPARATOR.test(char)) {
|
||||
if (current) {
|
||||
result.push(current);
|
||||
current = '';
|
||||
}
|
||||
result.push(char);
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
result.push(current);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const computeWordDiffForOld = (oldStr, newStr) => {
|
||||
if (oldStr === newStr) {
|
||||
return [{ text: oldStr, status: 'unchanged' }];
|
||||
}
|
||||
|
||||
if (!oldStr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!newStr) {
|
||||
return [{ text: oldStr, status: 'deleted' }];
|
||||
}
|
||||
|
||||
const oldWords = splitWithSeparators(oldStr);
|
||||
const newWords = splitWithSeparators(newStr);
|
||||
const lcs = computeLCS(oldWords, newWords);
|
||||
|
||||
const segments = [];
|
||||
let oldIdx = 0;
|
||||
let lcsIdx = 0;
|
||||
|
||||
while (oldIdx < oldWords.length) {
|
||||
if (lcsIdx < lcs.length && oldIdx === lcs[lcsIdx].oldIndex) {
|
||||
segments.push({ text: oldWords[oldIdx], status: 'unchanged' });
|
||||
lcsIdx++;
|
||||
} else {
|
||||
segments.push({ text: oldWords[oldIdx], status: 'deleted' });
|
||||
}
|
||||
oldIdx++;
|
||||
}
|
||||
|
||||
return mergeSegments(segments);
|
||||
};
|
||||
|
||||
export const computeWordDiffForNew = (oldStr, newStr) => {
|
||||
if (oldStr === newStr) {
|
||||
return [{ text: newStr, status: 'unchanged' }];
|
||||
}
|
||||
|
||||
if (!newStr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!oldStr) {
|
||||
return [{ text: newStr, status: 'added' }];
|
||||
}
|
||||
|
||||
const oldWords = splitWithSeparators(oldStr);
|
||||
const newWords = splitWithSeparators(newStr);
|
||||
const lcs = computeLCS(oldWords, newWords);
|
||||
|
||||
const segments = [];
|
||||
let newIdx = 0;
|
||||
let lcsIdx = 0;
|
||||
|
||||
while (newIdx < newWords.length) {
|
||||
if (lcsIdx < lcs.length && newIdx === lcs[lcsIdx].newIndex) {
|
||||
segments.push({ text: newWords[newIdx], status: 'unchanged' });
|
||||
lcsIdx++;
|
||||
} else {
|
||||
segments.push({ text: newWords[newIdx], status: 'added' });
|
||||
}
|
||||
newIdx++;
|
||||
}
|
||||
|
||||
return mergeSegments(segments);
|
||||
};
|
||||
|
||||
const mergeSegments = (segments) => {
|
||||
const merged = [];
|
||||
for (const segment of segments) {
|
||||
if (merged.length > 0 && merged[merged.length - 1].status === segment.status) {
|
||||
merged[merged.length - 1].text += segment.text;
|
||||
} else {
|
||||
merged.push({ ...segment });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
const computeLCS = (arr1, arr2) => {
|
||||
const m = arr1.length;
|
||||
const n = arr2.length;
|
||||
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (arr1[i - 1] === arr2[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lcs = [];
|
||||
let i = m, j = n;
|
||||
while (i > 0 && j > 0) {
|
||||
if (arr1[i - 1] === arr2[j - 1]) {
|
||||
lcs.unshift({ value: arr1[i - 1], oldIndex: i - 1, newIndex: j - 1 });
|
||||
i--;
|
||||
j--;
|
||||
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
||||
i--;
|
||||
} else {
|
||||
j--;
|
||||
}
|
||||
}
|
||||
|
||||
return lcs;
|
||||
};
|
||||
|
||||
export const computeLineDiffForOld = (oldStr, newStr) => {
|
||||
if (oldStr === newStr) {
|
||||
return (oldStr || '').split('\n').map((line) => ({ text: line, status: 'unchanged' }));
|
||||
}
|
||||
|
||||
if (!oldStr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!newStr) {
|
||||
return oldStr.split('\n').map((line) => ({ text: line, status: 'deleted' }));
|
||||
}
|
||||
|
||||
const oldLines = oldStr.split('\n');
|
||||
const newLines = newStr.split('\n');
|
||||
const lcs = computeLCS(oldLines, newLines);
|
||||
|
||||
const segments = [];
|
||||
let oldIdx = 0;
|
||||
let lcsIdx = 0;
|
||||
|
||||
while (oldIdx < oldLines.length) {
|
||||
if (lcsIdx < lcs.length && oldIdx === lcs[lcsIdx].oldIndex) {
|
||||
segments.push({ text: oldLines[oldIdx], status: 'unchanged' });
|
||||
lcsIdx++;
|
||||
} else {
|
||||
segments.push({ text: oldLines[oldIdx], status: 'deleted' });
|
||||
}
|
||||
oldIdx++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
export const computeLineDiffForNew = (oldStr, newStr) => {
|
||||
if (oldStr === newStr) {
|
||||
return (newStr || '').split('\n').map((line) => ({ text: line, status: 'unchanged' }));
|
||||
}
|
||||
|
||||
if (!newStr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!oldStr) {
|
||||
return newStr.split('\n').map((line) => ({ text: line, status: 'added' }));
|
||||
}
|
||||
|
||||
const oldLines = oldStr.split('\n');
|
||||
const newLines = newStr.split('\n');
|
||||
const lcs = computeLCS(oldLines, newLines);
|
||||
|
||||
const segments = [];
|
||||
let newIdx = 0;
|
||||
let lcsIdx = 0;
|
||||
|
||||
while (newIdx < newLines.length) {
|
||||
if (lcsIdx < lcs.length && newIdx === lcs[lcsIdx].newIndex) {
|
||||
segments.push({ text: newLines[newIdx], status: 'unchanged' });
|
||||
lcsIdx++;
|
||||
} else {
|
||||
segments.push({ text: newLines[newIdx], status: 'added' });
|
||||
}
|
||||
newIdx++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
|
||||
import {
|
||||
computeWordDiffForOld,
|
||||
computeWordDiffForNew,
|
||||
computeLineDiffForOld,
|
||||
computeLineDiffForNew
|
||||
} from './diffUtils';
|
||||
|
||||
describe('diffUtils', () => {
|
||||
describe('computeWordDiffForOld', () => {
|
||||
it('should return unchanged for identical strings', () => {
|
||||
expect(computeWordDiffForOld('hello world', 'hello world')).toEqual([
|
||||
{ text: 'hello world', status: 'unchanged' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array for empty old string', () => {
|
||||
expect(computeWordDiffForOld('', 'new text')).toEqual([]);
|
||||
expect(computeWordDiffForOld(null, 'new text')).toEqual([]);
|
||||
expect(computeWordDiffForOld(undefined, 'new text')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return deleted for entire old string when new is empty', () => {
|
||||
expect(computeWordDiffForOld('old text', '')).toEqual([
|
||||
{ text: 'old text', status: 'deleted' }
|
||||
]);
|
||||
expect(computeWordDiffForOld('old text', null)).toEqual([
|
||||
{ text: 'old text', status: 'deleted' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect deleted words', () => {
|
||||
const result = computeWordDiffForOld('hello world', 'hello');
|
||||
expect(result).toContainEqual({ text: 'hello', status: 'unchanged' });
|
||||
expect(result.some((s) => s.status === 'deleted' && s.text.includes('world'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle URL paths', () => {
|
||||
const result = computeWordDiffForOld(
|
||||
'https://api.example.com/users/123',
|
||||
'https://api.example.com/users/456'
|
||||
);
|
||||
expect(result.some((s) => s.status === 'unchanged')).toBe(true);
|
||||
expect(result.some((s) => s.status === 'deleted')).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve separators', () => {
|
||||
const result = computeWordDiffForOld('a/b/c', 'a/b/c');
|
||||
expect(result).toEqual([{ text: 'a/b/c', status: 'unchanged' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeWordDiffForNew', () => {
|
||||
it('should return unchanged for identical strings', () => {
|
||||
expect(computeWordDiffForNew('hello world', 'hello world')).toEqual([
|
||||
{ text: 'hello world', status: 'unchanged' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array for empty new string', () => {
|
||||
expect(computeWordDiffForNew('old text', '')).toEqual([]);
|
||||
expect(computeWordDiffForNew('old text', null)).toEqual([]);
|
||||
expect(computeWordDiffForNew('old text', undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return added for entire new string when old is empty', () => {
|
||||
expect(computeWordDiffForNew('', 'new text')).toEqual([
|
||||
{ text: 'new text', status: 'added' }
|
||||
]);
|
||||
expect(computeWordDiffForNew(null, 'new text')).toEqual([
|
||||
{ text: 'new text', status: 'added' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect added words', () => {
|
||||
const result = computeWordDiffForNew('hello', 'hello world');
|
||||
expect(result).toContainEqual({ text: 'hello', status: 'unchanged' });
|
||||
expect(result.some((s) => s.status === 'added' && s.text.includes('world'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle URL paths', () => {
|
||||
const result = computeWordDiffForNew(
|
||||
'https://api.example.com/users/123',
|
||||
'https://api.example.com/users/456'
|
||||
);
|
||||
expect(result.some((s) => s.status === 'unchanged')).toBe(true);
|
||||
expect(result.some((s) => s.status === 'added')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeLineDiffForOld', () => {
|
||||
it('should return unchanged for identical multiline strings', () => {
|
||||
const text = 'line1\nline2\nline3';
|
||||
expect(computeLineDiffForOld(text, text)).toEqual([
|
||||
{ text: 'line1', status: 'unchanged' },
|
||||
{ text: 'line2', status: 'unchanged' },
|
||||
{ text: 'line3', status: 'unchanged' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array for empty old string', () => {
|
||||
expect(computeLineDiffForOld('', 'new\ntext')).toEqual([]);
|
||||
expect(computeLineDiffForOld(null, 'new\ntext')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return deleted for all lines when new is empty', () => {
|
||||
expect(computeLineDiffForOld('line1\nline2', '')).toEqual([
|
||||
{ text: 'line1', status: 'deleted' },
|
||||
{ text: 'line2', status: 'deleted' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect deleted lines', () => {
|
||||
const result = computeLineDiffForOld('line1\nline2\nline3', 'line1\nline3');
|
||||
expect(result).toContainEqual({ text: 'line1', status: 'unchanged' });
|
||||
expect(result).toContainEqual({ text: 'line2', status: 'deleted' });
|
||||
expect(result).toContainEqual({ text: 'line3', status: 'unchanged' });
|
||||
});
|
||||
|
||||
it('should handle single line strings', () => {
|
||||
expect(computeLineDiffForOld('single line', 'single line')).toEqual([
|
||||
{ text: 'single line', status: 'unchanged' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle code blocks', () => {
|
||||
const oldCode = 'function foo() {\n return 1;\n}';
|
||||
const newCode = 'function foo() {\n return 2;\n}';
|
||||
const result = computeLineDiffForOld(oldCode, newCode);
|
||||
expect(result).toContainEqual({ text: 'function foo() {', status: 'unchanged' });
|
||||
expect(result).toContainEqual({ text: ' return 1;', status: 'deleted' });
|
||||
expect(result).toContainEqual({ text: '}', status: 'unchanged' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeLineDiffForNew', () => {
|
||||
it('should return unchanged for identical multiline strings', () => {
|
||||
const text = 'line1\nline2\nline3';
|
||||
expect(computeLineDiffForNew(text, text)).toEqual([
|
||||
{ text: 'line1', status: 'unchanged' },
|
||||
{ text: 'line2', status: 'unchanged' },
|
||||
{ text: 'line3', status: 'unchanged' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array for empty new string', () => {
|
||||
expect(computeLineDiffForNew('old\ntext', '')).toEqual([]);
|
||||
expect(computeLineDiffForNew('old\ntext', null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return added for all lines when old is empty', () => {
|
||||
expect(computeLineDiffForNew('', 'line1\nline2')).toEqual([
|
||||
{ text: 'line1', status: 'added' },
|
||||
{ text: 'line2', status: 'added' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect added lines', () => {
|
||||
const result = computeLineDiffForNew('line1\nline3', 'line1\nline2\nline3');
|
||||
expect(result).toContainEqual({ text: 'line1', status: 'unchanged' });
|
||||
expect(result).toContainEqual({ text: 'line2', status: 'added' });
|
||||
expect(result).toContainEqual({ text: 'line3', status: 'unchanged' });
|
||||
});
|
||||
|
||||
it('should handle code blocks', () => {
|
||||
const oldCode = 'function foo() {\n return 1;\n}';
|
||||
const newCode = 'function foo() {\n return 2;\n}';
|
||||
const result = computeLineDiffForNew(oldCode, newCode);
|
||||
expect(result).toContainEqual({ text: 'function foo() {', status: 'unchanged' });
|
||||
expect(result).toContainEqual({ text: ' return 2;', status: 'added' });
|
||||
expect(result).toContainEqual({ text: '}', status: 'unchanged' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty strings', () => {
|
||||
expect(computeWordDiffForOld('', '')).toEqual([{ text: '', status: 'unchanged' }]);
|
||||
expect(computeWordDiffForNew('', '')).toEqual([{ text: '', status: 'unchanged' }]);
|
||||
});
|
||||
|
||||
it('should handle strings with only whitespace', () => {
|
||||
const result = computeWordDiffForOld(' ', ' ');
|
||||
expect(result).toEqual([{ text: ' ', status: 'unchanged' }]);
|
||||
});
|
||||
|
||||
it('should handle special characters in URLs', () => {
|
||||
const url = 'https://api.example.com/users?id=123&name=test';
|
||||
expect(computeWordDiffForOld(url, url)).toEqual([{ text: url, status: 'unchanged' }]);
|
||||
});
|
||||
|
||||
it('should handle JSON-like content', () => {
|
||||
const json = '{"key": "value", "number": 123}';
|
||||
const result = computeLineDiffForOld(json, json);
|
||||
expect(result).toEqual([{ text: json, status: 'unchanged' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import styled from 'styled-components';
|
||||
const Wrapper = styled.div`
|
||||
font-weight: 400;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
white-space: normal;
|
||||
background-color: ${(props) => props.theme.infoTip.bg};
|
||||
border: 1px solid ${(props) => props.theme.infoTip.border};
|
||||
box-shadow: ${(props) => props.theme.infoTip.boxShadow};
|
||||
|
||||
@@ -4,62 +4,84 @@
|
||||
* We should allow icon and placement props to be passed in
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import HelpIcon from 'components/Icons/Help';
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import QuestionCircle from 'components/Icons/QuestionCircle';
|
||||
import InfoCircle from 'components/Icons/InfoCircle';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const getPlacementStyles = (placement) => {
|
||||
const GAP = 8;
|
||||
|
||||
const getPortalPosition = (rect, placement, width) => {
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
return {
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
top: rect.top - GAP,
|
||||
left: rect.left + rect.width / 2 - width / 2,
|
||||
transform: 'translateY(-100%)'
|
||||
};
|
||||
case 'bottom':
|
||||
return {
|
||||
top: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
top: rect.bottom + GAP,
|
||||
left: rect.left + rect.width / 2 - width / 2
|
||||
};
|
||||
case 'left':
|
||||
return {
|
||||
top: '50%',
|
||||
right: 'calc(100% + 8px)',
|
||||
top: rect.top + rect.height / 2,
|
||||
left: rect.left - GAP - width,
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
case 'right':
|
||||
default:
|
||||
return {
|
||||
top: '50%',
|
||||
left: 'calc(100% + 8px)',
|
||||
top: rect.top + rect.height / 2,
|
||||
left: rect.right + GAP,
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Help = ({ children, width = 200, placement = 'right' }) => {
|
||||
const iconMap = {
|
||||
question: QuestionCircle,
|
||||
info: InfoCircle
|
||||
};
|
||||
|
||||
const Help = ({ children, width = 200, placement = 'right', icon = 'question', iconComponent: IconComponent, size = 14 }) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [position, setPosition] = useState(null);
|
||||
const iconRef = useRef(null);
|
||||
const ResolvedIcon = IconComponent || iconMap[icon] || QuestionCircle;
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (iconRef.current) {
|
||||
const rect = iconRef.current.getBoundingClientRect();
|
||||
setPosition(getPortalPosition(rect, placement, width));
|
||||
}
|
||||
setShowTooltip(true);
|
||||
}, [placement, width]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center relative">
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
ref={iconRef}
|
||||
className="flex items-center"
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<HelpIcon size={14} />
|
||||
<ResolvedIcon size={size} />
|
||||
</span>
|
||||
{showTooltip && (
|
||||
{showTooltip && position && createPortal(
|
||||
<StyledWrapper
|
||||
className="absolute z-50 rounded-md p-3"
|
||||
className="z-50 rounded-md p-3"
|
||||
style={{
|
||||
...getPlacementStyles(placement),
|
||||
position: 'fixed',
|
||||
...position,
|
||||
width: `${width}px`
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StyledWrapper>
|
||||
</StyledWrapper>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
20
packages/bruno-app/src/components/Icons/InfoCircle/index.js
Normal file
20
packages/bruno-app/src/components/Icons/InfoCircle/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const InfoCircle = ({ size = 14 }) => {
|
||||
return (
|
||||
<svg
|
||||
tabIndex="-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
className="inline-block ml-2 cursor-pointer"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoCircle;
|
||||
13
packages/bruno-app/src/components/Icons/OpenAPISync/index.js
Normal file
13
packages/bruno-app/src/components/Icons/OpenAPISync/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
const OpenAPISyncIcon = ({ size = 16, color = 'currentColor', ...props }) => {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M39.1499 0.0455742C36.8449 0.458965 35.0171 2.12048 34.4267 4.37029C34.2407 5.06987 34.2245 6.31799 34.3944 7.02553C34.4591 7.30377 34.4914 7.56612 34.4672 7.61382C34.4348 7.66152 34.4429 7.67742 34.4914 7.65357C34.5319 7.62972 34.6208 7.76486 34.6936 7.94771L34.823 8.28955L32.9305 10.1498C31.8872 11.1753 30.9976 12.0021 30.9491 11.9862C30.9006 11.9783 30.8844 12.0021 30.9087 12.0419C30.9329 12.0816 29.6227 13.4172 27.9971 15.0072C26.3716 16.5892 24.6085 18.3222 24.0828 18.8469L23.1284 19.793L22.3278 19.4193C21.1794 18.8787 20.4515 18.7118 19.2707 18.7118C17.726 18.7038 16.715 18.99 15.4776 19.793C14.2079 20.6118 13.3506 21.5499 12.7198 22.8059C12.1779 23.8792 12.0081 24.6503 12 25.962C12 27.0671 12.0809 27.5202 12.4448 28.506C13.205 30.5411 15.138 32.1788 17.5319 32.8068C18.5509 33.0771 20.1765 33.0612 21.2683 32.775C25.5224 31.6621 27.803 27.393 26.2583 23.426C26.1208 23.0683 26.0157 22.7582 26.0319 22.7423C26.048 22.7264 27.0994 21.6771 28.3692 20.421C29.6389 19.1649 31.1108 17.6863 31.6446 17.1377C34.2488 14.4666 37.8397 10.8573 37.8882 10.8573C37.9044 10.8573 38.058 10.9289 38.2198 11.0084C39.1984 11.5013 40.8402 11.5887 41.9967 11.223C42.8782 10.9448 43.5737 10.5235 44.245 9.87157C45.4581 8.67909 45.9919 7.44687 46 5.80126C46 4.37824 45.539 3.16191 44.5442 1.96148C44.2774 1.63554 44.059 1.39705 44.059 1.42885C44.059 1.46065 43.9134 1.36525 43.7274 1.2142C43.2664 0.824657 42.4253 0.403316 41.7136 0.212521C41.01 0.0217247 39.7645 -0.0577736 39.1499 0.0455742Z" fill={color} />
|
||||
<circle cx="20" cy="26" r="18.5" stroke={color} strokeWidth="3" />
|
||||
<path d="M26 15.4988C22 13.4999 17.793 13.752 15.2009 14.6172C12.6088 15.4823 10.3896 17.2063 8.91029 19.504C7.431 21.8016 6.78025 24.5354 7.06564 27.2531C7.35103 29.9709 8.55548 32.5098 10.4798 34.4501C12.4041 36.3904 14.933 37.6157 17.6483 37.9235C20.3636 38.2314 23.1027 37.6032 25.4125 36.1429C27.7223 34.6826 29.6135 32.5849 30.5 30C31.3865 27.4151 31.5 25 31 20L28 22.5C28.5 24.5 28.0118 26.9632 27.359 28.8667C26.7061 30.7702 25.4231 32.3939 23.7222 33.4693C22.0212 34.5446 20.0042 35.0072 18.0046 34.7805C16.0051 34.5539 14.1427 33.6515 12.7257 32.2227C11.3086 30.7939 10.4216 28.9242 10.2115 26.9228C10.0013 24.9214 10.4805 22.9083 11.5699 21.2163C12.6592 19.5242 14.2935 18.2547 16.2023 17.6176C18.1112 16.9805 20 16.9999 23.5 17.9999L26 15.4988Z" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAPISyncIcon;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
const HelpIcon = ({ size = 14 }) => {
|
||||
const QuestionCircle = ({ size = 14 }) => {
|
||||
return (
|
||||
<svg
|
||||
tabIndex="-1"
|
||||
@@ -17,4 +17,4 @@ const HelpIcon = ({ size = 14 }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpIcon;
|
||||
export default QuestionCircle;
|
||||
@@ -25,7 +25,7 @@ const RenameWorkspace = ({ onClose, workspace }) => {
|
||||
.test('unique-name', 'A workspace with this name already exists', function (value) {
|
||||
if (!value) return true;
|
||||
return !workspaces.some((w) =>
|
||||
w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase()
|
||||
w.uid !== workspace.uid && w.name && w.name.toLowerCase() === value.toLowerCase()
|
||||
);
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -27,7 +27,8 @@ const ManageWorkspace = () => {
|
||||
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null });
|
||||
|
||||
const sortedWorkspaces = useMemo(() => {
|
||||
return sortWorkspaces(workspaces, preferences);
|
||||
const persistedWorkspaces = workspaces.filter((w) => !w.isCreating);
|
||||
return sortWorkspaces(persistedWorkspaces, preferences);
|
||||
}, [workspaces, preferences]);
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -69,7 +70,6 @@ const ManageWorkspace = () => {
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
toast.success('Workspace created!');
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import React from 'react';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
|
||||
const markdownItOptions = {
|
||||
@@ -33,14 +35,14 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
|
||||
};
|
||||
|
||||
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
|
||||
|
||||
const htmlFromMarkdown = md.render(content || '');
|
||||
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
|
||||
const cleanHTML = useMemo(() => DOMPurify.sanitize(htmlFromMarkdown), [htmlFromMarkdown]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
className="markdown-body"
|
||||
dangerouslySetInnerHTML={{ __html: htmlFromMarkdown }}
|
||||
dangerouslySetInnerHTML={{ __html: cleanHTML }}
|
||||
onClick={handleOnClick}
|
||||
onDoubleClick={handleOnDoubleClick}
|
||||
/>
|
||||
|
||||
@@ -185,6 +185,45 @@ const Wrapper = styled.div`
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
accent-color: ${(props) => props.theme.primary.solid};
|
||||
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.textLink};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
border-color: ${(props) => props.theme.button2.color.primary.border};
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid ${(props) => props.theme.button2.color.primary.text};
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -25,9 +24,6 @@ class MultiLineEditor extends Component {
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -49,16 +45,16 @@ class MultiLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
tabindex: 0,
|
||||
extraKeys: {
|
||||
// 'Ctrl-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
// 'Cmd-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
@@ -94,9 +90,6 @@ class MultiLineEditor extends Component {
|
||||
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(this.editor, this);
|
||||
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.addOverlay(variables);
|
||||
@@ -171,6 +164,10 @@ class MultiLineEditor extends Component {
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
// Re-apply masking after setValue() since it destroys all CodeMirror marks
|
||||
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
|
||||
this.maskedEditor.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
@@ -186,12 +183,6 @@ class MultiLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
|
||||
105
packages/bruno-app/src/components/OpenAPISpecTab/index.js
Normal file
105
packages/bruno-app/src/components/OpenAPISpecTab/index.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { IconLoader2, IconCloud } from '@tabler/icons';
|
||||
import fastJsonFormat from 'fast-json-format';
|
||||
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
|
||||
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
|
||||
|
||||
/**
|
||||
* Pretty-print JSON content for readable display. YAML content is returned as-is.
|
||||
*/
|
||||
const prettyPrintSpec = (content) => {
|
||||
if (!content) return content;
|
||||
if (content.trimStart()[0] !== '{') return content;
|
||||
try {
|
||||
return fastJsonFormat(content);
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
const OpenAPISpecTab = ({ collection }) => {
|
||||
const [specContent, setSpecContent] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isRemote, setIsRemote] = useState(false);
|
||||
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const sourceUrl = openApiSyncConfig?.sourceUrl;
|
||||
|
||||
const loadSpec = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setIsRemote(false);
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:read-openapi-spec', {
|
||||
collectionPath: collection.pathname
|
||||
});
|
||||
if (result.error) {
|
||||
// Local file not found — fall back to fetching from remote URL
|
||||
if (sourceUrl) {
|
||||
const fetchResult = await ipcRenderer.invoke('renderer:fetch-openapi-spec', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl,
|
||||
environmentContext: {
|
||||
activeEnvironmentUid: collection.activeEnvironmentUid,
|
||||
environments: collection.environments,
|
||||
runtimeVariables: collection.runtimeVariables,
|
||||
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
||||
}
|
||||
});
|
||||
if (fetchResult.content) {
|
||||
setSpecContent(prettyPrintSpec(fetchResult.content));
|
||||
setIsRemote(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError(result.error);
|
||||
} else {
|
||||
setSpecContent(prettyPrintSpec(result.content));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to read spec file');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [collection?.pathname, collection?.uid, collection?.activeEnvironmentUid, collection?.environments, collection?.runtimeVariables, collection?.globalEnvironmentVariables, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collection?.pathname) {
|
||||
loadSpec();
|
||||
}
|
||||
}, [loadSpec]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full gap-2 opacity-50">
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
<span>Loading spec...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !specContent) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full opacity-50">
|
||||
<span>{error || 'No spec file found. Sync your collection first.'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col flex-grow relative">
|
||||
{isRemote && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs opacity-60" style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
<IconCloud size={14} />
|
||||
<span>Showing spec file from {sourceUrl}.</span>
|
||||
</div>
|
||||
)}
|
||||
<SpecViewer content={specContent} readOnly />
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAPISpecTab;
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
IconCheck,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconArrowBackUp,
|
||||
IconExternalLink,
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
IconLoader2
|
||||
} from '@tabler/icons';
|
||||
import moment from 'moment';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import Modal from 'components/Modal';
|
||||
import EndpointChangeSection from '../EndpointChangeSection';
|
||||
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
|
||||
import useEndpointActions from '../hooks/useEndpointActions';
|
||||
|
||||
const CollectionStatusSection = ({
|
||||
collection,
|
||||
collectionDrift,
|
||||
reloadDrift,
|
||||
specDrift,
|
||||
storedSpec,
|
||||
lastSyncDate,
|
||||
onOpenEndpoint,
|
||||
isLoading,
|
||||
onTabSelect
|
||||
}) => {
|
||||
const {
|
||||
pendingAction, setPendingAction,
|
||||
confirmPendingAction,
|
||||
handleResetEndpoint,
|
||||
handleResetAllModified,
|
||||
handleDeleteEndpoint,
|
||||
handleDeleteAllLocalOnly,
|
||||
handleRevertAllChanges,
|
||||
handleAddMissingEndpoint,
|
||||
handleAddAllMissing
|
||||
} = useEndpointActions(collection, collectionDrift, reloadDrift);
|
||||
|
||||
const spec = storedSpec || specDrift?.newSpec;
|
||||
const hasStoredSpec = collectionDrift && !collectionDrift.noStoredSpec;
|
||||
const hasDrift = hasStoredSpec && (collectionDrift.modified?.length > 0
|
||||
|| collectionDrift.missing?.length > 0
|
||||
|| collectionDrift.localOnly?.length > 0);
|
||||
|
||||
const renderDriftRow = (endpoint, idx, actions) => (
|
||||
<ExpandableEndpointRow
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
collectionPath={collection.pathname}
|
||||
newSpec={spec}
|
||||
showDecisions={false}
|
||||
diffLeftLabel="Last Synced Spec"
|
||||
diffRightLabel="Current (in collection)"
|
||||
swapDiffSides
|
||||
collectionUid={collection.uid}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
|
||||
const modifiedCount = collectionDrift?.modified?.length || 0;
|
||||
const missingCount = collectionDrift?.missing?.length || 0;
|
||||
const localOnlyCount = collectionDrift?.localOnly?.length || 0;
|
||||
const version = specDrift?.storedVersion || storedSpec?.info?.version;
|
||||
|
||||
const bannerState = useMemo(() => {
|
||||
if (hasDrift) {
|
||||
return {
|
||||
variant: 'muted',
|
||||
message: 'Collection has changes since last sync',
|
||||
badges: { modifiedCount, missingCount, localOnlyCount },
|
||||
actions: ['revert-all']
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [hasDrift, modifiedCount, missingCount, localOnlyCount, version, lastSyncDate]);
|
||||
|
||||
return (
|
||||
<div className="collection-status-section">
|
||||
{bannerState && (
|
||||
<div className={`spec-update-banner ${bannerState.variant}`}>
|
||||
<div className="banner-left">
|
||||
{bannerState.variant === 'success'
|
||||
? <IconCheck size={16} className="status-check-icon" />
|
||||
: <div className={`status-dot ${bannerState.variant}`} />}
|
||||
<span className="banner-title">
|
||||
{bannerState.message}
|
||||
</span>
|
||||
{bannerState.badges && (
|
||||
<span className="banner-details">
|
||||
{bannerState.badges.modifiedCount > 0 && <StatusBadge status="warning" radius="full">{bannerState.badges.modifiedCount} modified</StatusBadge>}
|
||||
{bannerState.badges.missingCount > 0 && <StatusBadge status="danger" radius="full">{bannerState.badges.missingCount} deleted</StatusBadge>}
|
||||
{bannerState.badges.localOnlyCount > 0 && <StatusBadge status="muted" radius="full">{bannerState.badges.localOnlyCount} added</StatusBadge>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{bannerState.actions.includes('revert-all') && (
|
||||
<div className="banner-actions">
|
||||
<Button size="sm" variant="ghost" color="danger" onClick={handleRevertAllChanges}>
|
||||
Revert All to Spec
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDrift && (
|
||||
<div className="sync-info-notice mt-4">
|
||||
<IconInfoCircle size={14} className="sync-info-icon" />
|
||||
<span><span className="whats-updated-title">What's tracked:</span> Changes to parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDrift ? (
|
||||
<div className="mt-5">
|
||||
{/* Modified in Collection */}
|
||||
<EndpointChangeSection
|
||||
title="Modified in Collection"
|
||||
type="modified"
|
||||
endpoints={collectionDrift.modified || []}
|
||||
expandableLayout
|
||||
collectionUid={collection.uid}
|
||||
sectionKey="drift-modified"
|
||||
renderItem={(endpoint, idx) =>
|
||||
renderDriftRow(endpoint, idx, (
|
||||
<>
|
||||
<Button size="xs" variant="ghost" onClick={() => onOpenEndpoint(endpoint.id)} title="Open in tab" icon={<IconExternalLink size={14} />}>
|
||||
Open
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" onClick={() => handleResetEndpoint(endpoint)} title="Reset to spec" icon={<IconArrowBackUp size={14} />}>
|
||||
Reset
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
actions={(
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={handleResetAllModified}
|
||||
title="Reset all modified endpoints to match the spec"
|
||||
icon={<IconArrowBackUp size={14} />}
|
||||
>
|
||||
Reset All
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Deleted from Collection */}
|
||||
<EndpointChangeSection
|
||||
title="Deleted from Collection"
|
||||
type="missing"
|
||||
endpoints={collectionDrift.missing || []}
|
||||
expandableLayout
|
||||
collectionUid={collection.uid}
|
||||
sectionKey="drift-missing"
|
||||
renderItem={(endpoint, idx) =>
|
||||
renderDriftRow(endpoint, idx, (
|
||||
<Button size="xs" variant="ghost" onClick={() => handleAddMissingEndpoint(endpoint)} title="Restore to collection" icon={<IconPlus size={14} />}>
|
||||
Restore
|
||||
</Button>
|
||||
))}
|
||||
actions={(
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={handleAddAllMissing}
|
||||
title="Add all deleted endpoints back to collection"
|
||||
icon={<IconPlus size={14} />}
|
||||
>
|
||||
Restore All
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Added to Collection */}
|
||||
<EndpointChangeSection
|
||||
title="Added to Collection"
|
||||
type="local-only"
|
||||
endpoints={collectionDrift.localOnly || []}
|
||||
expandableLayout
|
||||
collectionUid={collection.uid}
|
||||
sectionKey="drift-local-only"
|
||||
renderItem={(endpoint, idx) =>
|
||||
renderDriftRow(endpoint, idx, (
|
||||
<>
|
||||
<Button size="xs" variant="ghost" onClick={() => onOpenEndpoint(endpoint.id)} title="Open in tab" icon={<IconExternalLink size={14} />}>
|
||||
Open
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" color="danger" onClick={() => handleDeleteEndpoint(endpoint)} title="Delete endpoint" icon={<IconTrash size={14} />}>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
actions={(
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="danger"
|
||||
onClick={handleDeleteAllLocalOnly}
|
||||
title="Delete all locally added endpoints"
|
||||
icon={<IconTrash size={14} />}
|
||||
>
|
||||
Delete All
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
|
||||
<h4>Checking for updates</h4>
|
||||
<p>Comparing your collection with the last synced spec...</p>
|
||||
</div>
|
||||
) : !hasStoredSpec ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconAlertTriangle size={40} className="empty-state-icon" />
|
||||
<h4>{lastSyncDate ? 'Cannot track collection changes' : 'Waiting for initial sync'}</h4>
|
||||
<p>{lastSyncDate
|
||||
? 'The last synced spec is missing. Go to the \'Spec Updates\' tab to restore it, or sync the collection if updates are available to track future changes.'
|
||||
: 'Once you sync your collection with the spec, local changes will appear here.'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => onTabSelect('spec-updates')}>Go to Spec Updates</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No changes in collection</h4>
|
||||
<p>The collection endpoints match the last synced spec. Nothing to review.</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Action confirmation modal */}
|
||||
{pendingAction && (
|
||||
<Modal size="sm" title={pendingAction.title} hideFooter={true} handleCancel={() => setPendingAction(null)}>
|
||||
<div className="action-confirm-modal">
|
||||
<p className="confirm-message">{pendingAction.message}</p>
|
||||
<div className="confirm-actions">
|
||||
<Button variant="ghost" onClick={() => setPendingAction(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={pendingAction.type.includes('delete') ? 'danger' : 'primary'}
|
||||
onClick={confirmPendingAction}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionStatusSection;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IconChevronRight } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import Button from 'ui/Button';
|
||||
import MethodBadge from 'ui/MethodBadge';
|
||||
|
||||
const handleKeyDown = (toggle) => (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
|
||||
const ConfirmGroup = ({ group }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const toggle = () => setExpanded((prev) => !prev);
|
||||
return (
|
||||
<div className={`confirm-group type-${group.type}`}>
|
||||
<div
|
||||
className="confirm-group-header"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={handleKeyDown(toggle)}
|
||||
>
|
||||
<IconChevronRight size={14} className={`chevron ${expanded ? 'expanded' : ''}`} />
|
||||
<span className="confirm-group-label">{group.label}</span>
|
||||
<span className="confirm-group-count">{group.endpoints.length}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="endpoints-list">
|
||||
{group.endpoints.map((ep, i) => (
|
||||
<div key={ep.id || i} className="endpoint-row">
|
||||
<MethodBadge method={ep.method} />
|
||||
<span className="endpoint-path">{ep.path}</span>
|
||||
{(ep.summary || ep.name) && (
|
||||
<span className="endpoint-summary">{ep.summary || ep.name}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmSyncModal = ({ groups, onCancel, onSync, isSyncing }) => {
|
||||
const hasNoChanges = groups.length === 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Confirm Sync"
|
||||
handleCancel={onCancel}
|
||||
hideFooter={true}
|
||||
>
|
||||
<div className="sync-confirm-modal">
|
||||
{hasNoChanges ? (
|
||||
<p className="sync-confirm-description">
|
||||
Your collection is already in sync with the remote spec. Syncing will update the local spec file to match the latest remote version.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="sync-confirm-description">
|
||||
The following changes will be applied to your collection. This action cannot be undone. Are you sure you want to proceed?
|
||||
</p>
|
||||
|
||||
<div className="sync-confirm-groups">
|
||||
{groups.map((group, idx) => (
|
||||
<ConfirmGroup key={idx} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="sync-confirm-actions">
|
||||
<Button variant="ghost" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSync} loading={isSyncing} disabled={isSyncing}>
|
||||
{hasNoChanges ? 'Restore Spec File' : 'Confirm & Sync Collection'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmSyncModal;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
|
||||
|
||||
const FEATURES = [
|
||||
'Detect new, modified, and removed endpoints',
|
||||
'Track local changes against the spec',
|
||||
'Sync collection with a single click',
|
||||
'Your tests, assertions, and scripts are preserved during sync'
|
||||
];
|
||||
|
||||
const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError, onConnect }) => {
|
||||
const [mode, setMode] = useState('url');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
return (
|
||||
<div className="setup-section">
|
||||
<div className="setup-header">
|
||||
<h2 className="setup-title">Connect to OpenAPI Spec</h2>
|
||||
<p className="setup-description">
|
||||
Keep your collection synchronized with an OpenAPI specification. Changes in the spec will be detected automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="setup-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault(); onConnect();
|
||||
}}
|
||||
>
|
||||
<label className="url-label">OpenAPI Specification</label>
|
||||
<div className="url-row">
|
||||
<div className="setup-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`setup-mode-btn ${mode === 'url' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('url'); setSourceUrl('');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`setup-mode-btn ${mode === 'file' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('file'); setSourceUrl('');
|
||||
}}
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'url' ? (
|
||||
<input
|
||||
type="text"
|
||||
className="url-input"
|
||||
value={sourceUrl}
|
||||
onChange={(e) => setSourceUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/openapi.json"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.yaml,.yml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setSourceUrl('');
|
||||
try {
|
||||
const data = await parseFileAsJsonOrYaml(file);
|
||||
if (!isOpenApiSpec(data)) {
|
||||
setError('The selected file is not a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
const filePath = window.ipcRenderer.getFilePath(file);
|
||||
if (filePath) setSourceUrl(filePath);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to read the selected file');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="url-input file-pick-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{sourceUrl ? sourceUrl.split(/[\\/]/).pop() : 'Choose file...'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={mode === 'url' ? !isHttpUrl(sourceUrl.trim()) : !sourceUrl.trim()}
|
||||
loading={isLoading}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
<p className="setup-hint">
|
||||
{mode === 'url'
|
||||
? 'Supports OpenAPI 3.x specifications in JSON or YAML format'
|
||||
: 'Select a local OpenAPI/Swagger JSON or YAML file'}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="setup-error">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="setup-features">
|
||||
{FEATURES.map((text) => (
|
||||
<div className="setup-feature" key={text}>
|
||||
<IconCheck size={16} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="beta-feedback-inline">
|
||||
OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="beta-feedback-link"
|
||||
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectSpecForm;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
import Modal from 'components/Modal';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
|
||||
|
||||
const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect, onClose }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const normalizedSourceUrl = (sourceUrl || '').trim();
|
||||
const isUrl = isHttpUrl(normalizedSourceUrl);
|
||||
const initialMode = isUrl ? 'url' : 'file';
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [url, setUrl] = useState(isUrl ? normalizedSourceUrl : '');
|
||||
const [filePath, setFilePath] = useState(isUrl ? '' : normalizedSourceUrl);
|
||||
const [autoCheck, setAutoCheck] = useState(openApiSyncConfig?.autoCheck !== false);
|
||||
const [checkInterval, setCheckInterval] = useState(openApiSyncConfig?.autoCheckInterval || 5);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const intervals = [5, 15, 30, 60];
|
||||
|
||||
const effectiveSource = mode === 'file' ? filePath : url.trim();
|
||||
const canSave = mode === 'file' ? !!effectiveSource : isHttpUrl(effectiveSource.trim());
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave({ sourceUrl: effectiveSource, autoCheck, autoCheckInterval: checkInterval });
|
||||
onClose();
|
||||
} catch (_) {
|
||||
// caller (handleSaveSettings) already shows a toast on failure
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Connection Settings"
|
||||
hideFooter={true}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="settings-modal">
|
||||
<div className="settings-body">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Spec Source</label>
|
||||
<div className="setup-mode-toggle" style={{ marginBottom: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`setup-mode-btn ${mode === 'url' ? 'active' : ''}`}
|
||||
onClick={() => setMode('url')}
|
||||
>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`setup-mode-btn ${mode === 'file' ? 'active' : ''}`}
|
||||
onClick={() => setMode('file')}
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'url' ? (
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/openapi.json"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.yaml,.yml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const data = await parseFileAsJsonOrYaml(file);
|
||||
if (!isOpenApiSpec(data)) {
|
||||
toast.error('The selected file is not a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
const path = window.ipcRenderer.getFilePath(file);
|
||||
if (path) setFilePath(path);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to read the selected file');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-input file-pick-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{filePath ? filePath.split(/[\\/]/).pop() : 'Choose file...'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Auto-check for updates</label>
|
||||
<div className="settings-toggle-row">
|
||||
<div className="toggle-info">
|
||||
<div className="toggle-description">
|
||||
Automatically check for spec changes at a regular interval
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`toggle-switch ${autoCheck ? 'active' : ''}`}
|
||||
onClick={() => setAutoCheck(!autoCheck)}
|
||||
type="button"
|
||||
>
|
||||
<span className="toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{autoCheck && (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Check interval</label>
|
||||
<div className="interval-buttons">
|
||||
{intervals.map((mins) => (
|
||||
<button
|
||||
key={mins}
|
||||
type="button"
|
||||
className={checkInterval === mins ? 'active' : ''}
|
||||
onClick={() => setCheckInterval(mins)}
|
||||
>
|
||||
{mins} min
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-footer">
|
||||
<button className="disconnect-link" onClick={onDisconnect} type="button">
|
||||
Disconnect sync
|
||||
</button>
|
||||
<div className="settings-actions">
|
||||
<Button variant="ghost" color="secondary" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleSave} loading={isSaving} disabled={!canSave || isSaving}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionSettingsModal;
|
||||
@@ -0,0 +1,30 @@
|
||||
import Button from 'ui/Button';
|
||||
import Modal from 'components/Modal';
|
||||
|
||||
const DisconnectSyncModal = ({ onConfirm, onClose }) => {
|
||||
return (
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Disconnect Sync"
|
||||
hideFooter={true}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="disconnect-modal">
|
||||
<p className="disconnect-message">
|
||||
<>Are you sure you want to disconnect OpenAPI sync? </> <br /> <br />
|
||||
<>This will only disconnect the sync configuration. Your collection will remain intact.</>
|
||||
</p>
|
||||
<div className="disconnect-actions">
|
||||
<Button variant="ghost" color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="danger" onClick={onConfirm}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisconnectSyncModal;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import MethodBadge from 'ui/MethodBadge';
|
||||
|
||||
// Simple endpoint item for non-review mode
|
||||
const EndpointItem = ({ endpoint, type, actions }) => {
|
||||
return (
|
||||
<div className={`endpoint-item type-${type}`}>
|
||||
<div className="endpoint-row">
|
||||
<MethodBadge method={endpoint.method} />
|
||||
<span className="endpoint-path">{endpoint.path}</span>
|
||||
{endpoint.summary && <span className="endpoint-summary">{endpoint.summary}</span>}
|
||||
{endpoint.name && !endpoint.summary && <span className="endpoint-summary">{endpoint.name}</span>}
|
||||
{endpoint.deprecated && <span className="deprecated-tag">deprecated</span>}
|
||||
{actions && <div className="endpoint-actions">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointItem;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import get from 'lodash/get';
|
||||
import VisualDiffUrlBar from 'components/Git/VisualDiffViewer/VisualDiffUrlBar';
|
||||
import VisualDiffParams from 'components/Git/VisualDiffViewer/VisualDiffParams';
|
||||
import VisualDiffHeaders from 'components/Git/VisualDiffViewer/VisualDiffHeaders';
|
||||
import VisualDiffAuth from 'components/Git/VisualDiffViewer/VisualDiffAuth';
|
||||
import VisualDiffBody from 'components/Git/VisualDiffViewer/VisualDiffBody';
|
||||
import VisualDiffContent from 'components/Git/VisualDiffViewer/VisualDiffContent/index';
|
||||
|
||||
// OpenAPI sync diff section configs (HTTP request sections only)
|
||||
// Data format matches Git diff format: data.request.url, data.request.params, etc.
|
||||
const openAPIDiffSectionDataPaths = {
|
||||
url: ['request.url', 'request.method'],
|
||||
params: 'request.params',
|
||||
headers: 'request.headers',
|
||||
auth: 'request.auth',
|
||||
body: 'request.body'
|
||||
};
|
||||
|
||||
const openAPISectionHasChanges = (sectionKey, oldData, newData) => {
|
||||
// For body, only compare the mode and the content for the active mode(s)
|
||||
// The full request.body object can have extra empty properties that cause false positives
|
||||
if (sectionKey === 'body') {
|
||||
const oldBody = get(oldData, 'request.body', {});
|
||||
const newBody = get(newData, 'request.body', {});
|
||||
if (oldBody.mode !== newBody.mode) return true;
|
||||
const mode = oldBody.mode || newBody.mode;
|
||||
if (!mode || mode === 'none') return false;
|
||||
return !isEqual(oldBody[mode], newBody[mode]);
|
||||
}
|
||||
|
||||
// For auth, only compare the mode and spec-derived fields for the active auth mode
|
||||
// Bruno adds extra fields (pkce, credentialsId, tokenQueryKey, etc.) that don't
|
||||
// come from the OpenAPI spec. Also, the converter generates ALL oauth2 fields
|
||||
// regardless of grant type, but the collection only stores relevant ones per flow.
|
||||
if (sectionKey === 'auth') {
|
||||
const oldAuth = get(oldData, 'request.auth', {});
|
||||
const newAuth = get(newData, 'request.auth', {});
|
||||
if (oldAuth.mode !== newAuth.mode) return true;
|
||||
const mode = oldAuth.mode || newAuth.mode;
|
||||
if (!mode || mode === 'none') return false;
|
||||
const oldConfig = oldAuth[mode] || {};
|
||||
const newConfig = newAuth[mode] || {};
|
||||
|
||||
if (mode === 'oauth2') {
|
||||
// Compare only fields relevant to the specific grant type
|
||||
const grantType = oldConfig.grantType || newConfig.grantType;
|
||||
const commonFields = ['grantType', 'scope', 'state'];
|
||||
const grantTypeFields = {
|
||||
authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl', 'refreshTokenUrl', 'callbackUrl', 'clientId', 'clientSecret'],
|
||||
implicit: [...commonFields, 'authorizationUrl', 'callbackUrl'],
|
||||
password: [...commonFields, 'accessTokenUrl', 'refreshTokenUrl', 'clientId', 'clientSecret'],
|
||||
client_credentials: [...commonFields, 'accessTokenUrl', 'clientId', 'clientSecret']
|
||||
};
|
||||
const fields = grantTypeFields[grantType] || commonFields;
|
||||
return fields.some((field) => !isEqual(oldConfig[field], newConfig[field]));
|
||||
}
|
||||
|
||||
// Other auth modes: compare only spec-relevant fields
|
||||
const specFields = {
|
||||
basic: ['username', 'password'],
|
||||
bearer: ['token'],
|
||||
apikey: ['key', 'value', 'placement'],
|
||||
digest: ['username', 'password']
|
||||
};
|
||||
const fields = specFields[mode];
|
||||
if (fields) {
|
||||
return fields.some((field) => !isEqual(oldConfig[field], newConfig[field]));
|
||||
}
|
||||
return !isEqual(oldConfig, newConfig);
|
||||
}
|
||||
|
||||
const paths = openAPIDiffSectionDataPaths[sectionKey];
|
||||
|
||||
if (Array.isArray(paths)) {
|
||||
return paths.some((path) => !isEqual(get(oldData, path), get(newData, path)));
|
||||
}
|
||||
|
||||
return !isEqual(get(oldData, paths), get(newData, paths));
|
||||
};
|
||||
|
||||
const openAPIDiffHasContent = {
|
||||
url: (data) => data?.request?.url || data?.request?.method,
|
||||
params: (data) => data?.request?.params && data.request.params.length > 0,
|
||||
headers: (data) => data?.request?.headers && data.request.headers.length > 0,
|
||||
auth: (data) => data?.request?.auth && data.request.auth.mode && data.request.auth.mode !== 'none',
|
||||
body: (data) => {
|
||||
if (!data?.request?.body) return false;
|
||||
const mode = data.request.body.mode;
|
||||
if (!mode || mode === 'none') return false;
|
||||
return data.request.body.json || data.request.body.text || data.request.body.xml
|
||||
|| data.request.body.graphql || data.request.body.formUrlEncoded?.length > 0
|
||||
|| data.request.body.multipartForm?.length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
const openAPIDiffSections = [
|
||||
{ key: 'url', title: 'URL', Component: VisualDiffUrlBar, hasContent: openAPIDiffHasContent.url },
|
||||
{ key: 'params', title: 'Parameters', Component: VisualDiffParams, hasContent: openAPIDiffHasContent.params },
|
||||
{ key: 'headers', title: 'Headers', Component: VisualDiffHeaders, hasContent: openAPIDiffHasContent.headers },
|
||||
{ key: 'auth', title: 'Authentication', Component: VisualDiffAuth, hasContent: openAPIDiffHasContent.auth },
|
||||
{ key: 'body', title: 'Body', Component: VisualDiffBody, hasContent: openAPIDiffHasContent.body }
|
||||
];
|
||||
|
||||
/**
|
||||
* EndpointVisualDiff - Wrapper around VisualDiffContent for OpenAPI sync
|
||||
*
|
||||
* Props:
|
||||
* - oldData: data from collection (actual current state)
|
||||
* - newData: data from spec (expected state)
|
||||
* - leftLabel/rightLabel: custom labels for diff panes
|
||||
* - swapSides: if true, show spec on left and collection on right
|
||||
*/
|
||||
const EndpointVisualDiff = ({
|
||||
oldData,
|
||||
newData,
|
||||
leftLabel = 'Current (in collection)',
|
||||
rightLabel = 'Expected (from spec)',
|
||||
swapSides = false
|
||||
}) => {
|
||||
const sections = openAPIDiffSections;
|
||||
|
||||
// Determine which data goes on which side based on swapSides
|
||||
const displayOldData = swapSides ? newData : oldData;
|
||||
const displayNewData = swapSides ? oldData : newData;
|
||||
|
||||
return (
|
||||
<VisualDiffContent
|
||||
oldData={displayOldData}
|
||||
newData={displayNewData}
|
||||
sections={sections}
|
||||
sectionHasChanges={openAPISectionHasChanges}
|
||||
oldLabel={leftLabel}
|
||||
newLabel={rightLabel}
|
||||
hideUnchanged={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointVisualDiff;
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconChevronDown,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconLoader2
|
||||
} from '@tabler/icons';
|
||||
import { toggleRowExpanded } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import MethodBadge from 'ui/MethodBadge';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import Help from 'components/Help';
|
||||
import EndpointVisualDiff from './EndpointVisualDiff';
|
||||
|
||||
// Expandable row - can be used with or without decision buttons
|
||||
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions }) => {
|
||||
const dispatch = useDispatch();
|
||||
const rowKey = endpoint.id || `${endpoint.method}-${endpoint.path}`;
|
||||
const isExpanded = useSelector((state) => {
|
||||
return state.openapiSync?.tabUiState?.[collectionUid]?.expandedRows?.[rowKey] || false;
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [diffData, setDiffData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadDiffData = useCallback(async () => {
|
||||
if (diffData) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:get-endpoint-diff-data', {
|
||||
collectionPath,
|
||||
endpointId: endpoint.id,
|
||||
newSpec
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setDiffData(result);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(formatIpcError(err) || 'Failed to load diff data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [collectionPath, endpoint.id, newSpec]);
|
||||
|
||||
// Load diff data when expanded (e.g. restored from Redux state)
|
||||
useEffect(() => {
|
||||
if (isExpanded && !diffData && !isLoading && !error) {
|
||||
loadDiffData();
|
||||
}
|
||||
}, [isExpanded, diffData, isLoading, loadDiffData, error]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const willExpand = !isExpanded;
|
||||
if (collectionUid) {
|
||||
dispatch(toggleRowExpanded({ collectionUid, rowKey }));
|
||||
}
|
||||
if (willExpand && !diffData && !isLoading) {
|
||||
loadDiffData();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`endpoint-review-row ${showDecisions ? `decision-${decision}` : ''}`}>
|
||||
<div
|
||||
className="review-row-header"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault(); handleToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="expand-toggle">
|
||||
{isExpanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||
</span>
|
||||
<MethodBadge method={endpoint.method} />
|
||||
<span className="endpoint-path">{endpoint.path}</span>
|
||||
{endpoint.summary && <span className="endpoint-name">{endpoint.summary}</span>}
|
||||
{endpoint.name && !endpoint.summary && <span className="endpoint-name">{endpoint.name}</span>}
|
||||
{endpoint.conflict && (
|
||||
<StatusBadge
|
||||
status="danger"
|
||||
rightSection={(
|
||||
<Help icon="info" size={11} placement="top" width={250}>
|
||||
This endpoint was modified in both the spec and your collection. Choose which version to keep.
|
||||
</Help>
|
||||
)}
|
||||
>
|
||||
Conflict
|
||||
</StatusBadge>
|
||||
)}
|
||||
|
||||
{actions && <div className="endpoint-actions" onClick={(e) => e.stopPropagation()}>{actions}</div>}
|
||||
|
||||
{showDecisions && onDecisionChange && (
|
||||
<div className="decision-buttons" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className={`decision-btn keep ${decision === 'keep-mine' ? 'selected' : ''}`}
|
||||
onClick={() => onDecisionChange('keep-mine')}
|
||||
title="Keep your local version"
|
||||
>
|
||||
<IconX size={12} /> {decisionLabels?.keep || 'Keep Mine'}
|
||||
</button>
|
||||
<button
|
||||
className={`decision-btn accept ${decision === 'accept-incoming' ? 'selected' : ''}`}
|
||||
onClick={() => onDecisionChange('accept-incoming')}
|
||||
title="Accept the spec version"
|
||||
>
|
||||
<IconCheck size={12} /> {decisionLabels?.accept || 'Accept Spec'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="review-row-diff">
|
||||
{isLoading && (
|
||||
<div className="diff-loading">
|
||||
<IconLoader2 size={16} className="spinning" />
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="diff-error">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
{diffData && !isLoading && !error && (
|
||||
<EndpointVisualDiff
|
||||
oldData={diffData.oldData}
|
||||
newData={diffData.newData}
|
||||
leftLabel={diffLeftLabel || 'Current (in collection)'}
|
||||
rightLabel={diffRightLabel || 'Expected (from spec)'}
|
||||
swapSides={swapDiffSides}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpandableEndpointRow;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconChevronRight } from '@tabler/icons';
|
||||
import { toggleSectionExpanded } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
|
||||
/**
|
||||
* Collapsible section container for endpoint lists.
|
||||
* Renders a clickable header (with chevron, dot, title, count) and a body of items.
|
||||
* Expand/collapse state is persisted in Redux via collectionUid + sectionKey.
|
||||
*
|
||||
* @param {string} title - Section heading
|
||||
* @param {string} type - CSS modifier for color theming (e.g. 'modified', 'missing', 'in-sync')
|
||||
* @param {Array} endpoints - Items to render; section is hidden when empty
|
||||
* @param {Function} renderItem - (endpoint, idx) => ReactNode
|
||||
* @param {boolean} [defaultExpanded=false] - Fallback when no Redux state exists
|
||||
* @param {boolean} [expandableLayout=false] - Removes max-height scroll constraint on body
|
||||
* @param {ReactNode} [actions] - Header-right buttons (wrapped in a stopPropagation container)
|
||||
* @param {string} [subtitle] - Secondary text after the count
|
||||
* @param {ReactNode} [headerExtra] - Extra content shown in header only when collapsed
|
||||
* @param {string} collectionUid - Redux key for persisting expand/collapse state
|
||||
* @param {string} sectionKey - Redux key for persisting expand/collapse state
|
||||
*/
|
||||
const EndpointChangeSection = ({
|
||||
title,
|
||||
type,
|
||||
endpoints,
|
||||
defaultExpanded = false,
|
||||
actions,
|
||||
subtitle,
|
||||
renderItem,
|
||||
expandableLayout = false,
|
||||
headerExtra,
|
||||
collectionUid,
|
||||
sectionKey
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const reduxExpanded = useSelector((state) => {
|
||||
if (!collectionUid || !sectionKey) return undefined;
|
||||
return state.openapiSync?.tabUiState?.[collectionUid]?.expandedSections?.[sectionKey];
|
||||
});
|
||||
const isExpanded = reduxExpanded !== undefined ? reduxExpanded : defaultExpanded;
|
||||
|
||||
if (endpoints.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`change-section type-${type}${isExpanded ? ' expanded' : ''}`}>
|
||||
<div
|
||||
className="section-header"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (collectionUid && sectionKey) {
|
||||
dispatch(toggleSectionExpanded({ collectionUid, sectionKey }));
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (collectionUid && sectionKey) {
|
||||
dispatch(toggleSectionExpanded({ collectionUid, sectionKey }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={16} className={`chevron ${isExpanded ? 'expanded' : ''}`} />
|
||||
<span className={`section-dot type-${type}`} />
|
||||
<span className="section-title">{title}</span>
|
||||
<span className="section-count">{endpoints.length}</span>
|
||||
{subtitle && <span className="section-subtitle">{subtitle}</span>}
|
||||
{!isExpanded && headerExtra}
|
||||
{actions && <div className="section-actions" onClick={(e) => e.stopPropagation()}>{actions}</div>}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className={`section-body${expandableLayout ? ' expandable-mode' : ''}`}>
|
||||
{endpoints.map((endpoint, idx) => renderItem(endpoint, idx))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointChangeSection;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconUnlink,
|
||||
IconSettings,
|
||||
IconRefresh,
|
||||
IconCircleCheck,
|
||||
IconAlertTriangle
|
||||
} from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Help from 'components/Help';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
|
||||
const OpenAPISyncHeader = ({
|
||||
collection, spec, sourceUrl, syncStatus, onViewSpec,
|
||||
onOpenSettings, onOpenDisconnect,
|
||||
onCheck, isLoading
|
||||
}) => {
|
||||
const sourceIsLocal = !isHttpUrl(sourceUrl);
|
||||
const canCheck = !!sourceUrl?.trim();
|
||||
|
||||
// Resolve relative file paths to absolute for display
|
||||
const [displayPath, setDisplayPath] = useState(sourceUrl);
|
||||
useEffect(() => {
|
||||
if (sourceIsLocal && sourceUrl) {
|
||||
window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname)
|
||||
.then((resolved) => setDisplayPath(resolved))
|
||||
.catch(() => setDisplayPath(sourceUrl));
|
||||
} else {
|
||||
setDisplayPath(sourceUrl);
|
||||
}
|
||||
}, [sourceUrl, sourceIsLocal, collection.pathname]);
|
||||
|
||||
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
|
||||
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!sourceUrl) return;
|
||||
try {
|
||||
if (sourceIsLocal) {
|
||||
const absolutePath = await window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname);
|
||||
await navigator.clipboard.writeText(absolutePath);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(sourceUrl);
|
||||
}
|
||||
toast.success(sourceIsLocal ? 'Path copied to clipboard' : 'URL copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Error copying to clipboard:', err);
|
||||
toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const revealInFolder = async () => {
|
||||
if (!sourceUrl) return;
|
||||
try {
|
||||
const absolutePath = await window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname);
|
||||
await window.ipcRenderer.invoke('renderer:show-in-folder', absolutePath);
|
||||
} catch (err) {
|
||||
console.error('Error revealing in folder:', err);
|
||||
toast.error('Failed to open in file manager');
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Edit connection settings',
|
||||
leftSection: IconSettings,
|
||||
onClick: onOpenSettings
|
||||
},
|
||||
{
|
||||
id: 'disconnect',
|
||||
label: 'Disconnect Sync',
|
||||
leftSection: IconUnlink,
|
||||
className: 'delete-item',
|
||||
onClick: onOpenDisconnect
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="spec-info-card">
|
||||
<div className="spec-info-header">
|
||||
<div className="spec-title-section">
|
||||
<div className="spec-title-row">
|
||||
<span className="spec-title">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec-header-actions">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onCheck}
|
||||
disabled={!canCheck}
|
||||
loading={isLoading}
|
||||
icon={<IconRefresh size={14} />}
|
||||
>
|
||||
Check for updates
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onViewSpec}
|
||||
>
|
||||
View spec
|
||||
</Button>
|
||||
<MenuDropdown items={menuItems} placement="bottom-end">
|
||||
<ActionIcon label="More options">
|
||||
<IconDotsVertical size={16} strokeWidth={2} />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec-url-row">
|
||||
<span className="spec-url-label">{sourceIsLocal ? 'Source File:' : 'Source URL:'}</span>
|
||||
{sourceIsLocal ? (
|
||||
<button
|
||||
className="spec-url-value spec-file-reveal"
|
||||
title="Reveal in file manager"
|
||||
type="button"
|
||||
onClick={revealInFolder}
|
||||
>
|
||||
{displayPath}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
className="spec-url-value"
|
||||
href={sourceUrl}
|
||||
title={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{sourceUrl}
|
||||
</a>
|
||||
)}
|
||||
<button className="copy-btn" onClick={copyUrl} title={sourceIsLocal ? 'Copy path' : 'Copy URL'} type="button">
|
||||
<IconCopy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="linked-collection-row mt-1">
|
||||
<span className="spec-url-label">Linked Collection:</span>
|
||||
<span className="linked-collection-name">{collection.name}</span>
|
||||
{syncStatus === 'in-sync' && (
|
||||
<Help
|
||||
placement="bottom"
|
||||
width={240}
|
||||
iconComponent={() => <IconCircleCheck size={14} className="sync-status-icon in-sync" />}
|
||||
>
|
||||
Collection is up to date with the spec
|
||||
</Help>
|
||||
)}
|
||||
{syncStatus === 'not-in-sync' && (
|
||||
<Help
|
||||
placement="bottom"
|
||||
width={260}
|
||||
iconComponent={() => <IconAlertTriangle size={14} className="sync-status-icon not-in-sync" />}
|
||||
>
|
||||
Collection is not up to date with the spec
|
||||
</Help>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAPISyncHeader;
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { countEndpoints } from '../utils';
|
||||
import moment from 'moment';
|
||||
import { IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import Help from 'components/Help';
|
||||
|
||||
const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str;
|
||||
|
||||
const SUMMARY_CARDS = [
|
||||
{
|
||||
key: 'total',
|
||||
label: 'Total in Collection',
|
||||
color: 'blue',
|
||||
tooltip: 'Total endpoints in your collection'
|
||||
},
|
||||
{
|
||||
key: 'inSync',
|
||||
label: 'In Sync with Spec',
|
||||
color: 'green',
|
||||
tooltip: 'Endpoints that currently match the latest spec from the source'
|
||||
},
|
||||
{
|
||||
key: 'changed',
|
||||
label: 'Changed in Collection',
|
||||
color: 'muted',
|
||||
tooltip: 'Endpoints modified, deleted, or added locally since last sync',
|
||||
tab: 'collection-changes'
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: 'Spec Updates Pending',
|
||||
color: 'amber',
|
||||
tooltip: 'Spec changes available to sync to your collection',
|
||||
tab: 'spec-updates'
|
||||
}
|
||||
];
|
||||
|
||||
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, onOpenSettings }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
|
||||
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
|
||||
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
|
||||
const activeError = error || reduxError;
|
||||
|
||||
const version = specMeta?.version;
|
||||
const endpointCount = specMeta?.endpointCount ?? null;
|
||||
const lastSyncDate = openApiSyncConfig?.lastSyncDate;
|
||||
const groupBy = openApiSyncConfig?.groupBy || 'tags';
|
||||
const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false;
|
||||
const autoCheckInterval = openApiSyncConfig?.autoCheckInterval || 5;
|
||||
|
||||
// Endpoint Summary counts
|
||||
// Total: from collection items in Redux; In Sync: from remote spec comparison
|
||||
// Changed/Conflicts: compare against stored spec in AppData (0 on initial sync)
|
||||
const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec;
|
||||
|
||||
const totalInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const inSyncCount = remoteDrift
|
||||
? (remoteDrift.inSync?.length || 0)
|
||||
: null;
|
||||
|
||||
const changedInCollection = hasDriftData
|
||||
? (collectionDrift.modified?.length || 0) + (collectionDrift.missing?.length || 0) + (collectionDrift.localOnly?.length || 0)
|
||||
: 0;
|
||||
|
||||
const specUpdatesPending = hasDriftData
|
||||
? (specDrift?.added?.length || 0) + (specDrift?.modified?.length || 0) + (specDrift?.removed?.length || 0)
|
||||
: (remoteDrift?.modified?.length || 0) + (remoteDrift?.missing?.length || 0);
|
||||
|
||||
// Conflict count: endpoints modified in both spec and collection
|
||||
const conflictCount = hasDriftData && specDrift?.modified
|
||||
? (() => {
|
||||
const localModifiedIds = new Set((collectionDrift.modified || []).map((ep) => ep.id));
|
||||
return specDrift.modified.filter((ep) => localModifiedIds.has(ep.id)).length;
|
||||
})()
|
||||
: 0;
|
||||
|
||||
const summaryValues = {
|
||||
total: totalInCollection,
|
||||
inSync: inSyncCount,
|
||||
changed: changedInCollection,
|
||||
pending: activeError ? null : specDrift ? specUpdatesPending : null
|
||||
};
|
||||
|
||||
const details = [
|
||||
{ label: 'Spec Version', value: version ? `v${version}` : '–' },
|
||||
{ label: 'Endpoints in Spec', value: endpointCount != null ? endpointCount : '–' },
|
||||
{ label: 'Last Synced At', value: lastSyncDate ? moment(lastSyncDate).fromNow() : '–', tooltip: lastSyncDate ? moment(lastSyncDate).format('MMMM D, YYYY [at] h:mm A') : undefined },
|
||||
{ label: 'Folder Grouping', value: capitalize(groupBy) },
|
||||
{ label: 'Auto Check for Updates', value: autoCheckEnabled ? `Every ${autoCheckInterval} min` : 'Disabled' }
|
||||
];
|
||||
|
||||
const hasCollectionChanges = changedInCollection > 0;
|
||||
const hasSpecUpdates = specUpdatesPending > 0;
|
||||
|
||||
const bannerState = useMemo(() => {
|
||||
const versionInfo = (specDrift?.storedVersion && specDrift?.newVersion && specDrift.storedVersion !== specDrift.newVersion)
|
||||
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
|
||||
: '';
|
||||
|
||||
if (activeError) {
|
||||
return {
|
||||
variant: 'danger',
|
||||
title: 'Failed to check for spec updates',
|
||||
subtitle: activeError,
|
||||
buttons: ['open-settings']
|
||||
};
|
||||
}
|
||||
if (specDrift?.storedSpecMissing && !lastSyncDate) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'Initial sync required — your collection differs from the spec',
|
||||
subtitle: 'Review the changes and sync to bring your collection up to date.',
|
||||
buttons: ['review']
|
||||
};
|
||||
}
|
||||
if (hasSpecUpdates && hasCollectionChanges) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: `OpenAPI spec has new updates${versionInfo} and the collection has changes`,
|
||||
subtitle: 'New or changed requests are available. Some collection changes may be overwritten.',
|
||||
buttons: ['sync', 'changes']
|
||||
};
|
||||
}
|
||||
if (hasSpecUpdates) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: `OpenAPI spec has new updates${versionInfo}`,
|
||||
subtitle: 'New or changed requests are available.',
|
||||
buttons: ['sync']
|
||||
};
|
||||
}
|
||||
if (specDrift?.storedSpecMissing && lastSyncDate) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'Last synced spec not found',
|
||||
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track collection changes.',
|
||||
buttons: ['spec-details']
|
||||
};
|
||||
}
|
||||
if (!hasDriftData) return null;
|
||||
if (hasCollectionChanges) {
|
||||
return {
|
||||
variant: 'muted',
|
||||
title: 'Collection has changes not in the spec',
|
||||
subtitle: 'Some requests have been modified or removed and no longer match the spec.',
|
||||
buttons: ['changes']
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]);
|
||||
|
||||
return (
|
||||
<div className="overview-section">
|
||||
{bannerState && (
|
||||
<div className={`overview-status-banner ${bannerState.variant}`}>
|
||||
<div className="banner-text">
|
||||
<div className="banner-title-row">
|
||||
{bannerState.variant === 'success'
|
||||
? <IconCheck size={16} className="status-check-icon" />
|
||||
: <div className={`status-dot ${bannerState.variant}`} />}
|
||||
<span className="banner-title">{bannerState.title}</span>
|
||||
</div>
|
||||
{bannerState.subtitle && (
|
||||
<p className="banner-subtitle">{bannerState.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{bannerState.buttons.length > 0 && (
|
||||
<div className="banner-button-row">
|
||||
{bannerState.buttons.includes('changes') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={bannerState.buttons.includes('sync') ? 'outline' : 'filled'}
|
||||
color={bannerState.buttons.includes('sync') ? 'secondary' : 'primary'}
|
||||
onClick={() => onTabSelect('collection-changes')}
|
||||
>
|
||||
View Collection Changes
|
||||
</Button>
|
||||
)}
|
||||
{(bannerState.buttons.includes('sync') || bannerState.buttons.includes('review')) && (
|
||||
<Button size="sm" onClick={() => onTabSelect('spec-updates')}>
|
||||
Review and Sync Collection
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('spec-details') && (
|
||||
<Button variant="outline" size="sm" onClick={() => onTabSelect('spec-updates')}>
|
||||
Go to Spec Updates
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('open-settings') && (
|
||||
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||
Update connection settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h4 className="overview-section-title mt-5">Endpoint Summary</h4>
|
||||
<div className="sync-summary-cards">
|
||||
{SUMMARY_CARDS.map(({ key, label, tooltip, tab, color }) => {
|
||||
const count = summaryValues[key];
|
||||
const resolvedColor = count > 0 ? color : 'muted';
|
||||
const isClickable = tab && count > 0;
|
||||
return (
|
||||
<div
|
||||
className={`summary-card${isClickable ? ' clickable' : ''}`}
|
||||
key={key}
|
||||
onClick={isClickable ? () => onTabSelect(tab) : undefined}
|
||||
>
|
||||
<span className="card-info-icon">
|
||||
<Help icon="info" size={12} placement="top" width={220}>{tooltip}</Help>
|
||||
</span>
|
||||
<div className="summary-count-row">
|
||||
<span className={`summary-count ${resolvedColor}`}>{count != null ? count : '–'}</span>
|
||||
{key === 'pending' && conflictCount > 0 && (
|
||||
<span className="conflict-annotation">({conflictCount} {conflictCount === 1 ? 'conflict' : 'conflicts'})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="summary-label">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h4 className="overview-section-title mt-7">Last Synced Spec Details</h4>
|
||||
<div className="spec-details-grid">
|
||||
{details.map(({ label, value, tooltip }) => (
|
||||
<div className="spec-detail-item" key={label}>
|
||||
<div className="spec-detail-label">{label}</div>
|
||||
<div className="spec-detail-value">
|
||||
{value}
|
||||
{tooltip && (
|
||||
<Help icon="info" size={11} placement="top" width={200}>{tooltip}</Help>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverviewSection;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
|
||||
const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
const diffRef = useRef(null);
|
||||
const { displayedTheme } = useTheme();
|
||||
const [isRendering, setIsRendering] = useState(true);
|
||||
|
||||
const addedCount = specDrift?.added?.length || 0;
|
||||
const modifiedCount = specDrift?.modified?.length || 0;
|
||||
const removedCount = specDrift?.removed?.length || 0;
|
||||
|
||||
const versionLabel = specDrift?.versionChanged
|
||||
? `v${specDrift.storedVersion || '?'} → v${specDrift.newVersion}`
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const { Diff2Html } = window;
|
||||
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
|
||||
setIsRendering(false);
|
||||
return;
|
||||
}
|
||||
setIsRendering(true);
|
||||
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
|
||||
drawFileList: false,
|
||||
matching: 'lines',
|
||||
outputFormat: 'side-by-side',
|
||||
synchronisedScroll: true,
|
||||
highlight: true,
|
||||
renderNothingWhenEmpty: false,
|
||||
colorScheme: displayedTheme
|
||||
});
|
||||
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
|
||||
diffRef.current.innerHTML = diffHtml;
|
||||
setIsRendering(false);
|
||||
}, [displayedTheme, specDrift?.unifiedDiff]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="xl"
|
||||
title="Spec Diff"
|
||||
hideFooter
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="spec-diff-modal">
|
||||
<div className="spec-diff-badges">
|
||||
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
|
||||
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
|
||||
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
|
||||
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
|
||||
</div>
|
||||
|
||||
<p className="spec-diff-subtitle">
|
||||
{specDrift?.storedSpecMissing
|
||||
? 'The current spec file is missing. The full remote spec is shown below.'
|
||||
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
|
||||
</p>
|
||||
|
||||
<div className="spec-diff-body">
|
||||
<div className="text-diff-container">
|
||||
{specDrift?.unifiedDiff ? (
|
||||
<>
|
||||
<div className="diff-column-headers">
|
||||
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
|
||||
<span className="diff-column-label">Updated Spec</span>
|
||||
</div>
|
||||
{isRendering && (
|
||||
<div className="text-diff-loading">
|
||||
<IconLoader2 className="animate-spin" size={20} strokeWidth={1.5} />
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-diff-empty">No text diff available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecDiffModal;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
IconCheck,
|
||||
IconRefresh,
|
||||
IconAlertTriangle,
|
||||
IconClock
|
||||
} from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import ConfirmSyncModal from '../ConfirmSyncModal';
|
||||
import SyncReviewPage from '../SyncReviewPage';
|
||||
import useSyncFlow from '../hooks/useSyncFlow';
|
||||
|
||||
const SpecStatusSection = ({
|
||||
collection, sourceUrl,
|
||||
isLoading, error, setError, fileNotFound,
|
||||
specDrift, storedSpec,
|
||||
collectionDrift, remoteDrift,
|
||||
onCheck, onOpenSettings
|
||||
}) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const lastCheckedAt = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.lastChecked);
|
||||
|
||||
const {
|
||||
isSyncing, showConfirmModal, confirmGroups,
|
||||
handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
} = useSyncFlow({
|
||||
collection, specDrift, remoteDrift, collectionDrift,
|
||||
setError, checkForUpdates: onCheck
|
||||
});
|
||||
|
||||
const lastSyncedAt = openApiSyncConfig?.lastSyncDate;
|
||||
|
||||
const hasRemoteUpdates = remoteDrift && (
|
||||
(remoteDrift.missing?.length || 0)
|
||||
+ (remoteDrift.modified?.length || 0)
|
||||
+ (remoteDrift.localOnly?.length || 0)
|
||||
) > 0;
|
||||
|
||||
const bannerState = useMemo(() => {
|
||||
if (fileNotFound) {
|
||||
return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] };
|
||||
}
|
||||
if (error || specDrift?.isValid === false) {
|
||||
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: ['open-settings'] };
|
||||
}
|
||||
if (!specDrift) {
|
||||
return null;
|
||||
}
|
||||
if (specDrift.storedSpecMissing && !hasRemoteUpdates) {
|
||||
return null;
|
||||
}
|
||||
const hasEndpointUpdates = specDrift.storedSpecMissing
|
||||
? hasRemoteUpdates
|
||||
: (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0;
|
||||
if (hasEndpointUpdates) {
|
||||
const versionInfo = (specDrift.storedVersion && specDrift.newVersion && specDrift.storedVersion !== specDrift.newVersion)
|
||||
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
|
||||
: '';
|
||||
return {
|
||||
variant: 'warning', message: `OpenAPI spec has been updated${versionInfo}`, actions: [],
|
||||
changes: { added: specDrift.added?.length || 0, modified: specDrift.modified?.length || 0, removed: specDrift.removed?.length || 0 }
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt, hasRemoteUpdates]);
|
||||
return (
|
||||
<>
|
||||
{bannerState && (
|
||||
<div className="spec-status-section">
|
||||
|
||||
<div className={`spec-update-banner ${bannerState.variant}`}>
|
||||
<div className="banner-left">
|
||||
{bannerState.variant === 'success'
|
||||
? <IconCheck size={16} className="status-check-icon" />
|
||||
: <div className={`status-dot ${bannerState.variant}`} />}
|
||||
<span className="banner-title">
|
||||
{bannerState.message}
|
||||
{bannerState.version && (
|
||||
<> · <code style={{ fontStyle: 'normal' }} className="checked-text">v{bannerState.version}</code></>
|
||||
)}
|
||||
{bannerState.lastChecked && (
|
||||
<span className="checked-text"> · Checked {bannerState.lastChecked}</span>
|
||||
)}
|
||||
</span>
|
||||
{bannerState.changes && (
|
||||
<span className="banner-details">
|
||||
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="warning" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
|
||||
{bannerState.changes.added > 0 && <StatusBadge key="added" status="success" radius="full">{bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added</StatusBadge>}
|
||||
{bannerState.changes.removed > 0 && <StatusBadge key="removed" status="danger" radius="full">{bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed</StatusBadge>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="banner-actions">
|
||||
{bannerState.actions.includes('open-settings') && (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenSettings}>
|
||||
Update connection settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || fileNotFound || specDrift?.isValid === false) ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconAlertTriangle size={40} className="empty-state-icon" />
|
||||
<h4>Unable to check for updates</h4>
|
||||
<p>Fix the connection issue above and check again.</p>
|
||||
</div>
|
||||
) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate && !hasRemoteUpdates ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No updates from the spec</h4>
|
||||
<p>The spec endpoints have not been updated since the last sync. You can restore the spec file to track local collection changes.</p>
|
||||
<Button className="mt-4" color="warning" onClick={handleRestoreSpec} loading={isSyncing}>
|
||||
Restore Spec File
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5">
|
||||
<SyncReviewPage
|
||||
specDrift={specDrift}
|
||||
remoteDrift={remoteDrift}
|
||||
collectionDrift={collectionDrift}
|
||||
collectionPath={collection.pathname}
|
||||
collectionUid={collection.uid}
|
||||
newSpec={specDrift?.newSpec}
|
||||
isSyncing={isSyncing}
|
||||
isLoading={isLoading}
|
||||
onApplySync={handleApplySync}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmModal && (
|
||||
<ConfirmSyncModal
|
||||
groups={confirmGroups}
|
||||
isSyncing={isSyncing}
|
||||
onCancel={cancelConfirmModal}
|
||||
onSync={handleConfirmModalSync}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecStatusSection;
|
||||
2326
packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js
Normal file
2326
packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconArrowRight,
|
||||
IconArrowsDiff,
|
||||
IconInfoCircle,
|
||||
IconLoader2
|
||||
} from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import EndpointChangeSection from '../EndpointChangeSection';
|
||||
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
|
||||
import ConfirmSyncModal from '../ConfirmSyncModal';
|
||||
import SpecDiffModal from '../SpecDiffModal';
|
||||
import Help from 'components/Help';
|
||||
import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
|
||||
/**
|
||||
* Categorize remoteDrift endpoints using three-way merge.
|
||||
* Uses specDrift and collectionDrift to determine who changed each modified endpoint.
|
||||
*
|
||||
* Returns:
|
||||
* - specAddedEndpoints: new in spec, not yet in collection
|
||||
* - specUpdatedEndpoints: modified in spec (includes conflicts where both sides changed)
|
||||
* - localUpdatedEndpoints: modified only in the collection (spec didn't change)
|
||||
* - specRemovedEndpoints: removed from spec, still in collection
|
||||
*/
|
||||
const categorizeEndpoints = (remoteDrift, specDrift, collectionDrift) => {
|
||||
// Only show endpoints as "New in Spec" if they were actually added to the spec
|
||||
// (i.e., they appear in specDrift.added). Endpoints the user deleted locally that
|
||||
// still exist in both stored and remote spec should not appear here — they belong
|
||||
// in "Collection Changes" only.
|
||||
const specAddedIds = new Set((specDrift?.added || []).map((ep) => ep.id));
|
||||
const specAddedEndpoints = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
|
||||
|
||||
// Only show endpoints as "Removed from Spec" if they were actually in the stored spec
|
||||
// (i.e., they appear in specDrift.removed). Locally-added endpoints that were never in
|
||||
// the spec should not appear here — they belong in "Collection Changes" only.
|
||||
const specRemovedIds = new Set((specDrift?.removed || []).map((ep) => ep.id));
|
||||
const specRemovedEndpoints = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
|
||||
|
||||
// Build lookup sets to determine who changed each modified endpoint
|
||||
const specModifiedIds = new Set((specDrift?.modified || []).map((ep) => ep.id));
|
||||
const localModifiedIds = new Set((collectionDrift?.modified || []).map((ep) => ep.id));
|
||||
const noMergeBase = collectionDrift?.noStoredSpec;
|
||||
|
||||
const specUpdatedEndpoints = [];
|
||||
const localUpdatedEndpoints = [];
|
||||
|
||||
(remoteDrift.modified || []).forEach((ep) => {
|
||||
// When there's no merge base (noStoredSpec), we can't tell who changed what — treat as spec update
|
||||
const specChanged = !noMergeBase && specModifiedIds.has(ep.id);
|
||||
const localChanged = !noMergeBase && localModifiedIds.has(ep.id);
|
||||
|
||||
if (!specChanged && localChanged) {
|
||||
// Only local changed — user modification, spec didn't change
|
||||
localUpdatedEndpoints.push({
|
||||
...ep,
|
||||
source: 'collection-drift',
|
||||
localAction: 'modified'
|
||||
});
|
||||
} else {
|
||||
// Spec changed, both changed (conflict), no merge base, or sensitivity mismatch
|
||||
specUpdatedEndpoints.push({
|
||||
...ep,
|
||||
source: 'spec-modified',
|
||||
specAction: 'modified',
|
||||
...(specChanged && localChanged && { conflict: true, localAction: 'modified' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints };
|
||||
};
|
||||
|
||||
const SyncReviewPage = ({
|
||||
specDrift,
|
||||
remoteDrift,
|
||||
collectionDrift,
|
||||
collectionPath,
|
||||
collectionUid,
|
||||
newSpec,
|
||||
isSyncing,
|
||||
isLoading,
|
||||
onApplySync
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabUiState = useSelector(selectTabUiState(collectionUid));
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
|
||||
|
||||
const { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints } = useMemo(() => {
|
||||
if (!remoteDrift) {
|
||||
return { specAddedEndpoints: [], specUpdatedEndpoints: [], localUpdatedEndpoints: [], specRemovedEndpoints: [] };
|
||||
}
|
||||
return categorizeEndpoints(remoteDrift, specDrift, collectionDrift);
|
||||
}, [specDrift, remoteDrift, collectionDrift]);
|
||||
|
||||
const conflictCount = specUpdatedEndpoints.filter((ep) => ep.conflict).length;
|
||||
const hasConflicts = conflictCount > 0;
|
||||
|
||||
// Track decisions in Redux (persisted across navigations)
|
||||
const savedDecisions = tabUiState.reviewDecisions || {};
|
||||
|
||||
// Compute defaults for any endpoints not yet in Redux
|
||||
const decisions = useMemo(() => {
|
||||
const merged = { ...savedDecisions };
|
||||
// Spec changes: accept-incoming by default, null for conflicts (must resolve manually)
|
||||
specUpdatedEndpoints.forEach((ep) => {
|
||||
if (!(ep.id in merged)) merged[ep.id] = ep.conflict ? null : 'accept-incoming';
|
||||
});
|
||||
// Local changes: keep-mine (preserved silently, not shown in review)
|
||||
localUpdatedEndpoints.forEach((ep) => {
|
||||
if (!(ep.id in merged)) merged[ep.id] = 'keep-mine';
|
||||
});
|
||||
// Added + removed endpoints: accept-incoming
|
||||
[...specAddedEndpoints, ...specRemovedEndpoints].forEach((ep) => {
|
||||
if (!(ep.id in merged)) merged[ep.id] = 'accept-incoming';
|
||||
});
|
||||
return merged;
|
||||
}, [savedDecisions, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints, specAddedEndpoints]);
|
||||
|
||||
// Sync computed defaults back to Redux when they differ from saved state
|
||||
useEffect(() => {
|
||||
const hasNewDefaults = Object.keys(decisions).some((id) => !(id in savedDecisions));
|
||||
if (hasNewDefaults) {
|
||||
dispatch(setReviewDecisions({ collectionUid, decisions }));
|
||||
}
|
||||
}, [decisions, savedDecisions, collectionUid, dispatch]);
|
||||
|
||||
const handleDecisionChange = (endpointId, decision) => {
|
||||
dispatch(setReviewDecision({ collectionUid, endpointId, decision }));
|
||||
};
|
||||
|
||||
// Bulk actions — all spec-driven sections
|
||||
const decidableEndpoints = useMemo(() => {
|
||||
return [...specUpdatedEndpoints, ...specAddedEndpoints, ...specRemovedEndpoints];
|
||||
}, [specUpdatedEndpoints, specAddedEndpoints, specRemovedEndpoints]);
|
||||
|
||||
const setBulkDecision = (decision) => {
|
||||
const newDecisions = {};
|
||||
decidableEndpoints.forEach((ep) => { newDecisions[ep.id] = decision; });
|
||||
dispatch(setReviewDecisions({ collectionUid, decisions: newDecisions }));
|
||||
};
|
||||
|
||||
const allAccepted = decidableEndpoints.length > 0
|
||||
&& decidableEndpoints.every((ep) => decisions[ep.id] === 'accept-incoming');
|
||||
const allSkipped = decidableEndpoints.length > 0
|
||||
&& decidableEndpoints.every((ep) => decisions[ep.id] === 'keep-mine');
|
||||
|
||||
const unresolvedConflicts = specUpdatedEndpoints.filter((ep) => ep.conflict && !decisions[ep.id]).length;
|
||||
|
||||
// Confirmation summary — grouped endpoint lists
|
||||
const confirmGroups = useMemo(() => {
|
||||
const groups = [];
|
||||
const addGroup = (label, type, endpoints) => {
|
||||
if (endpoints.length > 0) groups.push({ label, type, endpoints });
|
||||
};
|
||||
|
||||
const isAccepted = (ep) => decisions[ep.id] === 'accept-incoming';
|
||||
const isSkipped = (ep) => decisions[ep.id] === 'keep-mine';
|
||||
|
||||
// Accepted — changes that will be applied
|
||||
addGroup('New endpoints to add', 'add', specAddedEndpoints.filter(isAccepted));
|
||||
addGroup('Endpoints to update', 'update', specUpdatedEndpoints.filter(isAccepted));
|
||||
addGroup('Endpoints to delete', 'remove', specRemovedEndpoints.filter(isAccepted));
|
||||
|
||||
// Skipped — changes that will be preserved as-is
|
||||
addGroup('Keeping local version', 'keep', specUpdatedEndpoints.filter((ep) => ep.conflict && isSkipped(ep)));
|
||||
addGroup('Retaining removed endpoints', 'keep', specRemovedEndpoints.filter(isSkipped));
|
||||
addGroup('Skipped new endpoints', 'keep', specAddedEndpoints.filter(isSkipped));
|
||||
addGroup('Keeping current version (skipped updates)', 'keep', specUpdatedEndpoints.filter((ep) => !ep.conflict && isSkipped(ep)));
|
||||
|
||||
return groups;
|
||||
}, [specAddedEndpoints, specUpdatedEndpoints, specRemovedEndpoints, decisions]);
|
||||
|
||||
const handleConfirmApply = () => {
|
||||
setShowConfirmation(false);
|
||||
|
||||
// Filter based on decisions
|
||||
const filteredAddedEndpoints = specAddedEndpoints.filter(
|
||||
(ep) => decisions[ep.id] === 'accept-incoming'
|
||||
);
|
||||
const filteredSpecChanges = specUpdatedEndpoints.filter(
|
||||
(ep) => !ep.conflict && decisions[ep.id] === 'accept-incoming'
|
||||
);
|
||||
|
||||
// Collect "Not in Spec" endpoints where user chose to remove
|
||||
const localOnlyIds = specRemovedEndpoints
|
||||
.filter((ep) => decisions[ep.id] === 'accept-incoming')
|
||||
.map((ep) => ep.id);
|
||||
|
||||
onApplySync({
|
||||
endpointDecisions: decisions,
|
||||
localOnlyIds,
|
||||
// Pass filtered categorized endpoints for performSync to construct the right backend diff
|
||||
newToCollection: filteredAddedEndpoints,
|
||||
specUpdates: filteredSpecChanges,
|
||||
resolvedConflicts: specUpdatedEndpoints.filter((ep) => ep.conflict && decisions[ep.id] === 'accept-incoming'),
|
||||
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming')
|
||||
});
|
||||
};
|
||||
|
||||
const totalChanges = specAddedEndpoints.length + specUpdatedEndpoints.length + localUpdatedEndpoints.length + specRemovedEndpoints.length;
|
||||
const hasRemoteUpdates = specAddedEndpoints.length + specUpdatedEndpoints.length + specRemovedEndpoints.length > 0;
|
||||
|
||||
const buttonLabel = unresolvedConflicts > 0
|
||||
? `Resolve ${unresolvedConflicts} conflict${unresolvedConflicts !== 1 ? 's and sync' : ' and sync'}`
|
||||
: !hasRemoteUpdates && specDrift?.storedSpecMissing
|
||||
? 'Restore Spec File'
|
||||
: 'Sync Collection';
|
||||
|
||||
return (
|
||||
<div className="sync-review-page sync-mode">
|
||||
{hasRemoteUpdates && (
|
||||
<div className="sync-review-header">
|
||||
<div className="title-row">
|
||||
<div className="title-left">
|
||||
<h3 className="review-title">Review Changes</h3>
|
||||
{totalChanges > 0 && (
|
||||
<p className="review-subtitle">
|
||||
Choose to keep the current version or accept the updated one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
|
||||
<div className="bulk-actions">
|
||||
{specDrift?.unifiedDiff && (
|
||||
<button className="bulk-btn" onClick={() => setShowSpecDiffModal(true)}>
|
||||
<IconArrowsDiff size={12} /> View Spec Diff
|
||||
</button>
|
||||
)}
|
||||
{decidableEndpoints.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
className={`bulk-btn ${allSkipped ? 'active' : ''}`}
|
||||
onClick={() => setBulkDecision('keep-mine')}
|
||||
>
|
||||
<IconX size={12} /> Skip All
|
||||
</button>
|
||||
<button
|
||||
className={`bulk-btn ${allAccepted ? 'active' : ''}`}
|
||||
onClick={() => setBulkDecision('accept-incoming')}
|
||||
>
|
||||
<IconCheck size={12} /> Accept All
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sync-review-body">
|
||||
{!hasRemoteUpdates ? (
|
||||
<div className="sync-review-empty-state">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
|
||||
<h4>Checking for updates</h4>
|
||||
<p>Comparing your last synced spec with the latest spec...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No updates from the spec</h4>
|
||||
<p>The spec endpoints have not been updated since the last sync.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="endpoints-review-sections">
|
||||
{/* === Updates from Spec === */}
|
||||
{decidableEndpoints.length > 0 && (
|
||||
<div className="review-group">
|
||||
|
||||
<EndpointChangeSection
|
||||
title="Updated in Spec"
|
||||
type="spec-modified"
|
||||
endpoints={specUpdatedEndpoints}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="The spec has updates for these endpoints"
|
||||
headerExtra={conflictCount > 0 ? (
|
||||
<StatusBadge
|
||||
status="danger"
|
||||
rightSection={(
|
||||
<Help icon="info" size={11} placement="top" width={250}>
|
||||
{`This section has ${conflictCount} endpoint${conflictCount === 1 ? '' : 's'} modified in both the spec and your collection. Expand to review and resolve.`}
|
||||
</Help>
|
||||
)}
|
||||
>
|
||||
{conflictCount} {conflictCount === 1 ? 'Conflict' : 'Conflicts'}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
collectionUid={collectionUid}
|
||||
sectionKey="review-spec-modified"
|
||||
renderItem={(endpoint, idx) => (
|
||||
<ExpandableEndpointRow
|
||||
key={endpoint.id || idx}
|
||||
endpoint={endpoint}
|
||||
decision={decisions?.[endpoint.id]}
|
||||
onDecisionChange={(decision) => handleDecisionChange(endpoint.id, decision)}
|
||||
collectionPath={collectionPath}
|
||||
newSpec={newSpec}
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Keep Current', accept: 'Update' }}
|
||||
collectionUid={collectionUid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<EndpointChangeSection
|
||||
title="New in Spec"
|
||||
type="added"
|
||||
endpoints={specAddedEndpoints}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="New endpoints from the spec"
|
||||
collectionUid={collectionUid}
|
||||
sectionKey="review-added"
|
||||
renderItem={(endpoint, idx) => (
|
||||
<ExpandableEndpointRow
|
||||
key={endpoint.id || idx}
|
||||
endpoint={endpoint}
|
||||
decision={decisions?.[endpoint.id]}
|
||||
onDecisionChange={(decision) => handleDecisionChange(endpoint.id, decision)}
|
||||
collectionPath={collectionPath}
|
||||
newSpec={newSpec}
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Skip', accept: 'Add' }}
|
||||
collectionUid={collectionUid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<EndpointChangeSection
|
||||
title="Removed from Spec"
|
||||
type="removed"
|
||||
endpoints={specRemovedEndpoints}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="These endpoints are in your collection but not in the spec"
|
||||
collectionUid={collectionUid}
|
||||
sectionKey="review-removed"
|
||||
renderItem={(endpoint, idx) => (
|
||||
<ExpandableEndpointRow
|
||||
key={endpoint.id || idx}
|
||||
endpoint={endpoint}
|
||||
decision={decisions?.[endpoint.id]}
|
||||
onDecisionChange={(decision) => handleDecisionChange(endpoint.id, decision)}
|
||||
collectionPath={collectionPath}
|
||||
newSpec={newSpec}
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Keep', accept: 'Delete' }}
|
||||
collectionUid={collectionUid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasRemoteUpdates && (
|
||||
<div className="sync-info-notice mt-4">
|
||||
<IconInfoCircle size={14} className="sync-info-icon" />
|
||||
<span><span className="whats-updated-title">What gets updated:</span> Parameters, headers, body and auth will be updated. Tests, scripts, and assertions are always preserved.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasRemoteUpdates && (
|
||||
<div className="sync-review-bottom-bar mt-4">
|
||||
<div className="bar-stats">
|
||||
{totalChanges === 0 && (
|
||||
<span className="stats-prefix">
|
||||
{specDrift?.storedSpecMissing ? 'Sync will update the spec file' : 'No endpoint changes to apply'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bar-actions">
|
||||
<Button
|
||||
onClick={totalChanges === 0 ? handleConfirmApply : () => setShowConfirmation(true)}
|
||||
disabled={unresolvedConflicts > 0 || isSyncing}
|
||||
loading={isSyncing}
|
||||
>
|
||||
{buttonLabel}
|
||||
{unresolvedConflicts === 0 && <IconArrowRight size={14} style={{ marginLeft: 4 }} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmation && (
|
||||
<ConfirmSyncModal
|
||||
groups={confirmGroups}
|
||||
onCancel={() => setShowConfirmation(false)}
|
||||
onSync={handleConfirmApply}
|
||||
isSyncing={isSyncing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSpecDiffModal && (
|
||||
<SpecDiffModal
|
||||
specDrift={specDrift}
|
||||
onClose={() => setShowSpecDiffModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncReviewPage;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const useEndpointActions = (collection, collectionDrift, reloadDrift) => {
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
|
||||
// Action execution helper — runs IPC call(s), shows toast, reloads drift
|
||||
const executeEndpointAction = async (ipcCalls, successMsg, errorMsg) => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
if (Array.isArray(ipcCalls[0])) {
|
||||
await Promise.all(ipcCalls.map(([channel, params]) => ipcRenderer.invoke(channel, params)));
|
||||
} else {
|
||||
const [channel, params] = ipcCalls;
|
||||
await ipcRenderer.invoke(channel, params);
|
||||
}
|
||||
toast.success(successMsg);
|
||||
await reloadDrift();
|
||||
} catch (err) {
|
||||
console.error(`Error: ${errorMsg}`, err);
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// Confirmation handlers — show modal before executing
|
||||
const handleResetEndpoint = (endpoint) => {
|
||||
setPendingAction({
|
||||
type: 'reset-endpoint',
|
||||
title: 'Reset Endpoint',
|
||||
message: `Are you sure you want to reset "${endpoint.method} ${endpoint.path}" to match the spec? Your local changes will be lost.`,
|
||||
endpoint
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetAllModified = () => {
|
||||
if (!collectionDrift?.modified?.length) return;
|
||||
setPendingAction({
|
||||
type: 'reset-all-modified',
|
||||
title: 'Reset All Modified',
|
||||
message: `Are you sure you want to reset ${collectionDrift.modified.length} modified endpoint(s) to match the spec? Your local changes will be lost.`
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteEndpoint = (endpoint) => {
|
||||
setPendingAction({
|
||||
type: 'delete-endpoint',
|
||||
title: 'Delete Endpoint',
|
||||
message: `Are you sure you want to delete "${endpoint.method} ${endpoint.path}"? This action cannot be undone.`,
|
||||
endpoint
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAllLocalOnly = () => {
|
||||
if (!collectionDrift?.localOnly?.length) return;
|
||||
setPendingAction({
|
||||
type: 'delete-all-local',
|
||||
title: 'Delete All Local Endpoints',
|
||||
message: `Are you sure you want to delete ${collectionDrift.localOnly.length} local-only endpoint(s)? This action cannot be undone.`
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevertAllChanges = () => {
|
||||
const modifiedCount = collectionDrift?.modified?.length || 0;
|
||||
const missingCount = collectionDrift?.missing?.length || 0;
|
||||
const localOnlyCount = collectionDrift?.localOnly?.length || 0;
|
||||
|
||||
setPendingAction({
|
||||
type: 'revert-all',
|
||||
title: 'Revert All Changes',
|
||||
message: `Are you sure you want to revert all changes? This will reset ${modifiedCount} modified, restore ${missingCount} missing, and delete ${localOnlyCount} local-only endpoint(s).`
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddMissingEndpoint = (endpoint) => {
|
||||
setPendingAction({
|
||||
type: 'restore-endpoint',
|
||||
title: 'Restore Endpoint',
|
||||
message: `Are you sure you want to restore "${endpoint.method} ${endpoint.path}" to your collection?`,
|
||||
endpoint
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddAllMissing = () => {
|
||||
if (!collectionDrift?.missing?.length) return;
|
||||
setPendingAction({
|
||||
type: 'restore-all-missing',
|
||||
title: 'Restore All Missing',
|
||||
message: `Are you sure you want to restore ${collectionDrift.missing.length} missing endpoint(s) to your collection?`
|
||||
});
|
||||
};
|
||||
|
||||
// Execute confirmed action
|
||||
const confirmPendingAction = async () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
const { type, endpoint } = pendingAction;
|
||||
setPendingAction(null);
|
||||
|
||||
switch (type) {
|
||||
case 'reset-endpoint':
|
||||
return executeEndpointAction(
|
||||
['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: [endpoint] }],
|
||||
`Reset ${endpoint.method} ${endpoint.path} to spec`,
|
||||
'Failed to reset endpoint'
|
||||
);
|
||||
case 'reset-all-modified':
|
||||
return executeEndpointAction(
|
||||
['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: collectionDrift.modified }],
|
||||
`Reset ${collectionDrift.modified.length} endpoints to spec`,
|
||||
'Failed to reset endpoints'
|
||||
);
|
||||
case 'delete-endpoint':
|
||||
return executeEndpointAction(
|
||||
['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: [endpoint] }],
|
||||
`Deleted ${endpoint.method} ${endpoint.path}`,
|
||||
'Failed to delete endpoint'
|
||||
);
|
||||
case 'delete-all-local':
|
||||
return executeEndpointAction(
|
||||
['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: collectionDrift.localOnly }],
|
||||
`Deleted ${collectionDrift.localOnly.length} local-only endpoints`,
|
||||
'Failed to delete endpoints'
|
||||
);
|
||||
case 'revert-all': {
|
||||
const calls = [];
|
||||
if (collectionDrift?.modified?.length > 0) {
|
||||
calls.push(['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: collectionDrift.modified }]);
|
||||
}
|
||||
if (collectionDrift?.missing?.length > 0) {
|
||||
calls.push(['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: collectionDrift.missing }]);
|
||||
}
|
||||
if (collectionDrift?.localOnly?.length > 0) {
|
||||
calls.push(['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: collectionDrift.localOnly }]);
|
||||
}
|
||||
return executeEndpointAction(calls, 'All changes discarded successfully', 'Failed to discard changes');
|
||||
}
|
||||
case 'restore-endpoint':
|
||||
return executeEndpointAction(
|
||||
['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: [endpoint] }],
|
||||
`Added ${endpoint.method} ${endpoint.path} to collection`,
|
||||
'Failed to add endpoint'
|
||||
);
|
||||
case 'restore-all-missing':
|
||||
return executeEndpointAction(
|
||||
['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: collectionDrift.missing }],
|
||||
`Added ${collectionDrift.missing.length} endpoints to collection`,
|
||||
'Failed to add endpoints'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
pendingAction, setPendingAction,
|
||||
confirmPendingAction,
|
||||
handleResetEndpoint,
|
||||
handleResetAllModified,
|
||||
handleDeleteEndpoint,
|
||||
handleDeleteAllLocalOnly,
|
||||
handleRevertAllChanges,
|
||||
handleAddMissingEndpoint,
|
||||
handleAddAllMissing
|
||||
};
|
||||
};
|
||||
|
||||
export default useEndpointActions;
|
||||
@@ -0,0 +1,412 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
import { countEndpoints } from '../utils';
|
||||
|
||||
const useOpenAPISync = (collection) => {
|
||||
const dispatch = useDispatch();
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
|
||||
// Core state
|
||||
const [sourceUrl, setSourceUrl] = useState(openApiSyncConfig?.sourceUrl || '');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [fileNotFound, setFileNotFound] = useState(false);
|
||||
const [specDrift, setSpecDrift] = useState(null);
|
||||
// Collection drift state
|
||||
const [collectionDrift, setCollectionDrift] = useState(null);
|
||||
const [remoteDrift, setRemoteDrift] = useState(null);
|
||||
const [isDriftLoading, setIsDriftLoading] = useState(false);
|
||||
const [storedSpec, setStoredSpec] = useState(null);
|
||||
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
|
||||
const isConfigured = !!openApiSyncConfig?.sourceUrl;
|
||||
|
||||
const updateStoredSpec = (spec) => {
|
||||
setStoredSpec(spec);
|
||||
dispatch(setStoredSpecMeta({
|
||||
collectionUid: collection.uid,
|
||||
title: spec?.info?.title || null,
|
||||
version: spec?.info?.version || null,
|
||||
endpointCount: spec ? countEndpoints(spec) : null
|
||||
}));
|
||||
};
|
||||
|
||||
// Flatten collection items including nested items in folders
|
||||
const allHttpItems = useMemo(() => {
|
||||
return flattenItems(collection?.items || []).filter((item) => item.type === 'http-request');
|
||||
}, [collection?.items]);
|
||||
|
||||
const httpItemCount = useMemo(() => {
|
||||
return String(allHttpItems.filter((item) => !item.partial && !item.loading).length);
|
||||
}, [allHttpItems]);
|
||||
|
||||
// Map endpoint drift id (METHOD:path) → collection item uid
|
||||
const endpointUidMap = useMemo(() => {
|
||||
const normalize = (url) => (url || '')
|
||||
.replace(/\{\{[^}]+\}\}/g, '')
|
||||
.replace(/^https?:\/\/[^/]+/, '')
|
||||
.replace(/\?.*$/, '')
|
||||
.replace(/{([^}]+)}/g, ':$1')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/\/$/, '');
|
||||
const map = {};
|
||||
allHttpItems.forEach((item) => {
|
||||
if (item.request?.method && item.request?.url) {
|
||||
const key = `${item.request.method.toUpperCase()}:${normalize(item.request.url)}`;
|
||||
map[key] = item.uid;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [allHttpItems]);
|
||||
|
||||
// Open an endpoint in a tab (focus existing or add new), same as sidebar click
|
||||
const openEndpointInTab = (endpointId) => {
|
||||
const itemUid = endpointUidMap[endpointId];
|
||||
if (!itemUid) return;
|
||||
const existingTab = tabs.find((t) => t.uid === itemUid);
|
||||
if (existingTab) {
|
||||
dispatch(focusTab({ uid: itemUid }));
|
||||
} else {
|
||||
const item = allHttpItems.find((i) => i.uid === itemUid);
|
||||
dispatch(addTab({
|
||||
uid: itemUid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
|
||||
type: 'request'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const prevItemCountRef = useRef(httpItemCount);
|
||||
const isDriftLoadingRef = useRef(false);
|
||||
const specDriftRef = useRef(specDrift);
|
||||
|
||||
const loadCollectionDrift = async ({ clear = false } = {}) => {
|
||||
if (isDriftLoadingRef.current && !clear) return;
|
||||
isDriftLoadingRef.current = true;
|
||||
if (clear) setCollectionDrift(null);
|
||||
setIsDriftLoading(true);
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname
|
||||
});
|
||||
|
||||
if (!result.error) {
|
||||
setCollectionDrift(result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading collection drift:', err);
|
||||
} finally {
|
||||
isDriftLoadingRef.current = false;
|
||||
setIsDriftLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkForUpdates = async ({ sourceUrlOverride } = {}) => {
|
||||
const effectiveUrl = (sourceUrlOverride ?? sourceUrl).trim();
|
||||
if (!effectiveUrl) {
|
||||
setError('Please enter a URL or select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setFileNotFound(false);
|
||||
setSpecDrift(null);
|
||||
setRemoteDrift(null);
|
||||
setCollectionDrift(null);
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: effectiveUrl,
|
||||
environmentContext: {
|
||||
activeEnvironmentUid: collection.activeEnvironmentUid,
|
||||
environments: collection.environments,
|
||||
runtimeVariables: collection.runtimeVariables,
|
||||
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
||||
}
|
||||
});
|
||||
|
||||
if (result.errorCode === 'SOURCE_FILE_NOT_FOUND') {
|
||||
setFileNotFound(true);
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSpecDrift(result);
|
||||
updateStoredSpec(result.storedSpec || null);
|
||||
|
||||
// Update Redux store so toolbar status stays in sync
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: result.isValid !== false && result.hasChanges,
|
||||
diff: result,
|
||||
error: result.isValid === false ? result.error : null
|
||||
}));
|
||||
|
||||
// Fetch remote drift (remote spec vs collection) for collection-centric categorization
|
||||
if (result.newSpec) {
|
||||
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
compareSpec: result.newSpec
|
||||
});
|
||||
if (remoteComparison.error) {
|
||||
console.error('Error computing remote drift:', remoteComparison.error);
|
||||
setError(remoteComparison.error);
|
||||
} else {
|
||||
setRemoteDrift(remoteComparison);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh collection drift (stored spec vs collection) — skip if no stored spec
|
||||
if (!result.storedSpecMissing) {
|
||||
await loadCollectionDrift({ clear: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking for updates:', err);
|
||||
setError(formatIpcError(err) || 'Failed to check for updates');
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: false,
|
||||
diff: null,
|
||||
error: formatIpcError(err) || 'Failed to check for updates'
|
||||
}));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isConfigured) {
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [isConfigured]);
|
||||
|
||||
// Reload drift when collection items change (e.g., endpoint deleted from sidebar)
|
||||
useEffect(() => {
|
||||
if (prevItemCountRef.current !== httpItemCount && isConfigured) {
|
||||
prevItemCountRef.current = httpItemCount;
|
||||
loadCollectionDrift();
|
||||
}
|
||||
}, [httpItemCount, isConfigured]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
const trimmedUrl = sourceUrl.trim();
|
||||
if (!trimmedUrl) {
|
||||
setError('Please enter a URL or select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setFileNotFound(false);
|
||||
|
||||
try {
|
||||
// Validate it's a valid OpenAPI spec before proceeding (URL only; files are validated at picker)
|
||||
if (isHttpUrl(trimmedUrl)) {
|
||||
try {
|
||||
const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl });
|
||||
if (specType !== 'openapi') {
|
||||
setError('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
// Validate the spec first
|
||||
const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: trimmedUrl,
|
||||
environmentContext: {
|
||||
activeEnvironmentUid: collection.activeEnvironmentUid,
|
||||
environments: collection.environments,
|
||||
runtimeVariables: collection.runtimeVariables,
|
||||
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
||||
}
|
||||
});
|
||||
|
||||
if (result.isValid === false) {
|
||||
setSpecDrift(result);
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save sync config (no spec file yet — deferred to first sync unless collection already matches)
|
||||
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
config: {
|
||||
sourceUrl: trimmedUrl,
|
||||
groupBy: 'tags',
|
||||
autoCheck: true,
|
||||
autoCheckInterval: 5
|
||||
}
|
||||
});
|
||||
|
||||
// Check if collection already matches the spec
|
||||
if (result.newSpec) {
|
||||
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
compareSpec: result.newSpec
|
||||
});
|
||||
|
||||
const isInSync = !drift.error
|
||||
&& (!drift.missing || drift.missing.length === 0)
|
||||
&& (!drift.modified || drift.modified.length === 0)
|
||||
&& (!drift.localOnly || drift.localOnly.length === 0);
|
||||
|
||||
if (isInSync) {
|
||||
// Collection matches — save spec file silently to complete setup
|
||||
await ipcRenderer.invoke('renderer:save-openapi-spec', {
|
||||
collectionPath: collection.pathname,
|
||||
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('OpenAPI sync connected');
|
||||
} catch (err) {
|
||||
console.error('Error connecting OpenAPI sync:', err);
|
||||
setError(formatIpcError(err) || 'Failed to connect');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
await ipcRenderer.invoke('renderer:remove-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
deleteSpecFile: true
|
||||
});
|
||||
setSourceUrl('');
|
||||
setSpecDrift(null);
|
||||
setCollectionDrift(null);
|
||||
setRemoteDrift(null);
|
||||
setStoredSpec(null);
|
||||
|
||||
// Clear Redux state for this collection
|
||||
dispatch(clearCollectionState({ collectionUid: collection.uid }));
|
||||
|
||||
// Close the openapi-spec tab if open (spec file no longer exists)
|
||||
const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec');
|
||||
if (specTab) {
|
||||
dispatch(closeTabs({ tabUids: [specTab.uid] }));
|
||||
}
|
||||
|
||||
toast.success('OpenAPI sync disconnected');
|
||||
} catch (err) {
|
||||
console.error('Error disconnecting sync:', err);
|
||||
toast.error('Failed to disconnect sync');
|
||||
}
|
||||
};
|
||||
|
||||
// Keep ref in sync so reloadDrift always reads the latest specDrift
|
||||
specDriftRef.current = specDrift;
|
||||
|
||||
// Reload both drifts — passed to useEndpointActions so it can refresh after actions.
|
||||
// Uses specDriftRef to avoid stale closure over specDrift state.
|
||||
const reloadDrift = async () => {
|
||||
await loadCollectionDrift({ clear: true });
|
||||
// Refresh remoteDrift if we have a remote spec cached from the last check
|
||||
const currentSpecDrift = specDriftRef.current;
|
||||
if (currentSpecDrift?.newSpec) {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
compareSpec: currentSpecDrift.newSpec
|
||||
});
|
||||
if (!remoteComparison.error) {
|
||||
setRemoteDrift(remoteComparison);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reloading remote drift:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save connection settings from the modal
|
||||
const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => {
|
||||
const sourceUrlChanged = newUrl !== openApiSyncConfig?.sourceUrl;
|
||||
|
||||
// Validate the spec before saving if source URL changed (URL only; files are validated at picker)
|
||||
// Kept outside try-catch so validation errors propagate to the caller and the modal stays open
|
||||
if (sourceUrlChanged && isHttpUrl(newUrl)) {
|
||||
let specType;
|
||||
try {
|
||||
({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl }));
|
||||
} catch {
|
||||
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
throw new Error('Invalid OpenAPI specification');
|
||||
}
|
||||
if (specType !== 'openapi') {
|
||||
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
throw new Error('Invalid OpenAPI specification');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
config: {
|
||||
sourceUrl: newUrl,
|
||||
autoCheck,
|
||||
autoCheckInterval
|
||||
}
|
||||
});
|
||||
setSourceUrl(newUrl);
|
||||
setFileNotFound(false);
|
||||
toast.success('Settings saved');
|
||||
// Re-check with new settings — pass newUrl directly to avoid stale closure
|
||||
await checkForUpdates({ sourceUrlOverride: newUrl });
|
||||
} catch (err) {
|
||||
console.error('Error saving settings:', err);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
sourceUrl, setSourceUrl,
|
||||
isLoading,
|
||||
error, setError,
|
||||
fileNotFound,
|
||||
specDrift,
|
||||
collectionDrift,
|
||||
remoteDrift,
|
||||
isDriftLoading,
|
||||
storedSpec,
|
||||
|
||||
// Handlers
|
||||
checkForUpdates,
|
||||
handleConnect,
|
||||
handleDisconnect,
|
||||
handleSaveSettings,
|
||||
openEndpointInTab,
|
||||
reloadDrift
|
||||
};
|
||||
};
|
||||
|
||||
export default useOpenAPISync;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { clearCollectionUpdate } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
const useSyncFlow = ({
|
||||
collection, specDrift, remoteDrift, collectionDrift,
|
||||
setError, checkForUpdates
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [pendingSyncMode, setPendingSyncMode] = useState(null);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
setShowConfirmModal(false);
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
|
||||
const {
|
||||
localOnlyIds = [], endpointDecisions: decisions = {},
|
||||
newToCollection, specUpdates, resolvedConflicts, localChangesToReset
|
||||
} = selections;
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
let filteredDiff;
|
||||
let localOnlyToRemove;
|
||||
let driftedToReset;
|
||||
|
||||
if (newToCollection) {
|
||||
// Called from SyncReviewPage with categorized remoteDrift data
|
||||
filteredDiff = {
|
||||
...specDrift,
|
||||
added: newToCollection,
|
||||
modified: [...(specUpdates || []), ...(resolvedConflicts || [])],
|
||||
removed: [] // Removals handled via localOnlyToRemove
|
||||
};
|
||||
|
||||
localOnlyToRemove = localOnlyIds.length > 0
|
||||
? (remoteDrift?.localOnly || []).filter((ep) => localOnlyIds.includes(ep.id))
|
||||
: [];
|
||||
|
||||
driftedToReset = localChangesToReset || [];
|
||||
} else {
|
||||
// Called from "Sync Now" (skip review) or ConfirmSyncModal — use specDrift directly
|
||||
filteredDiff = {
|
||||
...specDrift,
|
||||
removed: [] // Removals handled via localOnlyToRemove
|
||||
};
|
||||
|
||||
localOnlyToRemove = localOnlyIds.length > 0
|
||||
? (remoteDrift?.localOnly || collectionDrift?.localOnly || []).filter((ep) => localOnlyIds.includes(ep.id))
|
||||
: [];
|
||||
|
||||
driftedToReset = collectionDrift?.modified?.filter((ep) => {
|
||||
const decision = decisions[ep.id];
|
||||
return decision === 'accept-incoming';
|
||||
}) || [];
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke('renderer:apply-openapi-sync', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
addNewRequests: mode !== 'spec-only',
|
||||
removeDeletedRequests: localOnlyIds.length > 0,
|
||||
diff: filteredDiff,
|
||||
localOnlyToRemove,
|
||||
driftedToReset,
|
||||
mode,
|
||||
endpointDecisions: decisions
|
||||
});
|
||||
|
||||
setPendingSyncMode(null);
|
||||
|
||||
dispatch(clearCollectionUpdate({ collectionUid: collection.uid }));
|
||||
toast.success(
|
||||
mode === 'spec-only' ? 'Spec updated successfully'
|
||||
: mode === 'reset' ? 'Collection reset to spec successfully'
|
||||
: 'Collection synced successfully'
|
||||
);
|
||||
|
||||
// Re-check to show "up to date" state
|
||||
await checkForUpdates();
|
||||
} catch (err) {
|
||||
console.error('Error syncing collection:', err);
|
||||
setError(formatIpcError(err) || 'Failed to sync collection');
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncNow = () => {
|
||||
if (!remoteDrift) return;
|
||||
setPendingSyncMode('sync');
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const handleApplySync = (selections) => {
|
||||
const mode = pendingSyncMode || 'sync';
|
||||
setPendingSyncMode(null);
|
||||
performSync(selections, mode);
|
||||
};
|
||||
|
||||
const cancelConfirmModal = () => {
|
||||
setShowConfirmModal(false);
|
||||
setPendingSyncMode(null);
|
||||
};
|
||||
|
||||
// Only treat endpoints as spec changes if they actually changed in the spec
|
||||
// (not locally-added/deleted endpoints that were never in or removed from the spec)
|
||||
const specAddedIds = useMemo(() => {
|
||||
return new Set((specDrift?.added || []).map((ep) => ep.id));
|
||||
}, [specDrift]);
|
||||
|
||||
const specRemovedIds = useMemo(() => {
|
||||
return new Set((specDrift?.removed || []).map((ep) => ep.id));
|
||||
}, [specDrift]);
|
||||
|
||||
const handleRestoreSpec = () => {
|
||||
const localOnlyIds = (remoteDrift?.localOnly || [])
|
||||
.filter((ep) => specRemovedIds.has(ep.id))
|
||||
.map((ep) => ep.id);
|
||||
performSync({ localOnlyIds, endpointDecisions: {} }, 'sync');
|
||||
};
|
||||
|
||||
const handleConfirmModalSync = () => {
|
||||
const localOnlyIds = (remoteDrift?.localOnly || [])
|
||||
.filter((ep) => specRemovedIds.has(ep.id))
|
||||
.map((ep) => ep.id);
|
||||
performSync({
|
||||
localOnlyIds,
|
||||
endpointDecisions: {}
|
||||
}, pendingSyncMode || 'sync');
|
||||
};
|
||||
|
||||
const confirmGroups = useMemo(() => {
|
||||
if (!remoteDrift) return [];
|
||||
const groups = [];
|
||||
const actuallyAdded = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
|
||||
if (actuallyAdded.length > 0) {
|
||||
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: actuallyAdded });
|
||||
}
|
||||
if (remoteDrift.modified?.length > 0) {
|
||||
groups.push({ label: 'Endpoints to update', type: 'update', endpoints: remoteDrift.modified });
|
||||
}
|
||||
const actuallyRemoved = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
|
||||
if (actuallyRemoved.length > 0) {
|
||||
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: actuallyRemoved });
|
||||
}
|
||||
return groups;
|
||||
}, [remoteDrift, specAddedIds, specRemovedIds]);
|
||||
|
||||
return {
|
||||
isSyncing, showConfirmModal, confirmGroups,
|
||||
handleSyncNow, handleRestoreSpec,
|
||||
handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
};
|
||||
};
|
||||
|
||||
export default useSyncFlow;
|
||||
213
packages/bruno-app/src/components/OpenAPISyncTab/index.js
Normal file
213
packages/bruno-app/src/components/OpenAPISyncTab/index.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { setTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import OpenAPISyncHeader from './OpenAPISyncHeader';
|
||||
import ConnectSpecForm from './ConnectSpecForm';
|
||||
import SpecStatusSection from './SpecStatusSection';
|
||||
import CollectionStatusSection from './CollectionStatusSection';
|
||||
import ConnectionSettingsModal from './ConnectionSettingsModal';
|
||||
import DisconnectSyncModal from './DisconnectSyncModal';
|
||||
import OverviewSection from './OverviewSection';
|
||||
import useOpenAPISync from './hooks/useOpenAPISync';
|
||||
|
||||
const OpenAPISyncTab = ({ collection }) => {
|
||||
const {
|
||||
sourceUrl, setSourceUrl,
|
||||
isLoading,
|
||||
error, setError,
|
||||
fileNotFound,
|
||||
specDrift,
|
||||
collectionDrift,
|
||||
remoteDrift,
|
||||
isDriftLoading,
|
||||
storedSpec,
|
||||
checkForUpdates,
|
||||
handleConnect,
|
||||
handleDisconnect,
|
||||
handleSaveSettings,
|
||||
openEndpointInTab,
|
||||
reloadDrift
|
||||
} = useOpenAPISync(collection);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const isConfigured = !!openApiSyncConfig?.sourceUrl;
|
||||
|
||||
const handleViewSpec = () => {
|
||||
dispatch(addTab({
|
||||
uid: uuid(),
|
||||
collectionUid: collection.uid,
|
||||
type: 'openapi-spec'
|
||||
}));
|
||||
};
|
||||
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [showDisconnectModal, setShowDisconnectModal] = useState(false);
|
||||
const activeTab = useSelector((state) => state.openapiSync?.tabUiState?.[collection.uid]?.activeTab) || 'overview';
|
||||
const setActiveTab = useCallback((tab) => {
|
||||
dispatch(setTabUiState({ collectionUid: collection.uid, activeTab: tab }));
|
||||
}, [dispatch, collection.uid]);
|
||||
|
||||
const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec;
|
||||
const collectionChangesCount = hasDriftData
|
||||
? (collectionDrift.modified?.length || 0) + (collectionDrift.missing?.length || 0) + (collectionDrift.localOnly?.length || 0)
|
||||
: 0;
|
||||
const specUpdatesCount = hasDriftData
|
||||
? (specDrift?.added?.length || 0) + (specDrift?.modified?.length || 0) + (specDrift?.removed?.length || 0)
|
||||
: (remoteDrift?.modified?.length || 0) + (remoteDrift?.missing?.length || 0);
|
||||
|
||||
const syncStatus = (() => {
|
||||
if (isLoading) return 'loading';
|
||||
if (error) return 'not-in-sync';
|
||||
if (!hasDriftData) return null;
|
||||
if (collectionChangesCount > 0 || specUpdatesCount > 0) return 'not-in-sync';
|
||||
return 'in-sync';
|
||||
})();
|
||||
|
||||
const syncTabs = useMemo(() => [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{
|
||||
key: 'collection-changes',
|
||||
label: 'Collection Changes',
|
||||
indicator: collectionChangesCount > 0 ? <span className="tab-count">{collectionChangesCount}</span> : null
|
||||
},
|
||||
{
|
||||
key: 'spec-updates',
|
||||
label: 'Spec Updates',
|
||||
indicator: specUpdatesCount > 0 ? <span className="tab-count">{specUpdatesCount}</span> : null
|
||||
}
|
||||
], [collectionChangesCount, specUpdatesCount]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative px-4 pt-4 overflow-auto">
|
||||
<div className="sync-page w-full">
|
||||
|
||||
{/* Setup form when not configured */}
|
||||
{!isConfigured && (
|
||||
<ConnectSpecForm
|
||||
sourceUrl={sourceUrl}
|
||||
setSourceUrl={setSourceUrl}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
setError={setError}
|
||||
onConnect={handleConnect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Configured: spec header + tabs */}
|
||||
{isConfigured && (
|
||||
<>
|
||||
<OpenAPISyncHeader
|
||||
collection={collection}
|
||||
spec={storedSpec || specDrift?.newSpec}
|
||||
sourceUrl={sourceUrl}
|
||||
syncStatus={syncStatus}
|
||||
onViewSpec={handleViewSpec}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
onOpenDisconnect={() => setShowDisconnectModal(true)}
|
||||
onCheck={checkForUpdates}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<ResponsiveTabs
|
||||
tabs={syncTabs}
|
||||
activeTab={activeTab}
|
||||
onTabSelect={setActiveTab}
|
||||
/>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="sync-tab-content">
|
||||
<OverviewSection
|
||||
collection={collection}
|
||||
storedSpec={storedSpec}
|
||||
collectionDrift={collectionDrift}
|
||||
specDrift={specDrift}
|
||||
remoteDrift={remoteDrift}
|
||||
onTabSelect={setActiveTab}
|
||||
error={error}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
/>
|
||||
<p className="beta-feedback-inline">
|
||||
OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="beta-feedback-link"
|
||||
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'collection-changes' && (
|
||||
<div className="sync-tab-content">
|
||||
|
||||
<CollectionStatusSection
|
||||
collection={collection}
|
||||
collectionDrift={collectionDrift}
|
||||
reloadDrift={reloadDrift}
|
||||
specDrift={specDrift}
|
||||
storedSpec={storedSpec}
|
||||
lastSyncDate={openApiSyncConfig?.lastSyncDate}
|
||||
onOpenEndpoint={openEndpointInTab}
|
||||
isLoading={isDriftLoading || isLoading}
|
||||
onTabSelect={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'spec-updates' && (
|
||||
<div className="sync-tab-content">
|
||||
<SpecStatusSection
|
||||
collection={collection}
|
||||
sourceUrl={sourceUrl}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
setError={setError}
|
||||
fileNotFound={fileNotFound}
|
||||
specDrift={specDrift}
|
||||
storedSpec={storedSpec}
|
||||
collectionDrift={collectionDrift}
|
||||
remoteDrift={remoteDrift}
|
||||
onCheck={checkForUpdates}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{showSettingsModal && (
|
||||
<ConnectionSettingsModal
|
||||
collection={collection}
|
||||
sourceUrl={sourceUrl}
|
||||
onSave={handleSaveSettings}
|
||||
onDisconnect={() => {
|
||||
setShowSettingsModal(false);
|
||||
setShowDisconnectModal(true);
|
||||
}}
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDisconnectModal && (
|
||||
<DisconnectSyncModal
|
||||
onConfirm={() => {
|
||||
setShowDisconnectModal(false);
|
||||
handleDisconnect();
|
||||
}}
|
||||
onClose={() => setShowDisconnectModal(false)}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAPISyncTab;
|
||||
16
packages/bruno-app/src/components/OpenAPISyncTab/utils.js
Normal file
16
packages/bruno-app/src/components/OpenAPISyncTab/utils.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
|
||||
|
||||
/**
|
||||
* Count the number of HTTP endpoints in an OpenAPI spec.
|
||||
* Returns null if the spec has no paths (e.g. spec is null/undefined).
|
||||
*/
|
||||
export const countEndpoints = (spec) => {
|
||||
if (!spec?.paths) return null;
|
||||
let count = 0;
|
||||
for (const path of Object.values(spec.paths)) {
|
||||
for (const key of Object.keys(path)) {
|
||||
if (HTTP_METHODS.includes(key.toLowerCase())) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
@@ -8,17 +8,19 @@ import debounce from 'lodash/debounce';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconFlask } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
|
||||
|
||||
/**
|
||||
* Add beta features here.
|
||||
* Example:
|
||||
* {
|
||||
* id: 'nodevm',
|
||||
* label: 'Node VM Runtime',
|
||||
* description: 'Enable Node VM runtime for JavaScript execution in Developer Mode'
|
||||
* }
|
||||
* UI metadata for beta features rendered in Preferences.
|
||||
* IDs must match keys from utils/beta-features.js BETA_FEATURES.
|
||||
*/
|
||||
const BETA_FEATURES = [];
|
||||
const BETA_FEATURES = [
|
||||
{
|
||||
id: BETA_FEATURE_IDS.OPENAPI_SYNC,
|
||||
label: 'OpenAPI Sync',
|
||||
description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
|
||||
}
|
||||
];
|
||||
|
||||
const Beta = ({ close }) => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
@@ -45,6 +47,7 @@ const Beta = ({ close }) => {
|
||||
const betaSchema = generateValidationSchema();
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: generateInitialValues(),
|
||||
validationSchema: betaSchema,
|
||||
onSubmit: async (values) => {
|
||||
@@ -61,22 +64,28 @@ const Beta = ({ close }) => {
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
beta: newBetaPreferences
|
||||
beta: {
|
||||
...preferences.beta,
|
||||
...newBetaPreferences
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
betaSchema.validate(values, { abortEarly: true })
|
||||
.then((validatedValues) => {
|
||||
handleSave(validatedValues);
|
||||
handleSaveRef.current(validatedValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
});
|
||||
}, 500),
|
||||
[handleSave, betaSchema]
|
||||
[betaSchema]
|
||||
);
|
||||
|
||||
// Auto-save when form values change
|
||||
@@ -85,7 +94,7 @@ const Beta = ({ close }) => {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
form.bruno-form {
|
||||
label {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
122
packages/bruno-app/src/components/Preferences/Cache/index.js
Normal file
122
packages/bruno-app/src/components/Preferences/Cache/index.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
savePreferences,
|
||||
clearHttpHttpsAgentCache
|
||||
} from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const cacheSchema = Yup.object().shape({
|
||||
sslSession: Yup.object({
|
||||
enabled: Yup.boolean()
|
||||
})
|
||||
});
|
||||
|
||||
const Cache = () => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newCachePreferences) => {
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
cache: newCachePreferences
|
||||
})
|
||||
).catch(() => toast.error('Failed to update cache preferences'));
|
||||
},
|
||||
[dispatch, preferences]
|
||||
);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
sslSession: {
|
||||
enabled: get(preferences, 'cache.sslSession.enabled', false)
|
||||
}
|
||||
},
|
||||
validationSchema: cacheSchema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
const newPreferences = await cacheSchema.validate(values, { abortEarly: true });
|
||||
handleSave(newPreferences);
|
||||
} catch (error) {
|
||||
console.error('Cache preferences validation error:', error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
cacheSchema
|
||||
.validate(values, { abortEarly: true })
|
||||
.then((validatedValues) => handleSaveRef.current(validatedValues))
|
||||
.catch(() => {});
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
const handleAgentCachingChange = (e) => {
|
||||
formik.handleChange(e);
|
||||
// Immediately evict all cached agents when caching is disabled
|
||||
if (!e.target.checked) {
|
||||
dispatch(clearHttpHttpsAgentCache()).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetCache = () => {
|
||||
dispatch(clearHttpHttpsAgentCache())
|
||||
.then(() => toast.success('ssl session cache cleared'))
|
||||
.catch(() => toast.error('Failed to clear ssl session cache'));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="section-title mt-6 mb-3">Cache SSL Session</div>
|
||||
|
||||
<div className="flex items-center my-2">
|
||||
<input
|
||||
id="sslSession.enabled"
|
||||
type="checkbox"
|
||||
name="sslSession.enabled"
|
||||
checked={formik.values.sslSession.enabled}
|
||||
onChange={handleAgentCachingChange}
|
||||
className="mousetrap mr-0"
|
||||
/>
|
||||
<label className="block ml-2 select-none" htmlFor="sslSession.enabled">
|
||||
Enable SSL session caching
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-xs mt-1 ml-6 opacity-70">
|
||||
Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every
|
||||
request.
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button type="button" className="text-link cursor-pointer hover:underline" onClick={handleResetCache}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cache;
|
||||
@@ -38,11 +38,14 @@ const Font = () => {
|
||||
});
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((font, fontSize) => {
|
||||
handleSave(font, fontSize);
|
||||
handleSaveRef.current(font, fontSize);
|
||||
}, 500),
|
||||
[handleSave]
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,7 +55,7 @@ const Font = () => {
|
||||
}
|
||||
debouncedSave(codeFont, codeFontSize);
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [codeFont, codeFontSize, debouncedSave]);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.zoom-field {
|
||||
width: 120px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -16,7 +15,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
width: 80px;
|
||||
width: fit-content;
|
||||
height: 35.89px;
|
||||
padding: 0 0.5rem;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -3,7 +3,9 @@ import get from 'lodash/get';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconReload } from '@tabler/icons';
|
||||
import { IconChevronDown, IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button/index';
|
||||
const { percentageToZoomLevel } = require('@usebruno/common');
|
||||
|
||||
// Zoom options for dropdown (50% to 150%)
|
||||
@@ -85,10 +87,12 @@ const Zoom = () => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex flex-row gap-1 items-end">
|
||||
<div>
|
||||
<label className="block">Interface Zoom</label>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1 items-center mt-2">
|
||||
<div className="zoom-field" ref={dropdownRef}>
|
||||
<label className="block">Interface Zoom</label>
|
||||
<div className="custom-select mt-2" onClick={() => setIsOpen(!isOpen)}>
|
||||
<div className="custom-select" onClick={() => setIsOpen(!isOpen)}>
|
||||
<span className="selected-value">{selectedOption?.label}</span>
|
||||
<IconChevronDown size={14} className="chevron-icon" />
|
||||
</div>
|
||||
@@ -108,13 +112,13 @@ const Zoom = () => {
|
||||
)}
|
||||
</div>
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-btn"
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<IconReload />}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onClick={handleResetToDefault}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -127,16 +127,19 @@ const General = () => {
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
preferencesSchema.validate(values, { abortEarly: true })
|
||||
.then((validatedValues) => {
|
||||
handleSave(validatedValues);
|
||||
handleSaveRef.current(validatedValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
});
|
||||
}, 500),
|
||||
[handleSave]
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -144,7 +147,7 @@ const General = () => {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
|
||||
@@ -1,198 +1,53 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
table {
|
||||
width: 80%;
|
||||
border-collapse: collapse;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reset-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
border-radius: 6px;
|
||||
padding: 4px 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.button.secondary.hoverBg};
|
||||
border-color: ${(props) => props.theme.button.secondary.hoverBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.keybinding-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.keybinding-row:hover .edit-btn {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.shortcut-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 260px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shortcut-input {
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
flex-shrink: 0;
|
||||
|
||||
caret-color: ${(props) => props.theme.table.input.color};
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
font-family: monospace;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
thead th {
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
border-radius: 8px;
|
||||
padding: 0px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-input--error {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.kb-tooltip {
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
max-width: 320px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.kb-tooltip--error {
|
||||
color: ${(props) => props.theme.colors?.text?.red || '#ef4444'};
|
||||
}
|
||||
|
||||
.table-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 650px;
|
||||
overflow-y: auto;
|
||||
|
||||
border-radius: 8px;
|
||||
border-top: 1px solid ${(props) => props.theme.table.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
thead th:first-child,
|
||||
tbody td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
thead th:last-child,
|
||||
tbody td:last-child {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
|
||||
background: ${(props) => props.theme.background};
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
|
||||
border-left: 1px solid ${(props) => props.theme.table.border};
|
||||
border-right: 1px solid ${(props) => props.theme.table.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
box-shadow: 0 1px 0 ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-top: 1px solid ${(props) => props.theme.table.border};
|
||||
border-left: 1px solid ${(props) => props.theme.table.border};
|
||||
border-right: 1px solid ${(props) => props.theme.table.border};
|
||||
.key-button {
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
opacity: 0.7;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: monospace;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-bottom: 1.44px solid ${(props) => props.theme.table.input.border};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,524 +1,14 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconRefresh, IconPencil } from '@tabler/icons';
|
||||
import React from 'react';
|
||||
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { DEFAULT_KEY_BINDINGS } from 'providers/Hotkeys/keyMappings.js';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
|
||||
const SEP = '+bind+';
|
||||
const getOS = () => (isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
// Stored tokens must match your preferences defaults (lowercase)
|
||||
const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']);
|
||||
|
||||
const REQUIRED_MODIFIERS_BY_OS = {
|
||||
mac: new Set(['command', 'alt', 'shift', 'ctrl']),
|
||||
windows: new Set(['ctrl', 'alt', 'shift']) // command (Win key) should NOT count
|
||||
};
|
||||
|
||||
const hasRequiredModifier = (os, arr) => arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k));
|
||||
const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k));
|
||||
|
||||
const sortCombo = (arr) => {
|
||||
const order = ['ctrl', 'command', 'alt', 'shift'];
|
||||
const modifiers = [];
|
||||
const nonModifiers = [];
|
||||
|
||||
// Separate modifiers from non-modifiers
|
||||
arr.forEach((key) => {
|
||||
if (order.includes(key)) {
|
||||
modifiers.push(key);
|
||||
} else {
|
||||
nonModifiers.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort modifiers by their order
|
||||
modifiers.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
|
||||
// Keep non-modifiers in the order they were pressed (don't sort them)
|
||||
return [...modifiers, ...nonModifiers];
|
||||
};
|
||||
|
||||
const uniqSorted = (arr) => {
|
||||
// Remove duplicates while preserving order
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
arr.forEach((key) => {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(key);
|
||||
}
|
||||
});
|
||||
return sortCombo(unique);
|
||||
};
|
||||
|
||||
const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []);
|
||||
const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP);
|
||||
|
||||
// Signature MUST be stable: unique + sorted
|
||||
const comboSignature = (arr) => toKeysString(arr);
|
||||
|
||||
// OS reserved shortcuts in stored-token format
|
||||
const RESERVED_BY_OS = {
|
||||
mac: new Set([
|
||||
comboSignature(['command', 'q']),
|
||||
comboSignature(['command', 'w']),
|
||||
comboSignature(['command', 'h']),
|
||||
comboSignature(['command', 'm']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['command', 'space']),
|
||||
comboSignature(['ctrl', 'command', 'q']),
|
||||
comboSignature(['command', ',']),
|
||||
comboSignature(['command', 'shift', '3']),
|
||||
comboSignature(['command', 'shift', '4']),
|
||||
comboSignature(['command', 'shift', '5']),
|
||||
comboSignature(['command', 'alt', 'esc'])
|
||||
]),
|
||||
windows: new Set([
|
||||
comboSignature(['alt', 'tab']),
|
||||
comboSignature(['alt', 'f4']),
|
||||
comboSignature(['ctrl', 'alt', 'delete']),
|
||||
comboSignature(['command', 'l']),
|
||||
comboSignature(['command', 'd']),
|
||||
comboSignature(['command', 'e']),
|
||||
comboSignature(['command', 'r']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['ctrl', 'shift', 'esc'])
|
||||
])
|
||||
};
|
||||
|
||||
// normalize keyboard event -> stored tokens
|
||||
const normalizeKey = (e) => {
|
||||
const k = e.key;
|
||||
|
||||
// ignore lock keys
|
||||
if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null;
|
||||
|
||||
if (k === ' ') return 'space';
|
||||
if (k === 'Escape') return 'esc';
|
||||
if (k === 'Control') return 'ctrl';
|
||||
if (k === 'Alt') return 'alt';
|
||||
if (k === 'Shift') return 'shift';
|
||||
if (k === 'Enter') return 'enter';
|
||||
if (k === 'Backspace') return 'backspace';
|
||||
if (k === 'Tab') return 'tab';
|
||||
if (k === 'Delete') return 'delete';
|
||||
|
||||
// Meta -> command (matches your stored default format)
|
||||
if (k === 'Meta') return 'command';
|
||||
|
||||
// single char (letters/punct) to lowercase
|
||||
if (k.length === 1) return k.toLowerCase();
|
||||
|
||||
// ArrowUp -> arrowup, PageUp -> pageup, etc
|
||||
return k.toLowerCase();
|
||||
};
|
||||
|
||||
const ERROR = {
|
||||
EMPTY: 'EMPTY',
|
||||
ONLY_MODIFIERS: 'ONLY_MODIFIERS',
|
||||
MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD',
|
||||
RESERVED: 'RESERVED',
|
||||
DUPLICATE: 'DUPLICATE',
|
||||
CONFLICT: 'CONFLICT'
|
||||
};
|
||||
|
||||
const Keybindings = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const os = getOS();
|
||||
|
||||
// Source of truth: merge defaults with user preferences
|
||||
const keyBindings = useMemo(() => {
|
||||
const merged = {};
|
||||
|
||||
// Start with defaults
|
||||
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
|
||||
// Override with user preferences
|
||||
const userBindings = preferences?.keyBindings || {};
|
||||
for (const [action, binding] of Object.entries(userBindings)) {
|
||||
if (merged[action]) {
|
||||
// Merge user's OS-specific overrides into defaults
|
||||
merged[action] = {
|
||||
...merged[action],
|
||||
...binding
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [preferences?.keyBindings]);
|
||||
|
||||
// Build table data (action -> { name, keys })
|
||||
const keyMapping = useMemo(() => {
|
||||
const out = {};
|
||||
for (const [action, binding] of Object.entries(keyBindings)) {
|
||||
if (binding?.[os]) out[action] = { name: binding.name, keys: binding[os] };
|
||||
}
|
||||
return out;
|
||||
}, [keyBindings, os]);
|
||||
|
||||
// ✏️ which row is allowed to edit (pencil clicked)
|
||||
const [editingAction, setEditingAction] = useState(null);
|
||||
|
||||
// hover tracking (for showing pencil/refresh only on hover row)
|
||||
const [hoveredAction, setHoveredAction] = useState(null);
|
||||
|
||||
// Recording state
|
||||
const [recordingAction, setRecordingAction] = useState(null);
|
||||
const pressedKeysRef = useRef(new Set());
|
||||
const inputRefs = useRef({});
|
||||
const [draftByAction, setDraftByAction] = useState({}); // action -> string[]
|
||||
const [errorByAction, setErrorByAction] = useState({}); // action -> { code, message }
|
||||
|
||||
const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || '';
|
||||
const getDefaultRowKeysString = (action) => DEFAULT_KEY_BINDINGS?.[action]?.[os] || '';
|
||||
|
||||
const isRowDirty = (action) => {
|
||||
const current = getCurrentRowKeysString(action);
|
||||
const def = getDefaultRowKeysString(action);
|
||||
if (!DEFAULT_KEY_BINDINGS) return false;
|
||||
return current !== def;
|
||||
};
|
||||
|
||||
// Check if any keybinding is dirty (different from default)
|
||||
const hasDirtyRows = useMemo(() => {
|
||||
for (const action of Object.keys(DEFAULT_KEY_BINDINGS)) {
|
||||
if (isRowDirty(action)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [keyBindings, os]);
|
||||
|
||||
const buildUsedSignatures = (excludeAction) => {
|
||||
const used = new Set();
|
||||
for (const [action, binding] of Object.entries(keyBindings)) {
|
||||
if (action === excludeAction) continue;
|
||||
const keysStr = binding?.[os];
|
||||
if (!keysStr) continue;
|
||||
used.add(comboSignature(fromKeysString(keysStr)));
|
||||
}
|
||||
return used;
|
||||
};
|
||||
|
||||
const validateCombo = (action, arrRaw) => {
|
||||
const arr = uniqSorted(arrRaw);
|
||||
const sig = comboSignature(arr);
|
||||
|
||||
if (!sig) return { code: ERROR.EMPTY, message: `Shortcut can’t be empty.` };
|
||||
if (isOnlyModifiers(arr))
|
||||
return { code: ERROR.ONLY_MODIFIERS, message: 'Add a non-modifier key (e.g. Ctrl + K).' };
|
||||
|
||||
// OS-specific must-have modifier rule
|
||||
if (!hasRequiredModifier(os, arr)) {
|
||||
return {
|
||||
code: ERROR.MISSING_REQUIRED_MOD,
|
||||
message:
|
||||
os === 'mac'
|
||||
? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).'
|
||||
: 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).'
|
||||
};
|
||||
}
|
||||
|
||||
// OS reserved
|
||||
if (RESERVED_BY_OS[os]?.has(sig))
|
||||
return { code: ERROR.RESERVED, message: 'This shortcut is reserved by the OS.' };
|
||||
|
||||
// No duplicates (across all other actions)
|
||||
if (buildUsedSignatures(action).has(sig))
|
||||
return { code: ERROR.DUPLICATE, message: 'That shortcut is already in use.' };
|
||||
|
||||
// Check for subset conflicts (e.g., Cmd+A conflicts with Cmd+Z+A)
|
||||
for (const [otherAction, binding] of Object.entries(keyBindings)) {
|
||||
if (otherAction === action) continue;
|
||||
const otherKeysStr = binding?.[os];
|
||||
if (!otherKeysStr) continue;
|
||||
|
||||
const otherKeys = fromKeysString(otherKeysStr);
|
||||
|
||||
// Check if current is a subset of other (current is shorter)
|
||||
if (arr.length < otherKeys.length) {
|
||||
const isSubset = arr.every((k) => otherKeys.includes(k));
|
||||
if (isSubset) {
|
||||
return {
|
||||
code: ERROR.CONFLICT,
|
||||
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove the longer shortcut first.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if other is a subset of current (current is longer)
|
||||
if (arr.length > otherKeys.length) {
|
||||
const isSubset = otherKeys.every((k) => arr.includes(k));
|
||||
if (isSubset) {
|
||||
return {
|
||||
code: ERROR.CONFLICT,
|
||||
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove that shortcut first.`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const persistToPreferences = (action, nextKeys) => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {
|
||||
...(preferences?.keyBindings || {}),
|
||||
[action]: {
|
||||
...(preferences?.keyBindings?.[action] || {}),
|
||||
name: preferences?.keyBindings?.[action]?.name || action,
|
||||
[os]: nextKeys
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Commit only if valid. Returns true if commit succeeded (or no-op), false if invalid.
|
||||
const commitCombo = (action) => {
|
||||
const draftArr = draftByAction[action] || [];
|
||||
if (!draftArr.length) return;
|
||||
|
||||
const arr = uniqSorted(draftArr);
|
||||
const err = validateCombo(action, arr);
|
||||
|
||||
if (err) {
|
||||
setErrorByAction((prev) => ({ ...prev, [action]: err }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
const nextKeys = toKeysString(arr);
|
||||
const currentKeys = getCurrentRowKeysString(action);
|
||||
if (nextKeys === currentKeys) return true;
|
||||
|
||||
persistToPreferences(action, nextKeys);
|
||||
// toast success for 2s with Command name
|
||||
const commandName = keyBindings?.[action]?.name || action;
|
||||
toast.success(`"${commandName}" shortcut updated`, { autoClose: 2000 });
|
||||
return true;
|
||||
};
|
||||
|
||||
const resetRowToDefault = (action) => {
|
||||
const def = DEFAULT_KEY_BINDINGS?.[action]?.[os];
|
||||
if (!def) return;
|
||||
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setDraftByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
persistToPreferences(action, def);
|
||||
};
|
||||
|
||||
const resetAllKeybindings = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
const startEditing = (action) => {
|
||||
// if another row is editing, commit/stop it first
|
||||
if (editingAction && editingAction !== action) {
|
||||
const ok = commitCombo(editingAction);
|
||||
if (ok) {
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
} else {
|
||||
// keep previous row editing if invalid
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEditingAction(action);
|
||||
setRecordingAction(action);
|
||||
pressedKeysRef.current = new Set();
|
||||
|
||||
// seed draft with current value
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: fromKeysString(getCurrentRowKeysString(action))
|
||||
}));
|
||||
|
||||
// clear error on start edit
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRefs.current[action]?.focus?.();
|
||||
inputRefs.current[action]?.setSelectionRange?.(
|
||||
inputRefs.current[action].value.length,
|
||||
inputRefs.current[action].value.length
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const stopEditing = (action) => {
|
||||
const ok = commitCombo(action);
|
||||
if (!ok) {
|
||||
// If commit failed (validation error), reset to original value
|
||||
cancelEditing(action);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
};
|
||||
|
||||
// Reset draft to original value and clear error (used on blur with invalid state)
|
||||
const cancelEditing = (action) => {
|
||||
// Clear error for this action
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
// Reset draft to current saved value
|
||||
setDraftByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
};
|
||||
|
||||
const handleKeyDown = (action, e) => {
|
||||
if (recordingAction !== action || editingAction !== action) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// allow user to clear and keep editing (do NOT auto-stop)
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
pressedKeysRef.current = new Set();
|
||||
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
|
||||
setErrorByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.repeat) return;
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
pressedKeysRef.current.add(keyName);
|
||||
|
||||
const currentDraft = uniqSorted(Array.from(pressedKeysRef.current));
|
||||
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: currentDraft
|
||||
}));
|
||||
|
||||
const err = validateCombo(action, currentDraft);
|
||||
if (err) {
|
||||
setErrorByAction((prev) => ({ ...prev, [action]: err }));
|
||||
} else {
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (action, e) => {
|
||||
if (recordingAction !== action || editingAction !== action) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
pressedKeysRef.current.delete(keyName);
|
||||
|
||||
// commit only when released AND currently valid
|
||||
if (pressedKeysRef.current.size === 0) {
|
||||
const currentDraft = draftByAction[action] || [];
|
||||
|
||||
// if empty -> keep editing
|
||||
if (currentDraft.length === 0) return;
|
||||
|
||||
// if error -> keep editing
|
||||
if (errorByAction[action]?.message) return;
|
||||
|
||||
stopEditing(action);
|
||||
}
|
||||
};
|
||||
|
||||
const renderValue = (action) => {
|
||||
const arr
|
||||
= recordingAction === action ? draftByAction[action] : fromKeysString(getCurrentRowKeysString(action));
|
||||
|
||||
return (arr || []).join(' + ');
|
||||
};
|
||||
const Keybindings = ({ close }) => {
|
||||
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<Tooltip
|
||||
id="kb-editing-error-tooltip"
|
||||
place="bottom-start"
|
||||
opacity={1}
|
||||
className="kb-tooltip kb-tooltip--error"
|
||||
/>
|
||||
|
||||
<div className="section-header">
|
||||
<span>Keybindings</span>
|
||||
{hasDirtyRows && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-all-btn"
|
||||
onClick={resetAllKeybindings}
|
||||
title="Reset all keybindings to default"
|
||||
>
|
||||
<IconRefresh size={12} stroke={1} />
|
||||
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-header">Keybindings</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -529,90 +19,18 @@ const Keybindings = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{keyMapping ? (
|
||||
Object.entries(keyMapping).map(([action, row]) => {
|
||||
const isEditing = editingAction === action;
|
||||
const isHovered = hoveredAction === action;
|
||||
const isDirty = isRowDirty(action);
|
||||
|
||||
const showPencil = isHovered && !isEditing && !isDirty;
|
||||
const showRefresh = isDirty && !isEditing;
|
||||
const hasError = Boolean(errorByAction[action]?.message);
|
||||
const errorMessage = errorByAction[action]?.message;
|
||||
const inputId = `kb-input-${action}`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={action}
|
||||
data-testid={`keybinding-row-${action}`}
|
||||
onMouseEnter={() => setHoveredAction(action)}
|
||||
onMouseLeave={() => setHoveredAction((prev) => (prev === action ? null : prev))}
|
||||
>
|
||||
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
|
||||
|
||||
<td>
|
||||
<div className="keybinding-row">
|
||||
<div className="shortcut-wrap">
|
||||
<input
|
||||
id={inputId}
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current[action] = el;
|
||||
}}
|
||||
data-testid={`keybinding-input-${action}`}
|
||||
className={`shortcut-input ${hasError ? 'shortcut-input--error' : ''}`}
|
||||
value={renderValue(action)}
|
||||
readOnly={!isEditing}
|
||||
onKeyDown={(e) => handleKeyDown(action, e)}
|
||||
onKeyUp={(e) => handleKeyUp(action, e)}
|
||||
onBlur={() => {
|
||||
// If there's an error, reset to original value instead of keeping invalid state
|
||||
if (isEditing && hasError) {
|
||||
cancelEditing(action);
|
||||
} else if (isEditing) {
|
||||
stopEditing(action);
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{isEditing && hasError && (
|
||||
<Tooltip
|
||||
id={`kb-editing-error-tooltip-${action}`}
|
||||
anchorSelect={`#${inputId}`}
|
||||
place="bottom-start"
|
||||
opacity={1}
|
||||
isOpen={true}
|
||||
content={errorMessage}
|
||||
className="kb-tooltip kb-tooltip--error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-btn"
|
||||
data-testid={`keybinding-reset-${action}`}
|
||||
onClick={() => resetRowToDefault(action)}
|
||||
title="Reset to default"
|
||||
>
|
||||
<IconRefresh size={12} stroke={1} />
|
||||
</button>
|
||||
)}
|
||||
{showPencil && (
|
||||
<button
|
||||
type="button"
|
||||
className="edit-btn"
|
||||
data-testid={`keybinding-edit-${action}`}
|
||||
onClick={() => startEditing(action)}
|
||||
title="Edit shortcut"
|
||||
>
|
||||
<IconPencil size={12} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
Object.entries(keyMapping).map(([action, { name, keys }], index) => (
|
||||
<tr key={index}>
|
||||
<td>{name}</td>
|
||||
<td>
|
||||
{keys.split('+').map((key, i) => (
|
||||
<div className="key-button" key={i}>
|
||||
{key}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="2">No key bindings available</td>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
@@ -75,41 +75,26 @@ const ProxySettings = ({ close }) => {
|
||||
});
|
||||
}, [dispatch, preferences, proxySchema]);
|
||||
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
onUpdateRef.current = onUpdate;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
onUpdate(values);
|
||||
onUpdateRef.current(values);
|
||||
}, 500),
|
||||
[onUpdate]
|
||||
[]
|
||||
);
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
formik.setValues({
|
||||
disabled: preferences.proxy.disabled || false,
|
||||
inherit: preferences.proxy.inherit || false,
|
||||
config: {
|
||||
protocol: preferences.proxy.config?.protocol || 'http',
|
||||
hostname: preferences.proxy.config?.hostname || '',
|
||||
port: preferences.proxy.config?.port || '',
|
||||
auth: {
|
||||
disabled: preferences.proxy.config?.auth?.disabled || false,
|
||||
username: preferences.proxy.config?.auth?.username || '',
|
||||
password: preferences.proxy.config?.auth?.password || ''
|
||||
},
|
||||
bypassProxy: preferences.proxy.config?.bypassProxy || ''
|
||||
}
|
||||
});
|
||||
}, [preferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty) {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, debouncedSave]);
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
IconUserCircle,
|
||||
IconKeyboard,
|
||||
IconZoomQuestion,
|
||||
IconSquareLetterB
|
||||
IconSquareLetterB,
|
||||
IconDatabase
|
||||
} from '@tabler/icons';
|
||||
|
||||
import Support from './Support';
|
||||
@@ -21,6 +22,7 @@ import Keybindings from './Keybindings';
|
||||
import Beta from './Beta';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Cache from './Cache/index';
|
||||
|
||||
const Preferences = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -65,6 +67,10 @@ const Preferences = () => {
|
||||
case 'support': {
|
||||
return <Support />;
|
||||
}
|
||||
|
||||
case 'cache': {
|
||||
return <Cache />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,6 +98,10 @@ const Preferences = () => {
|
||||
<IconKeyboard size={16} strokeWidth={1.5} />
|
||||
Keybindings
|
||||
</div>
|
||||
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
|
||||
<IconDatabase size={16} strokeWidth={1.5} />
|
||||
Cache
|
||||
</div>
|
||||
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
|
||||
<IconZoomQuestion size={16} strokeWidth={1.5} />
|
||||
Support
|
||||
|
||||
@@ -33,7 +33,7 @@ const RequestNotFound = ({ itemUid }) => {
|
||||
const errors = [
|
||||
{
|
||||
title: 'Request no longer exists',
|
||||
message: 'This can happen when the .bru file associated with this request was deleted on your filesystem.'
|
||||
message: 'This can happen when the file associated with this request was deleted on your filesystem.'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview';
|
||||
import Preferences from 'components/Preferences';
|
||||
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
|
||||
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
|
||||
import OpenAPISyncTab from 'components/OpenAPISyncTab';
|
||||
import OpenAPISpecTab from 'components/OpenAPISpecTab';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 490;
|
||||
@@ -175,7 +177,7 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
|
||||
if (!activeTabUid || !focusedTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
return <div className="pb-4 px-4">Loading...</div>;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'global-environment-settings') {
|
||||
@@ -245,6 +247,14 @@ const RequestTabPanel = () => {
|
||||
return <EnvironmentSettings collection={collection} />;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'openapi-sync') {
|
||||
return <OpenAPISyncTab collection={collection} />;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'openapi-spec') {
|
||||
return <OpenAPISpecTab collection={collection} />;
|
||||
}
|
||||
|
||||
if (!item || !item.uid) {
|
||||
return <RequestNotFound itemUid={activeTabUid} />;
|
||||
}
|
||||
|
||||
@@ -72,19 +72,46 @@ const StyledWrapper = styled.div`
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.workspace-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 3px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
min-width: 150px;
|
||||
|
||||
&:focus-within {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-name-input {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 3px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
outline: none;
|
||||
min-width: 150px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
.cog-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,21 +14,28 @@ import {
|
||||
IconFolder,
|
||||
IconUpload
|
||||
} from '@tabler/icons';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { uuid } from 'utils/common';
|
||||
import toast from 'react-hot-toast';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
|
||||
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
|
||||
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import { getRevealInFolderLabel } from 'utils/common/platform';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import classNames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
|
||||
const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -39,17 +46,25 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
|
||||
// Get the current active workspace
|
||||
const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const gitRootPath = collection?.git?.gitRootPath;
|
||||
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
|
||||
|
||||
// Workspace rename state
|
||||
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
|
||||
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
|
||||
const [workspaceNameError, setWorkspaceNameError] = useState('');
|
||||
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
|
||||
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
|
||||
|
||||
const switcherRef = useRef();
|
||||
const workspaceActionsRef = useRef();
|
||||
const workspaceNameInputRef = useRef(null);
|
||||
const workspaceRenameContainerRef = useRef(null);
|
||||
const openingAdvancedRef = useRef(false);
|
||||
const clickedOutsideRef = useRef(false);
|
||||
const handleSaveRef = useRef(null);
|
||||
const tempWorkspaceUidRef = useRef(null);
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
const onSwitcherCreate = (ref) => (switcherRef.current = ref);
|
||||
const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref);
|
||||
@@ -61,39 +76,56 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
setIsRenamingWorkspace(true);
|
||||
setWorkspaceNameInput(currentWorkspace.name || '');
|
||||
setWorkspaceNameError('');
|
||||
const timer = setTimeout(() => {
|
||||
workspaceNameInputRef.current?.focus();
|
||||
workspaceNameInputRef.current?.select();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isScratchCollection, currentWorkspace?.isNewlyCreated, currentWorkspace?.uid, currentWorkspace?.name, dispatch]);
|
||||
|
||||
const handleCancelWorkspaceRename = useCallback(() => {
|
||||
if (openingAdvancedRef.current) return;
|
||||
if (currentWorkspace?.isCreating) {
|
||||
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
|
||||
return;
|
||||
}
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
}, []);
|
||||
}, [currentWorkspace?.isCreating, currentWorkspace?.uid, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRenamingWorkspace) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
|
||||
handleCancelWorkspaceRename();
|
||||
if (currentWorkspace?.isCreating) {
|
||||
clickedOutsideRef.current = true;
|
||||
handleSaveRef.current?.();
|
||||
} else {
|
||||
handleCancelWorkspaceRename();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
const timer = setTimeout(() => {
|
||||
workspaceNameInputRef.current?.focus();
|
||||
workspaceNameInputRef.current?.select();
|
||||
}, 50);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isRenamingWorkspace, handleCancelWorkspaceRename]);
|
||||
}, [isRenamingWorkspace, handleCancelWorkspaceRename, currentWorkspace?.isCreating]);
|
||||
|
||||
const collectionUpdates = useSelector((state) => state.openapiSync?.collectionUpdates || {});
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasOpenApiSyncConfigured = collection?.brunoConfig?.openapi?.[0]?.sourceUrl;
|
||||
const hasOpenApiUpdates = hasOpenApiSyncConfigured && collectionUpdates[collection.uid]?.hasUpdates;
|
||||
const hasOpenApiError = hasOpenApiSyncConfigured && collectionUpdates[collection.uid]?.error;
|
||||
|
||||
// Get mounted collections for the current workspace (excluding scratch collections)
|
||||
const mountedCollections = collections.filter((c) => {
|
||||
if (c.mountStatus !== 'mounted') return false;
|
||||
@@ -102,7 +134,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
if (isScratch) return false;
|
||||
|
||||
const workspaceCollectionPaths = currentWorkspace?.collections?.map((wc) => wc.path) || [];
|
||||
return workspaceCollectionPaths.some((wcPath) => c.pathname === wcPath);
|
||||
return workspaceCollectionPaths.some((wcPath) => normalizePath(c.pathname) === normalizePath(wcPath));
|
||||
});
|
||||
|
||||
// Count tabs for the current collection
|
||||
@@ -180,16 +212,29 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const viewOpenApiSync = () => {
|
||||
dispatch(addTab({
|
||||
uid: uuid(),
|
||||
collectionUid: collection.uid,
|
||||
type: 'openapi-sync'
|
||||
}));
|
||||
};
|
||||
|
||||
// Build overflow menu items for the "..." dropdown
|
||||
const overflowMenuItems = [
|
||||
{ id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables },
|
||||
...(isOpenAPISyncEnabled && !hasOpenApiSyncConfigured
|
||||
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, rightSection: <StatusBadge status="info" size="xs">Beta</StatusBadge>, onClick: viewOpenApiSync }]
|
||||
: []),
|
||||
{ id: 'collection-settings', label: 'Collection Settings', leftSection: IconSettings, onClick: viewCollectionSettings }
|
||||
];
|
||||
|
||||
// Workspace action handlers (only used when isScratchCollection is true)
|
||||
const handleRenameWorkspaceClick = () => {
|
||||
workspaceActionsRef.current?.hide();
|
||||
setIsRenamingWorkspace(true);
|
||||
setWorkspaceNameInput(currentWorkspace?.name || '');
|
||||
setWorkspaceNameError('');
|
||||
setTimeout(() => {
|
||||
workspaceNameInputRef.current?.focus();
|
||||
workspaceNameInputRef.current?.select();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleCloseWorkspaceClick = () => {
|
||||
@@ -239,28 +284,71 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
};
|
||||
|
||||
const handleSaveWorkspaceRename = () => {
|
||||
const fromOutside = clickedOutsideRef.current;
|
||||
clickedOutsideRef.current = false;
|
||||
|
||||
if (openingAdvancedRef.current) return;
|
||||
if (isSavingRef.current) return;
|
||||
|
||||
const trimmedName = workspaceNameInput?.trim();
|
||||
if (!trimmedName) {
|
||||
if (fromOutside && currentWorkspace?.isCreating) {
|
||||
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
|
||||
return;
|
||||
}
|
||||
setWorkspaceNameError('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const error = validateWorkspaceName(workspaceNameInput);
|
||||
if (error) {
|
||||
setWorkspaceNameError(error);
|
||||
if (fromOutside && currentWorkspace?.isCreating) {
|
||||
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = currentWorkspace?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
|
||||
.then(() => {
|
||||
toast.success('Workspace renamed!');
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message || 'An error occurred while renaming the workspace');
|
||||
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
|
||||
});
|
||||
isSavingRef.current = true;
|
||||
|
||||
if (currentWorkspace?.isCreating) {
|
||||
dispatch(confirmWorkspaceCreation(uid, trimmedName))
|
||||
.then(() => {
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
toast.success('Workspace created!');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message || 'An error occurred while creating the workspace');
|
||||
})
|
||||
.finally(() => {
|
||||
isSavingRef.current = false;
|
||||
});
|
||||
} else {
|
||||
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
|
||||
.then(() => {
|
||||
toast.success('Workspace renamed!');
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message || 'An error occurred while renaming the workspace');
|
||||
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
|
||||
})
|
||||
.finally(() => {
|
||||
isSavingRef.current = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Keep ref in sync so click-outside handler always has the latest save logic
|
||||
handleSaveRef.current = handleSaveWorkspaceRename;
|
||||
|
||||
const handleWorkspaceNameChange = (e) => {
|
||||
setWorkspaceNameInput(e.target.value);
|
||||
if (workspaceNameError) {
|
||||
@@ -278,6 +366,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdvancedCreate = () => {
|
||||
openingAdvancedRef.current = true;
|
||||
tempWorkspaceUidRef.current = currentWorkspace?.isCreating ? currentWorkspace.uid : null;
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAdvancedCreateClose = () => {
|
||||
openingAdvancedRef.current = false;
|
||||
setCreateWorkspaceModalOpen(false);
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
const tempUid = tempWorkspaceUidRef.current;
|
||||
tempWorkspaceUidRef.current = null;
|
||||
// Clean up the temp workspace (cancelWorkspaceCreation only switches to default
|
||||
// if the temp workspace was still active, so this is safe after modal success too)
|
||||
if (tempUid) {
|
||||
dispatch(cancelWorkspaceCreation(tempUid));
|
||||
}
|
||||
};
|
||||
|
||||
// Check if workspace actions should be shown
|
||||
const showWorkspaceActions = isScratchCollection
|
||||
&& currentWorkspace
|
||||
@@ -293,30 +402,46 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{createWorkspaceModalOpen && (
|
||||
<CreateWorkspace onClose={handleAdvancedCreateClose} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 py-2 px-4">
|
||||
{/* Left side: Switcher dropdown or rename input */}
|
||||
<div className="collection-switcher">
|
||||
{isRenamingWorkspace ? (
|
||||
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
|
||||
<DisplayIcon size={18} strokeWidth={1.5} />
|
||||
<input
|
||||
ref={workspaceNameInputRef}
|
||||
type="text"
|
||||
className="workspace-name-input"
|
||||
value={workspaceNameInput}
|
||||
onChange={handleWorkspaceNameChange}
|
||||
onKeyDown={handleWorkspaceNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="workspace-input-wrapper">
|
||||
<input
|
||||
ref={workspaceNameInputRef}
|
||||
type="text"
|
||||
className="workspace-name-input"
|
||||
value={workspaceNameInput}
|
||||
onChange={handleWorkspaceNameChange}
|
||||
onKeyDown={handleWorkspaceNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{currentWorkspace?.isCreating && (
|
||||
<button
|
||||
className="cog-btn"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleOpenAdvancedCreate}
|
||||
title="Advanced options"
|
||||
>
|
||||
<IconSettings size={13} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveWorkspaceRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
title={currentWorkspace?.isCreating ? 'Create' : 'Save'}
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
@@ -437,24 +562,38 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
|
||||
{/* Right side: Actions (only for regular collections) */}
|
||||
{!isScratchCollection && (
|
||||
<div className="flex flex-grow gap-1 items-center justify-end">
|
||||
<div className="flex flex-grow gap-1.5 items-center justify-end">
|
||||
{/* OpenAPI Sync - standalone only when configured and beta enabled */}
|
||||
{isOpenAPISyncEnabled && hasOpenApiSyncConfigured && (
|
||||
<ToolHint
|
||||
text={hasOpenApiError ? 'OpenAPI Error' : hasOpenApiUpdates ? 'OpenAPI Updates Available' : 'OpenAPI'}
|
||||
toolhintId="OpenApiSyncToolhintId"
|
||||
place="bottom"
|
||||
>
|
||||
<ActionIcon onClick={viewOpenApiSync} aria-label="OpenAPI" size="sm" className="relative">
|
||||
<OpenAPISyncIcon size={15} />
|
||||
{(hasOpenApiUpdates || hasOpenApiError) && (
|
||||
<span className="absolute top-0 right-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: hasOpenApiError ? theme.status.danger.text : theme.status.warning.text }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
)}
|
||||
{/* Runner - always visible */}
|
||||
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
|
||||
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
|
||||
<IconRun size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
|
||||
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
|
||||
<IconEye size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
|
||||
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
|
||||
<IconSettings size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
{/* JS Sandbox Mode - always visible */}
|
||||
<JsSandboxMode collection={collection} />
|
||||
<span className="ml-2">
|
||||
{/* Overflow menu */}
|
||||
<MenuDropdown items={overflowMenuItems} placement="bottom-end">
|
||||
<ActionIcon label="More actions" size="sm" style={{ border: `1px solid ${theme.border.border1}`, borderRadius: theme.border.radius.base, width: 24, marginRight: 4, marginLeft: 4 }}>
|
||||
<IconDots size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
{/* Environment Selector - always visible */}
|
||||
<span>
|
||||
<EnvironmentSelector collection={collection} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld, IconHome } from '@tabler/icons';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconDatabase, IconWorld, IconHome, IconFileCode } from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
|
||||
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
|
||||
const getTabInfo = (type, tabName) => {
|
||||
@@ -85,6 +87,23 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'openapi-sync': {
|
||||
return (
|
||||
<>
|
||||
<OpenAPISyncIcon size={14} className="special-tab-icon flex-shrink-0" />
|
||||
<span className="ml-1 tab-name mr-1">OpenAPI</span>
|
||||
<StatusBadge status="info" size="xs">Beta</StatusBadge>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'openapi-spec': {
|
||||
return (
|
||||
<>
|
||||
<IconFileCode size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">API Spec</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -36,10 +36,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false);
|
||||
const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false);
|
||||
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const activeTab = tabs.find((t) => t.uid === activeTabUid);
|
||||
|
||||
const menuDropdownRef = useRef();
|
||||
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
@@ -90,62 +86,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
};
|
||||
}, [item, item?.name, method, setHasOverflow]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseTabFromHotkeys = () => {
|
||||
if (!activeTabUid || !activeTab) return;
|
||||
|
||||
// Only the active tab component should handle this
|
||||
if (tab.uid !== activeTabUid) return;
|
||||
|
||||
// Always compute item for the active tab
|
||||
const activeItem = findItemInCollection(collection, activeTabUid);
|
||||
|
||||
switch (activeTab.type) {
|
||||
case 'request':
|
||||
if (activeItem && hasRequestChanges(activeItem)) {
|
||||
console.log('Item have changes');
|
||||
setShowConfirmClose(true);
|
||||
} else {
|
||||
console.log('Item dont have changes');
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'collection-settings':
|
||||
if (collection?.draft) {
|
||||
setShowConfirmCollectionClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'folder-settings': {
|
||||
const folderItem = findItemInCollection(collection, activeTab.folderUid || tab.folderUid);
|
||||
if (folderItem?.draft) {
|
||||
setShowConfirmFolderClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'environment-settings':
|
||||
if (collection?.environmentsDraft) {
|
||||
setShowConfirmEnvironmentClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('close-active-tab', handleCloseTabFromHotkeys);
|
||||
return () => window.removeEventListener('close-active-tab', handleCloseTabFromHotkeys);
|
||||
}, [dispatch, activeTab, activeTabUid, tab.uid, collection]);
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@@ -206,6 +146,21 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
setShowConfirmFolderClose(true);
|
||||
};
|
||||
|
||||
const specialTabs = [
|
||||
'collection-overview',
|
||||
'collection-settings',
|
||||
'folder-settings',
|
||||
'variables',
|
||||
'collection-runner',
|
||||
'environment-settings',
|
||||
'global-environment-settings',
|
||||
'preferences',
|
||||
'workspaceOverview',
|
||||
'workspaceEnvironments',
|
||||
'openapi-sync',
|
||||
'openapi-spec'
|
||||
];
|
||||
|
||||
const hasDraft = tab.type === 'collection-settings' && collection?.draft;
|
||||
const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft;
|
||||
const hasEnvironmentDraft = tab.type === 'environment-settings' && collection?.environmentsDraft;
|
||||
@@ -232,7 +187,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
setShowConfirmGlobalEnvironmentClose(true);
|
||||
};
|
||||
|
||||
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences', 'workspaceOverview', 'workspaceEnvironments'].includes(tab.type)) {
|
||||
if (specialTabs.includes(tab.type)) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { newFolder, closeTabs, mountCollection, createCollection, browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import path from 'utils/common/path';
|
||||
import path, { normalizePath } from 'utils/common/path';
|
||||
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection, areItemsLoading } from 'utils/collections';
|
||||
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
|
||||
import { itemSchema } from '@usebruno/schema';
|
||||
@@ -44,13 +44,13 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
const isDefaultWorkspace = activeWorkspace?.type === 'default';
|
||||
const defaultCollectionLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
: (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : '');
|
||||
|
||||
const availableCollections = useMemo(() => {
|
||||
if (!isScratchCollection || !activeWorkspace) return [];
|
||||
|
||||
return (activeWorkspace.collections || []).map((wc) => {
|
||||
const fullCollection = allCollections.find((c) => c.pathname === wc.path);
|
||||
const fullCollection = allCollections.find((c) => normalizePath(c.pathname) === normalizePath(wc.path));
|
||||
// Use stable deterministic UID based on path to avoid duplicate Redux entries
|
||||
const stableUid = wc.path ? `pending-${wc.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuid();
|
||||
return fullCollection || { ...wc, uid: stableUid, mountStatus: 'unmounted' };
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState, useMemo, forwardRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import path from 'utils/common/path';
|
||||
import { browseDirectory, importCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Modal from 'components/Modal';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
@@ -134,7 +135,7 @@ export const BulkImportCollectionLocation = ({
|
||||
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
: (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : '');
|
||||
|
||||
const [status, setStatus] = useState({});
|
||||
const [errorMessages, setErrorMessages] = useState({});
|
||||
@@ -839,7 +840,7 @@ export const BulkImportCollectionLocation = ({
|
||||
|
||||
{isMultipleImport && hasOpenApiSpec && (
|
||||
<div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-4 items-center mt-4">
|
||||
<div>
|
||||
<label htmlFor="groupingType" className="block font-semibold">
|
||||
Folder arrangement
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { removeGitOperationProgress } from 'providers/ReduxStore/slices/app';
|
||||
import Modal from 'components/Modal';
|
||||
import * as path from 'path';
|
||||
import path from 'utils/common/path';
|
||||
import Portal from 'components/Portal';
|
||||
import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons';
|
||||
import { uuid } from 'utils/common/index';
|
||||
@@ -34,7 +34,7 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
|
||||
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
: (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : '');
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import path from 'utils/common/path';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -27,7 +28,7 @@ const CloneCollection = ({ onClose, collectionUid }) => {
|
||||
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
: (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : '');
|
||||
const { name } = collection;
|
||||
|
||||
const formik = useFormik({
|
||||
|
||||
@@ -203,7 +203,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" data-testid="collection-item-clone">
|
||||
<Button type="submit">
|
||||
Clone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { getEmptyImage } from 'react-dnd-html5-backend';
|
||||
import range from 'lodash/range';
|
||||
import filter from 'lodash/filter';
|
||||
@@ -69,21 +69,12 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const isSidebarDragging = useSelector((state) => state.app.isDragging);
|
||||
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
|
||||
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const userKeyBindings = preferences?.keyBindings || {};
|
||||
const hasCustomCopyBinding = !!userKeyBindings?.copyItem;
|
||||
const hasCustomPasteBinding = !!userKeyBindings?.pasteItem;
|
||||
const hasCustomRenameBinding = !!userKeyBindings?.renameItem;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// We use a single ref for drag and drop.
|
||||
const ref = useRef(null);
|
||||
const menuDropdownRef = useRef(null);
|
||||
|
||||
// Refs to store current handler references for event listeners (avoid stale closures)
|
||||
const copyHandlerRef = useRef(null);
|
||||
const pasteHandlerRef = useRef(null);
|
||||
|
||||
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
|
||||
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
|
||||
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
|
||||
@@ -130,52 +121,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
}
|
||||
}, [isTabForItemActive]);
|
||||
|
||||
// Listen for clone-item-open event from Hotkeys provider
|
||||
const isFocusedRef = useRef(isKeyboardFocused);
|
||||
isFocusedRef.current = isKeyboardFocused;
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloneItemOpen = () => {
|
||||
// Only open modal if this item is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setCloneItemModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyItemOpen = () => {
|
||||
// Copy item to clipboard if this item is keyboard focused
|
||||
if (isFocusedRef.current && copyHandlerRef.current) {
|
||||
copyHandlerRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasteItemOpen = () => {
|
||||
// Paste item from clipboard if this item is keyboard focused
|
||||
if (isFocusedRef.current && pasteHandlerRef.current) {
|
||||
pasteHandlerRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameItemOpen = () => {
|
||||
// Rename item if this item is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setRenameItemModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.addEventListener('copy-item-open', handleCopyItemOpen);
|
||||
window.addEventListener('paste-item-open', handlePasteItemOpen);
|
||||
window.addEventListener('rename-item-open', handleRenameItemOpen);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.removeEventListener('copy-item-open', handleCopyItemOpen);
|
||||
window.removeEventListener('paste-item-open', handlePasteItemOpen);
|
||||
window.removeEventListener('rename-item-open', handleRenameItemOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const determineDropType = (monitor) => {
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
@@ -592,13 +537,13 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyItem = useCallback(() => {
|
||||
const handleCopyItem = () => {
|
||||
dispatch(copyRequest(item));
|
||||
const itemType = isFolder ? 'Folder' : 'Request';
|
||||
toast.success(`${itemType} copied`);
|
||||
}, [dispatch, item, isFolder]);
|
||||
};
|
||||
|
||||
const handlePasteItem = useCallback(() => {
|
||||
const handlePasteItem = () => {
|
||||
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
|
||||
let targetFolderUid = item.uid;
|
||||
if (!isFolder) {
|
||||
@@ -613,11 +558,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
.catch((err) => {
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the item');
|
||||
});
|
||||
}, [dispatch, collection, item, isFolder, collectionUid]);
|
||||
|
||||
// Update refs whenever handlers change
|
||||
copyHandlerRef.current = handleCopyItem;
|
||||
pasteHandlerRef.current = handlePasteItem;
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handler
|
||||
const handleKeyDown = (e) => {
|
||||
@@ -625,19 +566,23 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
|
||||
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// Only use default handler if no custom keybinding is set for copy/paste
|
||||
if (!hasCustomCopyBinding && isModifierPressed && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (copyHandlerRef.current) copyHandlerRef.current();
|
||||
} else if (!hasCustomPasteBinding && isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (pasteHandlerRef.current) pasteHandlerRef.current();
|
||||
} else if (!hasCustomRenameBinding && e.key === 'F2') {
|
||||
const [macRenameKey, winRenameKey] = getKeyBindingsForActionAllOS('renameItem');
|
||||
const renameKey = isMac ? macRenameKey : winRenameKey;
|
||||
|
||||
// Only trigger rename if no modifier keys are pressed (allow Cmd+Enter for run request)
|
||||
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
||||
if (e.key.toLowerCase() === renameKey && !hasModifier) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRenameItemModalOpen(true);
|
||||
} else if (isModifierPressed && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyItem();
|
||||
} else if (isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePasteItem();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IconFolder,
|
||||
IconBook
|
||||
} from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem, showInFolder, saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -47,6 +48,8 @@ import { getRevealInFolderLabel } from 'utils/common/platform';
|
||||
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
|
||||
import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
|
||||
|
||||
@@ -55,6 +58,7 @@ import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
|
||||
const EMPTY_STATE_DELAY_MS = 300;
|
||||
|
||||
const Collection = ({ collection, searchText }) => {
|
||||
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
|
||||
const { dropdownContainerRef } = useSidebarAccordion();
|
||||
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
@@ -69,12 +73,25 @@ const Collection = ({ collection, searchText }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = collection.isLoading;
|
||||
const collectionRef = useRef(null);
|
||||
const itemCount = collection.items?.length || 0;
|
||||
// Only count persisted items; transients don't affect empty state
|
||||
const itemCount = collection.items?.filter((i) => !i.isTransient).length || 0;
|
||||
|
||||
const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid }));
|
||||
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
|
||||
const menuDropdownRef = useRef(null);
|
||||
|
||||
// Open the OpenAPI Sync tab
|
||||
const openOpenAPISyncTab = () => {
|
||||
ensureCollectionIsMounted();
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: uuid(),
|
||||
collectionUid: collection.uid,
|
||||
type: 'openapi-sync'
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRun = () => {
|
||||
dispatch(
|
||||
addTab({
|
||||
@@ -264,34 +281,6 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
}, [isCollectionFocused]);
|
||||
|
||||
// Listen for clone-item-open event from Hotkeys provider
|
||||
const isFocusedRef = useRef(isKeyboardFocused);
|
||||
isFocusedRef.current = isKeyboardFocused;
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloneItemOpen = () => {
|
||||
// Only open modal if this collection is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setShowCloneCollectionModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameCollectionOpen = () => {
|
||||
// Only open rename collection modal if this collection is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setShowRenameCollectionModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.addEventListener('rename-item-open', handleRenameCollectionOpen);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.removeEventListener('rename-item-open', handleRenameCollectionOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounce showing empty state to prevent flicker
|
||||
// Race condition: isLoading can become false before items batch arrives from IPC
|
||||
useEffect(() => {
|
||||
@@ -368,6 +357,13 @@ const Collection = ({ collection, searchText }) => {
|
||||
setShowCloneCollectionModalOpen(true);
|
||||
}
|
||||
},
|
||||
...(isOpenAPISyncEnabled ? [{
|
||||
id: 'sync-openapi',
|
||||
leftSection: OpenAPISyncIcon,
|
||||
label: 'OpenAPI',
|
||||
rightSection: <StatusBadge status="info" size="xs">Beta</StatusBadge>,
|
||||
onClick: openOpenAPISyncTab
|
||||
}] : []),
|
||||
...(hasCopiedItems
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconCheck, IconX, IconSettings } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import path from 'utils/common/path';
|
||||
import toast from 'react-hot-toast';
|
||||
import { createCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
@@ -26,7 +27,7 @@ const InlineCollectionCreator = ({ onComplete, onCancel, onOpenAdvanced }) => {
|
||||
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
: (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : '');
|
||||
|
||||
useEffect(() => {
|
||||
const focusAndSelect = (value) => {
|
||||
@@ -41,7 +42,7 @@ const InlineCollectionCreator = ({ onComplete, onCancel, onOpenAdvanced }) => {
|
||||
};
|
||||
|
||||
if (defaultLocation) {
|
||||
window.ipcRenderer?.invoke('renderer:find-unique-folder-name', 'untitled collection', defaultLocation)
|
||||
window.ipcRenderer?.invoke('renderer:find-unique-folder-name', 'Untitled Collection', defaultLocation)
|
||||
?.then((name) => focusAndSelect(name))
|
||||
?.catch(() => focusAndSelect());
|
||||
} else {
|
||||
@@ -126,7 +127,7 @@ const InlineCollectionCreator = ({ onComplete, onCancel, onOpenAdvanced }) => {
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="inline-collection-input"
|
||||
defaultValue="untitled collection"
|
||||
defaultValue="Untitled Collection"
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState, forwardRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import path from 'utils/common/path';
|
||||
import { browseDirectory, createCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import Portal from 'components/Portal';
|
||||
@@ -32,7 +33,7 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation, initi
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid);
|
||||
const isDefaultWorkspace = activeWorkspace?.type === 'default';
|
||||
|
||||
const defaultLocation = isDefaultWorkspace ? get(preferences, 'general.defaultLocation', '') : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
const defaultLocation = isDefaultWorkspace ? get(preferences, 'general.defaultLocation', '') : (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : '');
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
|
||||
@@ -166,7 +166,13 @@ const FileTab = ({
|
||||
throw new Error('Unsupported collection format');
|
||||
}
|
||||
|
||||
await handleSubmit({ rawData: data, type });
|
||||
if (type === 'openapi') {
|
||||
const filePath = window.ipcRenderer.getFilePath(file);
|
||||
const rawContent = await file.text();
|
||||
await handleSubmit({ rawData: data, type, filePath, rawContent });
|
||||
} else {
|
||||
await handleSubmit({ rawData: data, type });
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err, 'Import collection failed');
|
||||
} finally {
|
||||
|
||||
@@ -17,9 +17,9 @@ const UrlTab = ({
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { data, specType } = await fetchAndValidateApiSpecFromUrl({ url: urlInput.trim() });
|
||||
// Pass raw data for all types
|
||||
handleSubmit({ rawData: data, type: specType });
|
||||
const { data, specType, rawContent } = await fetchAndValidateApiSpecFromUrl({ url: urlInput.trim() });
|
||||
// Pass raw data for all types, include sourceUrl and rawContent for OpenAPI sync
|
||||
handleSubmit({ rawData: data, type: specType, sourceUrl: urlInput.trim(), rawContent });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setErrorMessage('URL import failed. Please check the URL and try again.');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import get from 'lodash/get';
|
||||
import path from 'utils/common/path';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { postmanToBruno } from 'utils/importers/postman-collection';
|
||||
@@ -12,6 +13,7 @@ import { processBrunoCollection } from 'utils/importers/bruno-collection';
|
||||
import { processOpenCollection } from 'utils/importers/opencollection';
|
||||
import { wsdlToBruno } from '@usebruno/converters';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
import Modal from 'components/Modal';
|
||||
import Help from 'components/Help';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
@@ -95,14 +97,19 @@ const groupingOptions = [
|
||||
{ value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' }
|
||||
];
|
||||
|
||||
const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) => {
|
||||
const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format, sourceUrl, filePath, rawContent }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const [groupingType, setGroupingType] = useState('tags');
|
||||
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
|
||||
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
|
||||
const [enableCheckForSpecUpdates, setEnableCheckForSpecUpdates] = useState(isOpenAPISyncEnabled);
|
||||
const dropdownTippyRef = useRef();
|
||||
const isOpenApi = format === 'openapi';
|
||||
const isZipImport = format === 'bruno-zip';
|
||||
const isOpenApiFromUrl = isOpenApi && !!sourceUrl && !filePath;
|
||||
const isOpenApiFromFile = isOpenApi && !!filePath && !sourceUrl;
|
||||
const showCheckForSpecUpdatesOption = isOpenAPISyncEnabled && (isOpenApiFromUrl || isOpenApiFromFile);
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
@@ -111,7 +118,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
: (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : '');
|
||||
|
||||
const collectionName = getCollectionName(format, rawData);
|
||||
|
||||
@@ -128,7 +135,34 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
const convertedCollection = await convertCollection(format, rawData, groupingType, collectionFormat);
|
||||
handleSubmit(convertedCollection, values.collectionLocation, { format: collectionFormat });
|
||||
const options = { format: collectionFormat };
|
||||
|
||||
if (showCheckForSpecUpdatesOption && enableCheckForSpecUpdates) {
|
||||
const syncSourceUrl = sourceUrl || filePath; // URL or absolute path (backend converts to relative)
|
||||
const baseBrunoConfig = {
|
||||
version: convertedCollection.version || '1',
|
||||
name: convertedCollection.name || 'Untitled Collection',
|
||||
type: 'collection',
|
||||
ignore: ['node_modules', '.git']
|
||||
};
|
||||
|
||||
convertedCollection.brunoConfig = {
|
||||
...baseBrunoConfig,
|
||||
...convertedCollection.brunoConfig,
|
||||
openapi: [
|
||||
{
|
||||
sourceUrl: syncSourceUrl,
|
||||
groupBy: groupingType,
|
||||
autoCheck: true,
|
||||
autoCheckInterval: 5
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
options.rawOpenAPISpec = rawContent || rawData;
|
||||
}
|
||||
|
||||
handleSubmit(convertedCollection, values.collectionLocation, options);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -260,12 +294,12 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
</div>
|
||||
|
||||
{isOpenApi && (
|
||||
<div className="mt-4 flex gap-4 items-center">
|
||||
<div className="mt-4 flex gap-4 items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="groupingType" className="block font-medium mt-4">
|
||||
<label htmlFor="groupingType" className="block font-medium">
|
||||
Folder arrangement
|
||||
</label>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1 mb-2">
|
||||
<p className="text-muted text-xs mt-1 mb-2">
|
||||
Select whether to create folders according to the spec's paths or tags.
|
||||
</p>
|
||||
</div>
|
||||
@@ -288,6 +322,23 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCheckForSpecUpdatesOption && (
|
||||
<div className="mt-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableCheckForSpecUpdates}
|
||||
onChange={(e) => setEnableCheckForSpecUpdates(e.target.checked)}
|
||||
className="cursor-pointer checkbox"
|
||||
/>
|
||||
<span className="font-medium">Check for Spec Updates</span>
|
||||
</label>
|
||||
<p className="text-muted text-xs mt-1">
|
||||
Stay notified of spec changes and sync your collection with the spec.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -104,7 +104,6 @@ const NewFolder = ({ collectionUid, item, onClose }) => {
|
||||
formik.setFieldValue('folderName', e.target.value);
|
||||
!isEditing && formik.setFieldValue('directoryName', sanitizeName(e.target.value));
|
||||
}}
|
||||
data-testid="new-folder-input"
|
||||
value={formik.values.folderName || ''}
|
||||
/>
|
||||
{formik.touched.folderName && formik.errors.folderName ? (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
|
||||
import { useState, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
|
||||
import { importCollection, openCollection, importCollectionFromZip, newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
@@ -59,22 +58,6 @@ const CollectionsSection = () => {
|
||||
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
|
||||
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
|
||||
|
||||
// Listen for sidebar-search-open hotkey event
|
||||
useEffect(() => {
|
||||
const handleSidebarSearch = () => {
|
||||
setShowSearch(true);
|
||||
// Focus the search input after it's rendered
|
||||
setTimeout(() => {
|
||||
const searchInput = document.querySelector('.collection-search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
window.addEventListener('sidebar-search-open', handleSidebarSearch);
|
||||
return () => window.removeEventListener('sidebar-search-open', handleSidebarSearch);
|
||||
}, []);
|
||||
// Default to true (don't show modal) so that:
|
||||
// 1. Existing users who upgrade (no hasSeenWelcomeModal in their prefs) don't see it
|
||||
// 2. The modal doesn't flash before preferences are loaded from the electron process
|
||||
@@ -385,6 +368,9 @@ const CollectionsSection = () => {
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
sourceUrl={importData.sourceUrl}
|
||||
filePath={importData.filePath}
|
||||
rawContent={importData.rawContent}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
@@ -22,11 +21,8 @@ class SingleLineEditor extends Component {
|
||||
this.variables = {};
|
||||
this.readOnly = props.readOnly || false;
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,8 +59,8 @@ class SingleLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
extraKeys: {
|
||||
'Enter': runHandler,
|
||||
// 'Ctrl-Enter': runHandler,
|
||||
// 'Cmd-Enter': runHandler,
|
||||
'Ctrl-Enter': runHandler,
|
||||
'Cmd-Enter': runHandler,
|
||||
'Alt-Enter': () => {
|
||||
if (this.props.allowNewlines) {
|
||||
this.editor.setValue(this.editor.getValue() + '\n');
|
||||
@@ -73,7 +69,7 @@ class SingleLineEditor extends Component {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
// 'Shift-Enter': runHandler,
|
||||
'Shift-Enter': runHandler,
|
||||
'Cmd-S': saveHandler,
|
||||
'Ctrl-S': saveHandler,
|
||||
'Cmd-F': noopHandler,
|
||||
@@ -112,9 +108,6 @@ class SingleLineEditor extends Component {
|
||||
this._updateNewlineMarkers();
|
||||
}
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Setup keyboard shortcuts using the dedicated utility
|
||||
this._shortcutsCleanup = setupShortcuts(this.editor, this);
|
||||
}
|
||||
|
||||
/** Enable or disable masking the rendered content of the editor */
|
||||
@@ -186,6 +179,10 @@ class SingleLineEditor extends Component {
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
// Re-apply masking after setValue() since it destroys all CodeMirror marks
|
||||
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
|
||||
this.maskedEditor.update();
|
||||
}
|
||||
|
||||
// Update newline markers after value change
|
||||
if (this.props.showNewlineArrow) {
|
||||
@@ -209,12 +206,6 @@ class SingleLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
if (this.editor?._destroyLinkAware) {
|
||||
this.editor._destroyLinkAware();
|
||||
|
||||
@@ -49,7 +49,10 @@ const StatusBar = () => {
|
||||
};
|
||||
|
||||
const openGlobalSearch = () => {
|
||||
window.dispatchEvent(new CustomEvent('global-search-open'));
|
||||
const bindings = getKeyBindingsForActionAllOS('globalSearch') || [];
|
||||
bindings.forEach((binding) => {
|
||||
Mousetrap.trigger(binding);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 8px 20px;
|
||||
padding: 9px 20px 8px 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.title {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user