mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
init
This commit is contained in:
1601
package-lock.json
generated
1601
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,8 @@
|
||||
"idb": "^7.0.0",
|
||||
"immer": "^9.0.15",
|
||||
"jsesc": "^3.0.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"jshint": "^2.13.6",
|
||||
"json5": "^2.2.3",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
@@ -83,6 +85,7 @@
|
||||
"shell-quote": "^1.8.3",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "5.17.12",
|
||||
"system": "^2.0.1",
|
||||
"url": "^0.11.3",
|
||||
"xml-formatter": "^3.5.0",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
const yamlPlugin = (cm) => {
|
||||
cm.defineMode('yaml', function () {
|
||||
var cons = ['true', 'false', 'on', 'off', 'yes', 'no'];
|
||||
var keywordRegex = new RegExp('\\b((' + cons.join(')|(') + '))$', 'i');
|
||||
|
||||
return {
|
||||
token: function (stream, state) {
|
||||
var ch = stream.peek();
|
||||
var esc = state.escaped;
|
||||
state.escaped = false;
|
||||
/* comments */
|
||||
if (ch == '#' && (stream.pos == 0 || /\s/.test(stream.string.charAt(stream.pos - 1)))) {
|
||||
stream.skipToEnd();
|
||||
return 'comment';
|
||||
}
|
||||
|
||||
if (stream.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/)) return 'string';
|
||||
|
||||
if (state.literal && stream.indentation() > state.keyCol) {
|
||||
stream.skipToEnd();
|
||||
return 'string';
|
||||
} else if (state.literal) {
|
||||
state.literal = false;
|
||||
}
|
||||
if (stream.sol()) {
|
||||
state.keyCol = 0;
|
||||
state.pair = false;
|
||||
state.pairStart = false;
|
||||
/* document start */
|
||||
if (stream.match('---')) {
|
||||
return 'def';
|
||||
}
|
||||
/* document end */
|
||||
if (stream.match('...')) {
|
||||
return 'def';
|
||||
}
|
||||
/* array list item */
|
||||
if (stream.match(/\s*-\s+/)) {
|
||||
return 'meta';
|
||||
}
|
||||
}
|
||||
/* inline pairs/lists */
|
||||
if (stream.match(/^(\{|\}|\[|\])/)) {
|
||||
if (ch == '{') state.inlinePairs++;
|
||||
else if (ch == '}') state.inlinePairs--;
|
||||
else if (ch == '[') state.inlineList++;
|
||||
else state.inlineList--;
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
/* list separator */
|
||||
if (state.inlineList > 0 && !esc && ch == ',') {
|
||||
stream.next();
|
||||
return 'meta';
|
||||
}
|
||||
/* pairs separator */
|
||||
if (state.inlinePairs > 0 && !esc && ch == ',') {
|
||||
state.keyCol = 0;
|
||||
state.pair = false;
|
||||
state.pairStart = false;
|
||||
stream.next();
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
/* start of value of a pair */
|
||||
if (state.pairStart) {
|
||||
/* block literals */
|
||||
if (stream.match(/^\s*(\||\>)\s*/)) {
|
||||
state.literal = true;
|
||||
return 'meta';
|
||||
}
|
||||
/* references */
|
||||
if (stream.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i)) {
|
||||
return 'variable-2';
|
||||
}
|
||||
/* numbers */
|
||||
if (state.inlinePairs == 0 && stream.match(/^\s*-?[0-9\.\,]+\s?$/)) {
|
||||
return 'number';
|
||||
}
|
||||
if (state.inlinePairs > 0 && stream.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/)) {
|
||||
return 'number';
|
||||
}
|
||||
/* keywords */
|
||||
if (stream.match(keywordRegex)) {
|
||||
return 'keyword';
|
||||
}
|
||||
}
|
||||
|
||||
/* pairs (associative arrays) -> key */
|
||||
if (
|
||||
!state.pair
|
||||
&& stream.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)
|
||||
) {
|
||||
state.pair = true;
|
||||
state.keyCol = stream.indentation();
|
||||
return 'atom';
|
||||
}
|
||||
if (state.pair && stream.match(/^:\s*/)) {
|
||||
state.pairStart = true;
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
/* nothing found, continue */
|
||||
state.pairStart = false;
|
||||
state.escaped = ch == '\\';
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
startState: function () {
|
||||
return {
|
||||
pair: false,
|
||||
pairStart: false,
|
||||
keyCol: 0,
|
||||
inlinePairs: 0,
|
||||
inlineList: 0,
|
||||
literal: false,
|
||||
escaped: false
|
||||
};
|
||||
},
|
||||
lineComment: '#',
|
||||
fold: 'indent'
|
||||
};
|
||||
});
|
||||
|
||||
cm.defineMIME('text/x-yaml', 'yaml');
|
||||
cm.defineMIME('text/yaml', 'yaml');
|
||||
};
|
||||
|
||||
export default yamlPlugin;
|
||||
@@ -0,0 +1,65 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.CodeMirror {
|
||||
height: calc(100vh - 4rem);
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
font-family: ${(props) => (props.font ? props.font : 'default')};
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog {
|
||||
overflow: visible;
|
||||
input {
|
||||
background: transparent;
|
||||
border: 1px solid #d3d6db;
|
||||
outline: none;
|
||||
border-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #d2d7db;
|
||||
}
|
||||
|
||||
textarea.cm-editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Todo: dark mode temporary fix
|
||||
// Clean this
|
||||
.CodeMirror.cm-s-monokai {
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #444444;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-property,
|
||||
.cm-s-monokai span.cm-attribute {
|
||||
color: #9cdcfe !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-string {
|
||||
color: #ce9178 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-number {
|
||||
color: #b5cea8 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-atom {
|
||||
color: #569cd6 !important;
|
||||
}
|
||||
|
||||
.cm-variable-valid {
|
||||
color: green;
|
||||
}
|
||||
.cm-variable-invalid {
|
||||
color: red;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Copyright (c) 2021 GraphQL Contributors.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import yamlPlugin from './Plugins/Yaml/index';
|
||||
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
}
|
||||
|
||||
export default class CodeEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.cachedValue = props.value || '';
|
||||
this.variables = {};
|
||||
this.lintOptions = {
|
||||
esversion: 11,
|
||||
expr: true,
|
||||
asi: true
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
switch (this.props.mode) {
|
||||
case 'yaml':
|
||||
// YAML linting and hightlighting plugin
|
||||
yamlPlugin(CodeMirror);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
tabSize: 2,
|
||||
mode: this.props.mode || 'application/text',
|
||||
keyMap: 'sublime',
|
||||
autoCloseBrackets: true,
|
||||
matchBrackets: true,
|
||||
showCursorWhenSelecting: true,
|
||||
foldGutter: true,
|
||||
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
|
||||
lint: this.lintOptions,
|
||||
readOnly: this.props.readOnly,
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
'Tab': function (cm) {
|
||||
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
|
||||
? cm.execCommand('indentMore')
|
||||
: cm.replaceSelection(' ', 'end');
|
||||
},
|
||||
'Shift-Tab': 'indentLess',
|
||||
'Ctrl-Space': 'autocomplete',
|
||||
'Cmd-Space': 'autocomplete',
|
||||
'Ctrl-Y': 'foldAll',
|
||||
'Cmd-Y': 'foldAll',
|
||||
'Ctrl-I': 'unfoldAll',
|
||||
'Cmd-I': 'unfoldAll'
|
||||
}
|
||||
}));
|
||||
if (editor) {
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
this.ignoreChangeEvent = true;
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
}
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
|
||||
}
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.editor) {
|
||||
this.editor.refresh();
|
||||
}
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="h-full w-full graphiql-container"
|
||||
aria-label="Code Editor"
|
||||
font={this.props.font}
|
||||
ref={(node) => {
|
||||
this._node = node;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
_onEdit = () => {
|
||||
if (!this.ignoreChangeEvent && this.editor) {
|
||||
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
this.cachedValue = this.editor.getValue();
|
||||
if (this.props.onEdit) {
|
||||
this.props.onEdit(this.cachedValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from './CodeEditor/index';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import { saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { useState } from 'react';
|
||||
|
||||
const FileEditor = ({ apiSpec }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [content, setContent] = useState(apiSpec?.raw);
|
||||
|
||||
const onEdit = (value) => {
|
||||
setContent(value);
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
dispatch(saveApiSpecToFile({ uid: apiSpec?.uid, content }));
|
||||
};
|
||||
|
||||
const hasChanges = Boolean(content != apiSpec?.raw);
|
||||
|
||||
const editorMode = 'yaml';
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow relative">
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={content}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode={editorMode}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
<IconDeviceFloppy
|
||||
onClick={onSave}
|
||||
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer oapcity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
||||
@@ -0,0 +1,19 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.swagger-root {
|
||||
height: calc(100vh - 4rem);
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
|
||||
&.dark {
|
||||
.swagger-ui {
|
||||
filter: invert(88%) hue-rotate(180deg);
|
||||
}
|
||||
.swagger-ui .microlight {
|
||||
filter: invert(100%) hue-rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,19 @@
|
||||
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);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className={`swagger-root w-full overflow-y-scroll ${displayedTheme}`}>
|
||||
<SwaggerUI spec={string} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Swagger;
|
||||
@@ -0,0 +1,22 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.menu-icon {
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
|
||||
}
|
||||
|
||||
div.dropdown-item.menu-item {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.colors.bg.danger};
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.react-tooltip {
|
||||
z-index: 10;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
97
packages/bruno-app/src/components/ApiSpecPanel/index.js
Normal file
97
packages/bruno-app/src/components/ApiSpecPanel/index.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { forwardRef, useRef } from 'react';
|
||||
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 Dropdown from 'components/Dropdown';
|
||||
import { openApiSpec } 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 = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [createApiSpecModalOpen, setCreateApiSpecModalOpen] = useState(false);
|
||||
|
||||
const { apiSpecs, activeApiSpecUid } = useSelector((state) => state.apiSpec);
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
|
||||
const { filename, pathname, raw, uid } = apiSpec || {};
|
||||
if (!uid) {
|
||||
return <div className="p-4 opacity-50">API Spec not found!</div>;
|
||||
}
|
||||
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<IconDots size={22} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleOpenApiSpec = () => {
|
||||
dispatch(openApiSpec()).catch(
|
||||
(err) => console.log(err) && toast.error('An error occurred while opening the API spec')
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col flex-grow relative">
|
||||
{createApiSpecModalOpen ? <CreateApiSpec onClose={() => setCreateApiSpecModalOpen(false)} /> : null}
|
||||
<div className="p-3 mb-2 w-full flex flex-row justify-between grid grid-cols-3">
|
||||
<div className="flex flex-row justify-start gap-x-4 col-span-1">
|
||||
<div className="flex w-fit items-center cursor-pointer">
|
||||
<IconFileCode size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2 mr-4 font-semibold">API Designer</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full col-span-1 flex justify-center" title={pathname}>
|
||||
{filename}
|
||||
</div>
|
||||
<div className="menu-icon pr-2 col-span-1 flex justify-end">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
setCreateApiSpecModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create API Spec
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleOpenApiSpec();
|
||||
}}
|
||||
>
|
||||
Open API Spec
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<section className="main flex flex-grow px-4 relative">
|
||||
<div className="w-full grid grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<FileEditor apiSpec={apiSpec} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Suspense fallback="">
|
||||
<Swagger string={raw} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiSpecPanel;
|
||||
@@ -0,0 +1,11 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.api-specs-badge {
|
||||
margin-inline: 0.5rem;
|
||||
background-color: ${(props) => props.theme.sidebar.badge.bg};
|
||||
border-radius: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IconFileCode } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ApiSpecsBadge = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="items-center mt-2 relative">
|
||||
<div className="api-specs-badge flex items-center justify-between px-2">
|
||||
<div className="flex items-center py-1 select-none">
|
||||
<span className="mr-2">
|
||||
<IconFileCode size={18} strokeWidth={1.5} />
|
||||
</span>
|
||||
<span>APIs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiSpecsBadge;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { setActiveApiSpecUid } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { showApiSpecPage as _showApiSpecPage } from 'providers/ReduxStore/slices/app';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { IconDots } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CloseApiSpec from '../CloseApiSpec/index';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
const ApiSpecItem = ({ apiSpec }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid);
|
||||
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
|
||||
|
||||
const [closeApiSpecModal, setCloseApiSpecModal] = useState(false);
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const handleOpenApiSpec = (apiSpec) => (e) => {
|
||||
dispatch(_showApiSpecPage());
|
||||
dispatch(setActiveApiSpecUid({ uid: apiSpec.uid }));
|
||||
};
|
||||
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<IconDots size={22} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-grow api-spec-item items-center h-full overflow-hidden w-full justify-between ${
|
||||
showApiSpecPage && apiSpec?.uid == activeApiSpecUid ? 'active' : ''
|
||||
}`}
|
||||
>
|
||||
{closeApiSpecModal && <CloseApiSpec apiSpec={apiSpec} onClose={() => setCloseApiSpecModal(false)} />}
|
||||
<div
|
||||
className="cursor-pointer py-2 pl-4 h-8 flex items-center flex-grow w-[80%] justify-between"
|
||||
onClick={handleOpenApiSpec(apiSpec)}
|
||||
>
|
||||
<span className="flex-nowrap whitespace-nowrap overflow-ellipsis overflow-hidden w-full">{apiSpec?.name}</span>
|
||||
</div>
|
||||
<div className="menu-icon pr-2">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
||||
<div
|
||||
className="dropdown-item close-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
setCloseApiSpecModal(true);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiSpecItem;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconFileCode } from '@tabler/icons';
|
||||
import { closeApiSpecFile } from 'providers/ReduxStore/slices/apiSpec';
|
||||
|
||||
const CloseApiSpec = ({ onClose, apiSpec }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onConfirm = () => {
|
||||
dispatch(closeApiSpecFile({ uid: apiSpec.uid }))
|
||||
.then(() => {
|
||||
toast.success('API Spec closed');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while closing the API Spec'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal size="sm" title="Close Api Spec" confirmText="Close" handleConfirm={onConfirm} handleCancel={onClose}>
|
||||
<div className="flex items-center">
|
||||
<IconFileCode size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2 mr-4 font-semibold">{apiSpec.name}</span>
|
||||
</div>
|
||||
<div className="break-words text-xs mt-1">{apiSpec.pathname}</div>
|
||||
<div className="mt-4">
|
||||
Are you sure you want to close API Spec <span className="font-semibold">{apiSpec.name}</span> in Bruno?
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
It will still be available in the file system at the above location and can be re-opened later.
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloseApiSpec;
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.api-spec-file-extension {
|
||||
color: ${(props) => props.theme.colors.text.darkOrange};
|
||||
}
|
||||
select {
|
||||
background: ${(props) => props.theme.bg};
|
||||
}
|
||||
option {
|
||||
background: ${(props) => props.theme.bg};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,326 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import { createApiSpecFile } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { exportApiSpec } from 'utils/exporters/openapi-spec';
|
||||
import { each } from 'lodash';
|
||||
import { showApiSpecPage } from 'providers/ReduxStore/slices/app';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
|
||||
export const getEnvironmentVariablesKeyValuePairs = (envVariables) => {
|
||||
let variables = {};
|
||||
each(envVariables, (variable) => {
|
||||
if (variable.name && variable.value && variable.enabled) {
|
||||
variables[variable.name] = variable.value;
|
||||
}
|
||||
});
|
||||
return variables;
|
||||
};
|
||||
|
||||
const CreateApiSpec = ({ onClose }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const workspaces = useSelector((state) => state.workspaces.workspaces);
|
||||
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const [defaultApiSpecLocation, setDefaultApiSpecLocation] = React.useState('');
|
||||
|
||||
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
|
||||
|
||||
React.useEffect(() => {
|
||||
const getDefaultLocation = async () => {
|
||||
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const apiSpecPath = await ipcRenderer.invoke('renderer:ensure-apispec-folder', activeWorkspace.pathname);
|
||||
setDefaultApiSpecLocation(apiSpecPath);
|
||||
} catch (error) {
|
||||
console.error('Error getting apispec folder:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
getDefaultLocation();
|
||||
}, [activeWorkspace]);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
importFrom: 'blank',
|
||||
collectionLocation: '',
|
||||
environment: '',
|
||||
apiSpecName: '',
|
||||
apiSpecLocation: defaultApiSpecLocation || ''
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
importFrom: Yup.string().oneOf(['blank', 'collection']),
|
||||
collectionLocation: Yup.string().min(1, 'location is required'),
|
||||
environment: Yup.string(),
|
||||
apiSpecName: Yup.string()
|
||||
.min(1, 'Must be at least 1 character')
|
||||
.max(255, 'Must be 255 characters or less')
|
||||
.test('is-valid-filename', function (value) {
|
||||
const isValid = validateName(value);
|
||||
return isValid ? true : this.createError({ message: validateNameError(value) });
|
||||
})
|
||||
.required('Name is required'),
|
||||
apiSpecLocation: Yup.string().min(1, 'location is required').required('location is required')
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
let yamlContent = '';
|
||||
if (values?.importFrom === 'collection' && values?.collectionLocation && collectionData) {
|
||||
const { files, envVariables, processEnvVariables } = collectionData;
|
||||
let variables = {
|
||||
processEnvVariables
|
||||
};
|
||||
// Get selected env's variables
|
||||
if (values?.environment && values?.environment?.length) {
|
||||
variables = {
|
||||
...getEnvironmentVariablesKeyValuePairs(envVariables[values?.environment] || {}),
|
||||
...variables
|
||||
};
|
||||
}
|
||||
// Create API spec yaml
|
||||
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files });
|
||||
if (exportedYamlContentData?.content) {
|
||||
yamlContent = exportedYamlContentData?.content;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(createApiSpecFile(`${values.apiSpecName}.yaml`, values.apiSpecLocation, yamlContent))
|
||||
.then(() => {
|
||||
setTimeout(() => {
|
||||
dispatch(showApiSpecPage());
|
||||
}, 200);
|
||||
toast.success('ApiSpec created');
|
||||
onClose();
|
||||
})
|
||||
.catch((err) => toast.error(err?.message));
|
||||
}
|
||||
});
|
||||
|
||||
const browse = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
// When the user closes the diolog without selecting anything dirPath will be false
|
||||
if (typeof dirPath === 'string') {
|
||||
formik.setFieldValue('apiSpecLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
formik.setFieldValue('apiSpecLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const browseCollection = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
formik.setFieldValue('collectionLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
formik.setFieldValue('collectionLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const [environments, setEnvironments] = useState([]);
|
||||
const [collectionData, setCollectionData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const collectionLocation = formik.values.collectionLocation;
|
||||
if (collectionLocation) {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:get-collection-json', collectionLocation)
|
||||
.then(({ files, name, envVariables, processEnvVariables }) => {
|
||||
setCollectionData({ name, files, envVariables, processEnvVariables });
|
||||
const environments = envVariables || {};
|
||||
const environmentNames = Object.keys(environments);
|
||||
if (environmentNames?.length) {
|
||||
setEnvironments(environments);
|
||||
formik.setFieldValue('environment', environmentNames[0] || '');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error loading collection:', err);
|
||||
toast.error('Failed to load collection');
|
||||
});
|
||||
}
|
||||
}, [formik.values.collectionLocation]);
|
||||
|
||||
const onSubmit = () => formik.handleSubmit();
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Modal size="sm" title="Create API Spec" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
|
||||
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<label htmlFor="api-spec-location" className="block font-semibold mb-2">
|
||||
Template
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="blank"
|
||||
className="cursor-pointer"
|
||||
type="radio"
|
||||
name="importFrom"
|
||||
onChange={formik.handleChange}
|
||||
value="blank"
|
||||
checked={formik.values.importFrom === 'blank'}
|
||||
/>
|
||||
<label htmlFor="blank" className="ml-1 cursor-pointer select-none">
|
||||
Blank spec
|
||||
</label>
|
||||
<input
|
||||
id="collection"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="importFrom"
|
||||
onChange={formik.handleChange}
|
||||
value="collection"
|
||||
checked={formik.values.importFrom === 'collection'}
|
||||
/>
|
||||
<label htmlFor="collection" className="ml-1 cursor-pointer select-none">
|
||||
From Bruno Collection
|
||||
</label>
|
||||
</div>
|
||||
{formik.touched.importFrom && formik.errors.importFrom ? (
|
||||
<div className="text-red-500">{formik.errors.importFrom}</div>
|
||||
) : null}
|
||||
{formik.values.importFrom === 'collection' ? (
|
||||
<>
|
||||
<label htmlFor="collection-location" className="block font-semibold mt-3">
|
||||
Collection Location
|
||||
</label>
|
||||
<input
|
||||
id="collection-location"
|
||||
type="text"
|
||||
name="collectionLocation"
|
||||
readOnly={true}
|
||||
className="block textbox mt-2 w-full cursor-pointer"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
title={formik.values.collectionLocation || ''}
|
||||
value={formik.values.collectionLocation || ''}
|
||||
onClick={browseCollection}
|
||||
/>
|
||||
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
|
||||
<div className="text-red-500">{formik.errors.collectionLocation}</div>
|
||||
) : null}
|
||||
<div className="mt-1">
|
||||
<span className="text-link cursor-pointer hover:underline" onClick={browseCollection}>
|
||||
Browse
|
||||
</span>
|
||||
</div>
|
||||
{environments && Object.keys(environments || {})?.length > 0 ? (
|
||||
<>
|
||||
<label htmlFor="api-spec-name" className="flex items-center font-semibold mt-3">
|
||||
Environment
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={formik.values.environment || ''}
|
||||
onChange={(e) => {
|
||||
formik.setFieldValue('environment', e.target.value);
|
||||
}}
|
||||
className="block textbox mt-2 w-full mousetrap"
|
||||
>
|
||||
{Object.keys(environments).map((env) => (
|
||||
<option key={env} value={env}>
|
||||
{env}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{formik.touched.environment && formik.errors.environment ? (
|
||||
<div className="text-red-500">{formik.errors.environment}</div>
|
||||
) : null}
|
||||
<label htmlFor="api-spec-name" className="flex items-center font-semibold mt-3">
|
||||
Spec Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="api-spec-name"
|
||||
type="text"
|
||||
name="apiSpecName"
|
||||
ref={inputRef}
|
||||
className="block textbox mt-2 !pr-11 w-full"
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={formik.values.apiSpecName || ''}
|
||||
/>
|
||||
<div className="absolute right-2 top-0 bottom-0 h-full flex items-center api-spec-file-extension">
|
||||
.yaml
|
||||
</div>
|
||||
</div>
|
||||
{formik.touched.apiSpecName && formik.errors.apiSpecName ? (
|
||||
<div className="text-red-500">{formik.errors.apiSpecName}</div>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="api-spec-location" className="block font-semibold mt-3">
|
||||
Spec Location
|
||||
</label>
|
||||
<input
|
||||
id="api-spec-location"
|
||||
type="text"
|
||||
name="apiSpecLocation"
|
||||
readOnly={true}
|
||||
className="block textbox mt-2 w-full cursor-pointer"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
title={formik.values.apiSpecLocation || ''}
|
||||
value={formik.values.apiSpecLocation || ''}
|
||||
onClick={browse}
|
||||
/>
|
||||
{formik.touched.apiSpecLocation && formik.errors.apiSpecLocation ? (
|
||||
<div className="text-red-500">{formik.errors.apiSpecLocation}</div>
|
||||
) : null}
|
||||
<div className="mt-1">
|
||||
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
|
||||
Browse
|
||||
</span>
|
||||
{!isDefaultWorkspace && (
|
||||
<span className="text-xs opacity-60 ml-2">
|
||||
(defaults to workspace's apispec folder)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateApiSpec;
|
||||
@@ -0,0 +1,52 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.api-spec-item {
|
||||
&.active {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.bg};
|
||||
}
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
.menu-icon {
|
||||
.dropdown {
|
||||
div[aria-expanded='false'] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
|
||||
|
||||
.dropdown {
|
||||
div[aria-expanded='true'] {
|
||||
visibility: visible;
|
||||
}
|
||||
div[aria-expanded='false'] {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.tippy-box {
|
||||
position: relative;
|
||||
top: -0.625rem;
|
||||
}
|
||||
|
||||
div.dropdown-item.close-item {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.colors.bg.danger};
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
77
packages/bruno-app/src/components/Sidebar/ApiSpecs/index.js
Normal file
77
packages/bruno-app/src/components/Sidebar/ApiSpecs/index.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import ApiSpecItem from './ApiSpecItem';
|
||||
import ApiSpecsBadge from './ApiSpecBadge';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const LinkStyle = styled.span`
|
||||
color: ${(props) => props.theme['text-link']};
|
||||
`;
|
||||
|
||||
const ApiSpecs = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { theme } = useTheme();
|
||||
const allApiSpecs = useSelector((state) => state.apiSpec.apiSpecs);
|
||||
const workspaces = useSelector((state) => state.workspaces.workspaces);
|
||||
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
const apiSpecs = React.useMemo(() => {
|
||||
if (!activeWorkspace) return [];
|
||||
|
||||
const workspaceApiSpecs = activeWorkspace.apiSpecs || [];
|
||||
|
||||
// Map workspace API specs to loaded API specs from Redux store
|
||||
return workspaceApiSpecs.map((ws) => {
|
||||
const loadedApiSpec = allApiSpecs.find((apiSpec) => apiSpec.pathname === ws.path);
|
||||
return loadedApiSpec;
|
||||
}).filter(Boolean);
|
||||
}, [allApiSpecs, activeWorkspace, activeWorkspace?.apiSpecs]);
|
||||
|
||||
const handleOpenApiSpec = () => {
|
||||
dispatch(openApiSpec()).catch(
|
||||
(err) => console.log(err) && toast.error('An error occurred while opening the API spec')
|
||||
);
|
||||
};
|
||||
|
||||
const OpenLink = () => (
|
||||
<LinkStyle className="underline text-link cursor-pointer" theme={theme} onClick={() => handleOpenApiSpec()}>
|
||||
Open
|
||||
</LinkStyle>
|
||||
);
|
||||
|
||||
if (!apiSpecs || !apiSpecs.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<ApiSpecsBadge />
|
||||
<div className="text-xs text-center placeholder mt-4">
|
||||
<div>No API Specs found.</div>
|
||||
<div className="mt-2">
|
||||
<OpenLink /> API Spec.
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="relative">
|
||||
<ApiSpecsBadge />
|
||||
<div className="flex flex-col top-32 bottom-10 left-0 right-0 py-4">
|
||||
{apiSpecs && apiSpecs.length
|
||||
? apiSpecs.map((apiSpec) => {
|
||||
return <ApiSpecItem apiSpec={apiSpec} key={apiSpec.uid} />;
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiSpecs;
|
||||
@@ -7,6 +7,7 @@ import CreateOrOpenCollection from './CreateOrOpenCollection';
|
||||
import CollectionSearch from './CollectionSearch/index';
|
||||
import { useMemo } from 'react';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import ApiSpecs from '../ApiSpecs/index';
|
||||
|
||||
const Collections = ({ showSearch }) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
@@ -51,6 +52,8 @@ const Collections = ({ showSearch }) => {
|
||||
);
|
||||
})
|
||||
: null}
|
||||
<div className="w-full my-2" style={{ height: 1 }}></div>
|
||||
<ApiSpecs />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -8,10 +8,6 @@ const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Section Title (single view mode) - with separator */
|
||||
&.single-view {
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IconDeviceDesktop,
|
||||
IconDotsVertical,
|
||||
IconDownload,
|
||||
IconFileCode,
|
||||
IconFolder,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -19,22 +20,19 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
|
||||
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
|
||||
|
||||
import RemoveCollectionsModal from '../Collections/RemoveCollectionsModal/index';
|
||||
import CreateCollection from '../CreateCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const VIEW_TABS = [
|
||||
{ id: 'collections', label: 'Collections', icon: IconBox }
|
||||
];
|
||||
|
||||
const SidebarHeader = ({ setShowSearch, activeView = 'collections', onViewChange }) => {
|
||||
const SidebarHeader = ({ setShowSearch }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
@@ -48,6 +46,7 @@ const SidebarHeader = ({ setShowSearch, activeView = 'collections', onViewChange
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
const [createApiSpecModalOpen, setCreateApiSpecModalOpen] = useState(false);
|
||||
|
||||
const handleImportCollection = ({ rawData, type }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
@@ -148,6 +147,13 @@ const SidebarHeader = ({ setShowSearch, activeView = 'collections', onViewChange
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenApiSpec = () => {
|
||||
dispatch(openApiSpec()).catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while opening the API spec');
|
||||
});
|
||||
};
|
||||
|
||||
const renderModals = () => (
|
||||
<>
|
||||
{createCollectionModalOpen && (
|
||||
@@ -169,11 +175,14 @@ const SidebarHeader = ({ setShowSearch, activeView = 'collections', onViewChange
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
)}
|
||||
{createApiSpecModalOpen && (
|
||||
<CreateApiSpec
|
||||
onClose={() => setCreateApiSpecModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const isSingleView = VIEW_TABS.length === 1;
|
||||
|
||||
// Render Collections-specific actions
|
||||
const renderCollectionsActions = () => (
|
||||
<>
|
||||
@@ -227,6 +236,27 @@ const SidebarHeader = ({ setShowSearch, activeView = 'collections', onViewChange
|
||||
Open collection
|
||||
</div>
|
||||
|
||||
<div className="label-item mt-2">API Specs</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
setCreateApiSpecModalOpen(true);
|
||||
addDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
>
|
||||
<IconPlus size={16} stroke={1.5} className="icon" />
|
||||
Create API Spec
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
handleOpenApiSpec();
|
||||
addDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
>
|
||||
<IconFileCode size={16} stroke={1.5} className="icon" />
|
||||
Open API Spec
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{/* Actions dropdown (sort, close all, etc.) */}
|
||||
@@ -277,57 +307,18 @@ const SidebarHeader = ({ setShowSearch, activeView = 'collections', onViewChange
|
||||
</>
|
||||
);
|
||||
|
||||
// Render Second Tab-specific actions
|
||||
const renderSecondTabActions = () => (
|
||||
<>
|
||||
{/* Add second tab actions here */}
|
||||
</>
|
||||
);
|
||||
|
||||
// Render the view switcher - either tabs or single title
|
||||
const renderViewSwitcher = () => {
|
||||
if (isSingleView) {
|
||||
// Single view - just show the title
|
||||
const tab = VIEW_TABS[0];
|
||||
const TabIcon = tab.icon;
|
||||
return (
|
||||
<div className="section-title">
|
||||
<TabIcon size={14} stroke={1.5} />
|
||||
<span>{tab.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple views - show segmented tabs
|
||||
return (
|
||||
<div className="view-tabs">
|
||||
{VIEW_TABS.map((tab) => {
|
||||
const TabIcon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`view-tab ${activeView === tab.id ? 'active' : ''}`}
|
||||
onClick={() => onViewChange?.(tab.id)}
|
||||
title={tab.label}
|
||||
>
|
||||
<TabIcon size={14} stroke={1.5} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={isSingleView ? 'single-view' : ''}>
|
||||
<StyledWrapper>
|
||||
{renderModals()}
|
||||
<div className="sidebar-header">
|
||||
{renderViewSwitcher()}
|
||||
<div className="section-title">
|
||||
<IconBox size={14} stroke={1.5} />
|
||||
<span>Collections</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Context Sensitive */}
|
||||
<div className="header-actions">
|
||||
{activeView === 'collections' ? renderCollectionsActions() : renderSecondTabActions()}
|
||||
{renderCollectionsActions()}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -15,7 +15,6 @@ const Sidebar = () => {
|
||||
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
|
||||
const lastWidthRef = useRef(leftSidebarWidth);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [activeView, setActiveView] = useState('collections'); // 'collections' or any other future tab
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -85,18 +84,8 @@ const Sidebar = () => {
|
||||
<div className="flex flex-col flex-grow" style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
<SidebarHeader
|
||||
setShowSearch={setShowSearch}
|
||||
activeView={activeView}
|
||||
onViewChange={setActiveView}
|
||||
/>
|
||||
{activeView === 'collections' ? (
|
||||
<Collections showSearch={showSearch} />
|
||||
) : (
|
||||
<div className="second-tab-placeholder">
|
||||
<p className="text-center text-muted py-8 px-4 text-sm opacity-60">
|
||||
Second tab content will appear here
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Collections showSearch={showSearch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import RequestTabPanel from 'components/RequestTabPanel';
|
||||
import Sidebar from 'components/Sidebar';
|
||||
import StatusBar from 'components/StatusBar';
|
||||
import AppTitleBar from 'components/AppTitleBar';
|
||||
import ApiSpecPanel from 'components/ApiSpecPanel';
|
||||
// import ErrorCapture from 'components/ErrorCapture';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
@@ -13,6 +14,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import 'codemirror/theme/material.css';
|
||||
import 'codemirror/theme/monokai.css';
|
||||
import 'codemirror/addon/scroll/simplescrollbars.css';
|
||||
import 'swagger-ui-react/swagger-ui.css';
|
||||
import Devtools from 'components/Devtools';
|
||||
import useGrpcEventListeners from 'utils/network/grpc-event-listeners';
|
||||
import useWsEventListeners from 'utils/network/ws-event-listeners';
|
||||
@@ -52,8 +54,10 @@ require('utils/codemirror/autocomplete');
|
||||
|
||||
export default function Main() {
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid);
|
||||
const isDragging = useSelector((state) => state.app.isDragging);
|
||||
const showHomePage = useSelector((state) => state.app.showHomePage);
|
||||
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
|
||||
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
|
||||
const mainSectionRef = useRef(null);
|
||||
const [showRosettaBanner, setShowRosettaBanner] = useState(false);
|
||||
@@ -113,7 +117,9 @@ export default function Main() {
|
||||
<StyledWrapper className={className} style={{ height: '100%', zIndex: 1 }}>
|
||||
<Sidebar />
|
||||
<section className="flex flex-grow flex-col overflow-hidden">
|
||||
{showHomePage ? (
|
||||
{showApiSpecPage && activeApiSpecUid ? (
|
||||
<ApiSpecPanel key={activeApiSpecUid} />
|
||||
) : showHomePage ? (
|
||||
<WorkspaceHome />
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'provide
|
||||
import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { addLog } from 'providers/ReduxStore/slices/logs';
|
||||
import { updateSystemResources } from 'providers/ReduxStore/slices/performance';
|
||||
import { apiSpecAddFileEvent, apiSpecChangeFileEvent } from 'providers/ReduxStore/slices/apiSpec';
|
||||
|
||||
const useIpcEvents = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -91,10 +92,25 @@ const useIpcEvents = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const _apiSpecTreeUpdated = (type, val) => {
|
||||
if (window.__IS_DEV__) {
|
||||
console.log('API Spec update:', type);
|
||||
console.log(val);
|
||||
}
|
||||
if (type === 'addFile') {
|
||||
dispatch(apiSpecAddFileEvent({ data: val }));
|
||||
}
|
||||
if (type === 'changeFile') {
|
||||
dispatch(apiSpecChangeFileEvent({ data: val }));
|
||||
}
|
||||
};
|
||||
|
||||
ipcRenderer.invoke('renderer:ready');
|
||||
|
||||
const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated);
|
||||
|
||||
const removeApiSpecTreeUpdateListener = ipcRenderer.on('main:apispec-tree-updated', _apiSpecTreeUpdated);
|
||||
|
||||
const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', (pathname, uid, brunoConfig) => {
|
||||
dispatch(openCollectionEvent(uid, pathname, brunoConfig));
|
||||
});
|
||||
@@ -267,6 +283,7 @@ const useIpcEvents = () => {
|
||||
|
||||
return () => {
|
||||
removeCollectionTreeUpdateListener();
|
||||
removeApiSpecTreeUpdateListener();
|
||||
removeOpenCollectionListener();
|
||||
removeOpenWorkspaceListener();
|
||||
removeWorkspaceConfigUpdatedListener();
|
||||
|
||||
@@ -9,6 +9,7 @@ import globalEnvironmentsReducer from './slices/global-environments';
|
||||
import logsReducer from './slices/logs';
|
||||
import performanceReducer from './slices/performance';
|
||||
import workspacesReducer from './slices/workspaces';
|
||||
import apiSpecReducer from './slices/apiSpec';
|
||||
import { draftDetectMiddleware } from './middlewares/draft/middleware';
|
||||
import { autosaveMiddleware } from './middlewares/autosave/middleware';
|
||||
|
||||
@@ -30,7 +31,8 @@ export const store = configureStore({
|
||||
globalEnvironments: globalEnvironmentsReducer,
|
||||
logs: logsReducer,
|
||||
performance: performanceReducer,
|
||||
workspaces: workspacesReducer
|
||||
workspaces: workspacesReducer,
|
||||
apiSpec: apiSpecReducer
|
||||
},
|
||||
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware)
|
||||
});
|
||||
|
||||
162
packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js
Normal file
162
packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { find } from 'lodash';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const initialState = {
|
||||
apiSpecs: [],
|
||||
activeApiSpecUid: null
|
||||
};
|
||||
|
||||
export const apiSpecSlice = createSlice({
|
||||
name: 'apiSpec',
|
||||
initialState,
|
||||
reducers: {
|
||||
apiSpecAddFileEvent: (state, action) => {
|
||||
const { name, raw, uid, filename, pathname, json } = action?.payload?.data || {};
|
||||
if (!uid) {
|
||||
toast.error('Error adding API spec');
|
||||
}
|
||||
const apiSpec = findApiSpecByUid(state.apiSpecs, uid);
|
||||
if (apiSpec) {
|
||||
apiSpec.raw = raw;
|
||||
apiSpec.name = name;
|
||||
apiSpec.filename = filename;
|
||||
apiSpec.pathname = pathname;
|
||||
apiSpec.json = json;
|
||||
} else {
|
||||
const newApiSpec = {
|
||||
name,
|
||||
raw,
|
||||
uid,
|
||||
filename,
|
||||
pathname,
|
||||
json
|
||||
};
|
||||
state.apiSpecs.push(newApiSpec);
|
||||
}
|
||||
state.activeApiSpecUid = uid;
|
||||
},
|
||||
apiSpecChangeFileEvent: (state, action) => {
|
||||
const { name, raw, uid, filename, pathname, json } = action?.payload?.data || {};
|
||||
if (!uid) return;
|
||||
|
||||
const apiSpec = findApiSpecByUid(state.apiSpecs, uid);
|
||||
if (apiSpec) {
|
||||
apiSpec.raw = raw;
|
||||
apiSpec.name = name;
|
||||
apiSpec.filename = filename;
|
||||
apiSpec.pathname = pathname;
|
||||
apiSpec.json = json;
|
||||
}
|
||||
},
|
||||
saveApiSpec: (state, action) => {
|
||||
const { content, uid } = action.payload;
|
||||
const apiSpec = findApiSpecByUid(state.apiSpecs, uid);
|
||||
if (apiSpec) {
|
||||
apiSpec.raw = content;
|
||||
}
|
||||
},
|
||||
setActiveApiSpecUid: (state, action) => {
|
||||
state.activeApiSpecUid = action.payload.uid;
|
||||
},
|
||||
removeApiSpec: (state, action) => {
|
||||
const { uid } = action.payload;
|
||||
let apiSpecIndex = state.apiSpecs.findIndex((c) => c.uid == uid);
|
||||
state.apiSpecs = state.apiSpecs.filter((c) => c.uid !== uid);
|
||||
let shiftedApiSpec = state.apiSpecs.at(apiSpecIndex);
|
||||
let lastApiSpec = state.apiSpecs.at(-1);
|
||||
state.activeApiSpecUid = shiftedApiSpec?.uid || lastApiSpec?.uid || null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const { apiSpecAddFileEvent, apiSpecChangeFileEvent, saveApiSpec, removeApiSpec, setActiveApiSpecUid } = apiSpecSlice.actions;
|
||||
|
||||
export default apiSpecSlice.reducer;
|
||||
|
||||
const findApiSpecByUid = (apiSpecs, uid) => {
|
||||
return find(apiSpecs, (apiSpec) => apiSpec.uid === uid);
|
||||
};
|
||||
|
||||
export const openApiSpec = (workspacePath = null) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
if (!workspacePath) {
|
||||
const state = getState();
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
workspacePath = activeWorkspace?.pathname || null;
|
||||
}
|
||||
|
||||
ipcRenderer.invoke('renderer:open-api-spec', workspacePath).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const saveApiSpecToFile
|
||||
= ({ uid, content }) =>
|
||||
(dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const apiSpec = findApiSpecByUid(state.apiSpec.apiSpecs, uid);
|
||||
const { pathname } = apiSpec;
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-api-spec', pathname, content)
|
||||
.then(() => {
|
||||
dispatch(saveApiSpec({ content, uid }));
|
||||
toast.success('Saved API spec successfully!');
|
||||
resolve();
|
||||
})
|
||||
.catch((reject) => {
|
||||
toast.error('Error saving file');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const createApiSpecFile = (apiSpecName, apiSpecLocation, content, workspacePath = null) => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
if (!workspacePath) {
|
||||
const state = getState();
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
workspacePath = activeWorkspace?.pathname || null;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer.invoke('renderer:create-api-spec', apiSpecName, apiSpecLocation, content, workspacePath).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const closeApiSpecFile
|
||||
= ({ uid }) =>
|
||||
(dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const apiSpec = findApiSpecByUid(state.apiSpec.apiSpecs, uid);
|
||||
if (!apiSpec) {
|
||||
return reject(new Error('API Spec not found'));
|
||||
}
|
||||
if (apiSpec) {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
const workspacePath = activeWorkspace?.pathname || null;
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:remove-api-spec', apiSpec.pathname, workspacePath)
|
||||
.then(async () => {
|
||||
dispatch(removeApiSpec({ uid }));
|
||||
|
||||
if (activeWorkspace) {
|
||||
const { loadWorkspaceApiSpecs } = require('./workspaces/actions');
|
||||
await dispatch(loadWorkspaceApiSpecs(activeWorkspace.uid));
|
||||
}
|
||||
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
return;
|
||||
});
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const initialState = {
|
||||
screenWidth: 500,
|
||||
showHomePage: false,
|
||||
showPreferences: false,
|
||||
showApiSpecPage: false,
|
||||
isEnvironmentSettingsModalOpen: false,
|
||||
isGlobalEnvironmentSettingsModalOpen: false,
|
||||
preferences: {
|
||||
@@ -72,10 +73,19 @@ export const appSlice = createSlice({
|
||||
},
|
||||
showHomePage: (state) => {
|
||||
state.showHomePage = true;
|
||||
state.showApiSpecPage = false;
|
||||
},
|
||||
hideHomePage: (state) => {
|
||||
state.showHomePage = false;
|
||||
},
|
||||
showApiSpecPage: (state) => {
|
||||
state.showHomePage = false;
|
||||
state.showPreferences = false;
|
||||
state.showApiSpecPage = true;
|
||||
},
|
||||
hideApiSpecPage: (state) => {
|
||||
state.showApiSpecPage = false;
|
||||
},
|
||||
showPreferences: (state, action) => {
|
||||
state.showPreferences = action.payload;
|
||||
},
|
||||
@@ -122,6 +132,8 @@ export const {
|
||||
updateGlobalEnvironmentSettingsModalVisibility,
|
||||
showHomePage,
|
||||
hideHomePage,
|
||||
showApiSpecPage,
|
||||
hideApiSpecPage,
|
||||
showPreferences,
|
||||
updatePreferences,
|
||||
updateCookies,
|
||||
|
||||
@@ -183,11 +183,47 @@ const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => {
|
||||
await openCollectionsFunction(collectionPaths, updatedWorkspace.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
// Load API specs for this workspace
|
||||
await dispatch(loadWorkspaceApiSpecs(workspace.uid));
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspace collections:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadWorkspaceApiSpecs = (workspaceUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace || !workspace.pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiSpecs = await ipcRenderer.invoke('renderer:load-workspace-apispecs', workspace.pathname);
|
||||
|
||||
dispatch(updateWorkspace({
|
||||
uid: workspaceUid,
|
||||
apiSpecs: apiSpecs
|
||||
}));
|
||||
|
||||
const allApiSpecs = getState().apiSpec.apiSpecs;
|
||||
const alreadyOpenApiSpecs = allApiSpecs.map((a) => a.pathname);
|
||||
|
||||
for (const apiSpec of apiSpecs) {
|
||||
if (apiSpec.path && !alreadyOpenApiSpecs.includes(apiSpec.path)) {
|
||||
try {
|
||||
await ipcRenderer.invoke('renderer:open-api-spec-file', apiSpec.path, workspace.pathname);
|
||||
} catch (error) {
|
||||
console.error('Error opening API spec:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading workspace API specs:', error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const switchWorkspace = (workspaceUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch(setActiveWorkspace(workspaceUid));
|
||||
@@ -337,7 +373,7 @@ export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspa
|
||||
return;
|
||||
}
|
||||
|
||||
const { collections, ...configWithoutCollections } = workspaceConfig;
|
||||
const { collections, apiSpecs, ...configWithoutCollections } = workspaceConfig;
|
||||
|
||||
dispatch(updateWorkspace({
|
||||
uid: workspaceUid,
|
||||
@@ -364,6 +400,9 @@ export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspa
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load API specs when workspace config is updated
|
||||
await dispatch(loadWorkspaceApiSpecs(workspaceUid));
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
427
packages/bruno-app/src/utils/exporters/openapi-spec.js
Normal file
427
packages/bruno-app/src/utils/exporters/openapi-spec.js
Normal file
@@ -0,0 +1,427 @@
|
||||
import jsyaml from 'js-yaml';
|
||||
import { interpolate } from '@usebruno/common';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
const xml2js = require('xml2js');
|
||||
|
||||
export const exportApiSpec = ({ variables, items, name }) => {
|
||||
items = items.filter((item) => !['grpc-request'].includes(item.type));
|
||||
|
||||
const components = {
|
||||
schemas: {},
|
||||
requestBodies: {},
|
||||
securitySchemes: {}
|
||||
};
|
||||
|
||||
const servers = [];
|
||||
const warnings = [];
|
||||
|
||||
const addWarning = (message, itemName) => {
|
||||
warnings.push({
|
||||
message,
|
||||
itemName
|
||||
});
|
||||
};
|
||||
|
||||
const addUrlToServersList = (url) => {
|
||||
if (!servers?.find((s) => s?.url === url)) {
|
||||
servers.push({ url });
|
||||
}
|
||||
};
|
||||
|
||||
const extractTagFromDepth = (item) => {
|
||||
const { pathname, depth } = item;
|
||||
if (!pathname) return;
|
||||
|
||||
const parts = pathname.split('\\');
|
||||
const baseDepth = parts.length - depth;
|
||||
if (depth === 1) return '';
|
||||
|
||||
const tagIndex = Math.max(baseDepth, 0);
|
||||
|
||||
return parts[tagIndex];
|
||||
};
|
||||
|
||||
const generatePaths = () => {
|
||||
const _items = items.map((item) => {
|
||||
let url = interpolate(item?.request?.url, variables);
|
||||
if (isValidUrl(url)) {
|
||||
let urlDetails = new URL(url);
|
||||
urlDetails?.pathname && (url = urlDetails?.pathname);
|
||||
urlDetails?.origin && addUrlToServersList(urlDetails?.origin);
|
||||
}
|
||||
const { request } = item;
|
||||
const { method, params = [], headers = [], body, auth } = request || {};
|
||||
|
||||
// PARAMS
|
||||
|
||||
const pathParamsRegex = /(?<!{){([^{}]+)}(?!})/g;
|
||||
|
||||
const pathMatches = url.match(pathParamsRegex) || [];
|
||||
|
||||
const parameters = [
|
||||
...params?.map((param) => ({
|
||||
name: param?.name,
|
||||
in: 'query',
|
||||
description: '',
|
||||
required: param?.enabled,
|
||||
example: param?.value
|
||||
})),
|
||||
...headers?.map((header) => ({
|
||||
name: header?.name,
|
||||
in: 'header',
|
||||
description: '',
|
||||
required: header?.enabled,
|
||||
example: header?.value
|
||||
})),
|
||||
...pathMatches?.map((path) => ({
|
||||
name: path.slice(1, path.length - 1),
|
||||
in: 'path',
|
||||
required: true
|
||||
}))
|
||||
];
|
||||
|
||||
const pathBody = {
|
||||
summary: item?.name,
|
||||
operationId: item?.name,
|
||||
description: '',
|
||||
tags: [extractTagFromDepth(item)],
|
||||
responses: {
|
||||
200: {
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (parameters?.length) {
|
||||
pathBody['parameters'] = parameters;
|
||||
}
|
||||
|
||||
// BODY
|
||||
|
||||
let schemaId = `${item?.name?.split(' ').join('_').toLowerCase()}`;
|
||||
let securitySchemaId = `${item?.name?.split(' ').join('_').toLowerCase()}`;
|
||||
let requestBodyId = `${item?.name?.split(' ').join('_').toLowerCase()}`;
|
||||
if (body?.mode) {
|
||||
switch (body?.mode) {
|
||||
case 'json':
|
||||
if (!body?.json) break;
|
||||
try {
|
||||
const parsedJson = JSON.parse(body.json);
|
||||
components.schemas[schemaId] = generateProperyShape(parsedJson);
|
||||
components.requestBodies[requestBodyId] = {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: `#/components/schemas/${schemaId}`
|
||||
}
|
||||
}
|
||||
},
|
||||
description: '',
|
||||
required: true
|
||||
};
|
||||
pathBody['requestBody'] = {
|
||||
$ref: `#/components/requestBodies/${requestBodyId}`
|
||||
};
|
||||
} catch (error) {
|
||||
addWarning(`Failed to parse JSON in request body: ${error.message}`, item?.name);
|
||||
components.schemas[schemaId] = {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
};
|
||||
components.requestBodies[requestBodyId] = {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: `#/components/schemas/${schemaId}`
|
||||
}
|
||||
}
|
||||
},
|
||||
description: '',
|
||||
required: true
|
||||
};
|
||||
pathBody['requestBody'] = {
|
||||
$ref: `#/components/requestBodies/${requestBodyId}`
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'xml':
|
||||
if (!body?.xml) break;
|
||||
try {
|
||||
const jsonResult = xmlToJson(body?.xml);
|
||||
if (!jsonResult) {
|
||||
addWarning('Failed to parse XML in request body', item?.name);
|
||||
break;
|
||||
}
|
||||
components.schemas[schemaId] = generateProperyShape(jsonResult);
|
||||
components.requestBodies[requestBodyId] = {
|
||||
content: {
|
||||
'application/xml': {
|
||||
schema: {
|
||||
$ref: `#/components/schemas/${schemaId}`
|
||||
}
|
||||
}
|
||||
},
|
||||
description: '',
|
||||
required: true
|
||||
};
|
||||
pathBody['requestBody'] = {
|
||||
$ref: `#/components/requestBodies/${requestBodyId}`
|
||||
};
|
||||
} catch (error) {
|
||||
addWarning(`Failed to parse XML in request body: ${error.message}`, item?.name);
|
||||
}
|
||||
break;
|
||||
case 'multipartForm':
|
||||
if (!body?.multipartForm) return;
|
||||
let multipartFormToKeyValue = body?.multipartForm.reduce((acc, f) => {
|
||||
acc[f?.name] = f.value;
|
||||
return acc;
|
||||
}, {});
|
||||
components.schemas[schemaId] = generateProperyShape(multipartFormToKeyValue);
|
||||
components.requestBodies[requestBodyId] = {
|
||||
content: {
|
||||
'multipart/form-data:': {
|
||||
schema: {
|
||||
$ref: `#/components/schemas/${schemaId}`
|
||||
}
|
||||
}
|
||||
},
|
||||
description: '',
|
||||
required: true
|
||||
};
|
||||
pathBody['requestBody'] = {
|
||||
$ref: `#/components/requestBodies/${requestBodyId}`
|
||||
};
|
||||
case 'formUrlEncoded':
|
||||
if (!body?.formUrlEncoded) return;
|
||||
let formUrlEncodedToKeyValue = body?.formUrlEncoded.reduce((acc, f) => {
|
||||
acc[f?.name] = f.value;
|
||||
return acc;
|
||||
}, {});
|
||||
components.schemas[schemaId] = generateProperyShape(formUrlEncodedToKeyValue);
|
||||
components.requestBodies[requestBodyId] = {
|
||||
content: {
|
||||
'application/x-www-form-urlencoded:': {
|
||||
schema: {
|
||||
$ref: `#/components/schemas/${schemaId}`
|
||||
}
|
||||
}
|
||||
},
|
||||
description: '',
|
||||
required: true
|
||||
};
|
||||
pathBody['requestBody'] = {
|
||||
$ref: `#/components/requestBodies/${requestBodyId}`
|
||||
};
|
||||
case 'text':
|
||||
if (!body?.text) return;
|
||||
pathBody['requestBody'] = {
|
||||
content: {
|
||||
'text/plain': {
|
||||
schema: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// AUTH
|
||||
|
||||
if (auth?.mode) {
|
||||
switch (auth?.mode) {
|
||||
case 'basic':
|
||||
components.securitySchemes[securitySchemaId] = {
|
||||
type: 'http',
|
||||
scheme: 'basic'
|
||||
};
|
||||
pathBody['security'] = {
|
||||
[securitySchemaId]: []
|
||||
};
|
||||
break;
|
||||
case 'bearer':
|
||||
components.securitySchemes[securitySchemaId] = {
|
||||
type: 'http',
|
||||
scheme: 'bearer'
|
||||
};
|
||||
pathBody['security'] = {
|
||||
[securitySchemaId]: []
|
||||
};
|
||||
break;
|
||||
case 'oauth2':
|
||||
if (!auth?.oauth2?.grantType) break;
|
||||
const { authorizationUrl, accessTokenUrl, callbackUrl, scope } = auth?.oauth2;
|
||||
switch (auth?.oauth2?.grantType) {
|
||||
case 'authorization_code':
|
||||
components.securitySchemes[securitySchemaId] = {
|
||||
type: 'oauth2',
|
||||
flows: {
|
||||
authorizationCode: {
|
||||
authorizationUrl,
|
||||
tokenUrl: accessTokenUrl,
|
||||
...(scope.length > 0
|
||||
? {
|
||||
scopes: {
|
||||
[scope]: ''
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
};
|
||||
pathBody['security'] = {
|
||||
[securitySchemaId]: []
|
||||
};
|
||||
break;
|
||||
case 'password':
|
||||
components.securitySchemes[securitySchemaId] = {
|
||||
type: 'oauth2',
|
||||
flows: {
|
||||
password: {
|
||||
tokenUrl: accessTokenUrl,
|
||||
...(scope.length > 0
|
||||
? {
|
||||
scopes: {
|
||||
[scope]: ''
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
};
|
||||
pathBody['security'] = {
|
||||
[securitySchemaId]: []
|
||||
};
|
||||
break;
|
||||
case 'client_credentials':
|
||||
components.securitySchemes[securitySchemaId] = {
|
||||
type: 'oauth2',
|
||||
flows: {
|
||||
password: {
|
||||
tokenUrl: accessTokenUrl,
|
||||
...(scope.length > 0
|
||||
? {
|
||||
scopes: {
|
||||
[scope]: ''
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
};
|
||||
pathBody['security'] = {
|
||||
[securitySchemaId]: []
|
||||
};
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'awsv4':
|
||||
components.securitySchemes[securitySchemaId] = {
|
||||
'type': 'apiKey',
|
||||
'name': 'Authorization',
|
||||
'in': 'header',
|
||||
'x-amazon-apigateway-authtype': 'awsSigv4'
|
||||
};
|
||||
pathBody['security'] = {
|
||||
[securitySchemaId]: []
|
||||
};
|
||||
break;
|
||||
case 'digest':
|
||||
components.securitySchemes[securitySchemaId] = {
|
||||
type: 'digest',
|
||||
scheme: 'digest',
|
||||
description: 'Digest Authentication'
|
||||
};
|
||||
pathBody['security'] = {
|
||||
[securitySchemaId]: []
|
||||
};
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
method: method.toLowerCase(),
|
||||
data: pathBody
|
||||
};
|
||||
});
|
||||
|
||||
return _items.reduce((acc, item) => {
|
||||
if (!acc[item?.url]) {
|
||||
acc[item?.url] = {};
|
||||
}
|
||||
acc[item?.url][item?.method] = item?.data;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const collectionToExport = {};
|
||||
collectionToExport.openapi = '3.0.0';
|
||||
collectionToExport.info = generateInfoSection(name);
|
||||
collectionToExport.paths = generatePaths();
|
||||
collectionToExport.servers = servers;
|
||||
collectionToExport.components = components;
|
||||
|
||||
let yaml = jsyaml.dump(collectionToExport);
|
||||
|
||||
return {
|
||||
content: yaml,
|
||||
warnings
|
||||
};
|
||||
};
|
||||
|
||||
const xmlToJson = (xmlString) => {
|
||||
const parser = new xml2js.Parser({ explicitArray: false, trim: true });
|
||||
let jsonResult = null;
|
||||
|
||||
parser.parseString(xmlString, (err, result) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
} else {
|
||||
jsonResult = result;
|
||||
}
|
||||
});
|
||||
|
||||
return jsonResult;
|
||||
};
|
||||
|
||||
const generateInfoSection = (name) => {
|
||||
return {
|
||||
title: name,
|
||||
version: '1.0.0'
|
||||
};
|
||||
};
|
||||
|
||||
const generateProperyShape = (obj) => {
|
||||
let data = {};
|
||||
|
||||
// add 'type'
|
||||
if (Array.isArray(obj)) {
|
||||
data['type'] = 'array';
|
||||
data['items'] = {
|
||||
type: 'string'
|
||||
};
|
||||
} else {
|
||||
data['type'] = typeof obj;
|
||||
}
|
||||
|
||||
// add 'properties'
|
||||
let properties = null;
|
||||
if (obj && typeof obj == 'object') {
|
||||
properties = {};
|
||||
let keys = Object.keys(obj);
|
||||
keys.forEach((key) => {
|
||||
let value = obj[key];
|
||||
properties[key] = generateProperyShape(value);
|
||||
});
|
||||
if (keys.length) {
|
||||
data['properties'] = properties;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
110
packages/bruno-electron/src/app/apiSpecs.js
Normal file
110
packages/bruno-electron/src/app/apiSpecs.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const { dialog, ipcMain } = require('electron');
|
||||
const { isDirectory, normalizeAndResolvePath } = require('../utils/filesystem');
|
||||
const { generateUidBasedOnHash } = require('../utils/common');
|
||||
|
||||
const openApiSpecDialog = async (win, watcher, options = {}) => {
|
||||
const { filePaths } = await dialog.showOpenDialog(win, {
|
||||
properties: ['openFile', 'createFile']
|
||||
});
|
||||
|
||||
if (filePaths && filePaths[0]) {
|
||||
const resolvedPath = normalizeAndResolvePath(filePaths[0]);
|
||||
try {
|
||||
openApiSpec(win, watcher, resolvedPath, options);
|
||||
} catch (err) {
|
||||
console.error(`[ERROR] Cannot open API spec: "${resolvedPath}"`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
|
||||
try {
|
||||
const uid = generateUidBasedOnHash(apiSpecPath);
|
||||
|
||||
if (options.workspacePath) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const workspaceFilePath = path.join(options.workspacePath, 'workspace.yml');
|
||||
|
||||
if (fs.existsSync(workspaceFilePath)) {
|
||||
const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8');
|
||||
const workspaceConfig = yaml.load(yamlContent);
|
||||
|
||||
workspaceConfig.apiSpecs = workspaceConfig.apiSpecs || [];
|
||||
|
||||
let relativePath = apiSpecPath;
|
||||
try {
|
||||
const relPath = path.relative(options.workspacePath, apiSpecPath);
|
||||
if (!relPath.startsWith('..') && !path.isAbsolute(relPath)) {
|
||||
relativePath = relPath;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Using absolute path for API spec:', error.message);
|
||||
}
|
||||
|
||||
const apiSpecName = path.basename(apiSpecPath, path.extname(apiSpecPath));
|
||||
const apiSpecEntry = {
|
||||
name: apiSpecName,
|
||||
path: relativePath
|
||||
};
|
||||
|
||||
const existingApiSpec = workspaceConfig.apiSpecs.find((a) => {
|
||||
const existingPath = path.isAbsolute(a.path)
|
||||
? a.path
|
||||
: path.resolve(options.workspacePath, a.path);
|
||||
return existingPath === apiSpecPath || a.name === apiSpecName;
|
||||
});
|
||||
|
||||
if (!existingApiSpec) {
|
||||
workspaceConfig.apiSpecs.push(apiSpecEntry);
|
||||
|
||||
const updatedYamlContent = yaml.dump(workspaceConfig, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true
|
||||
});
|
||||
fs.writeFileSync(workspaceFilePath, updatedYamlContent);
|
||||
|
||||
// Notify frontend that workspace config was updated
|
||||
const workspaceUid = generateUidBasedOnHash(options.workspacePath);
|
||||
win.webContents.send('main:workspace-config-updated', options.workspacePath, workspaceUid, workspaceConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!watcher.hasWatcher(apiSpecPath)) {
|
||||
ipcMain.emit('main:apispec-opened', win, apiSpecPath, uid, options.workspacePath);
|
||||
} else {
|
||||
win.webContents.send('main:apispec-tree-updated', 'addFile', {
|
||||
pathname: apiSpecPath,
|
||||
uid: uid,
|
||||
raw: require('fs').readFileSync(apiSpecPath, 'utf8'),
|
||||
name: require('path').basename(apiSpecPath, require('path').extname(apiSpecPath)),
|
||||
filename: require('path').basename(apiSpecPath),
|
||||
json: (() => {
|
||||
const ext = require('path').extname(apiSpecPath).toLowerCase();
|
||||
const content = require('fs').readFileSync(apiSpecPath, 'utf8');
|
||||
if (ext === '.yaml' || ext === '.yml') {
|
||||
return require('js-yaml').load(content);
|
||||
} else if (ext === '.json') {
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!options.dontSendDisplayErrors) {
|
||||
win.webContents.send('main:display-error', {
|
||||
error: err.message || 'An error occurred while opening the apiSpec'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
openApiSpec,
|
||||
openApiSpecDialog
|
||||
};
|
||||
135
packages/bruno-electron/src/app/apiSpecsWatcher.js
Normal file
135
packages/bruno-electron/src/app/apiSpecsWatcher.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const _ = require('lodash');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const chokidar = require('chokidar');
|
||||
const { getApiSpecUid } = require('../cache/apiSpecUids');
|
||||
const yaml = require('js-yaml');
|
||||
const { isDirectory } = require('../utils/filesystem');
|
||||
const { safeParseJSON } = require('../utils/common');
|
||||
|
||||
const hasApiSpecExtension = (filename) => {
|
||||
if (!filename || typeof filename !== 'string') return false;
|
||||
return ['yaml', 'yml', 'json'].some((ext) => filename.toLowerCase().endsWith(`.${ext}`));
|
||||
};
|
||||
|
||||
const parseApiSpecContent = (pathname) => {
|
||||
const extension = path.extname(pathname).toLowerCase();
|
||||
let content = fs.readFileSync(pathname, 'utf8');
|
||||
|
||||
if (extension === '.yaml' || extension === '.yml') {
|
||||
return yaml.load(content);
|
||||
} else if (extension === '.json') {
|
||||
return safeParseJSON(content);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const hydrateApiSpecWithUuid = (apiSpec, pathname) => {
|
||||
apiSpec.uid = getApiSpecUid(pathname);
|
||||
return apiSpec;
|
||||
};
|
||||
|
||||
const add = async (win, pathname) => {
|
||||
if (!hasApiSpecExtension(pathname)) return;
|
||||
try {
|
||||
const basename = path.basename(pathname);
|
||||
const file = {};
|
||||
const apiSpecContent = parseApiSpecContent(pathname);
|
||||
|
||||
file.raw = fs.readFileSync(pathname, 'utf8');
|
||||
file.name = apiSpecContent?.info?.title || basename.split('.')[0];
|
||||
file.filename = basename;
|
||||
file.pathname = pathname;
|
||||
file.json = apiSpecContent;
|
||||
hydrateApiSpecWithUuid(file, pathname);
|
||||
win.webContents.send('main:apispec-tree-updated', 'addFile', file);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const change = async (win, pathname) => {
|
||||
if (!hasApiSpecExtension(pathname)) return;
|
||||
try {
|
||||
const basename = path.basename(pathname);
|
||||
const file = {};
|
||||
const apiSpecContent = parseApiSpecContent(pathname);
|
||||
|
||||
file.raw = fs.readFileSync(pathname, 'utf8');
|
||||
file.name = apiSpecContent?.info?.title || basename.split('.')[0];
|
||||
file.filename = basename;
|
||||
file.pathname = pathname;
|
||||
file.json = apiSpecContent;
|
||||
hydrateApiSpecWithUuid(file, pathname);
|
||||
win.webContents.send('main:apispec-tree-updated', 'changeFile', file);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
class ApiSpecWatcher {
|
||||
constructor() {
|
||||
this.watchers = {};
|
||||
this.watcherWorkspaces = {};
|
||||
}
|
||||
|
||||
addWatcher(win, watchPath, apiSpecUid, brunoConfig, workspacePath = null) {
|
||||
// Avoid creating watcher for directories
|
||||
if (isDirectory(watchPath)) return;
|
||||
|
||||
if (this.watchers[watchPath]) {
|
||||
this.watchers[watchPath].close();
|
||||
}
|
||||
|
||||
if (workspacePath) {
|
||||
this.watcherWorkspaces[watchPath] = workspacePath;
|
||||
}
|
||||
|
||||
const ignores = brunoConfig?.ignore || [];
|
||||
const self = this;
|
||||
setTimeout(() => {
|
||||
const watcher = chokidar.watch(watchPath, {
|
||||
ignoreInitial: false,
|
||||
usePolling: watchPath.startsWith('\\\\') ? true : false,
|
||||
ignored: (filepath) => {
|
||||
const normalizedPath = filepath.replace(/\\/g, '/');
|
||||
const relativePath = path.relative(watchPath, normalizedPath);
|
||||
|
||||
return ignores.some((ignorePattern) => {
|
||||
const normalizedIgnorePattern = ignorePattern.replace(/\\/g, '/');
|
||||
return relativePath === normalizedIgnorePattern || relativePath.startsWith(normalizedIgnorePattern);
|
||||
});
|
||||
},
|
||||
persistent: true,
|
||||
ignorePermissionErrors: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 80,
|
||||
pollInterval: 10
|
||||
},
|
||||
depth: 20
|
||||
});
|
||||
|
||||
watcher
|
||||
.on('add', (pathname) => add(win, pathname, apiSpecUid, watchPath, workspacePath))
|
||||
.on('change', (pathname) => change(win, pathname, apiSpecUid, watchPath, workspacePath));
|
||||
|
||||
self.watchers[watchPath] = watcher;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
hasWatcher(watchPath) {
|
||||
return this.watchers[watchPath];
|
||||
}
|
||||
|
||||
removeWatcher(watchPath, win) {
|
||||
if (this.watchers[watchPath]) {
|
||||
this.watchers[watchPath].close();
|
||||
this.watchers[watchPath] = null;
|
||||
}
|
||||
if (this.watcherWorkspaces[watchPath]) {
|
||||
delete this.watcherWorkspaces[watchPath];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ApiSpecWatcher;
|
||||
44
packages/bruno-electron/src/cache/apiSpecUids.js
vendored
Normal file
44
packages/bruno-electron/src/cache/apiSpecUids.js
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* we maintain a cache of apiSpec uids to ensure that we
|
||||
* preserve the same uid for a apiSpec even when the apiSpec
|
||||
* moves to a different location
|
||||
*
|
||||
* In the past, we used to generate unique ids based on the
|
||||
* pathname of the apiSpec, but we faced problems when implementing
|
||||
* functionality where the user can move the apiSpec to a different
|
||||
* location. In that case, the uid would change, and the we would
|
||||
* lose the apiSpec's draft state if the user has made some changes
|
||||
*/
|
||||
|
||||
const apiSpecUids = new Map();
|
||||
const { uuid } = require('../utils/common');
|
||||
|
||||
const getApiSpecUid = (pathname) => {
|
||||
let uid = apiSpecUids.get(pathname);
|
||||
|
||||
if (!uid) {
|
||||
uid = uuid();
|
||||
apiSpecUids.set(pathname, uid);
|
||||
}
|
||||
|
||||
return uid;
|
||||
};
|
||||
|
||||
const moveApiSpecUid = (oldPathname, newPathname) => {
|
||||
const uid = apiSpecUids.get(oldPathname);
|
||||
|
||||
if (uid) {
|
||||
apiSpecUids.delete(oldPathname);
|
||||
apiSpecUids.set(newPathname, uid);
|
||||
}
|
||||
};
|
||||
|
||||
const removeApiSpecUid = (pathname) => {
|
||||
apiSpecUids.delete(pathname);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getApiSpecUid,
|
||||
moveApiSpecUid,
|
||||
removeApiSpecUid
|
||||
};
|
||||
@@ -39,8 +39,10 @@ const registerFilesystemIpc = require('./ipc/filesystem');
|
||||
const registerPreferencesIpc = require('./ipc/preferences');
|
||||
const registerSystemMonitorIpc = require('./ipc/system-monitor');
|
||||
const registerWorkspaceIpc = require('./ipc/workspace');
|
||||
const registerApiSpecIpc = require('./ipc/apiSpec');
|
||||
const collectionWatcher = require('./app/collection-watcher');
|
||||
const WorkspaceWatcher = require('./app/workspace-watcher');
|
||||
const ApiSpecWatcher = require('./app/apiSpecsWatcher');
|
||||
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
|
||||
const { globalEnvironmentsManager } = require('./store/workspace-environments');
|
||||
const registerNotificationsIpc = require('./ipc/notifications');
|
||||
@@ -58,6 +60,7 @@ const systemMonitor = new SystemMonitor();
|
||||
const terminalManager = new TerminalManager();
|
||||
|
||||
const workspaceWatcher = new WorkspaceWatcher();
|
||||
const apiSpecWatcher = new ApiSpecWatcher();
|
||||
|
||||
// Reference: https://content-security-policy.com/
|
||||
const contentSecurityPolicy = [
|
||||
@@ -238,6 +241,7 @@ app.on('ready', async () => {
|
||||
registerCollectionsIpc(mainWindow, collectionWatcher);
|
||||
registerPreferencesIpc(mainWindow, collectionWatcher);
|
||||
registerWorkspaceIpc(mainWindow, workspaceWatcher);
|
||||
registerApiSpecIpc(mainWindow, apiSpecWatcher);
|
||||
registerNotificationsIpc(mainWindow, collectionWatcher);
|
||||
registerFilesystemIpc(mainWindow);
|
||||
registerSystemMonitorIpc(mainWindow, systemMonitor);
|
||||
|
||||
122
packages/bruno-electron/src/ipc/apiSpec.js
Normal file
122
packages/bruno-electron/src/ipc/apiSpec.js
Normal file
@@ -0,0 +1,122 @@
|
||||
const { ipcMain } = require('electron');
|
||||
const { openApiSpecDialog, openApiSpec } = require('../app/apiSpecs');
|
||||
const { writeFile } = require('../utils/filesystem');
|
||||
const { removeApiSpecUid } = require('../cache/apiSpecUids');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedApiSpecs) => {
|
||||
ipcMain.handle('renderer:open-api-spec', (event, workspacePath = null) => {
|
||||
if (watcher && mainWindow) {
|
||||
openApiSpecDialog(mainWindow, watcher, { workspacePath });
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:open-api-spec-file', (event, apiSpecPath, workspacePath = null) => {
|
||||
if (watcher && mainWindow) {
|
||||
openApiSpec(mainWindow, watcher, apiSpecPath, { workspacePath });
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:save-api-spec', async (event, pathname, content) => {
|
||||
try {
|
||||
await writeFile(pathname, content);
|
||||
Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:create-api-spec', async (event, apiSpecName, apiSpecLocation, content = '', workspacePath = null) => {
|
||||
try {
|
||||
let pathname = path.join(apiSpecLocation, apiSpecName);
|
||||
if (fs.existsSync(pathname)) {
|
||||
throw new Error(`path: ${pathname} already exists`);
|
||||
}
|
||||
await writeFile(pathname, content);
|
||||
openApiSpec(mainWindow, watcher, pathname, { workspacePath });
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:remove-api-spec', async (event, pathname, workspacePath = null) => {
|
||||
try {
|
||||
if (watcher && mainWindow) {
|
||||
watcher.removeWatcher(pathname, mainWindow);
|
||||
removeApiSpecUid(pathname);
|
||||
|
||||
if (workspacePath) {
|
||||
const yaml = require('js-yaml');
|
||||
const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
|
||||
|
||||
if (fs.existsSync(workspaceFilePath)) {
|
||||
const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8');
|
||||
const workspaceConfig = yaml.load(yamlContent);
|
||||
|
||||
if (workspaceConfig.apiSpecs) {
|
||||
workspaceConfig.apiSpecs = workspaceConfig.apiSpecs.filter((a) => {
|
||||
const apiSpecPathFromYml = a.path;
|
||||
if (!apiSpecPathFromYml) return true;
|
||||
|
||||
const absoluteApiSpecPath = path.isAbsolute(apiSpecPathFromYml)
|
||||
? apiSpecPathFromYml
|
||||
: path.resolve(workspacePath, apiSpecPathFromYml);
|
||||
|
||||
return absoluteApiSpecPath !== pathname;
|
||||
});
|
||||
|
||||
const updatedYamlContent = yaml.dump(workspaceConfig, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true
|
||||
});
|
||||
fs.writeFileSync(workspaceFilePath, updatedYamlContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:fetch-api-spec', async (event, url) => {
|
||||
try {
|
||||
const data = await fetch(url).then((res) => res.text());
|
||||
return data;
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:ensure-apispec-folder', async (event, workspacePath) => {
|
||||
try {
|
||||
const apiSpecPath = path.join(workspacePath, 'apispec');
|
||||
if (!fs.existsSync(apiSpecPath)) {
|
||||
fs.mkdirSync(apiSpecPath, { recursive: true });
|
||||
}
|
||||
return apiSpecPath;
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedApiSpecs) => {
|
||||
ipcMain.handle('main:open-api-spec', () => {
|
||||
if (watcher && mainWindow) {
|
||||
openApiSpecDialog(mainWindow, watcher);
|
||||
}
|
||||
});
|
||||
ipcMain.on('main:apispec-opened', (win, pathname, uid, workspacePath = null) => {
|
||||
watcher.addWatcher(win, pathname, uid, {}, workspacePath);
|
||||
});
|
||||
};
|
||||
|
||||
const registerApiSpecIpc = (mainWindow, watcher, lastOpenedApiSpecs) => {
|
||||
registerRendererEventHandlers(mainWindow, watcher, lastOpenedApiSpecs);
|
||||
registerMainEventHandlers(mainWindow, watcher, lastOpenedApiSpecs);
|
||||
};
|
||||
|
||||
module.exports = registerApiSpecIpc;
|
||||
@@ -13,8 +13,10 @@ const {
|
||||
stringifyCollection,
|
||||
parseFolder,
|
||||
stringifyFolder,
|
||||
stringifyEnvironment
|
||||
stringifyEnvironment,
|
||||
parseEnvironment
|
||||
} = require('@usebruno/filestore');
|
||||
const { dotenvToJson } = require('@usebruno/lang');
|
||||
const brunoConverters = require('@usebruno/converters');
|
||||
const { postmanToBruno } = brunoConverters;
|
||||
const { cookiesStore } = require('../store/cookies');
|
||||
@@ -41,7 +43,11 @@ const {
|
||||
copyPath,
|
||||
removePath,
|
||||
getPaths,
|
||||
generateUniqueName
|
||||
generateUniqueName,
|
||||
isDotEnvFile,
|
||||
isBrunoConfigFile,
|
||||
isBruEnvironmentConfig,
|
||||
isCollectionRootBruFile
|
||||
} = require('../utils/filesystem');
|
||||
const { openCollectionDialog, openCollectionsByPathname } = require('../app/collections');
|
||||
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
|
||||
@@ -1540,6 +1546,116 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:get-collection-json', async (event, collectionPath) => {
|
||||
let variables = {};
|
||||
let name = '';
|
||||
const getBruFilesRecursively = async (dir) => {
|
||||
const getFilesInOrder = async (dir) => {
|
||||
let bruJsons = [];
|
||||
|
||||
const traverse = async (currentPath) => {
|
||||
const filesInCurrentDir = fs.readdirSync(currentPath);
|
||||
|
||||
if (currentPath.includes('node_modules')) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of filesInCurrentDir) {
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
|
||||
if (stats.isDirectory() && !filePath.startsWith('.git') && !filePath.startsWith('node_modules')) {
|
||||
await traverse(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
const currentDirBruJsons = [];
|
||||
for (const file of filesInCurrentDir) {
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
|
||||
if (isBrunoConfigFile(filePath, collectionPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const brunoConfig = JSON.parse(content);
|
||||
|
||||
name = brunoConfig?.name;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDotEnvFile(filePath, collectionPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const jsonData = dotenvToJson(content);
|
||||
variables = {
|
||||
...variables,
|
||||
processEnvVariables: {
|
||||
...process.env,
|
||||
...jsonData
|
||||
}
|
||||
};
|
||||
continue;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (isBruEnvironmentConfig(filePath, collectionPath)) {
|
||||
try {
|
||||
let bruContent = fs.readFileSync(filePath, 'utf8');
|
||||
const environmentFilepathBasename = path.basename(filePath);
|
||||
const environmentName = environmentFilepathBasename.substring(0, environmentFilepathBasename.length - 4);
|
||||
let data = await parseEnvironment(bruContent);
|
||||
variables = {
|
||||
...variables,
|
||||
envVariables: {
|
||||
...(variables?.envVariables || {}),
|
||||
[path.basename(filePath)]: data.variables
|
||||
}
|
||||
};
|
||||
continue;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (isCollectionRootBruFile(filePath, collectionPath)) {
|
||||
try {
|
||||
let bruContent = fs.readFileSync(filePath, 'utf8');
|
||||
let data = await parseCollection(bruContent);
|
||||
// TODO
|
||||
continue;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
if (!stats.isDirectory() && path.extname(filePath) === '.bru' && file !== 'folder.bru') {
|
||||
const bruContent = fs.readFileSync(filePath, 'utf8');
|
||||
const bruJson = parseRequest(bruContent);
|
||||
|
||||
currentDirBruJsons.push({
|
||||
...bruJson
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bruJsons = bruJsons.concat(currentDirBruJsons);
|
||||
};
|
||||
|
||||
await traverse(dir);
|
||||
return bruJsons;
|
||||
};
|
||||
|
||||
const orderedFiles = await getFilesInOrder(dir);
|
||||
return orderedFiles;
|
||||
};
|
||||
|
||||
const files = await getBruFilesRecursively(collectionPath);
|
||||
return { name, files, ...variables };
|
||||
});
|
||||
};
|
||||
|
||||
const registerMainEventHandlers = (mainWindow, watcher) => {
|
||||
|
||||
@@ -150,6 +150,43 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:load-workspace-apispecs', async (event, workspacePath) => {
|
||||
try {
|
||||
if (!workspacePath) {
|
||||
throw new Error('Workspace path is undefined');
|
||||
}
|
||||
|
||||
const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
|
||||
|
||||
if (!fs.existsSync(workspaceFilePath)) {
|
||||
throw new Error('Invalid workspace: workspace.yml not found');
|
||||
}
|
||||
|
||||
const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8');
|
||||
const workspaceConfig = yaml.load(yamlContent);
|
||||
|
||||
if (!workspaceConfig || typeof workspaceConfig !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const apiSpecs = workspaceConfig.apiSpecs || [];
|
||||
|
||||
const resolvedApiSpecs = apiSpecs.map((apiSpec) => {
|
||||
if (apiSpec.path && !path.isAbsolute(apiSpec.path)) {
|
||||
return {
|
||||
...apiSpec,
|
||||
path: path.join(workspacePath, apiSpec.path)
|
||||
};
|
||||
}
|
||||
return apiSpec;
|
||||
});
|
||||
|
||||
return resolvedApiSpecs;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:get-last-opened-workspaces', async () => {
|
||||
try {
|
||||
const workspaces = lastOpenedWorkspaces.getAll();
|
||||
|
||||
@@ -417,6 +417,35 @@ const isLargeFile = (filePath, threshold = 10 * 1024 * 1024) => {
|
||||
return size > threshold;
|
||||
};
|
||||
|
||||
const isDotEnvFile = (pathname, collectionPath) => {
|
||||
const dirname = path.dirname(pathname);
|
||||
const basename = path.basename(pathname);
|
||||
|
||||
return dirname === collectionPath && basename === '.env';
|
||||
};
|
||||
|
||||
const isBrunoConfigFile = (pathname, collectionPath) => {
|
||||
const dirname = path.dirname(pathname);
|
||||
const basename = path.basename(pathname);
|
||||
|
||||
return dirname === collectionPath && basename === 'bruno.json';
|
||||
};
|
||||
|
||||
const isBruEnvironmentConfig = (pathname, collectionPath) => {
|
||||
const dirname = path.dirname(pathname);
|
||||
const envDirectory = path.join(collectionPath, 'environments');
|
||||
const basename = path.basename(pathname);
|
||||
|
||||
return dirname === envDirectory && hasBruExtension(basename);
|
||||
};
|
||||
|
||||
const isCollectionRootBruFile = (pathname, collectionPath) => {
|
||||
const dirname = path.dirname(pathname);
|
||||
const basename = path.basename(pathname);
|
||||
|
||||
return dirname === collectionPath && basename === 'collection.bru';
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
isValidPathname,
|
||||
exists,
|
||||
@@ -450,5 +479,9 @@ module.exports = {
|
||||
getPaths,
|
||||
isLargeFile,
|
||||
generateUniqueName,
|
||||
getCollectionFormat
|
||||
getCollectionFormat,
|
||||
isDotEnvFile,
|
||||
isBrunoConfigFile,
|
||||
isBruEnvironmentConfig,
|
||||
isCollectionRootBruFile
|
||||
};
|
||||
|
||||
@@ -65,7 +65,8 @@ const createWorkspaceConfig = (workspaceName) => ({
|
||||
type: WORKSPACE_TYPE,
|
||||
version: '1.0.0',
|
||||
docs: '',
|
||||
collections: []
|
||||
collections: [],
|
||||
apiSpecs: []
|
||||
});
|
||||
|
||||
const readWorkspaceConfig = (workspacePath) => {
|
||||
@@ -211,6 +212,84 @@ const getWorkspaceCollections = (workspacePath) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getWorkspaceApiSpecs = (workspacePath) => {
|
||||
const config = readWorkspaceConfig(workspacePath);
|
||||
const apiSpecs = config.apiSpecs || [];
|
||||
|
||||
// Resolve relative paths to absolute
|
||||
return apiSpecs.map((apiSpec) => {
|
||||
if (apiSpec.path && !path.isAbsolute(apiSpec.path)) {
|
||||
return {
|
||||
...apiSpec,
|
||||
path: path.join(workspacePath, apiSpec.path)
|
||||
};
|
||||
}
|
||||
return apiSpec;
|
||||
});
|
||||
};
|
||||
|
||||
const addApiSpecToWorkspace = async (workspacePath, apiSpec) => {
|
||||
const config = readWorkspaceConfig(workspacePath);
|
||||
|
||||
if (!config.apiSpecs) {
|
||||
config.apiSpecs = [];
|
||||
}
|
||||
|
||||
// Normalize API spec entry with relative path
|
||||
const normalizedApiSpec = {
|
||||
name: apiSpec.name,
|
||||
path: makeRelativePath(workspacePath, apiSpec.path)
|
||||
};
|
||||
|
||||
// Check if API spec already exists
|
||||
const existingIndex = config.apiSpecs.findIndex(
|
||||
(a) => a.name === normalizedApiSpec.name || a.path === normalizedApiSpec.path
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
config.apiSpecs[existingIndex] = normalizedApiSpec;
|
||||
} else {
|
||||
config.apiSpecs.push(normalizedApiSpec);
|
||||
}
|
||||
|
||||
await writeWorkspaceConfig(workspacePath, config);
|
||||
return config.apiSpecs;
|
||||
};
|
||||
|
||||
const removeApiSpecFromWorkspace = async (workspacePath, apiSpecPath) => {
|
||||
const config = readWorkspaceConfig(workspacePath);
|
||||
|
||||
if (!config.apiSpecs) {
|
||||
return { removedApiSpec: null, updatedConfig: config };
|
||||
}
|
||||
|
||||
let removedApiSpec = null;
|
||||
|
||||
config.apiSpecs = config.apiSpecs.filter((a) => {
|
||||
const apiSpecPathFromYml = a.path;
|
||||
if (!apiSpecPathFromYml) return true;
|
||||
|
||||
// Convert to absolute path for comparison
|
||||
const absoluteApiSpecPath = path.isAbsolute(apiSpecPathFromYml)
|
||||
? apiSpecPathFromYml
|
||||
: path.resolve(workspacePath, apiSpecPathFromYml);
|
||||
|
||||
if (path.normalize(absoluteApiSpecPath) === path.normalize(apiSpecPath)) {
|
||||
removedApiSpec = a;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
await writeWorkspaceConfig(workspacePath, config);
|
||||
|
||||
return {
|
||||
removedApiSpec,
|
||||
updatedConfig: config
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
makeRelativePath,
|
||||
normalizeCollectionEntry,
|
||||
@@ -224,5 +303,8 @@ module.exports = {
|
||||
updateWorkspaceDocs,
|
||||
addCollectionToWorkspace,
|
||||
removeCollectionFromWorkspace,
|
||||
getWorkspaceCollections
|
||||
getWorkspaceCollections,
|
||||
getWorkspaceApiSpecs,
|
||||
addApiSpecToWorkspace,
|
||||
removeApiSpecFromWorkspace
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user