This commit is contained in:
naman-bruno
2025-12-07 18:41:37 +05:30
parent 33022843f2
commit cd0f1e45ba
38 changed files with 4086 additions and 142 deletions

1601
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 />
) : (
<>

View File

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

View File

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

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

View File

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

View File

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

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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