mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-04 01:48:33 +00:00
Compare commits
37 Commits
workspaces
...
feat/toolb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dc76e023a | ||
|
|
2c34844172 | ||
|
|
2f8986624a | ||
|
|
0197ae37c8 | ||
|
|
cf969dfcd6 | ||
|
|
a66be21523 | ||
|
|
4016754d71 | ||
|
|
f3aebf6374 | ||
|
|
f87460b00e | ||
|
|
354e8d7496 | ||
|
|
dc107f8b96 | ||
|
|
cd0f1e45ba | ||
|
|
33022843f2 | ||
|
|
facdf3264a | ||
|
|
4ffb447c53 | ||
|
|
3e5ae613f5 | ||
|
|
42bef4ae1e | ||
|
|
e93e545b81 | ||
|
|
4a8d787f31 | ||
|
|
f5211f6a08 | ||
|
|
57222d2500 | ||
|
|
f479e0d325 | ||
|
|
5302addda0 | ||
|
|
80b017f224 | ||
|
|
b18d582004 | ||
|
|
109394c65b | ||
|
|
c355153f26 | ||
|
|
b87a02beb3 | ||
|
|
4624ffb116 | ||
|
|
a9ce97fb1b | ||
|
|
72ce6cadeb | ||
|
|
c4ff2918a2 | ||
|
|
9972eb3de6 | ||
|
|
ebe0203415 | ||
|
|
f7ea1f8dbb | ||
|
|
cf19035b0b | ||
|
|
d9a3f74cb7 |
@@ -6,7 +6,7 @@
|
||||
|
||||
- Use 2 spaces for indentation. No tabs, just spaces – keeps everything neat and uniform.
|
||||
|
||||
- Stick to single quotes for strings. Double quotes are cool elsewhere, but here we go single.
|
||||
- Stick to single quotes for strings. For JSX/TSX attributes, use double quotes (e.g., <svg xmlns="..." viewBox="...">) to follow React conventions.
|
||||
|
||||
- Always add semicolons at the end of statements. It's like putting a period at the end of a sentence – clarity matters.
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
'**/dist/**/*',
|
||||
'**/*.bru',
|
||||
'packages/bruno-js/src/sandbox/bundle-browser-rollup.js',
|
||||
'packages/bruno-app/public/static/**/*'
|
||||
'packages/bruno-app/public/static/**/*',
|
||||
'packages/bruno-app/.next/**/*',
|
||||
'packages/bruno-electron/web/**/*'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
1804
package-lock.json
generated
1804
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;
|
||||
198
packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js
Normal file
198
packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
|
||||
.titlebar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
padding-left: 70px; /* Space for macOS window controls */
|
||||
transition: padding-left 0.15s ease;
|
||||
}
|
||||
|
||||
/* When in full screen, no traffic lights so reduce padding */
|
||||
&.fullscreen .titlebar-content {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* Remove drag region from interactive elements */
|
||||
.workspace-name-container,
|
||||
.dropdown-item,
|
||||
.home-button,
|
||||
.dropdown,
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Left section */
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: 10px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* When in full screen, no traffic lights so remove margin-left */
|
||||
&.fullscreen .titlebar-left {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
/* Workspace Name Dropdown Trigger */
|
||||
.workspace-name-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* Center section - Bruno branding */
|
||||
.titlebar-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
|
||||
.bruno-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Right section */
|
||||
.titlebar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Workspace Dropdown Styles */
|
||||
.workspace-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px !important;
|
||||
margin: 0 !important;
|
||||
|
||||
&.active {
|
||||
.check-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.pin-btn:not(.pinned) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.dropdown.color};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.workspace-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: ${(props) => props.theme.workspace?.accent || props.theme.colors?.text?.yellow};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.dropdown.mutedText};
|
||||
transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
||||
opacity: 0;
|
||||
|
||||
&.pinned {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.dropdown.hoverBg};
|
||||
color: ${(props) => props.theme.dropdown.mutedText};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Adjust for non-macOS platforms */
|
||||
body:not(.os-mac) & {
|
||||
.titlebar-content {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Leave room for Windows caption buttons when the overlay is enabled */
|
||||
body.os-windows & {
|
||||
.titlebar-content {
|
||||
padding-right: 120px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
252
packages/bruno-app/src/components/AppTitleBar/index.js
Normal file
252
packages/bruno-app/src/components/AppTitleBar/index.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import React from 'react';
|
||||
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePreferences, showHomePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
|
||||
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
|
||||
|
||||
import Bruno from 'components/Bruno';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
|
||||
|
||||
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { toTitleCase } from 'utils/common/index';
|
||||
|
||||
const AppTitleBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
|
||||
// Listen for fullscreen changes
|
||||
useEffect(() => {
|
||||
const { ipcRenderer } = window;
|
||||
if (!ipcRenderer) return;
|
||||
|
||||
const removeEnterFullScreenListener = ipcRenderer.on('main:enter-full-screen', () => {
|
||||
setIsFullScreen(true);
|
||||
});
|
||||
|
||||
const removeLeaveFullScreenListener = ipcRenderer.on('main:leave-full-screen', () => {
|
||||
setIsFullScreen(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeEnterFullScreenListener();
|
||||
removeLeaveFullScreenListener();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get workspace info
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
// Sort workspaces according to preferences
|
||||
const sortedWorkspaces = useMemo(() => {
|
||||
return sortWorkspaces(workspaces, preferences);
|
||||
}, [workspaces, preferences]);
|
||||
|
||||
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
|
||||
|
||||
const WorkspaceName = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="workspace-name-container" {...props}>
|
||||
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
|
||||
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleHomeClick = () => {
|
||||
dispatch(showHomePage());
|
||||
};
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
dispatch(switchWorkspace(workspaceUid));
|
||||
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
|
||||
};
|
||||
|
||||
const handleOpenWorkspace = async () => {
|
||||
try {
|
||||
await dispatch(openWorkspaceDialog());
|
||||
toast.success('Workspace opened successfully');
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to open workspace');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePinWorkspace = useCallback((workspaceUid, e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const newPreferences = toggleWorkspacePin(workspaceUid, preferences);
|
||||
dispatch(savePreferences(newPreferences));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
};
|
||||
|
||||
const handleToggleDevtools = () => {
|
||||
if (isConsoleOpen) {
|
||||
dispatch(closeConsole());
|
||||
} else {
|
||||
dispatch(openConsole());
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVerticalLayout = () => {
|
||||
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
layout: {
|
||||
...preferences?.layout || {},
|
||||
responsePaneOrientation: newOrientation
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Build workspace menu items
|
||||
const workspaceMenuItems = useMemo(() => {
|
||||
const items = sortedWorkspaces.map((workspace) => {
|
||||
const isActive = workspace.uid === activeWorkspaceUid;
|
||||
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
|
||||
|
||||
return {
|
||||
id: workspace.uid,
|
||||
label: toTitleCase(workspace.name),
|
||||
onClick: () => handleWorkspaceSwitch(workspace.uid),
|
||||
className: `workspace-item ${isActive ? 'active' : ''}`,
|
||||
rightSection: (
|
||||
<div className="workspace-actions">
|
||||
{workspace.type !== 'default' && (
|
||||
<ActionIcon
|
||||
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
|
||||
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
|
||||
label={isPinned ? 'Unpin workspace' : 'Pin workspace'}
|
||||
size="sm"
|
||||
>
|
||||
{isPinned ? (
|
||||
<IconPinned size={14} stroke={1.5} />
|
||||
) : (
|
||||
<IconPin size={14} stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
)}
|
||||
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
// Add label and action items
|
||||
items.push(
|
||||
{ type: 'label', label: 'Workspaces' },
|
||||
{
|
||||
id: 'create-workspace',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create workspace',
|
||||
onClick: handleCreateWorkspace
|
||||
},
|
||||
{
|
||||
id: 'open-workspace',
|
||||
leftSection: IconFolder,
|
||||
label: 'Open workspace',
|
||||
onClick: handleOpenWorkspace
|
||||
}
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`app-titlebar ${isFullScreen ? 'fullscreen' : ''}`}>
|
||||
{createWorkspaceModalOpen && (
|
||||
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
|
||||
)}
|
||||
|
||||
<div className="titlebar-content">
|
||||
{/* Left section: Home + Workspace */}
|
||||
<div className="titlebar-left">
|
||||
<ActionIcon
|
||||
onClick={handleHomeClick}
|
||||
label="Home"
|
||||
size="lg"
|
||||
className="home-button"
|
||||
>
|
||||
<IconHome size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
{/* Workspace Dropdown */}
|
||||
<MenuDropdown
|
||||
data-testid="workspace-menu"
|
||||
items={workspaceMenuItems}
|
||||
placement="bottom-start"
|
||||
selectedItemId={activeWorkspaceUid}
|
||||
>
|
||||
<WorkspaceName />
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
|
||||
{/* Center section: Bruno logo + text */}
|
||||
<div className="titlebar-center">
|
||||
<Bruno width={18} />
|
||||
<span className="bruno-text">Bruno</span>
|
||||
</div>
|
||||
|
||||
{/* Right section: Action buttons */}
|
||||
<div className="titlebar-right">
|
||||
{/* Toggle sidebar */}
|
||||
<ActionIcon
|
||||
onClick={handleToggleSidebar}
|
||||
label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
|
||||
size="lg"
|
||||
data-testid="toggle-sidebar-button"
|
||||
>
|
||||
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
{/* Toggle devtools */}
|
||||
<ActionIcon
|
||||
onClick={handleToggleDevtools}
|
||||
label={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
|
||||
size="lg"
|
||||
data-testid="toggle-devtools-button"
|
||||
>
|
||||
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
{/* Toggle vertical layout */}
|
||||
<ActionIcon
|
||||
onClick={handleToggleVerticalLayout}
|
||||
label={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
|
||||
size="lg"
|
||||
data-testid="toggle-vertical-layout-button"
|
||||
>
|
||||
{orientation === 'horizontal' ? (
|
||||
<IconLayoutColumns size={16} stroke={1.5} />
|
||||
) : (
|
||||
<IconLayoutRows size={16} stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppTitleBar;
|
||||
@@ -373,7 +373,7 @@ const ClientCertSettings = ({ collection }) => {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 flex flex-row gap-2 items-center">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" data-testid="add-client-cert">
|
||||
Add
|
||||
</button>
|
||||
<div className="h-4 border-l border-gray-600"></div>
|
||||
|
||||
@@ -3,15 +3,23 @@ import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { IconBox, IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
|
||||
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
|
||||
import { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
const Info = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const isCollectionLoading = areItemsLoading(collection);
|
||||
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
||||
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
|
||||
const collectionEnvironmentCount = collection.environments?.length || 0;
|
||||
const globalEnvironmentCount = globalEnvironments?.length || 0;
|
||||
|
||||
const handleToggleShowShareCollectionModal = (value) => (e) => {
|
||||
toggleShowShareCollectionModal(value);
|
||||
};
|
||||
@@ -39,9 +47,24 @@ const Info = ({ collection }) => {
|
||||
<IconWorld className="w-5 h-5 text-green-500" stroke={1.5} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-medium">Environments</div>
|
||||
<div className="mt-1 text-muted text-xs">
|
||||
{collection.environments?.length || 0} environment{collection.environments?.length !== 1 ? 's' : ''} configured
|
||||
<div className="font-medium text-sm">Environments</div>
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
|
||||
onClick={() => {
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
>
|
||||
{collectionEnvironmentCount} collection environment{collectionEnvironmentCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
|
||||
onClick={() => dispatch(updateGlobalEnvironmentSettingsModalVisibility(true))}
|
||||
>
|
||||
{globalEnvironmentCount} global environment{globalEnvironmentCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -4,18 +4,23 @@ import VarsTable from './VarsTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import DeprecationWarning from 'components/DeprecationWarning';
|
||||
|
||||
const Vars = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
|
||||
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1 mt-2">
|
||||
<div className="mb-1 title text-xs">Pre Request</div>
|
||||
<VarsTable collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mt-1 mb-1 title text-xs">Post Response</div>
|
||||
<VarsTable collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
|
||||
Save
|
||||
|
||||
@@ -32,17 +32,32 @@ const CollectionSettings = ({ collection }) => {
|
||||
const hasTests = root?.request?.tests;
|
||||
const hasDocs = root?.docs;
|
||||
|
||||
const headers = collection.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
|
||||
const headers = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.headers', [])
|
||||
: get(collection, 'root.request.headers', []);
|
||||
const activeHeadersCount = headers.filter((header) => header.enabled).length;
|
||||
|
||||
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length;
|
||||
const authMode = (collection.draft?.root ? get(collection, 'draft.root.request.auth', {}) : get(collection, 'root.request.auth', {})).mode || 'none';
|
||||
const requestVars = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.vars.req', [])
|
||||
: get(collection, 'root.request.vars.req', []);
|
||||
const responseVars = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.vars.res', [])
|
||||
: get(collection, 'root.request.vars.res', []);
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
const authMode
|
||||
= (collection.draft?.root ? get(collection, 'draft.root.request.auth', {}) : get(collection, 'root.request.auth', {}))
|
||||
.mode || 'none';
|
||||
|
||||
const proxyConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.proxy', {}) : get(collection, 'brunoConfig.proxy', {});
|
||||
const proxyConfig = collection.draft?.brunoConfig
|
||||
? get(collection, 'draft.brunoConfig.proxy', {})
|
||||
: get(collection, 'brunoConfig.proxy', {});
|
||||
const proxyEnabled = proxyConfig.hostname ? true : false;
|
||||
const clientCertConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.clientCertificates.certs', []) : get(collection, 'brunoConfig.clientCertificates.certs', []);
|
||||
const protobufConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.protobuf', {}) : get(collection, 'brunoConfig.protobuf', {});
|
||||
const clientCertConfig = collection.draft?.brunoConfig
|
||||
? get(collection, 'draft.brunoConfig.clientCertificates.certs', [])
|
||||
: get(collection, 'brunoConfig.clientCertificates.certs', []);
|
||||
const protobufConfig = collection.draft?.brunoConfig
|
||||
? get(collection, 'draft.brunoConfig.protobuf', {})
|
||||
: get(collection, 'brunoConfig.protobuf', {});
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
@@ -68,11 +83,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
return <ProxySettings collection={collection} />;
|
||||
}
|
||||
case 'clientCert': {
|
||||
return (
|
||||
<ClientCertSettings
|
||||
collection={collection}
|
||||
/>
|
||||
);
|
||||
return <ClientCertSettings collection={collection} />;
|
||||
}
|
||||
case 'protobuf': {
|
||||
return <Protobuf collection={collection} />;
|
||||
|
||||
@@ -25,6 +25,16 @@ const Wrapper = styled.div`
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
[role="menu"] {
|
||||
outline: none;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.label-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -59,6 +69,10 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
@@ -70,10 +84,31 @@ const Wrapper = styled.div`
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dropdown-right-section {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&.selected-focused:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&:focus-visible:not(:disabled) {
|
||||
outline: none;
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -2,46 +2,55 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.current-environment {
|
||||
border-radius: 0.9375rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
|
||||
user-select: none;
|
||||
background-color: transparent;
|
||||
border: 1px solid ${(props) => props.theme.dropdown.selectedColor};
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.bg};
|
||||
border: 1px solid ${(props) => props.theme.app.collection.toolbar.environmentSelector.border};
|
||||
line-height: 1rem;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBorder};
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBg};
|
||||
}
|
||||
|
||||
.caret {
|
||||
margin-left: 0.25rem;
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140, 140, 140);
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
|
||||
fill: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.env-icon {
|
||||
margin-right: 0.25rem;
|
||||
color: ${(props) => props.theme.dropdown.selectedColor};
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.icon};
|
||||
}
|
||||
|
||||
.env-text {
|
||||
color: ${(props) => props.theme.dropdown.selectedColor};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.text};
|
||||
display: block;
|
||||
}
|
||||
|
||||
.env-separator {
|
||||
color: #8c8c8c;
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
|
||||
margin: 0 0.25rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.env-text-inactive {
|
||||
color: ${(props) => props.theme.dropdown.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
opacity: 0.7;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
|
||||
&.no-environments {
|
||||
background-color: ${(props) => props.theme.sidebar.badge.bg};
|
||||
border: 1px solid transparent;
|
||||
color: ${(props) => props.theme.dropdown.secondaryText};
|
||||
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.text};
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.bg};
|
||||
border: 1px dashed ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.border};
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.hoverBorder};
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.noEnvironment.hoverBg};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import find from 'lodash/find';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dropdownTippyRef = useRef();
|
||||
const [activeTab, setActiveTab] = useState('collection');
|
||||
const [showGlobalSettings, setShowGlobalSettings] = useState(false);
|
||||
const [showCollectionSettings, setShowCollectionSettings] = useState(false);
|
||||
const [showCreateGlobalModal, setShowCreateGlobalModal] = useState(false);
|
||||
const [showImportGlobalModal, setShowImportGlobalModal] = useState(false);
|
||||
const [showCreateCollectionModal, setShowCreateCollectionModal] = useState(false);
|
||||
@@ -29,6 +27,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
|
||||
const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen);
|
||||
const activeGlobalEnvironment = activeGlobalEnvironmentUid
|
||||
? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid)
|
||||
: null;
|
||||
@@ -79,9 +79,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
const handleSettingsClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
setShowCollectionSettings(true);
|
||||
} else {
|
||||
setShowGlobalSettings(true);
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
@@ -108,9 +107,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
|
||||
// Modal handlers
|
||||
const handleCloseSettings = () => {
|
||||
setShowGlobalSettings(false);
|
||||
setShowCollectionSettings(false);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(false));
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(false));
|
||||
};
|
||||
|
||||
// Calculate dropdown width based on the longest environment name.
|
||||
@@ -164,7 +162,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No environments</span>
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -176,7 +174,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
{displayContent}
|
||||
<IconCaretDown className="caret" size={14} strokeWidth={2} />
|
||||
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -220,7 +218,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
</div>
|
||||
|
||||
{/* Modals - Rendered outside dropdown to avoid conflicts */}
|
||||
{showGlobalSettings && (
|
||||
{isGlobalEnvironmentSettingsModalOpen && (
|
||||
<GlobalEnvironmentSettings
|
||||
globalEnvironments={globalEnvironments}
|
||||
collection={collection}
|
||||
@@ -229,13 +227,15 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
|
||||
{isEnvironmentSettingsModalOpen && (
|
||||
<EnvironmentSettings collection={collection} onClose={handleCloseSettings} />
|
||||
)}
|
||||
|
||||
{showCreateGlobalModal && (
|
||||
<CreateGlobalEnvironment
|
||||
onClose={() => setShowCreateGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -245,7 +245,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
type="global"
|
||||
onClose={() => setShowImportGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -255,7 +255,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
collection={collection}
|
||||
onClose={() => setShowCreateCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -266,7 +266,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
collection={collection}
|
||||
onClose={() => setShowImportCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -8,13 +8,19 @@ import { useDispatch } from 'react-redux';
|
||||
const Vars = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const requestVars = folder.draft ? get(folder, 'draft.request.vars.req', []) : get(folder, 'root.request.vars.req', []);
|
||||
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
|
||||
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1 mt-2">
|
||||
<div className="mb-1 title text-xs">Pre Request</div>
|
||||
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mt-1 mb-1 title text-xs">Post Response</div>
|
||||
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
|
||||
Save
|
||||
|
||||
@@ -28,7 +28,8 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
const activeHeadersCount = headers.filter((header) => header.enabled).length;
|
||||
|
||||
const requestVars = folderRoot?.request?.vars?.req || [];
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length;
|
||||
const responseVars = folderRoot?.request?.vars?.res || [];
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
|
||||
const auth = get(folderRoot, 'request.auth.mode');
|
||||
const hasAuth = auth && auth !== 'none';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const IconBottombarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={`icon icon-tabler icons-tabler-outline icon-tabler-layout-bottombar ${className}`} {...rest}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
|
||||
<path d="M4 15l16 0" />
|
||||
{!collapsed && (
|
||||
<rect x="4.6" y="15.6" width="14.8" height="2.8" rx="0.8" fill="currentColor" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconBottombarToggle;
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 2.3rem;
|
||||
height: 2.1rem;
|
||||
border: ${(props) => props.theme.requestTabPanel.url.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -99,13 +99,16 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
const tests = getPropertyFromDraftOrRequest('request.tests');
|
||||
const docs = getPropertyFromDraftOrRequest('request.docs');
|
||||
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
|
||||
const responseVars = getPropertyFromDraftOrRequest('request.vars.res');
|
||||
const auth = getPropertyFromDraftOrRequest('request.auth');
|
||||
const tags = getPropertyFromDraftOrRequest('tags');
|
||||
|
||||
const activeParamsLength = params.filter((param) => param.enabled).length;
|
||||
const activeHeadersLength = headers.filter((header) => header.enabled).length;
|
||||
const activeAssertionsLength = assertions.filter((assertion) => assertion.enabled).length;
|
||||
const activeVarsLength = requestVars.filter((request) => request.enabled).length;
|
||||
const activeVarsLength
|
||||
= requestVars.filter((request) => request.enabled).length
|
||||
+ responseVars.filter((response) => response.enabled).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeParamsLength === 0 && body.mode !== 'none') {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 2.3rem;
|
||||
height: 2.1rem;
|
||||
border: ${(props) => props.theme.requestTabPanel.url.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
handleGenerateCode(e);
|
||||
}}
|
||||
>
|
||||
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={22} className="cursor-pointer" />
|
||||
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
|
||||
<span className="infotiptext text-xs">Generate Code</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -142,7 +142,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
<IconDeviceFloppy
|
||||
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
size={20}
|
||||
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
/>
|
||||
<span className="infotiptext text-xs">
|
||||
@@ -153,7 +153,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
<IconSquareRoundedX
|
||||
color={theme.requestTabPanel.url.iconDanger}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
size={20}
|
||||
data-testid="cancel-request-icon"
|
||||
onClick={handleCancelRequest}
|
||||
/>
|
||||
@@ -161,7 +161,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
<IconArrowRight
|
||||
color={theme.requestTabPanel.url.icon}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
size={20}
|
||||
data-testid="send-arrow-icon"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import VarsTable from './VarsTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DeprecationWarning from 'components/DeprecationWarning';
|
||||
|
||||
const Vars = ({ item, collection }) => {
|
||||
const requestVars = item.draft ? get(item, 'draft.request.vars.req') : get(item, 'request.vars.req');
|
||||
@@ -11,8 +10,13 @@ const Vars = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="mt-2">
|
||||
<div className="mb-1 title text-xs">Pre Request</div>
|
||||
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-1 mb-1 title text-xs">Post Response</div>
|
||||
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 2.3rem;
|
||||
height: 2.1rem;
|
||||
position: relative;
|
||||
border: ${(props) => props.theme.requestTabPanel.url.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
|
||||
@@ -243,7 +243,7 @@ const RequestTabPanel = () => {
|
||||
isVerticalLayout ? 'vertical-layout' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="pt-4 pb-3 px-4">
|
||||
<div className="pt-3 pb-3 px-4">
|
||||
{
|
||||
isGrpcRequest
|
||||
? <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { uuid } from 'utils/common';
|
||||
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
|
||||
import { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons';
|
||||
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -45,26 +45,28 @@ const CollectionToolBar = ({ collection }) => {
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center py-2 px-4">
|
||||
<div className="flex flex-1 items-center cursor-pointer hover:underline" onClick={viewCollectionSettings}>
|
||||
<IconFiles size={18} strokeWidth={1.5} />
|
||||
<IconBox size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-3 items-center justify-end">
|
||||
<span className="mr-2">
|
||||
<JsSandboxMode collection={collection} />
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
<ToolHint text="Runner" toolhintId="RunnnerToolhintId" place="bottom">
|
||||
<IconRun className="cursor-pointer" size={18} strokeWidth={1.5} onClick={handleRun} />
|
||||
<IconRun className="cursor-pointer" size={16} strokeWidth={1.5} onClick={handleRun} />
|
||||
</ToolHint>
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
|
||||
<IconEye className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewVariables} />
|
||||
<IconEye className="cursor-pointer" size={16} strokeWidth={1.5} onClick={viewVariables} />
|
||||
</ToolHint>
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
|
||||
<IconSettings className="cursor-pointer" size={18} strokeWidth={1.5} onClick={viewCollectionSettings} />
|
||||
<IconSettings className="cursor-pointer" size={16} strokeWidth={1.5} onClick={viewCollectionSettings} />
|
||||
</ToolHint>
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
<ToolHint text="Javascript Sandbox" toolhintId="JavascriptSandboxToolhintId" place="bottom">
|
||||
<JsSandboxMode collection={collection} />
|
||||
</ToolHint>
|
||||
</span>
|
||||
<span>
|
||||
|
||||
@@ -8,8 +8,7 @@ import ExampleIcon from 'components/Icons/ExampleIcon';
|
||||
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
|
||||
import RequestTabNotFound from '../RequestTab/RequestTabNotFound';
|
||||
import StyledWrapper from '../RequestTab/StyledWrapper';
|
||||
import CloseTabIcon from '../RequestTab/CloseTabIcon';
|
||||
import DraftTabIcon from '../RequestTab/DraftTabIcon';
|
||||
import GradientCloseButton from '../RequestTab/GradientCloseButton';
|
||||
|
||||
const ExampleTab = ({ tab, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -59,7 +58,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
if (!item || !example) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container px-1"
|
||||
className="flex items-center justify-between tab-container px-3"
|
||||
onMouseUp={(e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
@@ -75,7 +74,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-1">
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-3">
|
||||
{showConfirmClose && (
|
||||
<ConfirmRequestClose
|
||||
item={item}
|
||||
@@ -116,13 +115,13 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-500 flex-shrink-0" />
|
||||
<ExampleIcon size={14} color="currentColor" className="mr-1.5 text-gray-500 flex-shrink-0" />
|
||||
<span className="tab-name" title={example.name}>
|
||||
{example.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex px-2 close-icon-container"
|
||||
<GradientCloseButton
|
||||
hasChanges={hasChanges}
|
||||
onClick={(e) => {
|
||||
if (!hasChanges) {
|
||||
return handleCloseClick(e);
|
||||
@@ -132,13 +131,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
e.preventDefault();
|
||||
setShowConfirmClose(true);
|
||||
}}
|
||||
>
|
||||
{!hasChanges ? (
|
||||
<CloseTabIcon />
|
||||
) : (
|
||||
<DraftTabIcon />
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div.attrs((props) => ({
|
||||
style: {
|
||||
'--gradient-color': props.theme.requestTabs.bg,
|
||||
'--gradient-color-active': props.theme.bg
|
||||
}
|
||||
}))`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 100%;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding-right: 4px;
|
||||
z-index: 3;
|
||||
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--gradient-color) 40%
|
||||
);
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
li.active & {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--gradient-color-active) 40%
|
||||
);
|
||||
}
|
||||
|
||||
li:hover &,
|
||||
&.has-changes {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.close-icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
|
||||
.close-icon {
|
||||
color: ${(props) => props.theme.requestTabs.icon.hoverColor};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
color: ${(props) => props.theme.requestTabs.icon.color};
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.has-changes-icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.draft-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.close-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.has-changes:not(li:hover &) {
|
||||
.draft-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.close-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
li:hover &.has-changes {
|
||||
.draft-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.close-icon-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import CloseTabIcon from '../CloseTabIcon';
|
||||
import DraftTabIcon from '../DraftTabIcon';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GradientCloseButton = ({ onClick, hasChanges = false }) => {
|
||||
return (
|
||||
<StyledWrapper className={`close-gradient ${hasChanges ? 'has-changes' : ''}`}>
|
||||
<div className="close-icon-container" onClick={onClick} data-testid="request-tab-close-icon">
|
||||
<span className="draft-icon-wrapper">
|
||||
<DraftTabIcon />
|
||||
</span>
|
||||
<span className="close-icon-wrapper">
|
||||
<CloseTabIcon />
|
||||
</span>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default GradientCloseButton;
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import CloseTabIcon from './CloseTabIcon';
|
||||
import DraftTabIcon from './DraftTabIcon';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
|
||||
|
||||
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
|
||||
@@ -8,49 +7,49 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
switch (type) {
|
||||
case 'collection-settings': {
|
||||
return (
|
||||
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
|
||||
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Collection</span>
|
||||
</div>
|
||||
<>
|
||||
<IconSettings size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Collection</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'collection-overview': {
|
||||
return (
|
||||
<>
|
||||
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Collection</span>
|
||||
<IconSettings size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Overview</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'security-settings': {
|
||||
return (
|
||||
<>
|
||||
<IconShieldLock size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1">Security</span>
|
||||
<IconShieldLock size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Security</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'folder-settings': {
|
||||
return (
|
||||
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
|
||||
<IconFolder size={18} strokeWidth={1.5} className="text-yellow-600 min-w-[18px]" />
|
||||
<span className="ml-1 leading-6 truncate">{tabName || 'Folder'}</span>
|
||||
</div>
|
||||
<>
|
||||
<IconFolder size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">{tabName || 'Folder'}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'variables': {
|
||||
return (
|
||||
<>
|
||||
<IconVariable size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Variables</span>
|
||||
<IconVariable size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Variables</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'collection-runner': {
|
||||
return (
|
||||
<>
|
||||
<IconRun size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Runner</span>
|
||||
<IconRun size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Runner</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -59,10 +58,13 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center tab-label pl-2">{getTabInfo(type, tabName)}</div>
|
||||
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
|
||||
{hasDraft ? <DraftTabIcon /> : <CloseTabIcon />}
|
||||
<div
|
||||
className="flex items-baseline tab-label"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{getTabInfo(type, tabName)}
|
||||
</div>
|
||||
<GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,29 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.tab-label {
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-method {
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.close-icon-container {
|
||||
min-height: 20px;
|
||||
min-width: 24px;
|
||||
margin-left: 4px;
|
||||
border-radius: 3px;
|
||||
|
||||
.close-icon {
|
||||
display: none;
|
||||
color: ${(props) => props.theme.requestTabs.icon.color};
|
||||
width: 8px;
|
||||
padding-bottom: 6px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:hover .close-icon {
|
||||
color: ${(props) => props.theme.requestTabs.icon.hoverColor};
|
||||
background-color: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
}
|
||||
|
||||
.has-changes-icon {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.tab-method {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo } from 'react';
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -17,16 +17,17 @@ import StyledWrapper from './StyledWrapper';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
|
||||
import NewRequest from 'components/Sidebar/NewRequest/index';
|
||||
import CloseTabIcon from './CloseTabIcon';
|
||||
import DraftTabIcon from './DraftTabIcon';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
import { closeWsConnection } from 'utils/network/index';
|
||||
import ExampleTab from '../ExampleTab';
|
||||
|
||||
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
|
||||
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid, hasOverflow, setHasOverflow }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
|
||||
const tabNameRef = useRef(null);
|
||||
const lastOverflowStateRef = useRef(null);
|
||||
const [showConfirmClose, setShowConfirmClose] = useState(false);
|
||||
const [showConfirmCollectionClose, setShowConfirmCollectionClose] = useState(false);
|
||||
const [showConfirmFolderClose, setShowConfirmFolderClose] = useState(false);
|
||||
@@ -36,6 +37,48 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
|
||||
const method = useMemo(() => {
|
||||
if (!item) return;
|
||||
switch (item.type) {
|
||||
case 'grpc-request':
|
||||
return 'gRPC';
|
||||
case 'ws-request':
|
||||
return 'WS';
|
||||
case 'graphql-request':
|
||||
return 'GQL';
|
||||
default:
|
||||
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item || !tabNameRef.current || !setHasOverflow) return;
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (tabNameRef.current && setHasOverflow) {
|
||||
const hasOverflow = tabNameRef.current.scrollWidth > tabNameRef.current.clientWidth;
|
||||
if (lastOverflowStateRef.current !== hasOverflow) {
|
||||
lastOverflowStateRef.current = hasOverflow;
|
||||
setHasOverflow(hasOverflow);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(checkOverflow, 0);
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
|
||||
if (tabNameRef.current) {
|
||||
resizeObserver.observe(tabNameRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [item, item?.name, method, setHasOverflow]);
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@@ -105,11 +148,12 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
const hasDraft = tab.type === 'collection-settings' && collection?.draft;
|
||||
const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft;
|
||||
|
||||
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? 'italic' : ''}`}
|
||||
onMouseUp={handleMouseUp} // Add middle-click behavior here
|
||||
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{showConfirmCollectionClose && tab.type === 'collection-settings' && (
|
||||
<ConfirmCollectionClose
|
||||
@@ -192,21 +236,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
);
|
||||
}
|
||||
|
||||
const getMethodText = useCallback((item) => {
|
||||
if (!item) return;
|
||||
|
||||
switch (item.type) {
|
||||
case 'grpc-request':
|
||||
return 'gRPC';
|
||||
case 'ws-request':
|
||||
return 'WS';
|
||||
case 'graphql-request':
|
||||
return 'GQL';
|
||||
default:
|
||||
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
|
||||
|
||||
if (!item) {
|
||||
@@ -228,10 +257,36 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}
|
||||
|
||||
const isWS = item.type === 'ws-request';
|
||||
const method = getMethodText(item);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (tabNameRef.current && setHasOverflow) {
|
||||
const hasOverflow = tabNameRef.current.scrollWidth > tabNameRef.current.clientWidth;
|
||||
if (lastOverflowStateRef.current !== hasOverflow) {
|
||||
lastOverflowStateRef.current = hasOverflow;
|
||||
setHasOverflow(hasOverflow);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(checkOverflow, 0);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
|
||||
if (tabNameRef.current) {
|
||||
resizeObserver.observe(tabNameRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [item.name, method, setHasOverflow]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-1">
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-2">
|
||||
{showConfirmClose && (
|
||||
<ConfirmRequestClose
|
||||
item={item}
|
||||
@@ -268,7 +323,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-baseline tab-label pl-2 ${tab.preview ? 'italic' : ''}`}
|
||||
className={`flex items-baseline tab-label ${tab.preview ? 'italic' : ''}`}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
|
||||
onMouseUp={(e) => {
|
||||
@@ -284,7 +339,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>
|
||||
{method}
|
||||
</span>
|
||||
<span className="ml-1 tab-name" title={item.name}>
|
||||
<span ref={tabNameRef} className="ml-1 tab-name" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
<RequestTabMenu
|
||||
@@ -297,25 +352,19 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex px-2 close-icon-container"
|
||||
<GradientCloseButton
|
||||
hasChanges={hasChanges}
|
||||
onClick={(e) => {
|
||||
if (!hasChanges) {
|
||||
isWS && closeWsConnection(item.uid);
|
||||
return handleCloseClick(e);
|
||||
};
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowConfirmClose(true);
|
||||
}}
|
||||
>
|
||||
{!hasChanges ? (
|
||||
<CloseTabIcon />
|
||||
) : (
|
||||
<DraftTabIcon />
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
@@ -349,7 +398,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
}
|
||||
|
||||
dispatch(closeTabs({ tabUids: [tabUid] }));
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
function handleRevertChanges(event) {
|
||||
@@ -368,7 +417,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
}
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
function handleCloseOtherTabs(event) {
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tabs-scroll-container {
|
||||
overflow-x: auto;
|
||||
overflow-y: clip;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: -10px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
scrollbar-width: none;
|
||||
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
padding: 0 2px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
overflow: scroll;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -17,57 +48,127 @@ const Wrapper = styled.div`
|
||||
|
||||
li {
|
||||
display: inline-flex;
|
||||
max-width: 150px;
|
||||
border: 1px solid transparent;
|
||||
max-width: 180px;
|
||||
min-width: 80px;
|
||||
list-style: none;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
cursor: pointer;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
height: 38px;
|
||||
|
||||
margin-right: 6px;
|
||||
font-size: 0.8125rem;
|
||||
position: relative;
|
||||
margin-right: 3px;
|
||||
color: ${(props) => props.theme.requestTabs.color};
|
||||
background: ${(props) => props.theme.requestTabs.bg};
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
margin-bottom: 3px;
|
||||
|
||||
.tab-container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:not(.active) {
|
||||
background: ${(props) => props.theme.requestTabs.bg};
|
||||
border-color: transparent;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
|
||||
}
|
||||
|
||||
&:nth-last-child(1) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&.has-overflow:not(:hover) .tab-name {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
${(props) => props.theme.requestTabs.color} 0%,
|
||||
${(props) => props.theme.requestTabs.color} calc(100% - 12px),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
${(props) => props.theme.requestTabs.color} 0%,
|
||||
${(props) => props.theme.requestTabs.color} calc(100% - 12px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
&.has-overflow:hover .tab-name {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
${(props) => props.theme.requestTabs.color} 0%,
|
||||
${(props) => props.theme.requestTabs.color} calc(100% - 8px),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
${(props) => props.theme.requestTabs.color} 0%,
|
||||
${(props) => props.theme.requestTabs.color} calc(100% - 8px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.requestTabs.active.bg};
|
||||
}
|
||||
background: ${(props) => props.theme.bg || '#ffffff'};
|
||||
border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
border-bottom-color: ${(props) => props.theme.bg || '#ffffff'};
|
||||
border-radius: 8px 8px 0 0;
|
||||
z-index: 2;
|
||||
margin-bottom: -2px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
&.active {
|
||||
.close-icon-container .close-icon {
|
||||
display: block;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: -8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
border-bottom-right-radius: 8px;
|
||||
box-shadow: 2px 2px 0 0 ${(props) => props.theme.bg || '#ffffff'};
|
||||
border-right: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.close-icon-container .close-icon {
|
||||
display: block;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
border-bottom-left-radius: 8px;
|
||||
box-shadow: -2px 2px 0 0 ${(props) => props.theme.bg || '#ffffff'};
|
||||
border-left: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
}
|
||||
}
|
||||
|
||||
&.short-tab {
|
||||
vertical-align: bottom;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
max-width: 34px;
|
||||
padding: 3px 0px;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
max-width: 32px;
|
||||
padding: 5px 0;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.requestTabs.shortTab.color};
|
||||
background-color: ${(props) => props.theme.requestTabs.shortTab.bg};
|
||||
position: relative;
|
||||
top: -1px;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
flex-shrink: 0;
|
||||
|
||||
> div {
|
||||
padding: 3px 4px;
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
> div.home-icon-container {
|
||||
@@ -81,19 +182,23 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 22px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> div {
|
||||
background-color: ${(props) => props.theme.requestTabs.shortTab.hoverBg};
|
||||
color: ${(props) => props.theme.requestTabs.shortTab.hoverColor};
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.has-chevrons ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import filter from 'lodash/filter';
|
||||
import classnames from 'classnames';
|
||||
@@ -14,7 +14,10 @@ import DraggableTab from './DraggableTab';
|
||||
const RequestTabs = () => {
|
||||
const dispatch = useDispatch();
|
||||
const tabsRef = useRef();
|
||||
const scrollContainerRef = useRef();
|
||||
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
|
||||
const [tabOverflowStates, setTabOverflowStates] = useState({});
|
||||
const [showChevrons, setShowChevrons] = useState(false);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
@@ -22,10 +25,48 @@ const RequestTabs = () => {
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const screenWidth = useSelector((state) => state.app.screenWidth);
|
||||
|
||||
const createSetHasOverflow = useCallback((tabUid) => {
|
||||
return (hasOverflow) => {
|
||||
setTabOverflowStates((prev) => {
|
||||
if (prev[tabUid] === hasOverflow) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[tabUid]: hasOverflow
|
||||
};
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const activeCollection = find(collections, (c) => c.uid === activeTab?.collectionUid);
|
||||
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabUid || !activeTab) return;
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (tabsRef.current && scrollContainerRef.current) {
|
||||
const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth;
|
||||
setShowChevrons(hasOverflow);
|
||||
}
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
const resizeObserver = new ResizeObserver(checkOverflow);
|
||||
if (scrollContainerRef.current) {
|
||||
resizeObserver.observe(scrollContainerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [activeTabUid, activeTab, collectionRequestTabs.length, screenWidth, leftSidebarWidth, sidebarCollapsed]);
|
||||
|
||||
const getTabClassname = (tab, index) => {
|
||||
return classnames('request-tab select-none', {
|
||||
'active': tab.uid === activeTabUid,
|
||||
'last-tab': tabs && tabs.length && index === tabs.length - 1
|
||||
'last-tab': tabs && tabs.length && index === tabs.length - 1,
|
||||
'has-overflow': tabOverflowStates[tab.uid]
|
||||
});
|
||||
};
|
||||
|
||||
@@ -43,31 +84,22 @@ const RequestTabs = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) {
|
||||
return <StyledWrapper>Something went wrong!</StyledWrapper>;
|
||||
}
|
||||
|
||||
const activeCollection = find(collections, (c) => c.uid === activeTab.collectionUid);
|
||||
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab.collectionUid);
|
||||
|
||||
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
|
||||
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
|
||||
const tabsWidth = collectionRequestTabs.length * 150 + 34; // 34: (+)icon
|
||||
const showChevrons = maxTablistWidth < tabsWidth;
|
||||
|
||||
const leftSlide = () => {
|
||||
tabsRef.current.scrollBy({
|
||||
scrollContainerRef.current?.scrollBy({
|
||||
left: -120,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
// todo: bring new tab to focus if its not in focus
|
||||
// tabsRef.current.scrollLeft
|
||||
|
||||
const rightSlide = () => {
|
||||
tabsRef.current.scrollBy({
|
||||
scrollContainerRef.current?.scrollBy({
|
||||
left: 120,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
@@ -87,7 +119,7 @@ const RequestTabs = () => {
|
||||
{collectionRequestTabs && collectionRequestTabs.length ? (
|
||||
<>
|
||||
<CollectionToolBar collection={activeCollection} />
|
||||
<div className="flex items-center pl-4">
|
||||
<div className="flex items-center pl-2">
|
||||
<ul role="tablist">
|
||||
{showChevrons ? (
|
||||
<li className="select-none short-tab" onClick={leftSlide}>
|
||||
@@ -103,36 +135,40 @@ const RequestTabs = () => {
|
||||
</div>
|
||||
</li> */}
|
||||
</ul>
|
||||
<ul role="tablist" style={{ maxWidth: maxTablistWidth }} ref={tabsRef}>
|
||||
{collectionRequestTabs && collectionRequestTabs.length
|
||||
? collectionRequestTabs.map((tab, index) => {
|
||||
return (
|
||||
<DraggableTab
|
||||
key={tab.uid}
|
||||
id={tab.uid}
|
||||
index={index}
|
||||
onMoveTab={(source, target) => {
|
||||
dispatch(reorderTabs({
|
||||
sourceUid: source,
|
||||
targetUid: target
|
||||
}));
|
||||
}}
|
||||
className={getTabClassname(tab, index)}
|
||||
onClick={() => handleClick(tab)}
|
||||
>
|
||||
<RequestTab
|
||||
collectionRequestTabs={collectionRequestTabs}
|
||||
tabIndex={index}
|
||||
<div className="tabs-scroll-container" style={{ maxWidth: maxTablistWidth }} ref={scrollContainerRef}>
|
||||
<ul role="tablist" ref={tabsRef}>
|
||||
{collectionRequestTabs && collectionRequestTabs.length
|
||||
? collectionRequestTabs.map((tab, index) => {
|
||||
return (
|
||||
<DraggableTab
|
||||
key={tab.uid}
|
||||
tab={tab}
|
||||
collection={activeCollection}
|
||||
folderUid={tab.folderUid}
|
||||
/>
|
||||
</DraggableTab>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ul>
|
||||
id={tab.uid}
|
||||
index={index}
|
||||
onMoveTab={(source, target) => {
|
||||
dispatch(reorderTabs({
|
||||
sourceUid: source,
|
||||
targetUid: target
|
||||
}));
|
||||
}}
|
||||
className={getTabClassname(tab, index)}
|
||||
onClick={() => handleClick(tab)}
|
||||
>
|
||||
<RequestTab
|
||||
collectionRequestTabs={collectionRequestTabs}
|
||||
tabIndex={index}
|
||||
key={tab.uid}
|
||||
tab={tab}
|
||||
collection={activeCollection}
|
||||
folderUid={tab.folderUid}
|
||||
hasOverflow={tabOverflowStates[tab.uid]}
|
||||
setHasOverflow={createSetHasOverflow(tab.uid)}
|
||||
/>
|
||||
</DraggableTab>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul role="tablist">
|
||||
{showChevrons ? (
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.safe-mode {
|
||||
padding: 0.15rem 0.3rem;
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
border: solid 1px ${(props) => props.theme.colors.text.green} !important;
|
||||
.sandbox-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-mode {
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.safeMode.bg};
|
||||
color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.safeMode.color};
|
||||
}
|
||||
|
||||
.developer-mode {
|
||||
padding: 0.15rem 0.3rem;
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
|
||||
background-color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.developerMode.bg};
|
||||
color: ${(props) => props.theme.app.collection.toolbar.sandboxMode.developerMode.color};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconShieldLock } from '@tabler/icons';
|
||||
import { IconShieldCheck, IconCode } from '@tabler/icons';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { uuid } from 'utils/common/index';
|
||||
import JsSandboxModeModal from '../JsSandboxModeModal';
|
||||
@@ -23,18 +23,22 @@ const JsSandboxMode = ({ collection }) => {
|
||||
<StyledWrapper className="flex">
|
||||
{jsSandboxMode === 'safe' && (
|
||||
<div
|
||||
className="flex items-center border rounded-md text-xs cursor-pointer safe-mode"
|
||||
className="sandbox-icon safe-mode"
|
||||
data-testid="sandbox-mode-selector"
|
||||
onClick={viewSecuritySettings}
|
||||
title="Safe Mode"
|
||||
>
|
||||
Safe Mode
|
||||
<IconShieldCheck size={14} strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
{jsSandboxMode === 'developer' && (
|
||||
<div
|
||||
className="flex items-center border rounded-md text-xs cursor-pointer developer-mode"
|
||||
className="sandbox-icon developer-mode"
|
||||
data-testid="sandbox-mode-selector"
|
||||
onClick={viewSecuritySettings}
|
||||
title="Developer Mode"
|
||||
>
|
||||
Developer Mode
|
||||
<IconCode size={14} strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
{!jsSandboxMode ? <JsSandboxModeModal collection={collection} /> : null}
|
||||
|
||||
@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
margin-right: ${(props) => props.theme.tabs.marginRight};
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -21,7 +21,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.collection-item-name {
|
||||
height: 1.75rem;
|
||||
height: 1.6rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
|
||||
@@ -20,7 +20,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
.collection-item-name {
|
||||
height: 1.75rem;
|
||||
height: 1.6rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
@@ -132,12 +132,9 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
&.item-keyboard-focused {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.keyboardFocusBg};
|
||||
border-top: 1px solid ${(props) => props.theme.sidebar.collection.item.focusBorder};
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.focusBorder};
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.keyboardFocusBg} !important;
|
||||
}
|
||||
}
|
||||
|
||||
div.tippy-box {
|
||||
|
||||
@@ -39,7 +39,7 @@ import GenerateCodeItem from './GenerateCodeItem';
|
||||
import { isItemARequest, isItemAFolder } from 'utils/tabs';
|
||||
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { hideHomePage, hideApiSpecPage } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError/index';
|
||||
@@ -104,6 +104,17 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
dragPreview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to show this item when its tab becomes active
|
||||
useEffect(() => {
|
||||
if (isTabForItemActive && ref.current) {
|
||||
try {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} catch (err) {
|
||||
// ignore scroll errors (some environments may not support smooth scrolling)
|
||||
}
|
||||
}
|
||||
}, [isTabForItemActive]);
|
||||
|
||||
const determineDropType = (monitor) => {
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
@@ -189,7 +200,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
});
|
||||
|
||||
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
|
||||
'item-focused-in-tab': isTabForItemActive && !isKeyboardFocused,
|
||||
'item-focused-in-tab': isTabForItemActive,
|
||||
'item-hovered': isOver && canDrop,
|
||||
'drop-target': isOver && dropType === 'inside',
|
||||
'drop-target-above': isOver && dropType === 'adjacent',
|
||||
@@ -211,6 +222,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const isRequest = isItemARequest(item);
|
||||
if (isRequest) {
|
||||
dispatch(hideHomePage());
|
||||
dispatch(hideApiSpecPage());
|
||||
if (isTabForItemPresent) {
|
||||
dispatch(
|
||||
focusTab({
|
||||
@@ -228,6 +240,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(hideHomePage());
|
||||
dispatch(hideApiSpecPage());
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
|
||||
@@ -2,10 +2,10 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.collection-name {
|
||||
height: 1.75rem;
|
||||
height: 1.6rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding-left: 8px;
|
||||
padding-left: 4px;
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} transparent;
|
||||
|
||||
.rotate-90 {
|
||||
|
||||
@@ -24,7 +24,7 @@ import Dropdown from 'components/Dropdown';
|
||||
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { hideApiSpecPage, hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import toast from 'react-hot-toast';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
@@ -112,6 +112,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
|
||||
if (!isChevronClick) {
|
||||
dispatch(hideHomePage()); // @TODO Playwright tests are often stuck on home page, rather than collection settings tab. Revisit for a proper fix.
|
||||
dispatch(hideApiSpecPage());
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: collection.uid,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
margin: 4px 10px 8px 10px;
|
||||
position: relative;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 32px 0 32px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
border-color: ${(props) => props.theme.sidebar.muted}40;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
border-color: ${(props) => props.theme.sidebar.muted}80;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,39 +1,28 @@
|
||||
import { IconSearch, IconX } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CollectionSearch = ({ searchText, setSearchText }) => {
|
||||
return (
|
||||
<div className="relative collection-filter px-2">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm">
|
||||
<IconSearch size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
</div>
|
||||
<StyledWrapper>
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search requests …"
|
||||
placeholder="Search requests..."
|
||||
id="search"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="block w-full pl-7 pr-8 py-1 sm:text-sm"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value.toLowerCase())}
|
||||
/>
|
||||
{searchText !== '' && (
|
||||
<div className="absolute inset-y-0 right-0 pr-4 flex items-center">
|
||||
<span
|
||||
className="close-icon"
|
||||
onClick={() => {
|
||||
setSearchText('');
|
||||
}}
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} className="cursor-pointer" />
|
||||
</span>
|
||||
<div className="clear-icon" onClick={() => setSearchText('')}>
|
||||
<IconX size={14} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.collections-badge {
|
||||
margin-inline: 0.5rem;
|
||||
background-color: ${(props) => props.theme.sidebar.badge.bg};
|
||||
border-radius: 5px;
|
||||
|
||||
.caret {
|
||||
margin-left: 0.25rem;
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140, 140, 140);
|
||||
}
|
||||
|
||||
.collections-header-actions {
|
||||
.collection-action-button {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconArrowsSort, IconFolders, IconSortAscendingLetters, IconSortDescendingLetters } from '@tabler/icons';
|
||||
import CloseAllIcon from 'components/Icons/CloseAll';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import RemoveCollectionsModal from '../RemoveCollectionsModal';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CollectionsHeader = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const { collectionSortOrder } = useSelector((state) => state.collections);
|
||||
const [collectionsToClose, setCollectionsToClose] = useState([]);
|
||||
|
||||
const sortCollectionOrder = () => {
|
||||
let order;
|
||||
switch (collectionSortOrder) {
|
||||
case 'default':
|
||||
order = 'alphabetical';
|
||||
break;
|
||||
case 'alphabetical':
|
||||
order = 'reverseAlphabetical';
|
||||
break;
|
||||
case 'reverseAlphabetical':
|
||||
order = 'default';
|
||||
break;
|
||||
}
|
||||
dispatch(sortCollections({ order }));
|
||||
};
|
||||
|
||||
let sortIcon;
|
||||
if (collectionSortOrder === 'default') {
|
||||
sortIcon = <IconArrowsSort size={18} strokeWidth={1.5} />;
|
||||
} else if (collectionSortOrder === 'alphabetical') {
|
||||
sortIcon = <IconSortAscendingLetters size={18} strokeWidth={1.5} />;
|
||||
} else {
|
||||
sortIcon = <IconSortDescendingLetters size={18} strokeWidth={1.5} />;
|
||||
}
|
||||
|
||||
const selectAllCollectionsToClose = () => {
|
||||
setCollectionsToClose(collections.map((c) => c.uid));
|
||||
};
|
||||
|
||||
const clearCollectionsToClose = () => {
|
||||
setCollectionsToClose([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="collections-badge flex items-center justify-between px-2 mt-2 relative">
|
||||
<div className="flex items-center py-1 select-none">
|
||||
<span className="mr-2">
|
||||
<IconFolders size={18} strokeWidth={1.5} />
|
||||
</span>
|
||||
<span>Collections</span>
|
||||
</div>
|
||||
{collections.length >= 1 && (
|
||||
<div className="flex items-center collections-header-actions">
|
||||
<button
|
||||
className="mr-1 collection-action-button"
|
||||
onClick={selectAllCollectionsToClose}
|
||||
aria-label="Close all collections"
|
||||
title="Close all collections"
|
||||
data-testid="close-all-collections-button"
|
||||
>
|
||||
<CloseAllIcon size={18} strokeWidth={1.5} className="cursor-pointer" />
|
||||
</button>
|
||||
<button
|
||||
className="collection-action-button"
|
||||
onClick={() => sortCollectionOrder()}
|
||||
aria-label="Sort collections"
|
||||
title="Sort collections"
|
||||
>
|
||||
{sortIcon}
|
||||
</button>
|
||||
{collectionsToClose.length > 0 && (
|
||||
<RemoveCollectionsModal collectionUids={collectionsToClose} onClose={clearCollectionsToClose} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionsHeader;
|
||||
@@ -1,12 +1,34 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
span.close-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 4px;
|
||||
|
||||
&:hover .collections-badge .collections-header-actions .collection-action-button {
|
||||
opacity: 1;
|
||||
.collections-list {
|
||||
min-height: 0;
|
||||
padding-top: 4px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${(props) => props.theme.scrollbar.color};
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: ${(props) => props.theme.scrollbar.color};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import CreateCollection from '../CreateCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
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('');
|
||||
@@ -14,13 +17,12 @@ const Collections = ({ showSearch }) => {
|
||||
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid) || workspaces.find((w) => w.type === 'default');
|
||||
|
||||
let workspaceCollections = [];
|
||||
|
||||
if (activeWorkspace?.collections?.length) {
|
||||
workspaceCollections = activeWorkspace.collections.map((wc) => {
|
||||
return collections.find((c) => c.pathname === wc.path);
|
||||
}).filter(Boolean);
|
||||
}
|
||||
const workspaceCollections = useMemo(() => {
|
||||
if (!activeWorkspace) return [];
|
||||
return collections.filter((c) =>
|
||||
activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname))
|
||||
);
|
||||
}, [activeWorkspace, collections]);
|
||||
|
||||
if (!workspaceCollections || !workspaceCollections.length) {
|
||||
return (
|
||||
@@ -42,7 +44,7 @@ const Collections = ({ showSearch }) => {
|
||||
<CollectionSearch searchText={searchText} setSearchText={setSearchText} />
|
||||
)}
|
||||
|
||||
<div className={`mt-4 flex flex-col overflow-hidden hover:overflow-y-auto absolute ${showSearch ? 'top-16' : 'top-8'} bottom-0 left-0 right-0`}>
|
||||
<div className="collections-list flex flex-col flex-1 overflow-hidden hover:overflow-y-auto">
|
||||
{workspaceCollections && workspaceCollections.length
|
||||
? workspaceCollections.map((c) => {
|
||||
return (
|
||||
@@ -50,6 +52,8 @@ const Collections = ({ showSearch }) => {
|
||||
);
|
||||
})
|
||||
: null}
|
||||
<div className="w-full my-2" style={{ height: 1 }}></div>
|
||||
<ApiSpecs />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ const StyledWrapper = styled.div`
|
||||
div.method-selector-container,
|
||||
div.input-container {
|
||||
background-color: ${(props) => props.theme.modal.input.bg};
|
||||
height: 2.3rem;
|
||||
height: 2.1rem;
|
||||
}
|
||||
div.input-container {
|
||||
border: solid 1px ${(props) => props.theme.modal.input.border};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
padding: 6px 4px 6px 10px;
|
||||
}
|
||||
|
||||
/* Section Title (single view mode) */
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 0;
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
}
|
||||
}
|
||||
|
||||
/* View Tabs (multi-view mode) */
|
||||
.view-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.view-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 280px) {
|
||||
span {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Header Actions */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.dropdown?.hoverBg || props.theme.sidebar?.collection?.item?.hoverBg};
|
||||
color: ${(props) => props.theme.dropdown?.mutedText || props.theme.text?.muted || '#888'};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -9,6 +9,7 @@ import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
|
||||
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
|
||||
import { toTitleCase } from 'utils/common/index';
|
||||
|
||||
const WorkspaceSelector = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -27,15 +28,6 @@ const WorkspaceSelector = () => {
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const toTitleCase = (str) => {
|
||||
if (!str) return '';
|
||||
if (str === 'default') return 'Default';
|
||||
return str
|
||||
.split(/[\s-_]+/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const WorkspaceName = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="workspace-name-container" onClick={() => setShowDropdown(!showDropdown)}>
|
||||
312
packages/bruno-app/src/components/Sidebar/SidebarHeader/index.js
Normal file
312
packages/bruno-app/src/components/Sidebar/SidebarHeader/index.js
Normal file
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
IconArrowsSort,
|
||||
IconBox,
|
||||
IconDeviceDesktop,
|
||||
IconDotsVertical,
|
||||
IconDownload,
|
||||
IconFileCode,
|
||||
IconFolder,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSortAscendingLetters,
|
||||
IconSortDescendingLetters,
|
||||
IconSquareX,
|
||||
IconTrash
|
||||
} from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
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 MenuDropdown from 'ui/MenuDropdown';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
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 SidebarHeader = ({ setShowSearch }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
// Get collection sort order
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const { collectionSortOrder } = useSelector((state) => state.collections);
|
||||
const [collectionsToClose, setCollectionsToClose] = useState([]);
|
||||
|
||||
const [importData, setImportData] = useState(null);
|
||||
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);
|
||||
|
||||
if (activeWorkspace && activeWorkspace.type !== 'default') {
|
||||
dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type))
|
||||
.catch((err) => {
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
} else {
|
||||
setImportData({ rawData, type });
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
|
||||
dispatch(importCollection(convertedCollection, collectionLocation))
|
||||
.then(() => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
toast.success('Collection imported successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSearch = () => {
|
||||
if (setShowSearch) {
|
||||
setShowSearch((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortCollections = () => {
|
||||
let order;
|
||||
switch (collectionSortOrder) {
|
||||
case 'default':
|
||||
order = 'alphabetical';
|
||||
break;
|
||||
case 'alphabetical':
|
||||
order = 'reverseAlphabetical';
|
||||
break;
|
||||
case 'reverseAlphabetical':
|
||||
order = 'default';
|
||||
break;
|
||||
default:
|
||||
order = 'default';
|
||||
break;
|
||||
}
|
||||
dispatch(sortCollections({ order }));
|
||||
};
|
||||
|
||||
const getSortIcon = () => {
|
||||
switch (collectionSortOrder) {
|
||||
case 'alphabetical':
|
||||
return IconSortDescendingLetters;
|
||||
case 'reverseAlphabetical':
|
||||
return IconArrowsSort;
|
||||
default:
|
||||
return IconSortAscendingLetters;
|
||||
}
|
||||
};
|
||||
|
||||
const getSortLabel = () => {
|
||||
switch (collectionSortOrder) {
|
||||
case 'alphabetical':
|
||||
return 'Sort Z-A';
|
||||
case 'reverseAlphabetical':
|
||||
return 'Clear sort';
|
||||
default:
|
||||
return 'Sort A-Z';
|
||||
}
|
||||
};
|
||||
|
||||
const selectAllCollectionsToClose = () => {
|
||||
setCollectionsToClose(collections.map((c) => c.uid));
|
||||
};
|
||||
|
||||
const clearCollectionsToClose = () => {
|
||||
setCollectionsToClose([]);
|
||||
};
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
const options = {};
|
||||
if (activeWorkspace?.pathname) {
|
||||
options.workspaceId = activeWorkspace.pathname;
|
||||
}
|
||||
|
||||
dispatch(openCollection(options)).catch((err) => {
|
||||
toast.error('An error occurred while opening the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenApiSpec = () => {
|
||||
dispatch(openApiSpec()).catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while opening the API spec');
|
||||
});
|
||||
};
|
||||
|
||||
const renderModals = () => (
|
||||
<>
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{importCollectionModalOpen && (
|
||||
<ImportCollection
|
||||
onClose={() => setImportCollectionModalOpen(false)}
|
||||
handleSubmit={handleImportCollection}
|
||||
/>
|
||||
)}
|
||||
{importCollectionLocationModalOpen && importData && (
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
)}
|
||||
{createApiSpecModalOpen && (
|
||||
<CreateApiSpec
|
||||
onClose={() => setCreateApiSpecModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Configuration for Add/Create dropdown items
|
||||
const addDropdownItems = [
|
||||
{
|
||||
id: 'create',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create collection',
|
||||
onClick: () => {
|
||||
setCreateCollectionModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'import',
|
||||
leftSection: IconDownload,
|
||||
label: 'Import collection',
|
||||
onClick: () => {
|
||||
setImportCollectionModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
leftSection: IconFolder,
|
||||
label: 'Open collection',
|
||||
onClick: () => {
|
||||
handleOpenCollection();
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'label',
|
||||
label: 'API Specs'
|
||||
},
|
||||
{
|
||||
id: 'create-api-spec',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create API Spec',
|
||||
onClick: () => {
|
||||
setCreateApiSpecModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'open-api-spec',
|
||||
leftSection: IconFileCode,
|
||||
label: 'Open API Spec',
|
||||
onClick: () => {
|
||||
handleOpenApiSpec();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Configuration for Actions dropdown items
|
||||
const actionsDropdownItems = [
|
||||
{
|
||||
id: 'sort',
|
||||
leftSection: getSortIcon(),
|
||||
label: getSortLabel(),
|
||||
onClick: () => {
|
||||
handleSortCollections();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'close-all',
|
||||
leftSection: IconSquareX,
|
||||
label: 'Close all',
|
||||
onClick: () => {
|
||||
selectAllCollectionsToClose();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Render Collections-specific actions
|
||||
const renderCollectionsActions = () => (
|
||||
<>
|
||||
<ActionIcon
|
||||
onClick={handleToggleSearch}
|
||||
label="Search requests"
|
||||
>
|
||||
<IconSearch size={14} stroke={1.5} aria-hidden="true" />
|
||||
</ActionIcon>
|
||||
|
||||
{/* Add Collection dropdown */}
|
||||
<MenuDropdown
|
||||
data-testid="collections-header-add-menu"
|
||||
items={[
|
||||
{ type: 'label', label: 'Collections' },
|
||||
...addDropdownItems
|
||||
]}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ActionIcon
|
||||
label="Add new collection"
|
||||
>
|
||||
<IconPlus size={14} stroke={1.5} aria-hidden="true" />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
|
||||
{/* More Actions dropdown (sort, close all, etc.) */}
|
||||
<MenuDropdown
|
||||
data-testid="collections-header-actions-menu"
|
||||
items={actionsDropdownItems}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ActionIcon
|
||||
label="More actions"
|
||||
>
|
||||
<IconDotsVertical size={14} stroke={1.5} aria-hidden="true" />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
|
||||
{collectionsToClose.length > 0 && (
|
||||
<RemoveCollectionsModal collectionUids={collectionsToClose} onClose={clearCollectionsToClose} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{renderModals()}
|
||||
<div className="sidebar-header">
|
||||
<div className="section-title">
|
||||
<IconBox size={14} stroke={1.5} />
|
||||
<span>Collections</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Context Sensitive */}
|
||||
<div className="header-actions">
|
||||
{renderCollectionsActions()}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarHeader;
|
||||
@@ -28,18 +28,6 @@ const Wrapper = styled.div`
|
||||
top: -0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.collection-filter {
|
||||
input {
|
||||
border: ${(props) => props.theme.sidebar.search.border};
|
||||
border-radius: 2px;
|
||||
background-color: ${(props) => props.theme.sidebar.search.bg};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.sidebar-drag-handle {
|
||||
@@ -65,6 +53,15 @@ const Wrapper = styled.div`
|
||||
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.second-tab-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.titlebar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workspace-name-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
margin-left: 0px;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* Actions Button */
|
||||
.actions-container {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.home-icon-button,
|
||||
.search-icon-button,
|
||||
.plus-icon-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.workspace-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px !important;
|
||||
margin: 0 !important;
|
||||
|
||||
&.active {
|
||||
.check-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.pin-btn:not(.pinned) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.dropdown.color};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.workspace-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: ${(props) => props.theme.workspace?.accent || props.theme.colors?.text?.yellow || '#f0c674'};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.dropdown?.mutedText || props.theme.text?.muted || '#888'};
|
||||
transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
||||
opacity: 0;
|
||||
|
||||
&.pinned {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.dropdown?.hoverBg || props.theme.sidebar?.collection?.item?.hoverBg};
|
||||
color: ${(props) => props.theme.dropdown?.mutedText || props.theme.text?.muted || '#888'};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collection-dropdown {
|
||||
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tippy-box {
|
||||
top: -0.5rem;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,181 +0,0 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconPlus, IconFolder, IconDownload, IconHome, IconSearch, IconDeviceDesktop } from '@tabler/icons';
|
||||
|
||||
import { showHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
|
||||
import CreateCollection from '../CreateCollection';
|
||||
import WorkspaceSelector from './WorkspaceSelector';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const TitleBar = ({ showSearch, setShowSearch }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
const [importData, setImportData] = useState(null);
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
|
||||
const actionsDropdownTippyRef = useRef();
|
||||
const onActionsDropdownCreate = (ref) => (actionsDropdownTippyRef.current = ref);
|
||||
|
||||
const handleImportCollection = ({ rawData, type }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
|
||||
if (activeWorkspace && activeWorkspace.type !== 'default') {
|
||||
dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type))
|
||||
.catch((err) => {
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
} else {
|
||||
setImportData({ rawData, type });
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
|
||||
dispatch(importCollection(convertedCollection, collectionLocation))
|
||||
.then(() => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
toast.success('Collection imported successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSearch = () => {
|
||||
if (setShowSearch) {
|
||||
setShowSearch((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
const options = {};
|
||||
if (activeWorkspace?.pathname) {
|
||||
options.workspaceId = activeWorkspace.pathname;
|
||||
}
|
||||
|
||||
dispatch(openCollection(options)).catch((err) => {
|
||||
toast.error('An error occurred while opening the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const openDevTools = () => {
|
||||
ipcRenderer.invoke('renderer:open-devtools');
|
||||
};
|
||||
|
||||
const renderModals = () => (
|
||||
<>
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{importCollectionModalOpen && (
|
||||
<ImportCollection
|
||||
onClose={() => setImportCollectionModalOpen(false)}
|
||||
handleSubmit={handleImportCollection}
|
||||
/>
|
||||
)}
|
||||
{importCollectionLocationModalOpen && importData && (
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="px-2 py-2">
|
||||
{renderModals()}
|
||||
<div className="titlebar-container">
|
||||
<WorkspaceSelector />
|
||||
|
||||
<div className="actions-container">
|
||||
<button className="home-icon-button" onClick={() => dispatch(showHomePage())} title="Home">
|
||||
<IconHome size={16} stroke={1.5} />
|
||||
</button>
|
||||
|
||||
{setShowSearch && (
|
||||
<button className="search-icon-button" onClick={handleToggleSearch} title="Toggle search">
|
||||
<IconSearch size={16} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
onCreate={onActionsDropdownCreate}
|
||||
icon={(
|
||||
<button className="plus-icon-button">
|
||||
<IconPlus size={16} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
placement="bottom-end"
|
||||
style="new"
|
||||
>
|
||||
<div className="label-item">Collections</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
setCreateCollectionModalOpen(true);
|
||||
actionsDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
>
|
||||
<IconPlus size={16} stroke={1.5} className="icon" />
|
||||
Create collection
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
actionsDropdownTippyRef.current?.hide();
|
||||
setImportCollectionModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<IconDownload size={16} stroke={1.5} className="icon" />
|
||||
Import collection
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
handleOpenCollection();
|
||||
actionsDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
>
|
||||
<IconFolder size={16} stroke={1.5} className="icon" />
|
||||
Open collection
|
||||
</div>
|
||||
<div className="dropdown-separator"></div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
actionsDropdownTippyRef.current?.hide();
|
||||
openDevTools();
|
||||
}}
|
||||
>
|
||||
<IconDeviceDesktop size={16} stroke={1.5} className="icon" />
|
||||
Devtools
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleBar;
|
||||
@@ -1,4 +1,4 @@
|
||||
import TitleBar from './TitleBar';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import Collections from './Collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateLeftSidebarWidth, updateIsDragging } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
const MIN_LEFT_SIDEBAR_WIDTH = 221;
|
||||
const MIN_LEFT_SIDEBAR_WIDTH = 220;
|
||||
const MAX_LEFT_SIDEBAR_WIDTH = 600;
|
||||
|
||||
const Sidebar = () => {
|
||||
@@ -14,6 +14,7 @@ const Sidebar = () => {
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
|
||||
const lastWidthRef = useRef(leftSidebarWidth);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -80,9 +81,11 @@ const Sidebar = () => {
|
||||
<aside className="sidebar" style={{ width: currentWidth, transition: dragging ? 'none' : 'width 0.2s ease-in-out' }}>
|
||||
<div className="flex flex-row h-full w-full">
|
||||
<div className="flex flex-col w-full" style={{ width: asideWidth }}>
|
||||
<div className="flex flex-col flex-grow">
|
||||
<TitleBar />
|
||||
<Collections />
|
||||
<div className="flex flex-col flex-grow" style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
<SidebarHeader
|
||||
setShowSearch={setShowSearch}
|
||||
/>
|
||||
<Collections showSearch={showSearch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import Cookies from 'components/Cookies';
|
||||
import Notifications from 'components/Notifications';
|
||||
import Portal from 'components/Portal';
|
||||
import { showPreferences, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { showPreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { openConsole } from 'providers/ReduxStore/slices/logs';
|
||||
import { useApp } from 'providers/App';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -18,7 +18,6 @@ const StatusBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferencesOpen = useSelector((state) => state.app.showPreferences);
|
||||
const logs = useSelector((state) => state.logs.logs);
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const [cookiesOpen, setCookiesOpen] = useState(false);
|
||||
const { version } = useApp();
|
||||
|
||||
@@ -70,16 +69,6 @@ const StatusBar = () => {
|
||||
<div className="status-bar">
|
||||
<div className="status-bar-section">
|
||||
<div className="status-bar-group">
|
||||
<ToolHint text="Toggle Sidebar" toolhintId="Toggle Sidebar" place="top-start" offset={10}>
|
||||
<button
|
||||
className="status-bar-button"
|
||||
aria-label="Toggle Sidebar"
|
||||
onClick={() => dispatch(toggleSidebarCollapse())}
|
||||
>
|
||||
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
</button>
|
||||
</ToolHint>
|
||||
|
||||
<ToolHint text="Preferences" toolhintId="Preferences" place="top-start" offset={10}>
|
||||
<button
|
||||
className="status-bar-button preferences-button"
|
||||
|
||||
@@ -12,6 +12,7 @@ import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCo
|
||||
import ShareCollection from 'components/ShareCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
|
||||
const WorkspaceCollections = ({ workspace, onImportCollection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -46,7 +47,7 @@ const WorkspaceCollections = ({ workspace, onImportCollection }) => {
|
||||
const result = [];
|
||||
|
||||
workspace.collections.forEach((wc) => {
|
||||
const loadedCollection = collections.find((c) => c.pathname === wc.path);
|
||||
const loadedCollection = collections.find((c) => normalizePath(c.pathname) === normalizePath(wc.path));
|
||||
|
||||
if (loadedCollection) {
|
||||
result.push({
|
||||
@@ -146,14 +147,13 @@ const WorkspaceCollections = ({ workspace, onImportCollection }) => {
|
||||
if (!collectionToRemove) return;
|
||||
|
||||
try {
|
||||
const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
|
||||
const isDelete = collectionInfo.isInternal && !collectionInfo.isGitBacked;
|
||||
|
||||
await dispatch(removeCollectionFromWorkspaceAction(workspace.uid, collectionToRemove.pathname));
|
||||
|
||||
const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
|
||||
|
||||
if (collectionInfo.isLoaded && !collectionInfo.isGitBacked) {
|
||||
if (isDelete) {
|
||||
toast.success(`Deleted "${collectionToRemove.name}" collection`);
|
||||
} else if (collectionInfo.isGitBacked) {
|
||||
toast.success(`Removed git-backed collection "${collectionToRemove.name}" from workspace`);
|
||||
} else {
|
||||
toast.success(`Removed "${collectionToRemove.name}" from workspace`);
|
||||
}
|
||||
@@ -165,23 +165,32 @@ const WorkspaceCollections = ({ workspace, onImportCollection }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isInternalCollection = (collection) => {
|
||||
if (!workspace.pathname || !collection.pathname) return false;
|
||||
const workspaceCollectionsFolder = normalizePath(`${workspace.pathname}/collections`);
|
||||
const collectionPath = normalizePath(collection.pathname);
|
||||
return collectionPath.startsWith(workspaceCollectionsFolder);
|
||||
};
|
||||
|
||||
const getCollectionWorkspaceInfo = (collection) => {
|
||||
if (collection.hasOwnProperty('isGitBacked')) {
|
||||
return {
|
||||
isGitBacked: collection.isGitBacked,
|
||||
gitRemoteUrl: collection.gitRemoteUrl,
|
||||
isLoaded: collection.isLoaded !== false
|
||||
isLoaded: collection.isLoaded !== false,
|
||||
isInternal: isInternalCollection(collection)
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceCollection = workspace.collections?.find((wc) => {
|
||||
return collection.pathname === wc.path;
|
||||
return normalizePath(collection.pathname) === normalizePath(wc.path);
|
||||
});
|
||||
|
||||
return {
|
||||
isGitBacked: !!workspaceCollection?.remote,
|
||||
gitRemoteUrl: workspaceCollection?.remote,
|
||||
isLoaded: true
|
||||
isLoaded: true,
|
||||
isInternal: isInternalCollection(collection)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -222,32 +231,31 @@ const WorkspaceCollections = ({ workspace, onImportCollection }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{collectionToRemove && (
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Delete Collection"
|
||||
handleCancel={() => setCollectionToRemove(null)}
|
||||
handleConfirm={confirmRemoveCollection}
|
||||
confirmText="Delete Collection"
|
||||
cancelText="Cancel"
|
||||
style="new"
|
||||
>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Are you sure you want to delete <strong>"{collectionToRemove.name}"</strong>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3">
|
||||
{(() => {
|
||||
const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
|
||||
{collectionToRemove && (() => {
|
||||
const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
|
||||
const isDelete = collectionInfo.isInternal && !collectionInfo.isGitBacked;
|
||||
|
||||
if (collectionInfo.isGitBacked) {
|
||||
return 'This will remove the git-backed collection reference from workspace.yml. Local files (if any) will not be deleted.';
|
||||
} else {
|
||||
return 'This will permanently delete the collection files from the workspace collections folder.';
|
||||
}
|
||||
})()}
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
return (
|
||||
<Modal
|
||||
size="sm"
|
||||
title={isDelete ? 'Delete Collection' : 'Remove Collection'}
|
||||
handleCancel={() => setCollectionToRemove(null)}
|
||||
handleConfirm={confirmRemoveCollection}
|
||||
confirmText={isDelete ? 'Delete' : 'Remove'}
|
||||
cancelText="Cancel"
|
||||
style="new"
|
||||
>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Are you sure you want to {isDelete ? 'delete' : 'remove'} <strong>"{collectionToRemove.name}"</strong>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3">
|
||||
{isDelete
|
||||
? 'This will permanently delete the collection files from the workspace collections folder.'
|
||||
: 'This will remove the collection from the workspace. The collection files will not be deleted.'}
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="h-full overflow-auto">
|
||||
{workspaceCollections.length === 0 ? (
|
||||
|
||||
@@ -2,17 +2,17 @@ import React, { useEffect, useState, useRef } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import CreateEnvironment from '../CreateEnvironment';
|
||||
import { IconDownload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
|
||||
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
|
||||
import ImportEnvironment from '../ImportEnvironment';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection }) => {
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => {
|
||||
const dispatch = useDispatch();
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
@@ -258,6 +258,12 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportClick = () => {
|
||||
if (setShowExportModal) {
|
||||
setShowExportModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSwitch = (saveChanges) => {
|
||||
if (!saveChanges) {
|
||||
setSwitchEnvConfirmClose(false);
|
||||
@@ -270,7 +276,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
|
||||
{openImportModal && <ImportEnvironment onClose={() => setOpenImportModal(false)} />}
|
||||
{openImportModal && <ImportEnvironmentModal type="global" onClose={() => setOpenImportModal(false)} />}
|
||||
|
||||
<div className="environments-container">
|
||||
{switchEnvConfirmClose && (
|
||||
@@ -290,6 +296,9 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<IconDownload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<IconUpload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,42 +4,29 @@ import CreateEnvironment from './CreateEnvironment';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconFileAlert } from '@tabler/icons';
|
||||
import ImportEnvironment from './ImportEnvironment';
|
||||
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
|
||||
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
|
||||
|
||||
export const SharedButton = ({ children, className, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded bg-transparent px-2.5 py-2 w-fit text-xs font-semibold text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
|
||||
${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultTab = ({ setTab }) => {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<IconFileAlert size={48} strokeWidth={1.5} />
|
||||
<div className="title">No Environments</div>
|
||||
<div className="actions">
|
||||
<button className="shared-button" onClick={() => setTab('create')}>
|
||||
Create Environment
|
||||
</button>
|
||||
<button className="shared-button" onClick={() => setTab('import')}>
|
||||
Import Environment
|
||||
</button>
|
||||
</div>
|
||||
const DefaultTab = ({ setTab }) => (
|
||||
<div className="empty-state">
|
||||
<IconFileAlert size={48} strokeWidth={1.5} />
|
||||
<div className="title">No Environments</div>
|
||||
<div className="actions">
|
||||
<button className="shared-button" onClick={() => setTab('create')}>
|
||||
Create Environment
|
||||
</button>
|
||||
<button className="shared-button" onClick={() => setTab('import')}>
|
||||
Import Environment
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</div>
|
||||
);
|
||||
|
||||
const WorkspaceEnvironments = ({ workspace }) => {
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
const [tab, setTab] = useState('default');
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
@@ -50,7 +37,7 @@ const WorkspaceEnvironments = ({ workspace }) => {
|
||||
{tab === 'create' ? (
|
||||
<CreateEnvironment onClose={() => setTab('default')} />
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment onClose={() => setTab('default')} />
|
||||
<ImportEnvironmentModal type="global" onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
@@ -68,7 +55,15 @@ const WorkspaceEnvironments = ({ workspace }) => {
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
collection={null}
|
||||
setShowExportModal={setShowExportModal}
|
||||
/>
|
||||
{showExportModal && (
|
||||
<ExportEnvironmentModal
|
||||
onClose={() => setShowExportModal(false)}
|
||||
environments={globalEnvironments}
|
||||
environmentType="global"
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showInFolder, openCollection } from 'providers/ReduxStore/slices/collec
|
||||
import toast from 'react-hot-toast';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import CloseWorkspace from 'components/Sidebar/TitleBar/CloseWorkspace';
|
||||
import CloseWorkspace from 'components/Sidebar/SidebarHeader/CloseWorkspace';
|
||||
import WorkspaceCollections from './WorkspaceCollections';
|
||||
import WorkspaceDocs from './WorkspaceDocs';
|
||||
import WorkspaceEnvironments from './WorkspaceEnvironments';
|
||||
|
||||
@@ -2,7 +2,7 @@ import find from 'lodash/find';
|
||||
import { updateRequestPaneTabHeight, updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
const MIN_TOP_PANE_HEIGHT = 150;
|
||||
const MIN_TOP_PANE_HEIGHT = 380;
|
||||
|
||||
export function useTabPaneBoundaries(activeTabUid) {
|
||||
const DEFAULT_PANE_WIDTH_DIVISOR = 2.2;
|
||||
@@ -12,7 +12,7 @@ export function useTabPaneBoundaries(activeTabUid) {
|
||||
const screenWidth = useSelector((state) => state.app.screenWidth);
|
||||
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const left = focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / DEFAULT_PANE_WIDTH_DIVISOR;
|
||||
const top = focusedTab?.requestPaneHeight;
|
||||
const top = focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,8 @@ import RequestTabs from 'components/RequestTabs';
|
||||
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';
|
||||
@@ -12,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';
|
||||
@@ -51,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);
|
||||
@@ -87,6 +92,7 @@ export default function Main() {
|
||||
return (
|
||||
// <ErrorCapture>
|
||||
<div id="main-container" className="flex flex-col h-screen max-h-screen overflow-hidden">
|
||||
<AppTitleBar />
|
||||
{showRosettaBanner ? (
|
||||
<Portal>
|
||||
<div className="fixed bottom-0 left-0 right-0 z-10 bg-amber-100 border border-amber-400 text-amber-700 px-4 py-3" role="alert">
|
||||
@@ -105,13 +111,15 @@ export default function Main() {
|
||||
className="flex-1 min-h-0 flex"
|
||||
data-app-state="loading"
|
||||
style={{
|
||||
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
|
||||
height: isConsoleOpen ? `calc(100vh - 60px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 60px)'
|
||||
}}
|
||||
>
|
||||
<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 />
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -20,9 +20,24 @@ export const AppProvider = (props) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const platform = get(navigator, 'platform', '');
|
||||
if (platform && platform.toLowerCase().indexOf('mac') > -1) {
|
||||
const platform = get(navigator, 'platform', '').toLowerCase();
|
||||
|
||||
if (!platform) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform.includes('mac')) {
|
||||
document.body.classList.add('os-mac');
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform.includes('win')) {
|
||||
document.body.classList.add('os-windows');
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform.includes('linux')) {
|
||||
document.body.classList.add('os-linux');
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -27,6 +27,7 @@ export const HotkeysProvider = (props) => {
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
|
||||
const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen);
|
||||
const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false);
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
|
||||
@@ -43,7 +44,7 @@ export const HotkeysProvider = (props) => {
|
||||
// save hotkey
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => {
|
||||
if (isEnvironmentSettingsModalOpen) {
|
||||
if (isEnvironmentSettingsModalOpen || isGlobalEnvironmentSettingsModalOpen) {
|
||||
console.log('todo: save environment settings');
|
||||
} else {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
@@ -70,7 +71,7 @@ export const HotkeysProvider = (props) => {
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, saveRequest, collections, isEnvironmentSettingsModalOpen]);
|
||||
}, [activeTabUid, tabs, saveRequest, collections, isEnvironmentSettingsModalOpen, isGlobalEnvironmentSettingsModalOpen]);
|
||||
|
||||
// send request (ctrl/cmd + enter)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -5,12 +5,14 @@ import brunoClipboard from 'utils/bruno-clipboard';
|
||||
const initialState = {
|
||||
isDragging: false,
|
||||
idbConnectionReady: false,
|
||||
leftSidebarWidth: 222,
|
||||
leftSidebarWidth: 250,
|
||||
sidebarCollapsed: false,
|
||||
screenWidth: 500,
|
||||
showHomePage: false,
|
||||
showPreferences: false,
|
||||
showApiSpecPage: false,
|
||||
isEnvironmentSettingsModalOpen: false,
|
||||
isGlobalEnvironmentSettingsModalOpen: false,
|
||||
preferences: {
|
||||
request: {
|
||||
sslVerification: true,
|
||||
@@ -66,12 +68,24 @@ export const appSlice = createSlice({
|
||||
updateEnvironmentSettingsModalVisibility: (state, action) => {
|
||||
state.isEnvironmentSettingsModalOpen = action.payload;
|
||||
},
|
||||
updateGlobalEnvironmentSettingsModalVisibility: (state, action) => {
|
||||
state.isGlobalEnvironmentSettingsModalOpen = action.payload;
|
||||
},
|
||||
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;
|
||||
},
|
||||
@@ -115,8 +129,11 @@ export const {
|
||||
updateLeftSidebarWidth,
|
||||
updateIsDragging,
|
||||
updateEnvironmentSettingsModalVisibility,
|
||||
updateGlobalEnvironmentSettingsModalVisibility,
|
||||
showHomePage,
|
||||
hideHomePage,
|
||||
showApiSpecPage,
|
||||
hideApiSpecPage,
|
||||
showPreferences,
|
||||
updatePreferences,
|
||||
updateCookies,
|
||||
|
||||
@@ -7,7 +7,7 @@ import find from 'lodash/find';
|
||||
import get from 'lodash/get';
|
||||
import set from 'lodash/set';
|
||||
import trim from 'lodash/trim';
|
||||
import path from 'utils/common/path';
|
||||
import path, { normalizePath } from 'utils/common/path';
|
||||
import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
@@ -672,26 +672,6 @@ export const runCollectionFolder
|
||||
})
|
||||
);
|
||||
|
||||
// to only include those requests in the specified order while preserving folder data
|
||||
if (selectedRequestUids && selectedRequestUids.length > 0) {
|
||||
const newItems = [];
|
||||
|
||||
selectedRequestUids.forEach((uid, index) => {
|
||||
const requestItem = findItemInCollection(collectionCopy, uid);
|
||||
if (requestItem) {
|
||||
const clonedRequest = cloneDeep(requestItem);
|
||||
clonedRequest.seq = index + 1;
|
||||
newItems.push(clonedRequest);
|
||||
}
|
||||
});
|
||||
|
||||
if (folder) {
|
||||
folder.items = newItems;
|
||||
} else {
|
||||
collectionCopy.items = newItems;
|
||||
}
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke(
|
||||
@@ -702,7 +682,8 @@ export const runCollectionFolder
|
||||
collectionCopy.runtimeVariables,
|
||||
recursive,
|
||||
delay,
|
||||
tags
|
||||
tags,
|
||||
selectedRequestUids
|
||||
)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
@@ -2278,7 +2259,6 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
.validate(collection)
|
||||
.then(() => dispatch(_createCollection({ ...collection, securityConfig })))
|
||||
.then(() => {
|
||||
// Expand sidebar if it's collapsed after collection is successfully opened
|
||||
const state = getState();
|
||||
if (state.app.sidebarCollapsed) {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
@@ -2286,7 +2266,9 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
if (activeWorkspace) {
|
||||
const isAlreadyInWorkspace = activeWorkspace.collections?.some((c) => c.path === pathname);
|
||||
const isAlreadyInWorkspace = activeWorkspace.collections?.some(
|
||||
(c) => normalizePath(c.path) === normalizePath(pathname)
|
||||
);
|
||||
|
||||
if (!isAlreadyInWorkspace) {
|
||||
const workspaceCollection = {
|
||||
@@ -2294,8 +2276,6 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
path: pathname
|
||||
};
|
||||
|
||||
// The electron handler will automatically trigger workspace config update
|
||||
// which will cause the app to react and reload collections
|
||||
ipcRenderer
|
||||
.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection)
|
||||
.catch((err) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { showHomePage } from '../app';
|
||||
import { createCollection, openCollection, openMultipleCollections } from '../collections/actions';
|
||||
import { removeCollection } from '../collections';
|
||||
import { updateGlobalEnvironments } from '../global-environments';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
@@ -127,7 +128,11 @@ export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
const collection = collectionsState.collections.find((c) => c.pathname === collectionPath);
|
||||
const normalizedCollectionPath = normalizePath(collectionPath);
|
||||
|
||||
const collection = collectionsState.collections.find(
|
||||
(c) => normalizePath(c.pathname) === normalizedCollectionPath
|
||||
);
|
||||
|
||||
await ipcRenderer.invoke('renderer:remove-collection-from-workspace',
|
||||
workspaceUid,
|
||||
@@ -135,8 +140,9 @@ export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath
|
||||
collectionPath);
|
||||
|
||||
if (collection) {
|
||||
const workspaceCollection = workspace.collections?.find((wc) =>
|
||||
wc.path === collectionPath);
|
||||
const workspaceCollection = workspace.collections?.find(
|
||||
(wc) => normalizePath(wc.path) === normalizedCollectionPath
|
||||
);
|
||||
|
||||
if (workspaceCollection) {
|
||||
dispatch(removeCollection({ collectionUid: collection.uid }));
|
||||
@@ -161,25 +167,63 @@ const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => {
|
||||
};
|
||||
|
||||
try {
|
||||
const workspaceCollections = await dispatch(loadWorkspaceCollections(workspace.uid));
|
||||
await dispatch(loadWorkspaceCollections(workspace.uid));
|
||||
const updatedWorkspace = await dispatch((_, getState) => getState().workspaces.workspaces.find((w) => w.uid === workspace.uid));
|
||||
|
||||
if (updatedWorkspace?.collections?.length > 0) {
|
||||
const alreadyOpenCollections = await dispatch((_, getState) => getState().collections.collections.map((c) => c.pathname));
|
||||
const alreadyOpenCollections = await dispatch((_, getState) =>
|
||||
getState().collections.collections.map((c) => normalizePath(c.pathname))
|
||||
);
|
||||
|
||||
const collectionPaths = updatedWorkspace.collections
|
||||
.map((wc) => wc.path)
|
||||
.filter((p) => p && !alreadyOpenCollections.includes(p));
|
||||
.filter((p) => p && !alreadyOpenCollections.includes(normalizePath(p)));
|
||||
|
||||
if (collectionPaths.length > 0) {
|
||||
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));
|
||||
@@ -222,7 +266,7 @@ export const loadWorkspaceCollections = (workspaceUid, force = false) => {
|
||||
|
||||
const hasProcessedCollections = workspace.collections
|
||||
&& workspace.collections.length > 0
|
||||
&& workspace.collections.some((c) => c.path && c.path.startsWith('/'));
|
||||
&& workspace.collections.some((c) => c.path && path.isAbsolute(c.path));
|
||||
|
||||
if (!force && hasProcessedCollections) {
|
||||
return workspace.collections;
|
||||
@@ -329,7 +373,7 @@ export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspa
|
||||
return;
|
||||
}
|
||||
|
||||
const { collections, ...configWithoutCollections } = workspaceConfig;
|
||||
const { collections, apiSpecs, ...configWithoutCollections } = workspaceConfig;
|
||||
|
||||
dispatch(updateWorkspace({
|
||||
uid: workspaceUid,
|
||||
@@ -342,12 +386,12 @@ export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspa
|
||||
await dispatch(loadWorkspaceCollections(workspaceUid, true));
|
||||
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
const openCollections = getState().collections.collections.map((c) => c.pathname);
|
||||
const openCollections = getState().collections.collections.map((c) => normalizePath(c.pathname));
|
||||
|
||||
if (workspace?.collections?.length > 0) {
|
||||
const newCollectionPaths = workspace.collections
|
||||
.map((workspaceCollection) => workspaceCollection.path)
|
||||
.filter((collectionPath) => collectionPath && !openCollections.includes(collectionPath));
|
||||
.filter((collectionPath) => collectionPath && !openCollections.includes(normalizePath(collectionPath)));
|
||||
|
||||
if (newCollectionPaths.length > 0) {
|
||||
try {
|
||||
@@ -356,6 +400,9 @@ export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspa
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load API specs when workspace config is updated
|
||||
await dispatch(loadWorkspaceApiSpecs(workspaceUid));
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
|
||||
const DEFAULT_WORKSPACE_UID = 'default';
|
||||
|
||||
@@ -61,9 +62,11 @@ export const workspacesSlice = createSlice({
|
||||
const { workspaceUid, collectionLocation } = action.payload;
|
||||
const workspace = state.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (workspace?.collections) {
|
||||
// Filter by both path and location since path could be relative or absolute
|
||||
workspace.collections = workspace.collections.filter((c) =>
|
||||
c.path !== collectionLocation && c.location !== collectionLocation);
|
||||
const normalizedLocation = normalizePath(collectionLocation);
|
||||
workspace.collections = workspace.collections.filter((c) => {
|
||||
const normalizedPath = normalizePath(c.path);
|
||||
return normalizedPath !== normalizedLocation;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -36,7 +36,11 @@ export const ThemeProvider = (props) => {
|
||||
setDisplayedTheme(storedTheme);
|
||||
root.classList.add(storedTheme);
|
||||
}
|
||||
}, [storedTheme, setDisplayedTheme, window.matchMedia]);
|
||||
|
||||
if (window.ipcRenderer) {
|
||||
window.ipcRenderer.send('renderer:theme-change', storedTheme);
|
||||
}
|
||||
}, [storedTheme]);
|
||||
|
||||
// storedTheme can have 3 values: 'light', 'dark', 'system'
|
||||
// displayedTheme can have 2 values: 'light', 'dark'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user