diff --git a/.gitignore b/.gitignore index 66cf19215..7b7a88b30 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ bruno.iml .vscode .cursor .claude +.codex # Playwright /blob-report/ diff --git a/package-lock.json b/package-lock.json index 6b6f4c0c1..0c24a8003 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index e7637dd11..e5c95f83f 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -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", diff --git a/packages/bruno-app/public/static/diff2Html.js b/packages/bruno-app/public/static/diff2Html.js new file mode 100644 index 000000000..15acb0d47 --- /dev/null +++ b/packages/bruno-app/public/static/diff2Html.js @@ -0,0 +1,3213 @@ +!(function (e, t) { + 'object' == typeof exports && 'object' == typeof module + ? (module.exports = t()) + : 'function' == typeof define && define.amd + ? define('Diff2Html', [], t) + : 'object' == typeof exports + ? (exports.Diff2Html = t()) + : (e.Diff2Html = t()); +})(this, () => { + return ( + (e = { + 696: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.convertChangesToDMP = function (e) { + for (var t, n, i = [], r = 0; r < e.length; r++) + (n = (t = e[r]).added ? 1 : t.removed ? -1 : 0), i.push([n, t.value]); + return i; + }); + }, + 826: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.convertChangesToXML = function (e) { + for (var t = [], n = 0; n < e.length; n++) { + var i = e[n]; + i.added ? t.push('') : i.removed && t.push(''), + t.push( + i.value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + ), + i.added ? t.push('') : i.removed && t.push(''); + } + return t.join(''); + }); + }, + 976: (e, t, n) => { + 'use strict'; + var i; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffArrays = function (e, t, n) { + return r.diff(e, t, n); + }), + (t.arrayDiff = void 0); + var r = new ((i = n(913)) && i.__esModule ? i : { default: i }).default(); + (t.arrayDiff = r), + (r.tokenize = function (e) { + return e.slice(); + }), + (r.join = r.removeEmpty = + function (e) { + return e; + }); + }, + 913: (e, t) => { + 'use strict'; + function n() {} + function i(e, t, n, i, r) { + for (var s = 0, o = t.length, a = 0, l = 0; s < o; s++) { + var c = t[s]; + if (c.removed) { + if (((c.value = e.join(i.slice(l, l + c.count))), (l += c.count), s && t[s - 1].added)) { + var d = t[s - 1]; + (t[s - 1] = t[s]), (t[s] = d); + } + } else { + if (!c.added && r) { + var f = n.slice(a, a + c.count); + (f = f.map(function (e, t) { + var n = i[l + t]; + return n.length > e.length ? n : e; + })), + (c.value = e.join(f)); + } else c.value = e.join(n.slice(a, a + c.count)); + (a += c.count), c.added || (l += c.count); + } + } + var u = t[o - 1]; + return ( + o > 1 && + 'string' == typeof u.value && + (u.added || u.removed) && + e.equals('', u.value) && + ((t[o - 2].value += u.value), t.pop()), + t + ); + } + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.default = n), + (n.prototype = { + diff: function (e, t) { + var n = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : {}, + r = n.callback; + 'function' == typeof n && ((r = n), (n = {})), (this.options = n); + var s = this; + function o(e) { + return r + ? (setTimeout(function () { + r(void 0, e); + }, 0), + !0) + : e; + } + (e = this.castInput(e)), (t = this.castInput(t)), (e = this.removeEmpty(this.tokenize(e))); + var a = (t = this.removeEmpty(this.tokenize(t))).length, + l = e.length, + c = 1, + d = a + l; + n.maxEditLength && (d = Math.min(d, n.maxEditLength)); + var f = [{ newPos: -1, components: [] }], + u = this.extractCommon(f[0], t, e, 0); + if (f[0].newPos + 1 >= a && u + 1 >= l) return o([{ value: this.join(t), count: t.length }]); + function h() { + for (var n = -1 * c; n <= c; n += 2) { + var r = void 0, + d = f[n - 1], + u = f[n + 1], + h = (u ? u.newPos : 0) - n; + d && (f[n - 1] = void 0); + var p = d && d.newPos + 1 < a, + b = u && 0 <= h && h < l; + if (p || b) { + if ( + (!p || (b && d.newPos < u.newPos) + ? ((r = { newPos: (g = u).newPos, components: g.components.slice(0) }), + s.pushComponent(r.components, void 0, !0)) + : ((r = d).newPos++, s.pushComponent(r.components, !0, void 0)), + (h = s.extractCommon(r, t, e, n)), + r.newPos + 1 >= a && h + 1 >= l) + ) + return o(i(s, r.components, t, e, s.useLongestToken)); + f[n] = r; + } else f[n] = void 0; + } + var g; + c++; + } + if (r) + !(function e() { + setTimeout(function () { + if (c > d) return r(); + h() || e(); + }, 0); + })(); + else + for (; c <= d; ) { + var p = h(); + if (p) return p; + } + }, + pushComponent: function (e, t, n) { + var i = e[e.length - 1]; + i && i.added === t && i.removed === n + ? (e[e.length - 1] = { count: i.count + 1, added: t, removed: n }) + : e.push({ count: 1, added: t, removed: n }); + }, + extractCommon: function (e, t, n, i) { + for ( + var r = t.length, s = n.length, o = e.newPos, a = o - i, l = 0; + o + 1 < r && a + 1 < s && this.equals(t[o + 1], n[a + 1]); + + ) + o++, a++, l++; + return l && e.components.push({ count: l }), (e.newPos = o), a; + }, + equals: function (e, t) { + return this.options.comparator + ? this.options.comparator(e, t) + : e === t || (this.options.ignoreCase && e.toLowerCase() === t.toLowerCase()); + }, + removeEmpty: function (e) { + for (var t = [], n = 0; n < e.length; n++) e[n] && t.push(e[n]); + return t; + }, + castInput: function (e) { + return e; + }, + tokenize: function (e) { + return e.split(''); + }, + join: function (e) { + return e.join(''); + } + }); + }, + 630: (e, t, n) => { + 'use strict'; + var i; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffChars = function (e, t, n) { + return r.diff(e, t, n); + }), + (t.characterDiff = void 0); + var r = new ((i = n(913)) && i.__esModule ? i : { default: i }).default(); + t.characterDiff = r; + }, + 852: (e, t, n) => { + 'use strict'; + var i; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffCss = function (e, t, n) { + return r.diff(e, t, n); + }), + (t.cssDiff = void 0); + var r = new ((i = n(913)) && i.__esModule ? i : { default: i }).default(); + (t.cssDiff = r), + (r.tokenize = function (e) { + return e.split(/([{}:;,]|\s+)/); + }); + }, + 276: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffJson = function (e, t, n) { + return l.diff(e, t, n); + }), + (t.canonicalize = c), + (t.jsonDiff = void 0); + var i, + r = (i = n(913)) && i.__esModule ? i : { default: i }, + s = n(187); + function o(e) { + return ( + (o = + 'function' == typeof Symbol && 'symbol' == typeof Symbol.iterator + ? function (e) { + return typeof e; + } + : function (e) { + return e && 'function' == typeof Symbol && e.constructor === Symbol && e !== Symbol.prototype + ? 'symbol' + : typeof e; + }), + o(e) + ); + } + var a = Object.prototype.toString, + l = new r.default(); + function c(e, t, n, i, r) { + var s, l; + for (t = t || [], n = n || [], i && (e = i(r, e)), s = 0; s < t.length; s += 1) if (t[s] === e) return n[s]; + if ('[object Array]' === a.call(e)) { + for (t.push(e), l = new Array(e.length), n.push(l), s = 0; s < e.length; s += 1) l[s] = c(e[s], t, n, i, r); + return t.pop(), n.pop(), l; + } + if ((e && e.toJSON && (e = e.toJSON()), 'object' === o(e) && null !== e)) { + t.push(e), (l = {}), n.push(l); + var d, + f = []; + for (d in e) e.hasOwnProperty(d) && f.push(d); + for (f.sort(), s = 0; s < f.length; s += 1) l[(d = f[s])] = c(e[d], t, n, i, d); + t.pop(), n.pop(); + } else l = e; + return l; + } + (t.jsonDiff = l), + (l.useLongestToken = !0), + (l.tokenize = s.lineDiff.tokenize), + (l.castInput = function (e) { + var t = this.options, + n = t.undefinedReplacement, + i = t.stringifyReplacer, + r = + void 0 === i + ? function (e, t) { + return void 0 === t ? n : t; + } + : i; + return 'string' == typeof e ? e : JSON.stringify(c(e, null, null, r), r, ' '); + }), + (l.equals = function (e, t) { + return r.default.prototype.equals.call(l, e.replace(/,([\r\n])/g, '$1'), t.replace(/,([\r\n])/g, '$1')); + }); + }, + 187: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffLines = function (e, t, n) { + return o.diff(e, t, n); + }), + (t.diffTrimmedLines = function (e, t, n) { + var i = (0, s.generateOptions)(n, { ignoreWhitespace: !0 }); + return o.diff(e, t, i); + }), + (t.lineDiff = void 0); + var i, + r = (i = n(913)) && i.__esModule ? i : { default: i }, + s = n(9), + o = new r.default(); + (t.lineDiff = o), + (o.tokenize = function (e) { + var t = [], + n = e.split(/(\n|\r\n)/); + n[n.length - 1] || n.pop(); + for (var i = 0; i < n.length; i++) { + var r = n[i]; + i % 2 && !this.options.newlineIsToken + ? (t[t.length - 1] += r) + : (this.options.ignoreWhitespace && (r = r.trim()), t.push(r)); + } + return t; + }); + }, + 146: (e, t, n) => { + 'use strict'; + var i; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffSentences = function (e, t, n) { + return r.diff(e, t, n); + }), + (t.sentenceDiff = void 0); + var r = new ((i = n(913)) && i.__esModule ? i : { default: i }).default(); + (t.sentenceDiff = r), + (r.tokenize = function (e) { + return e.split(/(\S.+?[.!?])(?=\s+|$)/); + }); + }, + 303: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffWords = function (e, t, n) { + return (n = (0, s.generateOptions)(n, { ignoreWhitespace: !0 })), l.diff(e, t, n); + }), + (t.diffWordsWithSpace = function (e, t, n) { + return l.diff(e, t, n); + }), + (t.wordDiff = void 0); + var i, + r = (i = n(913)) && i.__esModule ? i : { default: i }, + s = n(9), + o = /^[A-Za-z\xC0-\u02C6\u02C8-\u02D7\u02DE-\u02FF\u1E00-\u1EFF]+$/, + a = /\S/, + l = new r.default(); + (t.wordDiff = l), + (l.equals = function (e, t) { + return ( + this.options.ignoreCase && ((e = e.toLowerCase()), (t = t.toLowerCase())), + e === t || (this.options.ignoreWhitespace && !a.test(e) && !a.test(t)) + ); + }), + (l.tokenize = function (e) { + for (var t = e.split(/([^\S\r\n]+|[()[\]{}'"\r\n]|\b)/), n = 0; n < t.length - 1; n++) + !t[n + 1] && + t[n + 2] && + o.test(t[n]) && + o.test(t[n + 2]) && + ((t[n] += t[n + 2]), t.splice(n + 1, 2), n--); + return t; + }); + }, + 785: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + Object.defineProperty(t, 'Diff', { + enumerable: !0, + get: function () { + return r.default; + } + }), + Object.defineProperty(t, 'diffChars', { + enumerable: !0, + get: function () { + return s.diffChars; + } + }), + Object.defineProperty(t, 'diffWords', { + enumerable: !0, + get: function () { + return o.diffWords; + } + }), + Object.defineProperty(t, 'diffWordsWithSpace', { + enumerable: !0, + get: function () { + return o.diffWordsWithSpace; + } + }), + Object.defineProperty(t, 'diffLines', { + enumerable: !0, + get: function () { + return a.diffLines; + } + }), + Object.defineProperty(t, 'diffTrimmedLines', { + enumerable: !0, + get: function () { + return a.diffTrimmedLines; + } + }), + Object.defineProperty(t, 'diffSentences', { + enumerable: !0, + get: function () { + return l.diffSentences; + } + }), + Object.defineProperty(t, 'diffCss', { + enumerable: !0, + get: function () { + return c.diffCss; + } + }), + Object.defineProperty(t, 'diffJson', { + enumerable: !0, + get: function () { + return d.diffJson; + } + }), + Object.defineProperty(t, 'canonicalize', { + enumerable: !0, + get: function () { + return d.canonicalize; + } + }), + Object.defineProperty(t, 'diffArrays', { + enumerable: !0, + get: function () { + return f.diffArrays; + } + }), + Object.defineProperty(t, 'applyPatch', { + enumerable: !0, + get: function () { + return u.applyPatch; + } + }), + Object.defineProperty(t, 'applyPatches', { + enumerable: !0, + get: function () { + return u.applyPatches; + } + }), + Object.defineProperty(t, 'parsePatch', { + enumerable: !0, + get: function () { + return h.parsePatch; + } + }), + Object.defineProperty(t, 'merge', { + enumerable: !0, + get: function () { + return p.merge; + } + }), + Object.defineProperty(t, 'structuredPatch', { + enumerable: !0, + get: function () { + return b.structuredPatch; + } + }), + Object.defineProperty(t, 'createTwoFilesPatch', { + enumerable: !0, + get: function () { + return b.createTwoFilesPatch; + } + }), + Object.defineProperty(t, 'createPatch', { + enumerable: !0, + get: function () { + return b.createPatch; + } + }), + Object.defineProperty(t, 'convertChangesToDMP', { + enumerable: !0, + get: function () { + return g.convertChangesToDMP; + } + }), + Object.defineProperty(t, 'convertChangesToXML', { + enumerable: !0, + get: function () { + return m.convertChangesToXML; + } + }); + var i, + r = (i = n(913)) && i.__esModule ? i : { default: i }, + s = n(630), + o = n(303), + a = n(187), + l = n(146), + c = n(852), + d = n(276), + f = n(976), + u = n(690), + h = n(719), + p = n(51), + b = n(286), + g = n(696), + m = n(826); + }, + 690: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.applyPatch = o), + (t.applyPatches = function (e, t) { + 'string' == typeof e && (e = (0, r.parsePatch)(e)); + var n = 0; + !(function i() { + var r = e[n++]; + if (!r) return t.complete(); + t.loadFile(r, function (e, n) { + if (e) return t.complete(e); + var s = o(n, r, t); + t.patched(r, s, function (e) { + if (e) return t.complete(e); + i(); + }); + }); + })(); + }); + var i, + r = n(719), + s = (i = n(169)) && i.__esModule ? i : { default: i }; + function o(e, t) { + var n = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : {}; + if (('string' == typeof t && (t = (0, r.parsePatch)(t)), Array.isArray(t))) { + if (t.length > 1) throw new Error('applyPatch only works with a single input.'); + t = t[0]; + } + var i, + o, + a = e.split(/\r\n|[\n\v\f\r\x85]/), + l = e.match(/\r\n|[\n\v\f\r\x85]/g) || [], + c = t.hunks, + d = + n.compareLine || + function (e, t, n, i) { + return t === i; + }, + f = 0, + u = n.fuzzFactor || 0, + h = 0, + p = 0; + function b(e, t) { + for (var n = 0; n < e.lines.length; n++) { + var i = e.lines[n], + r = i.length > 0 ? i[0] : ' ', + s = i.length > 0 ? i.substr(1) : i; + if (' ' === r || '-' === r) { + if (!d(t + 1, a[t], r, s) && ++f > u) return !1; + t++; + } + } + return !0; + } + for (var g = 0; g < c.length; g++) { + for ( + var m = c[g], v = a.length - m.oldLines, y = 0, w = p + m.oldStart - 1, S = (0, s.default)(w, h, v); + void 0 !== y; + y = S() + ) + if (b(m, w + y)) { + m.offset = p += y; + break; + } + if (void 0 === y) return !1; + h = m.offset + m.oldStart + m.oldLines; + } + for (var L = 0, C = 0; C < c.length; C++) { + var x = c[C], + O = x.oldStart + x.offset + L - 1; + L += x.newLines - x.oldLines; + for (var T = 0; T < x.lines.length; T++) { + var j = x.lines[T], + _ = j.length > 0 ? j[0] : ' ', + N = j.length > 0 ? j.substr(1) : j, + P = x.linedelimiters[T]; + if (' ' === _) O++; + else if ('-' === _) a.splice(O, 1), l.splice(O, 1); + else if ('+' === _) a.splice(O, 0, N), l.splice(O, 0, P), O++; + else if ('\\' === _) { + var E = x.lines[T - 1] ? x.lines[T - 1][0] : null; + '+' === E ? (i = !0) : '-' === E && (o = !0); + } + } + } + if (i) for (; !a[a.length - 1]; ) a.pop(), l.pop(); + else o && (a.push(''), l.push('\n')); + for (var M = 0; M < a.length - 1; M++) a[M] = a[M] + l[M]; + return a.join(''); + } + }, + 286: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.structuredPatch = o), + (t.formatPatch = a), + (t.createTwoFilesPatch = l), + (t.createPatch = function (e, t, n, i, r, s) { + return l(e, e, t, n, i, r, s); + }); + var i = n(187); + function r(e) { + return ( + (function (e) { + if (Array.isArray(e)) return s(e); + })(e) || + (function (e) { + if ('undefined' != typeof Symbol && Symbol.iterator in Object(e)) return Array.from(e); + })(e) || + (function (e, t) { + if (e) { + if ('string' == typeof e) return s(e, t); + var n = Object.prototype.toString.call(e).slice(8, -1); + return ( + 'Object' === n && e.constructor && (n = e.constructor.name), + 'Map' === n || 'Set' === n + ? Array.from(e) + : 'Arguments' === n || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n) + ? s(e, t) + : void 0 + ); + } + })(e) || + (function () { + throw new TypeError( + 'Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.' + ); + })() + ); + } + function s(e, t) { + (null == t || t > e.length) && (t = e.length); + for (var n = 0, i = new Array(t); n < t; n++) i[n] = e[n]; + return i; + } + function o(e, t, n, s, o, a, l) { + l || (l = {}), void 0 === l.context && (l.context = 4); + var c = (0, i.diffLines)(n, s, l); + if (c) { + c.push({ value: '', lines: [] }); + for ( + var d = [], + f = 0, + u = 0, + h = [], + p = 1, + b = 1, + g = function (e) { + var t = c[e], + i = t.lines || t.value.replace(/\n$/, '').split('\n'); + if (((t.lines = i), t.added || t.removed)) { + var o; + if (!f) { + var a = c[e - 1]; + (f = p), + (u = b), + a && + ((h = l.context > 0 ? v(a.lines.slice(-l.context)) : []), (f -= h.length), (u -= h.length)); + } + (o = h).push.apply( + o, + r( + i.map(function (e) { + return (t.added ? '+' : '-') + e; + }) + ) + ), + t.added ? (b += i.length) : (p += i.length); + } else { + if (f) + if (i.length <= 2 * l.context && e < c.length - 2) { + var g; + (g = h).push.apply(g, r(v(i))); + } else { + var m, + y = Math.min(i.length, l.context); + (m = h).push.apply(m, r(v(i.slice(0, y)))); + var w = { oldStart: f, oldLines: p - f + y, newStart: u, newLines: b - u + y, lines: h }; + if (e >= c.length - 2 && i.length <= l.context) { + var S = /\n$/.test(n), + L = /\n$/.test(s), + C = 0 == i.length && h.length > w.oldLines; + !S && C && n.length > 0 && h.splice(w.oldLines, 0, '\\ No newline at end of file'), + ((S || C) && L) || h.push('\\ No newline at end of file'); + } + d.push(w), (f = 0), (u = 0), (h = []); + } + (p += i.length), (b += i.length); + } + }, + m = 0; + m < c.length; + m++ + ) + g(m); + return { oldFileName: e, newFileName: t, oldHeader: o, newHeader: a, hunks: d }; + } + function v(e) { + return e.map(function (e) { + return ' ' + e; + }); + } + } + function a(e) { + var t = []; + e.oldFileName == e.newFileName && t.push('Index: ' + e.oldFileName), + t.push('==================================================================='), + t.push('--- ' + e.oldFileName + (void 0 === e.oldHeader ? '' : '\t' + e.oldHeader)), + t.push('+++ ' + e.newFileName + (void 0 === e.newHeader ? '' : '\t' + e.newHeader)); + for (var n = 0; n < e.hunks.length; n++) { + var i = e.hunks[n]; + 0 === i.oldLines && (i.oldStart -= 1), + 0 === i.newLines && (i.newStart -= 1), + t.push('@@ -' + i.oldStart + ',' + i.oldLines + ' +' + i.newStart + ',' + i.newLines + ' @@'), + t.push.apply(t, i.lines); + } + return t.join('\n') + '\n'; + } + function l(e, t, n, i, r, s, l) { + return a(o(e, t, n, i, r, s, l)); + } + }, + 51: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.calcLineCount = l), + (t.merge = function (e, t, n) { + (e = c(e, n)), (t = c(t, n)); + var i = {}; + (e.index || t.index) && (i.index = e.index || t.index), + (e.newFileName || t.newFileName) && + (d(e) + ? d(t) + ? ((i.oldFileName = f(i, e.oldFileName, t.oldFileName)), + (i.newFileName = f(i, e.newFileName, t.newFileName)), + (i.oldHeader = f(i, e.oldHeader, t.oldHeader)), + (i.newHeader = f(i, e.newHeader, t.newHeader))) + : ((i.oldFileName = e.oldFileName), + (i.newFileName = e.newFileName), + (i.oldHeader = e.oldHeader), + (i.newHeader = e.newHeader)) + : ((i.oldFileName = t.oldFileName || e.oldFileName), + (i.newFileName = t.newFileName || e.newFileName), + (i.oldHeader = t.oldHeader || e.oldHeader), + (i.newHeader = t.newHeader || e.newHeader))), + (i.hunks = []); + for (var r = 0, s = 0, o = 0, a = 0; r < e.hunks.length || s < t.hunks.length; ) { + var l = e.hunks[r] || { oldStart: 1 / 0 }, + b = t.hunks[s] || { oldStart: 1 / 0 }; + if (u(l, b)) i.hunks.push(h(l, o)), r++, (a += l.newLines - l.oldLines); + else if (u(b, l)) i.hunks.push(h(b, a)), s++, (o += b.newLines - b.oldLines); + else { + var g = { + oldStart: Math.min(l.oldStart, b.oldStart), + oldLines: 0, + newStart: Math.min(l.newStart + o, b.oldStart + a), + newLines: 0, + lines: [] + }; + p(g, l.oldStart, l.lines, b.oldStart, b.lines), s++, r++, i.hunks.push(g); + } + } + return i; + }); + var i = n(286), + r = n(719), + s = n(780); + function o(e) { + return ( + (function (e) { + if (Array.isArray(e)) return a(e); + })(e) || + (function (e) { + if ('undefined' != typeof Symbol && Symbol.iterator in Object(e)) return Array.from(e); + })(e) || + (function (e, t) { + if (e) { + if ('string' == typeof e) return a(e, t); + var n = Object.prototype.toString.call(e).slice(8, -1); + return ( + 'Object' === n && e.constructor && (n = e.constructor.name), + 'Map' === n || 'Set' === n + ? Array.from(e) + : 'Arguments' === n || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n) + ? a(e, t) + : void 0 + ); + } + })(e) || + (function () { + throw new TypeError( + 'Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.' + ); + })() + ); + } + function a(e, t) { + (null == t || t > e.length) && (t = e.length); + for (var n = 0, i = new Array(t); n < t; n++) i[n] = e[n]; + return i; + } + function l(e) { + var t = C(e.lines), + n = t.oldLines, + i = t.newLines; + void 0 !== n ? (e.oldLines = n) : delete e.oldLines, void 0 !== i ? (e.newLines = i) : delete e.newLines; + } + function c(e, t) { + if ('string' == typeof e) { + if (/^@@/m.test(e) || /^Index:/m.test(e)) return (0, r.parsePatch)(e)[0]; + if (!t) throw new Error('Must provide a base reference or pass in a patch'); + return (0, i.structuredPatch)(void 0, void 0, t, e); + } + return e; + } + function d(e) { + return e.newFileName && e.newFileName !== e.oldFileName; + } + function f(e, t, n) { + return t === n ? t : ((e.conflict = !0), { mine: t, theirs: n }); + } + function u(e, t) { + return e.oldStart < t.oldStart && e.oldStart + e.oldLines < t.oldStart; + } + function h(e, t) { + return { + oldStart: e.oldStart, + oldLines: e.oldLines, + newStart: e.newStart + t, + newLines: e.newLines, + lines: e.lines + }; + } + function p(e, t, n, i, r) { + var s = { offset: t, lines: n, index: 0 }, + a = { offset: i, lines: r, index: 0 }; + for (v(e, s, a), v(e, a, s); s.index < s.lines.length && a.index < a.lines.length; ) { + var c = s.lines[s.index], + d = a.lines[a.index]; + if (('-' !== c[0] && '+' !== c[0]) || ('-' !== d[0] && '+' !== d[0])) + if ('+' === c[0] && ' ' === d[0]) { + var f; + (f = e.lines).push.apply(f, o(w(s))); + } else if ('+' === d[0] && ' ' === c[0]) { + var u; + (u = e.lines).push.apply(u, o(w(a))); + } else + '-' === c[0] && ' ' === d[0] + ? g(e, s, a) + : '-' === d[0] && ' ' === c[0] + ? g(e, a, s, !0) + : c === d + ? (e.lines.push(c), s.index++, a.index++) + : m(e, w(s), w(a)); + else b(e, s, a); + } + y(e, s), y(e, a), l(e); + } + function b(e, t, n) { + var i = w(t), + r = w(n); + if (S(i) && S(r)) { + var a, l; + if ((0, s.arrayStartsWith)(i, r) && L(n, i, i.length - r.length)) + return void (a = e.lines).push.apply(a, o(i)); + if ((0, s.arrayStartsWith)(r, i) && L(t, r, r.length - i.length)) + return void (l = e.lines).push.apply(l, o(r)); + } else if ((0, s.arrayEqual)(i, r)) { + var c; + return void (c = e.lines).push.apply(c, o(i)); + } + m(e, i, r); + } + function g(e, t, n, i) { + var r, + s = w(t), + a = (function (e, t) { + for (var n = [], i = [], r = 0, s = !1, o = !1; r < t.length && e.index < e.lines.length; ) { + var a = e.lines[e.index], + l = t[r]; + if ('+' === l[0]) break; + if (((s = s || ' ' !== a[0]), i.push(l), r++, '+' === a[0])) + for (o = !0; '+' === a[0]; ) n.push(a), (a = e.lines[++e.index]); + l.substr(1) === a.substr(1) ? (n.push(a), e.index++) : (o = !0); + } + if (('+' === (t[r] || '')[0] && s && (o = !0), o)) return n; + for (; r < t.length; ) i.push(t[r++]); + return { merged: i, changes: n }; + })(n, s); + a.merged ? (r = e.lines).push.apply(r, o(a.merged)) : m(e, i ? a : s, i ? s : a); + } + function m(e, t, n) { + (e.conflict = !0), e.lines.push({ conflict: !0, mine: t, theirs: n }); + } + function v(e, t, n) { + for (; t.offset < n.offset && t.index < t.lines.length; ) { + var i = t.lines[t.index++]; + e.lines.push(i), t.offset++; + } + } + function y(e, t) { + for (; t.index < t.lines.length; ) { + var n = t.lines[t.index++]; + e.lines.push(n); + } + } + function w(e) { + for (var t = [], n = e.lines[e.index][0]; e.index < e.lines.length; ) { + var i = e.lines[e.index]; + if (('-' === n && '+' === i[0] && (n = '+'), n !== i[0])) break; + t.push(i), e.index++; + } + return t; + } + function S(e) { + return e.reduce(function (e, t) { + return e && '-' === t[0]; + }, !0); + } + function L(e, t, n) { + for (var i = 0; i < n; i++) { + var r = t[t.length - n + i].substr(1); + if (e.lines[e.index + i] !== ' ' + r) return !1; + } + return (e.index += n), !0; + } + function C(e) { + var t = 0, + n = 0; + return ( + e.forEach(function (e) { + if ('string' != typeof e) { + var i = C(e.mine), + r = C(e.theirs); + void 0 !== t && (i.oldLines === r.oldLines ? (t += i.oldLines) : (t = void 0)), + void 0 !== n && (i.newLines === r.newLines ? (n += i.newLines) : (n = void 0)); + } else void 0 === n || ('+' !== e[0] && ' ' !== e[0]) || n++, void 0 === t || ('-' !== e[0] && ' ' !== e[0]) || t++; + }), + { oldLines: t, newLines: n } + ); + } + }, + 719: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.parsePatch = function (e) { + var t = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {}, + n = e.split(/\r\n|[\n\v\f\r\x85]/), + i = e.match(/\r\n|[\n\v\f\r\x85]/g) || [], + r = [], + s = 0; + function o() { + var e = {}; + for (r.push(e); s < n.length; ) { + var i = n[s]; + if (/^(\-\-\-|\+\+\+|@@)\s/.test(i)) break; + var o = /^(?:Index:|diff(?: -r \w+)+)\s+(.+?)\s*$/.exec(i); + o && (e.index = o[1]), s++; + } + for (a(e), a(e), e.hunks = []; s < n.length; ) { + var c = n[s]; + if (/^(Index:|diff|\-\-\-|\+\+\+)\s/.test(c)) break; + if (/^@@/.test(c)) e.hunks.push(l()); + else { + if (c && t.strict) throw new Error('Unknown line ' + (s + 1) + ' ' + JSON.stringify(c)); + s++; + } + } + } + function a(e) { + var t = /^(---|\+\+\+)\s+(.*)$/.exec(n[s]); + if (t) { + var i = '---' === t[1] ? 'old' : 'new', + r = t[2].split('\t', 2), + o = r[0].replace(/\\\\/g, '\\'); + /^".*"$/.test(o) && (o = o.substr(1, o.length - 2)), + (e[i + 'FileName'] = o), + (e[i + 'Header'] = (r[1] || '').trim()), + s++; + } + } + function l() { + var e = s, + r = n[s++].split(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/), + o = { + oldStart: +r[1], + oldLines: void 0 === r[2] ? 1 : +r[2], + newStart: +r[3], + newLines: void 0 === r[4] ? 1 : +r[4], + lines: [], + linedelimiters: [] + }; + 0 === o.oldLines && (o.oldStart += 1), 0 === o.newLines && (o.newStart += 1); + for ( + var a = 0, l = 0; + s < n.length && + !( + 0 === n[s].indexOf('--- ') && + s + 2 < n.length && + 0 === n[s + 1].indexOf('+++ ') && + 0 === n[s + 2].indexOf('@@') + ); + s++ + ) { + var c = 0 == n[s].length && s != n.length - 1 ? ' ' : n[s][0]; + if ('+' !== c && '-' !== c && ' ' !== c && '\\' !== c) break; + o.lines.push(n[s]), + o.linedelimiters.push(i[s] || '\n'), + '+' === c ? a++ : '-' === c ? l++ : ' ' === c && (a++, l++); + } + if ((a || 1 !== o.newLines || (o.newLines = 0), l || 1 !== o.oldLines || (o.oldLines = 0), t.strict)) { + if (a !== o.newLines) throw new Error('Added line count did not match for hunk at line ' + (e + 1)); + if (l !== o.oldLines) throw new Error('Removed line count did not match for hunk at line ' + (e + 1)); + } + return o; + } + for (; s < n.length; ) o(); + return r; + }); + }, + 780: (e, t) => { + 'use strict'; + function n(e, t) { + if (t.length > e.length) return !1; + for (var n = 0; n < t.length; n++) if (t[n] !== e[n]) return !1; + return !0; + } + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.arrayEqual = function (e, t) { + return e.length === t.length && n(e, t); + }), + (t.arrayStartsWith = n); + }, + 169: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.default = function (e, t, n) { + var i = !0, + r = !1, + s = !1, + o = 1; + return function a() { + if (i && !s) { + if ((r ? o++ : (i = !1), e + o <= n)) return o; + s = !0; + } + if (!r) return s || (i = !0), t <= e - o ? -o++ : ((r = !0), a()); + }; + }); + }, + 9: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.generateOptions = function (e, t) { + if ('function' == typeof e) t.callback = e; + else if (e) for (var n in e) e.hasOwnProperty(n) && (t[n] = e[n]); + return t; + }); + }, + 397: (e, t) => { + !(function (e) { + var t = /\S/, + n = /\"/g, + i = /\n/g, + r = /\r/g, + s = /\\/g, + o = /\u2028/, + a = /\u2029/; + function l(e) { + return e.trim ? e.trim() : e.replace(/^\s*|\s*$/g, ''); + } + function c(e, t, n) { + if (t.charAt(n) != e.charAt(0)) return !1; + for (var i = 1, r = e.length; i < r; i++) if (t.charAt(n + i) != e.charAt(i)) return !1; + return !0; + } + (e.tags = { '#': 1, '^': 2, '<': 3, $: 4, '/': 5, '!': 6, '>': 7, '=': 8, _v: 9, '{': 10, '&': 11, _t: 12 }), + (e.scan = function (n, i) { + var r, + s = n.length, + o = 0, + a = null, + d = null, + f = '', + u = [], + h = !1, + p = 0, + b = 0, + g = '{{', + m = '}}'; + function v() { + f.length > 0 && (u.push({ tag: '_t', text: new String(f) }), (f = '')); + } + function y(n, i) { + if ( + (v(), + n && + (function () { + for (var n = !0, i = b; i < u.length; i++) + if (!(n = e.tags[u[i].tag] < e.tags._v || ('_t' == u[i].tag && null === u[i].text.match(t)))) + return !1; + return n; + })()) + ) + for (var r, s = b; s < u.length; s++) + u[s].text && ((r = u[s + 1]) && '>' == r.tag && (r.indent = u[s].text.toString()), u.splice(s, 1)); + else i || u.push({ tag: '\n' }); + (h = !1), (b = u.length); + } + function w(e, t) { + var n = '=' + m, + i = e.indexOf(n, t), + r = l(e.substring(e.indexOf('=', t) + 1, i)).split(' '); + return (g = r[0]), (m = r[r.length - 1]), i + n.length - 1; + } + for (i && ((i = i.split(' ')), (g = i[0]), (m = i[1])), p = 0; p < s; p++) + 0 == o + ? c(g, n, p) + ? (--p, v(), (o = 1)) + : '\n' == n.charAt(p) + ? y(h) + : (f += n.charAt(p)) + : 1 == o + ? ((p += g.length - 1), + '=' == (a = (d = e.tags[n.charAt(p + 1)]) ? n.charAt(p + 1) : '_v') + ? ((p = w(n, p)), (o = 0)) + : (d && p++, (o = 2)), + (h = p)) + : c(m, n, p) + ? (u.push({ tag: a, n: l(f), otag: g, ctag: m, i: '/' == a ? h - g.length : p + m.length }), + (f = ''), + (p += m.length - 1), + (o = 0), + '{' == a && + ('}}' == m + ? p++ + : '}' === (r = u[u.length - 1]).n.substr(r.n.length - 1) && + (r.n = r.n.substring(0, r.n.length - 1)))) + : (f += n.charAt(p)); + return y(h, !0), u; + }); + var d = { _t: !0, '\n': !0, $: !0, '/': !0 }; + function f(t, n, i, r) { + var s, + o = [], + a = null, + l = null; + for (s = i[i.length - 1]; t.length > 0; ) { + if (((l = t.shift()), s && '<' == s.tag && !(l.tag in d))) + throw new Error('Illegal content in < super tag.'); + if (e.tags[l.tag] <= e.tags.$ || u(l, r)) i.push(l), (l.nodes = f(t, l.tag, i, r)); + else { + if ('/' == l.tag) { + if (0 === i.length) throw new Error('Closing tag without opener: /' + l.n); + if (((a = i.pop()), l.n != a.n && !h(l.n, a.n, r))) + throw new Error('Nesting error: ' + a.n + ' vs. ' + l.n); + return (a.end = l.i), o; + } + '\n' == l.tag && (l.last = 0 == t.length || '\n' == t[0].tag); + } + o.push(l); + } + if (i.length > 0) throw new Error('missing closing tag: ' + i.pop().n); + return o; + } + function u(e, t) { + for (var n = 0, i = t.length; n < i; n++) if (t[n].o == e.n) return (e.tag = '#'), !0; + } + function h(e, t, n) { + for (var i = 0, r = n.length; i < r; i++) if (n[i].c == e && n[i].o == t) return !0; + } + function p(e) { + var t = []; + for (var n in e.partials) + t.push('"' + g(n) + '":{name:"' + g(e.partials[n].name) + '", ' + p(e.partials[n]) + '}'); + return ( + 'partials: {' + + t.join(',') + + '}, subs: ' + + (function (e) { + var t = []; + for (var n in e) t.push('"' + g(n) + '": function(c,p,t,i) {' + e[n] + '}'); + return '{ ' + t.join(',') + ' }'; + })(e.subs) + ); + } + e.stringify = function (t, n, i) { + return '{code: function (c,p,i) { ' + e.wrapMain(t.code) + ' },' + p(t) + '}'; + }; + var b = 0; + function g(e) { + return e + .replace(s, '\\\\') + .replace(n, '\\"') + .replace(i, '\\n') + .replace(r, '\\r') + .replace(o, '\\u2028') + .replace(a, '\\u2029'); + } + function m(e) { + return ~e.indexOf('.') ? 'd' : 'f'; + } + function v(e, t) { + var n = '<' + (t.prefix || '') + e.n + b++; + return ( + (t.partials[n] = { name: e.n, partials: {} }), + (t.code += 't.b(t.rp("' + g(n) + '",c,p,"' + (e.indent || '') + '"));'), + n + ); + } + function y(e, t) { + t.code += 't.b(t.t(t.' + m(e.n) + '("' + g(e.n) + '",c,p,0)));'; + } + function w(e) { + return 't.b(' + e + ');'; + } + (e.generate = function (t, n, i) { + b = 0; + var r = { code: '', subs: {}, partials: {} }; + return e.walk(t, r), i.asString ? this.stringify(r, n, i) : this.makeTemplate(r, n, i); + }), + (e.wrapMain = function (e) { + return 'var t=this;t.b(i=i||"");' + e + 'return t.fl();'; + }), + (e.template = e.Template), + (e.makeTemplate = function (e, t, n) { + var i = this.makePartials(e); + return (i.code = new Function('c', 'p', 'i', this.wrapMain(e.code))), new this.template(i, t, this, n); + }), + (e.makePartials = function (e) { + var t, + n = { subs: {}, partials: e.partials, name: e.name }; + for (t in n.partials) n.partials[t] = this.makePartials(n.partials[t]); + for (t in e.subs) n.subs[t] = new Function('c', 'p', 't', 'i', e.subs[t]); + return n; + }), + (e.codegen = { + '#': function (t, n) { + (n.code += + 'if(t.s(t.' + + m(t.n) + + '("' + + g(t.n) + + '",c,p,1),c,p,0,' + + t.i + + ',' + + t.end + + ',"' + + t.otag + + ' ' + + t.ctag + + '")){t.rs(c,p,function(c,p,t){'), + e.walk(t.nodes, n), + (n.code += '});c.pop();}'); + }, + '^': function (t, n) { + (n.code += 'if(!t.s(t.' + m(t.n) + '("' + g(t.n) + '",c,p,1),c,p,1,0,0,"")){'), + e.walk(t.nodes, n), + (n.code += '};'); + }, + '>': v, + '<': function (t, n) { + var i = { partials: {}, code: '', subs: {}, inPartial: !0 }; + e.walk(t.nodes, i); + var r = n.partials[v(t, n)]; + (r.subs = i.subs), (r.partials = i.partials); + }, + $: function (t, n) { + var i = { subs: {}, code: '', partials: n.partials, prefix: t.n }; + e.walk(t.nodes, i), (n.subs[t.n] = i.code), n.inPartial || (n.code += 't.sub("' + g(t.n) + '",c,p,i);'); + }, + '\n': function (e, t) { + t.code += w('"\\n"' + (e.last ? '' : ' + i')); + }, + _v: function (e, t) { + t.code += 't.b(t.v(t.' + m(e.n) + '("' + g(e.n) + '",c,p,0)));'; + }, + _t: function (e, t) { + t.code += w('"' + g(e.text) + '"'); + }, + '{': y, + '&': y + }), + (e.walk = function (t, n) { + for (var i, r = 0, s = t.length; r < s; r++) (i = e.codegen[t[r].tag]) && i(t[r], n); + return n; + }), + (e.parse = function (e, t, n) { + return f(e, 0, [], (n = n || {}).sectionTags || []); + }), + (e.cache = {}), + (e.cacheKey = function (e, t) { + return [e, !!t.asString, !!t.disableLambda, t.delimiters, !!t.modelGet].join('||'); + }), + (e.compile = function (t, n) { + n = n || {}; + var i = e.cacheKey(t, n), + r = this.cache[i]; + if (r) { + var s = r.partials; + for (var o in s) delete s[o].instance; + return r; + } + return (r = this.generate(this.parse(this.scan(t, n.delimiters), t, n), t, n)), (this.cache[i] = r); + }); + })(t); + }, + 485: (e, t, n) => { + var i = n(397); + (i.Template = n(882).Template), (i.template = i.Template), (e.exports = i); + }, + 882: (e, t) => { + !(function (e) { + function t(e, t, n) { + var i; + return ( + t && + 'object' == typeof t && + (void 0 !== t[e] ? (i = t[e]) : n && t.get && 'function' == typeof t.get && (i = t.get(e))), + i + ); + } + (e.Template = function (e, t, n, i) { + (e = e || {}), + (this.r = e.code || this.r), + (this.c = n), + (this.options = i || {}), + (this.text = t || ''), + (this.partials = e.partials || {}), + (this.subs = e.subs || {}), + (this.buf = ''); + }), + (e.Template.prototype = { + r: function (e, t, n) { + return ''; + }, + v: function (e) { + return ( + (e = l(e)), + a.test(e) + ? e + .replace(n, '&') + .replace(i, '<') + .replace(r, '>') + .replace(s, ''') + .replace(o, '"') + : e + ); + }, + t: l, + render: function (e, t, n) { + return this.ri([e], t || {}, n); + }, + ri: function (e, t, n) { + return this.r(e, t, n); + }, + ep: function (e, t) { + var n = this.partials[e], + i = t[n.name]; + if (n.instance && n.base == i) return n.instance; + if ('string' == typeof i) { + if (!this.c) throw new Error('No compiler available.'); + i = this.c.compile(i, this.options); + } + if (!i) return null; + if (((this.partials[e].base = i), n.subs)) { + for (key in (t.stackText || (t.stackText = {}), n.subs)) + t.stackText[key] || + (t.stackText[key] = + void 0 !== this.activeSub && t.stackText[this.activeSub] + ? t.stackText[this.activeSub] + : this.text); + i = (function (e, t, n, i, r, s) { + function o() {} + function a() {} + var l; + (o.prototype = e), (a.prototype = e.subs); + var c = new o(); + for (l in ((c.subs = new a()), + (c.subsText = {}), + (c.buf = ''), + (i = i || {}), + (c.stackSubs = i), + (c.subsText = s), + t)) + i[l] || (i[l] = t[l]); + for (l in i) c.subs[l] = i[l]; + for (l in ((r = r || {}), (c.stackPartials = r), n)) r[l] || (r[l] = n[l]); + for (l in r) c.partials[l] = r[l]; + return c; + })(i, n.subs, n.partials, this.stackSubs, this.stackPartials, t.stackText); + } + return (this.partials[e].instance = i), i; + }, + rp: function (e, t, n, i) { + var r = this.ep(e, n); + return r ? r.ri(t, n, i) : ''; + }, + rs: function (e, t, n) { + var i = e[e.length - 1]; + if (c(i)) for (var r = 0; r < i.length; r++) e.push(i[r]), n(e, t, this), e.pop(); + else n(e, t, this); + }, + s: function (e, t, n, i, r, s, o) { + var a; + return ( + (!c(e) || 0 !== e.length) && + ('function' == typeof e && (e = this.ms(e, t, n, i, r, s, o)), + (a = !!e), + !i && a && t && t.push('object' == typeof e ? e : t[t.length - 1]), + a) + ); + }, + d: function (e, n, i, r) { + var s, + o = e.split('.'), + a = this.f(o[0], n, i, r), + l = this.options.modelGet, + d = null; + if ('.' === e && c(n[n.length - 2])) a = n[n.length - 1]; + else for (var f = 1; f < o.length; f++) void 0 !== (s = t(o[f], a, l)) ? ((d = a), (a = s)) : (a = ''); + return !(r && !a) && (r || 'function' != typeof a || (n.push(d), (a = this.mv(a, n, i)), n.pop()), a); + }, + f: function (e, n, i, r) { + for (var s = !1, o = !1, a = this.options.modelGet, l = n.length - 1; l >= 0; l--) + if (void 0 !== (s = t(e, n[l], a))) { + o = !0; + break; + } + return o ? (r || 'function' != typeof s || (s = this.mv(s, n, i)), s) : !r && ''; + }, + ls: function (e, t, n, i, r) { + var s = this.options.delimiters; + return ( + (this.options.delimiters = r), + this.b(this.ct(l(e.call(t, i)), t, n)), + (this.options.delimiters = s), + !1 + ); + }, + ct: function (e, t, n) { + if (this.options.disableLambda) throw new Error('Lambda features disabled.'); + return this.c.compile(e, this.options).render(t, n); + }, + b: function (e) { + this.buf += e; + }, + fl: function () { + var e = this.buf; + return (this.buf = ''), e; + }, + ms: function (e, t, n, i, r, s, o) { + var a, + l = t[t.length - 1], + c = e.call(l); + return 'function' == typeof c + ? !!i || + ((a = + this.activeSub && this.subsText && this.subsText[this.activeSub] + ? this.subsText[this.activeSub] + : this.text), + this.ls(c, l, n, a.substring(r, s), o)) + : c; + }, + mv: function (e, t, n) { + var i = t[t.length - 1], + r = e.call(i); + return 'function' == typeof r ? this.ct(l(r.call(i)), i, n) : r; + }, + sub: function (e, t, n, i) { + var r = this.subs[e]; + r && ((this.activeSub = e), r(t, n, this, i), (this.activeSub = !1)); + } + }); + var n = /&/g, + i = //g, + s = /\'/g, + o = /\"/g, + a = /[&<>\"\']/; + function l(e) { + return String(null == e ? '' : e); + } + var c = + Array.isArray || + function (e) { + return '[object Array]' === Object.prototype.toString.call(e); + }; + })(t); + }, + 468: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.parse = void 0); + const i = n(699), + r = n(593); + function s(e, t) { + const n = e.split('.'); + return n.length > 1 ? n[n.length - 1] : t; + } + function o(e, t) { + return t.reduce((t, n) => t || e.startsWith(n), !1); + } + const a = ['a/', 'b/', 'i/', 'w/', 'c/', 'o/']; + function l(e, t, n) { + const i = void 0 !== n ? [...a, n] : a, + s = t ? new RegExp(`^${(0, r.escapeForRegExp)(t)} "?(.+?)"?$`) : new RegExp('^"?(.+?)"?$'), + [, o = ''] = s.exec(e) || [], + l = i.find((e) => 0 === o.indexOf(e)); + return (l ? o.slice(l.length) : o).replace( + /\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)? [+-]\d{4}.*$/, + '' + ); + } + t.parse = function (e, t = {}) { + const n = []; + let r = null, + a = null, + c = null, + d = null, + f = null, + u = null, + h = null; + const p = '--- ', + b = '+++ ', + g = '@@', + m = /^old mode (\d{6})/, + v = /^new mode (\d{6})/, + y = /^deleted file mode (\d{6})/, + w = /^new file mode (\d{6})/, + S = /^copy from "?(.+)"?/, + L = /^copy to "?(.+)"?/, + C = /^rename from "?(.+)"?/, + x = /^rename to "?(.+)"?/, + O = /^similarity index (\d+)%/, + T = /^dissimilarity index (\d+)%/, + j = /^index ([\da-z]+)\.\.([\da-z]+)\s*(\d{6})?/, + _ = /^Binary files (.*) and (.*) differ/, + N = /^GIT binary patch/, + P = /^index ([\da-z]+),([\da-z]+)\.\.([\da-z]+)/, + E = /^mode (\d{6}),(\d{6})\.\.(\d{6})/, + M = /^new file mode (\d{6})/, + H = /^deleted file mode (\d{6}),(\d{6})/, + k = e + .replace(/\\ No newline at end of file/g, '') + .replace(/\r\n?/g, '\n') + .split('\n'); + function D() { + null !== a && null !== r && (r.blocks.push(a), (a = null)); + } + function F() { + null !== r && + (r.oldName || null === u || (r.oldName = u), + r.newName || null === h || (r.newName = h), + r.newName && (n.push(r), (r = null))), + (u = null), + (h = null); + } + function I() { + D(), F(), (r = { blocks: [], deletedLines: 0, addedLines: 0 }); + } + function A(e) { + let t; + D(), + null !== r && + ((t = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@.*/.exec(e)) + ? ((r.isCombined = !1), (c = parseInt(t[1], 10)), (f = parseInt(t[2], 10))) + : (t = /^@@@ -(\d+)(?:,\d+)? -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@@.*/.exec(e)) + ? ((r.isCombined = !0), (c = parseInt(t[1], 10)), (d = parseInt(t[2], 10)), (f = parseInt(t[3], 10))) + : (e.startsWith(g) && console.error('Failed to parse lines, starting in 0!'), + (c = 0), + (f = 0), + (r.isCombined = !1))), + (a = { lines: [], oldStartLine: c, oldStartLine2: d, newStartLine: f, header: e }); + } + return ( + k.forEach((e, d) => { + if (!e || e.startsWith('*')) return; + let D; + const F = k[d - 1], + R = k[d + 1], + W = k[d + 2]; + if (e.startsWith('diff --git') || e.startsWith('diff --combined')) { + if ( + (I(), + (D = /^diff --git "?([a-ciow]\/.+)"? "?([a-ciow]\/.+)"?/.exec(e)) && + ((u = l(D[1], void 0, t.dstPrefix)), (h = l(D[2], void 0, t.srcPrefix))), + null === r) + ) + throw new Error('Where is my file !!!'); + return void (r.isGitDiff = !0); + } + if (e.startsWith('Binary files') && !(null == r ? void 0 : r.isGitDiff)) { + if ( + (I(), + (D = /^Binary files "?([a-ciow]\/.+)"? and "?([a-ciow]\/.+)"? differ/.exec(e)) && + ((u = l(D[1], void 0, t.dstPrefix)), (h = l(D[2], void 0, t.srcPrefix))), + null === r) + ) + throw new Error('Where is my file !!!'); + return void (r.isBinary = !0); + } + if ( + ((!r || (!r.isGitDiff && r && e.startsWith(p) && R.startsWith(b) && W.startsWith(g))) && I(), + null == r ? void 0 : r.isTooBig) + ) + return; + if ( + r && + (('number' == typeof t.diffMaxChanges && r.addedLines + r.deletedLines > t.diffMaxChanges) || + ('number' == typeof t.diffMaxLineLength && e.length > t.diffMaxLineLength)) + ) + return ( + (r.isTooBig = !0), + (r.addedLines = 0), + (r.deletedLines = 0), + (r.blocks = []), + (a = null), + void A( + 'function' == typeof t.diffTooBigMessage + ? t.diffTooBigMessage(n.length) + : 'Diff too big to be displayed' + ) + ); + if ((e.startsWith(p) && R.startsWith(b)) || (e.startsWith(b) && F.startsWith(p))) { + if ( + r && + !r.oldName && + e.startsWith('--- ') && + (D = (function (e, t) { + return l(e, '---', t); + })(e, t.srcPrefix)) + ) + return (r.oldName = D), void (r.language = s(r.oldName, r.language)); + if ( + r && + !r.newName && + e.startsWith('+++ ') && + (D = (function (e, t) { + return l(e, '+++', t); + })(e, t.dstPrefix)) + ) + return (r.newName = D), void (r.language = s(r.newName, r.language)); + } + if (r && (e.startsWith(g) || (r.isGitDiff && r.oldName && r.newName && !a))) return void A(e); + if (a && (e.startsWith('+') || e.startsWith('-') || e.startsWith(' '))) + return void (function (e) { + if (null === r || null === a || null === c || null === f) return; + const t = { content: e }, + n = r.isCombined ? ['+ ', ' +', '++'] : ['+'], + s = r.isCombined ? ['- ', ' -', '--'] : ['-']; + o(e, n) + ? (r.addedLines++, (t.type = i.LineType.INSERT), (t.oldNumber = void 0), (t.newNumber = f++)) + : o(e, s) + ? (r.deletedLines++, (t.type = i.LineType.DELETE), (t.oldNumber = c++), (t.newNumber = void 0)) + : ((t.type = i.LineType.CONTEXT), (t.oldNumber = c++), (t.newNumber = f++)), + a.lines.push(t); + })(e); + const B = !(function (e, t) { + let n = t; + for (; n < k.length - 3; ) { + if (e.startsWith('diff')) return !1; + if (k[n].startsWith(p) && k[n + 1].startsWith(b) && k[n + 2].startsWith(g)) return !0; + n++; + } + return !1; + })(e, d); + if (null === r) throw new Error('Where is my file !!!'); + (D = m.exec(e)) + ? (r.oldMode = D[1]) + : (D = v.exec(e)) + ? (r.newMode = D[1]) + : (D = y.exec(e)) + ? ((r.deletedFileMode = D[1]), (r.isDeleted = !0)) + : (D = w.exec(e)) + ? ((r.newFileMode = D[1]), (r.isNew = !0)) + : (D = S.exec(e)) + ? (B && (r.oldName = D[1]), (r.isCopy = !0)) + : (D = L.exec(e)) + ? (B && (r.newName = D[1]), (r.isCopy = !0)) + : (D = C.exec(e)) + ? (B && (r.oldName = D[1]), (r.isRename = !0)) + : (D = x.exec(e)) + ? (B && (r.newName = D[1]), (r.isRename = !0)) + : (D = _.exec(e)) + ? ((r.isBinary = !0), + (r.oldName = l(D[1], void 0, t.srcPrefix)), + (r.newName = l(D[2], void 0, t.dstPrefix)), + A('Binary file')) + : N.test(e) + ? ((r.isBinary = !0), A(e)) + : (D = O.exec(e)) + ? (r.unchangedPercentage = parseInt(D[1], 10)) + : (D = T.exec(e)) + ? (r.changedPercentage = parseInt(D[1], 10)) + : (D = j.exec(e)) + ? ((r.checksumBefore = D[1]), (r.checksumAfter = D[2]), D[3] && (r.mode = D[3])) + : (D = P.exec(e)) + ? ((r.checksumBefore = [D[2], D[3]]), (r.checksumAfter = D[1])) + : (D = E.exec(e)) + ? ((r.oldMode = [D[2], D[3]]), (r.newMode = D[1])) + : (D = M.exec(e)) + ? ((r.newFileMode = D[1]), (r.isNew = !0)) + : (D = H.exec(e)) && ((r.deletedFileMode = D[1]), (r.isDeleted = !0)); + }), + D(), + F(), + n + ); + }; + }, + 979: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.defaultTemplates = void 0); + const o = s(n(485)); + (t.defaultTemplates = {}), + (t.defaultTemplates['file-summary-line'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b('
  • '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(i.rp(''), + i.b(i.v(i.f('fileName', e, t, 0))), + i.b(''), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b(i.v(i.f('addedLines', e, t, 0))), + i.b(''), + i.b('\n' + n), + i.b(' '), + i.b(i.v(i.f('deletedLines', e, t, 0))), + i.b(''), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
  • '), + i.fl() + ); + }, + partials: { ''), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' Files changed ('), + i.b(i.v(i.f('filesNumber', e, t, 0))), + i.b(')'), + i.b('\n' + n), + i.b(' hide'), + i.b('\n' + n), + i.b(' show'), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
      '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('files', e, t, 0))), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['generic-block-header'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.s(i.f('blockHeader', e, t, 1), e, t, 0, 156, 173, '{{ }}') && + (i.rs(e, t, function (e, t, n) { + n.b(n.t(n.f('blockHeader', e, t, 0))); + }), + e.pop()), + i.s(i.f('blockHeader', e, t, 1), e, t, 1, 0, 0, '') || i.b(' '), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['generic-empty-diff'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' File without changes'), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['generic-file-path'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.b('\n' + n), + i.b(i.rp(''), + i.b(i.v(i.f('fileDiffName', e, t, 0))), + i.b(''), + i.b('\n' + n), + i.b(i.rp(''), + i.b('\n' + n), + i.b(''), + i.fl() + ); + }, + partials: { + ''), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('lineNumber', e, t, 0))), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.s(i.f('prefix', e, t, 1), e, t, 0, 162, 238, '{{ }}') && + (i.rs(e, t, function (e, t, i) { + i.b(' '), + i.b(i.t(i.f('prefix', e, t, 0))), + i.b(''), + i.b('\n' + n); + }), + e.pop()), + i.s(i.f('prefix', e, t, 1), e, t, 1, 0, 0, '') || + (i.b('  '), i.b('\n' + n)), + i.s(i.f('content', e, t, 1), e, t, 0, 371, 445, '{{ }}') && + (i.rs(e, t, function (e, t, i) { + i.b(' '), + i.b(i.t(i.f('content', e, t, 0))), + i.b(''), + i.b('\n' + n); + }), + e.pop()), + i.s(i.f('content', e, t, 1), e, t, 1, 0, 0, '') || + (i.b('
    '), i.b('\n' + n)), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['generic-wrapper'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('content', e, t, 0))), + i.b('\n' + n), + i.b('
    '), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['icon-file-added'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b( + ''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['icon-file-changed'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['icon-file-deleted'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['icon-file-renamed'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['icon-file'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b( + ''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['line-by-line-file-diff'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('filePath', e, t, 0))), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('diffs', e, t, 0))), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['line-by-line-numbers'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b('
    '), + i.b(i.v(i.f('oldNumber', e, t, 0))), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b(i.v(i.f('newNumber', e, t, 0))), + i.b('
    '), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['side-by-side-file-diff'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('filePath', e, t, 0))), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.d('diffs.left', e, t, 0))), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.d('diffs.right', e, t, 0))), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['tag-file-added'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return i.b((n = n || '')), i.b('ADDED'), i.fl(); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['tag-file-changed'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), i.b('CHANGED'), i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['tag-file-deleted'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), i.b('DELETED'), i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['tag-file-renamed'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return i.b((n = n || '')), i.b('RENAMED'), i.fl(); + }, + partials: {}, + subs: {} + })); + }, + 834: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }, + o = + (this && this.__importDefault) || + function (e) { + return e && e.__esModule ? e : { default: e }; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.html = t.parse = t.defaultDiff2HtmlConfig = void 0); + const a = s(n(468)), + l = n(479), + c = s(n(378)), + d = s(n(170)), + f = n(699), + u = o(n(63)); + (t.defaultDiff2HtmlConfig = Object.assign( + Object.assign(Object.assign({}, c.defaultLineByLineRendererConfig), d.defaultSideBySideRendererConfig), + { outputFormat: f.OutputFormatType.LINE_BY_LINE, drawFileList: !0 } + )), + (t.parse = function (e, n = {}) { + return a.parse(e, Object.assign(Object.assign({}, t.defaultDiff2HtmlConfig), n)); + }), + (t.html = function (e, n = {}) { + const i = Object.assign(Object.assign({}, t.defaultDiff2HtmlConfig), n), + r = 'string' == typeof e ? a.parse(e, i) : e, + s = new u.default(i), + { colorScheme: o } = i, + f = { colorScheme: o }; + return ( + (i.drawFileList ? new l.FileListRenderer(s, f).render(r) : '') + + ('side-by-side' === i.outputFormat ? new d.default(s, i).render(r) : new c.default(s, i).render(r)) + ); + }); + }, + 479: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.FileListRenderer = t.defaultFileListRendererConfig = void 0); + const o = s(n(741)), + a = 'file-summary'; + (t.defaultFileListRendererConfig = { colorScheme: o.defaultRenderConfig.colorScheme }), + (t.FileListRenderer = class { + constructor(e, n = {}) { + (this.hoganUtils = e), + (this.config = Object.assign(Object.assign({}, t.defaultFileListRendererConfig), n)); + } + render(e) { + const t = e + .map((e) => + this.hoganUtils.render( + a, + 'line', + { + fileHtmlId: o.getHtmlId(e), + oldName: e.oldName, + newName: e.newName, + fileName: o.filenameDiff(e), + deletedLines: '-' + e.deletedLines, + addedLines: '+' + e.addedLines + }, + { fileIcon: this.hoganUtils.template('icon', o.getFileIcon(e)) } + ) + ) + .join('\n'); + return this.hoganUtils.render(a, 'wrapper', { + colorScheme: o.colorSchemeToCss(this.config.colorScheme), + filesNumber: e.length, + files: t + }); + } + }); + }, + 63: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }); + const o = s(n(485)), + a = n(979); + t.default = class { + constructor({ compiledTemplates: e = {}, rawTemplates: t = {} }) { + const n = Object.entries(t).reduce((e, [t, n]) => { + const i = o.compile(n, { asString: !1 }); + return Object.assign(Object.assign({}, e), { [t]: i }); + }, {}); + this.preCompiledTemplates = Object.assign(Object.assign(Object.assign({}, a.defaultTemplates), e), n); + } + static compile(e) { + return o.compile(e, { asString: !1 }); + } + render(e, t, n, i, r) { + const s = this.templateKey(e, t); + try { + return this.preCompiledTemplates[s].render(n, i, r); + } catch (e) { + throw new Error(`Could not find template to render '${s}'`); + } + } + template(e, t) { + return this.preCompiledTemplates[this.templateKey(e, t)]; + } + templateKey(e, t) { + return `${e}-${t}`; + } + }; + }, + 378: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.defaultLineByLineRendererConfig = void 0); + const o = s(n(483)), + a = s(n(741)), + l = n(699); + t.defaultLineByLineRendererConfig = Object.assign(Object.assign({}, a.defaultRenderConfig), { + renderNothingWhenEmpty: !1, + matchingMaxComparisons: 2500, + maxLineSizeInBlockForComparison: 200 + }); + const c = 'generic', + d = 'line-by-line'; + t.default = class { + constructor(e, n = {}) { + (this.hoganUtils = e), + (this.config = Object.assign(Object.assign({}, t.defaultLineByLineRendererConfig), n)); + } + render(e) { + const t = e + .map((e) => { + let t; + return ( + (t = e.blocks.length ? this.generateFileHtml(e) : this.generateEmptyDiff()), + this.makeFileDiffHtml(e, t) + ); + }) + .join('\n'); + return this.hoganUtils.render(c, 'wrapper', { + colorScheme: a.colorSchemeToCss(this.config.colorScheme), + content: t + }); + } + makeFileDiffHtml(e, t) { + if (this.config.renderNothingWhenEmpty && Array.isArray(e.blocks) && 0 === e.blocks.length) return ''; + const n = this.hoganUtils.template(d, 'file-diff'), + i = this.hoganUtils.template(c, 'file-path'), + r = this.hoganUtils.template('icon', 'file'), + s = this.hoganUtils.template('tag', a.getFileIcon(e)); + return n.render({ + file: e, + fileHtmlId: a.getHtmlId(e), + diffs: t, + filePath: i.render({ fileDiffName: a.filenameDiff(e) }, { fileIcon: r, fileTag: s }) + }); + } + generateEmptyDiff() { + return this.hoganUtils.render(c, 'empty-diff', { + contentClass: 'd2h-code-line', + CSSLineClass: a.CSSLineClass + }); + } + generateFileHtml(e) { + const t = o.newMatcherFn(o.newDistanceFn((t) => a.deconstructLine(t.content, e.isCombined).content)); + return e.blocks + .map((n) => { + let i = this.hoganUtils.render(c, 'block-header', { + CSSLineClass: a.CSSLineClass, + blockHeader: e.isTooBig ? n.header : a.escapeForHtml(n.header), + lineClass: 'd2h-code-linenumber', + contentClass: 'd2h-code-line' + }); + return ( + this.applyLineGroupping(n).forEach(([n, r, s]) => { + if (r.length && s.length && !n.length) + this.applyRematchMatching(r, s, t).map(([t, n]) => { + const { left: r, right: s } = this.processChangedLines(e, e.isCombined, t, n); + (i += r), (i += s); + }); + else if (n.length) + n.forEach((t) => { + const { prefix: n, content: r } = a.deconstructLine(t.content, e.isCombined); + i += this.generateSingleLineHtml(e, { + type: a.CSSLineClass.CONTEXT, + prefix: n, + content: r, + oldNumber: t.oldNumber, + newNumber: t.newNumber + }); + }); + else if (r.length || s.length) { + const { left: t, right: n } = this.processChangedLines(e, e.isCombined, r, s); + (i += t), (i += n); + } else console.error('Unknown state reached while processing groups of lines', n, r, s); + }), + i + ); + }) + .join('\n'); + } + applyLineGroupping(e) { + const t = []; + let n = [], + i = []; + for (let r = 0; r < e.lines.length; r++) { + const s = e.lines[r]; + ((s.type !== l.LineType.INSERT && i.length) || (s.type === l.LineType.CONTEXT && n.length > 0)) && + (t.push([[], n, i]), (n = []), (i = [])), + s.type === l.LineType.CONTEXT + ? t.push([[s], [], []]) + : s.type === l.LineType.INSERT && 0 === n.length + ? t.push([[], [], [s]]) + : s.type === l.LineType.INSERT && n.length > 0 + ? i.push(s) + : s.type === l.LineType.DELETE && n.push(s); + } + return (n.length || i.length) && (t.push([[], n, i]), (n = []), (i = [])), t; + } + applyRematchMatching(e, t, n) { + const i = e.length * t.length, + r = Math.max.apply(null, [0].concat(e.concat(t).map((e) => e.content.length))); + return i < this.config.matchingMaxComparisons && + r < this.config.maxLineSizeInBlockForComparison && + ('lines' === this.config.matching || 'words' === this.config.matching) + ? n(e, t) + : [[e, t]]; + } + processChangedLines(e, t, n, i) { + const r = { right: '', left: '' }, + s = Math.max(n.length, i.length); + for (let o = 0; o < s; o++) { + const s = n[o], + l = i[o], + c = void 0 !== s && void 0 !== l ? a.diffHighlight(s.content, l.content, t, this.config) : void 0, + d = + void 0 !== s && void 0 !== s.oldNumber + ? Object.assign( + Object.assign( + {}, + void 0 !== c + ? { + prefix: c.oldLine.prefix, + content: c.oldLine.content, + type: a.CSSLineClass.DELETE_CHANGES + } + : Object.assign(Object.assign({}, a.deconstructLine(s.content, t)), { + type: a.toCSSClass(s.type) + }) + ), + { oldNumber: s.oldNumber, newNumber: s.newNumber } + ) + : void 0, + f = + void 0 !== l && void 0 !== l.newNumber + ? Object.assign( + Object.assign( + {}, + void 0 !== c + ? { + prefix: c.newLine.prefix, + content: c.newLine.content, + type: a.CSSLineClass.INSERT_CHANGES + } + : Object.assign(Object.assign({}, a.deconstructLine(l.content, t)), { + type: a.toCSSClass(l.type) + }) + ), + { oldNumber: l.oldNumber, newNumber: l.newNumber } + ) + : void 0, + { left: u, right: h } = this.generateLineHtml(e, d, f); + (r.left += u), (r.right += h); + } + return r; + } + generateLineHtml(e, t, n) { + return { left: this.generateSingleLineHtml(e, t), right: this.generateSingleLineHtml(e, n) }; + } + generateSingleLineHtml(e, t) { + if (void 0 === t) return ''; + const n = this.hoganUtils.render(d, 'numbers', { + oldNumber: t.oldNumber || '', + newNumber: t.newNumber || '' + }); + return this.hoganUtils.render(c, 'line', { + type: t.type, + lineClass: 'd2h-code-linenumber', + contentClass: 'd2h-code-line', + prefix: ' ' === t.prefix ? ' ' : t.prefix, + content: t.content, + lineNumber: n, + line: t, + file: e + }); + } + }; + }, + 483: (e, t) => { + 'use strict'; + function n(e, t) { + if (0 === e.length) return t.length; + if (0 === t.length) return e.length; + const n = []; + let i, r; + for (i = 0; i <= t.length; i++) n[i] = [i]; + for (r = 0; r <= e.length; r++) n[0][r] = r; + for (i = 1; i <= t.length; i++) + for (r = 1; r <= e.length; r++) + t.charAt(i - 1) === e.charAt(r - 1) + ? (n[i][r] = n[i - 1][r - 1]) + : (n[i][r] = Math.min(n[i - 1][r - 1] + 1, Math.min(n[i][r - 1] + 1, n[i - 1][r] + 1))); + return n[t.length][e.length]; + } + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.newMatcherFn = t.newDistanceFn = t.levenshtein = void 0), + (t.levenshtein = n), + (t.newDistanceFn = function (e) { + return (t, i) => { + const r = e(t).trim(), + s = e(i).trim(); + return n(r, s) / (r.length + s.length); + }; + }), + (t.newMatcherFn = function (e) { + return function t(n, i, r = 0, s = new Map()) { + const o = (function (t, n, i = new Map()) { + let r, + s = 1 / 0; + for (let o = 0; o < t.length; ++o) + for (let a = 0; a < n.length; ++a) { + const l = JSON.stringify([t[o], n[a]]); + let c; + (i.has(l) && (c = i.get(l))) || ((c = e(t[o], n[a])), i.set(l, c)), + c < s && ((s = c), (r = { indexA: o, indexB: a, score: s })); + } + return r; + })(n, i, s); + if (!o || n.length + i.length < 3) return [[n, i]]; + const a = n.slice(0, o.indexA), + l = i.slice(0, o.indexB), + c = [n[o.indexA]], + d = [i[o.indexB]], + f = o.indexA + 1, + u = o.indexB + 1, + h = n.slice(f), + p = i.slice(u), + b = t(a, l, r + 1, s), + g = t(c, d, r + 1, s), + m = t(h, p, r + 1, s); + let v = g; + return ( + (o.indexA > 0 || o.indexB > 0) && (v = b.concat(v)), + (n.length > f || i.length > u) && (v = v.concat(m)), + v + ); + }; + }); + }, + 741: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffHighlight = + t.getFileIcon = + t.getHtmlId = + t.filenameDiff = + t.deconstructLine = + t.escapeForHtml = + t.colorSchemeToCss = + t.toCSSClass = + t.defaultRenderConfig = + t.CSSLineClass = + void 0); + const o = s(n(785)), + a = n(593), + l = s(n(483)), + c = n(699); + (t.CSSLineClass = { + INSERTS: 'd2h-ins', + DELETES: 'd2h-del', + CONTEXT: 'd2h-cntx', + INFO: 'd2h-info', + INSERT_CHANGES: 'd2h-ins d2h-change', + DELETE_CHANGES: 'd2h-del d2h-change' + }), + (t.defaultRenderConfig = { + matching: c.LineMatchingType.NONE, + matchWordsThreshold: 0.25, + maxLineLengthHighlight: 1e4, + diffStyle: c.DiffStyleType.WORD, + colorScheme: c.ColorSchemeType.LIGHT + }); + const d = '/', + f = l.newDistanceFn((e) => e.value), + u = l.newMatcherFn(f); + function h(e) { + return -1 !== e.indexOf('dev/null'); + } + function p(e) { + return e.replace(/(]*>((.|\n)*?)<\/del>)/g, ''); + } + function b(e) { + return e + .slice(0) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); + } + function g(e, t, n = !0) { + const i = (function (e) { + return e ? 2 : 1; + })(t); + return { prefix: e.substring(0, i), content: n ? b(e.substring(i)) : e.substring(i) }; + } + function m(e) { + const t = (0, a.unifyPath)(e.oldName), + n = (0, a.unifyPath)(e.newName); + if (t === n || h(t) || h(n)) return h(n) ? t : n; + { + const e = [], + i = [], + r = t.split(d), + s = n.split(d); + let o = 0, + a = r.length - 1, + l = s.length - 1; + for (; o < a && o < l && r[o] === s[o]; ) e.push(s[o]), (o += 1); + for (; a > o && l > o && r[a] === s[l]; ) i.unshift(s[l]), (a -= 1), (l -= 1); + const c = e.join(d), + f = i.join(d), + u = r.slice(o, a + 1).join(d), + h = s.slice(o, l + 1).join(d); + return c.length && f.length + ? c + d + '{' + u + ' → ' + h + '}' + d + f + : c.length + ? c + d + '{' + u + ' → ' + h + '}' + : f.length + ? '{' + u + ' → ' + h + '}' + d + f + : t + ' → ' + n; + } + } + (t.toCSSClass = function (e) { + switch (e) { + case c.LineType.CONTEXT: + return t.CSSLineClass.CONTEXT; + case c.LineType.INSERT: + return t.CSSLineClass.INSERTS; + case c.LineType.DELETE: + return t.CSSLineClass.DELETES; + } + }), + (t.colorSchemeToCss = function (e) { + switch (e) { + case c.ColorSchemeType.DARK: + return 'd2h-dark-color-scheme'; + case c.ColorSchemeType.AUTO: + return 'd2h-auto-color-scheme'; + case c.ColorSchemeType.LIGHT: + default: + return 'd2h-light-color-scheme'; + } + }), + (t.escapeForHtml = b), + (t.deconstructLine = g), + (t.filenameDiff = m), + (t.getHtmlId = function (e) { + return `d2h-${(0, a.hashCode)(m(e)).toString().slice(-6)}`; + }), + (t.getFileIcon = function (e) { + let t = 'file-changed'; + return ( + e.isRename || e.isCopy + ? (t = 'file-renamed') + : e.isNew + ? (t = 'file-added') + : e.isDeleted + ? (t = 'file-deleted') + : e.newName !== e.oldName && (t = 'file-renamed'), + t + ); + }), + (t.diffHighlight = function (e, n, i, r = {}) { + const { + matching: s, + maxLineLengthHighlight: a, + matchWordsThreshold: l, + diffStyle: c + } = Object.assign(Object.assign({}, t.defaultRenderConfig), r), + d = g(e, i, !1), + h = g(n, i, !1); + if (d.content.length > a || h.content.length > a) + return { + oldLine: { prefix: d.prefix, content: b(d.content) }, + newLine: { prefix: h.prefix, content: b(h.content) } + }; + const m = 'char' === c ? o.diffChars(d.content, h.content) : o.diffWordsWithSpace(d.content, h.content), + v = []; + if ('word' === c && 'words' === s) { + const e = m.filter((e) => e.removed), + t = m.filter((e) => e.added); + u(t, e).forEach((e) => { + 1 === e[0].length && 1 === e[1].length && f(e[0][0], e[1][0]) < l && (v.push(e[0][0]), v.push(e[1][0])); + }); + } + const y = m.reduce((e, t) => { + const n = t.added ? 'ins' : t.removed ? 'del' : null, + i = v.indexOf(t) > -1 ? ' class="d2h-change"' : '', + r = b(t.value); + return null !== n ? `${e}<${n}${i}>${r}` : `${e}${r}`; + }, ''); + return { + oldLine: { prefix: d.prefix, content: ((w = y), w.replace(/(]*>((.|\n)*?)<\/ins>)/g, '')) }, + newLine: { prefix: h.prefix, content: p(y) } + }; + var w; + }); + }, + 170: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.defaultSideBySideRendererConfig = void 0); + const o = s(n(483)), + a = s(n(741)), + l = n(699); + t.defaultSideBySideRendererConfig = Object.assign(Object.assign({}, a.defaultRenderConfig), { + renderNothingWhenEmpty: !1, + matchingMaxComparisons: 2500, + maxLineSizeInBlockForComparison: 200 + }); + const c = 'generic'; + t.default = class { + constructor(e, n = {}) { + (this.hoganUtils = e), + (this.config = Object.assign(Object.assign({}, t.defaultSideBySideRendererConfig), n)); + } + render(e) { + const t = e + .map((e) => { + let t; + return ( + (t = e.blocks.length ? this.generateFileHtml(e) : this.generateEmptyDiff()), + this.makeFileDiffHtml(e, t) + ); + }) + .join('\n'); + return this.hoganUtils.render(c, 'wrapper', { + colorScheme: a.colorSchemeToCss(this.config.colorScheme), + content: t + }); + } + makeFileDiffHtml(e, t) { + if (this.config.renderNothingWhenEmpty && Array.isArray(e.blocks) && 0 === e.blocks.length) return ''; + const n = this.hoganUtils.template('side-by-side', 'file-diff'), + i = this.hoganUtils.template(c, 'file-path'), + r = this.hoganUtils.template('icon', 'file'), + s = this.hoganUtils.template('tag', a.getFileIcon(e)); + return n.render({ + file: e, + fileHtmlId: a.getHtmlId(e), + diffs: t, + filePath: i.render({ fileDiffName: a.filenameDiff(e) }, { fileIcon: r, fileTag: s }) + }); + } + generateEmptyDiff() { + return { + right: '', + left: this.hoganUtils.render(c, 'empty-diff', { + contentClass: 'd2h-code-side-line', + CSSLineClass: a.CSSLineClass + }) + }; + } + generateFileHtml(e) { + const t = o.newMatcherFn(o.newDistanceFn((t) => a.deconstructLine(t.content, e.isCombined).content)); + return e.blocks + .map((n) => { + const i = { left: this.makeHeaderHtml(n.header, e), right: this.makeHeaderHtml('') }; + return ( + this.applyLineGroupping(n).forEach(([n, r, s]) => { + if (r.length && s.length && !n.length) + this.applyRematchMatching(r, s, t).map(([t, n]) => { + const { left: r, right: s } = this.processChangedLines(e.isCombined, t, n); + (i.left += r), (i.right += s); + }); + else if (n.length) + n.forEach((t) => { + const { prefix: n, content: r } = a.deconstructLine(t.content, e.isCombined), + { left: s, right: o } = this.generateLineHtml( + { type: a.CSSLineClass.CONTEXT, prefix: n, content: r, number: t.oldNumber }, + { type: a.CSSLineClass.CONTEXT, prefix: n, content: r, number: t.newNumber } + ); + (i.left += s), (i.right += o); + }); + else if (r.length || s.length) { + const { left: t, right: n } = this.processChangedLines(e.isCombined, r, s); + (i.left += t), (i.right += n); + } else console.error('Unknown state reached while processing groups of lines', n, r, s); + }), + i + ); + }) + .reduce((e, t) => ({ left: e.left + t.left, right: e.right + t.right }), { left: '', right: '' }); + } + applyLineGroupping(e) { + const t = []; + let n = [], + i = []; + for (let r = 0; r < e.lines.length; r++) { + const s = e.lines[r]; + ((s.type !== l.LineType.INSERT && i.length) || (s.type === l.LineType.CONTEXT && n.length > 0)) && + (t.push([[], n, i]), (n = []), (i = [])), + s.type === l.LineType.CONTEXT + ? t.push([[s], [], []]) + : s.type === l.LineType.INSERT && 0 === n.length + ? t.push([[], [], [s]]) + : s.type === l.LineType.INSERT && n.length > 0 + ? i.push(s) + : s.type === l.LineType.DELETE && n.push(s); + } + return (n.length || i.length) && (t.push([[], n, i]), (n = []), (i = [])), t; + } + applyRematchMatching(e, t, n) { + const i = e.length * t.length, + r = Math.max.apply(null, [0].concat(e.concat(t).map((e) => e.content.length))); + return i < this.config.matchingMaxComparisons && + r < this.config.maxLineSizeInBlockForComparison && + ('lines' === this.config.matching || 'words' === this.config.matching) + ? n(e, t) + : [[e, t]]; + } + makeHeaderHtml(e, t) { + return this.hoganUtils.render(c, 'block-header', { + CSSLineClass: a.CSSLineClass, + blockHeader: (null == t ? void 0 : t.isTooBig) ? e : a.escapeForHtml(e), + lineClass: 'd2h-code-side-linenumber', + contentClass: 'd2h-code-side-line' + }); + } + processChangedLines(e, t, n) { + const i = { right: '', left: '' }, + r = Math.max(t.length, n.length); + for (let s = 0; s < r; s++) { + const r = t[s], + o = n[s], + l = void 0 !== r && void 0 !== o ? a.diffHighlight(r.content, o.content, e, this.config) : void 0, + c = + void 0 !== r && void 0 !== r.oldNumber + ? Object.assign( + Object.assign( + {}, + void 0 !== l + ? { + prefix: l.oldLine.prefix, + content: l.oldLine.content, + type: a.CSSLineClass.DELETE_CHANGES + } + : Object.assign(Object.assign({}, a.deconstructLine(r.content, e)), { + type: a.toCSSClass(r.type) + }) + ), + { number: r.oldNumber } + ) + : void 0, + d = + void 0 !== o && void 0 !== o.newNumber + ? Object.assign( + Object.assign( + {}, + void 0 !== l + ? { + prefix: l.newLine.prefix, + content: l.newLine.content, + type: a.CSSLineClass.INSERT_CHANGES + } + : Object.assign(Object.assign({}, a.deconstructLine(o.content, e)), { + type: a.toCSSClass(o.type) + }) + ), + { number: o.newNumber } + ) + : void 0, + { left: f, right: u } = this.generateLineHtml(c, d); + (i.left += f), (i.right += u); + } + return i; + } + generateLineHtml(e, t) { + return { left: this.generateSingleHtml(e), right: this.generateSingleHtml(t) }; + } + generateSingleHtml(e) { + const t = 'd2h-code-side-linenumber', + n = 'd2h-code-side-line'; + return this.hoganUtils.render(c, 'line', { + type: (null == e ? void 0 : e.type) || `${a.CSSLineClass.CONTEXT} d2h-emptyplaceholder`, + lineClass: void 0 !== e ? t : `${t} d2h-code-side-emptyplaceholder`, + contentClass: void 0 !== e ? n : `${n} d2h-code-side-emptyplaceholder`, + prefix: ' ' === (null == e ? void 0 : e.prefix) ? ' ' : null == e ? void 0 : e.prefix, + content: null == e ? void 0 : e.content, + lineNumber: null == e ? void 0 : e.number + }); + } + }; + }, + 699: (e, t) => { + 'use strict'; + var n, i; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.ColorSchemeType = t.DiffStyleType = t.LineMatchingType = t.OutputFormatType = t.LineType = void 0), + (function (e) { + (e.INSERT = 'insert'), (e.DELETE = 'delete'), (e.CONTEXT = 'context'); + })(n || (t.LineType = n = {})), + (t.OutputFormatType = { LINE_BY_LINE: 'line-by-line', SIDE_BY_SIDE: 'side-by-side' }), + (t.LineMatchingType = { LINES: 'lines', WORDS: 'words', NONE: 'none' }), + (t.DiffStyleType = { WORD: 'word', CHAR: 'char' }), + (function (e) { + (e.AUTO = 'auto'), (e.DARK = 'dark'), (e.LIGHT = 'light'); + })(i || (t.ColorSchemeType = i = {})); + }, + 593: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.hashCode = t.unifyPath = t.escapeForRegExp = void 0); + const n = RegExp( + '[' + ['-', '[', ']', '/', '{', '}', '(', ')', '*', '+', '?', '.', '\\', '^', '$', '|'].join('\\') + ']', + 'g' + ); + (t.escapeForRegExp = function (e) { + return e.replace(n, '\\$&'); + }), + (t.unifyPath = function (e) { + return e ? e.replace(/\\/g, '/') : e; + }), + (t.hashCode = function (e) { + let t, + n, + i, + r = 0; + for (t = 0, i = e.length; t < i; t++) (n = e.charCodeAt(t)), (r = (r << 5) - r + n), (r |= 0); + return r; + }); + } + }), + (t = {}), + (function n(i) { + var r = t[i]; + if (void 0 !== r) return r.exports; + var s = (t[i] = { exports: {} }); + return e[i].call(s.exports, s, s.exports, n), s.exports; + })(834) + ); + var e, t; +}); diff --git a/packages/bruno-app/public/static/diff2Html.min.css b/packages/bruno-app/public/static/diff2Html.min.css new file mode 100644 index 000000000..d793f308a --- /dev/null +++ b/packages/bruno-app/public/static/diff2Html.min.css @@ -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); + } +} diff --git a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/StyledWrapper.js index 57be41098..5f60a144a 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/StyledWrapper.js @@ -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; } diff --git a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/index.js b/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/index.js deleted file mode 100644 index 4d27290f4..000000000 --- a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/index.js +++ /dev/null @@ -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 ( -
    - - -
    - ); -}; - -export default FileEditor; diff --git a/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/StyledWrapper.js b/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/StyledWrapper.js index 21dd2882d..ac9233da6 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/StyledWrapper.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/StyledWrapper.js @@ -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); + } } } } diff --git a/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/index.js b/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/index.js index 3e59f3bc5..d37b6b63c 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/index.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/index.js @@ -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 ( -
    - +
    +
    ); diff --git a/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js b/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js new file mode 100644 index 000000000..97d14c65e --- /dev/null +++ b/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js @@ -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 ( +
    +
    +
    +
    + setEditorContent(val)} + onSave={readOnly ? undefined : handleSave} + mode="yaml" + font={get(preferences, 'font.codeFont', 'default')} + /> + {!readOnly && onSave && ( + + )} +
    +
    +
    + + + +
    +
    +
    + ); +}; + +export default SpecViewer; diff --git a/packages/bruno-app/src/components/ApiSpecPanel/index.js b/packages/bruno-app/src/components/ApiSpecPanel/index.js index f6359ffdb..35f574000 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/index.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/index.js @@ -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 = () => {
    -
    -
    -
    - -
    -
    - - - -
    -
    -
    + dispatch(saveApiSpecToFile({ uid, content }))} + />
    ); }; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/CollapsibleDiffRow.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/CollapsibleDiffRow.js new file mode 100644 index 000000000..13344949f --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/CollapsibleDiffRow.js @@ -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 ( +
    +
    + + {isCollapsed ? ( + + ) : ( + + )} + + {title} +
    + {!isCollapsed && ( +
    +
    + {hasOldContent ? oldContent :
    } +
    +
    + {hasNewContent ? newContent :
    } +
    +
    + )} +
    + ); +}; + +export default CollapsibleDiffRow; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffAuth.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffAuth.js new file mode 100644 index 000000000..26a9eb904 --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffAuth.js @@ -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 && ( +
    + + + + + + + + + + + + + + + +
    FieldValue
    + {modeStatus !== 'unchanged' && ( + + {modeStatus === 'added' ? 'A' : modeStatus === 'deleted' ? 'D' : 'M'} + + )} + Auth Mode{AUTH_TYPE_LABELS[currentMode] || currentMode}
    +
    + )} + {authSections.map((section) => ( +
    +
    + {section.label} + {section.status !== 'unchanged' && ( + + {section.status === 'added' ? 'A' : section.status === 'deleted' ? 'D' : 'M'} + + )} +
    + + + + + + + + + + {section.fields.map((field, index) => ( + + + + + + ))} + +
    FieldValue
    + {field.status !== 'unchanged' && ( + + {field.status === 'added' ? 'A' : field.status === 'deleted' ? 'D' : 'M'} + + )} + {field.key}{field.value}
    +
    + ))} + + ); +}; + +export default VisualDiffAuth; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffBody.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffBody.js new file mode 100644 index 000000000..b4ac5700e --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffBody.js @@ -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) => ( +
    + {segment.text || '\u00A0'} +
    + )); + }; + + const renderFormData = (items, otherItems) => { + if (!items || items.length === 0) return null; + + const otherItemMap = new Map(); + (otherItems || []).forEach((item) => { + otherItemMap.set(item.name, item); + }); + + return ( + + + + + + + + + + + {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 ( + + + + + + + ); + })} + +
    KeyValue
    + {status !== 'unchanged' && ( + + {status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'} + + )} + + + {item.name}{item.value}
    + ); + }; + + 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 ( + + + + + + + + + + + {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 ( + + + + + + + ); + })} + +
    File PathContent Type
    + {status !== 'unchanged' && ( + + {status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'} + + )} + + + {file.filePath}{file.contentType || '-'}
    + ); + }; + + 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 ( +
    +
    + {typeLabel}: {msg.name || `Message ${index + 1}`}{msg.type ? ` (${msg.type})` : ''} + {msgStatus !== 'unchanged' && ( + + {msgStatus === 'added' ? 'A' : msgStatus === 'deleted' ? 'D' : 'M'} + + )} +
    +
    {renderLineDiff(contentDiff)}
    +
    + ); + }); + }; + + 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) && ( +
    +
    Query
    +
    {renderLineDiff(queryDiff)}
    +
    + )} + {(currentVariables || otherVariables) && ( +
    +
    Variables
    +
    {renderLineDiff(variablesDiff)}
    +
    + )} + + ); + }; + + const renderTextBody = (currentContent, otherContent) => { + const diffSegments = showSide === 'old' + ? computeLineDiffForOld(currentContent || '', otherContent || '') + : computeLineDiffForNew(otherContent || '', currentContent || ''); + + return ( +
    + {renderLineDiff(diffSegments)} +
    + ); + }; + + 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 && ( +
    + + + + + + + + + + + + + + + +
    FieldValue
    + {modeStatus !== 'unchanged' && ( + + {modeStatus === 'added' ? 'A' : modeStatus === 'deleted' ? 'D' : 'M'} + + )} + Body Mode{BODY_TYPE_LABELS[currentMode] || currentMode}
    +
    + )} + {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 ( +
    +
    + {BODY_TYPE_LABELS[type] || type} + {hasChanges && ( + + {otherVal === undefined ? (showSide === 'old' ? 'D' : 'A') : 'M'} + + )} +
    + {content} +
    + ); + })} + + ); +}; + +export default VisualDiffBody; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffContent/StyledWrapper.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffContent/StyledWrapper.js new file mode 100644 index 000000000..b874a7aa7 --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffContent/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffContent/index.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffContent/index.js new file mode 100644 index 000000000..089a59284 --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffContent/index.js @@ -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 ( + +
    + No content to display +
    +
    + ); + } + + return ( + + +
    +
    +
    {oldLabel}
    +
    {newLabel}
    +
    + +
    + {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 ( + toggleSection(key)} + hasOldContent={hasOld} + hasNewContent={hasNew} + oldContent={ + + } + newContent={ + + } + /> + ); + })} +
    +
    +
    + ); +}; + +export default VisualDiffContent; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffHeaders.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffHeaders.js new file mode 100644 index 000000000..47b41d4e9 --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffHeaders.js @@ -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 ( +
    + + + + + + + + + + + {headersWithStatus.map((header, index) => ( + + + + + + + ))} + +
    KeyValue
    + {header.status !== 'unchanged' && ( + + {header.status === 'added' ? 'A' : header.status === 'deleted' ? 'D' : 'M'} + + )} + + + {header.name}{header.value}
    +
    + ); +}; + +export default VisualDiffHeaders; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffParams.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffParams.js new file mode 100644 index 000000000..3a0078960 --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffParams.js @@ -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 ( +
    +
    {title}
    + + + + + + + + + + + {params.map((param, index) => ( + + + + + + + ))} + +
    KeyValue
    + {param.status !== 'unchanged' && ( + + {param.status === 'added' ? 'A' : param.status === 'deleted' ? 'D' : 'M'} + + )} + + + {param.name}{param.value}
    +
    + ); + }; + + return ( + <> + {renderTable(queryParams, 'Query Parameters')} + {renderTable(pathParams, 'Path Parameters')} + + ); +}; + +export default VisualDiffParams; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffUrlBar.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffUrlBar.js new file mode 100644 index 000000000..102fd7637 --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/VisualDiffUrlBar.js @@ -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 {segment.text}; + } + return ( + + {segment.text} + + ); + }); + }; + + return ( +
    +
    + + {currentMethod?.toUpperCase() || 'GET'} + + + {renderDiffSegments(urlDiffSegments)} + +
    +
    + ); +}; + +export default VisualDiffUrlBar; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/bruUtils.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/bruUtils.js new file mode 100644 index 000000000..f33bef817 --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/bruUtils.js @@ -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; +}; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/bruUtils.spec.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/bruUtils.spec.js new file mode 100644 index 000000000..fc62d4307 --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/bruUtils.spec.js @@ -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: 'value' })).toBe('value'); + }); + + 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: '' })).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'); + }); + }); +}); diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/diffUtils.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/diffUtils.js new file mode 100644 index 000000000..5f77b13bb --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/diffUtils.js @@ -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; +}; diff --git a/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/diffUtils.spec.js b/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/diffUtils.spec.js new file mode 100644 index 000000000..1891a8f8a --- /dev/null +++ b/packages/bruno-app/src/components/Git/VisualDiffViewer/utils/diffUtils.spec.js @@ -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' }]); + }); + }); +}); diff --git a/packages/bruno-app/src/components/Help/StyledWrapper.js b/packages/bruno-app/src/components/Help/StyledWrapper.js index 710f2c80b..5ce61cb63 100644 --- a/packages/bruno-app/src/components/Help/StyledWrapper.js +++ b/packages/bruno-app/src/components/Help/StyledWrapper.js @@ -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}; diff --git a/packages/bruno-app/src/components/Help/index.js b/packages/bruno-app/src/components/Help/index.js index f8ff625a5..a22f8540d 100644 --- a/packages/bruno-app/src/components/Help/index.js +++ b/packages/bruno-app/src/components/Help/index.js @@ -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 ( -
    +
    setShowTooltip(true)} + onMouseEnter={handleMouseEnter} onMouseLeave={() => setShowTooltip(false)} > - + - {showTooltip && ( + {showTooltip && position && createPortal( {children} - + , + document.body )}
    ); diff --git a/packages/bruno-app/src/components/Icons/InfoCircle/index.js b/packages/bruno-app/src/components/Icons/InfoCircle/index.js new file mode 100644 index 000000000..140b41cb5 --- /dev/null +++ b/packages/bruno-app/src/components/Icons/InfoCircle/index.js @@ -0,0 +1,20 @@ +import React from 'react'; + +const InfoCircle = ({ size = 14 }) => { + return ( + + + + + ); +}; + +export default InfoCircle; diff --git a/packages/bruno-app/src/components/Icons/OpenAPISync/index.js b/packages/bruno-app/src/components/Icons/OpenAPISync/index.js new file mode 100644 index 000000000..b27d38a3a --- /dev/null +++ b/packages/bruno-app/src/components/Icons/OpenAPISync/index.js @@ -0,0 +1,22 @@ +import React from 'react'; + +const OpenAPISyncIcon = ({ size = 16, color = 'currentColor', ...props }) => { + return ( + + + + + + + + + + + + + + + ); +}; + +export default OpenAPISyncIcon; diff --git a/packages/bruno-app/src/components/Icons/Help/index.js b/packages/bruno-app/src/components/Icons/QuestionCircle/index.js similarity index 92% rename from packages/bruno-app/src/components/Icons/Help/index.js rename to packages/bruno-app/src/components/Icons/QuestionCircle/index.js index b96e02ba8..32844b538 100644 --- a/packages/bruno-app/src/components/Icons/Help/index.js +++ b/packages/bruno-app/src/components/Icons/QuestionCircle/index.js @@ -1,6 +1,6 @@ import React from 'react'; -const HelpIcon = ({ size = 14 }) => { +const QuestionCircle = ({ size = 14 }) => { return ( { ); }; -export default HelpIcon; +export default QuestionCircle; diff --git a/packages/bruno-app/src/components/Modal/StyledWrapper.js b/packages/bruno-app/src/components/Modal/StyledWrapper.js index 1734eab5f..a6a188245 100644 --- a/packages/bruno-app/src/components/Modal/StyledWrapper.js +++ b/packages/bruno-app/src/components/Modal/StyledWrapper.js @@ -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); + } + } } `; diff --git a/packages/bruno-app/src/components/OpenAPISpecTab/index.js b/packages/bruno-app/src/components/OpenAPISpecTab/index.js new file mode 100644 index 000000000..484ecdba4 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISpecTab/index.js @@ -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 ( +
    + + Loading spec... +
    + ); + } + + if (error || !specContent) { + return ( +
    + {error || 'No spec file found. Sync your collection first.'} +
    + ); + } + + return ( + + {isRemote && ( +
    + + Showing spec file from {sourceUrl}. +
    + )} + +
    + ); +}; + +export default OpenAPISpecTab; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js new file mode 100644 index 000000000..b4bc6c360 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js @@ -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) => ( + + ); + + 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 ( +
    + {bannerState && ( +
    +
    + {bannerState.variant === 'success' + ? + :
    } + + {bannerState.message} + {bannerState.version && ( + <> · v{bannerState.version} + )} + {bannerState.lastSyncDate && ( + · Synced {moment(bannerState.lastSyncDate).fromNow()} + )} + + {bannerState.badges && ( + + {bannerState.badges.modifiedCount > 0 && {bannerState.badges.modifiedCount} modified} + {bannerState.badges.missingCount > 0 && {bannerState.badges.missingCount} deleted} + {bannerState.badges.localOnlyCount > 0 && {bannerState.badges.localOnlyCount} added} + + )} +
    + {bannerState.actions.includes('revert-all') && ( +
    + +
    + )} +
    + )} + + {hasDrift ? ( +
    + {/* Modified in Collection */} + + renderDriftRow(endpoint, idx, ( + <> + + + + ))} + actions={( + + )} + /> + + {/* Deleted from Collection */} + + renderDriftRow(endpoint, idx, ( + + ))} + actions={( + + )} + /> + + {/* Added to Collection */} + + renderDriftRow(endpoint, idx, ( + <> + + + + ))} + actions={( + + )} + /> +
    + ) : ( +
    + +

    No changes in collection

    +

    The collection matches the last synced spec. Nothing to review.

    +
    + )} + {/* Action confirmation modal */} + {pendingAction && ( + setPendingAction(null)}> +
    +

    {pendingAction.message}

    +
    + + +
    +
    +
    + )} +
    + ); +}; + +export default CollectionStatusSection; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/ConfirmSyncModal/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/ConfirmSyncModal/index.js new file mode 100644 index 000000000..9623dd154 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/ConfirmSyncModal/index.js @@ -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 ( +
    +
    + + {group.label} + {group.endpoints.length} +
    + {expanded && ( +
    + {group.endpoints.map((ep, i) => ( +
    + + {ep.path} + {(ep.summary || ep.name) && ( + {ep.summary || ep.name} + )} +
    + ))} +
    + )} +
    + ); +}; + +const ConfirmSyncModal = ({ groups, onCancel, onSync, isSyncing }) => { + const hasNoChanges = groups.length === 0; + + return ( + +
    + {hasNoChanges ? ( +

    + Your collection is already in sync with the remote spec. Syncing will update the local spec file to match the latest remote version. +

    + ) : ( + <> +

    + The following changes will be applied to your collection. This action cannot be undone. Are you sure you want to proceed? +

    + +
    + {groups.map((group, idx) => ( + + ))} +
    + + )} + +
    + + +
    +
    +
    + ); +}; + +export default ConfirmSyncModal; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/ConnectSpecForm/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/ConnectSpecForm/index.js new file mode 100644 index 000000000..03b052abf --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/ConnectSpecForm/index.js @@ -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 ( +
    +
    +

    Connect to OpenAPI Spec

    +

    + Keep your collection synchronized with an OpenAPI specification. Changes in the spec will be detected automatically. +

    +
    + +
    { + e.preventDefault(); onConnect(); + }} + > + +
    +
    + + +
    + + {mode === 'url' ? ( + setSourceUrl(e.target.value)} + placeholder="https://api.example.com/openapi.json" + /> + ) : ( + <> + { + const file = e.target.files?.[0]; + if (file) { + const filePath = window.ipcRenderer.getFilePath(file); + if (filePath) setSourceUrl(filePath); + } + }} + /> + + + )} + + +
    +

    + {mode === 'url' + ? 'Supports OpenAPI 3.x specifications in JSON or YAML format' + : 'Select a local OpenAPI/Swagger JSON or YAML file'} +

    +
    + +
    + {FEATURES.map((text) => ( +
    + + {text} +
    + ))} +
    +
    + ); +}; + +export default ConnectSpecForm; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/ConnectionSettingsModal/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/ConnectionSettingsModal/index.js new file mode 100644 index 000000000..451bdede9 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/ConnectionSettingsModal/index.js @@ -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 ( + +
    +
    +
    + +
    + + +
    + + {mode === 'url' ? ( + setUrl(e.target.value)} + placeholder="https://api.example.com/openapi.json" + /> + ) : ( + <> + { + const file = e.target.files?.[0]; + if (file) { + const path = window.ipcRenderer.getFilePath(file); + if (path) setFilePath(path); + } + }} + /> + + + )} +
    + +
    + +
    +
    +
    + Automatically check for spec changes at a regular interval +
    +
    + +
    +
    + + {autoCheck && ( +
    + +
    + {intervals.map((mins) => ( + + ))} +
    +
    + )} +
    + +
    + +
    + + +
    +
    +
    +
    + ); +}; + +export default ConnectionSettingsModal; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/DisconnectSyncModal/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/DisconnectSyncModal/index.js new file mode 100644 index 000000000..ebc731461 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/DisconnectSyncModal/index.js @@ -0,0 +1,30 @@ +import Button from 'ui/Button'; +import Modal from 'components/Modal'; + +const DisconnectSyncModal = ({ onConfirm, onClose }) => { + return ( + +
    +

    + <>Are you sure you want to disconnect OpenAPI sync?

    + <>This will only disconnect the sync configuration. Your collection will remain intact. +

    +
    + + +
    +
    +
    + ); +}; + +export default DisconnectSyncModal; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointItem.js b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointItem.js new file mode 100644 index 000000000..2b8db0c3b --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointItem.js @@ -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 ( +
    +
    + + {endpoint.path} + {endpoint.summary && {endpoint.summary}} + {endpoint.name && !endpoint.summary && {endpoint.name}} + {endpoint.deprecated && deprecated} + {actions &&
    {actions}
    } +
    +
    + ); +}; + +export default EndpointItem; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointVisualDiff.js b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointVisualDiff.js new file mode 100644 index 000000000..e6b98fe14 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointVisualDiff.js @@ -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 ( + + ); +}; + +export default EndpointVisualDiff; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/ExpandableEndpointRow.js b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/ExpandableEndpointRow.js new file mode 100644 index 000000000..95c3aede8 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/ExpandableEndpointRow.js @@ -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 ( +
    +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); handleToggle(); + } + }} + > + + {isExpanded ? : } + + + {endpoint.path} + {endpoint.summary && {endpoint.summary}} + {endpoint.name && !endpoint.summary && {endpoint.name}} + {endpoint.conflict && ( + + This endpoint was modified in both the spec and your collection. Choose which version to keep. + + )} + > + Conflict + + )} + + {actions &&
    e.stopPropagation()}>{actions}
    } + + {showDecisions && onDecisionChange && ( +
    e.stopPropagation()}> + + +
    + )} +
    + + {isExpanded && ( +
    + {isLoading && ( +
    + + Loading diff... +
    + )} + {error && ( +
    + Error: {error} +
    + )} + {diffData && !isLoading && !error && ( + + )} +
    + )} +
    + ); +}; + +export default ExpandableEndpointRow; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/index.js new file mode 100644 index 000000000..5a7cb6c4f --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/index.js @@ -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 ( +
    +
    { + if (collectionUid && sectionKey) { + dispatch(toggleSectionExpanded({ collectionUid, sectionKey })); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (collectionUid && sectionKey) { + dispatch(toggleSectionExpanded({ collectionUid, sectionKey })); + } + } + }} + > + + + {title} + {endpoints.length} + {subtitle && {subtitle}} + {!isExpanded && headerExtra} + {actions &&
    e.stopPropagation()}>{actions}
    } +
    + {isExpanded && ( +
    + {endpoints.map((endpoint, idx) => renderItem(endpoint, idx))} +
    + )} +
    + ); +}; + +export default EndpointChangeSection; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js new file mode 100644 index 000000000..037964362 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js @@ -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 ( +
    +
    +
    +
    + {title} + {version} +
    +
    +
    + + + + + + + +
    +
    +
    + {sourceIsLocal ? 'Source File' : 'Source URL'} + {sourceIsLocal ? ( + + ) : ( + + {sourceUrl} + + )} + +
    +
    + ); +}; + +export default OpenAPISyncHeader; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js new file mode 100644 index 000000000..f2adf2960 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js @@ -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 ( +
    + {bannerState && ( +
    +
    +
    + {bannerState.variant === 'success' + ? + :
    } + {bannerState.title} + {bannerState.showBadge && ( + {specUpdatesPending} {specUpdatesPending === 1 ? 'spec update' : 'spec updates'} + )} + {bannerState.showChangesBadge && ( + {changedInCollection} {changedInCollection === 1 ? 'collection change' : 'collection changes'} + )} +
    + {bannerState.subtitle && ( +

    {bannerState.subtitle}

    + )} +
    + {bannerState.buttons.length > 0 && ( +
    + {bannerState.buttons.includes('changes') && ( + + )} + {(bannerState.buttons.includes('sync') || bannerState.buttons.includes('review')) && ( + + )} + {bannerState.buttons.includes('restore') && ( + + )} + {bannerState.buttons.includes('open-settings') && ( + + )} +
    + )} +
    + )} + +

    Endpoint Summary

    +
    + {SUMMARY_CARDS.map(({ key, label, tooltip, tab, color }) => { + const count = summaryValues[key]; + const resolvedColor = count > 0 ? color : 'muted'; + const isClickable = tab && count > 0; + return ( +
    onTabSelect(tab) : undefined} + > + + {tooltip} + +
    + {count != null ? count : '–'} + {key === 'pending' && conflictCount > 0 && ( + ({conflictCount} {conflictCount === 1 ? 'conflict' : 'conflicts'}) + )} +
    +
    + {label} +
    +
    + ); + })} +
    + +

    Last Synced Spec Details

    +
    + {details.map(({ label, value, tooltip }) => ( +
    +
    {label}
    +
    + {value} + {tooltip && ( + {tooltip} + )} +
    +
    + ))} +
    +
    + ); +}; + +export default OverviewSection; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/index.js new file mode 100644 index 000000000..9981232fa --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/index.js @@ -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 ( + +
    +
    + {addedCount > 0 && Added: {addedCount}} + {modifiedCount > 0 && Updated: {modifiedCount}} + {removedCount > 0 && Removed: {removedCount}} + {versionLabel && {versionLabel}} +
    + +

    + {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.'} +

    + +
    +
    + {specDrift?.unifiedDiff ? ( + <> +
    + {specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'} + Updated Spec +
    +
    + + ) : ( +
    No text diff available.
    + )} +
    +
    +
    +
    + ); +}; + +export default SpecDiffModal; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js new file mode 100644 index 000000000..30fbfabb7 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js @@ -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 && ( +
    + +
    +
    + {bannerState.variant === 'success' + ? + :
    } + + {bannerState.message} + {bannerState.version && ( + <> · v{bannerState.version} + )} + {bannerState.lastChecked && ( + · Checked {bannerState.lastChecked} + )} + + {bannerState.changes && ( + + {bannerState.changes.added > 0 && {bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added} + {bannerState.changes.modified > 0 && {bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated} + {bannerState.changes.removed > 0 && {bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed} + + )} +
    +
    + {bannerState.actions.includes('quick-sync') && ( + + )} + {bannerState.actions.includes('open-settings') && ( + + )} +
    +
    +
    + )} + + {specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? ( +
    + +

    Last Synced Spec not found in storage

    +

    The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.

    + +
    + ) : remoteDrift && ( +
    + +
    + )} + + {showConfirmModal && ( + + )} + + ); +}; + +export default SpecStatusSection; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js b/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js new file mode 100644 index 000000000..2966c018a --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js @@ -0,0 +1,2421 @@ +import styled from 'styled-components'; +import { rgba, darken } from 'polished'; + +const StyledWrapper = styled.div` + + .setup-header { + margin-bottom: 1.5rem; + } + + .setup-title { + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin: 0 0 0.375rem 0; + } + + .setup-description { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + line-height: 1.5; + margin: 0; + } + + .setup-form { + /* background: ${(props) => props.theme.background.surface0}; */ + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.md}; + padding: 1rem; + margin-bottom: 1.25rem; + + .url-label { + display: block; + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 500; + color: ${(props) => props.theme.text}; + margin-bottom: 0.5rem; + } + + .url-row { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .url-input { + flex: 1; + padding: 0.25rem 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.md}; + outline: none; + + &:focus { + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + } + } + + .setup-hint { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + margin: 0.5rem 0 0 0; + } + + .setup-features { + display: flex; + flex-direction: column; + gap: 0.375rem; + } + + .setup-feature { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + + svg { + color: ${(props) => props.theme.colors.text.green}; + flex-shrink: 0; + } + } + + .url-label { + display: block; + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 500; + color: ${(props) => props.theme.colors.text.muted}; + margin-bottom: 0.375rem; + } + + .url-row { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .url-input { + flex: 1; + padding: 0.375rem 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + font-family: monospace; + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.md}; + outline: none; + + &:focus { + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + /* Spec Info Card — borderless header */ + .spec-info-card { + margin-bottom: 14px; + + .spec-info-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .spec-title-section { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .spec-title-row { + display: flex; + align-items: center; + gap: 8px; + } + + .spec-title { + font-weight: 600; + font-size: 13px; + color: ${(props) => props.theme.text}; + } + + .spec-version { + font-family: monospace; + } + + .spec-header-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + } + + .spec-url-row { + display: flex; + align-items: center; + gap: 6px; + + .spec-url-label { + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + flex-shrink: 0; + } + + .spec-url-value { + font-family: monospace; + font-size: 11px; + color: ${(props) => props.theme.colors.text.subtext0}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + text-decoration: none; + + &:hover { + text-decoration: underline; + color: ${(props) => props.theme.status.info.text}; + } + } + + .spec-file-reveal { + background: none; + border: none; + padding: 0; + text-align: left; + cursor: pointer; + + &:hover { + text-decoration: underline; + color: ${(props) => props.theme.status.info.text}; + } + } + + } + + .copy-btn { + flex-shrink: 0; + padding: 0 4px; + background: none; + border: none; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${(props) => props.theme.text}; + } + } + + } + + /* Overview Status Banner */ + .overview-status-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 16px; + border-radius: 10px; + border: 1px solid transparent; + margin-top: 20px; + + &.success { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.green, 0.22)}; + + .banner-title { color: ${(props) => props.theme.colors.text.green}; } + } + + &.warning { + background: ${(props) => rgba(props.theme.colors.text.warning, 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.warning, 0.22)}; + + .banner-title { color: ${(props) => props.theme.colors.text.warning}; } + } + + &.muted { + background: ${(props) => rgba(props.theme.colors.text.muted, 0.07)}; + border-color: ${(props) => props.theme.border.border1}; + + .banner-title { color: ${(props) => props.theme.text}; } + } + + &.danger { + background: ${(props) => rgba(props.theme.colors.text.danger || '#c0392b', 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.danger || '#c0392b', 0.22)}; + + .banner-title { color: ${(props) => props.theme.colors.text.danger}; } + } + + &.info { + background: ${(props) => rgba(props.theme.status.info.text, 0.07)}; + border-color: ${(props) => rgba(props.theme.status.info.text, 0.22)}; + + .banner-title { color: ${(props) => props.theme.status.info.text}; } + } + + .banner-text { + flex: 1 1 0%; + min-width: 60%; + } + + .banner-title-row { + display: flex; + align-items: center; + gap: 8px; + } + + .status-dot { + position: relative; + width: 12px; + height: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &::before { + content: ''; + position: absolute; + width: 7px; + height: 7px; + border-radius: 50%; + opacity: 0.35; + animation: radiate 1.6s ease-out infinite; + } + + &::after { + content: ''; + position: relative; + width: 7px; + height: 7px; + border-radius: 50%; + } + + &.success { + &::before, &::after { background: ${(props) => props.theme.colors.text.green}; } + &::before { animation: none; } + } + &.warning { + &::before, &::after { background: ${(props) => props.theme.colors.text.warning}; } + } + &.muted { + &::before, &::after { background: ${(props) => props.theme.colors.text.muted}; } + } + &.danger { + &::before, &::after { background: ${(props) => props.theme.colors.text.danger}; } + } + &.info { + &::before, &::after { background: ${(props) => props.theme.status.info.text}; } + } + } + + .status-check-icon { + flex-shrink: 0; + color: ${(props) => props.theme.colors.text.green}; + } + + .banner-title { + font-size: 12px; + font-weight: 500; + } + + .banner-subtitle { + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + margin: 6px 0 0 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .banner-button-row { + display: flex; + gap: 10px; + flex-shrink: 0; + margin-left: 16px; + } + } + + /* Overview Section */ + .overview-section { + margin-top: 0; + + .overview-section-title { + font-size: 12px; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin-bottom: 6px; + } + + .spec-details-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + } + + .spec-detail-item { + padding: 12px 16px; + border-right: 1px solid ${(props) => props.theme.border.border1}; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + &:nth-child(3n) { + border-right: none; + } + + &:nth-child(n+4) { + border-bottom: none; + } + + &:first-child { border-top-left-radius: 8px; } + &:nth-child(3) { border-top-right-radius: 8px; } + &:nth-child(4) { border-bottom-left-radius: 8px; } + &:last-child { border-bottom-right-radius: 8px; } + } + + .spec-detail-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: ${(props) => props.theme.colors.text.muted}; + margin-bottom: 6px; + font-weight: 600; + } + + .spec-detail-value { + display: inline-flex; + align-items: center; + gap: 0px; + font-size: 13px; + font-weight: 500; + color: ${(props) => props.theme.text}; + } + } + + /* Update Banner */ + .spec-update-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 16px; + margin-top: 20px; + border-radius: 8px; + background: ${(props) => rgba(props.theme.dropdown.selectedColor, 0.08)}; + border: 1px solid transparent; + overflow: hidden; + + &.danger { + background: ${(props) => rgba(props.theme.colors.text.danger || '#c0392b', 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.danger || '#c0392b', 0.22)}; + } + + &.info { + background: ${(props) => rgba(props.theme.status.info.text, 0.07)}; + border-color: ${(props) => rgba(props.theme.status.info.text, 0.22)}; + } + + &.warning { + background: ${(props) => rgba(props.theme.colors.text.warning, 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.warning, 0.22)}; + } + + &.success { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.green, 0.22)}; + + .status-dot::before { + animation: none; + } + } + + &.muted { + background: ${(props) => rgba(props.theme.colors.text.muted, 0.07)}; + border: 1px solid ${(props) => props.theme.border.border1}; + } + + .banner-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .status-dot { + position: relative; + width: 12px; + height: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &::before { + content: ''; + position: absolute; + width: 7px; + height: 7px; + border-radius: 50%; + opacity: 0.35; + animation: radiate 1.6s ease-out infinite; + } + + &::after { + content: ''; + position: relative; + width: 7px; + height: 7px; + border-radius: 50%; + } + + &.success { + &::before, &::after { background: ${(props) => props.theme.colors.text.green}; } + } + &.info { + &::before, &::after { background: ${(props) => props.theme.status.info.text}; } + } + &.warning { + &::before, &::after { background: ${(props) => props.theme.colors.text.warning}; } + } + &.danger { + &::before, &::after { background: ${(props) => props.theme.colors.text.danger}; } + } + &.muted { + &::before, &::after { background: ${(props) => props.theme.colors.text.muted}; } + } + } + + .status-check-icon { + flex-shrink: 0; + color: ${(props) => props.theme.colors.text.green}; + } + + .banner-title { + font-size: 12px; + font-weight: 500; + color: ${(props) => props.theme.text}; + + .version-code { + font-family: monospace; + font-size: 11px; + padding: 1px 5px; + border-radius: 3px; + background: ${(props) => props.theme.background.surface1}; + border: 1px solid ${(props) => props.theme.border.border1}; + } + + .checked-text { + font-weight: 400; + font-size: 11px; + font-style: italic; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .banner-details { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + .banner-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + } + + } + + @keyframes radiate { + 0% { transform: scale(1); opacity: 0.6; } + 100% { transform: scale(2.8); opacity: 0; } + } + + /* Summary Cards */ + + .sync-summary-title-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + + .sync-summary-title { + margin-bottom: 0; + } + + .last-synced-pill { + strong { + color: ${(props) => props.theme.text}; + font-weight: 600; + } + } + } + + .sync-summary-title { + font-size: 13px; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin-bottom: 10px; + } + + .sync-summary-subtitle { + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + font-weight: 400; + margin-top: 2px; + } + + .sync-summary-cards { + display: flex; + gap: 10px; + } + + .summary-card { + width: 180px; + flex-shrink: 0; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + padding: 14px 16px; + background: ${(props) => props.theme.background.default}; + position: relative; + + &.clickable { + cursor: pointer; + } + } + + .summary-count-row { + display: flex; + align-items: baseline; + gap: 4px; + margin-bottom: 8px; + } + + .summary-count { + font-size: 28px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + + &.green { color: ${(props) => props.theme.colors.text.green}; } + &.amber { color: ${(props) => props.theme.colors.text.warning}; } + &.blue { color: ${(props) => props.theme.status.info.text}; } + &.red { color: ${(props) => props.theme.colors.text.danger || '#c0392b'}; } + &.purple { color: #7c3aed; } + &.default { color: ${(props) => props.theme.text}; } + &.muted { color: ${(props) => props.theme.colors.text.muted}; } + } + + .summary-count-unit { + font-size: 10px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .summary-label { + font-size: 12px; + font-weight: 500; + color: ${(props) => props.theme.colors.text.muted}; + } + + .conflict-annotation { + font-size: 11px; + font-weight: 600; + color: ${(props) => props.theme.colors.text.danger || '#c0392b'}; + } + + .card-info-icon { + position: absolute; + top: 8px; + right: 8px; + + svg { + margin: 0; + width: 12px; + height: 12px; + opacity: 0.3; + } + + &:hover svg { + opacity: 0.6; + } + } + + /* Connection Settings Modal */ + .settings-modal { + + .settings-field { + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } + } + + .settings-label { + font-size: 11px; + font-weight: 600; + color: ${(props) => props.theme.colors.text.subtext0}; + display: block; + margin-bottom: 5px; + } + + .settings-input { + width: 100%; + padding: 7px 10px; + font-size: 12px; + font-family: monospace; + color: ${(props) => props.theme.text}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 5px; + background: ${(props) => props.theme.input.bg}; + outline: none; + box-sizing: border-box; + text-align: left; + + &:focus { + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &.file-pick-btn { + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .settings-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .toggle-info { + flex: 1; + min-width: 0; + } + + .toggle-description { + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + margin-top: 2px; + } + + .toggle-switch { + width: 34px; + height: 20px; + border-radius: 10px; + border: none; + cursor: pointer; + padding: 0; + flex-shrink: 0; + position: relative; + transition: background 0.2s; + background: ${(props) => props.theme.colors.text.muted}; + + &.active { + background: ${(props) => props.theme.colors.text.green}; + } + + .toggle-knob { + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + position: absolute; + top: 3px; + left: 3px; + transition: left 0.2s; + box-shadow: 0 1px 2px rgba(0,0,0,0.2); + } + + &.active .toggle-knob { + left: 17px; + } + } + + .interval-buttons { + display: flex; + gap: 6px; + margin-top: 8px; + + button { + padding: 5px 12px; + font-size: 12px; + border-radius: 5px; + cursor: pointer; + font-weight: 500; + border: 1px solid ${(props) => props.theme.border.border1}; + background: ${(props) => props.theme.background.default}; + color: ${(props) => props.theme.colors.text.subtext0}; + transition: all 0.15s; + + &.active { + border-color: ${(props) => props.theme.button2.color.primary.border}; + background: ${(props) => props.theme.button2.color.primary.bg}; + color: ${(props) => props.theme.button2.color.primary.text}; + } + } + } + + .settings-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 14px; + } + + .disconnect-link { + font-size: 12px; + color: ${(props) => props.theme.colors.text.danger}; + background: none; + border: none; + cursor: pointer; + padding: 0; + + &:hover { + text-decoration: underline; + } + } + + .settings-actions { + display: flex; + gap: 8px; + } + } + + + + /* State Messages */ + .state-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.5rem; + gap: 0.5rem; + color: ${(props) => props.theme.colors.text.muted}; + + &.success { + color: ${(props) => props.theme.colors.text.green}; + } + + .spinning { + animation: spin 1s linear infinite; + } + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + .spec-status-section { + margin-top: 20px; + + .spec-update-banner { + margin-top: 0; + } + } + + .sync-info-notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 8px 12px; + border-radius: 8px; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.subtext0}; + background: ${(props) => props.theme.background.mantle}; + + .sync-info-icon { + flex-shrink: 0; + margin-top: 1px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .whats-updated-title { + color: ${(props) => props.theme.text}; + font-weight: 500; + } + } + + .collection-status-section { + margin-top: 20px; + + .change-section { + margin-top: 0.75rem; + + .section-body.expandable-mode { + border-radius: 0 0 8px 8px; + max-height: none; /* Override default max-height so all items remain visible */ + } + } + + /* Local Changes tab: override hover background */ + .endpoint-review-row .review-row-header:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + + .sync-review-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + + .empty-state-icon { + color: var(--color-text-muted, #9ca3af); + margin-bottom: 1rem; + } + + h4 { + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 500; + color: ${(props) => props.theme.text}; + margin: 0 0 0.375rem 0; + } + + p { + font-size: ${(props) => props.theme.font.size.xs}; + line-height: 1.5; + max-width: 400px; + margin: 0; + color: ${(props) => props.theme.colors.text.muted}; + } + } + } + + .sync-tab-content .sync-review-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + + .empty-state-icon { + color: var(--color-text-muted, #9ca3af); + margin-bottom: 1rem; + } + + h4 { + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 500; + color: ${(props) => props.theme.text}; + margin: 0 0 0.375rem 0; + } + + p { + font-size: ${(props) => props.theme.font.size.xs}; + line-height: 1.5; + max-width: 400px; + margin: 0; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + /* Expandable endpoint rows — shared base styles */ + .endpoint-review-row { + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + &:last-child { + border-bottom: none; + } + + .review-row-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + cursor: pointer; + + &:hover { + background: ${(props) => props.theme.background.mantle}; + } + + .expand-toggle { + color: ${(props) => props.theme.colors.text.muted}; + } + + .endpoint-path { + font-family: monospace; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + } + + .endpoint-name { + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.xs}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .changes-tag { + font-size: ${(props) => props.theme.font.size.xs}; + padding: 0.125rem 0.375rem; + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.muted}; + border-radius: ${(props) => props.theme.border.radius.sm}; + } + + .endpoint-actions { + display: flex; + gap: 0.25rem; + margin-left: auto; + opacity: 0; + transition: opacity 0.15s; + } + + &:hover .endpoint-actions { + opacity: 1; + } + } + + .review-row-diff { + border-top: 1px solid ${(props) => props.theme.border.border1}; + background: ${(props) => props.theme.background.mantle}; + } + + .diff-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem; + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.sm}; + + .spinning { + animation: spin 1s linear infinite; + } + } + + .diff-error { + padding: 1rem; + color: ${(props) => props.theme.colors.text.danger}; + font-size: ${(props) => props.theme.font.size.sm}; + } + } + + .change-section { + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + + .section-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: transparent; + border-radius: 8px; + cursor: pointer; + user-select: none; + + &:hover { + background: ${(props) => props.theme.background.mantle}; + } + + .section-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + background: ${(props) => props.theme.colors.text.muted}; + + &.type-added { background: ${(props) => props.theme.colors.text.green}; } + &.type-modified { background: ${(props) => props.theme.colors.text.warning}; } + &.type-removed { background: ${(props) => props.theme.colors.text.danger}; } + &.type-missing { background: ${(props) => props.theme.colors.text.danger}; } + &.type-local-only { background: ${(props) => props.theme.colors.text.muted}; } + &.type-in-sync { background: ${(props) => props.theme.colors.text.green}; } + &.type-conflict { background: ${(props) => props.theme.colors.text.danger}; } + &.type-spec-modified { background: ${(props) => props.theme.colors.text.info}; } + &.type-collection-drift { background: ${(props) => props.theme.colors.text.warning}; } + } + + .section-title { + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 600; + color: ${(props) => props.theme.text}; + } + + .section-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.3rem; + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.subtext0}; + background: ${(props) => props.theme.background.surface0}; + border-radius: 999px; + } + + .section-subtitle { + font-size: 10px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .section-actions { + margin-left: auto; + } + } + + /* When section body is visible, show background and flatten header's bottom radius */ + &.expanded .section-header { + background: ${(props) => props.theme.background.mantle}; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + .section-body { + border-top: 1px solid ${(props) => props.theme.border.border1}; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + max-height: 300px; + overflow-y: auto; + + &.expandable-mode { + max-height: none; + overflow-y: visible; + } + } + } + + /* Chevron */ + .chevron { + color: ${(props) => props.theme.colors.text.muted}; + transition: transform 0.15s ease; + flex-shrink: 0; + + &.expanded { + transform: rotate(90deg); + } + } + + /* Endpoint Items */ + .endpoint-item { + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + &:last-child { + border-bottom: none; + } + + .endpoint-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + + &.clickable { + cursor: pointer; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + } + + .endpoint-path { + font-family: monospace; + color: ${(props) => props.theme.text}; + } + + .endpoint-summary { + flex: 1; + color: ${(props) => props.theme.colors.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .deprecated-tag { + font-size: ${(props) => props.theme.font.size.xs}; + padding: 0.125rem 0.375rem; + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + } + + .changes-tag { + font-size: ${(props) => props.theme.font.size.xs}; + padding: 0.125rem 0.5rem; + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + font-weight: 500; + } + + .endpoint-actions { + display: flex; + gap: 0.25rem; + margin-left: auto; + opacity: 0; + transition: opacity 0.15s; + } + + &:hover .endpoint-actions { + opacity: 1; + } + } + } + + + + + + /* Endpoint Details */ + .endpoint-details { + padding: 0.75rem; + background: ${(props) => props.theme.background.surface0}; + border-top: 1px solid ${(props) => props.theme.border.border1}; + + .detail-group { + margin-bottom: 0.75rem; + + &:last-child { + margin-bottom: 0; + } + + .detail-title { + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 600; + text-transform: uppercase; + color: ${(props) => props.theme.colors.text.muted}; + margin-bottom: 0.375rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .description-text { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + line-height: 1.5; + margin: 0; + } + } + + .params-table { + width: 100%; + font-size: ${(props) => props.theme.font.size.xs}; + border-collapse: collapse; + + td { + padding: 0.25rem 0.5rem; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + vertical-align: top; + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + } + } + + tr:last-child td { + border-bottom: none; + } + + .param-name { + font-family: monospace; + font-weight: 500; + color: ${(props) => props.theme.text}; + white-space: nowrap; + } + + .param-type { + color: ${(props) => props.theme.colors.text.subtext0}; + white-space: nowrap; + } + + .param-desc { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .required-badge { + font-size: 10px; + padding: 0.125rem 0.25rem; + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.status.danger.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + } + + .content-type-badge { + display: inline-block; + font-size: ${(props) => props.theme.font.size.xs}; + padding: 0.125rem 0.375rem; + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.muted}; + border-radius: ${(props) => props.theme.border.radius.sm}; + margin-bottom: 0.5rem; + } + + .schema-block { + font-family: monospace; + font-size: 11px; + background: ${(props) => props.theme.background.surface1}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + padding: 0.5rem; + margin: 0; + overflow-x: auto; + max-height: 120px; + color: ${(props) => props.theme.text}; + } + + .responses-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .response-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: ${(props) => props.theme.font.size.xs}; + + .status-code { + font-family: monospace; + font-weight: 500; + padding: 0.125rem 0.375rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + + &.status-2xx { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + color: ${(props) => props.theme.status.success.text}; + } + &.status-3xx { + background: ${(props) => props.theme.status.info.background}; + color: ${(props) => props.theme.status.info.text}; + } + &.status-4xx { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + } + &.status-5xx { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.status.danger.text}; + } + } + + .response-desc { + color: ${(props) => props.theme.colors.text.muted}; + } + } + } + + + + + + /* Disconnect Modal */ + .disconnect-modal { + .disconnect-message { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + line-height: 1.5; + margin-bottom: 1.5rem; + } + + .disconnect-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + margin-bottom: 1.5rem; + + input[type="checkbox"] { + cursor: pointer; + } + } + + .disconnect-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + } + + /* Action Confirm Modal */ + .action-confirm-modal { + .confirm-message { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + line-height: 1.5; + margin-bottom: 1.5rem; + } + + .confirm-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + } + + /* Endpoints Modal List */ + .endpoints-list { + max-height: 12rem; + overflow-y: auto; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.base}; + background: ${(props) => props.theme.background.default}; + + .endpoint-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + &:last-child { + border-bottom: none; + } + + &.selectable { + cursor: pointer; + + &:hover { + background: ${(props) => props.theme.background.surface1}; + } + } + } + + .endpoint-path { + font-family: monospace; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .endpoint-summary { + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.xs}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 40%; + } + + input[type="checkbox"] { + accent-color: ${(props) => props.theme.colors.primary}; + cursor: pointer; + } + } + + .removal-controls { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.25rem; + padding: 0 0.25rem; + } + + .removal-count { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + margin-left: 1.25rem; + } + + .removal-actions { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .removal-separator { + color: ${(props) => props.theme.colors.text.muted}; + } + + .text-link { + color: ${(props) => props.theme.colors.primary}; + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: ${(props) => props.theme.font.size.xs}; + + &:hover { + text-decoration: underline; + } + } + + + + /* Sync Review Modal */ + .sync-review-page { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + + .sync-review-header { + flex-shrink: 0; + padding-bottom: 0.75rem; + + .back-link-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + } + + .back-link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + background: none; + border: none; + padding: 0; + font-family: inherit; + + &:hover { + color: ${(props) => props.theme.text}; + } + } + + .title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.25rem; + } + + .title-left { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .description-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.25rem; + } + + .review-title { + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin: 0; + } + + .review-badges { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.375rem; + + .badge-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + } + + .review-subtitle { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .context-pill { + font-size: ${(props) => props.theme.font.size.xs}; + padding: 0.125rem 0.5rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.muted}; + white-space: nowrap; + font-weight: 500; + + &.spec { + background: ${(props) => props.theme.status.info.background}; + color: ${(props) => props.theme.status.info.text}; + } + + &.drift { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + } + + &.conflict { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.status.danger.text}; + } + + &.added { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + color: ${(props) => props.theme.colors.text.green}; + } + + &.removed { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .text-diff-container { + border-radius: ${(props) => props.theme.border.radius.sm}; + border: 1px solid ${(props) => props.theme.border.border1}; + overflow: hidden; + + .diff-column-headers { + display: flex; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + .diff-column-label { + flex: 1; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: ${(props) => props.theme.colors.text.muted}; + + &:first-child { + border-right: 1px solid ${(props) => props.theme.border.border1}; + } + } + } + + .d2h-wrapper { + background-color: ${(props) => props.theme.bg} !important; + font-family: 'Fira Code', monospace; + font-size: 12px; + } + + .d2h-file-wrapper { + border: none; + border-radius: 0; + margin-bottom: 0; + } + + .d2h-file-header { + display: none; + } + + .d2h-files-diff { + width: 100%; + + .d2h-file-side-diff:first-child { + border-right: 1px solid ${(props) => props.theme.border.border1}; + } + } + + .d2h-code-side-linenumber { + background: transparent !important; + position: static !important; + } + + .d2h-diff-tbody { + tr td { border: none !important; } + } + + .d2h-ins { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent) !important; + border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important; + } + + .d2h-del { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 15%, transparent) !important; + border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important; + } + + .d2h-file-diff .d2h-ins.d2h-change { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 25%, transparent) !important; + } + + .d2h-file-diff .d2h-del.d2h-change { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 20%, transparent) !important; + } + + .d2h-code-line ins, + .d2h-code-side-line ins { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important; + text-decoration: none; + } + + .d2h-code-line del, + .d2h-code-side-line del { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important; + text-decoration: none; + } + + .d2h-code-line, + .d2h-code-side-line { + color: ${(props) => props.theme.text} !important; + word-break: break-all; + } + + .d2h-code-line-ctn { + word-break: break-all; + } + + .d2h-tag { + font-size: 9px; + font-weight: 500; + padding: 1px 5px; + border-radius: ${(props) => props.theme.border.radius.sm}; + text-transform: uppercase; + letter-spacing: 0.02em; + border: none; + } + + .d2h-changed-tag { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 15%, transparent); + color: ${(props) => props.theme.colors.text.warning}; + } + + .d2h-added-tag { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 13%, transparent); + color: ${(props) => props.theme.colors.text.green}; + } + + .d2h-deleted-tag { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 13%, transparent); + color: ${(props) => props.theme.colors.text.danger}; + } + + .d2h-renamed-tag, + .d2h-moved-tag { + display: none; + } + + .d2h-file-wrapper, + .d2h-file-diff, + .d2h-code-wrapper, + .d2h-diff-table, + .d2h-code-line, + .d2h-code-side-line, + .d2h-code-line-ctn, + .d2h-code-linenumber, + .d2h-code-side-linenumber { + font-family: 'Fira Code', monospace !important; + font-size: 12px !important; + } + } + + .text-diff-empty { + padding: 2rem; + text-align: center; + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.sm}; + } + + .spec-diff-modal { + .spec-diff-badges { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; + } + + .spec-diff-subtitle { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + margin: 0 0 0.75rem 0; + } + + .spec-diff-body { + max-height: calc(80vh - 140px); + overflow: auto; + } + } + + .review-actions-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: ${(props) => props.theme.background.surface0}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.md}; + margin-bottom: 0.75rem; + } + + .review-stats { + display: flex; + gap: 1rem; + font-size: ${(props) => props.theme.font.size.sm}; + + .stat { + display: inline-flex; + align-items: center; + gap: 0.25rem; + + &.add { color: ${(props) => props.theme.colors.text.green}; } + &.update { color: ${(props) => props.theme.status.info.text}; } + &.remove { color: ${(props) => props.theme.colors.text.danger}; } + &.keep { color: ${(props) => props.theme.colors.text.muted}; } + } + } + + .bulk-actions { + display: flex; + gap: 0.5rem; + } + + .bulk-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: ${(props) => props.theme.font.size.xs}; + background: none; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + color: ${(props) => props.theme.text}; + cursor: pointer; + + &:hover { + background: ${(props) => props.theme.background.surface1}; + } + + &.active { + border-color: ${(props) => props.theme.status.info.text}; + color: ${(props) => props.theme.status.info.text}; + background: ${(props) => props.theme.status.info.background}; + } + } + + .sync-review-body { + flex: 1; + overflow-y: auto; + } + + &.sync-mode .sync-review-body { + margin-top: 0; + } + + .sync-review-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + color: var(--color-text-muted, #6b7280); + + .empty-state-icon { + color: var(--color-text-muted, #9ca3af); + margin-bottom: 1rem; + } + + h4 { + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 500; + color: ${(props) => props.theme.text}; + margin: 0 0 0.375rem 0; + } + + p { + font-size: ${(props) => props.theme.font.size.xs}; + line-height: 1.5; + max-width: 400px; + margin: 0; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .endpoints-review-sections { + display: flex; + flex-direction: column; + gap: 1.25rem; + + .review-group { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .review-group-header { + display: flex; + align-items: center; + justify-content: flex-end; + } + + .review-group-title { + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 600; + color: ${(props) => props.theme.colors.text.muted}; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; + } + + .change-section { + .section-subtitle { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + margin-left: 0.25rem; + } + + .section-body { + max-height: none; + + &.expandable-mode { + /* border: 1px solid ${(props) => props.theme.border.border1}; */ + border-top: none; + border-radius: 0 0 ${(props) => props.theme.border.radius.sm} ${(props) => props.theme.border.radius.sm}; + } + } + } + } + + .endpoint-review-row { + .review-row-header { + .source-tag { + font-size: 10px; + padding: 0.125rem 0.375rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + font-weight: 500; + + &.spec { + background: ${(props) => props.theme.status.info.background}; + color: ${(props) => props.theme.status.info.text}; + } + + &.drift { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + } + + &.conflict { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.status.danger.text}; + } + + &.local-modified, &.local-deleted, &.local-added { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + } + + &.spec-modified, &.spec-added { + background: ${(props) => props.theme.status.info.background}; + color: ${(props) => props.theme.status.info.text}; + } + + &.spec-removed { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.status.danger.text}; + } + } + + } + + .decision-buttons { + display: flex; + gap: 0.25rem; + margin-left: auto; + } + + .decision-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: ${(props) => props.theme.font.size.xs}; + background: none; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + + &:hover { + background: ${(props) => props.theme.background.surface1}; + } + + &.keep.selected { + background: ${(props) => props.theme.background.surface1}; + border-color: ${(props) => props.theme.colors.text.muted}; + color: ${(props) => props.theme.text}; + } + + &.accept.selected { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + border-color: ${(props) => props.theme.colors.text.green}; + color: ${(props) => props.theme.colors.text.green}; + } + } + + .review-row-diff { + background: ${(props) => props.theme.background.mantle}; + } + + .endpoint-diff-view { + .diff-section { + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } + + .url-bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem; + background: ${(props) => props.theme.background.surface0}; + border-radius: ${(props) => props.theme.border.radius.sm}; + + .url { + font-family: monospace; + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.text}; + word-break: break-all; + } + } + + .diff-section-title { + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 600; + color: ${(props) => props.theme.colors.text.muted}; + margin-bottom: 0.25rem; + } + + .diff-table { + width: 100%; + font-size: ${(props) => props.theme.font.size.xs}; + border-collapse: collapse; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + + th, td { + padding: 0.25rem 0.5rem; + text-align: left; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + } + + th { + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.muted}; + font-weight: 500; + } + + tr:last-child td { + border-bottom: none; + } + + .row-added { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + } + + .row-deleted { + background: ${(props) => props.theme.status.danger.background}; + } + + .row-modified { + background: ${(props) => props.theme.status.warning.background}; + } + + .status-badge { + display: inline-block; + width: 14px; + height: 14px; + text-align: center; + line-height: 14px; + font-size: 10px; + font-weight: 700; + border-radius: 2px; + + &.added { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + color: ${(props) => props.theme.colors.text.green}; + } + + &.deleted { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.colors.text.danger}; + } + + &.modified { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.colors.text.warning}; + } + } + + .key-cell { + font-family: monospace; + font-weight: 500; + } + + .value-cell { + font-family: monospace; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .body-mode-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + font-size: ${(props) => props.theme.font.size.xs}; + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.muted}; + border-radius: ${(props) => props.theme.border.radius.sm}; + font-family: monospace; + } + + .empty-diff { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + font-style: italic; + } + } + } + + .sync-review-bottom-bar { + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + bottom: 0rem; + background: ${(props) => props.theme.background.base}; + margin-top: 1rem; + /* box-shadow: 0 -4px 12px -4px rgba(0, 0, 0, 0.3); */ + z-index: 10; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + + + .bar-stats { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + + .stats-prefix { + color: ${(props) => props.theme.colors.text.muted}; + } + + .stat { + display: inline-flex; + align-items: center; + gap: 0.25rem; + position: relative; + cursor: default; + + &.add { color: ${(props) => props.theme.colors.text.green}; } + &.update { color: ${(props) => props.theme.status.info.text}; } + &.remove { color: ${(props) => props.theme.colors.text.danger}; } + &.keep { color: ${(props) => props.theme.colors.text.muted}; } + + .stat-hover-card { + transform: translateX(-50%); + background: ${(props) => props.theme.background.base}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.md}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 0.5rem; + min-width: 200px; + max-width: 320px; + max-height: 200px; + overflow-y: auto; + z-index: 100; + } + + .stat-hover-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .stat-hover-item { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.2rem 0.25rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + + &:hover { + background: ${(props) => props.theme.background.hover}; + } + } + + .stat-hover-path { + font-size: ${(props) => props.theme.font.size.xs}; + font-family: monospace; + color: ${(props) => props.theme.text}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + .bar-actions { + display: flex; + gap: 0.5rem; + } + } + + } + + .sync-confirm-modal { + display: flex; + flex-direction: column; + max-height: 60vh; + + .sync-confirm-description { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + margin: 0 0 0.75rem 0; + flex-shrink: 0; + } + + .sync-confirm-groups { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + flex: 1; + min-height: 0; + overflow-y: auto; + } + + .confirm-group { + .confirm-group-header { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0; + cursor: pointer; + user-select: none; + + .chevron { + color: ${(props) => props.theme.colors.text.muted}; + transition: transform 0.15s ease; + flex-shrink: 0; + &.expanded { transform: rotate(90deg); } + } + } + + .confirm-group-label { + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 500; + } + + .confirm-group-count { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + } + + .confirm-group-subtitle { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + font-weight: 400; + } + + &.type-add .confirm-group-label { color: ${(props) => props.theme.colors.text.green}; } + &.type-update .confirm-group-label { color: ${(props) => props.theme.status.info.text}; } + &.type-remove .confirm-group-label { color: ${(props) => props.theme.colors.text.danger}; } + &.type-keep .confirm-group-label { color: ${(props) => props.theme.colors.text.muted}; } + + .endpoints-list { + margin-top: 0.5rem; + margin-left: 1.25rem; + } + + .confirm-group-body { + margin-top: 0.5rem; + } + + .confirm-group-endpoints { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding-left: 1.25rem; + } + + .confirm-endpoint { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.25rem; + } + + .confirm-endpoint-path { + font-family: monospace; + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.text}; + } + } + + .sync-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + flex-shrink: 0; + } + } + + /* Visual Diff Content Overrides */ + .visual-diff-content { + margin: 0.75rem; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + + .diff-header-row { + border-top: none; + border-left: none; + border-right: none; + border-radius: 0; + margin-bottom: 0; + background: ${(props) => props.theme.background.surface0}; + } + + .diff-sections { + gap: 0; + } + + .diff-row { + border-top: none; + border-left: none; + border-right: none; + border-radius: 0; + margin-bottom: 0; + &:last-child { + border-bottom: none; + } + } + } + + &.review-active { + padding-bottom: 0; + } + + /* URL/File mode toggle in setup form and settings modal */ + .setup-mode-toggle { + display: inline-flex; + flex-shrink: 0; + align-items: stretch; + align-self: stretch; + gap: 2px; + padding: 2px; + background: ${(props) => props.theme.background.surface2}; + border-radius: ${(props) => props.theme.border.radius.md}; + } + + .setup-mode-btn { + padding: 0 0.65rem; + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 500; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + border-radius: calc(${(props) => props.theme.border.radius.md} - 3px); + cursor: pointer; + transition: background 0.12s, color 0.12s; + white-space: nowrap; + + &.active { + background: ${(props) => darken(0.03, props.theme.background.base)}; + color: ${(props) => props.theme.button2.color.secondary.text}; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); + } + + &:hover:not(.active) { + color: ${(props) => props.theme.text}; + } + } + + .file-pick-btn { + text-align: left; + cursor: pointer; + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: ${(props) => props.theme.colors.text.muted}; + } + + /* File not found banner */ + .file-not-found-banner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; + background: ${(props) => rgba(props.theme.colors.text.yellow || '#f59e0b', 0.08)}; + border: 1px solid ${(props) => rgba(props.theme.colors.text.yellow || '#f59e0b', 0.3)}; + border-radius: ${(props) => props.theme.border.radius.md}; + } + + .file-not-found-content { + display: flex; + align-items: flex-start; + gap: 0.625rem; + flex: 1; + min-width: 0; + } + + .file-not-found-icon { + flex-shrink: 0; + margin-top: 1px; + color: ${(props) => props.theme.colors.text.yellow || '#f59e0b'}; + } + + .file-not-found-title { + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin-bottom: 0.125rem; + } + + .file-not-found-desc { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + word-break: break-all; + + code { + font-family: monospace; + font-size: ${(props) => props.theme.font.size.xs}; + } + } + + .file-not-found-actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js new file mode 100644 index 000000000..f2837733d --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js @@ -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 ( +
    + {hasRemoteUpdates && ( +
    +
    +
    +

    Review Changes

    + {totalChanges > 0 && ( +

    + Choose to keep the current version or accept the updated one. +

    + )} +
    + {(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && ( +
    + {specDrift?.unifiedDiff && ( + + )} + {decidableEndpoints.length > 0 && ( + <> + + + + )} +
    + )} +
    +
    + )} + +
    + {!hasRemoteUpdates ? ( +
    + +

    No updates from the spec

    +

    The collection matches the latest spec. Nothing to sync.

    +
    + ) : ( +
    + {/* === Updates from Spec === */} + {decidableEndpoints.length > 0 && ( +
    + + 0 ? ( + + {`This section has ${conflictCount} endpoint${conflictCount === 1 ? '' : 's'} modified in both the spec and your collection. Expand to review and resolve.`} + + )} + > + {conflictCount} {conflictCount === 1 ? 'Conflict' : 'Conflicts'} + + ) : null} + collectionUid={collectionUid} + sectionKey="review-spec-modified" + renderItem={(endpoint, idx) => ( + handleDecisionChange(endpoint.id, decision)} + collectionPath={collectionPath} + newSpec={newSpec} + showDecisions={true} + decisionLabels={{ keep: 'Keep Current', accept: 'Update' }} + collectionUid={collectionUid} + /> + )} + /> + + ( + handleDecisionChange(endpoint.id, decision)} + collectionPath={collectionPath} + newSpec={newSpec} + showDecisions={true} + decisionLabels={{ keep: 'Skip', accept: 'Add' }} + collectionUid={collectionUid} + /> + )} + /> + + ( + handleDecisionChange(endpoint.id, decision)} + collectionPath={collectionPath} + newSpec={newSpec} + showDecisions={true} + decisionLabels={{ keep: 'Keep', accept: 'Delete' }} + collectionUid={collectionUid} + /> + )} + /> +
    + )} + +
    + )} +
    + + {hasRemoteUpdates && ( +
    + + What gets updated: Parameters, headers, body and auth will be updated. Tests, scripts, and assertions are always preserved. +
    + )} + + {hasRemoteUpdates && ( +
    +
    + {totalChanges === 0 && ( + + {specDrift?.storedSpecMissing ? 'Sync will update the spec file' : 'No endpoint changes to apply'} + + )} +
    +
    + +
    +
    + )} + + {showConfirmation && ( + setShowConfirmation(false)} + onSync={handleConfirmApply} + isSyncing={isSyncing} + /> + )} + + {showSpecDiffModal && ( + setShowSpecDiffModal(false)} + /> + )} +
    + ); +}; + +export default SyncReviewPage; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useEndpointActions.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useEndpointActions.js new file mode 100644 index 000000000..bb74069e7 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useEndpointActions.js @@ -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; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js new file mode 100644 index 000000000..8dd1c3c2a --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js @@ -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; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js new file mode 100644 index 000000000..425c0ff29 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js @@ -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; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/index.js new file mode 100644 index 000000000..1c941d93e --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/index.js @@ -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 ? {collectionChangesCount} : null + }, + { + key: 'spec-updates', + label: 'Spec Updates', + indicator: specUpdatesCount > 0 ? {specUpdatesCount} : null + } + ], [collectionChangesCount, specUpdatesCount]); + + return ( + +
    + + {/* Setup form when not configured */} + {!isConfigured && ( + + )} + + {/* Configured: spec header + tabs */} + {isConfigured && ( + <> + setShowSettingsModal(true)} + onOpenDisconnect={() => setShowDisconnectModal(true)} + onCheck={checkForUpdates} + isLoading={isLoading} + /> + + + + {activeTab === 'overview' && ( +
    + setShowSettingsModal(true)} + /> +
    + )} + + {activeTab === 'collection-changes' && ( +
    + {isDriftLoading && !collectionDrift && ( +
    + + Checking collection status... +
    + )} + {collectionDrift && !collectionDrift.noStoredSpec ? ( + + ) : !isDriftLoading && ( + <> +
    +
    +
    + + {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'} + +
    +
    +
    + +

    {openApiSyncConfig?.lastSyncDate ? 'Last Synced Spec missing from storage' : 'Waiting for initial sync'}

    +

    {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.'} +

    +
    + + )} +
    + )} + + {activeTab === 'spec-updates' && ( +
    + setShowSettingsModal(true)} + /> +
    + )} + + )} +
    + + {showSettingsModal && ( + { + setShowSettingsModal(false); + setShowDisconnectModal(true); + }} + onClose={() => setShowSettingsModal(false)} + /> + )} + + {showDisconnectModal && ( + { + setShowDisconnectModal(false); + handleDisconnect(); + }} + onClose={() => setShowDisconnectModal(false)} + /> + )} + + ); +}; + +export default OpenAPISyncTab; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 296670116..4c54f7a97 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -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 ; } + if (focusedTab.type === 'openapi-sync') { + return ; + } + + if (focusedTab.type === 'openapi-spec') { + return ; + } + if (!item || !item.uid) { return ; } diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index ac6861ef6..8f0e0f30f 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -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 && (
    + + + + {(hasOpenApiUpdates || hasOpenApiError) && ( + + )} + + diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js index 5905d6fbf..cac3dc7dd 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js @@ -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 ( + <> + + OpenAPI + + ); + } + case 'openapi-spec': { + return ( + <> + + API Spec + + ); + } } }; diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index f7bfd4884..6f109f22f 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -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 ( -
    +
    {isOpenApi && ( -
    +
    -
    @@ -288,6 +319,23 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
    )} + + {showCheckForSpecUpdatesOption && ( +
    + +

    + Stay notified of spec changes and sync your collection with the spec. +

    +
    + )} diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js index 930593773..c04f41fa5 100644 --- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js @@ -385,6 +385,9 @@ const CollectionsSection = () => { setImportCollectionLocationModalOpen(false)} handleSubmit={handleImportCollectionLocation} /> diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js index caadd2c97..f65fe51cd 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js @@ -105,6 +105,9 @@ const WorkspaceOverview = ({ workspace }) => { setImportCollectionLocationModalOpen(false)} handleSubmit={handleImportCollectionLocation} /> diff --git a/packages/bruno-app/src/index.js b/packages/bruno-app/src/index.js index 36b1d0bc6..cd8e09ead 100644 --- a/packages/bruno-app/src/index.js +++ b/packages/bruno-app/src/index.js @@ -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 ( ); +}; + +if (rootElement) { + const root = ReactDOM.createRoot(rootElement); + root.render(
    ); } diff --git a/packages/bruno-app/src/providers/App/index.js b/packages/bruno-app/src/providers/App/index.js index 1a2d9a925..4fbb7a5e4 100644 --- a/packages/bruno-app/src/providers/App/index.js +++ b/packages/bruno-app/src/providers/App/index.js @@ -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(() => { diff --git a/packages/bruno-app/src/providers/App/useOpenAPISyncPolling.js b/packages/bruno-app/src/providers/App/useOpenAPISyncPolling.js new file mode 100644 index 000000000..aa5eb9027 --- /dev/null +++ b/packages/bruno-app/src/providers/App/useOpenAPISyncPolling.js @@ -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; diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js index 3e17f6ad7..1bb26bffc 100644 --- a/packages/bruno-app/src/providers/ReduxStore/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/index.js @@ -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) }); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 3ceda68d0..aaced8c05 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -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') { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js b/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js new file mode 100644 index 000000000..f6a414d97 --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js @@ -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; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 81ba04f99..6de48e44f 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -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); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index 142e4818f..4d92a523a 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -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 })); } } diff --git a/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js b/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js index 701d797f7..e627f71d1 100644 --- a/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js +++ b/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js @@ -38,10 +38,6 @@ const StyledWrapper = styled.button` color: ${props.$color}; `} - svg { - stroke: currentColor; - } - &:disabled { opacity: 0.5; cursor: not-allowed; diff --git a/packages/bruno-app/src/ui/MethodBadge/StyledWrapper.js b/packages/bruno-app/src/ui/MethodBadge/StyledWrapper.js new file mode 100644 index 000000000..d571f7c3c --- /dev/null +++ b/packages/bruno-app/src/ui/MethodBadge/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/ui/MethodBadge/index.js b/packages/bruno-app/src/ui/MethodBadge/index.js new file mode 100644 index 000000000..0e1b62ede --- /dev/null +++ b/packages/bruno-app/src/ui/MethodBadge/index.js @@ -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 ( + + {displayText} + + ); +}; + +export default MethodBadge; diff --git a/packages/bruno-app/src/ui/ResponsiveTabs/StyledWrapper.js b/packages/bruno-app/src/ui/ResponsiveTabs/StyledWrapper.js index 76a457abc..35b9ed3d0 100644 --- a/packages/bruno-app/src/ui/ResponsiveTabs/StyledWrapper.js +++ b/packages/bruno-app/src/ui/ResponsiveTabs/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/ui/StatusBadge/StyledWrapper.js b/packages/bruno-app/src/ui/StatusBadge/StyledWrapper.js new file mode 100644 index 000000000..259a96a86 --- /dev/null +++ b/packages/bruno-app/src/ui/StatusBadge/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/ui/StatusBadge/index.js b/packages/bruno-app/src/ui/StatusBadge/index.js new file mode 100644 index 000000000..c0abf8ed1 --- /dev/null +++ b/packages/bruno-app/src/ui/StatusBadge/index.js @@ -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 + * Error + * v2.1 + * tooltip}>Conflict + */ +const StatusBadge = ({ + children, + status = 'muted', + variant = 'light', + size = 'sm', + radius, + leftSection, + rightSection, + className = '' +}) => { + return ( + + {leftSection} + {children} + {rightSection} + + ); +}; + +export default StatusBadge; diff --git a/packages/bruno-app/src/utils/importers/common.js b/packages/bruno-app/src/utils/importers/common.js index 3ab032f73..4a6477afc 100644 --- a/packages/bruno-app/src/utils/importers/common.js +++ b/packages/bruno-app/src/utils/importers/common.js @@ -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); diff --git a/packages/bruno-converters/src/opencollection/types.ts b/packages/bruno-converters/src/opencollection/types.ts index 5512ef8a9..b03dd9e17 100644 --- a/packages/bruno-converters/src/opencollection/types.ts +++ b/packages/bruno-converters/src/opencollection/types.ts @@ -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 { diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index 3f7ec3701..8ff319820 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -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", diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js index 63d35486b..7327c7ddd 100644 --- a/packages/bruno-electron/src/app/collections.js +++ b/packages/bruno-electron/src/app/collections.js @@ -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)); } }; diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 830cb2f32..606127513 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -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 diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index e1b7e6e15..2bcc6ffcd 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -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; diff --git a/packages/bruno-electron/src/ipc/openapi-sync.js b/packages/bruno-electron/src/ipc/openapi-sync.js new file mode 100644 index 000000000..6faab633e --- /dev/null +++ b/packages/bruno-electron/src/ipc/openapi-sync.js @@ -0,0 +1,1730 @@ +const _ = require('lodash'); +const fs = require('fs'); +const fsExtra = require('fs-extra'); +const path = require('path'); +const crypto = require('crypto'); +const { ipcMain, app } = require('electron'); +const { + parseRequest, + stringifyRequestViaWorker, + parseCollection, + stringifyCollection, + stringifyFolder +} = require('@usebruno/filestore'); +const { openApiToBruno } = require('@usebruno/converters'); +const { writeFile, sanitizeName, getCollectionFormat } = require('../utils/filesystem'); +const { getEnvVars } = require('../utils/collection'); +const { getProcessEnvVars } = require('../store/process-env'); +const { getCertsAndProxyConfig } = require('./network/cert-utils'); +const { makeAxiosInstance } = require('./network/axios-instance'); +const jsyaml = require('js-yaml'); + +/** + * Detect if a string content is YAML (not JSON). + * Attempts JSON.parse first for a definitive check rather than relying on heuristics. + */ +const isYamlContent = (content) => { + if (!content || typeof content !== 'string') return false; + try { + JSON.parse(content); + return false; // Valid JSON — not YAML + } catch { + // Not JSON — verify it's actually parseable as YAML and produces an object + try { + const result = jsyaml.load(content); + return result && typeof result === 'object'; + } catch { + return false; + } + } +}; + +/** + * Generate an MD5 hash of a parsed OpenAPI spec for quick change detection. + */ +const generateSpecHash = (spec) => { + if (!spec) return null; + return crypto.createHash('md5').update(JSON.stringify(spec)).digest('hex'); +}; + +/** + * Validate that a target path is inside the collection directory. + * Prevents path traversal attacks via ../../ in user-supplied paths. + */ +const isPathInsideCollection = (targetPath, collectionPath) => { + const resolvedTarget = path.resolve(targetPath); + const resolvedCollection = path.resolve(collectionPath); + return resolvedTarget.startsWith(resolvedCollection + path.sep) || resolvedTarget === resolvedCollection; +}; + +/** + * Validate that a URL uses http or https scheme only. + */ +const isValidHttpUrl = (urlString) => { + try { + const url = new URL(urlString); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +const isLocalFilePath = (str) => !isValidHttpUrl(str) && typeof str === 'string' && str.length > 0; + +/** + * Get the directory where OpenAPI spec files are stored in AppData. + */ +const getSpecsDir = () => path.join(app.getPath('userData'), 'specs'); + +/** + * Load the spec metadata file from AppData. + * Returns an object mapping collectionPath → array of { filename, sourceUrl } entries. + */ +const loadSpecMetadata = () => { + const metadataPath = path.join(getSpecsDir(), 'metadata.json'); + try { + if (fs.existsSync(metadataPath)) { + return JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + } + } catch { + // ignore parse errors, return empty + } + return {}; +}; + +/** + * Save the spec metadata file to AppData. + */ +const saveSpecMetadata = (metadata) => { + const specsDir = getSpecsDir(); + fsExtra.ensureDirSync(specsDir); + const metadataPath = path.join(specsDir, 'metadata.json'); + const tmpPath = metadataPath + '.tmp'; + fs.writeFileSync(tmpPath, JSON.stringify(metadata, null, 2), 'utf8'); + fs.renameSync(tmpPath, metadataPath); +}; + +/** + * Get all spec entries for a collection. + */ +const getSpecEntriesForCollection = (collectionPath) => { + return loadSpecMetadata()[collectionPath] || []; +}; + +/** + * Get the spec entry for a specific sourceUrl within a collection. + */ +const getSpecEntryForUrl = (collectionPath, sourceUrl) => { + return getSpecEntriesForCollection(collectionPath).find((e) => e.sourceUrl === sourceUrl) || null; +}; + +/** + * Parse a spec string (JSON or YAML) into an object. + */ +const parseSpec = (content) => { + try { + return JSON.parse(content); + } catch { + return jsyaml.load(content); + } +}; + +/** + * Validate that a parsed spec object is a valid OpenAPI 3.x document. + * Swagger 2.0 is not supported — the converter only handles OpenAPI 3.x. + */ +const isValidOpenApiSpec = (spec) => { + if (!spec || typeof spec !== 'object') return false; + if (spec.swagger) return false; + if (spec.openapi && typeof spec.openapi === 'string' && spec.openapi.startsWith('3.')) { + return spec.paths && typeof spec.paths === 'object'; + } + return false; +}; + +/** + * Fetch OpenAPI spec content from a remote URL or local file path. + * Handles proxy/cert resolution for remote URLs. + * Returns { content, spec } on success, or { error, errorCode? } on failure. + */ +const fetchSpecFromSource = async ({ collectionUid, collectionPath, sourceUrl, environmentContext = {} }) => { + const { activeEnvironmentUid, environments = [], runtimeVariables = {}, globalEnvironmentVariables = {} } = environmentContext; + + if (!isValidHttpUrl(sourceUrl) && !isLocalFilePath(sourceUrl)) { + return { error: 'Invalid source: only http/https URLs and local file paths are allowed' }; + } + + let content; + + if (isLocalFilePath(sourceUrl)) { + const resolvedPath = collectionPath ? path.resolve(collectionPath, sourceUrl) : sourceUrl; + if (!fs.existsSync(resolvedPath)) { + return { error: `Spec file not found at: ${sourceUrl}`, errorCode: 'SOURCE_FILE_NOT_FOUND' }; + } + content = fs.readFileSync(resolvedPath, 'utf8'); + } else { + const cacheBustUrl = sourceUrl.includes('?') + ? `${sourceUrl}&_=${Date.now()}` + : `${sourceUrl}?_=${Date.now()}`; + + const environment = _.find(environments, (e) => e.uid === activeEnvironmentUid); + const envVars = getEnvVars(environment); + const processEnvVars = getProcessEnvVars(collectionUid); + const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = await getCertsAndProxyConfig({ + collectionUid, + collection: { promptVariables: {} }, + request: {}, + envVars, + runtimeVariables, + processEnvVars, + collectionPath, + globalEnvironmentVariables + }); + const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions }); + + try { + const response = await axiosInstance.get(cacheBustUrl, { + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache' + }, + timeout: 30000, + transformResponse: [(data) => data] + }); + content = response.data; + } catch (fetchErr) { + if (fetchErr.response) { + return { error: `Failed to fetch spec: ${fetchErr.response.status} ${fetchErr.response.statusText}` }; + } + const reason = fetchErr.code || fetchErr.cause?.code || fetchErr.name || 'unknown'; + return { error: `Could not reach ${sourceUrl} (${reason})` }; + } + } + + const spec = parseSpec(content); + return { content, spec }; +}; + +/** + * Normalize a Bruno request URL down to a comparable path. + * Strips template variables ({{baseUrl}}), protocol/host, query params, + * converts {param} to :param, collapses slashes, removes trailing slash. + */ +const normalizeUrlPath = (urlStr) => { + if (!urlStr) return ''; + return urlStr + .replace(/\{\{[^}]+\}\}/g, '') + .replace(/^https?:\/\/[^/]+/, '') + .replace(/\?.*$/, '') + .replace(/{([^}]+)}/g, ':$1') + .replace(/\/+/g, '/') + .replace(/\/$/, ''); +}; + +/** + * Load bruno config from disk. Returns { format, brunoConfig, collectionRoot }. + * collectionRoot is only set for yml format collections. + */ +const loadBrunoConfig = (collectionPath) => { + const format = getCollectionFormat(collectionPath); + let brunoConfig; + let collectionRoot; + + if (format === 'yml') { + const configFilePath = path.join(collectionPath, 'opencollection.yml'); + if (!fs.existsSync(configFilePath)) { + throw new Error('opencollection.yml not found'); + } + const content = fs.readFileSync(configFilePath, 'utf8'); + const parsed = parseCollection(content, { format }); + brunoConfig = parsed.brunoConfig; + collectionRoot = parsed.collectionRoot; + } else { + const brunoJsonPath = path.join(collectionPath, 'bruno.json'); + if (!fs.existsSync(brunoJsonPath)) { + throw new Error('bruno.json not found'); + } + brunoConfig = JSON.parse(fs.readFileSync(brunoJsonPath, 'utf8')); + } + + return { format, brunoConfig, collectionRoot }; +}; + +/** + * Save bruno config to disk (bruno.json or opencollection.yml). + */ +const saveBrunoConfig = async (collectionPath, format, brunoConfig, collectionRoot) => { + if (format === 'yml') { + const content = await stringifyCollection(collectionRoot, brunoConfig, { format }); + await writeFile(path.join(collectionPath, 'opencollection.yml'), content); + } else { + const brunoJsonPath = path.join(collectionPath, 'bruno.json'); + await writeFile(brunoJsonPath, JSON.stringify(brunoConfig, null, 2)); + } +}; + +/** + * Find a spec item in a Bruno collection tree by HTTP method and path. + * Returns { item, folderName } or null. + */ +const findItemInCollection = (items, method, targetPath, currentFolderName = null) => { + const normalizedTarget = normalizeUrlPath(targetPath); + for (const item of items) { + if (item.type === 'folder' && item.items) { + const found = findItemInCollection(item.items, method, targetPath, item.name); + if (found) return found; + } + if (item.request?.method?.toLowerCase() === method.toLowerCase()) { + if (normalizeUrlPath(item.request.url) === normalizedTarget) { + return { item, folderName: currentFolderName }; + } + } + } + return null; +}; + +/** + * Find an existing request file on disk by HTTP method and normalized path. + * Scans .bru/.yml files in the collection directory recursively. + * Returns { filePath, request, content, fileFormat } or null. + */ +const findRequestFileOnDisk = (dirPath, method, urlPath) => { + if (!fs.existsSync(dirPath)) return null; + const files = fs.readdirSync(dirPath); + for (const file of files) { + const filePath = path.join(dirPath, file); + const stats = fs.statSync(filePath); + if (stats.isDirectory() && !['node_modules', '.git', 'environments'].includes(file)) { + const found = findRequestFileOnDisk(filePath, method, urlPath); + if (found) return found; + } else if (file.endsWith('.bru') || file.endsWith('.yml') || file.endsWith('.yaml')) { + if (file.startsWith('folder.') || file.startsWith('collection.')) continue; + try { + const content = fs.readFileSync(filePath, 'utf8'); + const fileFormat = file.endsWith('.yml') || file.endsWith('.yaml') ? 'yml' : 'bru'; + const request = parseRequest(content, { format: fileFormat }); + if (request?.request) { + const reqMethod = request.request.method?.toUpperCase(); + const reqPath = normalizeUrlPath(request.request.url); + if (reqMethod === method && reqPath === urlPath) { + return { filePath, request, content, fileFormat }; + } + } + } catch (err) { + // Skip files that can't be parsed + } + } + } + return null; +}; + +/** + * Save an OpenAPI spec file to AppData specs directory. + * - Detects format (JSON/YAML) from the content and uses the correct file extension. + * - Reuses an existing UUID filename if one exists for this sourceUrl, otherwise creates a new one. + * - Updates metadata.json with the filename → sourceUrl mapping. + * + * @param {Object} params + * @param {string} params.collectionPath - Path to the collection directory. + * @param {string} params.content - The spec content string to save (JSON or YAML). + * @param {string} params.sourceUrl - The source URL identifying which spec entry to update. + */ +const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => { + const specsDir = getSpecsDir(); + await fsExtra.ensureDir(specsDir); + + const meta = loadSpecMetadata(); + const entries = meta[collectionPath] || []; + const existingEntry = entries.find((e) => e.sourceUrl === sourceUrl); + + let filename; + if (existingEntry) { + // Reuse existing UUID filename + filename = existingEntry.filename; + } else { + // Generate a new UUID filename based on content type + const ext = isYamlContent(content) ? 'yaml' : 'json'; + filename = `${crypto.randomUUID()}.${ext}`; + meta[collectionPath] = [...entries, { filename, sourceUrl }]; + saveSpecMetadata(meta); + } + + await writeFile(path.join(specsDir, filename), content); +}; + +/** + * Save an OpenAPI spec file and update sync metadata (lastSyncDate, specHash) in brunoConfig. + * Shared by both the IPC handler (connect flow) and the import flow. + */ +const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent, sourceUrl }) => { + const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath); + + await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl }); + + let parsedSpec; + try { + parsedSpec = JSON.parse(specContent); + } catch { + parsedSpec = jsyaml.load(specContent); + } + + const specHash = generateSpecHash(parsedSpec); + const lastSyncDate = new Date().toISOString(); + const openapi = brunoConfig.openapi || []; + const idx = openapi.findIndex((e) => e.sourceUrl === sourceUrl); + if (idx !== -1) { + openapi[idx] = { ...openapi[idx], lastSyncDate, specHash }; + } else { + openapi.push({ sourceUrl, lastSyncDate, specHash }); + } + brunoConfig.openapi = openapi; + + await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); +}; + +/** + * Clean up stored spec files and metadata for a collection (called when a collection is removed). + */ +const cleanupSpecFilesForCollection = (collectionPath) => { + const meta = loadSpecMetadata(); + const entries = meta[collectionPath] || []; + for (const entry of entries) { + const specPath = path.join(getSpecsDir(), entry.filename); + if (fs.existsSync(specPath)) fs.unlinkSync(specPath); + } + if (entries.length > 0) { + delete meta[collectionPath]; + saveSpecMetadata(meta); + } +}; + +/** + * Merge spec params/headers with existing user values. + * For each spec item, preserves the user's value and enabled state if a matching name exists. + */ +const mergeWithUserValues = (specItems, existingItems) => { + return specItems?.map((specItem) => { + const existing = (existingItems || []).find((e) => e.name === specItem.name); + return existing ? { ...specItem, value: existing.value, enabled: existing.enabled } : specItem; + }); +}; + +/** + * Merge a spec item into an existing request, preserving collection-specific data + * (tests, scripts, assertions) and user values for matching params/headers. + * + * fullReset: true = spec replaces entire request section (reset mode) + * false = only override url/body/auth from spec (sync mode) + */ +const mergeSpecIntoRequest = (existingRequest, specItem, { fullReset = false } = {}) => { + const mergedParams = mergeWithUserValues(specItem.request.params, existingRequest.request?.params); + const mergedHeaders = mergeWithUserValues(specItem.request.headers, existingRequest.request?.headers); + + if (fullReset) { + return { + ...existingRequest, + request: { + ...specItem.request, + params: mergedParams || [], + headers: mergedHeaders || [] + } + }; + } + + return { + ...existingRequest, + request: { + ...existingRequest.request, + url: specItem.request.url, + body: specItem.request.body, + auth: specItem.request.auth, + params: mergedParams || existingRequest.request?.params || [], + headers: mergedHeaders || existingRequest.request?.headers || [] + } + }; +}; + +/** + * Ensure a tag-based folder exists in the collection directory. + * Creates the folder and its folder.bru/folder.yml file if missing. + * Returns the resolved target folder path (falls back to collectionPath on path traversal). + */ +const ensureTagFolder = async (collectionPath, folderName, format) => { + const safeFolderName = sanitizeName(folderName); + const targetFolder = path.join(collectionPath, safeFolderName); + if (!isPathInsideCollection(targetFolder, collectionPath)) { + console.error(`[OpenAPI Sync] Path traversal blocked in folder name: ${folderName}`); + return collectionPath; + } + if (!fs.existsSync(targetFolder)) { + fs.mkdirSync(targetFolder, { recursive: true }); + const folderBruPath = path.join(targetFolder, `folder.${format}`); + const folderContent = await stringifyFolder({ meta: { name: safeFolderName } }, { format }); + await writeFile(folderBruPath, folderContent); + } + return targetFolder; +}; + +/** + * Flatten a Bruno collection's items into a Map keyed by endpoint ID (METHOD:normalizedPath). + * Each value includes the original item plus the parent folderName. + */ +const buildSpecItemsMap = (collectionItems) => { + const map = new Map(); + const flatten = (items, parentFolder = null) => { + for (const item of items) { + if (item.type === 'folder' && item.items) { + flatten(item.items, item.name); + } else if (item.request) { + const method = item.request.method?.toUpperCase() || 'GET'; + const urlPath = normalizeUrlPath(item.request.url); + const id = `${method}:${urlPath}`; + map.set(id, { ...item, folderName: parentFolder }); + } + } + }; + flatten(collectionItems); + return map; +}; + +/** + * Load the stored spec for a collection and convert it to Bruno collection format. + * Throws if no stored spec file exists. + */ +const loadStoredSpecCollection = (collectionPath, brunoConfig) => { + const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl; + const specEntry = sourceUrl ? getSpecEntryForUrl(collectionPath, sourceUrl) : null; + const specPath = specEntry ? path.join(getSpecsDir(), specEntry.filename) : null; + + if (!specPath || !fs.existsSync(specPath)) { + throw new Error('No stored spec file found. Please sync with remote spec first.'); + } + + const specRaw = fs.readFileSync(specPath, 'utf8'); + const storedSpec = parseSpec(specRaw); + const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags'; + return openApiToBruno(storedSpec, { groupBy }); +}; + +const registerOpenAPISyncIpc = (mainWindow) => { + ipcMain.handle('renderer:check-openapi-updates', async (event, { + collectionUid, collectionPath, sourceUrl, storedSpecHash, environmentContext + }) => { + try { + const result = await fetchSpecFromSource({ collectionUid, collectionPath, sourceUrl, environmentContext }); + if (result.error) { + return { hasUpdates: false, error: result.error, errorCode: result.errorCode }; + } + const remoteSpecHash = generateSpecHash(result.spec); + return { hasUpdates: storedSpecHash !== remoteSpecHash, remoteSpecHash }; + } catch (error) { + console.error('[OpenAPI Sync] Lightweight check error:', error.message); + return { hasUpdates: false, error: error.message }; + } + }); + + ipcMain.handle('renderer:compare-openapi-specs', async (event, { + collectionUid, collectionPath, sourceUrl, environmentContext + }) => { + try { + // Get the title/name from the spec + const getSpecTitle = (spec) => { + return spec?.info?.title || null; + }; + + const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + + const normalizePath = (pathStr) => { + return pathStr + .replace(/{([^}]+)}/g, ':$1') + .replace(/\/+/g, '/') + .replace(/\/$/, ''); + }; + + const extractEndpoints = (spec) => { + const endpoints = []; + if (!spec || !spec.paths) return endpoints; + + // Get base URL from servers + const baseUrl = spec.servers?.[0]?.url || ''; + + Object.entries(spec.paths).forEach(([pathStr, methods]) => { + if (!methods || typeof methods !== 'object') return; + + Object.entries(methods).forEach(([method, operation]) => { + if (!HTTP_METHODS.includes(method.toLowerCase())) return; + + // Extract parameters + const parameters = operation?.parameters || []; + const pathParams = parameters.filter((p) => p.in === 'path'); + const queryParams = parameters.filter((p) => p.in === 'query'); + const headerParams = parameters.filter((p) => p.in === 'header'); + + // Extract request body + const requestBody = operation?.requestBody; + const bodyContent = requestBody?.content; + const bodySchema = bodyContent?.['application/json']?.schema + || bodyContent?.['application/x-www-form-urlencoded']?.schema + || bodyContent?.['multipart/form-data']?.schema; + const bodyExample = bodyContent?.['application/json']?.example + || bodyContent?.['application/json']?.examples; + + // Extract responses + const responses = operation?.responses || {}; + + endpoints.push({ + id: `${method.toUpperCase()}:${normalizePath(pathStr)}`, + method: method.toUpperCase(), + path: pathStr, + normalizedPath: normalizePath(pathStr), + operationId: operation?.operationId || null, + summary: operation?.summary || null, + description: operation?.description || null, + tags: operation?.tags || [], + deprecated: operation?.deprecated || false, + // Detailed info for UI + details: { + parameters: { + path: pathParams, + query: queryParams, + header: headerParams + }, + requestBody: requestBody ? { + required: requestBody.required || false, + contentType: Object.keys(bodyContent || {})[0] || null, + schema: bodySchema, + example: bodyExample + } : null, + responses: Object.entries(responses).map(([code, resp]) => ({ + code, + description: resp.description, + schema: resp.content?.['application/json']?.schema + })) + }, + // Hash for comparison (MD5 for quick change detection) + _hash: crypto.createHash('md5').update(JSON.stringify({ + parameters, + requestBody: operation?.requestBody, + responses: operation?.responses + })).digest('hex') + }); + }); + }); + + return endpoints; + }; + + const compareSpecs = (oldSpec, newSpec) => { + const oldEndpoints = extractEndpoints(oldSpec); + const newEndpoints = extractEndpoints(newSpec); + + const oldEndpointMap = new Map(oldEndpoints.map((ep) => [ep.id, ep])); + const newEndpointMap = new Map(newEndpoints.map((ep) => [ep.id, ep])); + + const added = []; + const removed = []; + const modified = []; + const unchanged = []; + + newEndpoints.forEach((endpoint) => { + if (!oldEndpointMap.has(endpoint.id)) { + added.push(endpoint); + } else { + const oldEndpoint = oldEndpointMap.get(endpoint.id); + // Check if endpoint was modified by comparing hashes + if (oldEndpoint._hash !== endpoint._hash) { + modified.push({ + ...endpoint, + oldEndpoint: oldEndpoint + }); + } else { + unchanged.push(endpoint); + } + } + }); + + oldEndpoints.forEach((endpoint) => { + if (!newEndpointMap.has(endpoint.id)) { + removed.push(endpoint); + } + }); + + // Compare metadata (title, version, description) + const oldTitle = oldSpec?.info?.title || null; + const newTitle = newSpec?.info?.title || null; + const titleChanged = oldTitle !== newTitle; + + const oldVersion = oldSpec?.info?.version || null; + const newVersion = newSpec?.info?.version || null; + const versionChanged = oldVersion !== newVersion; + + const oldDescription = oldSpec?.info?.description || null; + const newDescription = newSpec?.info?.description || null; + const descriptionChanged = oldDescription !== newDescription; + + const metadataChanged = titleChanged || versionChanged || descriptionChanged; + + return { + added, + removed, + modified, + unchanged, + // Metadata changes + titleChanged, + storedTitle: oldTitle, + newTitle, + versionChanged, + storedVersion: oldVersion, + newVersion, + descriptionChanged, + storedDescription: oldDescription, + newDescription, + metadataChanged, + hasChanges: added.length > 0 || removed.length > 0 || modified.length > 0 || metadataChanged + }; + }; + + const specEntry = getSpecEntryForUrl(collectionPath, sourceUrl); + const storedSpecPath = specEntry ? path.join(getSpecsDir(), specEntry.filename) : null; + + let storedSpec = null; + let storedContent = ''; + const storedSpecMissing = !storedSpecPath || !fs.existsSync(storedSpecPath); + if (!storedSpecMissing) { + storedContent = fs.readFileSync(storedSpecPath, 'utf8'); + storedSpec = parseSpec(storedContent); + } + + const fetchResult = await fetchSpecFromSource({ collectionUid, collectionPath, sourceUrl, environmentContext }); + if (fetchResult.error) { + return { + isValid: false, + error: fetchResult.error, + errorCode: fetchResult.errorCode, + storedSpec, + storedSpecMissing + }; + } + + const newSpecContent = fetchResult.content; + const newSpec = fetchResult.spec; + + if (!isValidOpenApiSpec(newSpec)) { + const error = newSpec?.swagger + ? 'Swagger 2.0 is not supported. Please convert your spec to OpenAPI 3.x.' + : 'The source does not contain a valid OpenAPI 3.x specification'; + return { + isValid: false, + error, + added: [], + removed: [], + unchanged: [], + hasChanges: false + }; + } + + // Check for title/name changes + const storedTitle = getSpecTitle(storedSpec); + const newTitle = getSpecTitle(newSpec); + const titleChanged = storedSpec && storedTitle && newTitle && storedTitle !== newTitle; + + // Generate hashes for quick change detection + const storedSpecHash = generateSpecHash(storedSpec); + const remoteSpecHash = generateSpecHash(newSpec); + const hasRemoteChanges = storedSpecHash !== remoteSpecHash; + + const diff = compareSpecs(storedSpec, newSpec); + + // Detect remote spec format and determine correct filename + const remoteIsYaml = isYamlContent(newSpecContent); + const correctSpecFilename = remoteIsYaml ? 'openapi.yaml' : 'openapi.json'; + + // Generate unified diff for text diff view + const { createTwoFilesPatch } = require('diff'); + const totalLines = Math.max( + (storedContent || '').split('\n').length, + newSpecContent.split('\n').length + ); + const unifiedDiff = createTwoFilesPatch( + correctSpecFilename, correctSpecFilename, + storedContent || '', newSpecContent, + 'Current Spec', 'New Spec', + { context: totalLines } + ); + + return { + ...diff, + isValid: true, + storedSpec, + newSpec, + newSpecContent, + specFilename: correctSpecFilename, + // Hash comparison for quick change detection + hasRemoteChanges, + storedSpecHash, + remoteSpecHash, + storedSpecMissing, + // Metadata + titleChanged, + storedTitle, + newTitle, + // Text diff + unifiedDiff + }; + } catch (error) { + console.error('Error comparing OpenAPI specs:', error); + throw error; + } + }); + + // Recursively extracts all key paths from a parsed JSON value (dot-notation). + // Used to compare JSON body structure/schema without comparing values. + const extractJsonKeys = (obj, prefix = '') => { + const keys = []; + if (obj && typeof obj === 'object' && !Array.isArray(obj)) { + for (const key of Object.keys(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + keys.push(fullKey); + keys.push(...extractJsonKeys(obj[key], fullKey)); + } + } else if (Array.isArray(obj) && obj.length > 0) { + // Only inspect first element (spec arrays always have one template item) + keys.push(...extractJsonKeys(obj[0], `${prefix}[]`)); + } + return keys; + }; + + // Collection Drift Detection - compare stored spec (converted to bru) vs actual .bru files + ipcMain.handle('renderer:get-collection-drift', async (event, { collectionPath, brunoConfig: passedBrunoConfig, compareSpec }) => { + try { + // Use passed brunoConfig if available, otherwise read from disk + let brunoConfig; + if (passedBrunoConfig) { + brunoConfig = passedBrunoConfig; + } else { + try { + ({ brunoConfig } = loadBrunoConfig(collectionPath)); + } catch (err) { + return { error: err.message }; + } + } + + // Load spec to compare against — use compareSpec if provided, otherwise read stored spec from disk + let specToCompare; + const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags'; + + if (compareSpec) { + specToCompare = compareSpec; + } else { + const driftSourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl; + const driftSpecEntry = driftSourceUrl ? getSpecEntryForUrl(collectionPath, driftSourceUrl) : null; + const storedSpecPath = driftSpecEntry ? path.join(getSpecsDir(), driftSpecEntry.filename) : null; + + if (!storedSpecPath || !fs.existsSync(storedSpecPath)) { + return { + error: null, + noStoredSpec: true, + inSync: [], + modified: [], + localOnly: [], + missing: [], + specEndpointCount: 0, + collectionEndpointCount: 0 + }; + } + + const storedContent = fs.readFileSync(storedSpecPath, 'utf8'); + specToCompare = parseSpec(storedContent); + } + + // Convert spec to Bruno collection format + const specAsCollection = openApiToBruno(specToCompare, { groupBy }); + + // Build map of expected items by endpoint ID (method:path) + const specItems = buildSpecItemsMap(specAsCollection.items || []); + + // Scan and parse collection endpoints from disk + const scanCollectionFiles = (dirPath, relativePath = '') => { + const files = []; + if (!fs.existsSync(dirPath)) return files; + const entries = fs.readdirSync(dirPath); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry); + const relPath = relativePath ? path.join(relativePath, entry) : entry; + if (['node_modules', '.git', 'environments'].includes(entry)) continue; + const stats = fs.statSync(fullPath); + if (stats.isDirectory()) { + files.push(...scanCollectionFiles(fullPath, relPath)); + } else if ((entry.endsWith('.bru') || entry.endsWith('.yml') || entry.endsWith('.yaml')) + && !entry.startsWith('folder.') && !entry.startsWith('collection.') && !entry.startsWith('opencollection.')) { + files.push({ fullPath, relativePath: relPath }); + } + } + return files; + }; + + const collectionFiles = scanCollectionFiles(collectionPath); + const collectionEndpoints = []; + for (const { fullPath, relativePath } of collectionFiles) { + try { + const content = fs.readFileSync(fullPath, 'utf8'); + const fileFormat = fullPath.endsWith('.yml') || fullPath.endsWith('.yaml') ? 'yml' : 'bru'; + const parsed = parseRequest(content, { format: fileFormat }); + if (!parsed?.request) continue; + collectionEndpoints.push({ + fullPath, + relativePath, + request: parsed.request, + name: parsed.meta?.name || parsed.name || path.basename(fullPath) + }); + } catch (err) { + console.error(`[Collection Drift] Error parsing ${fullPath}:`, err.message); + } + } + + // Compare each collection endpoint against spec + const result = { + inSync: [], + modified: [], + localOnly: [], + missing: [] + }; + + const foundEndpointIds = new Set(); + + for (const { fullPath, relativePath, request: actualRequest, name: itemName } of collectionEndpoints) { + const method = actualRequest.method?.toUpperCase() || 'GET'; + const urlPath = normalizeUrlPath(actualRequest.url); + const id = `${method}:${urlPath}`; + + foundEndpointIds.add(id); + + const specItem = specItems.get(id); + if (!specItem) { + // Endpoint exists in collection but not in spec + result.localOnly.push({ + id, + method, + path: urlPath, + filePath: relativePath, + pathname: fullPath, + name: itemName + }); + } else { + // Compare key fields to detect drift + const specRequest = specItem.request; + + // Compare parameters by name:type pairs (catches query<->path type changes) + const specParamKeys = (specRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort(); + const actualParamKeys = (actualRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort(); + + // Compare headers (by name) + const specHeaderNames = (specRequest.headers || []).map((h) => h.name).sort(); + const actualHeaderNames = (actualRequest.headers || []).map((h) => h.name).sort(); + + // Check for differences + const paramsDiff = JSON.stringify(specParamKeys) !== JSON.stringify(actualParamKeys); + const headersDiff = JSON.stringify(specHeaderNames) !== JSON.stringify(actualHeaderNames); + + // Check body mode difference + const specBodyMode = specRequest.body?.mode || 'none'; + const actualBodyMode = actualRequest.body?.mode || 'none'; + const bodyDiff = specBodyMode !== actualBodyMode; + + // Check auth mode difference + const specAuthMode = specRequest.auth?.mode || 'none'; + const actualAuthMode = actualRequest.auth?.mode || 'none'; + const authDiff = specAuthMode !== actualAuthMode; + + // Check auth config differences when auth modes match + let authConfigDiff = false; + if (!authDiff && specAuthMode !== 'none' && specAuthMode !== 'inherit') { + if (specAuthMode === 'apikey') { + const specApikey = specRequest.auth?.apikey || {}; + const actualApikey = actualRequest.auth?.apikey || {}; + authConfigDiff = specApikey.key !== actualApikey.key || specApikey.placement !== actualApikey.placement; + } else if (specAuthMode === 'oauth2') { + const specOauth2 = specRequest.auth?.oauth2 || {}; + const actualOauth2 = actualRequest.auth?.oauth2 || {}; + const grantType = specOauth2.grantType || actualOauth2.grantType; + const commonFields = ['grantType', 'scope']; + const grantTypeFields = { + authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl'], + implicit: [...commonFields, 'authorizationUrl'], + password: [...commonFields, 'accessTokenUrl'], + client_credentials: [...commonFields, 'accessTokenUrl'] + }; + const fields = grantTypeFields[grantType] || commonFields; + authConfigDiff = fields.some((field) => specOauth2[field] !== actualOauth2[field]); + } + } + + // Check form field names when body modes match and mode is form-based + let formFieldsDiff = false; + let specFormFieldNames = []; + let actualFormFieldNames = []; + if (!bodyDiff && (specBodyMode === 'formUrlEncoded' || specBodyMode === 'multipartForm')) { + if (specBodyMode === 'multipartForm') { + // For multipartForm, compare name:type pairs to catch text<->file changes + specFormFieldNames = (specRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort(); + actualFormFieldNames = (actualRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort(); + } else { + // For formUrlEncoded, all fields are text — compare by name only + specFormFieldNames = (specRequest.body?.formUrlEncoded || []).map((f) => f.name).sort(); + actualFormFieldNames = (actualRequest.body?.formUrlEncoded || []).map((f) => f.name).sort(); + } + formFieldsDiff = JSON.stringify(specFormFieldNames) !== JSON.stringify(actualFormFieldNames); + } + + // Check JSON body structure when both sides use json mode + let jsonBodyDiff = false; + if (!bodyDiff && specBodyMode === 'json') { + try { + const specJson = specRequest.body?.json ? JSON.parse(specRequest.body.json) : null; + const actualJson = actualRequest.body?.json ? JSON.parse(actualRequest.body.json) : null; + if (specJson !== null && actualJson !== null) { + const specKeys = extractJsonKeys(specJson).sort(); + const actualKeys = extractJsonKeys(actualJson).sort(); + jsonBodyDiff = JSON.stringify(specKeys) !== JSON.stringify(actualKeys); + } else if ((specJson === null) !== (actualJson === null)) { + jsonBodyDiff = true; + } + } catch (e) { + // Malformed JSON — skip structural comparison + } + } + + if (paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff) { + const changes = []; + if (paramsDiff) { + const addedParams = actualParamKeys.filter((p) => !specParamKeys.includes(p)); + const removedParams = specParamKeys.filter((p) => !actualParamKeys.includes(p)); + if (addedParams.length) changes.push(`+${addedParams.length} params`); + if (removedParams.length) changes.push(`-${removedParams.length} params`); + } + if (headersDiff) { + const addedHeaders = actualHeaderNames.filter((h) => !specHeaderNames.includes(h)); + const removedHeaders = specHeaderNames.filter((h) => !actualHeaderNames.includes(h)); + if (addedHeaders.length) changes.push(`+${addedHeaders.length} headers`); + if (removedHeaders.length) changes.push(`-${removedHeaders.length} headers`); + } + if (bodyDiff) changes.push(`body: ${actualBodyMode}`); + if (authDiff) changes.push(`auth: ${actualAuthMode}`); + if (authConfigDiff) changes.push('auth config'); + if (formFieldsDiff) { + const addedFields = actualFormFieldNames.filter((f) => !specFormFieldNames.includes(f)); + const removedFields = specFormFieldNames.filter((f) => !actualFormFieldNames.includes(f)); + if (addedFields.length) changes.push(`+${addedFields.length} form fields`); + if (removedFields.length) changes.push(`-${removedFields.length} form fields`); + } + if (jsonBodyDiff) changes.push('body schema'); + + result.modified.push({ + id, + method, + path: urlPath, + filePath: relativePath, + pathname: fullPath, + name: itemName, + changes: changes.join(', '), + actualRequest: { request: actualRequest }, + specItem + }); + } else { + result.inSync.push({ + id, + method, + path: urlPath, + filePath: relativePath, + pathname: fullPath, + name: itemName + }); + } + } + } + + // Find endpoints in spec but missing from collection + for (const [id, specItem] of specItems) { + if (!foundEndpointIds.has(id)) { + // Split only on first colon to preserve :param in paths + const colonIndex = id.indexOf(':'); + const method = id.substring(0, colonIndex); + const urlPath = id.substring(colonIndex + 1); + result.missing.push({ + id, + method, + path: urlPath, + name: specItem.name || specItem.request?.url || id + }); + } + } + + return { + error: null, + noStoredSpec: false, + ...result, + specEndpointCount: specItems.size, + collectionEndpointCount: collectionEndpoints.length + }; + } catch (error) { + console.error('Error getting collection drift:', error); + throw error; + } + }); + + // Get endpoint diff data for visual comparison (spec vs collection) + ipcMain.handle('renderer:get-endpoint-diff-data', async (event, { collectionPath, endpointId, newSpec }) => { + try { + let brunoConfig; + try { + ({ brunoConfig } = loadBrunoConfig(collectionPath)); + } catch (err) { + return { error: err.message }; + } + + // Parse endpoint ID (format: "METHOD:path") + const [method, ...pathParts] = endpointId.split(':'); + const endpointPath = pathParts.join(':'); // Rejoin in case path contains ':' + + // Get spec to use (new spec if provided, otherwise stored spec) + let specToUse = newSpec; + if (!specToUse) { + const diffSourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl; + const diffSpecEntry = diffSourceUrl ? getSpecEntryForUrl(collectionPath, diffSourceUrl) : null; + const storedSpecPath = diffSpecEntry ? path.join(getSpecsDir(), diffSpecEntry.filename) : null; + if (storedSpecPath && fs.existsSync(storedSpecPath)) { + const content = fs.readFileSync(storedSpecPath, 'utf8'); + specToUse = parseSpec(content); + } + } + + if (!specToUse) { + return { error: 'No spec available' }; + } + + // Convert spec to Bruno collection format + const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags'; + const specAsCollection = openApiToBruno(specToUse, { groupBy }); + + // Find the spec item for this endpoint + const specItem = findItemInCollection(specAsCollection.items || [], method, endpointPath)?.item || null; + + // Find the actual collection file for this endpoint + const actualFile = findRequestFileOnDisk(collectionPath, method.toUpperCase(), endpointPath); + const actualRequest = actualFile?.request || null; + + // Transform to visual diff format (matching what VisualDiffViewer rendering components expect) + // Components like VisualDiffUrlBar, VisualDiffParams, etc. read from data.request.* + const transformToVisualFormat = (item) => { + if (!item) return null; + const req = item.request || item; + // Strip query string from URL - params are shown in the separate Parameters section + const urlWithoutQuery = (req.url || '').split('?')[0]; + + // Normalize params/headers to only include fields relevant for comparison. + // Different sources (openApiToBruno vs parseRequest) include different metadata + // fields (uid, description) which cause false positives in isEqual comparisons. + const normalizeParams = (params) => (params || []).map((p) => ({ + name: p.name, + value: p.value, + enabled: p.enabled !== false, + type: p.type + })); + const normalizeHeaders = (headers) => (headers || []).map((h) => ({ + name: h.name, + value: h.value, + enabled: h.enabled !== false + })); + + return { + name: item.name || item.meta?.name, + type: item.type, + request: { + method: req.method, + url: urlWithoutQuery, + params: normalizeParams(req.params), + headers: normalizeHeaders(req.headers), + body: req.body || {}, + auth: req.auth || {}, + vars: item.vars || req.vars || {}, + assertions: item.assertions || req.assertions || [], + script: item.script || req.script || {}, + tests: item.tests || req.tests || '', + docs: item.docs || req.docs || '' + } + }; + }; + + return { + error: null, + // oldData = current collection state, newData = expected from spec + oldData: transformToVisualFormat(actualRequest), + newData: transformToVisualFormat(specItem) + }; + } catch (error) { + console.error('Error getting endpoint diff data:', error); + return { error: error.message }; + } + }); + + // Sync modes: 'spec-only' | 'reset' | 'sync' (default) + ipcMain.handle('renderer:apply-openapi-sync', async (event, { collectionPath, sourceUrl, addNewRequests, removeDeletedRequests, diff, localOnlyToRemove = [], driftedToReset = [], mode = 'sync', endpointDecisions = {} }) => { + try { + const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath); + + // Mode: spec-only - Just save the spec, don't touch collection + if (mode === 'spec-only') { + if (diff.newSpec && typeof diff.newSpec === 'object') { + const specContent = diff.newSpecContent || JSON.stringify(diff.newSpec, null, 2); + await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl }); + } + + // Update sync metadata + const openapi = brunoConfig.openapi || []; + const specOnlyIdx = openapi.findIndex((e) => e.sourceUrl === sourceUrl); + if (specOnlyIdx !== -1) { + openapi[specOnlyIdx] = { + ...openapi[specOnlyIdx], + lastSyncDate: new Date().toISOString(), + specHash: generateSpecHash(diff.newSpec) + }; + } + brunoConfig.openapi = openapi; + + await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); + + return { success: true, mode: 'spec-only' }; + } + + // Mode: reset - Save spec and reset all endpoints to spec (preserve tests/scripts) + if (mode === 'reset' && diff.newSpec) { + const openapiEntryReset = (brunoConfig.openapi || []).find((e) => e.sourceUrl === sourceUrl); + const groupBy = openapiEntryReset?.groupBy || 'tags'; + const newCollection = openApiToBruno(diff.newSpec, { groupBy }); + + // Build map of spec items by endpoint ID + const specItemsMap = buildSpecItemsMap(newCollection.items || []); + + // Find and update existing .bru files + const findAndResetRequest = async (dirPath) => { + if (!fs.existsSync(dirPath)) return; + + const files = fs.readdirSync(dirPath); + for (const file of files) { + const filePath = path.join(dirPath, file); + const stats = fs.statSync(filePath); + + if (stats.isDirectory() && !['node_modules', '.git', 'environments'].includes(file)) { + await findAndResetRequest(filePath); + } else if ((file.endsWith('.bru') || file.endsWith('.yml') || file.endsWith('.yaml')) + && !file.startsWith('folder.') && !file.startsWith('collection.')) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + const fileFormat = file.endsWith('.yml') || file.endsWith('.yaml') ? 'yml' : 'bru'; + const existingRequest = parseRequest(content, { format: fileFormat }); + + if (existingRequest?.request) { + const method = existingRequest.request.method?.toUpperCase() || 'GET'; + const urlPath = normalizeUrlPath(existingRequest.request.url); + const id = `${method}:${urlPath}`; + + const specItem = specItemsMap.get(id); + if (specItem) { + const mergedRequest = mergeSpecIntoRequest(existingRequest, specItem, { fullReset: true }); + const newContent = await stringifyRequestViaWorker(mergedRequest, { format: fileFormat }); + await writeFile(filePath, newContent); + + // Mark as processed + specItemsMap.delete(id); + } + } + } catch (err) { + console.error(`Error resetting file ${filePath}:`, err); + } + } + } + }; + + await findAndResetRequest(collectionPath); + + // Create missing endpoints from spec + for (const [, specItem] of specItemsMap) { + let targetFolder = collectionPath; + if (specItem.folderName && groupBy === 'tags') { + targetFolder = await ensureTagFolder(collectionPath, specItem.folderName, format); + } + + const requestContent = await stringifyRequestViaWorker(specItem, { format }); + const sanitizedFilename = `${sanitizeName(specItem.name || path.basename(specItem.filename || '', `.${format}`))}.${format}`; + await writeFile(path.join(targetFolder, sanitizedFilename), requestContent); + } + + // Save spec in original format + const specContent = diff.newSpecContent || JSON.stringify(diff.newSpec, null, 2); + await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl }); + + // Update sync metadata + const openapiReset = brunoConfig.openapi || []; + const resetIdx = openapiReset.findIndex((e) => e.sourceUrl === sourceUrl); + if (resetIdx !== -1) { + openapiReset[resetIdx] = { + ...openapiReset[resetIdx], + lastSyncDate: new Date().toISOString(), + specHash: generateSpecHash(diff.newSpec) + }; + } + brunoConfig.openapi = openapiReset; + + await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); + + return { success: true, mode: 'reset' }; + } + + // Mode: sync (default) — compute shared values once + const syncEntry = (brunoConfig.openapi || []).find((e) => e.sourceUrl === sourceUrl); + const groupBy = syncEntry?.groupBy || 'tags'; + let newCollection; + if (diff.newSpec) { + try { + newCollection = openApiToBruno(diff.newSpec, { groupBy }); + } catch (err) { + console.error('[OpenAPI Sync] Error converting spec:', err); + } + } + + if (addNewRequests && diff.added?.length > 0 && newCollection) { + for (const endpoint of diff.added) { + const normalizedPath = normalizeUrlPath(endpoint.path); + const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path); + const newItem = result?.item; + + if (newItem) { + // Check if endpoint already exists in collection (prevents overwriting user customizations) + const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath); + + if (existingFile) { + const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem); + const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat }); + await writeFile(existingFile.filePath, content); + } else { + // Truly new — create file as before + let targetFolder = collectionPath; + if (endpoint.tags?.length > 0 && groupBy === 'tags') { + targetFolder = await ensureTagFolder(collectionPath, endpoint.tags[0], format); + } + + const requestContent = await stringifyRequestViaWorker(newItem, { format }); + const sanitizedFilename = `${sanitizeName(newItem.name || path.basename(newItem.filename || '', `.${format}`))}.${format}`; + await writeFile(path.join(targetFolder, sanitizedFilename), requestContent); + } + } + } + } + + if (removeDeletedRequests && diff.removed?.length > 0) { + const findAndRemoveRequest = (dirPath) => { + if (!fs.existsSync(dirPath)) return; + + const files = fs.readdirSync(dirPath); + for (const file of files) { + const filePath = path.join(dirPath, file); + const stats = fs.statSync(filePath); + + if (stats.isDirectory() && !['node_modules', '.git', 'environments'].includes(file)) { + findAndRemoveRequest(filePath); + } else if ((file.endsWith('.bru') || file.endsWith('.yml') || file.endsWith('.yaml')) + && !file.startsWith('folder.') && !file.startsWith('collection.')) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + const request = parseRequest(content, { format: file.endsWith('.yml') || file.endsWith('.yaml') ? 'yml' : 'bru' }); + + if (request?.request) { + const method = request.request.method?.toUpperCase(); + const url = normalizeUrlPath(request.request.url); + + if (!isPathInsideCollection(filePath, collectionPath)) { + console.error(`[OpenAPI Sync] Path traversal blocked: ${filePath}`); + } else { + for (const removed of diff.removed) { + const removedPath = normalizeUrlPath(removed.path); + if (method === removed.method.toUpperCase() && url === removedPath) { + fs.unlinkSync(filePath); + break; + } + } + } + } + } catch (err) { + console.error(`Error parsing file ${filePath}:`, err); + } + } + } + }; + + findAndRemoveRequest(collectionPath); + } + + // Remove local-only endpoints (endpoints in collection but not in spec) + if (localOnlyToRemove?.length > 0) { + for (const endpoint of localOnlyToRemove) { + if (endpoint.filePath) { + const fullPath = path.resolve(collectionPath, endpoint.filePath); + if (!isPathInsideCollection(fullPath, collectionPath)) { + console.error(`[OpenAPI Sync] Path traversal blocked in localOnlyToRemove: ${endpoint.filePath}`); + continue; + } + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath); + } + } + } + } + + // Handle modified endpoints with conflict resolutions + // endpointDecisions: { endpointId: 'keep-mine' | 'accept-incoming' } + // Only apply changes for endpoints marked as 'accept-incoming' or not in decisions (default: apply) + if (diff.modified?.length > 0 && newCollection) { + for (const endpoint of diff.modified) { + // Check if user chose to keep their version + const endpointId = endpoint.id || `${endpoint.method.toUpperCase()}:${normalizeUrlPath(endpoint.path)}`; + const decision = endpointDecisions[endpointId]; + if (decision === 'keep-mine') { + continue; + } + + // Apply incoming changes for this endpoint + const normalizedPath = normalizeUrlPath(endpoint.path); + const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path); + const newItem = result?.item; + const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath); + + if (newItem && existingFile) { + const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem); + const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat }); + await writeFile(existingFile.filePath, content); + } + } + } + + // Handle drifted endpoints to reset (collection differs from stored spec) + // These are endpoints where user chose 'accept-incoming' to reset to spec + if (driftedToReset?.length > 0) { + // Reuse newCollection if available, otherwise fall back to stored spec + let driftCollection = newCollection; + if (!driftCollection) { + const applySpecEntry = getSpecEntryForUrl(collectionPath, sourceUrl); + const storedSpecPath = applySpecEntry ? path.join(getSpecsDir(), applySpecEntry.filename) : null; + if (storedSpecPath && fs.existsSync(storedSpecPath)) { + try { + driftCollection = openApiToBruno(parseSpec(fs.readFileSync(storedSpecPath, 'utf8')), { groupBy }); + } catch (err) { + console.error('[OpenAPI Sync] Error converting stored spec for drift reset:', err); + } + } + } + + if (driftCollection) { + const specItemsMap = buildSpecItemsMap(driftCollection.items || []); + + for (const endpoint of driftedToReset) { + const specItem = specItemsMap.get(endpoint.id); + if (!specItem) { + continue; + } + + if (endpoint.filePath) { + const fullPath = path.resolve(collectionPath, endpoint.filePath); + if (!isPathInsideCollection(fullPath, collectionPath)) { + console.error(`[OpenAPI Sync] Path traversal blocked in driftedToReset: ${endpoint.filePath}`); + continue; + } + if (fs.existsSync(fullPath)) { + try { + const fileFormat = fullPath.endsWith('.yml') || fullPath.endsWith('.yaml') ? 'yml' : 'bru'; + const existingContent = fs.readFileSync(fullPath, 'utf8'); + const existingRequest = parseRequest(existingContent, { format: fileFormat }); + const mergedRequest = mergeSpecIntoRequest(existingRequest, specItem, { fullReset: true }); + const content = await stringifyRequestViaWorker(mergedRequest, { format: fileFormat }); + await writeFile(fullPath, content); + } catch (err) { + console.error(`[OpenAPI Sync] Error resetting drifted endpoint ${endpoint.id}:`, err); + } + } + } + } + } + } + + // Save spec only if we have a valid spec + if (diff.newSpec && typeof diff.newSpec === 'object') { + const specContent = diff.newSpecContent || JSON.stringify(diff.newSpec, null, 2); + await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl }); + } + + const openapiSync = brunoConfig.openapi || []; + const syncIdx = openapiSync.findIndex((e) => e.sourceUrl === sourceUrl); + if (syncIdx !== -1) { + const updated = { + ...openapiSync[syncIdx], + lastSyncDate: new Date().toISOString() + }; + // Only update specHash when we have a valid newSpec, otherwise preserve existing hash + if (diff.newSpec) { + updated.specHash = generateSpecHash(diff.newSpec); + } + openapiSync[syncIdx] = updated; + } + brunoConfig.openapi = openapiSync; + + await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); + + return { success: true }; + } catch (error) { + console.error('Error applying OpenAPI sync:', error); + throw error; + } + }); + + // Update OpenAPI sync configuration (e.g., source URL) + ipcMain.handle('renderer:update-openapi-sync-config', async (event, { collectionPath, oldSourceUrl, config }) => { + try { + const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath); + + // Merge new config into existing entry (allowlist keys only) + const allowedKeys = ['sourceUrl', 'groupBy', 'lastSyncDate', 'specHash', 'autoCheck', 'autoCheckInterval']; + const sanitizedConfig = {}; + for (const key of allowedKeys) { + if (key in config) { + sanitizedConfig[key] = config[key]; + } + } + + // sourceUrl is required — it identifies which entry to create/update + if (!sanitizedConfig.sourceUrl) { + throw new Error('sourceUrl is required to update openapi sync config'); + } + + // Validate sourceUrl — reject protocol-based non-http(s) URLs (e.g. ftp://, file://) + if (sanitizedConfig.sourceUrl.includes('://') && !isValidHttpUrl(sanitizedConfig.sourceUrl)) { + throw new Error('Invalid URL: only http and https URLs are allowed'); + } + + // Convert absolute local file paths to collection-relative (git-shareable) + if (path.isAbsolute(sanitizedConfig.sourceUrl)) { + sanitizedConfig.sourceUrl = path.relative(collectionPath, sanitizedConfig.sourceUrl); + } + + // If sourceUrl is changing, remove the old entry and its metadata + const openapi = brunoConfig.openapi || []; + if (oldSourceUrl && oldSourceUrl !== sanitizedConfig.sourceUrl) { + const filteredOpenapi = openapi.filter((e) => e.sourceUrl !== oldSourceUrl); + brunoConfig.openapi = filteredOpenapi; + // Clean up metadata entry for old sourceUrl (keep spec file for potential re-use) + const meta = loadSpecMetadata(); + if (meta[collectionPath]) { + meta[collectionPath] = meta[collectionPath].filter((e) => e.sourceUrl !== oldSourceUrl); + if (meta[collectionPath].length === 0) delete meta[collectionPath]; + saveSpecMetadata(meta); + } + } + + // Apply defaults for new entries + const updatedOpenapi = brunoConfig.openapi || []; + const idx = updatedOpenapi.findIndex((e) => e.sourceUrl === sanitizedConfig.sourceUrl); + const isNewEntry = idx === -1; + if (isNewEntry) { + if (!('autoCheck' in sanitizedConfig)) sanitizedConfig.autoCheck = true; + if (!('autoCheckInterval' in sanitizedConfig)) sanitizedConfig.autoCheckInterval = 5; + updatedOpenapi.push(sanitizedConfig); + } else { + updatedOpenapi[idx] = { ...updatedOpenapi[idx], ...sanitizedConfig }; + } + brunoConfig.openapi = updatedOpenapi; + + // Save updated config + await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); + + return { success: true }; + } catch (error) { + console.error('Error updating OpenAPI sync config:', error); + throw error; + } + }); + + // Save OpenAPI spec file and update sync metadata (used by both connect and import flows) + ipcMain.handle('renderer:save-openapi-spec', async (event, { collectionPath, specContent, sourceUrl }) => { + try { + await saveSpecAndUpdateMetadata({ collectionPath, specContent, sourceUrl }); + return { success: true }; + } catch (error) { + console.error('Error saving OpenAPI spec file:', error); + throw error; + } + }); + + // Fetch OpenAPI spec content from a remote URL or local file path + ipcMain.handle('renderer:fetch-openapi-spec', async (event, { + collectionUid, collectionPath, sourceUrl, environmentContext + }) => { + try { + const result = await fetchSpecFromSource({ collectionUid, collectionPath, sourceUrl, environmentContext }); + if (result.error) return { error: result.error, errorCode: result.errorCode }; + if (!isValidOpenApiSpec(result.spec)) { + const error = result.spec?.swagger + ? 'Swagger 2.0 is not supported. Please convert your spec to OpenAPI 3.x.' + : 'The source does not contain a valid OpenAPI 3.x specification'; + return { error }; + } + return { content: result.content }; + } catch (error) { + return { error: error.message || 'Failed to fetch spec' }; + } + }); + + // Read stored OpenAPI spec file from AppData + ipcMain.handle('renderer:read-openapi-spec', async (event, { collectionPath, sourceUrl }) => { + try { + const entry = getSpecEntryForUrl(collectionPath, sourceUrl); + if (!entry) return { error: 'Spec file not found' }; + const specPath = path.join(getSpecsDir(), entry.filename); + if (!fs.existsSync(specPath)) return { error: 'Spec file not found' }; + return { content: fs.readFileSync(specPath, 'utf8') }; + } catch (error) { + return { error: error.message || 'Failed to read spec file' }; + } + }); + + // Remove OpenAPI sync configuration (disconnect sync) + ipcMain.handle('renderer:remove-openapi-sync-config', async (event, { collectionPath, sourceUrl, deleteSpecFile = false }) => { + try { + const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath); + + // Remove matching openapi entry from config array + if (brunoConfig.openapi?.length) { + brunoConfig.openapi = brunoConfig.openapi.filter((e) => e.sourceUrl !== sourceUrl); + if (brunoConfig.openapi.length === 0) { + delete brunoConfig.openapi; + } + } + + // Save updated config + await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); + + // Remove spec file from AppData if user opted in + const meta = loadSpecMetadata(); + const entries = meta[collectionPath] || []; + const entry = entries.find((e) => e.sourceUrl === sourceUrl); + if (entry && deleteSpecFile) { + const specPath = path.join(getSpecsDir(), entry.filename); + if (fs.existsSync(specPath)) fs.unlinkSync(specPath); + } + meta[collectionPath] = entries.filter((e) => e.sourceUrl !== sourceUrl); + if (meta[collectionPath].length === 0) delete meta[collectionPath]; + saveSpecMetadata(meta); + + return { success: true }; + } catch (error) { + console.error('Error removing OpenAPI sync config:', error); + throw error; + } + }); + + // Add missing endpoints to collection (from stored spec) + ipcMain.handle('renderer:add-missing-endpoints', async (event, { collectionPath, endpoints }) => { + try { + const { format, brunoConfig } = loadBrunoConfig(collectionPath); + const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags'; + const specCollection = loadStoredSpecCollection(collectionPath, brunoConfig); + + let addedCount = 0; + for (const endpoint of endpoints) { + const result = findItemInCollection(specCollection.items, endpoint.method, endpoint.path); + + if (result) { + const { item: specItem, folderName } = result; + let targetFolder = collectionPath; + + // Use folder name from spec collection structure + if (folderName && groupBy === 'tags') { + targetFolder = await ensureTagFolder(collectionPath, folderName, format); + } + + const requestContent = await stringifyRequestViaWorker(specItem, { format }); + const sanitizedFilename = `${sanitizeName(specItem.name || path.basename(specItem.filename || '', `.${format}`))}.${format}`; + await writeFile(path.join(targetFolder, sanitizedFilename), requestContent); + addedCount++; + } + } + + return { success: true, addedCount }; + } catch (error) { + console.error('Error adding missing endpoints:', error); + throw error; + } + }); + + // Reset modified endpoints to match the spec + ipcMain.handle('renderer:reset-endpoints-to-spec', async (event, { collectionPath, endpoints }) => { + try { + const { brunoConfig } = loadBrunoConfig(collectionPath); + const specCollection = loadStoredSpecCollection(collectionPath, brunoConfig); + + let resetCount = 0; + for (const endpoint of endpoints) { + // Find the spec version of this endpoint + const specItem = findItemInCollection(specCollection.items, endpoint.method, endpoint.path)?.item; + + if (specItem && endpoint.pathname) { + if (!isPathInsideCollection(endpoint.pathname, collectionPath)) { + console.error(`[OpenAPI Sync] Path traversal blocked in reset-endpoints: ${endpoint.pathname}`); + continue; + } + + try { + const fileFormat = endpoint.pathname.endsWith('.yml') || endpoint.pathname.endsWith('.yaml') ? 'yml' : 'bru'; + const existingContent = fs.readFileSync(endpoint.pathname, 'utf8'); + const existingRequest = parseRequest(existingContent, { format: fileFormat }); + const mergedRequest = mergeSpecIntoRequest(existingRequest, specItem, { fullReset: true }); + const requestContent = await stringifyRequestViaWorker(mergedRequest, { format: fileFormat }); + await writeFile(endpoint.pathname, requestContent); + resetCount++; + } catch (err) { + console.error(`[OpenAPI Sync] Error resetting endpoint ${endpoint.pathname}:`, err); + } + } + } + + return { success: true, resetCount }; + } catch (error) { + console.error('Error resetting endpoints to spec:', error); + throw error; + } + }); + + // Delete endpoints from collection + ipcMain.handle('renderer:delete-endpoints', async (event, { collectionPath, endpoints }) => { + try { + let deletedCount = 0; + + for (const endpoint of endpoints) { + if (endpoint.pathname && fs.existsSync(endpoint.pathname)) { + if (!isPathInsideCollection(endpoint.pathname, collectionPath)) { + console.error(`[OpenAPI Sync] Path traversal blocked in delete-endpoints: ${endpoint.pathname}`); + continue; + } + fs.unlinkSync(endpoint.pathname); + deletedCount++; + } + } + + return { success: true, deletedCount }; + } catch (error) { + console.error('Error deleting endpoints:', error); + throw error; + } + }); +}; + +module.exports = registerOpenAPISyncIpc; +module.exports.saveSpecAndUpdateMetadata = saveSpecAndUpdateMetadata; +module.exports.cleanupSpecFilesForCollection = cleanupSpecFilesForCollection; diff --git a/packages/bruno-filestore/src/formats/yml/parseCollection.ts b/packages/bruno-filestore/src/formats/yml/parseCollection.ts index 55555bdad..602d65aa2 100644 --- a/packages/bruno-filestore/src/formats/yml/parseCollection.ts +++ b/packages/bruno-filestore/src/formats/yml/parseCollection.ts @@ -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) { diff --git a/packages/bruno-filestore/src/formats/yml/stringifyCollection.ts b/packages/bruno-filestore/src/formats/yml/stringifyCollection.ts index 6ceb416fb..d006510d4 100644 --- a/packages/bruno-filestore/src/formats/yml/stringifyCollection.ts +++ b/packages/bruno-filestore/src/formats/yml/stringifyCollection.ts @@ -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);