mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat/openapi sync (#7279)
* feat: implement OpenAPI Sync * feat: enhance focus styles and error handling across components - Added focus-visible styles for buttons and tags in Swagger and Modal components to improve accessibility. - Updated ConnectSpecForm to ensure source URL is set only if the file path is valid. - Enhanced clipboard copy functionality in SpecInfoCard with error handling and success notifications. - Improved ExpandableEndpointRow to handle loading state more robustly. - Refined SyncReviewPage to ensure correct filtering of updated endpoints. - Updated file handling in OpenAPI Sync IPC to support both .yml and .yaml extensions. * fix: improve filename sanitization in OpenAPI Sync IPC - Updated filename sanitization logic to ensure proper handling of both `name` and `filename` properties, enhancing compatibility with various file formats. - Adjusted the logic to derive the base name from the filename when necessary, ensuring consistent output for generated files. * feat: enhance OpenAPI Sync tab with new overview and header components - Introduced OverviewSection to display summary of collection and spec status, including total endpoints, in-sync counts, and pending updates. - Added OpenAPISyncHeader for improved navigation and actions related to the OpenAPI spec. - Updated CollectionStatusSection to better handle and display collection drift information. - Refined styling for status banners and added new visual elements for better user experience. - Enhanced tooltip functionality in Help component for improved accessibility. * refactor: remove VisualDiffViewer components and add diff package - Deleted VisualDiffViewer components including VisualDiffMeta, VisualDiffDocs, VisualDiffVars, and others to streamline the codebase. - Introduced the 'diff' package in package-lock.json to enhance diff functionality. - Updated utility functions to improve diff status handling and maintainability.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,6 +50,7 @@ bruno.iml
|
||||
.vscode
|
||||
.cursor
|
||||
.claude
|
||||
.codex
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
76
package-lock.json
generated
76
package-lock.json
generated
@@ -6513,6 +6513,18 @@
|
||||
"node": ">=16.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@profoundlogic/hogan": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@profoundlogic/hogan/-/hogan-3.0.4.tgz",
|
||||
"integrity": "sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"nopt": "1.0.10"
|
||||
},
|
||||
"bin": {
|
||||
"hulk": "bin/hulk"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
@@ -10970,8 +10982,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
@@ -14734,6 +14745,41 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/diff2html": {
|
||||
"version": "3.4.56",
|
||||
"resolved": "https://registry.npmjs.org/diff2html/-/diff2html-3.4.56.tgz",
|
||||
"integrity": "sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@profoundlogic/hogan": "^3.0.4",
|
||||
"diff": "^8.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"highlight.js": "11.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff2html/node_modules/diff": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
|
||||
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff2html/node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/diffie-hellman": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
|
||||
@@ -22016,6 +22062,21 @@
|
||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
|
||||
"integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abbrev": "1"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -30114,6 +30175,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",
|
||||
@@ -33470,6 +33532,7 @@
|
||||
"chokidar": "^3.5.3",
|
||||
"content-disposition": "^0.5.4",
|
||||
"decomment": "^0.9.5",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"electron-is-dev": "^2.0.0",
|
||||
"electron-notarize": "^1.2.2",
|
||||
@@ -34845,6 +34908,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/bruno-electron/node_modules/diff": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
|
||||
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"packages/bruno-electron/node_modules/dir-compare": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz",
|
||||
|
||||
@@ -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",
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
22
packages/bruno-app/src/components/Icons/OpenAPISync/index.js
Normal file
22
packages/bruno-app/src/components/Icons/OpenAPISync/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
const OpenAPISyncIcon = ({ size = 16, color = 'currentColor', ...props }) => {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M20.5152 2.03784C19.7741 2.20613 19.198 2.71101 18.9585 3.41007C18.8776 3.63986 18.8711 3.71106 18.8744 4.04117C18.8776 4.24507 18.8841 4.45867 18.8905 4.51692C18.897 4.6302 18.842 4.68522 15.4179 7.90221C14.6962 8.58185 13.8515 9.37801 13.5408 9.67576L12.9744 10.2162L12.6993 10.1321C12.4566 10.0609 12.3659 10.0512 11.9226 10.0479C11.2203 10.0447 10.8384 10.1547 10.3043 10.5075C9.64412 10.9444 9.21368 11.5852 9.05833 12.3555C8.98389 12.718 9.00655 13.375 9.10364 13.7019C9.25252 14.2067 9.59558 14.744 9.98071 15.0773C10.573 15.5854 11.2397 15.8152 12.0358 15.7796C13.2042 15.7311 14.1783 15.0353 14.6152 13.9413C14.8612 13.3297 14.8644 12.5367 14.625 11.8765L14.544 11.6629L15.1363 11.0707C18.9391 7.25493 20.2175 5.9992 20.2822 6.00244C20.321 6.00567 20.4311 6.02509 20.5282 6.04451C21.2272 6.19015 22.0396 5.90211 22.4927 5.34221C23.2144 4.45867 23.072 3.12203 22.182 2.43915C21.7192 2.08315 21.0428 1.92132 20.5152 2.03784Z" fill={color} />
|
||||
<path d="M10.822 3.18312C7.54673 3.55854 4.74076 5.46155 3.12903 8.40992C2.32964 9.86954 1.90243 11.8308 2.01894 13.5202C2.17105 15.8116 2.99634 17.7793 4.52392 19.5043C6.00296 21.1775 8.1843 22.3653 10.3559 22.6792C11.3398 22.8249 12.0971 22.8378 13.0875 22.7278C15.2041 22.4915 17.2171 21.5336 18.7803 20.0157C19.994 18.8344 20.8451 17.4751 21.35 15.9022C22.2433 13.1189 21.8128 10.0411 20.1817 7.57492C20.0846 7.42928 19.9972 7.30954 19.9842 7.30954C19.9713 7.30954 19.8645 7.40987 19.7415 7.53285L19.5214 7.75292L19.6703 7.98271C20.392 9.11222 20.8451 10.3129 21.0652 11.6949C21.1364 12.1415 21.1655 13.3131 21.117 13.8018C20.8225 16.6951 19.2334 19.226 16.7284 20.7827C15.6086 21.4785 14.2364 21.9543 12.9095 22.1064C12.4596 22.1582 11.3689 22.1549 10.9029 22.1032C9.17788 21.909 7.56615 21.2455 6.16802 20.1484C4.44301 18.7988 3.20023 16.7566 2.8151 14.6432C2.70506 14.0445 2.66622 13.6011 2.66622 12.9279C2.66622 11.4262 2.9931 10.1026 3.70187 8.74003C4.59512 7.0215 6.05474 5.60395 7.8283 4.73659C9.11639 4.10549 10.2362 3.82716 11.6634 3.79156C12.8836 3.7592 13.848 3.90807 14.984 4.30291C15.4403 4.4615 16.2753 4.85958 16.7252 5.13144L17.0488 5.32886L17.2851 5.11525C17.4145 4.9955 17.5213 4.89194 17.5213 4.88223C17.5213 4.83692 16.7737 4.40001 16.2753 4.15404C15.3206 3.68476 14.3723 3.38701 13.3302 3.22843C12.7347 3.13781 11.4207 3.11515 10.822 3.18312Z" fill={color} />
|
||||
<path d="M10.7153 4.75567C9.63757 4.91749 8.63105 5.27349 7.64718 5.83986C7.23939 6.07612 7.24263 6.04052 7.58245 6.48714C7.74427 6.70075 8.13264 7.2121 8.44981 7.6296C8.76697 8.04709 9.04854 8.39986 9.07443 8.41928C9.11003 8.4387 9.178 8.41604 9.3204 8.3319C9.76055 8.07622 10.5114 7.82055 11.081 7.73316C11.2137 7.71051 11.3723 7.68785 11.4305 7.67814L11.5341 7.66196V6.225C11.5341 5.43531 11.5244 4.76861 11.5147 4.73948C11.4888 4.67152 11.2493 4.67476 10.7153 4.75567Z" fill={color} />
|
||||
<path d="M12.2848 4.75339C12.2362 4.8343 12.2265 7.61761 12.2783 7.64997C12.2945 7.65968 12.4433 7.68881 12.6084 7.7147C13.0162 7.77619 13.466 7.89594 13.8026 8.02863L14.0842 8.13867L14.2557 7.97685C14.4337 7.81179 15.3788 6.91531 16.0002 6.32305C16.1879 6.14504 16.3432 5.9897 16.3497 5.97999C16.3626 5.9541 15.9969 5.73726 15.615 5.54954C14.7606 5.1191 13.9256 4.86666 12.9482 4.73721C12.4336 4.67248 12.3365 4.67248 12.2848 4.75339Z" fill={color} />
|
||||
<path d="M6.26832 6.85656C5.49159 7.54915 4.89932 8.33236 4.39444 9.34859C4.12906 9.87936 4.09993 9.96998 4.16142 10.0185C4.21968 10.0671 6.83146 10.9668 6.91561 10.9668C6.93826 10.9668 7.02241 10.8438 7.09684 10.6885C7.37518 10.1286 7.88329 9.45216 8.29432 9.09615C8.40112 9.0023 8.49174 8.9052 8.49174 8.88255C8.49174 8.84371 6.95768 6.81125 6.7991 6.63648C6.76349 6.59765 6.702 6.56528 6.66317 6.56528C6.62433 6.56528 6.46251 6.68179 6.26832 6.85656Z" fill={color} />
|
||||
<path d="M17.7577 9.48521L16.6509 10.5953L16.748 10.7798C16.8548 10.9869 16.8807 10.9966 17.104 10.9157C17.1816 10.8866 17.7771 10.6827 18.4277 10.4626C19.0782 10.2393 19.6284 10.0386 19.651 10.016C19.7222 9.94478 19.4342 9.31045 19.0329 8.64375C18.9455 8.49811 18.8711 8.37836 18.8678 8.37836C18.8646 8.37836 18.3662 8.87677 17.7577 9.48521Z" fill={color} />
|
||||
<path d="M3.83459 10.8592C3.49477 12.1312 3.47211 13.4192 3.76986 14.8141C3.81841 15.0439 3.85401 15.1442 3.89284 15.154C3.92197 15.1637 4.56278 14.963 5.3201 14.7073C6.48844 14.3125 6.69557 14.2316 6.69234 14.183C6.6891 14.1474 6.66321 13.9565 6.63408 13.7558C6.55964 13.2445 6.56612 12.6037 6.65026 12.0891C6.68586 11.8593 6.71176 11.6651 6.70528 11.6586C6.67615 11.6295 3.9867 10.7395 3.92844 10.7395C3.88313 10.7395 3.85724 10.7751 3.83459 10.8592Z" fill={color} />
|
||||
<path d="M18.4275 11.2088C17.476 11.5357 17.0876 11.6813 17.0908 11.7137C17.0941 11.7396 17.12 11.8884 17.1491 12.0503C17.2365 12.5454 17.2462 13.1215 17.1815 13.6199C17.0876 14.3125 17.052 14.2284 17.5051 14.3805C17.7187 14.4517 18.3433 14.6621 18.8903 14.8465C19.7479 15.1378 19.8936 15.1766 19.9292 15.1378C19.981 15.086 20.0845 14.6523 20.159 14.1701C20.2431 13.6458 20.2593 12.5357 20.1946 11.9985C20.1331 11.5227 19.9939 10.8431 19.9421 10.7784C19.9227 10.7589 19.8774 10.7428 19.8386 10.7428C19.803 10.746 19.1654 10.9531 18.4275 11.2088Z" fill={color} />
|
||||
<path d="M6.79279 14.9643C6.33645 15.1132 4.18424 15.8511 4.15511 15.8705C4.1001 15.9028 4.11628 15.9643 4.27486 16.3139C4.75385 17.3786 5.49175 18.3852 6.32027 19.1134C6.4918 19.2622 6.61802 19.3496 6.66657 19.3496C6.72159 19.3496 6.8025 19.2719 6.99344 19.0227C7.13261 18.8447 7.44007 18.4467 7.67633 18.1424C7.90935 17.835 8.16826 17.5049 8.2427 17.4078C8.32037 17.3107 8.40775 17.1909 8.44012 17.1391L8.49837 17.0518L8.26212 16.8317C7.81226 16.4174 7.32679 15.7378 7.06465 15.1488C7.00639 15.0193 6.95137 14.9157 6.94166 14.919C6.93195 14.9222 6.86399 14.9416 6.79279 14.9643Z" fill={color} />
|
||||
<path d="M16.6963 15.2446C16.4018 15.8465 15.939 16.4712 15.4923 16.8725C15.3985 16.9566 15.3208 17.0375 15.3208 17.0537C15.3208 17.1055 17.0361 19.3322 17.1008 19.3613C17.1526 19.3839 17.1979 19.3613 17.3338 19.2448C17.5895 19.0247 18.1721 18.4292 18.4051 18.1444C18.8388 17.6201 19.3339 16.7948 19.5767 16.2025C19.6802 15.9501 19.6835 15.9307 19.6317 15.8789C19.6026 15.8498 18.9877 15.6297 18.2659 15.3902C17.5442 15.1475 16.9325 14.9436 16.9066 14.9339C16.8678 14.9177 16.816 14.9954 16.6963 15.2446Z" fill={color} />
|
||||
<path d="M8.80246 17.8237C8.65682 18.0114 8.38173 18.3707 8.19402 18.6199C8.00307 18.8691 7.73445 19.2251 7.58881 19.4128C7.44641 19.6005 7.32666 19.7817 7.32666 19.8206C7.32666 19.9338 8.14871 20.3934 8.88014 20.6847C9.63746 20.9889 10.5598 21.196 11.178 21.2025L11.5178 21.209V19.7364V18.2639L11.3074 18.2412C10.7055 18.1732 10.0808 17.9855 9.48534 17.6845L9.07108 17.4774L8.80246 17.8237Z" fill={color} />
|
||||
<path d="M14.4566 17.6207C13.9258 17.9087 13.1167 18.1741 12.5697 18.2324C12.4532 18.2421 12.3335 18.2615 12.3043 18.268C12.2493 18.2841 12.2461 18.3812 12.2461 19.7049C12.2461 21.006 12.2493 21.1289 12.3043 21.1904C12.3594 21.2584 12.3594 21.2584 12.7963 21.2099C14.0196 21.0675 15.078 20.7114 16.1557 20.0868C16.4146 19.9347 16.4858 19.8797 16.4858 19.8247C16.4858 19.7858 16.3434 19.569 16.1686 19.3457C15.9971 19.1224 15.6087 18.6143 15.3077 18.2194C15.0068 17.8246 14.7511 17.4977 14.7414 17.488C14.7284 17.4815 14.6022 17.5398 14.4566 17.6207Z" 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
92
packages/bruno-app/src/components/OpenAPISpecTab/index.js
Normal file
92
packages/bruno-app/src/components/OpenAPISpecTab/index.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { IconLoader2, IconCloud } from '@tabler/icons';
|
||||
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
|
||||
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
|
||||
|
||||
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,
|
||||
sourceUrl
|
||||
});
|
||||
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(fetchResult.content);
|
||||
setIsRemote(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError(result.error);
|
||||
} else {
|
||||
setSpecContent(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,236 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
IconCheck,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconArrowBackUp,
|
||||
IconExternalLink,
|
||||
IconClock
|
||||
} 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 EndpointItem from '../EndpointChangeSection/EndpointItem';
|
||||
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
|
||||
import useEndpointActions from '../hooks/useEndpointActions';
|
||||
|
||||
const CollectionStatusSection = ({
|
||||
collection,
|
||||
collectionDrift,
|
||||
reloadDrift,
|
||||
specDrift,
|
||||
storedSpec,
|
||||
lastSyncDate,
|
||||
onOpenEndpoint
|
||||
}) => {
|
||||
const {
|
||||
pendingAction, setPendingAction,
|
||||
confirmPendingAction,
|
||||
handleResetEndpoint,
|
||||
handleResetAllModified,
|
||||
handleDeleteEndpoint,
|
||||
handleDeleteAllLocalOnly,
|
||||
handleRevertAllChanges,
|
||||
handleAddMissingEndpoint,
|
||||
handleAddAllMissing
|
||||
} = useEndpointActions(collection, collectionDrift, reloadDrift);
|
||||
|
||||
const spec = storedSpec || specDrift?.newSpec;
|
||||
const hasDrift = !!collectionDrift && (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}
|
||||
{bannerState.version && (
|
||||
<> · <code style={{ fontStyle: 'normal' }} className="checked-text">v{bannerState.version}</code></>
|
||||
)}
|
||||
{bannerState.lastSyncDate && (
|
||||
<span className="checked-text"> · Synced {moment(bannerState.lastSyncDate).fromNow()}</span>
|
||||
)}
|
||||
</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="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>
|
||||
) : (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No changes in collection</h4>
|
||||
<p>The collection matches 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" 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,115 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
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, 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={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const filePath = window.ipcRenderer.getFilePath(file);
|
||||
if (filePath) setSourceUrl(filePath);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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={!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>
|
||||
</form>
|
||||
|
||||
<div className="setup-features">
|
||||
{FEATURES.map((text) => (
|
||||
<div className="setup-feature" key={text}>
|
||||
<IconCheck size={16} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectSpecForm;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import Button from 'ui/Button';
|
||||
import Modal from 'components/Modal';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
|
||||
const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect, onClose }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const isUrl = isValidUrl(sourceUrl);
|
||||
const initialMode = isUrl ? 'url' : 'file';
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [url, setUrl] = useState(isUrl ? (sourceUrl || '') : '');
|
||||
const [filePath, setFilePath] = useState(isUrl ? '' : sourceUrl);
|
||||
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 : isValidUrl(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={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const path = window.ipcRenderer.getFilePath(file);
|
||||
if (path) setFilePath(path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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" 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" 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,132 @@
|
||||
import {
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconUnlink,
|
||||
IconSettings,
|
||||
IconRefresh
|
||||
} from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
|
||||
const OpenAPISyncHeader = ({
|
||||
collection, spec, sourceUrl, onViewSpec,
|
||||
onOpenSettings, onOpenDisconnect,
|
||||
onCheck, isLoading
|
||||
}) => {
|
||||
const sourceIsLocal = !sourceUrl?.startsWith('http');
|
||||
const canCheck = !!sourceUrl?.trim();
|
||||
|
||||
const title = spec?.info?.title || 'Unknown API';
|
||||
const version = spec?.info?.version || '-';
|
||||
|
||||
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>
|
||||
<StatusBadge status="muted" className="spec-version">{version}</StatusBadge>
|
||||
</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}
|
||||
>
|
||||
{sourceUrl}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAPISyncHeader;
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import moment from 'moment';
|
||||
import { IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import Help from 'components/Help';
|
||||
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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'
|
||||
},
|
||||
{
|
||||
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, isLoading, fileNotFound, onOpenSettings }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
|
||||
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
|
||||
const activeError = error || reduxError;
|
||||
|
||||
const version = storedSpec?.info?.version;
|
||||
const endpointCount = countEndpoints(storedSpec);
|
||||
const lastSyncDate = openApiSyncConfig?.lastSyncDate;
|
||||
const groupBy = openApiSyncConfig?.groupBy || 'tags';
|
||||
const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false;
|
||||
const autoCheckInterval = openApiSyncConfig?.autoCheckInterval || 5;
|
||||
|
||||
// Endpoint Summary counts
|
||||
// Total/In Sync: always compare against remote spec
|
||||
// Changed/Conflicts: compare against stored spec in AppData (0 on initial sync)
|
||||
const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec;
|
||||
|
||||
const totalInCollection = remoteDrift
|
||||
? (remoteDrift.inSync?.length || 0) + (remoteDrift.modified?.length || 0) + (remoteDrift.localOnly?.length || 0)
|
||||
: null;
|
||||
|
||||
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(() => {
|
||||
if (activeError) {
|
||||
return {
|
||||
variant: 'danger',
|
||||
title: 'Failed to check for spec updates',
|
||||
subtitle: activeError,
|
||||
buttons: ['open-settings']
|
||||
};
|
||||
}
|
||||
if (isLoading) {
|
||||
return {
|
||||
variant: 'muted',
|
||||
title: 'Checking for updates...',
|
||||
subtitle: null,
|
||||
buttons: []
|
||||
};
|
||||
}
|
||||
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 (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 future changes..',
|
||||
buttons: ['restore']
|
||||
};
|
||||
}
|
||||
if (!hasDriftData) return null;
|
||||
if (hasSpecUpdates && hasCollectionChanges) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'The API spec has new updates 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: 'The API spec has new updates',
|
||||
subtitle: 'New or changed requests are available.',
|
||||
buttons: ['sync']
|
||||
};
|
||||
}
|
||||
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 {
|
||||
// variant: 'success',
|
||||
// title: 'Collection is in sync with the spec',
|
||||
// subtitle: null,
|
||||
// buttons: []
|
||||
// };
|
||||
return null;
|
||||
}, [activeError, isLoading, fileNotFound, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, 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>
|
||||
{bannerState.showBadge && (
|
||||
<StatusBadge status="info" radius="full">{specUpdatesPending} {specUpdatesPending === 1 ? 'spec update' : 'spec updates'}</StatusBadge>
|
||||
)}
|
||||
{bannerState.showChangesBadge && (
|
||||
<StatusBadge status="warning" radius="full">{changedInCollection} {changedInCollection === 1 ? 'collection change' : 'collection changes'}</StatusBadge>
|
||||
)}
|
||||
</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' : 'warning'}
|
||||
onClick={() => onTabSelect('collection-changes')}
|
||||
>
|
||||
View Collection Changes
|
||||
</Button>
|
||||
)}
|
||||
{(bannerState.buttons.includes('sync') || bannerState.buttons.includes('review')) && (
|
||||
<Button size="sm" color="warning" onClick={() => onTabSelect('spec-updates')}>
|
||||
Review and Sync Collection
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('restore') && (
|
||||
<Button size="sm" color="warning" onClick={() => onTabSelect('spec-updates')}>
|
||||
Restore Spec File
|
||||
</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,75 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import Modal from 'components/Modal';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
|
||||
const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
const diffRef = useRef(null);
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
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) return;
|
||||
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;
|
||||
}, [displayedTheme, specDrift?.unifiedDiff]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="xl"
|
||||
title="Spec Diff"
|
||||
hideFooter
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="spec-diff-modal">
|
||||
<div className="spec-diff-badges">
|
||||
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
|
||||
{modifiedCount > 0 && <StatusBadge status="info">Updated: {modifiedCount}</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>
|
||||
<div ref={diffRef}></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-diff-empty">No text diff available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecDiffModal;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
IconCheck,
|
||||
IconRefresh
|
||||
} from '@tabler/icons';
|
||||
import moment from 'moment';
|
||||
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,
|
||||
handleSyncNow, handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
} = useSyncFlow({
|
||||
collection, specDrift, remoteDrift, collectionDrift,
|
||||
sourceUrl, setError, checkForUpdates: onCheck
|
||||
});
|
||||
|
||||
const lastSyncedAt = openApiSyncConfig?.lastSyncDate;
|
||||
|
||||
const bannerState = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return { variant: 'muted', message: 'Checking for updates...', actions: [] };
|
||||
}
|
||||
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: [] };
|
||||
}
|
||||
if (!specDrift) {
|
||||
return null;
|
||||
// TODO: re-enable success banner
|
||||
// if (!lastSyncedAt) return null;
|
||||
// return {
|
||||
// variant: 'success', message: 'Spec is up to date', actions: [],
|
||||
// version: storedSpec?.info?.version,
|
||||
// lastChecked: moment(lastCheckedAt || lastSyncedAt).fromNow()
|
||||
// };
|
||||
}
|
||||
if (specDrift.storedSpecMissing) {
|
||||
if (!lastSyncedAt) {
|
||||
return { variant: 'warning', message: 'Initial sync required — your collection differs from the spec', actions: [] };
|
||||
}
|
||||
if (specDrift.hasRemoteChanges) {
|
||||
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
|
||||
}
|
||||
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
|
||||
}
|
||||
if (specDrift.hasRemoteChanges) {
|
||||
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 {
|
||||
// variant: 'success', message: 'Spec is up to date', actions: [],
|
||||
// version: specDrift.newVersion || storedSpec?.info?.version || specDrift.storedVersion,
|
||||
// lastChecked: lastCheckedAt ? moment(lastCheckedAt).fromNow() : 'just now'
|
||||
// };
|
||||
return null;
|
||||
}, [isLoading, fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]);
|
||||
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.added > 0 && <StatusBadge key="added" status="success" radius="full">{bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added</StatusBadge>}
|
||||
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="info" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</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('quick-sync') && (
|
||||
<Button size="xs" onClick={handleSyncNow}>Restore Spec File</Button>
|
||||
)}
|
||||
{bannerState.actions.includes('open-settings') && (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenSettings}>
|
||||
Update connection settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconRefresh size={40} className="empty-state-icon" />
|
||||
<h4>Last Synced Spec not found in storage</h4>
|
||||
<p>The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.</p>
|
||||
<Button className="mt-4" color="warning" onClick={handleSyncNow} loading={isSyncing}>
|
||||
Restore Spec File
|
||||
</Button>
|
||||
</div>
|
||||
) : remoteDrift && (
|
||||
<div className="mt-5">
|
||||
<SyncReviewPage
|
||||
specDrift={specDrift}
|
||||
remoteDrift={remoteDrift}
|
||||
collectionDrift={collectionDrift}
|
||||
collectionPath={collection.pathname}
|
||||
collectionUid={collection.uid}
|
||||
newSpec={specDrift?.newSpec}
|
||||
isSyncing={isSyncing}
|
||||
onApplySync={handleApplySync}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmModal && (
|
||||
<ConfirmSyncModal
|
||||
groups={confirmGroups}
|
||||
isSyncing={isSyncing}
|
||||
onCancel={cancelConfirmModal}
|
||||
onSync={handleConfirmModalSync}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecStatusSection;
|
||||
2421
packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js
Normal file
2421
packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconArrowRight,
|
||||
IconArrowsDiff,
|
||||
IconInfoCircle,
|
||||
IconRefresh
|
||||
} 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) => {
|
||||
const specAddedEndpoints = remoteDrift.missing || [];
|
||||
const specRemovedEndpoints = remoteDrift.localOnly || [];
|
||||
|
||||
// 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,
|
||||
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),
|
||||
...localUpdatedEndpoints.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, localUpdatedEndpoints, 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,
|
||||
removedIds: [],
|
||||
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">
|
||||
<IconRefresh size={40} className="empty-state-icon" />
|
||||
<h4>No updates from the spec</h4>
|
||||
<p>The collection matches the latest spec. Nothing to 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={hasConflicts}
|
||||
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={false}
|
||||
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={false}
|
||||
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,356 @@
|
||||
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 } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
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;
|
||||
|
||||
// Clear Redux state when the sync tab is closed (unmount)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dispatch(clearCollectionState({ collectionUid: collection.uid }));
|
||||
};
|
||||
}, [collection.uid]);
|
||||
|
||||
// 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 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,
|
||||
brunoConfig: collection.brunoConfig
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
if (result.storedSpec) {
|
||||
setStoredSpec(result.storedSpec);
|
||||
}
|
||||
|
||||
// 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,
|
||||
brunoConfig: collection.brunoConfig,
|
||||
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 () => {
|
||||
if (!sourceUrl.trim()) {
|
||||
setError('Please enter a URL or select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setFileNotFound(false);
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
// Validate the spec first
|
||||
const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
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: sourceUrl.trim(),
|
||||
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,
|
||||
brunoConfig: collection.brunoConfig,
|
||||
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),
|
||||
sourceUrl: sourceUrl.trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
sourceUrl: openApiSyncConfig?.sourceUrl || sourceUrl,
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
// Reload drift — passed to useEndpointActions so it can refresh after actions
|
||||
const reloadDrift = () => loadCollectionDrift({ clear: true });
|
||||
|
||||
// Save connection settings from the modal
|
||||
const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
oldSourceUrl: openApiSyncConfig?.sourceUrl,
|
||||
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,163 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { clearCollectionUpdate, setTabUiState, selectTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
const useSyncFlow = ({
|
||||
collection, specDrift, remoteDrift, collectionDrift,
|
||||
sourceUrl, setError, checkForUpdates
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabUiState = useSelector(selectTabUiState(collection.uid));
|
||||
const viewMode = tabUiState.viewMode || 'tabs';
|
||||
const setViewMode = (mode) => dispatch(setTabUiState({ collectionUid: collection.uid, viewMode: mode }));
|
||||
|
||||
const [pendingSyncMode, setPendingSyncMode] = useState(null);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const performSync = async (selections = { removedIds: [], localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
setShowConfirmModal(false);
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
|
||||
const {
|
||||
removedIds = [], 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: removedIds.length > 0
|
||||
? (specDrift?.removed || []).filter((ep) => removedIds.includes(ep.id))
|
||||
: []
|
||||
};
|
||||
|
||||
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,
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
addNewRequests: mode !== 'spec-only',
|
||||
removeDeletedRequests: removedIds.length > 0 || localOnlyIds.length > 0,
|
||||
diff: filteredDiff,
|
||||
localOnlyToRemove,
|
||||
driftedToReset,
|
||||
mode,
|
||||
endpointDecisions: decisions
|
||||
});
|
||||
|
||||
setViewMode('tabs');
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// View/modal transition handlers
|
||||
const enterReviewMode = () => {
|
||||
setPendingSyncMode('sync');
|
||||
setViewMode('review');
|
||||
};
|
||||
|
||||
const handleGoBackFromReview = () => {
|
||||
setViewMode('tabs');
|
||||
setPendingSyncMode(null);
|
||||
};
|
||||
|
||||
const handleSyncNow = () => {
|
||||
if (!remoteDrift) return;
|
||||
setPendingSyncMode('sync');
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const handleApplySync = (selections) => {
|
||||
const mode = pendingSyncMode || 'sync';
|
||||
setViewMode('tabs');
|
||||
setPendingSyncMode(null);
|
||||
performSync(selections, mode);
|
||||
};
|
||||
|
||||
const cancelConfirmModal = () => {
|
||||
setShowConfirmModal(false);
|
||||
setPendingSyncMode(null);
|
||||
};
|
||||
|
||||
const handleConfirmModalSync = () => {
|
||||
const localOnlyIds = (remoteDrift?.localOnly || []).map((ep) => ep.id);
|
||||
performSync({
|
||||
removedIds: [],
|
||||
localOnlyIds,
|
||||
endpointDecisions: {}
|
||||
}, pendingSyncMode || 'sync');
|
||||
};
|
||||
|
||||
const confirmGroups = useMemo(() => {
|
||||
if (!remoteDrift) return [];
|
||||
const groups = [];
|
||||
if (remoteDrift.missing?.length > 0) {
|
||||
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: remoteDrift.missing });
|
||||
}
|
||||
if (remoteDrift.modified?.length > 0) {
|
||||
groups.push({ label: 'Endpoints to update', type: 'update', endpoints: remoteDrift.modified });
|
||||
}
|
||||
if (remoteDrift.localOnly?.length > 0) {
|
||||
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: remoteDrift.localOnly });
|
||||
}
|
||||
return groups;
|
||||
}, [remoteDrift]);
|
||||
|
||||
return {
|
||||
viewMode, isSyncing, showConfirmModal, confirmGroups,
|
||||
enterReviewMode, handleSyncNow, handleGoBackFromReview,
|
||||
handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
};
|
||||
};
|
||||
|
||||
export default useSyncFlow;
|
||||
220
packages/bruno-app/src/components/OpenAPISyncTab/index.js
Normal file
220
packages/bruno-app/src/components/OpenAPISyncTab/index.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { IconLoader2, IconClock } from '@tabler/icons';
|
||||
import { selectTabUiState } 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 tabUiState = useSelector(selectTabUiState(collection.uid));
|
||||
const viewMode = tabUiState.viewMode || 'tabs';
|
||||
|
||||
const handleViewSpec = () => {
|
||||
dispatch(addTab({
|
||||
uid: uuid(),
|
||||
collectionUid: collection.uid,
|
||||
type: 'openapi-spec'
|
||||
}));
|
||||
};
|
||||
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [showDisconnectModal, setShowDisconnectModal] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
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 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 ${viewMode === 'review' ? ' review-active' : ''}`}>
|
||||
<div className="sync-page max-w-screen-xl">
|
||||
|
||||
{/* Setup form when not configured */}
|
||||
{!isConfigured && (
|
||||
<ConnectSpecForm
|
||||
sourceUrl={sourceUrl}
|
||||
setSourceUrl={setSourceUrl}
|
||||
isLoading={isLoading}
|
||||
onConnect={handleConnect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Configured: spec header + tabs */}
|
||||
{isConfigured && (
|
||||
<>
|
||||
<OpenAPISyncHeader
|
||||
collection={collection}
|
||||
spec={storedSpec || specDrift?.newSpec}
|
||||
sourceUrl={sourceUrl}
|
||||
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}
|
||||
isLoading={isLoading}
|
||||
fileNotFound={fileNotFound}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'collection-changes' && (
|
||||
<div className="sync-tab-content">
|
||||
{isDriftLoading && !collectionDrift && (
|
||||
<div className="state-message">
|
||||
<IconLoader2 size={24} className="animate-spin" />
|
||||
<span>Checking collection status...</span>
|
||||
</div>
|
||||
)}
|
||||
{collectionDrift && !collectionDrift.noStoredSpec ? (
|
||||
<CollectionStatusSection
|
||||
collection={collection}
|
||||
collectionDrift={collectionDrift}
|
||||
reloadDrift={reloadDrift}
|
||||
specDrift={specDrift}
|
||||
storedSpec={storedSpec}
|
||||
lastSyncDate={openApiSyncConfig?.lastSyncDate}
|
||||
onOpenEndpoint={openEndpointInTab}
|
||||
/>
|
||||
) : !isDriftLoading && (
|
||||
<>
|
||||
<div className="spec-update-banner warning">
|
||||
<div className="banner-left">
|
||||
<div className="status-dot warning" />
|
||||
<span className="banner-title">
|
||||
{openApiSyncConfig?.lastSyncDate
|
||||
? 'Last synced spec is required to show collection changes. Restore the latest spec from the source to track future changes..'
|
||||
: 'Collection changes will be available after the initial sync'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconClock size={40} className="empty-state-icon" />
|
||||
<h4>{openApiSyncConfig?.lastSyncDate ? 'Last Synced Spec missing from storage' : 'Waiting for initial sync'}</h4>
|
||||
<p>{openApiSyncConfig?.lastSyncDate
|
||||
? 'Restore the latest spec from the source to track future changes..'
|
||||
: 'Once you sync your collection with the spec, changes will appear here.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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;
|
||||
@@ -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;
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IconFolder,
|
||||
IconUpload
|
||||
} from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -29,6 +30,7 @@ import ActionIcon from 'ui/ActionIcon';
|
||||
import { getRevealInFolderLabel } from 'utils/common/platform';
|
||||
import classNames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -90,10 +92,17 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
};
|
||||
}, [isRenamingWorkspace, handleCancelWorkspaceRename]);
|
||||
|
||||
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;
|
||||
@@ -180,6 +189,14 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const viewOpenApiSync = () => {
|
||||
dispatch(addTab({
|
||||
uid: uuid(),
|
||||
collectionUid: collection.uid,
|
||||
type: 'openapi-sync'
|
||||
}));
|
||||
};
|
||||
|
||||
// Workspace action handlers (only used when isScratchCollection is true)
|
||||
const handleRenameWorkspaceClick = () => {
|
||||
workspaceActionsRef.current?.hide();
|
||||
@@ -434,6 +451,18 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
{/* Right side: Actions (only for regular collections) */}
|
||||
{!isScratchCollection && (
|
||||
<div className="flex flex-grow gap-1 items-center justify-end">
|
||||
<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={16} />
|
||||
{(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>
|
||||
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
|
||||
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
|
||||
<IconRun size={16} strokeWidth={1.5} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
|
||||
const getTabInfo = (type, tabName) => {
|
||||
@@ -85,6 +86,22 @@ 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">OpenAPI</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -206,6 +206,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 +247,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' : ''}`}
|
||||
|
||||
@@ -839,7 +839,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
|
||||
|
||||
@@ -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';
|
||||
@@ -76,6 +77,18 @@ const Collection = ({ collection, searchText }) => {
|
||||
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({
|
||||
@@ -369,6 +382,12 @@ const Collection = ({ collection, searchText }) => {
|
||||
setShowCloneCollectionModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'sync-openapi',
|
||||
leftSection: OpenAPISyncIcon,
|
||||
label: 'OpenAPI',
|
||||
onClick: openOpenAPISyncTab
|
||||
},
|
||||
...(hasCopiedItems
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -95,14 +95,18 @@ 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 [enableCheckForSpecUpdates, setEnableCheckForSpecUpdates] = useState(true);
|
||||
const dropdownTippyRef = useRef();
|
||||
const isOpenApi = format === 'openapi';
|
||||
const isZipImport = format === 'bruno-zip';
|
||||
const isOpenApiFromUrl = isOpenApi && !!sourceUrl && !filePath;
|
||||
const isOpenApiFromFile = isOpenApi && !!filePath && !sourceUrl;
|
||||
const showCheckForSpecUpdatesOption = isOpenApiFromUrl || isOpenApiFromFile;
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
@@ -128,7 +132,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 +291,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 +319,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>
|
||||
|
||||
@@ -385,6 +385,9 @@ const CollectionsSection = () => {
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
sourceUrl={importData.sourceUrl}
|
||||
filePath={importData.filePath}
|
||||
rawContent={importData.rawContent}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
|
||||
@@ -105,6 +105,9 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
sourceUrl={importData.sourceUrl}
|
||||
filePath={importData.filePath}
|
||||
rawContent={importData.rawContent}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './pages/index';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
@@ -6,13 +6,35 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (rootElement) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
const Main = () => {
|
||||
useEffect(() => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.type = 'text/css';
|
||||
link.href = `static/diff2html.min.css`;
|
||||
document.head.appendChild(link);
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.src = `static/diff2Html.js`;
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(link);
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<App />
|
||||
</DndProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
};
|
||||
|
||||
if (rootElement) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(<Main />);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import ConfirmAppClose from './ConfirmAppClose';
|
||||
import useIpcEvents from './useIpcEvents';
|
||||
import useTelemetry from './useTelemetry';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import useOpenAPISyncPolling from './useOpenAPISyncPolling';
|
||||
import { version } from '../../../package.json';
|
||||
|
||||
export const AppContext = React.createContext();
|
||||
@@ -13,6 +14,7 @@ export const AppContext = React.createContext();
|
||||
export const AppProvider = (props) => {
|
||||
useTelemetry({ version });
|
||||
useIpcEvents();
|
||||
useOpenAPISyncPolling();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { checkActiveWorkspaceCollectionsForUpdates } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
|
||||
const POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
const useOpenAPISyncPolling = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Global toggle for pausing all OpenAPI sync polling (defaults to true, not yet wired to any UI)
|
||||
const pollingEnabled = useSelector((state) => state.openapiSync?.pollingEnabled ?? true);
|
||||
const collections = useSelector((state) => state.collections?.collections || []);
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
// Filter to only active workspace collections
|
||||
const activeWorkspaceCollections = useMemo(() => {
|
||||
if (!activeWorkspace) return [];
|
||||
return collections.filter((c) =>
|
||||
activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname))
|
||||
);
|
||||
}, [activeWorkspace, collections]);
|
||||
|
||||
// Derive a stable boolean so polling doesn't restart on every collection mutation
|
||||
const hasSyncableCollections = useMemo(
|
||||
() => activeWorkspaceCollections.some((c) => {
|
||||
const syncConfig = c.brunoConfig?.openapi?.[0];
|
||||
return syncConfig?.sourceUrl && syncConfig.autoCheck !== false;
|
||||
}),
|
||||
[activeWorkspaceCollections]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pollingEnabled || !hasSyncableCollections) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial check after a short delay (to let the app initialize)
|
||||
const initialTimeout = setTimeout(() => {
|
||||
dispatch(checkActiveWorkspaceCollectionsForUpdates());
|
||||
}, 10000); // 10 seconds after app starts
|
||||
|
||||
// Set up polling interval
|
||||
intervalRef.current = setInterval(() => {
|
||||
dispatch(checkActiveWorkspaceCollectionsForUpdates());
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initialTimeout);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [dispatch, pollingEnabled, hasSyncableCollections]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default useOpenAPISyncPolling;
|
||||
@@ -10,6 +10,7 @@ import logsReducer from './slices/logs';
|
||||
import performanceReducer from './slices/performance';
|
||||
import workspacesReducer from './slices/workspaces';
|
||||
import apiSpecReducer from './slices/apiSpec';
|
||||
import openapiSyncReducer from './slices/openapi-sync';
|
||||
import { draftDetectMiddleware } from './middlewares/draft/middleware';
|
||||
import { autosaveMiddleware } from './middlewares/autosave/middleware';
|
||||
|
||||
@@ -32,7 +33,8 @@ export const store = configureStore({
|
||||
logs: logsReducer,
|
||||
performance: performanceReducer,
|
||||
workspaces: workspacesReducer,
|
||||
apiSpec: apiSpecReducer
|
||||
apiSpec: apiSpecReducer,
|
||||
openapiSync: openapiSyncReducer
|
||||
},
|
||||
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware)
|
||||
});
|
||||
|
||||
@@ -2704,7 +2704,10 @@ export const importCollection = (collection, collectionLocation, options = {}) =
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
const isMultiple = Array.isArray(collection);
|
||||
|
||||
const result = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
|
||||
const result = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, {
|
||||
format: options.format || DEFAULT_COLLECTION_FORMAT,
|
||||
rawOpenAPISpec: options.rawOpenAPISpec
|
||||
});
|
||||
const importedPaths = result.success.items;
|
||||
|
||||
if (importedPaths.length > 0 && activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
|
||||
const initialState = {
|
||||
// Map of collectionUid -> { hasUpdates, diff, lastChecked, error }
|
||||
collectionUpdates: {},
|
||||
// Whether App level OpenAPI polling is enabled
|
||||
pollingEnabled: true,
|
||||
// Last poll timestamp
|
||||
lastPollTime: null,
|
||||
// Map of collectionUid -> { activeTab, viewMode, expandedSections, expandedRows }
|
||||
tabUiState: {}
|
||||
};
|
||||
|
||||
export const openapiSyncSlice = createSlice({
|
||||
name: 'openapiSync',
|
||||
initialState,
|
||||
reducers: {
|
||||
setCollectionUpdate: (state, action) => {
|
||||
const { collectionUid, hasUpdates, diff, error } = action.payload;
|
||||
state.collectionUpdates[collectionUid] = {
|
||||
hasUpdates,
|
||||
diff,
|
||||
error,
|
||||
lastChecked: Date.now()
|
||||
};
|
||||
},
|
||||
clearCollectionUpdate: (state, action) => {
|
||||
const { collectionUid } = action.payload;
|
||||
delete state.collectionUpdates[collectionUid];
|
||||
},
|
||||
clearCollectionState: (state, action) => {
|
||||
const { collectionUid } = action.payload;
|
||||
delete state.collectionUpdates[collectionUid];
|
||||
delete state.tabUiState[collectionUid];
|
||||
},
|
||||
setPollingEnabled: (state, action) => {
|
||||
state.pollingEnabled = action.payload;
|
||||
},
|
||||
setLastPollTime: (state, action) => {
|
||||
state.lastPollTime = action.payload;
|
||||
},
|
||||
// UI state reducers
|
||||
setTabUiState: (state, action) => {
|
||||
const { collectionUid, ...uiState } = action.payload;
|
||||
if (!state.tabUiState[collectionUid]) {
|
||||
state.tabUiState[collectionUid] = {};
|
||||
}
|
||||
Object.assign(state.tabUiState[collectionUid], uiState);
|
||||
},
|
||||
toggleSectionExpanded: (state, action) => {
|
||||
const { collectionUid, sectionKey } = action.payload;
|
||||
if (!state.tabUiState[collectionUid]) {
|
||||
state.tabUiState[collectionUid] = {};
|
||||
}
|
||||
if (!state.tabUiState[collectionUid].expandedSections) {
|
||||
state.tabUiState[collectionUid].expandedSections = {};
|
||||
}
|
||||
const current = state.tabUiState[collectionUid].expandedSections[sectionKey];
|
||||
state.tabUiState[collectionUid].expandedSections[sectionKey] = !current;
|
||||
},
|
||||
setSectionExpanded: (state, action) => {
|
||||
const { collectionUid, sectionKey, expanded } = action.payload;
|
||||
if (!state.tabUiState[collectionUid]) {
|
||||
state.tabUiState[collectionUid] = {};
|
||||
}
|
||||
if (!state.tabUiState[collectionUid].expandedSections) {
|
||||
state.tabUiState[collectionUid].expandedSections = {};
|
||||
}
|
||||
state.tabUiState[collectionUid].expandedSections[sectionKey] = expanded;
|
||||
},
|
||||
toggleRowExpanded: (state, action) => {
|
||||
const { collectionUid, rowKey } = action.payload;
|
||||
if (!state.tabUiState[collectionUid]) {
|
||||
state.tabUiState[collectionUid] = {};
|
||||
}
|
||||
if (!state.tabUiState[collectionUid].expandedRows) {
|
||||
state.tabUiState[collectionUid].expandedRows = {};
|
||||
}
|
||||
const current = state.tabUiState[collectionUid].expandedRows[rowKey];
|
||||
state.tabUiState[collectionUid].expandedRows[rowKey] = !current;
|
||||
},
|
||||
setReviewDecision: (state, action) => {
|
||||
const { collectionUid, endpointId, decision } = action.payload;
|
||||
if (!state.tabUiState[collectionUid]) {
|
||||
state.tabUiState[collectionUid] = {};
|
||||
}
|
||||
if (!state.tabUiState[collectionUid].reviewDecisions) {
|
||||
state.tabUiState[collectionUid].reviewDecisions = {};
|
||||
}
|
||||
state.tabUiState[collectionUid].reviewDecisions[endpointId] = decision;
|
||||
},
|
||||
setReviewDecisions: (state, action) => {
|
||||
const { collectionUid, decisions } = action.payload;
|
||||
if (!state.tabUiState[collectionUid]) {
|
||||
state.tabUiState[collectionUid] = {};
|
||||
}
|
||||
// Merge into existing decisions instead of replacing, so decisions
|
||||
// for other change types (e.g., specChanges) are preserved
|
||||
state.tabUiState[collectionUid].reviewDecisions = {
|
||||
...state.tabUiState[collectionUid].reviewDecisions,
|
||||
...decisions
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const {
|
||||
setCollectionUpdate,
|
||||
clearCollectionUpdate,
|
||||
clearCollectionState,
|
||||
setPollingEnabled,
|
||||
setTabUiState,
|
||||
toggleSectionExpanded,
|
||||
setSectionExpanded,
|
||||
toggleRowExpanded,
|
||||
setLastPollTime,
|
||||
setReviewDecision,
|
||||
setReviewDecisions
|
||||
} = openapiSyncSlice.actions;
|
||||
|
||||
// Lightweight thunk for polling — only checks hash, no deep comparison
|
||||
export const checkCollectionForUpdates = (collection) => async (dispatch) => {
|
||||
if (!collection?.brunoConfig?.openapi?.[0]?.sourceUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const syncConfig = collection.brunoConfig.openapi[0];
|
||||
const result = await ipcRenderer.invoke('renderer:check-openapi-updates', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: syncConfig.sourceUrl,
|
||||
storedSpecHash: syncConfig.specHash || null,
|
||||
environmentContext: {
|
||||
activeEnvironmentUid: collection.activeEnvironmentUid,
|
||||
environments: collection.environments,
|
||||
runtimeVariables: collection.runtimeVariables,
|
||||
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
||||
}
|
||||
});
|
||||
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: result.hasUpdates || false,
|
||||
diff: null,
|
||||
error: result.error || null
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[OpenAPI Sync] Error checking for updates:', error);
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: false,
|
||||
diff: null,
|
||||
error: error.message
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Thunk to check active workspace collections for updates (respects per-collection autoCheck and autoCheckInterval)
|
||||
export const checkActiveWorkspaceCollectionsForUpdates = () => async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collections = state.collections?.collections || [];
|
||||
const { workspaces, activeWorkspaceUid } = state.workspaces;
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const now = Date.now();
|
||||
|
||||
// Filter to active workspace collections that have OpenAPI sync configured and auto-check enabled
|
||||
const syncableCollections = collections.filter((c) => {
|
||||
if (!activeWorkspace?.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname))) {
|
||||
return false;
|
||||
}
|
||||
const syncConfig = c.brunoConfig?.openapi?.[0];
|
||||
if (!syncConfig?.sourceUrl) return false;
|
||||
if (syncConfig.autoCheck === false) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const collection of syncableCollections) {
|
||||
const syncConfig = collection.brunoConfig.openapi[0];
|
||||
const intervalMs = (syncConfig.autoCheckInterval || 5) * 60 * 1000;
|
||||
const lastChecked = state.openapiSync?.collectionUpdates?.[collection.uid]?.lastChecked || 0;
|
||||
|
||||
// Only check if enough time has elapsed since last check for this collection
|
||||
if (now - lastChecked >= intervalMs) {
|
||||
await dispatch(checkCollectionForUpdates(collection));
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(setLastPollTime(Date.now()));
|
||||
};
|
||||
|
||||
// Selector to get UI state for a specific collection's sync tab
|
||||
export const selectTabUiState = (collectionUid) => (state) => {
|
||||
return state.openapiSync?.tabUiState?.[collectionUid] || {};
|
||||
};
|
||||
|
||||
export default openapiSyncSlice.reducer;
|
||||
@@ -28,7 +28,9 @@ export const tabsSlice = createSlice({
|
||||
'global-environment-settings',
|
||||
'preferences',
|
||||
'workspaceOverview',
|
||||
'workspaceEnvironments'
|
||||
'workspaceEnvironments',
|
||||
'openapi-sync',
|
||||
'openapi-spec'
|
||||
];
|
||||
|
||||
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { showHomePage } from '../app';
|
||||
import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent } from '../collections/actions';
|
||||
import { removeCollection, addTransientDirectory, updateCollectionMountStatus } from '../collections';
|
||||
import { clearCollectionState } from '../openapi-sync';
|
||||
import { updateGlobalEnvironments } from '../global-environments';
|
||||
import { addTab, focusTab } from '../tabs';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
@@ -170,6 +171,7 @@ export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath
|
||||
|
||||
if (workspaceCollection) {
|
||||
dispatch(removeCollection({ collectionUid: collection.uid }));
|
||||
dispatch(clearCollectionState({ collectionUid: collection.uid }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,6 @@ const StyledWrapper = styled.button`
|
||||
color: ${props.$color};
|
||||
`}
|
||||
|
||||
svg {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
34
packages/bruno-app/src/ui/MethodBadge/StyledWrapper.js
Normal file
34
packages/bruno-app/src/ui/MethodBadge/StyledWrapper.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
const methodColor = (props) => {
|
||||
const method = props.$method;
|
||||
return props.theme.request.methods[method] || props.theme.colors.text.muted;
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
md: css`
|
||||
display: inline-block;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
width: 52px;
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
`,
|
||||
sm: css`
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
};
|
||||
|
||||
const StyledWrapper = styled.span`
|
||||
color: ${methodColor};
|
||||
${(props) => sizeStyles[props.$size] || sizeStyles.md}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
19
packages/bruno-app/src/ui/MethodBadge/index.js
Normal file
19
packages/bruno-app/src/ui/MethodBadge/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const MethodBadge = ({ method, size = 'md', className = '' }) => {
|
||||
const normalizedMethod = method?.toLowerCase() || 'get';
|
||||
const displayText = method?.toUpperCase() || 'GET';
|
||||
|
||||
return (
|
||||
<StyledWrapper
|
||||
$method={normalizedMethod}
|
||||
$size={size}
|
||||
className={className}
|
||||
>
|
||||
{displayText}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default MethodBadge;
|
||||
@@ -33,6 +33,7 @@ const StyledWrapper = styled.div`
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
|
||||
&:focus,
|
||||
&:active,
|
||||
@@ -57,6 +58,20 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
padding: 0 5px;
|
||||
background: ${(props) => props.theme.colors.text.muted}20;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
sup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
120
packages/bruno-app/src/ui/StatusBadge/StyledWrapper.js
Normal file
120
packages/bruno-app/src/ui/StatusBadge/StyledWrapper.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
/**
|
||||
* Resolves status tokens from the theme.
|
||||
*
|
||||
* Each status (info, success, warning, danger) provides three tokens:
|
||||
* - background: light tinted color (15% opacity of intent)
|
||||
* - text: full-intensity intent color
|
||||
* - border: full-intensity intent color (same as text)
|
||||
*
|
||||
* The 'muted' status falls back to surface/muted colors for neutral badges.
|
||||
*
|
||||
* @see packages/bruno-app/src/themes/schema/oss.js — status schema
|
||||
* @see packages/bruno-app/src/themes/light/light.js — light theme tokens
|
||||
*/
|
||||
const getStatusTokens = (theme, status) => {
|
||||
switch (status) {
|
||||
case 'danger':
|
||||
return { background: theme.status.danger.background, text: theme.status.danger.text, border: theme.status.danger.border };
|
||||
case 'warning':
|
||||
return { background: theme.status.warning.background, text: theme.status.warning.text, border: theme.status.warning.border };
|
||||
case 'info':
|
||||
return { background: theme.status.info.background, text: theme.status.info.text, border: theme.status.info.border };
|
||||
case 'success':
|
||||
return { background: theme.status.success.background, text: theme.status.success.text, border: theme.status.success.border };
|
||||
case 'muted':
|
||||
default:
|
||||
return { background: theme.background.surface1, text: theme.colors.text.muted, border: theme.border.border1 };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Variant styles — follows the same pattern as Button (ui/Button/StyledWrapper.js).
|
||||
*
|
||||
* - light: tinted background + colored text (default, most common in codebase)
|
||||
* - filled: solid colored background + contrast text
|
||||
* - outline: transparent background + colored border + colored text
|
||||
* - ghost: no background or border, just colored text
|
||||
*/
|
||||
const getVariantStyles = (props) => {
|
||||
const { theme, $variant, $status } = props;
|
||||
const tokens = getStatusTokens(theme, $status);
|
||||
|
||||
switch ($variant) {
|
||||
case 'filled':
|
||||
return css`
|
||||
background: ${tokens.text};
|
||||
color: ${tokens.background};
|
||||
border: 1px solid ${tokens.text};
|
||||
`;
|
||||
case 'outline':
|
||||
return css`
|
||||
background: transparent;
|
||||
color: ${tokens.text};
|
||||
border: 1px solid ${tokens.border};
|
||||
`;
|
||||
case 'ghost':
|
||||
return css`
|
||||
background: transparent;
|
||||
color: ${tokens.text};
|
||||
border: 1px solid transparent;
|
||||
`;
|
||||
case 'light':
|
||||
default:
|
||||
return css`
|
||||
background: ${tokens.background};
|
||||
color: ${tokens.text};
|
||||
border: 1px solid transparent;
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves border-radius from theme keys or raw CSS values.
|
||||
*
|
||||
* Accepts theme radius keys (sm, base, md, lg, xl), the 'full' alias for pill
|
||||
* shapes (9999px), or any raw CSS value (e.g. '20px').
|
||||
* Defaults to theme.border.radius.sm when no radius is specified.
|
||||
*
|
||||
* @see packages/bruno-app/src/themes/light/light.js — radius: { sm: '4px', base: '6px', md: '8px', lg: '10px', xl: '12px' }
|
||||
*/
|
||||
const resolveRadius = (props) => {
|
||||
const { theme, $radius } = props;
|
||||
if (!$radius) return theme.border.radius.sm;
|
||||
if ($radius === 'full') return '9999px';
|
||||
if (theme.border.radius[$radius]) return theme.border.radius[$radius];
|
||||
return $radius;
|
||||
};
|
||||
|
||||
/**
|
||||
* Size presets — derived from existing badge patterns in the codebase.
|
||||
*
|
||||
* - sm: 10px font, compact padding (matches .conflict-badge, .source-tag, .required-badge)
|
||||
* - md: theme xs font, wider padding (matches .deprecated-tag, .changes-tag, .context-pill)
|
||||
*/
|
||||
const sizeStyles = {
|
||||
sm: css`
|
||||
font-size: 10px;
|
||||
padding: 0.125rem 0.375rem;
|
||||
`,
|
||||
md: css`
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
padding: 0.125rem 0.5rem;
|
||||
`
|
||||
};
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
gap: 3px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
border-radius: ${resolveRadius};
|
||||
${(props) => sizeStyles[props.$size] || sizeStyles.sm}
|
||||
${(props) => getVariantStyles(props)}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
47
packages/bruno-app/src/ui/StatusBadge/index.js
Normal file
47
packages/bruno-app/src/ui/StatusBadge/index.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
/**
|
||||
* StatusBadge — reusable themed badge component.
|
||||
*
|
||||
* Props:
|
||||
* - children: badge text content
|
||||
* - status: theme status key — 'danger' | 'warning' | 'info' | 'success' | 'muted' (default: 'muted')
|
||||
* - variant: visual style — 'light' | 'filled' | 'outline' | 'ghost' (default: 'light')
|
||||
* - size: size preset — 'sm' | 'md' (default: 'sm')
|
||||
* - radius: theme radius key ('sm','base','md','lg','xl') or CSS value (default: theme sm)
|
||||
* - leftSection: ReactNode rendered before children (e.g. icon)
|
||||
* - rightSection: ReactNode rendered after children (e.g. Help tooltip)
|
||||
* - className: passthrough for additional styling
|
||||
*
|
||||
* @example
|
||||
* <StatusBadge status="danger">Error</StatusBadge>
|
||||
* <StatusBadge status="info" variant="outline" radius="xl">v2.1</StatusBadge>
|
||||
* <StatusBadge status="warning" rightSection={<Help icon="info" size={11}>tooltip</Help>}>Conflict</StatusBadge>
|
||||
*/
|
||||
const StatusBadge = ({
|
||||
children,
|
||||
status = 'muted',
|
||||
variant = 'light',
|
||||
size = 'sm',
|
||||
radius,
|
||||
leftSection,
|
||||
rightSection,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<StyledWrapper
|
||||
$status={status}
|
||||
$variant={variant}
|
||||
$size={size}
|
||||
$radius={radius}
|
||||
className={className}
|
||||
>
|
||||
{leftSection}
|
||||
{children}
|
||||
{rightSection}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBadge;
|
||||
@@ -223,10 +223,10 @@ export const fetchAndValidateApiSpecFromUrl = ({ url }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:fetch-api-spec', url)
|
||||
.then((res) => jsyaml.load(res))
|
||||
.then((data) => {
|
||||
.then(async (res) => {
|
||||
const data = await jsyaml.load(res);
|
||||
const specType = getCollectionSpecType(data);
|
||||
resolve({ data, specType: specType });
|
||||
resolve({ data, specType, rawContent: res });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -207,6 +207,14 @@ export interface BrunoConfig {
|
||||
scripts?: {
|
||||
additionalContextRoots?: string[];
|
||||
};
|
||||
openapi?: Array<{
|
||||
sourceUrl: string;
|
||||
groupBy?: 'tags' | 'path';
|
||||
lastSyncDate?: string;
|
||||
specHash?: string;
|
||||
autoCheck?: boolean;
|
||||
autoCheckInterval?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BrunoCollectionRoot {
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.750.0",
|
||||
"archiver": "^7.0.1",
|
||||
"@grpc/grpc-js": "^1.13.2",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@lydell/node-pty": "^1.1.0",
|
||||
@@ -44,6 +43,7 @@
|
||||
"@usebruno/schema": "0.7.0",
|
||||
"about-window": "^1.15.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"aws4-axios": "^3.3.0",
|
||||
"axios": "^1.8.3",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
@@ -51,12 +51,13 @@
|
||||
"chokidar": "^3.5.3",
|
||||
"content-disposition": "^0.5.4",
|
||||
"decomment": "^0.9.5",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"electron-is-dev": "^2.0.0",
|
||||
"electron-notarize": "^1.2.2",
|
||||
"electron-store": "^8.1.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"electron-util": "^0.17.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"form-data": "^4.0.0",
|
||||
"fs-extra": "^10.1.0",
|
||||
"graphql": "^16.6.0",
|
||||
|
||||
@@ -22,7 +22,18 @@ const configSchema = Yup.object({
|
||||
// For BRU format collections
|
||||
version: Yup.string().oneOf(['1']).notRequired(),
|
||||
// For YAML format collections (opencollection)
|
||||
opencollection: Yup.string().notRequired()
|
||||
opencollection: Yup.string().notRequired(),
|
||||
// OpenAPI sync configuration (array, one entry per synced spec)
|
||||
openapi: Yup.array().of(
|
||||
Yup.object({
|
||||
sourceUrl: Yup.string().notRequired(),
|
||||
lastSyncDate: Yup.string().notRequired(),
|
||||
specHash: Yup.string().notRequired(),
|
||||
groupBy: Yup.string().oneOf(['tags', 'path']).notRequired(),
|
||||
autoCheck: Yup.boolean().notRequired(),
|
||||
autoCheckInterval: Yup.number().notRequired()
|
||||
})
|
||||
).notRequired()
|
||||
});
|
||||
|
||||
const readConfigFile = async (pathname) => {
|
||||
@@ -30,7 +41,7 @@ const readConfigFile = async (pathname) => {
|
||||
const jsonData = fs.readFileSync(pathname, 'utf8');
|
||||
return JSON.parse(jsonData);
|
||||
} catch (err) {
|
||||
return Promise.reject(new Error('Unable to parse json in bruno.json'));
|
||||
return Promise.reject(new Error(`Unable to parse json in bruno.json in ${pathname}`));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,7 +49,7 @@ const validateSchema = async (config) => {
|
||||
try {
|
||||
await configSchema.validate(config);
|
||||
} catch (err) {
|
||||
return Promise.reject(new Error('bruno.json format is invalid'));
|
||||
return Promise.reject(new Error('bruno.json format is invalid in ' + config?.name));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ const registerSystemMonitorIpc = require('./ipc/system-monitor');
|
||||
const registerWorkspaceIpc = require('./ipc/workspace');
|
||||
const registerApiSpecIpc = require('./ipc/apiSpec');
|
||||
const registerGitIpc = require('./ipc/git');
|
||||
const registerOpenAPISyncIpc = require('./ipc/openapi-sync');
|
||||
const collectionWatcher = require('./app/collection-watcher');
|
||||
const WorkspaceWatcher = require('./app/workspace-watcher');
|
||||
const ApiSpecWatcher = require('./app/apiSpecsWatcher');
|
||||
@@ -471,6 +472,7 @@ app.on('ready', async () => {
|
||||
registerFilesystemIpc(mainWindow);
|
||||
registerSystemMonitorIpc(mainWindow, systemMonitor);
|
||||
registerGitIpc(mainWindow);
|
||||
registerOpenAPISyncIpc(mainWindow);
|
||||
});
|
||||
|
||||
// Quit the app once all windows are closed
|
||||
|
||||
@@ -74,6 +74,7 @@ const { transformBrunoConfigBeforeSave } = require('../utils/transformBrunoConfi
|
||||
const { REQUEST_TYPES } = require('../utils/constants');
|
||||
const { cancelOAuth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } = require('../utils/oauth2-protocol-handler');
|
||||
const { findUniqueFolderName } = require('../utils/collection-import');
|
||||
const { saveSpecAndUpdateMetadata, cleanupSpecFilesForCollection } = require('./openapi-sync');
|
||||
|
||||
const environmentSecretsStore = new EnvironmentSecretsStore();
|
||||
const collectionSecurityStore = new CollectionSecurityStore();
|
||||
@@ -1129,9 +1130,18 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
console.error('Error removing collection from workspace.yml:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up AppData spec files for this collection
|
||||
try {
|
||||
cleanupSpecFilesForCollection(collectionPath);
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up spec files for removed collection:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:import-collection', async (_, collection, collectionLocation, format = DEFAULT_COLLECTION_FORMAT) => {
|
||||
ipcMain.handle('renderer:import-collection', async (_, collection, collectionLocation, options = {}) => {
|
||||
const format = options.format || DEFAULT_COLLECTION_FORMAT;
|
||||
const rawOpenAPISpec = options.rawOpenAPISpec;
|
||||
let collections = Array.isArray(collection) ? collection : [collection];
|
||||
let completedImports = 0;
|
||||
let failedImports = 0;
|
||||
@@ -1232,6 +1242,15 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
const uid = generateUidBasedOnHash(collectionPath);
|
||||
const brunoConfig = getBrunoJsonConfig(coll);
|
||||
|
||||
// Convert absolute local file paths to collection-relative (git-shareable)
|
||||
if (Array.isArray(brunoConfig.openapi)) {
|
||||
for (const entry of brunoConfig.openapi) {
|
||||
if (entry.sourceUrl && path.isAbsolute(entry.sourceUrl)) {
|
||||
entry.sourceUrl = path.relative(collectionPath, entry.sourceUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (format === 'yml') {
|
||||
brunoConfig.opencollection = '1.0.0';
|
||||
const collectionContent = await stringifyCollection(coll.root, brunoConfig, { format });
|
||||
@@ -1250,6 +1269,15 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
await parseCollectionItems(coll.items, collectionPath);
|
||||
await parseEnvironments(coll.environments, collectionPath);
|
||||
|
||||
// Save OpenAPI spec file for sync support
|
||||
if (rawOpenAPISpec && brunoConfig.openapi?.length) {
|
||||
const importSourceUrl = brunoConfig.openapi[0].sourceUrl;
|
||||
const specContent = typeof rawOpenAPISpec === 'string'
|
||||
? rawOpenAPISpec
|
||||
: JSON.stringify(rawOpenAPISpec, null, 2);
|
||||
await saveSpecAndUpdateMetadata({ collectionPath, specContent, sourceUrl: importSourceUrl });
|
||||
}
|
||||
|
||||
const { size, filesCount } = await getCollectionStats(collectionPath);
|
||||
brunoConfig.size = size;
|
||||
brunoConfig.filesCount = filesCount;
|
||||
|
||||
1730
packages/bruno-electron/src/ipc/openapi-sync.js
Normal file
1730
packages/bruno-electron/src/ipc/openapi-sync.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ const parseCollection = (ymlString: string): ParsedCollection => {
|
||||
}
|
||||
}
|
||||
|
||||
// bruno-specific script extensions
|
||||
// bruno-specific extensions
|
||||
const brunoExtensions = oc.extensions?.bruno as any;
|
||||
if (Array.isArray(brunoExtensions?.scripts?.additionalContextRoots)) {
|
||||
const sanitizedRoots = brunoExtensions.scripts.additionalContextRoots
|
||||
@@ -54,6 +54,16 @@ const parseCollection = (ymlString: string): ParsedCollection => {
|
||||
};
|
||||
}
|
||||
}
|
||||
if (Array.isArray(brunoExtensions?.openapi) && brunoExtensions.openapi.length > 0) {
|
||||
brunoConfig.openapi = brunoExtensions.openapi.map((entry: any) => ({
|
||||
sourceUrl: entry.sourceUrl,
|
||||
groupBy: entry.groupBy,
|
||||
...(entry.lastSyncDate && { lastSyncDate: entry.lastSyncDate }),
|
||||
...(entry.specHash && { specHash: entry.specHash }),
|
||||
autoCheck: entry.autoCheck !== false,
|
||||
autoCheckInterval: entry.autoCheckInterval || 5
|
||||
}));
|
||||
}
|
||||
|
||||
// protobuf
|
||||
if (oc.config?.protobuf) {
|
||||
|
||||
@@ -262,6 +262,21 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
|
||||
};
|
||||
}
|
||||
|
||||
// bruno-specific extensions
|
||||
if (Array.isArray(brunoConfig.openapi) && brunoConfig.openapi.length > 0) {
|
||||
if (!oc.extensions.bruno) {
|
||||
oc.extensions.bruno = {};
|
||||
}
|
||||
(oc.extensions.bruno as any).openapi = brunoConfig.openapi.map((entry: any) => ({
|
||||
sourceUrl: entry.sourceUrl,
|
||||
groupBy: entry.groupBy,
|
||||
...(entry.lastSyncDate && { lastSyncDate: entry.lastSyncDate }),
|
||||
...(entry.specHash && { specHash: entry.specHash }),
|
||||
autoCheck: entry.autoCheck !== false,
|
||||
autoCheckInterval: entry.autoCheckInterval || 5
|
||||
}));
|
||||
}
|
||||
|
||||
return stringifyYml(oc);
|
||||
} catch (error) {
|
||||
console.error('Error stringifying opencollection.yml:', error);
|
||||
|
||||
Reference in New Issue
Block a user