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:
Abhishek S Lal
2026-03-05 02:25:08 +05:30
committed by GitHub
parent fb65edea9e
commit 5944a9cf06
83 changed files with 15073 additions and 141 deletions

1
.gitignore vendored
View File

@@ -50,6 +50,7 @@ bruno.iml
.vscode
.cursor
.claude
.codex
# Playwright
/blob-report/

76
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View 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);
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);
}
}
}
}

View File

@@ -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>
);

View 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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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');
});
});
});

View File

@@ -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;
};

View File

@@ -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' }]);
});
});
});

View File

@@ -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};

View File

@@ -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>
);

View 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;

View 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;

View File

@@ -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;

View File

@@ -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);
}
}
}
`;

View 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;

View File

@@ -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 && (
<> &middot; <code style={{ fontStyle: 'normal' }} className="checked-text">v{bannerState.version}</code></>
)}
{bannerState.lastSyncDate && (
<span className="checked-text"> &middot; 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 && (
<> &middot; <code style={{ fontStyle: 'normal' }} className="checked-text">v{bannerState.version}</code></>
)}
{bannerState.lastChecked && (
<span className="checked-text"> &middot; 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;

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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} />;
}

View File

@@ -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} />

View File

@@ -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>
</>
);
}
}
};

View File

@@ -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' : ''}`}

View File

@@ -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

View File

@@ -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
? [
{

View File

@@ -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 {

View File

@@ -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.');

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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 />);
}

View File

@@ -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(() => {

View File

@@ -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;

View File

@@ -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)
});

View File

@@ -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') {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 }));
}
}

View File

@@ -38,10 +38,6 @@ const StyledWrapper = styled.button`
color: ${props.$color};
`}
svg {
stroke: currentColor;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;

View 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;

View 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;

View File

@@ -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;

View 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;

View 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;

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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));
}
};

View File

@@ -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

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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) {

View File

@@ -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);